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

nordisk/gunnar

crates/gunnar-front/tests/mcp_bridge.rs

//! **Gunnar's MCP connector against a REAL gunnar-server** (TASKS-13).
//!
//! The front's console (gunnar-ui-server built with `mcp`) in process, in
//! front of gunnar's real control plane (`gunnar_grpc::router` over real
//! stores in a temp dir) on loopback. An in-process MCP client drives it the
//! way claude.ai and Claude Code do: dynamic client registration, the consent
//! page, a passkey touch with a TEST credential (a P-256 key registered on
//! the person at gunnar), the code for a token with PKCE, then JSON-RPC
//! tool calls. Every tool is called against gunnar for real; a private
//! repository is refused to a person who cannot see it; a revoked token is
//! refused; the allowlist and the rate limit hold. No network beyond
//! loopback, no real account, no live site: users are `*@mock.invalid`-free
//! principal names and the site is `gunnar.mock.invalid`.

use std::net::SocketAddr;
use std::sync::Arc;
use std::time::{Duration, Instant};

use axum::body::Body;
use base64::Engine as _;
use gix_object::Kind;
use gunnar_core::{HashKind, NewRepo, RepoRef};
use gunnar_grpc::ControlPlane as _;
use gunnar_server::stores::ServerStores;
use gunnar_server::CoreControlPlane;
use gunnar_store::conformance::pack::PackBuilder;
use http::{Request, StatusCode};
use p256::ecdsa::signature::Signer as _;
use serde_json::{json, Value};
use sha2::Digest as _;
use tower::ServiceExt as _;

const RP_ID: &str = "gunnar.mock.invalid";
const ORIGIN: &str = "https://gunnar.mock.invalid";
const B64URL: base64::engine::GeneralPurpose = base64::engine::general_purpose::URL_SAFE_NO_PAD;
const VERIFIER: &str = "the-mcp-bridge-test-verifier-is-long-enough-for-pkce";

type Oid = gix_hash::ObjectId;

fn mint() -> (String, String) {
    let pem = gunnar_core::mint_ed25519().expect("mint");
    let text = std::str::from_utf8(&pem).expect("utf8").to_owned();
    let key = gunnar_core::ssh_key::PrivateKey::from_openssh(&text).expect("parse");
    let line = key.public_key().to_openssh().expect("line");
    (text, line)
}

/// A P-256 authenticator: its credential id and its counter.
struct Passkey {
    key: p256::ecdsa::SigningKey,
    id: Vec<u8>,
    counter: std::sync::atomic::AtomicU32,
}

impl Passkey {
    fn new(seed: u8, id: &str) -> Passkey {
        Passkey {
            key: p256::ecdsa::SigningKey::from_slice(&[seed; 32]).expect("a scalar"),
            id: id.as_bytes().to_vec(),
            counter: 0.into(),
        }
    }

    fn public(&self) -> Vec<u8> {
        self.key
            .verifying_key()
            .to_sec1_point(false)
            .as_bytes()
            .to_vec()
    }

