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