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