Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

crates/gunnar-ui-server/src/mcp.rs

//! **Gunnar's MCP connector, mounted** (feature `mcp`, off by default;
//! TASKS-13, owner 2026-10-25).
//!
//! The connector is the `gunnar-mcp` crate, and this file is all the console
//! adds for it. It fills in the crate's `Backend`:
//!
//! * **the passkey touch is the existing passkey login**: the identity core's
//!   login ceremony (IDENTITY-HOST, user verification required) verifies the
//!   person, and AS the person (the session that login minted, ended right
//!   after) `Access.AddKey` puts a FRESH ed25519 key minted here on their own
//!   principal, expiring with the grant — the CLI's device flow's shape;
//! * **each tool call is ONE existing control-plane call**, signed with that
//!   key over the console's own channel ([`crate::grpc::Gunnar::as_signer`]),
//!   so gunnar applies exactly the person's grants. A refusal or a miss is
//!   answered in one voice, so a private name is no oracle;
//! * **forgetting an agent** is `Access.RevokeKey` of its key, signed with
//!   that key (the way a session key revokes itself at logout);
//! * **the audit record** lands on the console's audit target
//!   ([`crate::auth::AUDIT_TARGET`]), as `mcp:<client> acting for <user>`.
//!
//! gunnar-server gets nothing new. Without passkeys (no relying party) the
//! connector is not mounted at all: it has no login to offer.
//!
//! **The administrator's switch** (owner decision 2026-10-26, item 5): `GET`/`PUT
//! /api/mcp {"on": bool}`, kept as gunnar's generic setting `console.mcp`
//! ([`crate::console_setting`]); only an administrator flips it, and each flip is
//! audited. OFF, every connector door answers as the default build does (the
//! session gate stands in front of it again, and a signed-in request is not found),
//! so an `mcp` build sells nothing to an agent until it is switched on. Its initial
//! position is `GUNNAR_UI_MCP` (OFF unless it says `on`); the stored setting wins
//! once an administrator has flipped it.

use std::sync::Arc;

use axum::extract::{Extension, Path};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use gunnar_mcp as mcp;
use http::StatusCode;
use serde_json::{json, Value};

use crate::app::AppState;
use crate::auth::middleware::Caller;
use crate::grpc::Gunnar;
use crate::pb;

/// The agent: a client signed with the key gunnar enrolled for this
/// connection, and that key's fingerprint.
#[derive(Clone)]
pub struct Agent {
    gunnar: Gunnar,
    fingerprint: String,
}

/// The console, as the connector's backend.
pub struct Console {
    state: AppState,
}

/// The connector this router serves.
pub type Connector = Arc<mcp::Mcp<Console>>;

/// The connector for `state`, or `None` where there is no passkey login.
pub fn connector(state: &AppState) -> Option<Connector> {
    let webauthn = state.webauthn.as_ref()?;
    Some(mcp::Mcp::new(
        Console {
            state: state.clone(),
        },
        mcp::Config::new(webauthn.origin()),
    ))
}

/// The switch's initial position, until an administrator flips it.
pub const ENV_SWITCH: &str = "GUNNAR_UI_MCP";

/// **The connector's on/off switch**, shared by every clone. The session gate reads it
/// ([`crate::auth::middleware`]: an OFF connector's doors are not let past it), and so does
/// the gate in front of the connector's routes.
#[derive(Clone, Debug, Default)]
pub struct Switch(Arc<std::sync::atomic::AtomicBool>);

impl Switch {
    /// A switch that starts OFF and follows the stored setting
    /// ([`crate::console_setting::follow`], RA2-1: it fails closed). `GUNNAR_UI_MCP=on` (or
    /// `yes`, `true`, `1`) is the initial position, taken only once gunnar says nothing is stored.
    pub fn starting(state: &AppState) -> Switch {
        let on = std::env::var(ENV_SWITCH).is_ok_and(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "on" | "yes" | "true" | "1"));
        let s = Switch(Arc::new(std::sync::atomic::AtomicBool::new(false)));
        let adopted = s.clone();
        crate::console_setting::follow(state, crate::console_setting::MCP, on, move |on| adopted.set(on));
        s
    }

    pub fn is_on(&self) -> bool {
        self.0.load(std::sync::atomic::Ordering::SeqCst)
    }

    pub fn set(&self, on: bool) {
        self.0.store(on, std::sync::atomic::Ordering::SeqCst);
    }
}

