SEP-3140: Signed Capability Declarations & Trustworthy Trust Labels - #3140
Conversation
|
Note: AI Policy disclosure |
|
Hi @pcarleton and @localden , requesting you to review the SEP and sponsor the changes. |
|
I built a private, vendor-neutral fixture against the independently verified proposal blob for PR 3140 at head The normative checks exercise declaration negotiation and unsigned policy; RFC 8785 plus SHA-256/base64url content hashing; string per-name versions; complete signed manifests; detached JWS/JWKS verification; canonical server binding; time/nonce checks; RFC 9728 publisher discovery with deterministic HTTPS/private/link-local/loopback/DNS-pinning SSRF checks; restrictive unknown trust values; and material-change re-verification and re-gating. The 35/35 focused checks pass on Homebrew Node 24. Three consistency checks that recompute Concrete ambiguities remain: monotonic ordering for string versions; delete/re-add semantics; exact detached-JWS protected headers and signing input; canonical URI equivalence; title/icon changes that alter the complete hash but remain non-material; redirect/proxy/multi-address DNS pinning and publisher-key rotation; clock skew and nonce replay; and missed, duplicated, reordered, or racing Optional secure-by-default authentication at lines 228–238 was not implemented and is documented as an exclusion. The local dependency-free JCS implementation is a fixture limitation relative to the proposal’s reference-implementation recommendation; it is tested against a published RFC 8785 vector but is not a maintained third-party JCS library. If this is useful, I’m happy to share minimized test vectors from the fixture, compare behavior with other implementations, and help turn the remaining ambiguities into independently reproducible candidate conformance cases. AI assistance disclosure: OpenAI Codex assisted with the fixture, tests, analysis, and drafting this comment. I reviewed the fixture, test evidence, analysis, and draft; I understand the claims above and am responsible for all conclusions and any publication. |
|
Hi @localden @pcarleton , I have implemented a PoC for the SEP proposal. Steps to run the PoC:
|
|
Strong direction. Binding signed manifests to One gap I do not see addressed yet: signatures and content hashes attest to what was declared at signing time. They do not, by themselves, constrain what the implementation does afterward. In static audits of MCP servers, most findings are manifest/capability mismatches — undeclared network, write, subprocess, or env reach — where the declared surface stays plausible while code drifts. That failure mode leaves signatures intact; it is not a “bad signature” event. If For context: I recently started weekly static rescans of public MCP server implementations against their declared tool surfaces. I have two observation points so far — 2026-08-02 and 2026-08-03 — so I do not yet have time-series drift measurements to cite. The rescans are specifically to accumulate that series; I will report back if the data bears on whether signed declarations need a non-signature drift channel. Question: does SEP-3140 intend to cover “declaration unchanged, behavior changed” paths, or is that explicitly out of scope for this SEP? |
|
@Tetsurohhori yes, this SEP covers "declaration unchanged, behaviour changed". I've added a working demo showing that a tool doesn't just claim what it can do, it actually gets held to it. The signed capability list is enforceable, so any surprise network, file, or secret access can be detected, blocked and logged. |
|
@omkarparth — thank you for answering the scope question directly, and for shipping something runnable against it. I have data that bears on the enforcement side, from the opposite direction. Over the past week I hand-verified 12 static findings of exactly the class this SEP targets — declared MCP tool surface versus observed code — by fetching each file at its pinned commit and reading the call site. One of the twelve survived. The other eleven were the tool doing the thing it exists to do:
That last one is the case I would most want your demo to have an answer for. The code needed name resolution in order to implement a network restriction. An enforcement layer that blocks "surprise network access" has to permit the call that builds the block list, and the only thing distinguishing it from an exfiltration path is intent. The one that survived is different in kind, and it is public if you want a fixture: So my read is that the difficulty is not enforcement, it is declaration granularity. A capability list precise enough that the filesystem server's ordinary work is not a violation is close to I also have one external label on it. On None of this argues against signed capability declarations. It argues that the signature and the enforcement are the tractable halves, and that whoever writes the declaration is carrying the part that decides whether the mechanism is useful or just noisy. AI-assistance disclosure: the static tooling, the analysis, and this comment were assisted by AI (Claude, Cursor). I read each of the twelve call sites at the pinned commit myself and remain responsible for what is claimed here. |
|
I'm building agent security tooling that (when released) will ship the mechanism this proposal describes, so I wanted to share what the mapping looked like in practice, plus one gap I think is worth closing before this lands. The shape maps cleanly onto what we already run. Immutable, versioned cards where a change is a new version, never an edit in place, matches your content hash plus version. The registry as the root of trust for publisher identity matches how we bind keys to identities. And the closed label vocabulary with unknown meaning most restrictive is the call that makes the whole thing enforceable: free form labels are exactly what made the old MUST unsatisfiable, and keeping the vocabulary orthogonal to the tool scopes work is the right boundary. I'd rather the labels stay risk and sensitivity, and let scopes carry authorisation, than merge the two. The gap: "detect and re-gate" on a material change is necessary but not sufficient imho. Detection tells the host something changed, and re-gating suggests the gate runs again. What I'd make explicit is that a version bump invalidates prior approval. Without that, a quiet bump after approval lands new declarations in front of the model with the gate re-run against the old consent. The difference matters: re-running a policy check is not the same as asking for approval again. One question, your PoC covers the signing path well. Is there appetite for a signed declarations conformance test living in the spec repo, so a client can check that a server actually signs before it trusts? The MUST stays unsatisfiable until that check is a thing a client can run. |
|
@damoclais — two data points, one for each half of your comment. Both are our own failures, so On "a version bump invalidates prior approval". I agree it needs saying, and I would bind the The label was ours to write and it said something untrue. The hash could not have. If prior The cost is noise: hash-triggered re-approval fires on a whitespace fix. That seems like the On a signed-declarations conformance test in the spec repo. Yes, and I would put one We learned that the expensive way. Running an external conformance suite against our own verifier That shape is not a one-off. We have three instances of it in our own ledger and the suite Concretely, for signed declarations, a vector like "signature valid but declaration unsigned" We publish Ed25519 attestations with a verification script a third party can run against a URL Disclosure: the implementation, the conformance runs cited above, and this comment were assisted |
|
On hash-keyed invalidation: agreed, the VCL-0004 example is the argument. A label is a claim the producer gets to write; a hash is not. It also matches how we key consent. Our offers are content-identified: the response binds to the offer's hand hash, which covers the full payload including the nonce and timestamp, so consent binds to the bytes and a re-issued offer is always a fresh consent. A re-mint of an underlying card never touches an existing offer, because the offer pins the deck manifest hash. One thing your rule inherits from the current text: the SEP specifies a content hash but not what bytes it covers. Two implementations can hash different serialisations of the same declaration and get different hashes, which quietly breaks cross-implementation invalidation, and it is where the whitespace noise cost comes from. If the hash covers the canonical serialisation (RFC 8785, as the fixture already does), formatting noise disappears and the rule becomes portable. Worth making canonicalisation explicit in the spec text; right now the rule depends on it without saying so. Materiality does not disappear but it moves. "Any change is a hash change" is right as the invalidation trigger, but the title/icon case already flagged in this thread shows the prompting layer still needs a materiality notion, or a description tweak re-asks the human. Two tiers: re-validate on any hash change, re-prompt only on material change. Same trigger, better UX, and "material" stops being a security term. On reason-asserting vectors: agreed, and the sharp way to state the failure is that a verifier which rejects everything passes every negative vector. One boundary for the conformance test's requirements: the reason must be assertable in the harness, not required on the wire, because a server disclosing why it stopped is an oracle. The verifier reports the reason to the auditor; the server stays quiet. Both are testable; they are different surfaces. And closing the loop with your rescan thread: this covers the declared surface only. The "declaration unchanged, behaviour changed" class is the SEP's own non-goal (signing establishes provenance, not honesty), so it still needs the drift channel you are building. |
|
@damoclais — agreed on the invalidation binding, and the consent parallel lands: an offer that The correction first. I said the verdict itself must say when a branch emitted it without On "the SEP specifies a content hash but not what bytes it covers": I have a fresh instance of The point for the spec: my hash was not wrong about the bytes it covered. It was silent about Your two tiers resolve the cost I raised. Re-validate on any hash change, re-prompt only on On the closing point: agreed, and I would rather it stay a non-goal here than be half-solved. Disclosure: this comment was assisted by AI (Claude, Cursor). I checked the ledger row and its |
|
Following up on my August 2 offer, here is a minimized, protocol-neutral reproduction of the failure mode @Tetsurohhori described and the harness/wire boundary @damoclais clarified: https://gist.github.com/Silentpartnercoding/2c5ddb251d4b60f337b86b0452409950
It runs the same vector against two implementations:
The mutant rejects on The vector binds four machine-readable fields: Local result on Node v24.18.1: 3/3 tests pass, including an explicit assertion that verdict-only conformance stays green while reason-bound conformance kills the mutant. This is a reproduction, not a claim of official MCP conformance. AI assistance disclosure: OpenAI Codex assisted with the fixture, tests, analysis, and drafting this comment. I reviewed the fixture, test evidence, and draft and remain responsible for the claims and publication. |
|
@Silentpartnercoding — I ran it. Three points back: one portability datum, one hole, and the Portability. 3/3 on Node v20.20.2, not just the v24.18.1 you cited. Same output from the The hole. The fixture kills the mutant it ships, but the four fields it binds — function lyingRejectAll(_input) {
return { verdict: "reject", property_under_test: "manifest_membership_binding",
property_reached: true, stop_reason: "DECLARATION_NOT_IN_MANIFEST" };
}So @damoclais's exact hazard — a verifier that rejects everything passes every negative vector — The patch. Pair the negative vector with a positive one — same manifest, declaration present, reject-all now dies on Getting to reason-binding that is observed rather than reported means instrumenting the One note on why I went looking. I published a hash eight days ago whose verifier read the value Disclosure: this comment was assisted by AI (Claude, Cursor). I ran the fixture and both probes |
|
@Tetsurohhori — thank you. You are right about both the hole and the narrower value of reason binding. I updated the same gist at commit https://gist.github.com/Silentpartnercoding/2c5ddb251d4b60f337b86b0452409950 The fixture now includes:
The corrected discrimination is:
Results: 5/5 tests pass on Node v20.20.2 and v26.5.0. The README now says explicitly that self-reported reason binding kills accidental short-circuits but is not an independent execution oracle. The positive control supplies the missing behavioral discrimination without requiring implementation instrumentation. Thank you for running the fixture, reducing the failure, and giving the exact portability target. AI-assistance disclosure: OpenAI Codex assisted with the patch, test execution, analysis, and drafting this response. I authorized publication and remain responsible for the claims made here. |
|
@Silentpartnercoding @Tetsurohhori @damoclais Thank you for all your comments. |
This SEP proposes an additive mechanism for MCP servers to make their declared capabilities — the
tools/list,prompts/list, andresources/listoutputs a client feeds to a model — authenticatable, integrity-protected, versioned, and labeled with a trustworthy, standardized risk vocabulary.It introduces (1) a content hash + version on each declaration, (2) a signed capability manifest (JWS) bound to a discoverable publisher identity, (3) a standardized, signature-covered
trustlabel block (effect / egress / data-sensitivity / reversibility), and (4) change semantics fornotifications/*/list_changedthat let a client detect and re-gate material changes ("rug pulls").A secondary, optional section proposes a secure-by-default posture for authentication.
The goal is to convert the spec's current unactionable guidance — "clients MUST consider tool annotations untrusted unless they come from a trusted server" — into something any MCP client can actually verify and enforce. The gap is inherent to the protocol and affects every client — interactive / human-in-the-loop and autonomous alike; autonomy makes it acute, but a human approver cannot verify authenticity or detect a silent change either.
Motivation and Context
The gap
MCP tool declarations are model-facing instructions in all but name: a model reads a tool's
description,inputSchema, andannotationsto decide when and how to invoke it. Yet the protocol delivers these with no authenticity, no integrity, and no trustworthy risk labeling. The specification acknowledges the danger but delegates it without providing a mechanism:There is no protocol mechanism to establish that a server is "trusted," to verify that a declaration is authentic, or to detect that it changed. The
MUSTis therefore unsatisfiable: the implementer has been assigned a trust decision the protocol renders unverifiable.Two concrete attack classes follow directly:
description; the model treats them as instructions (indirect prompt injection).notifications/tools/list_changedand silently swaps in malicious definitions. There is no version, hash, or re-consent contract to catch this.Why this belongs in the protocol, not the implementer
The natural objection is "let the client/host handle it." That fails for this specific class:
annotationscannot back a security decision. They are attacker-controlled and explicitly untrusted, so a host cannot use them to gate anything (reliance on them is CWE-807).In short: provenance and trustworthy labeling of what a server declares is the one security property that neither the host nor OAuth can synthesize on their own — it requires a protocol primitive.
Who is affected: every client — a human in the loop does not close the gap
This is a protocol-level gap that affects every MCP client, not only autonomous ones:
description/annotations; they have no way to verify that the declaration is authentic, unmodified, or unchanged since they approved it. A poisoned description reads as legitimate, and a silentlist_changedrug pull happens after approval. Human review therefore does not mitigate these attacks — it is the same unverified text either way.Authenticated declarations + machine-enforceable labels help any host: they surface verifiable provenance and risk to a human reviewer, and enable policy (risk-graduated approval, egress control, information-flow constraints) where there is no human. Both modes benefit from the same primitive.
Goals and Non-Goals
Goals
Non-Goals
How Has This Been Tested?
Breaking Changes
No. The proposed changes are fully backward compatible and the MCP server integrations secure moving forward.
Types of changes
Checklist
Additional context
Note:Extends / complements (does not replace): the Server Card WG, Tool Annotations IG, Tool Scopes WG, the Registry,
ext-auth, and the Security Best Practices.Relationship to existing work
This SEP is deliberately additive and complementary to work already underway in several MCP groups; it extends and composes with them rather than replacing any.
trustblock is a signed, standardized subset of tool annotations. This SEP builds on the annotations model by adding cryptographic integrity and a closed, host-enforceable vocabulary; it does not replace free-formannotations(which remain untrusted-by-default).trustlabels describe the risk / sensitivity a host gates on. The two are orthogonal and composable; this SEP aligns its label vocabulary with tool-scopes work rather than duplicating it.ext-auth— identity / authorization extensions align with the secondary secure-by-default posture and the publisher-identity model.Alignment with MCP design principles
Key design decisions
A signed manifest (JWS) rather than per-field signatures. One signature transitively authenticates every declaration through its contentHash, keeping messages small and verification a single operation.
Reuse of RFC 9728 Protected Resource Metadata for key discovery. No new discovery surface is introduced; the publisher JWKS and identity sit beside the existing authorization_servers and inherit the SSRF protections MCP already requires.
Binding to the RFC 8707 canonical server URI. Prevents replay of an otherwise-valid manifest against a different server (audience confusion).
Closed, enumerated trust labels (unknown ⇒ most-restrictive). A host can make a deterministic policy decision instead of parsing free text; this is what makes the labels usable for gating — whether surfacing risk to a human reviewer or enforcing policy without one.
Hashes in the manifest rather than full declaration bodies. Compactness; full-body signing is offered as a stricter option (see Open questions).