crates/gunnar-mcp/tests/common/mod.rs
//! A fake console behind the connector, and an in-process MCP client that
//! drives it the way claude.ai and Claude Code do: register, authorize with a
//! passkey touch, exchange the code with PKCE, then JSON-RPC over `/mcp`.
//! Nothing here touches a network: every request is a `tower::oneshot`.
#![allow(dead_code)]
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use axum::body::Body;
use axum::Router;
use gunnar_mcp::{
Answer, Audit, Backend, BoxFuture, Call, Config, Enrolled, Failure, Item, Mcp, Refusal, Repo,
};
use http::{HeaderMap, Request, StatusCode};
use serde_json::{json, Value};
use tower::ServiceExt as _;
pub const ISSUER: &str = "https://gunnar.mock.invalid";
/// What the fake saw.
#[derive(Default)]
pub struct Seen {
pub calls: Vec<(String, Call)>,
pub audits: Vec<Audit>,
pub forgotten: Vec<String>,
/// Agents whose key gunnar no longer knows.
pub gone: HashSet<String>,
pub enrolled: u64,
}
#[derive(Clone, Default)]
pub struct Fake(pub Arc<Mutex<Seen>>);
/// The agent: `<user>#<n>`.
fn user_of(agent: &str) -> &str {
agent.split('#').next().unwrap_or("")
}
impl Backend for Fake {
type Agent = String;
fn passkey_options<'a>(&'a self, _client: &'a str) -> BoxFuture<'a, Result<Value, Refusal>> {
Box::pin(async {
Ok(
json!({ "ceremony_id": "ceremony-1", "publicKey": { "challenge": "AAAA", "userVerification": "required", "allowCredentials": [] } }),
)
})
}
fn passkey_finish<'a>(
&'a self,
touch: &'a Value,
_expires_unix_ms: i64,
) -> BoxFuture<'a, Result<Enrolled<String>, Refusal>> {
Box::pin(async move {
let user = match touch.get("id").and_then(Value::as_str) {
Some("cred-kim") => "kim",
Some("cred-eve") => "eve",
_ => {
return Err(Refusal {
status: StatusCode::UNAUTHORIZED,
message: "Gunnar did not accept that passkey.".into(),
})
}
};
let mut seen = self.0.lock().unwrap();
seen.enrolled += 1;
let agent = format!("{user}#{}", seen.enrolled);
Ok(Enrolled {
agent: agent.clone(),
user: user.into(),
fingerprint: format!("SHA256:agent{}", seen.enrolled),
})
})
}
fn call<'a>(
&'a self,
agent: &'a String,
user: &'a str,
call: &'a Call,
) -> BoxFuture<'a, Result<Answer, Failure>> {
Box::pin(async move {
let mut seen = self.0.lock().unwrap();
assert_eq!(user_of(agent), user, "a call is made as its own person");
if seen.gone.contains(agent) {
return Err(Failure::KeyGone);
}
seen.calls.push((agent.clone(), call.clone()));
// kim/secret is kim's and private; pub/open is public.
if let Some(repo) = call.repo() {
let visible = repo.namespace == "pub" || (repo.namespace == "kim" && user == "kim");
if !visible || !(repo.name == "secret" || repo.name == "open") {
return Err(Failure::Hidden);
}
}
Ok(match call {
Call::ListRepositories { .. } => {
let mut rows = vec![Repo {
path: "pub/open".into(),
visibility: "public".into(),
default_branch: "main".into(),
description: "A public one.".into(),
}];
if user == "kim" {
rows.push(Repo {
path: "kim/secret".into(),
visibility: "private".into(),
default_branch: "main".into(),
description: "Kim's own.".into(),
});
}
Answer::Repos(rows)
}
Call::GetIssue { number, .. } => Answer::Item(Item {
number: *number,
state: "open".into(),
title: "A bug".into(),
author: "kim".into(),
body: "Reported by kim@mock.invalid".into(),
facts: vec![],
}),
Call::CommentOnIssue { number, .. } => {
Answer::Done(format!("Commented on issue #{number}."))
}
_ => Answer::Done("ok".into()),
})
})
}
fn forget<'a>(&'a self, agent: &'a String) -> BoxFuture<'a, ()> {
Box::pin(async move { self.0.lock().unwrap().forgotten.push(agent.clone()) })
}
fn client_of(&self, parts: &http::request::Parts) -> String {
parts
.headers
.get("x-test-client")
.and_then(|v| v.to_str().ok())
.unwrap_or("10.0.0.1")
.to_owned()
}
fn audit(&self, record: &Audit) {
self.0.lock().unwrap().audits.push(record.clone());
}
}
pub struct Harness {
pub app: Router,
pub fake: Fake,
pub mcp: Arc<Mcp<Fake>>,
}
pub struct Answered {
pub status: StatusCode,
pub headers: HeaderMap,
pub body: String,
}
impl Answered {
pub fn json(&self) -> Value {
serde_json::from_str(&self.body).unwrap_or_else(|e| panic!("not JSON ({e}): {}", self.body))
}
}
/// A verifier and its S256 challenge.
pub const VERIFIER: &str = "a-test-verifier-that-is-long-enough-for-rfc-7636-yes";
pub struct Connected {
pub client_id: String,
pub client_secret: Option<String>,
pub access: String,
pub refresh: String,
pub scope: String,
}
impl Harness {
pub fn new() -> Harness {
Harness::with(Config::new(ISSUER))
}
pub fn with(config: Config) -> Harness {
let fake = Fake::default();
let mcp = Mcp::new(fake.clone(), config);
Harness {
app: mcp.router(),
fake,
mcp,
}
}
pub 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(),
}
}
pub async fn get(&self, uri: &str) -> Answered {
self.send(Request::get(uri).body(Body::empty()).unwrap())
.await
}
pub async fn post_json(&self, uri: &str, body: &Value) -> Answered {
self.send(
Request::post(uri)
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap(),
)
.await
}
pub 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
}
pub async fn register(&self, body: &Value) -> Answered {
self.post_json("/oauth/register", body).await
}
/// The authorize page for `client_id`; answers the waiting request's id.
pub async fn authorize(&self, client_id: &str, redirect: &str, scope: &str) -> Answered {
let uri = format!(
"/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&code_challenge={}&code_challenge_method=S256&state=st-1&scope={}&resource={}",
enc(client_id),
enc(redirect),
gunnar_mcp::oauth::s256(VERIFIER),
enc(scope),
enc(&format!("{ISSUER}/mcp"))
);
self.get(&uri).await
}
/// Register as Claude Code, touch `cred`'s passkey, exchange the code.
pub async fn connect(&self, cred: &str, scope: &str, tick_write: bool) -> Connected {
let fixture: Value =
serde_json::from_str(include_str!("../fixtures/claude-code-dcr.json")).unwrap();
self.connect_as(&fixture, cred, scope, tick_write).await
}
pub async fn connect_as(
&self,
registration: &Value,
cred: &str,
scope: &str,
tick_write: bool,
) -> Connected {
let reg = self.register(registration).await;
assert_eq!(reg.status, StatusCode::CREATED, "{}", reg.body);
let reg = reg.json();
let client_id = reg["client_id"].as_str().unwrap().to_owned();
let client_secret = reg["client_secret"].as_str().map(str::to_owned);
let redirect = registration["redirect_uris"][0]
.as_str()
.unwrap()
.to_owned();
let page = self.authorize(&client_id, &redirect, scope).await;
assert_eq!(page.status, StatusCode::OK, "{}", page.body);
let request = request_of(&page.body);
let code = self.touch(&request, cred, tick_write).await;
let mut form = vec![
("grant_type", "authorization_code"),
("code", code.as_str()),
("redirect_uri", redirect.as_str()),
("code_verifier", VERIFIER),
("client_id", client_id.as_str()),
];
if let Some(s) = client_secret.as_deref() {
form.push(("client_secret", s));
}
let token = self.post_form("/oauth/token", &form).await;
assert_eq!(token.status, StatusCode::OK, "{}", token.body);
let t = token.json();
Connected {
client_id,
client_secret,
access: t["access_token"].as_str().unwrap().to_owned(),
refresh: t["refresh_token"].as_str().unwrap().to_owned(),
scope: t["scope"].as_str().unwrap().to_owned(),
}
}
/// The passkey touch on the consent page; answers the code.
pub async fn touch(&self, request: &str, cred: &str, tick_write: bool) -> String {
let options = self
.post_json("/oauth/authorize/passkey", &json!({ "request": request }))
.await;
assert_eq!(options.status, StatusCode::OK, "{}", options.body);
let ceremony = options.json()["ceremony_id"].as_str().unwrap().to_owned();
let done = self
.post_json("/oauth/authorize/approve", &json!({ "request": request, "write": tick_write, "ceremony_id": ceremony, "id": cred, "response": {} }))
.await;
assert_eq!(done.status, StatusCode::OK, "{}", done.body);
let redirect = done.json()["redirect"].as_str().unwrap().to_owned();
assert!(redirect.contains("state=st-1"), "{redirect}");
assert!(
redirect.contains(&format!("iss={}", enc(ISSUER))),
"{redirect}"
);
query_value(&redirect, "code").expect("a code")
}
pub async fn rpc(&self, token: &str, method: &str, params: Value) -> Answered {
let body = json!({ "jsonrpc": "2.0", "id": 7, "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
}
pub async fn tool(&self, token: &str, name: &str, arguments: Value) -> Answered {
self.rpc(
token,
"tools/call",
json!({ "name": name, "arguments": arguments }),
)
.await
}
}
pub fn request_of(page: &str) -> String {
let at = page
.find("data-request=\"")
.expect("a waiting request on the page")
+ "data-request=\"".len();
page[at..].split('"').next().unwrap().to_owned()
}
pub 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)| dec(v))
})
}
pub 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 dec(v: &str) -> String {
let b = v.as_bytes();
let mut out = Vec::new();
let mut i = 0;
while i < b.len() {
if b[i] == b'%' && i + 3 <= b.len() {
out.push(u8::from_str_radix(&v[i + 1..i + 3], 16).unwrap());
i += 3;
} else {
out.push(b[i]);
i += 1;
}
}
String::from_utf8(out).unwrap()
}
/// The text of a `tools/call` result, and whether it is an error.
pub fn result_text(a: &Answered) -> (String, bool) {
let v = a.json();
let r = &v["result"];
(
r["content"][0]["text"]
.as_str()
.unwrap_or_else(|| panic!("no text: {v}"))
.to_owned(),
r["isError"].as_bool().unwrap_or(false),
)
}