/// **The connector's routes, and the person's own list of connections**
/// (`GET /api/account/mcp`, `POST /api/account/mcp/{id}/end`, behind the
/// session like every `/api/account/` route), behind the switch; and the
/// administrator's switch itself (`/api/mcp`).
pub fn routes(app: Router<AppState>, state: &AppState, switch: &Switch) -> Router<AppState> {
    let Some(connector) = connector(state) else {
        return app;
    };
    let doors = connector
        .router::<AppState>()
        .route("/api/account/mcp", get(list))
        .route("/api/account/mcp/{id}/end", post(end))
        .route_layer(axum::middleware::from_fn_with_state(switch.clone(), switched))
        .layer(Extension(connector));
    let read = {
        let s = switch.clone();
        move || async move { Json(json!({ "on": s.is_on() })) }
    };
    let write = {
        let s = switch.clone();
        move |axum::extract::State(state): axum::extract::State<AppState>, caller: Option<Extension<Caller>>, Json(body): Json<Value>| async move {
            flip(&state, &s, caller.map(|c| c.0), &body).await
        }
    };
    app.merge(doors).route("/api/mcp", get(read).put(write))
}

/// The gate in front of the connector's routes: OFF, the door is not found, as in the
/// default build.
async fn switched(axum::extract::State(switch): axum::extract::State<Switch>, request: axum::extract::Request, next: axum::middleware::Next) -> Response {
    if switch.is_on() {
        next.run(request).await
    } else {
        StatusCode::NOT_FOUND.into_response()
    }
}

async fn flip(state: &AppState, s: &Switch, caller: Option<Caller>, body: &Value) -> Response {
    use crate::console_setting as setting;
    if !setting::may_flip(caller.as_ref()) {
        return (StatusCode::FORBIDDEN, "only an administrator switches the MCP connector").into_response();
    }
    let Some(on) = body.get("on").and_then(Value::as_bool) else {
        return (StatusCode::BAD_REQUEST, "send {\"on\": true} or {\"on\": false}").into_response();
    };
    let before = match setting::write(state, setting::MCP, on).await {
        Ok(before) => before,
        Err(why) => return (StatusCode::BAD_GATEWAY, format!("the switch could not be stored at Gunnar, so it was not flipped: {why}")).into_response(),
    };
    // Said once stored: a read of gunnar that was on its way meanwhile is older than this flip.
    setting::flipped(setting::MCP);
    s.set(on);
    setting::audit(setting::MCP, &setting::who(caller.as_ref()), before, on);
    Json(json!({ "on": on })).into_response()
}

/// The person's name from the session.
fn person(caller: Option<Extension<Caller>>) -> Option<String> {
    match caller {
        Some(Extension(Caller::Operator(session))) => Some(session.user),
        _ => None,
    }
}

fn nobody() -> Response {
    (
        StatusCode::FORBIDDEN,
        "app connections belong to a signed-in person",
    )
        .into_response()
}

async fn list(
    Extension(connector): Extension<Connector>,
    caller: Option<Extension<Caller>>,
) -> Response {
    match person(caller) {
        Some(me) => Json(json!({ "connections": connector.connections(&me) })).into_response(),
        None => nobody(),
    }
}

async fn end(
    Extension(connector): Extension<Connector>,
    caller: Option<Extension<Caller>>,
    Path(id): Path<u64>,
) -> Response {
    match person(caller) {
        Some(me) if connector.disconnect(&me, id).await => {
            Json(json!({ "ok": true })).into_response()
        }
        Some(_) => (StatusCode::NOT_FOUND, "no such connection of yours").into_response(),
        None => nobody(),
    }
}

fn refusal((status, message): (StatusCode, String)) -> mcp::Refusal {
    mcp::Refusal { status, message }
}

fn repo_path(repo: &mcp::RepoName) -> pb::RepoPath {
    crate::api::repo_path(&repo.namespace, &repo.name)
}

