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-mcp/src/lib.rs

//! **Gunnar's MCP connector** (TASKS-13, owner 2026-10-25).
//!
//! A remote MCP server, over streamable HTTP, at `https://<site>/mcp`. It is
//! an API BRIDGE and nothing else: Claude (a claude.ai custom connector, or
//! `claude mcp add --transport http`) sends a tool call over HTTPS, and this
//! crate turns it into ONE existing control-plane call to gunnar-server,
//! through the console's own client, and answers with text. No AI runs here.
//! Nothing here dials anything: the [`Backend`] is the only way out, and it
//! takes a typed [`Call`] from a fixed allowlist ([`tools::TOOLS`]), never an
//! address.
//!
//! # Who the agent is
//!
//! Gunnar is its own OAuth 2.1 authorization server for this ([`oauth`]):
//! RFC 8414 and RFC 9728 metadata, RFC 7591 dynamic client registration the
//! way claude.ai and Claude Code expect it, PKCE (S256) always, and the
//! EXISTING passkey login. At the consent page the person touches their
//! passkey; gunnar's identity core verifies it (user verification required)
//! and gunnar adds a FRESH key for this connection to the person's own principal,
//! expiring with the grant. Every tool call is signed with that key, so gunnar
//! applies exactly the person's grants, and a private repository they cannot
//! see is refused by gunnar on gunnar's side of the wire. The result for the
//! agent is a short-lived bearer token (an hour), renewed by a rotating
//! refresh token until the grant ends ([`Config::grant_ttl`]); revoking it
//! (RFC 7009, or the person's own list) drops the token and the key.
//!
//! # What it never does
//!
//! No generic proxy, no fetch-URL, no shell, nothing that takes an address or
//! host. Read-only unless the person ticks the separate write grant. Every
//! call is audited as the agent (`mcp:<client> acting for <user>`) through
//! [`Backend::audit`], into the console's audit trail. Rate-limited per
//! connection and per address, 429 with `Retry-After` beyond. No secret,
//! token or e-mail address in an answer or an audit record.

// The handlers return an early `Response` as their `Err`, the way axum
// handlers are written; boxing it would only move the allocation.
#![allow(clippy::result_large_err)]

use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};

use base64::Engine as _;
use sha2::Digest as _;

pub mod limit;
pub mod oauth;
mod page;
mod rpc;
pub mod tools;

pub use tools::{Answer, Call, Change, Commit, Entry, Item, Match, Ref, Repo, RepoName, Scope};

/// A boxed, sendable future (the [`Backend`]'s methods).
pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;

/// The MCP protocol versions this server speaks, newest first.
pub const PROTOCOL_VERSIONS: &[&str] = &["2025-11-25", "2025-06-18", "2025-03-26"];

/// The paths this crate answers. The console lets these through its own
/// login gate: this crate checks its own bearer tokens.
pub fn is_door(path: &str) -> bool {
    path == "/mcp"
        || path == "/.well-known/oauth-protected-resource"
        || path == "/.well-known/oauth-protected-resource/mcp"
        || path == "/.well-known/oauth-authorization-server"
        || path == "/oauth"
        || path.starts_with("/oauth/")
}

/// **What the console does for this crate.** Five things, and the only way
/// out of this crate.
pub trait Backend: Send + Sync + 'static {
    /// The agent's credential: a client signed with the key gunnar enrolled
    /// on the person for this connection.
    type Agent: Clone + Send + Sync + 'static;

    /// The options for one passkey touch with user verification:
    /// `{"ceremony_id": …, "publicKey": {…}}`, the challenge gunnar's.
    fn passkey_options<'a>(
        &'a self,
        client: &'a str,
    ) -> BoxFuture<'a, Result<serde_json::Value, Refusal>>;

    /// **The existing passkey login**: gunnar verifies `touch` (the
    /// browser's assertion) and enrols a fresh key for the agent on the
    /// person's principal, expiring at `expires_unix_ms`.
    fn passkey_finish<'a>(
        &'a self,
        touch: &'a serde_json::Value,
        expires_unix_ms: i64,
    ) -> BoxFuture<'a, Result<Enrolled<Self::Agent>, Refusal>>;

    /// **ONE control-plane call**, signed as the agent.
    fn call<'a>(
        &'a self,
        agent: &'a Self::Agent,
        user: &'a str,
        call: &'a Call,
    ) -> BoxFuture<'a, Result<Answer, Failure>>;

    /// Drop the agent's key at gunnar (a revoke, an expiry). Best effort: the
    /// key also expires at gunnar on its own.
    fn forget<'a>(&'a self, agent: &'a Self::Agent) -> BoxFuture<'a, ()>;

    /// The client address a budget is kept for (the console's trusted-proxy rule).
    fn client_of(&self, parts: &http::request::Parts) -> String;

    /// One audit record, into the console's audit trail.
    fn audit(&self, record: &Audit);
}