    /// The browser's answer to `options` (`{ceremony_id, publicKey}`): user
    /// present and verified, for `ORIGIN`.
    fn touch(&self, options: &Value) -> Value {
        let challenge = options["publicKey"]["challenge"]
            .as_str()
            .expect("a challenge");
        let n = self
            .counter
            .fetch_add(1, std::sync::atomic::Ordering::SeqCst)
            + 1;
        let client_data = format!(r#"{{"type":"webauthn.get","challenge":"{challenge}","origin":"{ORIGIN}","crossOrigin":false}}"#).into_bytes();
        let mut auth_data = sha2::Sha256::digest(RP_ID.as_bytes()).to_vec();
        auth_data.push(0x01 | 0x04);
        auth_data.extend_from_slice(&n.to_be_bytes());
        let mut message = auth_data.clone();
        message.extend_from_slice(&sha2::Sha256::digest(&client_data));
        let signature: p256::ecdsa::Signature = self.key.sign(&message);
        json!({
            "ceremony_id": options["ceremony_id"],
            "id": B64URL.encode(&self.id),
            "response": {
                "clientDataJSON": B64URL.encode(&client_data),
                "authenticatorData": B64URL.encode(&auth_data),
                "signature": B64URL.encode(signature.to_der().as_bytes()),
            },
        })
    }
}

fn compute(hash: gix_hash::Kind, kind: Kind, content: &[u8]) -> Oid {
    gix_object::compute_hash(hash, kind, content).expect("hash")
}

fn tree_bytes(entries: &[(&str, &str, Oid)]) -> Vec<u8> {
    let mut sorted = entries.to_vec();
    sorted.sort_by(|a, b| a.1.as_bytes().cmp(b.1.as_bytes()));
    let mut body = Vec::new();
    for (mode, name, oid) in sorted {
        body.extend_from_slice(mode.as_bytes());
        body.push(b' ');
        body.extend_from_slice(name.as_bytes());
        body.push(0);
        body.extend_from_slice(oid.as_bytes());
    }
    body
}

fn commit_bytes(tree: Oid, parents: &[Oid], message: &str) -> Vec<u8> {
    let mut body = format!("tree {}\n", tree.to_hex());
    for p in parents {
        body.push_str(&format!("parent {}\n", p.to_hex()));
    }
    // The author's address is a mock one; no answer may carry it.
    body.push_str("author Kim <kim@mock.invalid> 1700000000 +0000\ncommitter Kim <kim@mock.invalid> 1700000000 +0000\n\n");
    body.push_str(message);
    body.push('\n');
    body.into_bytes()
}

/// What one planted repository holds.
struct Planted {
    first: Oid,
    second: Oid,
    tree: Oid,
    src_tree: Oid,
    readme: Oid,
}

/// Two commits on `refs/heads/main`: the second changes `src/lib.rs`.
fn plant(stores: &ServerStores, ns: &str, name: &str, words: &str) -> Planted {
    let path = RepoRef::new(ns, name).expect("a repo path");
    if stores
        .catalog()
        .namespaces()
        .unwrap()
        .iter()
        .all(|n| n.name != ns)
    {
        stores.catalog().create_namespace(ns).expect("namespace");
    }
    let entry = stores
        .create_repo(
            NewRepo::new(path.clone()).hash_kind(HashKind::Sha256),
            false,
        )
        .expect("repo");
    let store = stores.repo_store(&entry).expect("store");
    let hash = store.hash_kind();
    let readme_text = format!("# {name}\n\n{words}\n").into_bytes();
    let readme = compute(hash, Kind::Blob, &readme_text);
    let lib_a = b"pub fn answer() -> u32 {\n    41\n}\n".to_vec();
    let lib_b = format!("pub fn answer() -> u32 {{\n    42 // {words}\n}}\n").into_bytes();
    let (la, lb) = (
        compute(hash, Kind::Blob, &lib_a),
        compute(hash, Kind::Blob, &lib_b),
    );
    let src_a = tree_bytes(&[("100644", "lib.rs", la)]);
    let src_b = tree_bytes(&[("100644", "lib.rs", lb)]);
    let (sa, sb) = (
        compute(hash, Kind::Tree, &src_a),
        compute(hash, Kind::Tree, &src_b),
    );
    let root_a = tree_bytes(&[("100644", "README.md", readme), ("40000", "src", sa)]);
    let root_b = tree_bytes(&[("100644", "README.md", readme), ("40000", "src", sb)]);
    let (ta, tb) = (
        compute(hash, Kind::Tree, &root_a),
        compute(hash, Kind::Tree, &root_b),
    );
    let ca = commit_bytes(ta, &[], "first");
    let ca_id = compute(hash, Kind::Commit, &ca);
    let cb = commit_bytes(
        tb,
        &[ca_id],
        "second: the answer\n\nSigned-off-by: Kim <kim@mock.invalid>",
    );
    let cb_id = compute(hash, Kind::Commit, &cb);
    let mut pb = PackBuilder::new(hash);
    for (kind, content) in [
        (Kind::Blob, readme_text),
        (Kind::Blob, lib_a),
        (Kind::Blob, lib_b),
        (Kind::Tree, src_a),
        (Kind::Tree, src_b.clone()),
        (Kind::Tree, root_a),
        (Kind::Tree, root_b),
        (Kind::Commit, ca),
        (Kind::Commit, cb),
    ] {
        pb.object(kind, content);
    }
    store
        .serve()
        .put_pack(&pb.build().expect("pack").bytes)
        .expect("pack");
    store
        .serve()
        .update_ref("refs/heads/main", None, Some(cb_id.as_bytes()))
        .expect("ref");
    Planted {
        first: ca_id,
        second: cb_id,
        tree: tb,
        src_tree: sb,
        readme,
    }
}

struct Box_ {
    _dir: tempfile::TempDir,
    stores: Arc<ServerStores>,
    _state_dir: tempfile::TempDir,
    app: axum::Router,
    kim: Passkey,
    eve: Passkey,
    secret: Planted,
    open: Planted,
}

fn free_addr() -> SocketAddr {
    std::net::TcpListener::bind("127.0.0.1:0")
        .expect("bind")
        .local_addr()
        .expect("addr")
}

/// gunnar, for real, and the console in front of it.
async fn a_box() -> Box_ {
    let dir = tempfile::tempdir().expect("tempdir");
    let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
    let (console_pem, console_line) = mint();
    stores
        .auth()
        .bootstrap_admin(&console_line)
        .expect("the console's key is the first admin");
    let (guest_pem, guest_line) = mint();
    let plane = CoreControlPlane::new(Arc::clone(&stores));
    let person = |id: &str, kind: gunnar_core::PrincipalKind| {
        stores
            .auth()
            .add_principal(gunnar_core::Principal {
                id: id.to_owned(),
                name: id.to_owned(),
                kind,
                created: 0,
            })
            .expect("a principal")
    };
    person("gunnar-guest", gunnar_core::PrincipalKind::Machine);
    stores
        .auth()
        .register_key("gunnar-guest", &guest_line, None)
        .expect("the guest's key");
    let (kim, eve) = (
        Passkey::new(11, "kim-test-passkey"),
        Passkey::new(12, "eve-test-passkey"),
    );
    for (who, key) in [("kim", &kim), ("eve", &eve)] {
        person(who, gunnar_core::PrincipalKind::User);
        plane
            .add_credential(who, &key.id, &key.public(), "test passkey", 0)
            .expect("a test passkey");
    }
    // kim's namespace is kim's (by name); kim/secret is private. pub/open is public.
    let secret = plant(&stores, "kim", "secret", "kims private words");
    let open = plant(&stores, "pub", "open", "public words");
    plane
        .set_visibility(
            &gunnar_proto::RepoPath::new("kim", "secret"),
            gunnar_proto::Visibility::Private,
        )
        .expect("private");
    plane
        .set_visibility(
            &gunnar_proto::RepoPath::new("pub", "open"),
            gunnar_proto::Visibility::Public,
        )
        .expect("public");

    let addr = free_addr();
    let served: Arc<dyn gunnar_grpc::ControlPlane> =
        Arc::new(CoreControlPlane::new(Arc::clone(&stores)));
    // The identity core gunnar serves (IDENTITY-HOST, 2026-10-27): the console's passkey
    // touch is its login ceremony now, for this relying party. Without it the router mounts
    // no IdentityService and every touch is 501 (OX-GUNBR 2026-10-28: red since then, as no
    // CI step ran this file).
    let config = gunnar_grpc::ServerConfig {
        web_relying_parties: vec![gunnar_grpc::webauthn::RelyingParty {
            rp_id: RP_ID.to_string(),
            origins: vec![ORIGIN.to_string()],
        }],
        ..gunnar_grpc::ServerConfig::default()
    };
    let identity = gunnar_server::identity::build(dir.path(), Arc::clone(&served), &config, false);
    let ctx =
        gunnar_grpc::ServiceContext::new(served, Arc::new(gunnar_grpc::EventBus::new(16)), config)
            .with_identity(identity);
    tokio::spawn(async move {
        let _ = gunnar_grpc::router(ctx, || 0).serve(addr).await;
    });
    let t0 = Instant::now();
    while std::net::TcpStream::connect(addr).is_err() {
        assert!(t0.elapsed() < Duration::from_secs(10), "gunnar listens");
        tokio::time::sleep(Duration::from_millis(25)).await;
    }

    use gunnar_ui_server::{app::App, auth, grpc::Gunnar, signer::Signer};
    let state_dir = tempfile::tempdir().expect("state dir");
    let store = gunnar_ui_server::state::StateStore::open(state_dir.path().join("state.redb"))
        .expect("state");
    let control = addr.to_string();
    let gunnar = Gunnar::connect_lazy(
        &control,
        Arc::new(Signer::from_openssh(&console_pem).expect("console key")),
    )
    .expect("channel");
    let guest = Gunnar::connect_lazy(
        &control,
        Arc::new(Signer::from_openssh(&guest_pem).expect("guest key")),
    )
    .expect("channel");
    let rp = auth::RelyingParty {
        rp_id: RP_ID.to_string(),
        origin: ORIGIN.to_string(),
        rp_name: "gunnar".to_string(),
    };
    let keys = Arc::new(auth::Webauthn::new(rp, store.clone()).expect("webauthn"));
    let app = App {
        webauthn: Some(keys.clone()),
        admin_token: None,
        events: gunnar_ui_server::sse::EventHub::spawn(gunnar.clone()),
        reviewers: Arc::new(gunnar_ui_server::reviewer::ReviewerKeys::new(
            control.clone(),
            store.clone(),
        )),
        tenants: Arc::new(gunnar_ui_server::tenant::TenantKeys::new(
            control.clone(),
            store.clone(),
        )),
        gunnar,
        twin: None,
        guest: Some(guest),
        forges: Arc::new(gunnar_ui_server::forges::ForgeSessions::new()),
        forge_apps: Arc::new(gunnar_ui_server::forges::ForgeApps::from_env()),
        imports: Arc::new(gunnar_ui_server::api::mirror_gate::Imports::from_env()),
        ui_dir: None,
        caches: Default::default(),
        objects: Default::default(),
        readmes: Arc::new(gunnar_ui_server::readme::Cache::new(Some(store.clone()))),
        // The console's metered half (a front built with `metered` too): no ONE box here.
        #[cfg(feature = "metered")]
        monetize: None,
    };
    // The administrator's switch (owner 2026-10-26): the connector is OFF until switched on,
    // and the switch is gunnar's generic setting. Stored here before the console starts, it is
    // what the console takes at start: the doors open once it has.
    stores
        .settings()
        .set("console.mcp", b"on")
        .expect("the switch is stored at gunnar");
    let app = gunnar_ui_server::serve::router(Arc::new(app));
    let _ = keys;
    let t0 = Instant::now();
    loop {
        // Switched on, `/mcp` answers with the connector's OWN login pointer (RFC 9728), not
        // the console's session gate.
        let r = app
            .clone()
            .oneshot(Request::post("/mcp").body(Body::from("{}")).unwrap())
            .await
            .expect("an answer");
        let pointer = r
            .headers()
            .get(http::header::WWW_AUTHENTICATE)
            .and_then(|v| v.to_str().ok())
            .is_some_and(|v| v.contains("resource_metadata"));
        if r.status() == StatusCode::UNAUTHORIZED && pointer {
            break;
        }
        assert!(
            t0.elapsed() < Duration::from_secs(10),
            "the console took the stored switch: {}",
            r.status()
        );
        tokio::time::sleep(Duration::from_millis(25)).await;
    }
    Box_ {
        _dir: dir,
        stores,
        _state_dir: state_dir,
        app,
        kim,
        eve,
        secret,
        open,
    }
}

struct Answered {
    status: StatusCode,
    headers: http::HeaderMap,
    body: String,
}

impl Answered {
    fn json(&self) -> Value {
        serde_json::from_str(&self.body)
            .unwrap_or_else(|e| panic!("not JSON ({e}): {} {}", self.status, self.body))
    }
}

fn enc(v: &str) -> String {
    v.bytes()
        .map(|b| {
            if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) {
                (b as char).to_string()
            } else {
                format!("%{b:02X}")
            }
        })
        .collect()
}