/// gunnar's refusal, as the connector says it.
fn failure(status: tonic::Status) -> mcp::Failure {
    use tonic::Code;
    match status.code() {
        Code::PermissionDenied | Code::NotFound => mcp::Failure::Hidden,
        Code::Unauthenticated => mcp::Failure::KeyGone,
        Code::InvalidArgument | Code::FailedPrecondition | Code::OutOfRange => {
            mcp::Failure::Refused(status.message().to_owned())
        }
        Code::ResourceExhausted => mcp::Failure::Busy(status.message().to_owned()),
        _ => mcp::Failure::Unavailable(status.message().to_owned()),
    }
}

fn visibility(v: i32) -> String {
    match pb::Visibility::try_from(v).unwrap_or(pb::Visibility::Unspecified) {
        pb::Visibility::Public => "public",
        pb::Visibility::Internal => "internal",
        _ => "private",
    }
    .to_owned()
}

fn commit(c: pb::Commit) -> mcp::Commit {
    let author = c.author.unwrap_or_default();
    mcp::Commit {
        oid: c.oid,
        tree: c.tree_oid,
        parents: c.parent_oids,
        author: author.name,
        when_unix: author.time_unix_seconds,
        message: c.message,
    }
}

fn change_kind(k: i32) -> String {
    match pb::ChangeKind::try_from(k).unwrap_or(pb::ChangeKind::Unspecified) {
        pb::ChangeKind::Added => "added",
        pb::ChangeKind::Modified => "modified",
        pb::ChangeKind::Deleted => "deleted",
        pb::ChangeKind::Renamed => "renamed",
        pb::ChangeKind::Copied => "copied",
        pb::ChangeKind::TypeChanged => "type-changed",
        pb::ChangeKind::Unspecified => "changed",
    }
    .to_owned()
}

fn pull_state(s: i32) -> String {
    match pb::PullRequestState::try_from(s).unwrap_or(pb::PullRequestState::Unspecified) {
        pb::PullRequestState::Open => "open",
        pb::PullRequestState::Merged => "merged",
        pb::PullRequestState::Closed => "closed",
        pb::PullRequestState::Unspecified => "unknown",
    }
    .to_owned()
}

fn pull(p: pb::PullRequest) -> mcp::Item {
    mcp::Item {
        number: p.number,
        state: pull_state(p.state),
        title: p.title,
        author: p.author,
        body: p.description,
        facts: vec![
            (
                "source".into(),
                format!("{} ({})", p.source_ref, p.source_oid),
            ),
            ("target".into(), p.target_ref),
        ],
    }
}

fn issue(i: pb::IssueState) -> mcp::Item {
    mcp::Item {
        number: i.number,
        state: if i.open { "open" } else { "closed" }.into(),
        title: i.title,
        author: i.author,
        body: i.body,
        facts: vec![("comments".into(), i.comments.to_string())],
    }
}

fn hash_kind(oid: &str) -> gix_hash::Kind {
    if oid.len() == 64 {
        gix_hash::Kind::Sha256
    } else {
        gix_hash::Kind::Sha1
    }
}

/// The tree a commit object's bytes name.
fn tree_of_commit(data: &[u8]) -> Option<String> {
    let text = std::str::from_utf8(data).ok()?;
    text.lines()
        .next()?
        .strip_prefix("tree ")
        .map(str::to_owned)
}