/// What a passkey touch enrolled.
#[derive(Debug, Clone)]
pub struct Enrolled<A> {
    /// The agent's credential.
    pub agent: A,
    /// The person gunnar named (their principal id).
    pub user: String,
    /// The fingerprint of the key gunnar enrolled.
    pub fingerprint: String,
}

/// A refusal the console gives at the consent page.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Refusal {
    /// The status to answer with.
    pub status: http::StatusCode,
    /// The sentence the person reads.
    pub message: String,
}

/// Why a tool call came back without an answer.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Failure {
    /// Not found, or not visible to this person: ONE voice for both, so a
    /// private name is no oracle.
    Hidden,
    /// Gunnar refused the arguments (its own sentence).
    Refused(String),
    /// Gunnar is busy; try again shortly.
    Busy(String),
    /// Gunnar did not answer.
    Unavailable(String),
    /// Gunnar no longer knows the agent's key (revoked or expired there):
    /// the connection is over.
    KeyGone,
}

/// The words for [`Failure::Hidden`].
pub const HIDDEN_WORDS: &str =
    "There is no such repository or object, or this connection cannot see it.";

/// One audit record. No token, no secret, no e-mail address.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Audit {
    /// `mcp.call`, `mcp.grant`, `mcp.revoke`, `mcp.refused`, …
    pub event: &'static str,
    /// `mcp:<client> acting for <user>`.
    pub actor: String,
    /// The tool, for a call.
    pub tool: String,
    /// `ok`, or why not.
    pub outcome: String,
    /// The connection's number (never its token).
    pub grant: u64,
}

/// The connector's settings.
#[derive(Debug, Clone)]
pub struct Config {
    /// `https://<site>`: the issuer, and the origin the passkey is bound to.
    pub issuer: String,
    /// How long an access token lives.
    pub access_ttl: Duration,
    /// How long a grant (and the agent's key at gunnar) lives at most.
    pub grant_ttl: Duration,
    /// How long an authorization code may wait for its exchange.
    pub code_ttl: Duration,
    /// How long a consent page waits for its touch.
    pub consent_ttl: Duration,
    /// Calls one connection may make at once.
    pub calls_burst: u32,
    /// Calls one connection may make per second, sustained.
    pub calls_per_second: f64,
    /// OAuth requests (register, authorize, token, revoke) one address may make per minute.
    pub oauth_per_minute: u32,
    /// Registrations one address may make per hour.
    pub registrations_per_hour: u32,
    /// How long one control-plane call may take.
    pub call_timeout: Duration,
}

impl Config {
    /// The defaults for `issuer` (`https://<site>`).
    pub fn new(issuer: impl Into<String>) -> Config {
        Config {
            issuer: issuer.into().trim_end_matches('/').to_owned(),
            access_ttl: Duration::from_secs(3600),
            grant_ttl: Duration::from_secs(24 * 3600),
            code_ttl: Duration::from_secs(120),
            consent_ttl: Duration::from_secs(600),
            calls_burst: 30,
            calls_per_second: 1.0,
            oauth_per_minute: 30,
            registrations_per_hour: 20,
            call_timeout: Duration::from_secs(30),
        }
    }

    /// The protected resource: `https://<site>/mcp`.
    pub fn resource(&self) -> String {
        format!("{}/mcp", self.issuer)
    }
}

/// The most registered clients held at once; at the cap the oldest unused go.
const CLIENTS_MAX: usize = 2048;
/// The most open consent pages.
const PENDING_MAX: usize = 512;
/// The most live grants.
const GRANTS_MAX: usize = 4096;
/// The most live grants one person holds.
const GRANTS_PER_USER: usize = 32;

/// A registered client (RFC 7591).
#[derive(Debug, Clone)]
pub(crate) struct Client {
    pub(crate) id: String,
    pub(crate) secret_hash: Option<String>,
    pub(crate) name: String,
    pub(crate) redirect_uris: Vec<String>,
    pub(crate) at: Instant,
}

/// A consent page waiting for its touch.
#[derive(Debug, Clone)]
pub(crate) struct Pending {
    pub(crate) client_id: String,
    pub(crate) redirect_uri: String,
    pub(crate) state: Option<String>,
    pub(crate) challenge: String,
    pub(crate) wants_write: bool,
    pub(crate) at: Instant,
}