fn query_value(uri: &str, key: &str) -> Option<String> {
    let q = uri.split_once('?')?.1;
    q.split('&').find_map(|p| {
        p.split_once('=')
            .filter(|(k, _)| *k == key)
            .map(|(_, v)| v.to_owned())
    })
}

struct Connected {
    client_id: String,
    access: String,
}

impl Box_ {
    async fn send(&self, request: Request<Body>) -> Answered {
        let r = self.app.clone().oneshot(request).await.expect("an answer");
        let status = r.status();
        let headers = r.headers().clone();
        let bytes = axum::body::to_bytes(r.into_body(), 1 << 22)
            .await
            .expect("a body");
        Answered {
            status,
            headers,
            body: String::from_utf8_lossy(&bytes).into_owned(),
        }
    }

    async fn post_json(&self, uri: &str, body: &Value) -> Answered {
        self.send(
            Request::post(uri)
                .header("content-type", "application/json")
                .header("origin", ORIGIN)
                .body(Body::from(body.to_string()))
                .unwrap(),
        )
        .await
    }

    async fn post_form(&self, uri: &str, pairs: &[(&str, &str)]) -> Answered {
        let body: Vec<String> = pairs
            .iter()
            .map(|(k, v)| format!("{k}={}", enc(v)))
            .collect();
        self.send(
            Request::post(uri)
                .header("content-type", "application/x-www-form-urlencoded")
                .body(Body::from(body.join("&")))
                .unwrap(),
        )
        .await
    }

