//! The published cases of the gateway over HTTP (C36, //! `jcp/extensions/mcp-over-http.md`): sessions of HTTP requests, each step //! with what the binding answered. `jcp/conformance/verify_mcp_http.py` //! decides every step again on its own. //! //! `JCP_MCP_HTTP_REGEN=1 cargo test -p jcp-gateway --test http_pack` writes //! `jcp/conformance/v1/mcp_http.json`; without it the test holds the //! committed pack to what the binding answers now. use ed25519_dalek::SigningKey; use jcp_bridge::mcp_resource; use jcp_gateway::http::{Front, HttpRequest, HttpResponse}; use jcp_gateway::{present_grant, Gateway, Upstream, JCP_META_NS}; use jcp_schema::{Capability, Did, Grant, SignedGrant}; use op_rails_x402::v2::{PaymentPayload, PaymentRequirements, SettlementResponse}; use serde_json::{json, Map, Value}; use std::sync::{Arc, Mutex}; const SERVER: &str = "srv"; const ENDPOINT: &str = "https://gw.example/mcp"; const ORIGIN: &str = "https://app.example"; const AGENT_URL: &str = "https://agent.example/.well-known/http-message-signatures-directory"; const NOW: u64 = 1_800_000_000; const BUDGET: u64 = 1_000_000_000_000; const STEWARD: [u8; 32] = [200; 32]; fn key(n: u8) -> SigningKey { SigningKey::from_bytes(&[n; 32]) } fn did(k: &SigningKey) -> Did { Did::from_verifying_key(&k.verifying_key()) } fn operator() -> SigningKey { key(1) } fn agent() -> SigningKey { key(2) } fn delegate() -> SigningKey { key(3) } fn listing() -> Value { json!([ {"name": "echo", "inputSchema": {"type": "object"}}, {"name": "region", "inputSchema": {"type": "object", "properties": { "where": {"type": "object", "properties": {"region": {"type": "string", "x-mcp-header": "Region"}}}, "limit": {"type": "integer", "x-mcp-header": "Limit"}, "query": {"type": "string"}}}}, ]) } /// The server behind the gateway: answers every call, opens a task for /// `slow`, and lists [`listing`]. struct Server; impl Upstream for Server { fn call(&mut self, line: &str) -> std::io::Result { let v: Value = serde_json::from_str(line).unwrap(); let result = match (v["method"].as_str(), v["params"]["name"].as_str()) { (Some("tools/list"), _) => json!({"resultType": "complete", "tools": listing()}), (Some("tools/call"), Some("slow")) => json!({"resultType": "task", "taskId": "t-1", "status": "working"}), (Some("tasks/get"), _) => json!({"resultType": "complete", "taskId": "t-1", "status": "working"}), _ => json!({"resultType": "complete", "content": [{"type": "text", "text": "ok"}]}), }; Ok(json!({"jsonrpc": "2.0", "id": v["id"], "result": result}).to_string()) } } fn grant(issuer: &SigningKey, subject: Did, tool: &str, budget: u64, expiry: u64, nonce: u8) -> SignedGrant { let g = Grant { issuer: did(issuer), subject, capability: Capability::new(mcp_resource(SERVER, tool), [tool]), budget_uj: budget, budget_funds: None, caveats: Vec::new(), expiry, parent: None, delegate_depth: None, nonce: [nonce; 16], }; jcp_grant::issue(issuer, g).unwrap() } fn root(tool: &str) -> SignedGrant { grant(&operator(), did(&agent()), tool, BUDGET, 0, 1) } fn delegated(tool: &str) -> Vec { let r = root(tool); let child = jcp_grant::attenuate(&r, &agent(), did(&delegate()), Capability::new(mcp_resource(SERVER, tool), [tool]), BUDGET / 2, 0, [7; 16]).unwrap(); vec![r, child] } /// A child that claims more budget than its parent, signed by the parent's /// holder: `attenuate` would refuse to mint it, so it is minted by hand. fn amplified(tool: &str) -> Vec { let r = root(tool); let mut g = r.grant.clone(); g.issuer = did(&agent()); g.subject = did(&delegate()); g.parent = Some(r.grant.id()); g.budget_uj = BUDGET * 2; g.nonce = [8; 16]; vec![r, jcp_grant::issue(&agent(), g).unwrap()] } /// A child of another root than the one presented before it. fn unlinked(tool: &str) -> Vec { let mut chain = delegated(tool); chain[0] = grant(&operator(), did(&agent()), tool, BUDGET, 0, 9); chain } fn rpc(id: Option, method: &str, mut params: Value, chain: &[SignedGrant], version: &str) -> Value { let meta = params.as_object_mut().unwrap().entry("_meta").or_insert(json!({})); meta["io.modelcontextprotocol/protocolVersion"] = json!(version); if !chain.is_empty() { meta[JCP_META_NS] = json!({ "grants": chain.iter().map(present_grant).collect::>() }); } let mut m = json!({ "jsonrpc": "2.0", "method": method, "params": params }); if let Some(id) = id { m["id"] = json!(id); } m } fn call(id: u64, tool: &str, args: Value, chain: &[SignedGrant]) -> Value { rpc(Some(id), "tools/call", json!({ "name": tool, "arguments": args }), chain, "2026-07-28") } fn headers_for(body: &Value) -> Vec<(String, String)> { let mut h: Vec<(String, String)> = vec![ ("content-type".into(), "application/json".into()), ("accept".into(), "application/json, text/event-stream".into()), ]; if let Some(v) = body.pointer("/params/_meta/io.modelcontextprotocol~1protocolVersion").and_then(Value::as_str) { h.push(("mcp-protocol-version".into(), v.into())); } let method = body["method"].as_str().unwrap_or(""); h.push(("mcp-method".into(), method.into())); let named = match method { "tools/call" | "prompts/get" => body["params"]["name"].as_str(), "resources/read" => body["params"]["uri"].as_str(), _ => None, }; if let Some(n) = named { h.push(("mcp-name".into(), n.into())); } h } /// How a request is made from a body: its headers adjusted, then signed. #[derive(Default, Clone)] struct How { signer: Option, nonce: &'static str, created: Option, call_id: Option<[u8; 32]>, set: Vec<(&'static str, Option<&'static str>)>, /// Bytes put in place of the body after it was signed. body_after: Option, /// An x402 authorization, sent in `PAYMENT-SIGNATURE` and signed over. payment: Option, } fn request(body: &Value, how: &How) -> HttpRequest { let bytes = body.to_string().into_bytes(); let mut headers = headers_for(body); for (name, value) in &how.set { headers.retain(|(n, _)| !n.eq_ignore_ascii_case(name)); if let Some(v) = value { headers.push((name.to_string(), v.to_string())); } } if let Some(p) = &how.payment { headers.push(("payment-signature".into(), p.clone())); } if let Some(n) = how.signer { let mut r = jcp_httpsig::Request::new("POST", ENDPOINT).unwrap(); for (n, v) in &headers { r.headers.append(n, v.as_str()); } let created = how.created.unwrap_or(NOW as i64); jcp_httpsig::jcp::sign_call(&mut r, &bytes, "sig1", AGENT_URL, &key(n), created, how.nonce, how.call_id.as_ref()).unwrap(); headers = r.headers.iter().map(|(n, v)| (n.to_string(), v.to_string())).collect(); } let body = how.body_after.as_ref().map_or(bytes, |b| b.as_bytes().to_vec()); HttpRequest { method: "POST".into(), target: "/mcp".into(), headers, body } } fn signed(by: u8, nonce: &'static str) -> How { How { signer: Some(by), nonce, ..How::default() } } /// What a step is held to: the status, the JSON-RPC error code, the JCP /// decision where one was reached, the signature rule a refused caller broke, /// what became of a call id, and the id the answer carries. fn expect_of(resp: &HttpResponse) -> Value { let body = resp.json_body(); let error = body.as_ref().and_then(|b| b.get("error")); let message = error.and_then(|e| e["message"].as_str()).unwrap_or(""); let decision = if let Some(rest) = message.strip_prefix("JCP denied: ") { Value::from(rest.split(|c: char| !(c.is_ascii_lowercase() || c == '-')).next().unwrap_or("")) } else if resp.status == 200 && error.is_none() { Value::from("allow") } else { Value::Null }; let call = if message.contains("names another call") { Value::from("conflict") } else if message.contains("not known") { Value::from("unresolved") } else { Value::Null }; json!({ "status": resp.status, "code": error.and_then(|e| e["code"].as_i64()), "decision": decision, "signature": error.and_then(|e| e.pointer("/data/signature")).cloned(), "call": call, "id": body.as_ref().and_then(|b| b.get("id")).cloned(), "accept_signature": resp.header("accept-signature"), "allow": resp.header("allow"), "same_as": null, }) } struct Session { gw: Gateway, front: Front, up: Server, record: Arc>>, steps: Vec, /// Each answer's sealed receipt, by step, so an answer given again (a /// retry recognised by its call id) names the step it repeats. receipts: Vec<(usize, String)>, /// Keep what each answer carried that varies from run to run (a priced /// call's receipt, its charge, its settlement) under `observed`. observe: bool, } /// The fixed tariffs and x402 terms of the priced session. fn tariff_echo() -> jcp_gateway::Tariff { jcp_gateway::Tariff { currency: "USD".into(), base_minor: 3, rate_minor: 1, per_uj: 1_000, max_minor: 50 } } fn tariff_paid() -> jcp_gateway::Tariff { jcp_gateway::Tariff { currency: "USDC".into(), base_minor: 1_000, rate_minor: 1, per_uj: 1, max_minor: 50_000_000 } } fn x402_terms(amount: u64) -> PaymentRequirements { PaymentRequirements { scheme: "upto".into(), network: "eip155:8453".into(), amount: amount.to_string(), asset: "0x00000000000000000000000000000000000000a1".into(), pay_to: "0x00000000000000000000000000000000000000b2".into(), max_timeout_seconds: 300, extra: None, } } fn authorization(amount: u64, valid: bool) -> String { PaymentPayload::new(x402_terms(amount), json!({"valid": valid})).encode_header() } /// The pack's facilitator: an authorization whose payload states /// `"valid": true` is valid, and every settlement succeeds under the /// transaction `0xfeed` for the amount asked. struct PackFacilitator; impl op_rails_x402::v2::Facilitator for PackFacilitator { fn verify(&self, payload: &PaymentPayload, _: &PaymentRequirements) -> Result { let valid = payload.payload["valid"] == json!(true); Ok(serde_json::from_value(json!({"isValid": valid, "invalidReason": if valid { Value::Null } else { json!("invalid_payload") }})).unwrap()) } fn settle(&self, _: &PaymentPayload, req: &PaymentRequirements) -> Result { let network = op_rails_x402::v2::Caip2::parse(&req.network)?; Ok(SettlementResponse::settled("0xfeed", &network, req.amount.parse().unwrap_or(0))) } fn supported(&self) -> Result { Ok(op_rails_x402::v2::SupportedResponse { kinds: Vec::new(), extensions: Vec::new(), signers: Map::new() }) } } /// The receipt an answer carries, where it carries one. fn receipt_of(resp: &HttpResponse) -> Option { let ns = JCP_META_NS.replace('/', "~1"); let b = resp.json_body()?; b.pointer(&format!("/result/_meta/{ns}/receipt_b64")) .or_else(|| b.pointer(&format!("/error/data/_meta/{ns}/receipt_b64"))) .and_then(Value::as_str) .map(str::to_string) } /// The agent's key directory, which every endpoint in the pack holds: an /// agent known by its directory is proved without presenting a chain. fn directory() -> (Vec, i64) { let key = jcp_httpsig::VerifyingKey::Ed25519(agent().verifying_key()); (jcp_httpsig::agent::directory_body(&[key]).unwrap(), NOW as i64 - 100) } fn front(record: &Arc>>) -> Front { let mut f = Front::new(ENDPOINT).unwrap(); f.allow_origin(ORIGIN); let (body, at) = directory(); f.keys.insert(jcp_httpsig::agent::read_directory(AGENT_URL, 200, Some(jcp_httpsig::agent::MEDIA_TYPE), &body).unwrap(), at); let w = Arc::clone(record); f.calls.record_to(move |line| { w.lock().unwrap().extend_from_slice(line); Ok(()) }); f } impl Session { fn new() -> Session { let mut gw = Gateway::new(SERVER, &[], SigningKey::from_bytes(&STEWARD)); gw.trust_root(did(&operator())); let record = Arc::new(Mutex::new(Vec::new())); Session { gw, front: front(&record), up: Server, record, steps: Vec::new(), receipts: Vec::new(), observe: false } } /// A session whose gateway prices `echo` by `tariff_echo` and sells `paid` /// by x402 under `x402_terms`, through `PackFacilitator`. fn priced() -> Session { let mut s = Session::new(); s.gw.price_tool("echo", tariff_echo()); s.gw.price_tool("paid", tariff_paid()); s.gw.use_facilitator(PackFacilitator); s.front.sell_by_x402("paid", x402_terms(tariff_paid().max_minor)).unwrap(); s.observe = true; s } fn send(&mut self, what: &str, req: HttpRequest, now: u64) { let resp = self.front.handle(&mut self.gw, &mut self.up, &req, now); let mut expect = expect_of(&resp); if let Some(r) = receipt_of(&resp) { match self.receipts.iter().find(|(_, seen)| *seen == r) { Some((step, _)) => expect["same_as"] = json!(step), None => self.receipts.push((self.steps.len(), r)), } } let mut step = json!({ "step": what, "now": now, "request": { "method": req.method, "target": req.target, "headers": req.headers.iter().map(|(n, v)| json!([n, v])).collect::>(), "body": String::from_utf8(req.body).unwrap(), }, "expect": expect, }); if self.observe { let ns = JCP_META_NS.replace('/', "~1"); let body = resp.json_body().unwrap_or(Value::Null); let mut observed = Map::new(); if let Some(r) = receipt_of(&resp) { observed.insert("receipt_b64".into(), json!(r)); } if let Some(c) = body.pointer(&format!("/result/_meta/{ns}/charged/minor")) { observed.insert("charged_minor".into(), c.clone()); } for h in ["payment-response", "payment-required"] { if let Some(v) = resp.header(h) { observed.insert(h.replace('-', "_"), json!(v)); } } step["observed"] = Value::Object(observed); } self.steps.push(step); } fn post(&mut self, what: &str, body: &Value, how: How) { self.send(what, request(body, &how), NOW); } /// A restart of the binding (not of the gateway): a new front whose call /// book is read back from `lines`, holding no nonce it spent before. fn resume(&mut self, what: &str, lines: Vec) { let text: String = lines.iter().map(|l| format!("{l}\n")).collect(); self.record = Arc::new(Mutex::new(Vec::new())); self.front = front(&self.record); self.front.calls.resume(&text).unwrap(); self.steps.push(json!({ "step": what, "resume": lines })); } fn recorded(&self) -> Vec { let text = String::from_utf8(self.record.lock().unwrap().clone()).unwrap(); text.lines().map(|l| serde_json::from_str(l).unwrap()).collect() } fn done(self, name: &str) -> Value { json!({ "name": name, "steps": self.steps }) } } fn set(pairs: &[(&'static str, Option<&'static str>)]) -> Vec<(&'static str, Option<&'static str>)> { pairs.to_vec() } fn sessions() -> Vec { let mut out = Vec::new(); let chain = [root("echo")]; let echo = call(1, "echo", json!({}), &chain); let mut s = Session::new(); let mut get = request(&echo, &signed(2, "t1")); get.method = "GET".into(); s.send("GET is not served", get, NOW); let mut other = request(&echo, &signed(2, "t2")); other.target = "/other".into(); s.send("another path", other, NOW); s.post("a foreign Origin", &echo, How { set: set(&[("origin", Some("https://evil.example"))]), ..signed(2, "t3") }); s.post("not JSON", &echo, How { set: set(&[("content-type", Some("text/plain"))]), ..signed(2, "t4") }); s.post("accepts no JSON", &echo, How { set: set(&[("accept", Some("text/html"))]), ..signed(2, "t5") }); s.post("a body that does not parse", &echo, How { body_after: Some("{not json".into()), ..signed(2, "t6") }); let mut old = echo.clone(); old["jsonrpc"] = json!("1.0"); s.post("not JSON-RPC 2.0", &old, signed(2, "t7")); s.post("an allowed Origin, and an old client's session header ignored", &echo, How { set: set(&[("origin", Some(ORIGIN)), ("mcp-session-id", Some("s-1"))]), ..signed(2, "t8") }); out.push(s.done("transport")); let mut s = Session::new(); s.post("MCP-Protocol-Version missing", &echo, How { set: set(&[("mcp-protocol-version", None)]), ..signed(2, "m1") }); s.post("MCP-Protocol-Version not the body's", &echo, How { set: set(&[("mcp-protocol-version", Some("2025-11-25"))]), ..signed(2, "m2") }); let older = rpc(Some(1), "tools/call", json!({"name": "echo", "arguments": {}}), &chain, "2025-11-25"); s.post("a version not spoken", &older, signed(2, "m3")); s.post("Mcp-Method not the body's", &echo, How { set: set(&[("mcp-method", Some("tools/list"))]), ..signed(2, "m4") }); s.post("Mcp-Name not the body's", &echo, How { set: set(&[("mcp-name", Some("shell"))]), ..signed(2, "m5") }); s.post("Mcp-Name missing", &echo, How { set: set(&[("mcp-name", None)]), ..signed(2, "m6") }); let accented = call(1, "ech\u{f6}", json!({}), &chain); s.post("Mcp-Name not a header value, though it is the body's", &accented, signed(2, "m7")); s.post("Mcp-Name in the Base64 form", &echo, How { set: set(&[("mcp-name", Some("=?base64?ZWNobw==?="))]), ..signed(2, "m8") }); let list = rpc(Some(2), "tools/list", json!({}), &[root("region")], "2026-07-28"); s.post("a listing that mirrors two parameters", &list, signed(2, "m9")); let region = [root("region")]; let args = json!({"where": {"region": "us-west1"}, "limit": 42, "query": "q"}); let rcall = call(3, "region", args, ®ion); s.post("a mirrored parameter missing", &rcall, How { set: set(&[("mcp-param-limit", Some("42"))]), ..signed(2, "m10") }); s.post("a mirrored parameter not the body's", &rcall, How { set: set(&[("mcp-param-region", Some("eu-west1")), ("mcp-param-limit", Some("42"))]), ..signed(2, "m11") }); s.post("both, an integer compared as a number", &rcall, How { set: set(&[("mcp-param-region", Some("us-west1")), ("mcp-param-limit", Some("42.0"))]), ..signed(2, "m12") }); let bare = call(4, "region", json!({"query": "q"}), ®ion); s.post("absent from the arguments, no header expected", &bare, signed(2, "m13")); out.push(s.done("metadata")); let mut s = Session::new(); s.post("a call under a root grant", &echo, signed(2, "c1")); let d = delegated("echo"); s.post("a delegated call, signed by the delegate", &call(2, "echo", json!({}), &d), signed(3, "c2")); s.post("the delegator signing under its delegate's chain", &call(3, "echo", json!({}), &d), signed(2, "c3")); let stranger = [grant(&key(9), did(&agent()), "echo", BUDGET, 0, 1)]; s.post("a root an untrusted issuer issued", &call(4, "echo", json!({}), &stranger), signed(2, "c4")); let own = [grant(&SigningKey::from_bytes(&[0x6a; 32]), did(&agent()), "echo", BUDGET, 0, 1)]; s.post("a root the gateway's own issuer issued", &call(5, "echo", json!({}), &own), signed(2, "c5")); let lapsed = [grant(&operator(), did(&agent()), "echo", BUDGET, NOW - 1, 2)]; s.post("a grant lapsed at the endpoint's clock", &call(6, "echo", json!({}), &lapsed), signed(2, "c6")); s.post("a grant for another tool", &call(7, "other", json!({}), &chain), signed(2, "c7")); let mut forged = root("echo"); forged.signature[0] ^= 1; s.post("a root whose signature does not verify", &call(12, "echo", json!({}), &[forged]), signed(2, "c12")); s.post("a child wider than its parent", &call(8, "echo", json!({}), &lified("echo")), signed(3, "c8")); s.post("a child of another root", &call(9, "echo", json!({}), &unlinked("echo")), signed(3, "c9")); let mut garbled = call(10, "echo", json!({}), &chain); garbled["params"]["_meta"][JCP_META_NS]["grants"] = json!(["not-a-grant!"]); s.post("grants that do not decode", &garbled, signed(2, "c10")); s.post("no chain presented, by an agent its directory names", &call(11, "echo", json!({}), &[]), signed(2, "c11")); s.post("no chain presented, by a key nothing names", &call(13, "echo", json!({}), &[]), signed(3, "c13")); out.push(s.done("authority")); let mut s = Session::new(); s.post("not signed", &echo, How::default()); s.post("signed by a key nothing names", &echo, signed(9, "s1")); s.post("signed outside the window", &echo, How { created: Some(NOW as i64 - 301), ..signed(2, "s2") }); s.post("signed", &echo, signed(2, "s3")); s.post("the same signature again", &echo, signed(2, "s3")); let changed = call(1, "echo", json!({"x": 1}), &chain); s.post("a body other than the one signed", &echo, How { body_after: Some(changed.to_string()), ..signed(2, "s4") }); out.push(s.done("signatures")); let mut s = Session::new(); let id = Some([4; 32]); s.post("a call under a call id", &call(1, "echo", json!({"n": 1}), &chain), How { call_id: id, ..signed(2, "r1") }); s.send("its retry, re-signed, a new request id", request(&call(2, "echo", json!({"n": 1}), &chain), &How { call_id: id, created: Some(NOW as i64 + 30), ..signed(2, "r2") }), NOW + 30); s.send("another call under the same id", request(&call(3, "echo", json!({"n": 2}), &chain), &How { call_id: id, created: Some(NOW as i64 + 31), ..signed(2, "r3") }), NOW + 31); let theirs = [grant(&operator(), did(&delegate()), "echo", BUDGET, 0, 3)]; s.send("another caller's call under the same id", request(&call(4, "echo", json!({"n": 1}), &theirs), &How { call_id: id, created: Some(NOW as i64 + 32), ..signed(3, "r4") }), NOW + 32); s.post("a call under a second id", &call(5, "echo", json!({"n": 5}), &chain), How { call_id: Some([5; 32]), ..signed(2, "r5") }); let admitted: Vec = s.recorded().into_iter().filter(|l| l["status"].is_null()).collect(); s.resume("a restart whose record stops at each admission", admitted); s.send("the first call's retry after it", request(&call(6, "echo", json!({"n": 1}), &chain), &How { call_id: id, created: Some(NOW as i64 + 40), ..signed(2, "r6") }), NOW + 40); s.send("another call under the second id after it", request(&call(7, "echo", json!({"n": 6}), &chain), &How { call_id: Some([5; 32]), created: Some(NOW as i64 + 41), ..signed(2, "r7") }), NOW + 41); let late = NOW + jcp_gateway::http::DEFAULT_HORIZON + 100; s.send("the second id past the horizon, forgotten", request(&call(8, "echo", json!({"n": 5}), &chain), &How { call_id: Some([5; 32]), created: Some(late as i64), ..signed(2, "r8") }), late); out.push(s.done("retries")); let mut s = Session::new(); let slow = [root("slow")]; s.post("a call the server answers with a task", &call(1, "slow", json!({}), &slow), signed(2, "k1")); let theirs = [grant(&operator(), did(&delegate()), "slow", BUDGET, 0, 4)]; s.post("another caller polling it", &rpc(Some(2), "tasks/get", json!({"taskId": "t-1"}), &theirs, "2026-07-28"), signed(3, "k2")); s.post("its opener polling it", &rpc(Some(3), "tasks/get", json!({"taskId": "t-1"}), &slow, "2026-07-28"), signed(2, "k3")); s.post("a task never opened", &rpc(Some(4), "tasks/get", json!({"taskId": "t-9"}), &slow, "2026-07-28"), signed(2, "k4")); out.push(s.done("tasks")); let mut s = Session::priced(); let funded = |currency: &str, minor: u64, nonce: u8| { let mut g = root("echo").grant; g.budget_funds = Some(jcp_schema::Funds::new(currency, minor)); g.nonce = [nonce; 16]; jcp_grant::issue(&operator(), g).unwrap() }; s.post("a priced call under a money ceiling", &call(1, "echo", json!({}), &[funded("USD", 100, 2)]), signed(2, "p1")); s.post("a priced call under a grant with no money ceiling", &call(2, "echo", json!({}), &chain), signed(2, "p2")); s.post("a priced call under a ceiling in another currency", &call(3, "echo", json!({}), &[funded("EUR", 100, 3)]), signed(2, "p3")); s.post("a priced call under a ceiling the cap does not fit", &call(4, "echo", json!({}), &[funded("USD", 10, 4)]), signed(2, "p4")); let paid_chain = [root("paid")]; let paid_call = |id| call(id, "paid", json!({}), &paid_chain); s.post("a tool sold by x402, called without payment", &paid_call(5), signed(2, "p5")); s.post("paying for other terms", &paid_call(6), How { payment: Some(authorization(1, true)), ..signed(2, "p6") }); s.post("paying with an authorization the facilitator does not take", &paid_call(7), How { payment: Some(authorization(tariff_paid().max_minor, false)), ..signed(2, "p7") }); s.post("paying the terms", &paid_call(8), How { payment: Some(authorization(tariff_paid().max_minor, true)), ..signed(2, "p8") }); let stranger = [grant(&key(9), did(&agent()), "paid", BUDGET, 0, 1)]; s.post("paying the terms for a call refused before it runs", &call(9, "paid", json!({}), &stranger), How { payment: Some(authorization(tariff_paid().max_minor, true)), ..signed(2, "p9") }); let mut priced = s.done("priced"); priced["tariffs"] = json!({"echo": tariff_json(&tariff_echo()), "paid": tariff_json(&tariff_paid())}); priced["x402"] = json!({ "paid": serde_json::to_value(x402_terms(tariff_paid().max_minor)).unwrap(), "facilitator": "an authorization whose payload states \"valid\": true is valid; every settlement succeeds under the transaction 0xfeed for the amount asked", }); out.push(priced); let mut s = Session::new(); s.post("a notification", &rpc(None, "notifications/progress", json!({}), &chain, "2026-07-28"), signed(2, "n1")); s.post("a gated method as a notification", &rpc(None, "tools/call", json!({"name": "echo", "arguments": {}}), &chain, "2026-07-28"), signed(2, "n2")); out.push(s.done("notifications")); out } fn tariff_json(t: &jcp_gateway::Tariff) -> Value { json!({"currency": t.currency, "base_minor": t.base_minor, "rate_minor": t.rate_minor, "per_uj": t.per_uj, "max_minor": t.max_minor}) } /// The pack without what varies from run to run. fn steady(mut v: Value) -> Value { for session in v["sessions"].as_array_mut().into_iter().flatten() { for step in session["steps"].as_array_mut().into_iter().flatten() { if let Some(o) = step.as_object_mut() { o.remove("observed"); } } } v } fn sorted(v: &Value) -> Value { match v { Value::Object(m) => { let mut keys: Vec<&String> = m.keys().collect(); keys.sort(); let mut out = Map::new(); for k in keys { out.insert(k.clone(), sorted(&m[k])); } Value::Object(out) } Value::Array(a) => Value::Array(a.iter().map(sorted).collect()), other => other.clone(), } } fn pack() -> Value { sorted(&json!({ "description": "C36, the gateway over HTTP (jcp/extensions/mcp-over-http.md): sessions of HTTP/1.1 requests to one MCP 2026-07-28 endpoint, each step with what the binding answers. A session keeps its nonces, call ids and tasks from step to step; a `resume` step restarts the binding (not the gateway) with its call record read back from the lines given. The server behind the gateway lists `listing`, answers every call, and answers `slow` with the task t-1.", "endpoint": ENDPOINT, "origins": [ORIGIN], "server": SERVER, "trusted": [did(&operator()).to_did_key()], "versions": ["2026-07-28"], "freshness": 300, "horizon": jcp_gateway::http::DEFAULT_HORIZON, "listing": listing(), "directories": [{"url": AGENT_URL, "status": 200, "content_type": jcp_httpsig::agent::MEDIA_TYPE, "body": String::from_utf8(directory().0).unwrap(), "at": directory().1}], "sessions": sessions(), "steward": {"name": format!("did:jcp:gateway:{SERVER}"), "key": SigningKey::from_bytes(&STEWARD).verifying_key().to_bytes().iter().map(|b| format!("{b:02x}")).collect::()}, })) } #[test] fn the_published_pack_is_what_the_binding_answers() { let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../conformance/v1/mcp_http.json"); let now = pack(); if std::env::var("JCP_MCP_HTTP_REGEN").is_ok() { std::fs::write(&path, serde_json::to_string_pretty(&now).unwrap() + "\n").unwrap(); return; } let text = std::fs::read_to_string(&path).expect("the published pack; regenerate with JCP_MCP_HTTP_REGEN=1"); let published: Value = serde_json::from_str(&text).unwrap(); assert!(steady(published) == steady(now), "the binding no longer answers as the pack says; regenerate with JCP_MCP_HTTP_REGEN=1 and review the diff"); } /// Every refusal the extension's ยง4 lists is in the pack, and so is an /// allowed call by a root grant and by a delegate. #[test] fn the_pack_covers_every_refusal() { let p = pack(); let expects: Vec<&Value> = p["sessions"].as_array().unwrap().iter().flat_map(|s| s["steps"].as_array().unwrap()).filter_map(|s| s.get("expect")).collect(); let has = |f: &dyn Fn(&Value) -> bool| expects.iter().any(|e| f(e)); for status in [202, 400, 402, 403, 404, 405, 406, 409, 415] { assert!(has(&|e| e["status"] == status), "no step answered {status}"); } for code in [-32700, -32600, -32602, -32020, -32022, -32001] { assert!(has(&|e| e["code"] == code), "no step answered {code}"); } for d in ["allow", "funds-exceeded", "currency-mismatch", "chain-broken", "untrusted-root", "wrong-subject", "expired", "wrong-resource", "attenuation-violation", "chain-broken", "bad-signature", "stale-request"] { assert!(has(&|e| e["decision"] == d), "no step decided {d}"); } for c in ["conflict", "unresolved"] { assert!(has(&|e| e["call"] == c), "no step's call id was {c}"); } for sig in ["label_absent", "key_unknown", "nonce_replayed", "digest_mismatch"] { assert!(has(&|e| e["signature"] == sig), "no step broke {sig}"); } }