/// A code waiting for its exchange.
#[derive(Debug, Clone)]
pub(crate) struct Code {
    pub(crate) grant: u64,
    pub(crate) client_id: String,
    pub(crate) redirect_uri: String,
    pub(crate) challenge: String,
    pub(crate) at: Instant,
}

/// One connection: a person's grant to one client.
#[derive(Debug, Clone)]
pub(crate) struct Grant<A> {
    pub(crate) id: u64,
    pub(crate) client_id: String,
    pub(crate) actor: String,
    pub(crate) user: String,
    pub(crate) agent: A,
    pub(crate) fingerprint: String,
    pub(crate) scope: Scope,
    pub(crate) created_unix: u64,
    pub(crate) expires: SystemTime,
    pub(crate) bucket: limit::Bucket,
    pub(crate) last_used_unix: Option<u64>,
    /// The hash of the one refresh token that is live now.
    pub(crate) refresh: Option<String>,
    /// Refresh tokens this grant has rotated past: presenting one again ends the grant.
    pub(crate) retired: Vec<String>,
}

/// An access token's row.
#[derive(Debug, Clone)]
pub(crate) struct Access {
    pub(crate) grant: u64,
    pub(crate) expires: SystemTime,
}

#[derive(Debug)]
pub(crate) struct State<A> {
    pub(crate) clients: HashMap<String, Client>,
    pub(crate) pending: HashMap<String, Pending>,
    pub(crate) codes: HashMap<String, Code>,
    pub(crate) grants: HashMap<u64, Grant<A>>,
    pub(crate) access: HashMap<String, Access>,
    pub(crate) refresh: HashMap<String, u64>,
    pub(crate) next_grant: u64,
}

impl<A> Default for State<A> {
    fn default() -> Self {
        State {
            clients: HashMap::new(),
            pending: HashMap::new(),
            codes: HashMap::new(),
            grants: HashMap::new(),
            access: HashMap::new(),
            refresh: HashMap::new(),
            next_grant: 1,
        }
    }
}

/// **The connector**: its settings, its backend and its state. Everything is
/// held in memory, so a restart of the console ends every connection (the
/// agents' keys also expire at gunnar on their own).
pub struct Mcp<B: Backend> {
    pub(crate) backend: B,
    pub(crate) config: Config,
    pub(crate) state: Mutex<State<B::Agent>>,
    pub(crate) oauth_budget: Mutex<limit::Window>,
    pub(crate) register_budget: Mutex<limit::Window>,
}

/// One connection, as its person sees it in their own list.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct Connection {
    /// Its number.
    pub id: u64,
    /// The client's name, as it registered (sanitised).
    pub client: String,
    /// `gunnar:read` or `gunnar:read gunnar:write`.
    pub scope: String,
    /// When it was granted (seconds since the epoch).
    pub created_unix: u64,
    /// When it ends.
    pub expires_unix: u64,
    /// When it last called a tool.
    pub last_used_unix: Option<u64>,
    /// The fingerprint of its key at gunnar.
    pub key: String,
}

pub(crate) fn now_unix() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or_default()
}

pub(crate) fn unix_of(t: SystemTime) -> u64 {
    t.duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or_default()
}

/// 32 bytes from the OS, base64url.
pub(crate) fn random_token(prefix: &str) -> String {
    let mut bytes = [0u8; 32];
    // The OS generator failing is not something to answer with a weak token.
    getrandom::fill(&mut bytes).expect("the operating system's random number generator");
    format!(
        "{prefix}{}",
        base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
    )
}

/// The SHA-256 of `text`, hex: how every token and secret is held.
pub(crate) fn digest(text: &str) -> String {
    let d = sha2::Sha256::digest(text.as_bytes());
    d.iter().map(|b| format!("{b:02x}")).collect()
}

/// A client's name as an audit record and a page may carry it: printable,
/// no `@` (so no e-mail address), at most 40 characters.
pub(crate) fn sanitise_name(name: &str) -> String {
    let cleaned: String = name
        .chars()
        .filter(|c| c.is_ascii_alphanumeric() || matches!(c, ' ' | '.' | '_' | '-' | '(' | ')'))
        .take(40)
        .collect();
    let cleaned = cleaned.trim().to_owned();
    if cleaned.is_empty() {
        "client".to_owned()
    } else {
        cleaned
    }
}