/// **The one control-plane call** for `call`, signed as the agent.
async fn answer(
    gunnar: &Gunnar,
    user: &str,
    call: &mcp::Call,
) -> Result<mcp::Answer, mcp::Failure> {
    use mcp::Call as C;
    Ok(match call {
        C::ListRepositories { query } => {
            let rows = gunnar
                .repos()
                .list(pb::ListReposRequest {
                    query: query.clone(),
                    ..Default::default()
                })
                .await
                .map_err(failure)?
                .into_inner()
                .repos;
            mcp::Answer::Repos(
                rows.into_iter()
                    .map(|r| {
                        let path = r.path.unwrap_or_default();
                        mcp::Repo {
                            path: format!("{}/{}", path.namespace, path.repo),
                            visibility: visibility(r.visibility),
                            default_branch: r.default_branch,
                            description: r.description,
                        }
                    })
                    .collect(),
            )
        }
        C::ListRefs { repo, prefix } => {
            let refs = gunnar
                .refs()
                .list(pb::ListRefsRequest {
                    repo: Some(repo_path(repo)),
                    prefix: prefix.clone(),
                    page_size: 500,
                    peel: true,
                    ..Default::default()
                })
                .await
                .map_err(failure)?
                .into_inner()
                .refs;
            mcp::Answer::Refs(
                refs.into_iter()
                    .map(|r| mcp::Ref {
                        oid: if r.peeled_oid.is_empty() {
                            r.oid
                        } else {
                            r.peeled_oid
                        },
                        name: r.ref_name,
                    })
                    .collect(),
            )
        }
        C::ListCommits {
            repo,
            rev,
            path,
            limit,
        } => {
            let commits = gunnar
                .refs()
                .log(pb::LogRequest {
                    repo: Some(repo_path(repo)),
                    start: rev.clone(),
                    page_size: *limit,
                    path: path.clone(),
                    ..Default::default()
                })
                .await
                .map_err(failure)?
                .into_inner()
                .commits;
            mcp::Answer::Commits(
                commits
                    .into_iter()
                    .take(*limit as usize)
                    .map(commit)
                    .collect(),
            )
        }
        C::BrowseTree { repo, oid } => {
            let object = gunnar
                .refs()
                .read_object(pb::ReadObjectRequest {
                    repo: Some(repo_path(repo)),
                    oid: oid.clone(),
                    max_bytes: 4 * 1024 * 1024,
                })
                .await
                .map_err(failure)?
                .into_inner();
            match pb::ObjectKind::try_from(object.kind).unwrap_or(pb::ObjectKind::Unspecified) {
                pb::ObjectKind::Tree => {
                    let entries = crate::git::parse_tree(&object.data, hash_kind(oid))
                        .map_err(mcp::Failure::Refused)?;
                    mcp::Answer::Tree(
                        entries
                            .into_iter()
                            .map(|e| mcp::Entry {
                                mode: e.mode,
                                name: e.name,
                                oid: e.oid,
                            })
                            .collect(),
                    )
                }
                pb::ObjectKind::Commit => match tree_of_commit(&object.data) {
                    Some(tree) => {
                        return Err(mcp::Failure::Refused(format!(
                            "{oid} is a commit; its tree is {tree}, which browse_tree opens."
                        )))
                    }
                    None => {
                        return Err(mcp::Failure::Refused(format!(
                            "{oid} is a commit, not a tree."
                        )))
                    }
                },
                _ => {
                    return Err(mcp::Failure::Refused(format!(
                        "{oid} is not a tree; read a file with read_file."
                    )))
                }
            }
        }
        C::ReadFile {
            repo,
            oid,
            max_bytes,
        } => {
            let object = gunnar
                .refs()
                .read_object(pb::ReadObjectRequest {
                    repo: Some(repo_path(repo)),
                    oid: oid.clone(),
                    max_bytes: *max_bytes,
                })
                .await
                .map_err(failure)?
                .into_inner();
            if pb::ObjectKind::try_from(object.kind).unwrap_or(pb::ObjectKind::Unspecified)
                != pb::ObjectKind::Blob
            {
                return Err(mcp::Failure::Refused(format!(
                    "{oid} is not a file (a blob); open a tree with browse_tree."
                )));
            }
            mcp::Answer::File {
                oid: object.oid,
                size: object.size,
                truncated: object.truncated,
                data: object.data,
            }
        }
        C::SearchCode { repo, pattern, rev } => {
            let matches = gunnar
                .refs()
                .search(pb::SearchRequest {
                    repo: Some(repo_path(repo)),
                    rev: rev.clone(),
                    pattern: pattern.clone(),
                    max_file_bytes: 1024 * 1024,
                    max_matches: 200,
                })
                .await
                .map_err(failure)?
                .into_inner()
                .matches;
            mcp::Answer::Matches(
                matches
                    .into_iter()
                    .map(|m| mcp::Match {
                        path: m.path,
                        line: m.line_number,
                        text: String::from_utf8_lossy(&m.line).into_owned(),
                    })
                    .collect(),
            )
        }
        C::Diff {
            repo,
            from,
            to,
            paths,
        } => {
            let changes = gunnar
                .refs()
                .diff(pb::DiffRequest {
                    repo: Some(repo_path(repo)),
                    from: from.clone(),
                    to: to.clone(),
                    path_prefixes: paths.clone(),
                })
                .await
                .map_err(failure)?
                .into_inner()
                .changes;
            mcp::Answer::Changes(
                changes
                    .into_iter()
                    .map(|c| mcp::Change {
                        kind: change_kind(c.kind),
                        path: c.path,
                        old_path: c.old_path,
                        old_oid: c.old_oid,
                        new_oid: c.new_oid,
                        insertions: c.insertions,
                        deletions: c.deletions,
                        binary: c.binary,
                    })
                    .collect(),
            )
        }
        C::ListIssues { repo } => mcp::Answer::Items(
            gunnar
                .forge()
                .list_issues(pb::ListIssuesRequest {
                    repo: repo.to_string(),
                })
                .await
                .map_err(failure)?
                .into_inner()
                .issues
                .into_iter()
                .map(issue)
                .collect(),
        ),
        C::GetIssue { repo, number } => {
            let state = gunnar
                .forge()
                .get_issue(pb::GetIssueRequest {
                    repo: repo.to_string(),
                    number: *number,
                })
                .await
                .map_err(failure)?
                .into_inner()
                .state;
            mcp::Answer::Item(issue(state.ok_or(mcp::Failure::Hidden)?))
        }
        C::ListMergeRequests { repo, state } => {
            let wanted = match state.as_str() {
                "open" => pb::PullRequestState::Open,
                "merged" => pb::PullRequestState::Merged,
                "closed" => pb::PullRequestState::Closed,
                _ => pb::PullRequestState::Unspecified,
            };
            let pulls = gunnar
                .pulls()
                .list(pb::ListPullRequestsRequest {
                    repo: Some(repo_path(repo)),
                    state: wanted as i32,
                })
                .await
                .map_err(failure)?
                .into_inner()
                .pulls;
            mcp::Answer::Items(pulls.into_iter().map(pull).collect())
        }
        C::GetMergeRequest { repo, number } => {
            let got = gunnar
                .pulls()
                .get(pb::GetPullRequestRequest {
                    repo: Some(repo_path(repo)),
                    number: *number,
                })
                .await
                .map_err(failure)?
                .into_inner()
                .pull;
            mcp::Answer::Item(pull(got.ok_or(mcp::Failure::Hidden)?))
        }
        C::CommentOnIssue { repo, number, body } => {
            let body = body.trim().to_owned();
            if body.is_empty() {
                return Err(mcp::Failure::Refused(
                    "an empty comment says nothing".to_owned(),
                ));
            }
            let event = crate::forge::ForgeEvent {
                repo: repo.to_string(),
                pull: *number,
                actor: user.to_owned(),
                at_unix_ms: crate::auth::now_secs() * 1000,
                event: crate::forge::Event::IssueCommented { body },
            };
            let signed = gunnar.sign_forge_event(&event).map_err(failure)?;
            gunnar
                .forge()
                .append(pb::AppendEventRequest {
                    event: Some(signed),
                })
                .await
                .map_err(failure)?;
            mcp::Answer::Done(format!("Commented on issue #{number} of {repo}."))
        }
    })
}

