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");
}
}