impl<B: Backend> Mcp<B> {
    /// The connector over `backend`.
    pub fn new(backend: B, config: Config) -> Arc<Mcp<B>> {
        let oauth = limit::Window::new(config.oauth_per_minute, Duration::from_secs(60));
        let register = limit::Window::new(config.registrations_per_hour, Duration::from_secs(3600));
        Arc::new(Mcp {
            backend,
            config,
            state: Mutex::new(State::default()),
            oauth_budget: Mutex::new(oauth),
            register_budget: Mutex::new(register),
        })
    }

    /// Its settings.
    pub fn config(&self) -> &Config {
        &self.config
    }

    /// Its backend.
    pub fn backend(&self) -> &B {
        &self.backend
    }

    pub(crate) fn lock(&self) -> std::sync::MutexGuard<'_, State<B::Agent>> {
        // A panic while holding the lock leaves plain maps behind: keep going.
        self.state
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
    }

    /// The routes, for any outer router state.
    pub fn router<S: Clone + Send + Sync + 'static>(self: &Arc<Self>) -> axum::Router<S> {
        use axum::routing::{get, post};
        axum::Router::new()
            .route(
                "/mcp",
                post(rpc::post::<B>)
                    .get(rpc::not_here)
                    .delete(rpc::not_here),
            )
            .route(
                "/.well-known/oauth-protected-resource",
                get(oauth::protected_resource::<B>),
            )
            .route(
                "/.well-known/oauth-protected-resource/mcp",
                get(oauth::protected_resource::<B>),
            )
            .route(
                "/.well-known/oauth-authorization-server",
                get(oauth::authorization_server::<B>),
            )
            .route("/oauth/register", post(oauth::register::<B>))
            .route("/oauth/authorize", get(oauth::authorize::<B>))
            .route("/oauth/authorize.js", get(page::script))
            .route("/oauth/authorize/passkey", post(oauth::passkey::<B>))
            .route("/oauth/authorize/approve", post(oauth::approve::<B>))
            .route("/oauth/authorize/deny", post(oauth::deny::<B>))
            .route("/oauth/token", post(oauth::token::<B>))
            .route("/oauth/revoke", post(oauth::revoke::<B>))
            .with_state(Arc::clone(self))
    }

    /// **The person's own connections**, for their list.
    pub fn connections(&self, user: &str) -> Vec<Connection> {
        let mut out: Vec<Connection> = self
            .lock()
            .grants
            .values()
            .filter(|g| g.user == user && g.expires > SystemTime::now())
            .map(|g| Connection {
                id: g.id,
                client: g
                    .actor
                    .trim_start_matches("mcp:")
                    .split(" acting for ")
                    .next()
                    .unwrap_or_default()
                    .to_owned(),
                scope: g.scope.as_str().to_owned(),
                created_unix: g.created_unix,
                expires_unix: unix_of(g.expires),
                last_used_unix: g.last_used_unix,
                key: g.fingerprint.clone(),
            })
            .collect();
        out.sort_by_key(|c| c.id);
        out
    }

    /// **End one of the person's own connections**: its tokens stop at once
    /// and its key is dropped at gunnar. False when it is not theirs or gone.
    pub async fn disconnect(&self, user: &str, id: u64) -> bool {
        let owned = self.lock().grants.get(&id).is_some_and(|g| g.user == user);
        if owned {
            self.end_grant(id, "revoked by its person").await;
        }
        owned
    }

    /// End a grant: drop its tokens, forget its key, audit it.
    pub(crate) async fn end_grant(&self, id: u64, why: &str) {
        let gone = {
            let mut st = self.lock();
            let grant = st.grants.remove(&id);
            st.access.retain(|_, a| a.grant != id);
            st.refresh.retain(|_, g| *g != id);
            st.codes.retain(|_, c| c.grant != id);
            grant
        };
        if let Some(grant) = gone {
            self.backend.audit(&Audit {
                event: "mcp.revoke",
                actor: grant.actor.clone(),
                tool: String::new(),
                outcome: why.to_owned(),
                grant: id,
            });
            self.backend.forget(&grant.agent).await;
        }
    }

    /// Drop everything expired; answers the grants whose keys to forget.
    pub(crate) fn sweep(&self) -> Vec<Grant<B::Agent>> {
        let now = Instant::now();
        let wall = SystemTime::now();
        let cfg = &self.config;
        let mut st = self.lock();
        st.pending
            .retain(|_, p| now.saturating_duration_since(p.at) < cfg.consent_ttl);
        st.access.retain(|_, a| a.expires > wall);
        // A code not exchanged in time takes its (never used) grant with it.
        let mut unredeemed = Vec::new();
        st.codes.retain(|_, c| {
            let live = now.saturating_duration_since(c.at) < cfg.code_ttl;
            if !live {
                unredeemed.push(c.grant);
            }
            live
        });
        let expired: Vec<u64> = st
            .grants
            .values()
            .filter(|g| g.expires <= wall || (unredeemed.contains(&g.id) && g.refresh.is_none()))
            .map(|g| g.id)
            .collect();
        let mut out = Vec::new();
        for id in expired {
            if let Some(g) = st.grants.remove(&id) {
                out.push(g);
            }
            st.access.retain(|_, a| a.grant != id);
            st.refresh.retain(|_, g| *g != id);
        }
        out
    }

    /// [`Mcp::sweep`], and forget what it dropped.
    pub(crate) async fn sweep_and_forget(&self) {
        for g in self.sweep() {
            self.backend.audit(&Audit {
                event: "mcp.revoke",
                actor: g.actor.clone(),
                tool: String::new(),
                outcome: "expired".to_owned(),
                grant: g.id,
            });
            self.backend.forget(&g.agent).await;
        }
    }

    /// Room for one more client: the oldest client no live grant uses goes.
    pub(crate) fn room_for_a_client(st: &mut State<B::Agent>) {
        if st.clients.len() < CLIENTS_MAX {
            return;
        }
        let used: Vec<String> = st.grants.values().map(|g| g.client_id.clone()).collect();
        if let Some(oldest) = st
            .clients
            .values()
            .filter(|c| !used.contains(&c.id))
            .min_by_key(|c| c.at)
            .map(|c| c.id.clone())
        {
            st.clients.remove(&oldest);
        }
    }

    /// Room for one more consent page.
    pub(crate) fn room_for_a_page(st: &mut State<B::Agent>) {
        while st.pending.len() >= PENDING_MAX {
            let Some(oldest) = st
                .pending
                .iter()
                .min_by_key(|(_, p)| p.at)
                .map(|(k, _)| k.clone())
            else {
                break;
            };
            st.pending.remove(&oldest);
        }
    }

    /// May `user` hold one more grant?
    pub(crate) fn room_for_a_grant(st: &State<B::Agent>, user: &str) -> bool {
        st.grants.len() < GRANTS_MAX
            && st.grants.values().filter(|g| g.user == user).count() < GRANTS_PER_USER
    }

    /// A budget for an OAuth door: `Err(seconds)` beyond it.
    pub(crate) fn oauth_admit(&self, client: &str) -> Result<(), u64> {
        self.oauth_budget
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
            .admit(client)
    }

    /// The registration budget.
    pub(crate) fn register_admit(&self, client: &str) -> Result<(), u64> {
        self.register_budget
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
            .admit(client)
    }
}

