jcp-gateway: make enforcement discoverable, and refusable #157
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/mcp-extensions"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes the item the 2026-10-04 sweep ranked as the highest strategic leverage per unit of work, and records a measurement that bears on an open PR I cannot comment on.
The gap
Everything
jcp-gatewaydoes was private convention. It gates, meters, debits and seals everytools/call,resources/readandprompts/get— and a client had no way to discover that, and no way to insist on it. A client that dropped the_metablock got served anyway, and a client talking to a gateway that enforced nothing could not tell the difference.Enforcement nobody can require is a courtesy, not a control.
Both halves
The advertisement.
initializeis now the one discovery method not relayed verbatim: the gateway adds its own entry toresult.capabilities.extensions, under the same reverse-domain key it already uses for per-call side data (science.transaction/jcp) — one name for one thing. MCP's 2026-07-28 revision formalisedcapabilities.extensionsfor exactly this, with reverse-domain third-party keys and a 12-month deprecation policy behind it, so this has a defined place to live rather than an invented one. It names the receipt profile it seals with and the methods it enforces, so a client can require the parts it needs rather than the whole bundle.The refusal.
require_jcp(initialize_response, required)is what makes the advertisement worth making. A client calls it before sending anything and fails closed: an endpoint advertising nothing is refused, and so is one that enforcestools/callbut notresources/read— the case that matters, since the resource read was the egress door standing open until #153.Tests
Three, each falsified from the committed baseline:
require_jcpwith the right profile and method list — disabling theinitializebranch fails it withNotAdvertised("science.transaction/jcp");require_jcptolerate a missing extension fails it;protocolVersion,serverInfo, its owncapabilities.toolsand a third party's existingextensionsentry all survive, and a response that is not a JSON-RPC result comes back byte-identical. That last property is the oneattach_receiptmust also have — never mangle a payload you do not understand.RequireError'sDisplayis hand-written: this crate carries nothiserrordependency and three variants are not worth adding one for.396 tests pass across the jcp workspace, 0 clippy findings in the crate.
⚠️ A measurement for #146, which I could not post there
The forge credential available to me is repo-scoped with no issue scope — even listing issue comments returns 403 — so this is recorded in
jcp/tools/counter-resolution/README.mdwhere the constant it concerns will be checked, rather than on the PR.#146's
RAIL_EDGE_WAIT = 1000 msis exceeded at about 1 in 1000 idle gaps. Sampling the AppleGPU Energyrail every 2 ms over 90 s (n=2936), three gaps ran past a second — 1446, 1919 and 2033 ms — the worst being 2× the wait. A 6 s run had given a max of 992.2 ms, which sits just inside 1 s and invites exactly the wrong conclusion; the sample max is not a bound. #146's design is right to wait for the next republish rather than assume a period, and per its own docs a timed-out snapshot "bounds no span" — but that makes a path intended to be deterministic drop a figure ~0.1% of the time, invisibly unless counted. Bounding above ~2.5 s, or counting the timeout, would close it.And the condition attribution is inverted. #146's body says the rail "sometimes hold[s] 300–600 ms under load". Measured, under sustained load it is tight — p50 13.1 ms, max 42.0 ms (n=435) — and the long tail belongs to idle (p99 436 ms, max 2033 ms). Both observations reconcile once the cause is named: the GGUF decode the 300–600 ms figure came from is dispatch-latency-bound with idle gaps between tokens, so it was sampling idle behaviour. 300–600 ms is about the idle p98–p99 here. This matters for the constant, because it has to survive the condition a snapshot is most likely taken in — and a decode's inter-token gaps are that condition.
Harness is
jcp/tools/counter-resolution/gpu_rail_idle_tail.py, so it is re-runnable rather than quoted.Second commit: refuse a tool whose parameter schema changed under its name
This is the other hole the sweep named, and it belongs with the first: both are about enforcement a client cannot currently see or insist on.
A grant binds
mcp_resource(server, tool)— a hash of the tool's name — and the action list.Capability { resource, actions }has no field for the tool's parameter shape. So a server can advertiseread_file(path: string), take a grant, and later advertiseread_file(command: string): same name, same content-addressed resource, same action, same signature. Every check passes, the chain still verifies, and the authority the client issued now covers arguments it never saw.Closed at the enforcement point, the only place the shape is observable without changing the signed wire format.
tools/listis read on the way back — read, never rewritten — eachinputSchemais content-addressed, and atools/callto a tool whose digest has since changed is refused before any flow labelling or debit, sealing like every other refusal.Three deliberate properties:
inputSchemarecords the null schema, so the first real one is a contradiction rather than a first sighting.And the honest limit, stated in the field's own doc comment: this is trust-on-first-listing. It catches a shape that changes under a gateway that has seen it listed, and promises nothing about a tool this gateway never watched being listed. Binding the digest into the
Capabilityis the stronger fix and a wire change; this is what is enforceable today.It composes with the first commit:
tool_schema_pinnedis advertised ininitializeand surfaced onJcpAdvertisement, so a client can require it — and an absent field does not read as enforced.Falsified three ways from the committed baseline: stop observing the listing, notice the change but serve anyway, or let reverting clear the flag — each fails the test. The middle one is the failure mode in miniature, since it shows the rug-pulled call being served with a complete sealed receipt.
398 tests across the jcp workspace, 0 clippy findings in the crate.
🤖 Generated with Claude Code
A grant binds `mcp_resource(server, tool)` — a hash of the tool's **name** — and the action list. `Capability { resource, actions }` has no field for the tool's parameter shape, so nothing in a signed grant describes what the arguments mean. So a server can advertise `read_file(path: string)`, take a grant for it, and later advertise `read_file(command: string)`. Same name, same content-addressed resource, same action, same signature: every check passes and the chain still verifies. The authority the client issued now covers arguments it never saw. That is a rug-pull JCP could not see, and a grant cannot be made to see it without changing the signed wire format. Closed at the enforcement point, which is the only place the shape is observable without that wire change. `tools/list` is now read on the way back — read, never rewritten — and each tool's `inputSchema` is content-addressed. A `tools/call` to a tool whose digest has since changed is refused **before any flow labelling or debit**, and the refusal seals like every other, so it is recorded rather than silently dropped. Three properties worth stating because they are choices, not accidents: - **Reverting is still a change.** A server that swaps the shape and swaps it back has still changed it, and nothing says which of the two a later call means, so the flag does not clear. - **A tool that gains a schema where it had none is a change.** A listing without `inputSchema` records the null schema rather than nothing, so the first real schema is a contradiction and not a first sighting. - **Key order is not a change.** The digest is over the canonical form, so a server reordering its JSON does not trip the gate. And one honest limit, in the field's own doc comment: this is **trust-on-first-listing**. It catches a shape that changes under a gateway that has seen it listed, and promises nothing about a tool this gateway never watched being listed. Binding the digest into the `Capability` itself is the stronger fix and a wire change; this is what is enforceable today. Composed with the advertisement from the previous commit: `tool_schema_pinned` is announced in `initialize` and surfaced on `JcpAdvertisement`, so a client can *require* it rather than hope for it — and an absent field does not read as enforced. 398 tests across the jcp workspace, 0 clippy findings in the crate. Co-Authored-By: Claude Opus 5 <[email protected]>