//! MCP over HTTP against a reference PDS: an assistant registers, its user //! signs in with their account, and it works as them with the token it gets. #[path = "../../knotless/tests/common/mod.rs"] mod common; use common::*; use knotless_mcp::http::{Event, Limits, Mcp, router}; use reqwest::blocking::{Client, Response}; use serde_json::{Value, json}; use std::collections::HashMap; use std::io::{BufRead, BufReader, Write}; use std::sync::{Arc, Mutex, mpsc}; struct Server { /// Where tests reach it, and where PDSes and assistants do. base: String, url: String, _rt: tokio::runtime::Runtime, _proxy: Option, /// What it told its operator's counts, as `name subject ok did`. events: Arc>>, } /// The MCP server, turning away the accounts `refused` names and keeping /// what it needs in `s`'s directories. fn server(s: &Sandbox, refused: Vec) -> Server { serve(|url: &str| { let mcp = Mcp::new(&ctx(s), url, Limits::default()).unwrap(); mcp.refuse(Arc::new(move |did| refused.iter().any(|r| r == did))); mcp }) } /// The MCP server `mcp` makes for its public URL, served. fn serve(mcp: impl FnOnce(&str) -> Arc) -> Server { let rt = tokio::runtime::Runtime::new().unwrap(); let listener = rt .block_on(tokio::net::TcpListener::bind("127.0.0.1:0")) .unwrap(); let port = listener.local_addr().unwrap().port(); // PDSes reach their clients over https. let proxy = tls_proxy(port); let mcp = mcp(&format!("{}/", proxy.url)); let events = Arc::new(Mutex::new(Vec::new())); let told = events.clone(); mcp.observe(Arc::new(move |e: Event| { let did = e.did.unwrap_or("-"); let line = format!("{} {} {} {did}", e.name, e.subject, e.ok); told.lock().unwrap().push(line); })); rt.spawn(async move { axum::serve(listener, router(mcp)).await.unwrap() }); Server { base: format!("http://127.0.0.1:{port}"), url: proxy.url.clone(), _rt: rt, _proxy: Some(proxy), events, } } fn client() -> Client { Client::builder() .redirect(reqwest::redirect::Policy::none()) .build() .unwrap() } fn location(res: &Response) -> String { res.headers()["location"].to_str().unwrap().to_string() } fn query(url: &str) -> HashMap { reqwest::Url::parse(url) .unwrap() .query_pairs() .into_owned() .collect() } /// An assistant's loopback redirect, and the query the browser brings it. fn assistant() -> (String, mpsc::Receiver>) { let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let uri = format!( "http://127.0.0.1:{}/back", listener.local_addr().unwrap().port() ); let (tx, rx) = mpsc::channel(); std::thread::spawn(move || { for stream in listener.incoming() { let mut stream = stream.unwrap(); let mut line = String::new(); BufReader::new(&stream).read_line(&mut line).unwrap(); let target = line.split(' ').nth(1).unwrap_or_default(); stream .write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\nconnection: close\r\n\r\n") .unwrap(); let _ = tx.send(query(&format!("http://127.0.0.1{target}"))); } }); (uri, rx) } /// Approves (or rejects) the sign-in at `url` as `account`, in a browser. fn approve(account: &Account, extra: &str, url: &str) { let ok = std::process::Command::new("sh") .arg("-c") .arg(format!("{} '{url}'", browser(account, extra))) .status() .unwrap() .success(); assert!(ok); } fn register(s: &Server, body: Value) -> (u16, Value) { let res = client() .post(format!("{}/mcp/register", s.base)) .json(&body) .send() .unwrap(); (res.status().as_u16(), res.json().unwrap()) } fn token(s: &Server, form: &[(&str, &str)]) -> (u16, Value) { let res = client() .post(format!("{}/mcp/token", s.base)) .form(form) .send() .unwrap(); (res.status().as_u16(), res.json().unwrap()) } /// The sign-in form's signed request, from its page. fn ask(page: &str) -> String { let at = page.find("name=ask value=\"").unwrap() + 16; page[at..].split('"').next().unwrap().to_string() } /// The authorization page for `params`. fn authorize(s: &Server, params: &[(&str, &str)]) -> Response { client() .get(format!("{}/mcp/authorize", s.base)) .query(params) .send() .unwrap() } fn sign_in(s: &Server, ask: &str, handle: &str) -> Response { client() .post(format!("{}/mcp/authorize", s.base)) .form(&[("ask", ask), ("handle", handle)]) .send() .unwrap() } /// A JSON-RPC call to `/mcp` with `bearer`. fn rpc(s: &Server, bearer: Option<&str>, method: &str, params: Value) -> (u16, Value) { let mut req = client() .post(format!("{}/mcp", s.base)) .header("accept", "application/json, text/event-stream") .json(&json!({"jsonrpc": "2.0", "id": 1, "method": method, "params": params})); if let Some(t) = bearer { req = req.bearer_auth(t); } let res = req.send().unwrap(); let status = res.status().as_u16(); (status, res.json().unwrap_or(Value::Null)) } #[test] fn an_assistant_signs_in_and_works_as_its_user() { let s = Sandbox::new(); let allowed = account(); let other = account(); let srv = server(&s, vec![other.did.clone()]); // Discovery, from the 401 on. let (status, _) = rpc(&srv, None, "tools/list", json!({})); assert_eq!(status, 401); let res = client().post(format!("{}/mcp", srv.base)).send().unwrap(); let challenge = res.headers()["www-authenticate"].to_str().unwrap(); assert!( challenge.contains(&format!( "{}/.well-known/oauth-protected-resource/mcp", srv.url )), "{challenge}" ); for path in ["oauth-protected-resource", "oauth-protected-resource/mcp"] { let meta: Value = client() .get(format!("{}/.well-known/{path}", srv.base)) .send() .unwrap() .json() .unwrap(); assert_eq!(meta["resource"], format!("{}/mcp", srv.url)); assert_eq!(meta["authorization_servers"][0], srv.url); } let meta: Value = client() .get(format!( "{}/.well-known/oauth-authorization-server", srv.base )) .send() .unwrap() .json() .unwrap(); assert_eq!(meta["token_endpoint"], format!("{}/mcp/token", srv.url)); // Registration: https, or http on the assistant's own machine. let (redirect, back) = assistant(); for bad in [ json!({}), json!({"redirect_uris": ["http://elsewhere.test/back"]}), json!({"redirect_uris": ["https://ok.test/back", "https://ok.test/#frag"]}), json!({"redirect_uris": ["not a url"]}), ] { let (status, body) = register(&srv, bad); assert_eq!(status, 400); assert_eq!(body["error"], "invalid_redirect_uri"); } let (status, reg) = register( &srv, json!({ "redirect_uris": [redirect], "client_name": "Claude ", "token_endpoint_auth_method": "client_secret_post", }), ); assert_eq!(status, 201, "{reg}"); assert_eq!(reg["token_endpoint_auth_method"], "client_secret_post"); let id = reg["client_id"].as_str().unwrap().to_string(); let secret = reg["client_secret"].as_str().unwrap().to_string(); // What the authorization page refuses: to the user when it cannot // trust where to send them back, else to the assistant. let verifier = "a-verifier-long-enough-for-pkce-0123456789"; let challenge = knotless::oauth::sha256(verifier); let mut params = vec![ ("client_id", id.as_str()), ("response_type", "code"), ("code_challenge", challenge.as_str()), ("code_challenge_method", "S256"), ("state", "st"), ]; assert_eq!(authorize(&srv, &[("client_id", "nope")]).status(), 400); let res = authorize( &srv, &[("client_id", &id), ("redirect_uri", "https://x.test/")], ); assert_eq!(res.status(), 400); let refused = |extra: (&str, &str)| { let mut p = params.clone(); p.retain(|(k, _)| *k != extra.0); p.push(extra); let res = authorize(&srv, &p); assert_eq!(res.status(), 303); let q = query(&location(&res)); assert_eq!(q["state"], "st"); assert_eq!(q["iss"], srv.url); q["error"].clone() }; assert_eq!( refused(("response_type", "token")), "unsupported_response_type" ); assert_eq!( refused(("code_challenge_method", "plain")), "invalid_request" ); assert_eq!( refused(("resource", "https://elsewhere.test/mcp")), "invalid_target" ); let resource = format!("{}/mcp/", srv.url); params.push(("resource", &resource)); // The user says who they are; one turned away goes no further. let page = authorize(&srv, ¶ms).text().unwrap(); assert!( page.contains("Connect Claude <b> to knotless"), "{page}" ); let signed = ask(&page); assert_eq!(sign_in(&srv, "forged", &allowed.handle).status(), 400); let res = sign_in(&srv, &signed, "nobody.invalid"); assert_eq!(res.status(), 400); assert!( res.text() .unwrap() .contains("No account answers to nobody.invalid") ); let res = sign_in(&srv, &signed, &other.handle); assert_eq!(res.status(), 403); // A sign-in the user walks away from waits, among others. assert_eq!(sign_in(&srv, &signed, &allowed.handle).status(), 303); // A refusal at the PDS goes back to the assistant. let res = sign_in(&srv, &signed, &format!("@{}", allowed.handle)); assert_eq!(res.status(), 303); approve(&allowed, "--reject", &location(&res)); let q = back.recv().unwrap(); assert_eq!(q["error"], "access_denied"); assert_eq!(q["state"], "st"); // Approved: the assistant gets a code, and trades it for tokens. let res = sign_in(&srv, &signed, &allowed.handle); approve(&allowed, "", &location(&res)); let q = back.recv().unwrap(); assert_eq!(q["state"], "st"); assert_eq!(q["iss"], srv.url); let code = q["code"].clone(); let form = |extra: &[(&'static str, &str)]| { let mut f: Vec<(&str, String)> = vec![ ("grant_type", "authorization_code".into()), ("client_id", id.clone()), ("client_secret", secret.clone()), ("code", code.clone()), ("code_verifier", verifier.into()), ("redirect_uri", redirect.clone()), ]; for (k, v) in extra { f.retain(|(key, _)| key != k); f.push((k, v.to_string())); } f }; let send = |f: Vec<(&str, String)>| { let f: Vec<(&str, &str)> = f.iter().map(|(k, v)| (*k, v.as_str())).collect(); token(&srv, &f) }; let (status, body) = send(form(&[("client_secret", "bm9wZQ")])); assert_eq!( (status, body["error"].as_str()), (401, Some("invalid_client")) ); let (status, _) = send(form(&[("client_id", "nope")])); assert_eq!(status, 401); let (status, body) = send(form(&[("grant_type", "password")])); assert_eq!( (status, body["error"].as_str()), (400, Some("unsupported_grant_type")) ); let (status, tokens) = send(form(&[])); assert_eq!(status, 200, "{tokens}"); assert_eq!(tokens["token_type"], "Bearer"); // A code is good once. let (status, body) = send(form(&[])); assert_eq!( (status, body["error"].as_str()), (400, Some("invalid_grant")) ); let access = tokens["access_token"].as_str().unwrap().to_string(); let refresh = tokens["refresh_token"].as_str().unwrap().to_string(); // The tools, as the user. let (status, init) = rpc( &srv, Some(&access), "initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "t", "version": "1"}}), ); assert_eq!(status, 200, "{init}"); assert!( init["result"]["instructions"] .as_str() .unwrap() .contains("handle/name") ); assert!(init["result"]["capabilities"]["resources"].is_object()); let (_, list) = rpc(&srv, Some(&access), "tools/list", json!({})); assert!(list.to_string().contains("repo_create"), "{list}"); let (_, made) = rpc( &srv, Some(&access), "tools/call", json!({"name": "repo_create", "arguments": {"name": "over-http"}}), ); let text = made["result"]["content"][0]["text"].as_str().unwrap(); assert!( text.contains(&format!("{}/over-http", allowed.handle)), "{made}" ); // A newer client names the tool in headers too, which are checked // against its schema. let res = client() .post(format!("{}/mcp", srv.base)) .bearer_auth(&access) .header("accept", "application/json, text/event-stream") .header("mcp-protocol-version", "2026-07-28") .header("mcp-method", "tools/call") .header("mcp-name", "repo_list") .json( &json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "repo_list", "arguments": {"handle": allowed.handle}, "_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": {}, "io.modelcontextprotocol/clientInfo": {"name": "t", "version": "1"}, }, }}), ) .send() .unwrap(); let listed = res.text().unwrap(); assert!(listed.contains("over-http"), "{listed}"); // A tool that fails, as its result says. let (_, failed) = rpc( &srv, Some(&access), "tools/call", json!({"name": "repo_read", "arguments": {"repo": "!", "path": "a"}}), ); assert_eq!(failed["result"]["isError"], true, "{failed}"); // A call no tool takes, as an error. let (_, none) = rpc( &srv, Some(&access), "tools/call", json!({"name": "no_such_tool", "arguments": {}}), ); assert!(none["error"].is_object(), "{none}"); // Resources, read as the user too. let (_, templates) = rpc(&srv, Some(&access), "resources/templates/list", json!({})); let templates = templates["result"]["resourceTemplates"].as_array().unwrap(); assert_eq!(templates.len(), 6, "{templates:?}"); let repo = format!("{}/over-http", allowed.handle); let commit = json!({"name": "repo_commit", "arguments": {"repo": repo, "branch": "main", "message": "m", "changes": [{"path": "a.md", "content": "over\n"}]}}); let (_, committed) = rpc(&srv, Some(&access), "tools/call", commit); assert_ne!(committed["result"]["isError"], true, "{committed}"); let file = format!("knotless://{repo}/file/main/a.md"); let (_, read) = rpc(&srv, Some(&access), "resources/read", json!({"uri": file})); assert_eq!(read["result"]["contents"][0]["text"], "over\n", "{read}"); let (_, unknown) = rpc( &srv, Some(&access), "resources/read", json!({"uri": "knotless://nowhere"}), ); assert!( unknown["error"]["message"] .as_str() .unwrap() .contains("expected one of") ); // The operator's counts were told of the sign-ins, the tool calls and // the resources read, each read named for the tool that answers it. let me = &allowed.did; let told = srv.events.lock().unwrap().clone(); let tools: Vec<&String> = told.iter().filter(|e| e.starts_with("mcp.tool")).collect(); assert_eq!( tools, [ &format!("mcp.tool repo_create true {me}"), &format!("mcp.tool repo_list true {me}"), &format!("mcp.tool repo_read false {me}"), &format!("mcp.tool no_such_tool false {me}"), &format!("mcp.tool repo_commit true {me}"), ] ); let reads: Vec<&String> = told .iter() .filter(|e| e.starts_with("mcp.resource")) .collect(); assert_eq!( reads, [ &format!("mcp.resource repo_read true {me}"), &format!("mcp.resource false {me}"), ] ); assert!( told.contains(&"mcp.signin false -".to_string()), "{told:?}" ); assert!(told.contains(&format!("mcp.signin true {me}")), "{told:?}"); // A refresh token is the client's alone, and makes new tokens. let (status, _) = token( &srv, &[ ("grant_type", "refresh_token"), ("client_id", &id), ("client_secret", &secret), ("refresh_token", &access), ], ); assert_eq!(status, 400); let (status, fresh) = token( &srv, &[ ("grant_type", "refresh_token"), ("client_id", &id), ("client_secret", &secret), ("refresh_token", &refresh), ], ); assert_eq!(status, 200, "{fresh}"); // A public client proves nothing but its PKCE; a code is for the // client it was issued to, with the verifier it was asked with. let (_, public) = register(&srv, json!({"redirect_uris": [redirect]})); assert_eq!(public["token_endpoint_auth_method"], "none"); assert!(public.get("client_secret").is_none()); let pid = public["client_id"].as_str().unwrap().to_string(); let mut p = params.clone(); p[0] = ("client_id", &pid); let page = authorize(&srv, &p).text().unwrap(); assert!(page.contains("Connect An assistant to knotless")); // A code the assistant never trades waits, among others. let res = sign_in(&srv, &ask(&page), &allowed.handle); approve(&allowed, "", &location(&res)); back.recv().unwrap(); let res = sign_in(&srv, &ask(&page), &allowed.handle); approve(&allowed, "", &location(&res)); let code = back.recv().unwrap()["code"].clone(); let (status, _) = token( &srv, &[ ("grant_type", "authorization_code"), ("client_id", &pid), ("code", &code), ("code_verifier", "wrong"), ], ); assert_eq!(status, 400); // Nothing waits for a callback nobody started. let res = client() .get(format!("{}/mcp/oauth/callback?state=nope", srv.base)) .send() .unwrap(); assert_eq!(res.status(), 400); // Turned away, its tokens stop working. let off = server(&s, vec![allowed.did.clone()]); assert_eq!(rpc(&off, Some(&access), "tools/list", json!({})).0, 401); let (status, body) = token( &off, &[ ("grant_type", "refresh_token"), ("client_id", &id), ("client_secret", &secret), ("refresh_token", &refresh), ], ); assert_eq!(status, 400); assert!( body["error_description"] .as_str() .unwrap() .contains("no longer") ); // The login it keeps with the PDS is refreshed as it runs out, and // waits out a PDS that cannot be reached. let dir = allowed.did.replace(':', "_"); let kept = ctx(&s) .env .config_dir .join("mcp") .join(&dir) .join("sessions") .join(format!("{dir}.json")); let change = |what: &dyn Fn(&mut Value)| { let mut session: Value = serde_json::from_slice(&std::fs::read(&kept).unwrap()).unwrap(); session["oauth"]["expiresAt"] = 0.into(); what(&mut session); std::fs::write(&kept, session.to_string()).unwrap(); }; change(&|_| {}); assert_eq!(rpc(&srv, Some(&access), "tools/list", json!({})).0, 200); let read: Value = serde_json::from_slice(&std::fs::read(&kept).unwrap()).unwrap(); assert_ne!(read["oauth"]["expiresAt"], 0, "{read}"); let endpoint = read["oauth"]["tokenEndpoint"].clone(); change(&|s| s["oauth"]["tokenEndpoint"] = "http://127.0.0.1:1/token".into()); assert_eq!(rpc(&srv, Some(&access), "tools/list", json!({})).0, 200); assert!(kept.exists()); // Once the PDS turns it down the login is over: the assistant is sent // to sign in again, rather than working as no one. change(&|s| { s["oauth"]["tokenEndpoint"] = endpoint.clone(); s["refreshJwt"] = format!("ref-{}", "0".repeat(64)).into(); }); let (status, _) = rpc(&srv, Some(&access), "tools/list", json!({})); assert_eq!(status, 401); assert!(!kept.exists()); let (status, body) = token( &srv, &[ ("grant_type", "refresh_token"), ("client_id", &id), ("client_secret", &secret), ("refresh_token", &refresh), ], ); assert_eq!( (status, body["error"].as_str()), (400, Some("invalid_grant")) ); } #[test] fn a_pds_that_will_not_take_the_sign_in() { // A server whose public URL no PDS can reach is no client of theirs. let s = Sandbox::new(); let allowed = account(); let rt = tokio::runtime::Runtime::new().unwrap(); let listener = rt .block_on(tokio::net::TcpListener::bind("127.0.0.1:0")) .unwrap(); let base = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port()); let mcp = Mcp::new(&ctx(&s), "https://nowhere.invalid", Limits::default()).unwrap(); rt.spawn(async move { axum::serve(listener, router(mcp)).await.unwrap() }); let srv = Server { base, url: String::new(), _rt: rt, _proxy: None, events: Arc::default(), }; let meta: Value = client() .get(format!("{}/mcp/oauth-client-metadata.json", srv.base)) .send() .unwrap() .json() .unwrap(); assert_eq!( meta["client_id"], "https://nowhere.invalid/mcp/oauth-client-metadata.json" ); let (_, reg) = register(&srv, json!({"redirect_uris": ["https://claude.test/back"]})); let id = reg["client_id"].as_str().unwrap(); let page = authorize( &srv, &[ ("client_id", id), ("response_type", "code"), ("code_challenge", "c"), ("code_challenge_method", "S256"), ], ) .text() .unwrap(); let res = sign_in(&srv, &ask(&page), &allowed.handle); assert_eq!(res.status(), 502); assert!(res.text().unwrap().contains("didn't take the sign-in")); } #[test] fn open_to_anyone_but_those_turned_away() { let s = Sandbox::new(); let anyone = account(); let away = account(); let refused = away.did.clone(); let srv = serve(|url| { let limits = Limits { signing_in: 1, running: 8, waiting: 2, per_minute: 3, }; let mcp = Mcp::new(&ctx(&s), url, limits).unwrap(); mcp.refuse(Arc::new(move |did| did == refused)); mcp }); let (redirect, back) = assistant(); let (_, reg) = register(&srv, json!({"redirect_uris": [redirect]})); let id = reg["client_id"].as_str().unwrap().to_string(); let verifier = "a-verifier-long-enough-for-pkce-0123456789"; let challenge = knotless::oauth::sha256(verifier); let page = authorize( &srv, &[ ("client_id", &id), ("response_type", "code"), ("code_challenge", &challenge), ("code_challenge_method", "S256"), ], ) .text() .unwrap(); let signed = ask(&page); // Turned away. let res = sign_in(&srv, &signed, &away.handle); assert_eq!(res.status(), 403); assert!(res.text().unwrap().contains("taken down here")); // Any other account goes on, as many at once as the server lets. let res = sign_in(&srv, &signed, &anyone.handle); assert_eq!(res.status(), 303); let crowded = sign_in(&srv, &signed, &anyone.handle); assert_eq!(crowded.status(), 503); assert!(crowded.text().unwrap().contains("Too many sign-ins")); approve(&anyone, "", &location(&res)); let code = back.recv().unwrap()["code"].clone(); let (status, tokens) = token( &srv, &[ ("grant_type", "authorization_code"), ("client_id", &id), ("code", &code), ("code_verifier", verifier), ], ); assert_eq!(status, 200, "{tokens}"); let access = tokens["access_token"].as_str().unwrap(); // A public repository is read from the server's own mirror, not one // of the account's. let create = json!({"name": "repo_create", "arguments": {"name": "shared"}}); let (_, made) = rpc(&srv, Some(access), "tools/call", create); assert_ne!(made["result"]["isError"], true, "{made}"); let repo = format!("{}/shared", anyone.handle); let tree = json!({"name": "repo_tree", "arguments": {"repo": repo}}); rpc(&srv, Some(access), "tools/call", tree); let cache = ctx(&s).env.cache_dir; let own = anyone.did.replace(':', "_"); assert!(cache.join("mirrors").join(&own).exists()); assert!(!cache.join("mcp").join(&own).join("mirrors").exists()); // So many tool calls a minute, and then a result that says to wait. let list = json!({"name": "repo_list", "arguments": {"handle": anyone.handle}}); let (_, listed) = rpc(&srv, Some(access), "tools/call", list.clone()); assert_ne!(listed["result"]["isError"], true, "{listed}"); let (_, waits) = rpc(&srv, Some(access), "tools/call", list); assert_eq!(waits["result"]["isError"], true, "{waits}"); let said = waits["result"]["content"][0]["text"].as_str().unwrap(); assert!(said.contains("this minute"), "{said}"); // A resource read is a call as a tool's is, and waits the same. let file = json!({"uri": format!("knotless://{repo}/file/main/a")}); let (_, refused) = rpc(&srv, Some(access), "resources/read", file); let said = refused["error"]["message"].as_str().unwrap(); assert!(said.contains("this minute"), "{said}"); }