impl mcp::Backend for Console {
    type Agent = Agent;

    fn passkey_options<'a>(
        &'a self,
        client: &'a str,
    ) -> mcp::BoxFuture<'a, Result<Value, mcp::Refusal>> {
        Box::pin(async move {
            let webauthn = crate::auth::routes::passkey(&self.state).map_err(refusal)?;
            // The identity core's login ceremony (IDENTITY-HOST): its
            // challenge, its per-browser bound.
            let ceremony = self
                .state
                .gunnar
                .identity()
                .login_start(webauthn.login_start_request(client))
                .await
                .map_err(|status| {
                    let (status, message) = crate::auth::identity_refused(&status);
                    mcp::Refusal { status, message }
                })?
                .into_inner();
            Ok(webauthn.login_options(&ceremony, &[]))
        })
    }

    fn passkey_finish<'a>(
        &'a self,
        touch: &'a Value,
        expires_unix_ms: i64,
    ) -> mcp::BoxFuture<'a, Result<mcp::Enrolled<Agent>, mcp::Refusal>> {
        Box::pin(async move {
            let unauthorized = |message: String| mcp::Refusal {
                status: StatusCode::UNAUTHORIZED,
                message,
            };
            let webauthn = crate::auth::routes::passkey(&self.state).map_err(refusal)?;
            let assertion = webauthn.assertion_in(touch).map_err(unauthorized)?;
            let internal = |e: crate::signer::SignerError| mcp::Refusal {
                status: StatusCode::INTERNAL_SERVER_ERROR,
                message: e.to_string(),
            };
            // The person: the identity core verifies the passkey and mints
            // their session — only to add the connection's own key AS them.
            let reply = match self.state.gunnar.identity().login_finish(webauthn.login_finish_request(&assertion)).await {
                Ok(reply) => reply.into_inner(),
                Err(status) => {
                    return Err(match nornir_identity::grpc::refusal_of(&status) {
                        Some(nornir_identity::Code::Store) | None => mcp::Refusal {
                            status: StatusCode::SERVICE_UNAVAILABLE,
                            message: format!("Gunnar could not check the passkey just now; try again. {}", status.message()),
                        },
                        Some(_) => unauthorized(format!("Gunnar did not accept that passkey: {}", status.message())),
                    })
                }
            };
            let Some(session) = crate::auth::Webauthn::session_of(reply) else {
                return Err(unauthorized("the identity core verified the passkey and named nobody".to_owned()));
            };
            let private = crate::reviewer::mint_ed25519().map_err(internal)?;
            let signer = crate::signer::Signer::from_openssh(&private).map_err(internal)?;
            let added = self
                .state
                .gunnar
                .as_session(&session.token)
                .access()
                .add_key(pb::AddKeyRequest {
                    principal_id: session.user.clone(),
                    openssh_public_key: signer.public_key_openssh(),
                    comment: "MCP connection".to_owned(),
                    expires_at_unix_ms: Some(expires_unix_ms),
                    attestation: None,
                })
                .await;
            // The session was only for that: ended, the browser is not logged in.
            let _ = self
                .state
                .gunnar
                .identity()
                .logout(nornir_identity::grpc::pb::LogoutRequest { session: session.token.clone(), everywhere: false })
                .await;
            let key = match added {
                Ok(answer) => answer.into_inner().key.unwrap_or_default(),
                Err(status) if crate::auth::routes::refuses_the_credential(status.code()) => {
                    return Err(unauthorized(format!("Gunnar did not take the connection's key: {}", status.message())))
                }
                Err(status) => {
                    return Err(mcp::Refusal {
                        status: StatusCode::SERVICE_UNAVAILABLE,
                        message: format!("Gunnar could not take the connection's key just now; try again. {}", status.message()),
                    })
                }
            };
            Ok(mcp::Enrolled {
                agent: Agent {
                    gunnar: self.state.gunnar.as_signer(Arc::new(signer)),
                    fingerprint: key.fingerprint.clone(),
                },
                user: session.user,
                fingerprint: key.fingerprint,
            })
        })
    }

    fn call<'a>(
        &'a self,
        agent: &'a Agent,
        user: &'a str,
        call: &'a mcp::Call,
    ) -> mcp::BoxFuture<'a, Result<mcp::Answer, mcp::Failure>> {
        Box::pin(answer(&agent.gunnar, user, call))
    }

    fn forget<'a>(&'a self, agent: &'a Agent) -> mcp::BoxFuture<'a, ()> {
        Box::pin(async move {
            if let Err(status) = agent
                .gunnar
                .access()
                .revoke_key(pb::RevokeKeyRequest {
                    fingerprint: agent.fingerprint.clone(),
                })
                .await
            {
                if status.code() != tonic::Code::Unauthenticated
                    && status.code() != tonic::Code::NotFound
                {
                    tracing::warn!(target: crate::auth::AUDIT_TARGET, event = "mcp.key_not_revoked", fingerprint = %agent.fingerprint, reason = %status.message(), "gunnar did not revoke an MCP connection's key; it expires on its own");
                }
            }
        })
    }

    fn client_of(&self, parts: &http::request::Parts) -> String {
        crate::security::client_ip(&parts.headers, parts.extensions.get()).0
    }

    fn audit(&self, r: &mcp::Audit) {
        tracing::info!(target: crate::auth::AUDIT_TARGET, event = r.event, actor = %r.actor, tool = %r.tool, outcome = %r.outcome, grant = r.grant, "an MCP connection acted");
    }
}