Skip to Content
ConceptsAttestation to MCP servers

Attestation to MCP servers

An MCP server behind AgentValet normally sees only the credential you connected it with. It cannot tell a governed child agent from a stolen bearer, or which of your agents made a given call. Attestation closes that: on every governed call to a server that asks for it, the proxy attaches a short-lived, signed statement of the calling agent’s standing, and the server checks it against keys AgentValet publishes.

Off by default. Turn it on per MCP server with PATCH /v1/mcp/platforms/{id}/attestation (admin) and attestation_mode set to header, meta or both.

What the server receives

A compact JWS, alg: ES256, typ: av-attest+jwt, signed with the same evidence key that signs governance receipts, minted per call, valid for 120 seconds, with the server’s own URL as the audience:

{ "iss": "https://api.agentvalet.ai", "sub": "agt_child9f2a1c7b3e4d5f60", "aud": "https://mcp.internal.example.com/mcp", "iat": 1757740800, "exp": 1757740920, "jti": "3f0c6a0e-6d5e-4f0a-9b1c-1a2b3c4d5e6f", "av": { "v": 1, "org_id": "9c1e...", "owner_id": "01e3e01d-...", "delegation": true, "depth": 1, "chain": [ { "agent_id": "agt_7o00saqajyahinuuc6g83", "kind": "root" }, { "agent_id": "agt_child9f2a1c7b3e4d5f60", "kind": "child", "expires_at": "2026-09-13T04:15:00.000Z" } ], "platform": "mcp-mcp-internal-example-com-1a2b3c4d", "scopes_granted": ["mcp-...:get_doc", "mcp-...:search_docs"], "scope_used": "mcp-...:search_docs", "tool": "search_docs", "session_id": "sess_a1b2c3d4e5f60718", "trace_id": "3f0c6a0e-6d5e-4f0a-9b1c-1a2b3c4d5e6f", "approval": null } }

On a call that was held for a human and replayed after approval, approval carries { id, actioned_by, decided_via, actioned_at }.

It travels as the X-AgentValet-Attestation request header (header), as _meta["ai.agentvalet/attestation"] on the tools/call request (meta), or both. meta survives gateways that strip custom headers and reaches the tool handler directly.

Deliberately absent: the parent’s own scopes (the server only needs the child’s slice), tool arguments and headers, any credential or connection id, the owner’s email, the policy trace (that is the receipt), and the person a call ran for.

Verifying it

GET https://api.agentvalet.ai/.well-known/agentvalet-attestation names the key set and the fields. A verifier needs nothing else, and no AgentValet SDK. In TypeScript with jose:

import { createRemoteJWKSet, jwtVerify } from "jose"; const JWKS = createRemoteJWKSet(new URL("https://api.agentvalet.ai/.well-known/agentvalet-receipt-keys.json")); const MY_URL = "https://mcp.internal.example.com/mcp"; // exactly the URL connected in AgentValet export async function verifyAttestation(jws: string | undefined, toolName: string) { if (!jws) return { verified: false, reason: "missing" }; try { const { payload } = await jwtVerify(jws, JWKS, { issuer: "https://api.agentvalet.ai", audience: MY_URL, algorithms: ["ES256"], typ: "av-attest+jwt", clockTolerance: 60, }); const av = payload.av as any; const leaf = av.chain[av.chain.length - 1]; if (leaf.agent_id !== payload.sub) return { verified: false, reason: "chain_sub_mismatch" }; if (av.tool !== toolName) return { verified: false, reason: "tool_mismatch" }; if (!av.scopes_granted.includes(av.scope_used)) return { verified: false, reason: "scope_not_in_slice" }; return { verified: true, av }; } catch (e) { return { verified: false, reason: "bad_signature" }; } } // In an MCP SDK tool handler: server.registerTool("search_docs", { /* ... */ }, async (args, extra) => { const jws = extra.requestInfo?.headers["x-agentvalet-attestation"] as string | undefined ?? (extra._meta as any)?.["ai.agentvalet/attestation"]; const id = await verifyAttestation(jws, "search_docs"); if (!id.verified || !id.av.delegation) { return { isError: true, content: [{ type: "text", text: `denied: ${id.reason ?? "not a delegated child"}` }] }; } // serve; log id.av.chain[0].agent_id (parent), id.av.trace_id });

The four checks that matter: the signature against the published key set, the audience (an attestation minted for server A is worthless at server B), the leaf of the chain being the subject, and the scope used being inside the granted slice. The lab’s own upstream at https://api.agentvalet.ai/lab/mcp runs exactly this verifier: its whoami tool answers verified: true with the chain when the lab platform is connected with attestation on.

What it proves, and what it does not

  • It proves the call came through AgentValet from a specific agent, that the agent held those scopes at that moment, and that the call was inside them. AgentValet already enforced all of that; the attestation lets the server check rather than trust.
  • For a child identity it proves who the parent is and that the child’s slice is what AgentValet attenuated it to. It does not carry the parent’s scopes, so a server cannot re-check attenuation hop by hop; that is AgentValet’s job, and an attestation exists only because that check passed.
  • Claude Code stdio Task-tool subagents share the parent’s identity and are attested as the parent with delegation: false and a session marker; see Child agents.
  • Nothing inside AgentValet reads the attestation back. It is evidence for the server, never a second authorization input.
  • Plain SaaS platforms (Slack, GitHub, Stripe) do not receive it; those calls go through the connector layer, which would drop or reject an unknown header.

Honest limits

  • The attestation is minted after authorization and never fails the call: if the signing key is unavailable the call proceeds without it and the audit row says attested: false. Treat a missing attestation as “not verified”, never as “denied by AgentValet”.
  • Depth is 1 today (a child of a root). The chain format already allows deeper trees.
  • Cross-organisation federation (a partner’s non-AgentValet gateway trusting this) is not built; the issuer is a URL that can become a did:web later and the key set is the same one published in the root DID document.
Last updated on