open-standards/jcp/jcp-rs/crates/jcp-gateway/tests/http_pack.rs
Transaction Science 6223c9c50b
All checks were successful
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (push) Successful in 14m10s
C36 pack: priced tools and x402, decided by the second reader and their answers held to the step
The published steps covered the transport, the caller and the chain;
the money rules of mcp-over-http.md §6 had no second reader. A `priced`
session now prices `echo` (USD) and sells `paid` by x402 (upto) through
a facilitator whose behaviour the pack states: a call under a money
ceiling, under none, in another currency, under one the cap does not
fit; a sold tool unpaid, paid for other terms, paid with an invalid
authorization, paid, and paid but refused before it runs.

Those answers vary with the meter, so each step keeps what its answer
carried under `observed` (the generator's equality check leaves it out),
and verify_mcp_http.py holds it to the step: the receipt verifies under
the steward's key, the charge is the tariff's price of the receipt's
cost_uj, a funded receipt debits it, and a paid receipt commits to the
x402 settlement record it recomputes in Python. Its self-test also
tampers with the answers (9 ways) and requires each caught, by those
checks and not by the decisions: 63 steps, 44 rules, 9 tampers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-14 07:14:29 -05:00

629 lines
30 KiB
Rust

//! 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<String> {
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<SignedGrant> {
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<SignedGrant> {
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<SignedGrant> {
let mut chain = delegated(tool);
chain[0] = grant(&operator(), did(&agent()), tool, BUDGET, 0, 9);
chain
}
fn rpc(id: Option<u64>, 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::<Vec<_>>() });
}
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<u8>,
nonce: &'static str,
created: Option<i64>,
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<String>,
/// An x402 authorization, sent in `PAYMENT-SIGNATURE` and signed over.
payment: Option<String>,
}
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<Mutex<Vec<u8>>>,
steps: Vec<Value>,
/// 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<op_rails_x402::v2::VerifyResponse, op_rails_x402::v2::Error> {
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<SettlementResponse, op_rails_x402::v2::Error> {
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<op_rails_x402::v2::SupportedResponse, op_rails_x402::v2::Error> {
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<String> {
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<u8>, 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<Mutex<Vec<u8>>>) -> 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::<Vec<_>>(),
"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<Value>) {
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<Value> {
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<Value> {
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, &region);
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"}), &region);
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!({}), &amplified("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<Value> = 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::<String>()},
}))
}
#[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}");
}
}