    /// DCR → authorize → passkey touch → token, as Claude Code does it.
    async fn connect(&self, passkey: &Passkey, scope: &str, tick_write: bool) -> Connected {
        let redirect = "http://localhost:47215/callback";
        let reg = self.post_json("/oauth/register", &json!({ "client_name": "Claude Code (gunnar)", "redirect_uris": [redirect], "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "none" })).await;
        assert_eq!(reg.status, StatusCode::CREATED, "{}", reg.body);
        let client_id = reg.json()["client_id"].as_str().unwrap().to_owned();
        let challenge = B64URL.encode(sha2::Sha256::digest(VERIFIER.as_bytes()));
        let page = self
            .send(
                Request::get(format!(
                    "/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&code_challenge={challenge}&code_challenge_method=S256&state=s1&scope={}&resource={}",
                    enc(&client_id),
                    enc(redirect),
                    enc(scope),
                    enc(&format!("{ORIGIN}/mcp"))
                ))
                .body(Body::empty())
                .unwrap(),
            )
            .await;
        assert_eq!(page.status, StatusCode::OK, "{}", page.body);
        let at =
            page.body.find("data-request=\"").expect("a consent page") + "data-request=\"".len();
        let request = page.body[at..].split('"').next().unwrap().to_owned();
        let options = self
            .post_json("/oauth/authorize/passkey", &json!({ "request": request }))
            .await;
        assert_eq!(options.status, StatusCode::OK, "{}", options.body);
        let mut touch = passkey.touch(&options.json());
        touch["request"] = json!(request);
        touch["write"] = json!(tick_write);
        let done = self.post_json("/oauth/authorize/approve", &touch).await;
        assert_eq!(
            done.status,
            StatusCode::OK,
            "gunnar refused the test passkey: {}",
            done.body
        );
        let to = done.json()["redirect"].as_str().unwrap().to_owned();
        let code = query_value(&to, "code").expect("a code");
        let token = self
            .post_form(
                "/oauth/token",
                &[
                    ("grant_type", "authorization_code"),
                    ("code", &code),
                    ("redirect_uri", redirect),
                    ("code_verifier", VERIFIER),
                    ("client_id", &client_id),
                ],
            )
            .await;
        assert_eq!(token.status, StatusCode::OK, "{}", token.body);
        Connected {
            client_id,
            access: token.json()["access_token"].as_str().unwrap().to_owned(),
        }
    }

    async fn rpc(&self, token: &str, method: &str, params: Value) -> Answered {
        let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params });
        self.send(
            Request::post("/mcp")
                .header("content-type", "application/json")
                .header("accept", "application/json, text/event-stream")
                .header("authorization", format!("Bearer {token}"))
                .body(Body::from(body.to_string()))
                .unwrap(),
        )
        .await
    }

    /// One tool call: `(text, is_error)`.
    async fn tool(&self, token: &str, name: &str, arguments: Value) -> (String, bool) {
        let a = self
            .rpc(
                token,
                "tools/call",
                json!({ "name": name, "arguments": arguments }),
            )
            .await;
        assert_eq!(a.status, StatusCode::OK, "{name}: {}", a.body);
        let v = a.json();
        let r = &v["result"];
        (
            r["content"][0]["text"]
                .as_str()
                .unwrap_or_else(|| panic!("{name}: {v}"))
                .to_owned(),
            r["isError"].as_bool().unwrap_or(false),
        )
    }
}

fn ok(answer: (String, bool)) -> String {
    assert!(!answer.1, "an error: {}", answer.0);
    answer.0
}

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn every_tool_answers_from_the_real_gunnar_as_the_person() {
    let b = a_box().await;
    let kim = b.connect(&b.kim, "gunnar:read", false).await;
    let init = b.rpc(&kim.access, "initialize", json!({ "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "bridge-test", "version": "1" } })).await;
    assert_eq!(init.json()["result"]["serverInfo"]["name"], "gunnar");
    let (ns, repo) = ("kim", "secret");
    let at = |extra: Value| {
        let mut v = json!({ "namespace": ns, "repo": repo });
        v.as_object_mut()
            .unwrap()
            .extend(extra.as_object().unwrap().clone());
        v
    };

    let repos = ok(b.tool(&kim.access, "list_repositories", json!({})).await);
    assert!(
        repos.contains("kim/secret (private") && repos.contains("pub/open (public"),
        "{repos}"
    );

    let refs = ok(b.tool(&kim.access, "list_refs", at(json!({}))).await);
    assert!(
        refs.contains(&format!("{} refs/heads/main", b.secret.second)),
        "{refs}"
    );

    let log = ok(b
        .tool(
            &kim.access,
            "list_commits",
            at(json!({ "rev": "refs/heads/main" })),
        )
        .await);
    assert!(
        log.contains(&format!("commit {}", b.secret.second))
            && log.contains(&format!("tree {}", b.secret.tree))
            && log.contains(&format!("parent {}", b.secret.first)),
        "{log}"
    );
    assert!(
        log.contains("author Kim") && !log.contains("@mock.invalid"),
        "no e-mail address in an answer: {log}"
    );

    let tree = ok(b
        .tool(
            &kim.access,
            "browse_tree",
            at(json!({ "oid": b.secret.tree.to_string() })),
        )
        .await);
    assert!(
        tree.contains(&format!("100644 file {}\tREADME.md", b.secret.readme)),
        "{tree}"
    );
    assert!(
        tree.contains(&format!("40000 dir {}\tsrc", b.secret.src_tree)),
        "{tree}"
    );
    let commit_as_tree = b
        .tool(
            &kim.access,
            "browse_tree",
            at(json!({ "oid": b.secret.second.to_string() })),
        )
        .await;
    assert!(
        commit_as_tree.1 && commit_as_tree.0.contains(&b.secret.tree.to_string()),
        "a commit oid names its tree: {}",
        commit_as_tree.0
    );

    let readme = ok(b
        .tool(
            &kim.access,
            "read_file",
            at(json!({ "oid": b.secret.readme.to_string() })),
        )
        .await);
    assert_eq!(readme, "# secret\n\nkims private words\n");

    let found = ok(b
        .tool(
            &kim.access,
            "search_code",
            at(json!({ "pattern": "42 //" })),
        )
        .await);
    assert!(found.contains("src/lib.rs:2:"), "{found}");

    let diff = ok(b
        .tool(
            &kim.access,
            "diff",
            at(json!({ "from": b.secret.first.to_string(), "to": b.secret.second.to_string() })),
        )
        .await);
    assert!(diff.starts_with("modified src/lib.rs (+1 -1)"), "{diff}");

    let issues = ok(b.tool(&kim.access, "list_issues", at(json!({}))).await);
    assert_eq!(issues.trim(), "None.");
    let mrs = ok(b
        .tool(&kim.access, "list_merge_requests", at(json!({})))
        .await);
    assert_eq!(mrs.trim(), "None.");
    let (missing, err) = b
        .tool(&kim.access, "get_issue", at(json!({ "number": 9 })))
        .await;
    assert!(err, "{missing}");
    let (missing, err) = b
        .tool(&kim.access, "get_merge_request", at(json!({ "number": 9 })))
        .await;
    assert!(err, "{missing}");
}

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_private_repository_is_refused_to_a_person_who_cannot_see_it() {
    let b = a_box().await;
    let eve = b.connect(&b.eve, "", false).await;
    let repos = ok(b.tool(&eve.access, "list_repositories", json!({})).await);
    assert!(
        repos.contains("pub/open") && !repos.contains("secret"),
        "eve's list names kim's private repository: {repos}"
    );
    let readme = b.secret.readme.to_string();
    for (tool, args) in [
        (
            "read_file",
            json!({ "namespace": "kim", "repo": "secret", "oid": readme }),
        ),
        (
            "list_commits",
            json!({ "namespace": "kim", "repo": "secret" }),
        ),
        (
            "search_code",
            json!({ "namespace": "kim", "repo": "secret", "pattern": "private" }),
        ),
        ("list_refs", json!({ "namespace": "kim", "repo": "secret" })),
    ] {
        let (text, err) = b.tool(&eve.access, tool, args).await;
        assert!(err, "{tool}: eve read kim's private repository: {text}");
        assert_eq!(text, gunnar_mcp_hidden(), "{tool}");
        assert!(!text.contains("kims private words"));
    }
    // A name that does not exist answers in the same voice.
    let (nothing, _) = b
        .tool(
            &eve.access,
            "list_commits",
            json!({ "namespace": "kim", "repo": "nothing-here" }),
        )
        .await;
    assert_eq!(nothing, gunnar_mcp_hidden());
    // And the public one is eve's to read.
    let open = ok(b
        .tool(
            &eve.access,
            "read_file",
            json!({ "namespace": "pub", "repo": "open", "oid": b.open.readme.to_string() }),
        )
        .await);
    assert!(open.contains("public words"));
}

/// The connector's one voice for "not there, or not yours".
fn gunnar_mcp_hidden() -> String {
    "There is no such repository or object, or this connection cannot see it.".to_owned()
}

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_revoked_token_is_refused_and_its_key_is_gone_at_gunnar() {
    let b = a_box().await;
    let kim = b.connect(&b.kim, "", false).await;
    ok(b.tool(&kim.access, "list_repositories", json!({})).await);
    let keys = b.stores.auth().keys_of("kim").expect("kim's keys");
    assert_eq!(
        keys.len(),
        1,
        "gunnar enrolled one key for the connection on kim: {keys:?}"
    );
    let revoke = b
        .post_form(
            "/oauth/revoke",
            &[("token", &kim.access), ("client_id", &kim.client_id)],
        )
        .await;
    assert_eq!(revoke.status, StatusCode::OK);
    let after = b
        .rpc(
            &kim.access,
            "tools/call",
            json!({ "name": "list_repositories", "arguments": {} }),
        )
        .await;
    assert_eq!(after.status, StatusCode::UNAUTHORIZED, "{}", after.body);
    assert!(after.headers["www-authenticate"]
        .to_str()
        .unwrap()
        .contains("invalid_token"));
    assert!(
        b.stores
            .auth()
            .keys_of("kim")
            .expect("kim's keys")
            .is_empty(),
        "the connection's key is still enrolled at gunnar"
    );
}

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_allowlist_holds_and_no_tool_takes_an_address() {
    let b = a_box().await;
    let kim = b.connect(&b.kim, "", false).await;
    let unknown = b
        .rpc(
            &kim.access,
            "tools/call",
            json!({ "name": "fetch_url", "arguments": { "url": "https://example.invalid" } }),
        )
        .await
        .json();
    assert_eq!(unknown["error"]["code"], -32602);
    assert!(
        unknown["error"]["message"]
            .as_str()
            .unwrap()
            .starts_with("Unknown tool: fetch_url."),
        "{unknown}"
    );
    let (text, err) = b
        .tool(
            &kim.access,
            "list_repositories",
            json!({ "query": "https://example.invalid/x" }),
        )
        .await;
    assert!(err && text.contains("address"), "{text}");
    let write = b.rpc(&kim.access, "tools/call", json!({ "name": "comment_on_issue", "arguments": { "namespace": "kim", "repo": "secret", "number": 1, "body": "x" } })).await.json();
    assert_eq!(
        write["error"]["code"], -32602,
        "a write tool without the write grant: {write}"
    );
}

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn calls_past_the_budget_are_429_with_retry_after() {
    let b = a_box().await;
    let kim = b.connect(&b.kim, "", false).await;
    let mut limited = None;
    for _ in 0..40 {
        let a = b.rpc(&kim.access, "ping", json!({})).await;
        if a.status == StatusCode::TOO_MANY_REQUESTS {
            limited = Some(a);
            break;
        }
        assert_eq!(a.status, StatusCode::OK);
    }
    let limited = limited.expect("the budget never ran out");
    assert!(limited.headers.contains_key("retry-after"));
}

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_person_lists_and_ends_their_connection_from_their_console_session() {
    let b = a_box().await;
    let kim = b.connect(&b.kim, "", false).await;
    // kim signs in to the console with the same passkey: the real login.
    let start = b
        .send(
            Request::get("/auth/passkey/login/start")
                .body(Body::empty())
                .unwrap(),
        )
        .await;
    assert_eq!(start.status, StatusCode::OK, "{}", start.body);
    let login = b
        .post_json("/auth/passkey/login/finish", &b.kim.touch(&start.json()))
        .await;
    assert_eq!(login.status, StatusCode::OK, "{}", login.body);
    let set = login.headers["set-cookie"].to_str().unwrap();
    let cookie = set.split(';').next().unwrap().to_owned();
    let list = b
        .send(
            Request::get("/api/account/mcp")
                .header("cookie", &cookie)
                .body(Body::empty())
                .unwrap(),
        )
        .await;
    assert_eq!(list.status, StatusCode::OK, "{}", list.body);
    let id = list.json()["connections"][0]["id"]
        .as_u64()
        .expect("kim's connection");
    let end = b
        .send(
            Request::post(format!("/api/account/mcp/{id}/end"))
                .header("cookie", &cookie)
                .header("origin", ORIGIN)
                .body(Body::empty())
                .unwrap(),
        )
        .await;
    assert_eq!(end.status, StatusCode::OK, "{}", end.body);
    let after = b.rpc(&kim.access, "ping", json!({})).await;
    assert_eq!(after.status, StatusCode::UNAUTHORIZED);
}