/// `429` with `Retry-After`, in OAuth's JSON shape.
pub(crate) fn too_many(retry_after: u64) -> axum::response::Response {
    use axum::response::IntoResponse as _;
    (
        http::StatusCode::TOO_MANY_REQUESTS,
        [(http::header::RETRY_AFTER, retry_after.to_string()), (http::header::CACHE_CONTROL, "no-store".to_owned())],
        axum::Json(serde_json::json!({ "error": "slow_down", "error_description": format!("Too many requests; try again in {retry_after} seconds.") })),
    )
        .into_response()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_doors_are_exactly_these() {
        for p in [
            "/mcp",
            "/.well-known/oauth-protected-resource",
            "/.well-known/oauth-protected-resource/mcp",
            "/.well-known/oauth-authorization-server",
            "/oauth/token",
            "/oauth/authorize",
        ] {
            assert!(is_door(p), "{p}");
        }
        for p in [
            "/",
            "/mcpx",
            "/mcp/x",
            "/api/repos",
            "/oauthx",
            "/.well-known/openid-configuration",
            "/login",
        ] {
            assert!(!is_door(p), "{p}");
        }
    }

    #[test]
    fn a_name_carries_no_address_and_no_markup() {
        assert_eq!(sanitise_name("Claude"), "Claude");
        assert_eq!(
            sanitise_name("kim@mock.invalid <script>"),
            "kimmock.invalid script"
        );
        assert_eq!(sanitise_name("   "), "client");
        assert_eq!(sanitise_name(&"x".repeat(100)).len(), 40);
    }

    #[test]
    fn tokens_are_random_and_held_only_as_digests() {
        let a = random_token("t_");
        let b = random_token("t_");
        assert_ne!(a, b);
        assert!(a.len() > 40 && a.starts_with("t_"));
        assert_eq!(
            digest("abc"),
            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
        );
    }
}