| //! `amem tool-server` (PLAN M19): the tools of a root served over MCP's streamable | |
| //! HTTP, so launchers elsewhere call them instead of carrying the components. The same | |
| //! WASI component runs here as in a launcher, under this root's grant and with the | |
| //! credential this host holds, or, for a tool this project writes and trusts, fetch | |
| //! today, the binary's own native code behind the same gate when no component is | |
| //! installed under the name ([`dm_taps::fetch`]); what differs is who decides each | |
| //! request it makes. | |
| //! There is no calling session here, so the call names the hosts its session may | |
| //! reach, and a request goes only to a host both that list and this root's grant name | |
| //! (`dm_taps::tool::HostGate`): a caller narrows what the server may reach, never | |
| //! widens it. The answer carries every URL the tool reached, for the caller's memory | |
| //! server to decide by its policy before it keeps anything. | |
| //! | |
| //! Off loopback the server wants a bearer token, so it is never an open proxy; in the | |
| //! Foundation's deployment it sits behind the gateway's MCP registry. | |
| use crate::mcp_meta::{ | |
| decode, named_by, supported, CLIENT_CAPABILITIES, HEADER_MISMATCH, LEGACY, METHOD_HEADER, | |
| MODERN, NAME_HEADER, PROTOCOL_VERSION, SERVER_INFO, UNSUPPORTED_VERSION, VERSION_HEADER, | |
| }; | |
| use crate::Fallible; | |
| use axum::extract::State; | |
| use axum::http::{HeaderMap, StatusCode}; | |
| use axum::response::{IntoResponse, Response}; | |
| use axum::routing::post; | |
| use axum::{Json, Router}; | |
| use dm_policy::{Grants, TapGrant}; | |
| use serde_json::{json, Value}; | |
| use std::path::{Path, PathBuf}; | |
| use std::sync::Arc; | |
| /// One call may take this long, and its component grow to this much memory. | |
| const LIMITS: dm_taps::Limits = dm_taps::Limits { | |
| wall: std::time::Duration::from_secs(60), | |
| memory: 128 << 20, | |
| }; | |
| struct Served { | |
| root: PathBuf, | |
| grants: Grants, | |
| token: Option<String>, | |
| } | |
| /// The component installed under the root as `name`, if one is. | |
| fn installed(root: &Path, name: &str) -> Option<PathBuf> { | |
| crate::taps::component_path(root, name) | |
| .ok() | |
| .filter(|p| p.is_file()) | |
| } | |
| /// The tools this root serves: granted as tools, run here (not hosted on, and not the | |
| /// owner's browser's), and either installed as a component or native to this binary. | |
| fn served(s: &Served) -> Vec<(&String, &TapGrant)> { | |
| s.grants | |
| .taps | |
| .iter() | |
| .filter(|(name, g)| { | |
| g.tool | |
| && g.hosted.is_none() | |
| && !g.browser | |
| && (installed(&s.root, name).is_some() || dm_taps::fetch::is_native(name)) | |
| }) | |
| .collect() | |
| } | |
| /// Which revision a request is served under. One that names its version in `_meta`, | |
| /// or asks `server/discover`, which only 2026-07-28 has, or mirrors a version newer | |
| /// than the handshake's in its header, is served statelessly by 2026-07-28; anything | |
| /// else as the revisions with `initialize` answer it, as this server always has. | |
| #[derive(Clone, Copy, PartialEq, Eq)] | |
| enum Era { | |
| Handshake, | |
| Stateless, | |
| } | |
| fn era(method: &str, params: &Value, headers: &HeaderMap) -> Era { | |
| if method == "initialize" { | |
| return Era::Handshake; | |
| } | |
| let newer = match header(headers, VERSION_HEADER) { | |
| Ok(v) => v.is_some_and(|v| !LEGACY.contains(&v.as_str())), | |
| Err(_) => true, | |
| }; | |
| if params["_meta"].get(PROTOCOL_VERSION).is_some() || method == "server/discover" || newer { | |
| Era::Stateless | |
| } else { | |
| Era::Handshake | |
| } | |
| } | |
| /// The one value of header `name`, if it was sent; more than one, or one that is not | |
| /// text, is an error. | |
| fn header(headers: &HeaderMap, name: &str) -> Result<Option<String>, Refusal> { | |
| let mut all = headers.get_all(name).iter(); | |
| let Some(first) = all.next() else { | |
| return Ok(None); | |
| }; | |
| if all.next().is_some() { | |
| return Err(Refusal::mismatch(format!("{name} is sent more than once"))); | |
| } | |
| first | |
| .to_str() | |
| .map(|v| Some(v.to_string())) | |
| .map_err(|_| Refusal::mismatch(format!("{name} is not text"))) | |
| } | |
| /// A request refused, before it is served. | |
| struct Refusal { | |
| code: i64, | |
| message: String, | |
| data: Option<Value>, | |
| } | |
| impl Refusal { | |
| fn mismatch(message: String) -> Refusal { | |
| Refusal { | |
| code: HEADER_MISMATCH, | |
| message, | |
| data: None, | |
| } | |
| } | |
| fn invalid(message: String) -> Refusal { | |
| Refusal { | |
| code: -32602, | |
| message, | |
| data: None, | |
| } | |
| } | |
| /// A request without a handshake that names a version other than the one spoken | |
| /// that way. The versions it lists are the ones such a request may name; the older | |
| /// ones are reached through `initialize`, which the message says. | |
| fn unsupported(requested: &str) -> Refusal { | |
| Refusal { | |
| code: UNSUPPORTED_VERSION, | |
| message: format!( | |
| "Unsupported protocol version {requested}: a request without a handshake speaks {MODERN}; {} are reached through initialize", | |
| LEGACY.join(", ") | |
| ), | |
| data: Some(json!({"supported": [MODERN], "requested": requested})), | |
| } | |
| } | |
| /// The JSON-RPC error. A stateless request learns what kind from the status too: | |
| /// malformed, at odds with its headers, or of a version not spoken is a 400, a | |
| /// method unknown a 404. After a handshake every error went with a 200 as before, | |
| /// but headers at odds with the body and a body that is not one request, which are | |
| /// a 400 whoever sends them. | |
| fn answer(self, era: Era, id: &Value) -> Response { | |
| let mut error = json!({"code": self.code, "message": self.message}); | |
| if let Some(data) = self.data { | |
| error["data"] = data; | |
| } | |
| let status = match (era, self.code) { | |
| (_, HEADER_MISMATCH | -32600) | |
| | (Era::Stateless, -32602 | -32021 | UNSUPPORTED_VERSION) => StatusCode::BAD_REQUEST, | |
| (Era::Stateless, -32601) => StatusCode::NOT_FOUND, | |
| _ => StatusCode::OK, | |
| }; | |
| ( | |
| status, | |
| Json(json!({"jsonrpc": "2.0", "id": id, "error": error})), | |
| ) | |
| .into_response() | |
| } | |
| } | |
| fn server_info() -> Value { | |
| json!({"name": "amem tool-server", "version": env!("CARGO_PKG_VERSION")}) | |
| } | |
| /// A result. To a stateless request it says it is complete and names this server, | |
| /// since no handshake did. | |
| fn answer(era: Era, id: &Value, mut result: Value) -> Response { | |
| if era == Era::Stateless { | |
| result["resultType"] = json!("complete"); | |
| result["_meta"] = json!({ SERVER_INFO: server_info() }); | |
| } | |
| Json(json!({"jsonrpc": "2.0", "id": id, "result": result})).into_response() | |
| } | |
| fn tool_error(message: &str) -> Value { | |
| json!({"isError": true, "content": [{"type": "text", "text": message}]}) | |
| } | |
| /// What 2026-07-28 asks of a request before it is served: a version this server speaks | |
| /// in `_meta`, mirrored in its header; the client's capabilities; the method, and the | |
| /// name a call names, mirrored in theirs. A header missing is refused as one at odds: | |
| /// that revision requires them, and something between may route on them. | |
| fn checked(method: &str, params: &Value, headers: &HeaderMap) -> Result<(), Refusal> { | |
| let meta = ¶ms["_meta"]; | |
| let sent = header(headers, VERSION_HEADER)?; | |
| let Some(version) = meta[PROTOCOL_VERSION].as_str() else { | |
| return Err(match sent.filter(|v| !supported().contains(&v.as_str())) { | |
| Some(v) => Refusal::unsupported(&v), | |
| None => Refusal::invalid(format!("the request's _meta names no {PROTOCOL_VERSION}")), | |
| }); | |
| }; | |
| if version != MODERN { | |
| return Err(Refusal::unsupported(version)); | |
| } | |
| match sent { | |
| Some(v) if v == version => {} | |
| Some(v) => { | |
| return Err(Refusal::mismatch(format!( | |
| "MCP-Protocol-Version {v} is not the {version} the body names" | |
| ))) | |
| } | |
| None => return Err(Refusal::mismatch("MCP-Protocol-Version is missing".into())), | |
| } | |
| if !meta[CLIENT_CAPABILITIES].is_object() { | |
| return Err(Refusal::invalid(format!( | |
| "the request's _meta carries no {CLIENT_CAPABILITIES}" | |
| ))); | |
| } | |
| if header(headers, METHOD_HEADER)?.is_none() { | |
| return Err(Refusal::mismatch("Mcp-Method is missing".into())); | |
| } | |
| if named_by(method).is_some() && header(headers, NAME_HEADER)?.is_none() { | |
| return Err(Refusal::mismatch("Mcp-Name is missing".into())); | |
| } | |
| Ok(()) | |
| } | |
| /// Whether the method and name headers, where sent, say what the body says. Whoever | |
| /// sends them, a request whose headers name another method or tool than its body is | |
| /// refused: whatever routed on the headers acted on a different request than this one. | |
| /// A client of the handshake may leave them out. The name is decoded before it is | |
| /// compared; the method is not, since 2026-07-28 allows the base64 sentinel in | |
| /// `Mcp-Name` and `Mcp-Param-*` only, and whatever routed on `Mcp-Method` read it as | |
| /// sent. | |
| fn mirrored(method: &str, params: &Value, headers: &HeaderMap) -> Result<(), Refusal> { | |
| if let Some(m) = header(headers, METHOD_HEADER)? { | |
| if m != method { | |
| return Err(Refusal::mismatch(format!( | |
| "Mcp-Method {m} is not the {method} the body names" | |
| ))); | |
| } | |
| } | |
| if let Some(sent) = header(headers, NAME_HEADER)? { | |
| let named = named_by(method).and_then(|key| params[key].as_str()); | |
| if named.is_none() || decode(&sent).as_deref() != named { | |
| return Err(Refusal::mismatch(format!( | |
| "Mcp-Name {sent} is not the {} the body names", | |
| named.unwrap_or("nothing") | |
| ))); | |
| } | |
| } | |
| Ok(()) | |
| } | |
| /// The answer to `initialize`: the version asked when it is one with a handshake this | |
| /// server speaks, else the newest of those, for the client to take or leave. | |
| fn initialized(params: &Value) -> Value { | |
| let asked = params["protocolVersion"].as_str().unwrap_or_default(); | |
| let version = LEGACY | |
| .iter() | |
| .find(|v| **v == asked) | |
| .copied() | |
| .unwrap_or(LEGACY[0]); | |
| json!({ | |
| "protocolVersion": version, | |
| "capabilities": {"tools": {}}, | |
| "serverInfo": server_info(), | |
| }) | |
| } | |
| /// The answer to `server/discover`. Nothing in it is promised fresh for any time, and | |
| /// with a token in front of the server none of it is for a shared cache. | |
| fn discovered() -> Value { | |
| json!({ | |
| "supportedVersions": supported(), | |
| "capabilities": {"tools": {}}, | |
| "ttlMs": 0, | |
| "cacheScope": "private", | |
| }) | |
| } | |
| /// The answer to `tools/list`. What is served follows what is installed under the root | |
| /// at the time of asking, so it is not promised fresh either. | |
| fn listed(s: &Served) -> Value { | |
| json!({ | |
| "tools": served(s).iter().map(|(name, _)| json!({ | |
| "name": name, | |
| "description": format!("The {name} tool, run under this server's grant; answers with the URLs it reached"), | |
| "inputSchema": { | |
| "type": "object", | |
| "properties": { | |
| "args": {"type": "object", "description": "the tool's own arguments"}, | |
| "config": {"description": "the caller's configuration for the tool"}, | |
| "hosts": {"type": "array", "items": {"type": "string"}, | |
| "description": "the hosts the calling session may reach"}, | |
| }, | |
| "required": ["args", "hosts"], | |
| }, | |
| })).collect::<Vec<_>>(), | |
| "ttlMs": 0, | |
| "cacheScope": "private", | |
| }) | |
| } | |
| async fn mcp(State(s): State<Arc<Served>>, headers: HeaderMap, body: String) -> Response { | |
| if let Some(t) = &s.token { | |
| let given = headers | |
| .get(axum::http::header::AUTHORIZATION) | |
| .and_then(|v| v.to_str().ok()) | |
| .and_then(|v| v.strip_prefix("Bearer ")); | |
| if given != Some(t.as_str()) { | |
| return (StatusCode::UNAUTHORIZED, "a bearer token is required").into_response(); | |
| } | |
| } | |
| let Ok(msg) = serde_json::from_str::<Value>(&body) else { | |
| let refused = Refusal { | |
| code: -32700, | |
| message: "not JSON".into(), | |
| data: None, | |
| }; | |
| return refused.answer(Era::Handshake, &Value::Null); | |
| }; | |
| if !msg.is_object() { | |
| // one request a POST: a batch, or JSON that is no request, is not one | |
| let refused = Refusal { | |
| code: -32600, | |
| message: "the body is not one JSON-RPC request".into(), | |
| data: None, | |
| }; | |
| return refused.answer(Era::Handshake, &Value::Null); | |
| } | |
| let id = msg.get("id").cloned().unwrap_or(Value::Null); | |
| let method = msg["method"].as_str().unwrap_or_default(); | |
| if msg.get("id").is_none() { | |
| // a notification: nothing to answer | |
| return StatusCode::ACCEPTED.into_response(); | |
| } | |
| let params = &msg["params"]; | |
| let era = era(method, params, &headers); | |
| let ready = match era { | |
| Era::Stateless => { | |
| mirrored(method, params, &headers).and_then(|()| checked(method, params, &headers)) | |
| } | |
| Era::Handshake => mirrored(method, params, &headers), | |
| }; | |
| if let Err(refused) = ready { | |
| return refused.answer(era, &id); | |
| } | |
| match method { | |
| "initialize" => answer(era, &id, initialized(params)), | |
| "server/discover" => answer(era, &id, discovered()), | |
| "tools/list" => answer(era, &id, listed(&s)), | |
| "tools/call" => answer(era, &id, call(&s, params).await), | |
| _ => Refusal { | |
| code: -32601, | |
| message: format!("no method {method}"), | |
| data: None, | |
| } | |
| .answer(era, &id), | |
| } | |
| } | |
| async fn call(s: &Served, params: &Value) -> Value { | |
| let name = params["name"].as_str().unwrap_or_default(); | |
| let Some((_, grant)) = served(s).into_iter().find(|(n, _)| n.as_str() == name) else { | |
| return tool_error(&format!("no tool {name} is served here")); | |
| }; | |
| let a = ¶ms["arguments"]; | |
| let Some(hosts) = a["hosts"].as_array() else { | |
| return tool_error("the call names no hosts its session may reach"); | |
| }; | |
| let text = |v: &Value| match v { | |
| Value::String(t) => t.clone(), | |
| Value::Null => String::new(), | |
| other => other.to_string(), | |
| }; | |
| let config = match &a["config"] { | |
| Value::Null => grant.config.clone().unwrap_or_else(|| "{}".into()), | |
| v => text(v), | |
| }; | |
| let args = text(&a["args"]); | |
| let mut granted = grant.hosts.clone(); | |
| if let Some(t) = &grant.trusted { | |
| granted.extend(t.hosts.iter().cloned()); | |
| } | |
| let gate = dm_taps::tool::HostGate { | |
| granted, | |
| caller: hosts | |
| .iter() | |
| .filter_map(|h| h.as_str().map(str::to_string)) | |
| .collect(), | |
| methods: grant.methods.clone(), | |
| loopback: grant.loopback, | |
| }; | |
| let credential = grant | |
| .credential | |
| .as_deref() | |
| .and_then(|var| crate::present(std::env::var(var).ok())); | |
| let c = dm_taps::tool::Call { | |
| tool: name, | |
| grant, | |
| credential: credential.as_deref(), | |
| config: &config, | |
| args: &args, | |
| }; | |
| let gate = Arc::new(gate); | |
| // a component installed under the name runs; without one, the binary's own fetch | |
| let done = match installed(&s.root, name) { | |
| Some(path) => dm_taps::tool::call_gated(gate, &path, c, LIMITS).await, | |
| None => dm_taps::fetch::call(gate, c, LIMITS).await, | |
| }; | |
| match done { | |
| Ok(done) => { | |
| let answer = | |
| serde_json::from_str::<Value>(&done.answer).unwrap_or(Value::String(done.answer)); | |
| let body = json!({"answer": answer, "reached": done.reached}); | |
| json!({"content": [{"type": "text", "text": body.to_string()}]}) | |
| } | |
| Err(e) => tool_error(&e.to_string()), | |
| } | |
| } | |
| /// The MCP endpoint, at `/mcp`. | |
| fn router(s: Arc<Served>) -> Router { | |
| Router::new().route("/mcp", post(mcp)).with_state(s) | |
| } | |
| /// Whether an address to listen on is this machine's loopback. | |
| fn loopback(listen: &str) -> bool { | |
| listen | |
| .parse::<std::net::SocketAddr>() | |
| .is_ok_and(|a| a.ip().is_loopback()) | |
| || listen.starts_with("localhost:") | |
| } | |
| /// `amem tool-server`: serve this root's tools until stopped. | |
| /// | |
| /// # Errors | |
| /// | |
| /// No record at `root`, a token variable that is unset, a listen address off loopback | |
| /// without a token, or one that cannot be bound. | |
| pub async fn serve(root: &Path, listen: &str, token_env: Option<&str>) -> Fallible { | |
| let grants = Grants::load(&root.join("grants.toml"))?; | |
| let token = match token_env { | |
| Some(var) => Some( | |
| crate::present(std::env::var(var).ok()) | |
| .ok_or_else(|| format!("--token-env {var}: the variable is not set"))?, | |
| ), | |
| None => None, | |
| }; | |
| if token.is_none() && !loopback(listen) { | |
| return Err(format!( | |
| "{listen} is off loopback: a tool server there wants --token-env, so it is never an open proxy" | |
| ) | |
| .into()); | |
| } | |
| let s = Arc::new(Served { | |
| root: root.to_path_buf(), | |
| grants, | |
| token, | |
| }); | |
| let names: Vec<String> = served(&s) | |
| .iter() | |
| .map(|(n, _)| match installed(root, n) { | |
| Some(_) => (*n).clone(), | |
| None => format!("{n} (native)"), | |
| }) | |
| .collect(); | |
| let listener = tokio::net::TcpListener::bind(listen).await?; | |
| eprintln!( | |
| "tool-server: serving {} at http://{}/mcp", | |
| if names.is_empty() { | |
| "no tools".to_string() | |
| } else { | |
| names.join(", ") | |
| }, | |
| listener.local_addr()? | |
| ); | |
| axum::serve(listener, router(s)).await?; | |
| Ok(()) | |
| } | |
| #[cfg(test)] | |
| mod tests { | |
| use super::*; | |
| use crate::mcp_meta::{self, MODERN, PROTOCOL_VERSION}; | |
| use tower::ServiceExt as _; | |
| /// A server for fetch, the binary's own, on a root with nothing installed. | |
| fn app() -> Router { | |
| let grants = Grants::parse( | |
| "owner = \"human:test:me\"\n[domains.dom-ts]\n\ | |
| [taps.fetch]\ndomain = \"dom-ts\"\ntool = true\nceiling = \"public\"\nhosts = [\"127.0.0.1\"]\n", | |
| ) | |
| .unwrap(); | |
| router(Arc::new(Served { | |
| root: PathBuf::from("/nonexistent"), | |
| grants, | |
| token: None, | |
| })) | |
| } | |
| /// POST `body` with `headers`; the status and the JSON answered, `null` for none. | |
| async fn post_to(app: &Router, headers: &[(&str, String)], body: &Value) -> (u16, Value) { | |
| let mut req = axum::http::Request::post("/mcp") | |
| .header("content-type", "application/json") | |
| .header("accept", "application/json, text/event-stream"); | |
| for (k, v) in headers { | |
| req = req.header(*k, v); | |
| } | |
| let resp = app | |
| .clone() | |
| .oneshot(req.body(axum::body::Body::from(body.to_string())).unwrap()) | |
| .await | |
| .unwrap(); | |
| let status = resp.status().as_u16(); | |
| let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20) | |
| .await | |
| .unwrap(); | |
| ( | |
| status, | |
| serde_json::from_slice(&bytes).unwrap_or(Value::Null), | |
| ) | |
| } | |
| /// A 2026-07-28 request and the headers it goes with. | |
| fn modern(method: &str, params: Value) -> (Value, Vec<(&'static str, String)>) { | |
| let r = mcp_meta::request(1, method, params, "test"); | |
| let h = mcp_meta::headers(&r); | |
| (r, h) | |
| } | |
| fn call_fetch() -> Value { | |
| json!({"name": "fetch", "arguments": {"args": {"url": "http://127.0.0.1:1/"}, "hosts": []}}) | |
| } | |
| #[tokio::test] | |
| async fn discovery_names_every_version_spoken_and_the_tools() { | |
| let app = app(); | |
| let (r, h) = modern("server/discover", Value::Null); | |
| let (status, got) = post_to(&app, &h, &r).await; | |
| assert_eq!(status, 200, "{got}"); | |
| let result = &got["result"]; | |
| assert_eq!(result["resultType"], "complete", "{got}"); | |
| assert_eq!( | |
| result["supportedVersions"], | |
| json!(["2026-07-28", "2025-11-25", "2025-06-18", "2025-03-26"]), | |
| "{got}" | |
| ); | |
| assert!(result["capabilities"]["tools"].is_object(), "{got}"); | |
| assert_eq!( | |
| result["_meta"][mcp_meta::SERVER_INFO]["name"], | |
| "amem tool-server", | |
| "{got}" | |
| ); | |
| } | |
| #[tokio::test] | |
| async fn a_stateless_request_is_served_and_its_result_says_it_is_complete() { | |
| let app = app(); | |
| let (r, h) = modern("tools/list", Value::Null); | |
| let (status, got) = post_to(&app, &h, &r).await; | |
| assert_eq!(status, 200, "{got}"); | |
| assert_eq!(got["result"]["resultType"], "complete", "{got}"); | |
| assert_eq!(got["result"]["tools"][0]["name"], "fetch", "{got}"); | |
| assert_eq!(got["result"]["ttlMs"], 0, "{got}"); | |
| assert_eq!(got["result"]["cacheScope"], "private", "{got}"); | |
| // a tool's refusal is a complete result too, marked as an error | |
| let (r, h) = modern("tools/call", call_fetch()); | |
| let (status, got) = post_to(&app, &h, &r).await; | |
| assert_eq!(status, 200, "{got}"); | |
| assert_eq!(got["result"]["resultType"], "complete", "{got}"); | |
| assert_eq!(got["result"]["isError"], true, "{got}"); | |
| // a name that is not plain in a header goes as base64 and is read back | |
| let (r, mut h) = modern("tools/call", call_fetch()); | |
| h.retain(|(k, _)| *k != mcp_meta::NAME_HEADER); | |
| h.push((mcp_meta::NAME_HEADER, "=?base64?ZmV0Y2g=?=".into())); | |
| let (status, got) = post_to(&app, &h, &r).await; | |
| assert_eq!(status, 200, "{got}"); | |
| } | |
| #[tokio::test] | |
| async fn an_unsupported_version_is_refused_naming_the_supported_ones() { | |
| let app = app(); | |
| let (mut r, mut h) = modern("tools/list", Value::Null); | |
| r["params"]["_meta"][PROTOCOL_VERSION] = json!("1900-01-01"); | |
| h[0].1 = "1900-01-01".into(); | |
| let (status, got) = post_to(&app, &h, &r).await; | |
| assert_eq!(status, 400, "{got}"); | |
| assert_eq!(got["error"]["code"], -32022, "{got}"); | |
| assert_eq!(got["error"]["data"]["requested"], "1900-01-01", "{got}"); | |
| // a stateless request is answered with the stateless versions only; the older | |
| // ones are reached through initialize, which the message says | |
| assert_eq!(got["error"]["data"]["supported"], json!([MODERN]), "{got}"); | |
| let said = got["error"]["message"].as_str().unwrap_or_default(); | |
| assert!(said.contains("initialize"), "{got}"); | |
| // a version with a handshake named in _meta is no stateless version either | |
| let (mut r, mut h) = modern("tools/list", Value::Null); | |
| r["params"]["_meta"][PROTOCOL_VERSION] = json!("2025-06-18"); | |
| h[0].1 = "2025-06-18".into(); | |
| let (status, got) = post_to(&app, &h, &r).await; | |
| assert_eq!(status, 400, "{got}"); | |
| assert_eq!(got["error"]["code"], -32022, "{got}"); | |
| assert_eq!(got["error"]["data"]["supported"], json!([MODERN]), "{got}"); | |
| } | |
| #[tokio::test] | |
| async fn a_body_that_is_not_one_request_is_refused_as_invalid() { | |
| let app = app(); | |
| let one = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}); | |
| for body in [json!([one]), json!([]), json!("tools/list"), json!(7)] { | |
| let (status, got) = post_to(&app, &[], &body).await; | |
| assert_eq!(status, 400, "{body}: {got}"); | |
| assert_eq!(got["error"]["code"], -32600, "{body}: {got}"); | |
| assert_eq!(got["id"], Value::Null, "{body}: {got}"); | |
| } | |
| } | |
| /// A name header at odds with the body is refused whoever sends it; without one, a | |
| /// client of the handshake is served as before. | |
| #[tokio::test] | |
| async fn a_name_header_at_odds_with_the_body_is_refused_after_a_handshake_too() { | |
| let app = app(); | |
| let call = | |
| json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": call_fetch()}); | |
| let method = (mcp_meta::METHOD_HEADER, "tools/call".to_string()); | |
| let other = (mcp_meta::NAME_HEADER, "search".to_string()); | |
| let version = (mcp_meta::VERSION_HEADER, "2025-06-18".to_string()); | |
| for headers in [ | |
| vec![method.clone(), other.clone()], | |
| vec![method.clone(), other.clone(), version.clone()], | |
| vec![other.clone()], | |
| ] { | |
| let (status, got) = post_to(&app, &headers, &call).await; | |
| assert_eq!(status, 400, "{headers:?}: {got}"); | |
| assert_eq!(got["error"]["code"], mcp_meta::HEADER_MISMATCH, "{got}"); | |
| } | |
| let same = (mcp_meta::NAME_HEADER, "=?base64?ZmV0Y2g=?=".to_string()); | |
| for headers in [vec![], vec![version, same]] { | |
| let (status, got) = post_to(&app, &headers, &call).await; | |
| assert_eq!(status, 200, "{headers:?}: {got}"); | |
| assert_eq!(got["result"]["isError"], true, "{got}"); | |
| } | |
| } | |
| /// Each way a 2026-07-28 request's headers fail its body, and what is answered. | |
| #[tokio::test] | |
| async fn headers_missing_or_at_odds_with_the_body_are_refused() { | |
| let app = app(); | |
| let refused = |status: u16, got: &Value, code: i64| { | |
| assert_eq!(status, 400, "{got}"); | |
| assert_eq!(got["error"]["code"], code, "{got}"); | |
| assert_eq!(got["id"], 1, "{got}"); | |
| }; | |
| let without = |h: &[(&'static str, String)], name: &str| { | |
| h.iter() | |
| .filter(|(k, _)| *k != name) | |
| .cloned() | |
| .collect::<Vec<_>>() | |
| }; | |
| let (r, h) = modern("tools/call", call_fetch()); | |
| for name in [ | |
| mcp_meta::VERSION_HEADER, | |
| mcp_meta::METHOD_HEADER, | |
| mcp_meta::NAME_HEADER, | |
| ] { | |
| let (status, got) = post_to(&app, &without(&h, name), &r).await; | |
| refused(status, &got, mcp_meta::HEADER_MISMATCH); | |
| } | |
| let mut other = without(&h, mcp_meta::NAME_HEADER); | |
| other.push((mcp_meta::NAME_HEADER, "search".into())); | |
| let (status, got) = post_to(&app, &other, &r).await; | |
| refused(status, &got, mcp_meta::HEADER_MISMATCH); | |
| let mut other = without(&h, mcp_meta::METHOD_HEADER); | |
| other.push((mcp_meta::METHOD_HEADER, "tools/list".into())); | |
| let (status, got) = post_to(&app, &other, &r).await; | |
| refused(status, &got, mcp_meta::HEADER_MISMATCH); | |
| let mut other = without(&h, mcp_meta::VERSION_HEADER); | |
| other.push((mcp_meta::VERSION_HEADER, "2025-06-18".into())); | |
| let (status, got) = post_to(&app, &other, &r).await; | |
| refused(status, &got, mcp_meta::HEADER_MISMATCH); | |
| // the capabilities every request carries are not optional | |
| let mut bare = r.clone(); | |
| bare["params"]["_meta"] | |
| .as_object_mut() | |
| .unwrap() | |
| .remove(mcp_meta::CLIENT_CAPABILITIES); | |
| let (status, got) = post_to(&app, &h, &bare).await; | |
| refused(status, &got, -32602); | |
| // and a method this version does not have is not found | |
| let (r, h) = modern("ping", Value::Null); | |
| let (status, got) = post_to(&app, &h, &r).await; | |
| assert_eq!(status, 404, "{got}"); | |
| assert_eq!(got["error"]["code"], -32601, "{got}"); | |
| } | |
| /// 2026-07-28 lets the sentinel stand for a value in `Mcp-Name` and `Mcp-Param-*` | |
| /// only, so a method header in it names another method than the body's. | |
| #[tokio::test] | |
| async fn a_method_header_is_compared_as_sent_and_never_decoded() { | |
| let app = app(); | |
| let (r, mut h) = modern("tools/list", Value::Null); | |
| h.retain(|(k, _)| *k != mcp_meta::METHOD_HEADER); | |
| h.push(( | |
| mcp_meta::METHOD_HEADER, | |
| "=?base64?dG9vbHMvbGlzdA==?=".into(), | |
| )); | |
| let (status, got) = post_to(&app, &h, &r).await; | |
| assert_eq!(status, 400, "{got}"); | |
| assert_eq!(got["error"]["code"], mcp_meta::HEADER_MISMATCH, "{got}"); | |
| } | |
| #[tokio::test] | |
| async fn a_client_that_opens_with_initialize_is_answered_as_before() { | |
| let app = app(); | |
| let init = |v: &str| { | |
| json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", | |
| "params": {"protocolVersion": v, "capabilities": {}, | |
| "clientInfo": {"name": "t", "version": "0"}}}) | |
| }; | |
| let (status, got) = post_to(&app, &[], &init("2025-06-18")).await; | |
| assert_eq!(status, 200, "{got}"); | |
| assert_eq!(got["result"]["protocolVersion"], "2025-06-18", "{got}"); | |
| assert!(got["result"].get("resultType").is_none(), "{got}"); | |
| // a version it does not speak is answered with the newest it does | |
| let (_, got) = post_to(&app, &[], &init("2024-11-05")).await; | |
| assert_eq!(got["result"]["protocolVersion"], "2025-11-25", "{got}"); | |
| let (_, got) = post_to(&app, &[], &init(MODERN)).await; | |
| assert_eq!(got["result"]["protocolVersion"], "2025-11-25", "{got}"); | |
| // a method header that contradicts the body is refused on this path too | |
| let (status, got) = post_to( | |
| &app, | |
| &[(mcp_meta::METHOD_HEADER, "tools/call".into())], | |
| &init("2025-06-18"), | |
| ) | |
| .await; | |
| assert_eq!(status, 400, "{got}"); | |
| assert_eq!(got["error"]["code"], mcp_meta::HEADER_MISMATCH, "{got}"); | |
| let list = json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}); | |
| let (status, got) = post_to( | |
| &app, | |
| &[(mcp_meta::VERSION_HEADER, "2025-06-18".into())], | |
| &list, | |
| ) | |
| .await; | |
| assert_eq!(status, 200, "{got}"); | |
| assert_eq!(got["result"]["tools"][0]["name"], "fetch", "{got}"); | |
| assert!(got["result"].get("resultType").is_none(), "{got}"); | |
| } | |
| #[test] | |
| fn only_loopback_goes_without_a_token() { | |
| assert!(loopback("127.0.0.1:4100")); | |
| assert!(loopback("[::1]:4100")); | |
| assert!(loopback("localhost:4100")); | |
| assert!(!loopback("0.0.0.0:4100")); | |
| assert!(!loopback("10.1.2.3:4100")); | |
| } | |
| } |