From d6468630f575f4f53c953c778cd8a2a99d34539f Mon Sep 17 00:00:00 2001 From: Vishal Katyal Date: Fri, 28 Aug 2026 19:42:16 -0400 Subject: [PATCH 1/4] fix(idempotency): define payload identity per operation class Raw-body SHA-256 matching treats every MCP retry as a payload mismatch, because the JSON-RPC envelope (id, and meta when unpinned) varies per request while the logical operation does not. This affects exactly the operations idempotency exists to protect: complete_checkout and cancel_checkout. Replaces the single raw-body-hash rule in signatures.md (Payload Matching, under Replay Protection) with two classes decided from the operation's input schema: - Target-only operations (arguments carry nothing beyond meta and the target resource identifier, e.g. cancel_checkout, cancel_cart): identity is (idempotency key, target resource identifier); no hashing. - Payload-carrying operations (e.g. complete_checkout): hash over the operation's arguments with the transport envelope excluded, not the JSON-RPC message bytes. For REST the hashed input remains the raw body bytes, the same digest RFC 9530 mandates as Content-Digest. States, in signatures.md (Idempotency Key Placement) and restated in the checkout MCP binding, which key location is authoritative when a signed request carries the key in both the covered Idempotency-Key header and a payload copy such as meta["idempotency-key"]. Aligns the duplicate-key items in the two REST binding summaries (checkout, cart) with the class split: they now say payload rather than request body, deferring to the payload-matching contract they already reference. Refs #664. --- docs/specification/shopping/cart/rest.md | 4 +- docs/specification/shopping/checkout/mcp.md | 8 +++ docs/specification/shopping/checkout/rest.md | 4 +- docs/specification/signatures.md | 56 +++++++++++++++----- 4 files changed, 56 insertions(+), 16 deletions(-) diff --git a/docs/specification/shopping/cart/rest.md b/docs/specification/shopping/cart/rest.md index 9ad0f01bc..483b4c5d7 100644 --- a/docs/specification/shopping/cart/rest.md +++ b/docs/specification/shopping/cart/rest.md @@ -482,8 +482,8 @@ operations unless otherwise noted. * **Idempotency-Key**: Operations that modify state **SHOULD** support idempotency. When provided, the server **MUST**: 1. Store the key with the operation result for at least 24 hours. - 2. Return the cached result for duplicate keys whose request body matches the original. - 3. Return `409 Conflict` if the key is reused with a mismatched body. + 2. Return the cached result for duplicate keys whose payload matches the original. + 3. Return `409 Conflict` if the key is reused with a mismatched payload. See [Message Signatures — Idempotency Key Requirements](../../signatures.md#replay-protection) for the full payload-matching contract. diff --git a/docs/specification/shopping/checkout/mcp.md b/docs/specification/shopping/checkout/mcp.md index d0f281c94..b95a5cf0e 100644 --- a/docs/specification/shopping/checkout/mcp.md +++ b/docs/specification/shopping/checkout/mcp.md @@ -109,6 +109,14 @@ The `meta["ucp-agent"]` field is **required** on all requests to enable `meta["idempotency-key"]` for retry safety. Platforms **MAY** include additional metadata fields. +When the request is signed, the covered `Idempotency-Key` header (see +[Message Signing](#message-signing)) is authoritative for the idempotency +key; `meta["idempotency-key"]` **MUST** equal it, and the Business +**MUST** reject a mismatch without executing. On unsigned requests, +`meta["idempotency-key"]` alone is authoritative. See +[Message Signatures — Replay Protection](../../signatures.md#replay-protection) +for the full payload-matching contract. + ## Tools UCP Capabilities map 1:1 to MCP Tools. diff --git a/docs/specification/shopping/checkout/rest.md b/docs/specification/shopping/checkout/rest.md index 1faa4e40f..492e465ed 100644 --- a/docs/specification/shopping/checkout/rest.md +++ b/docs/specification/shopping/checkout/rest.md @@ -1308,8 +1308,8 @@ operations unless otherwise noted. * **Idempotency-Key**: Operations that modify state **SHOULD** support idempotency. When provided, the server **MUST**: 1. Store the key with the operation result for at least 24 hours. - 2. Return the cached result for duplicate keys whose request body matches the original. - 3. Return `409 Conflict` if the key is reused with a mismatched body. + 2. Return the cached result for duplicate keys whose payload matches the original. + 3. Return `409 Conflict` if the key is reused with a mismatched payload. See [Message Signatures — Idempotency Key Requirements](../../signatures.md#replay-protection) for the full payload-matching contract. diff --git a/docs/specification/signatures.md b/docs/specification/signatures.md index 88683e25b..da2112ced 100644 --- a/docs/specification/signatures.md +++ b/docs/specification/signatures.md @@ -823,6 +823,13 @@ Signature-Input: sig1=("@method" "@authority" "@path" "idempotency-key" ...);key Signature: sig1=:6G4i8TS6oUkGrx8KnCFUpsSPwd74...: ``` +Some bindings also carry the key inside the request payload (over MCP, +`meta["idempotency-key"]` in the tool arguments). When the request is +signed, the covered `Idempotency-Key` header is authoritative for the +idempotency key; a payload copy of the key **MUST** equal it, and +Businesses **MUST** reject a mismatch without executing. On unsigned +requests, the payload copy alone is authoritative. + **Idempotency Key Requirements:** | Requirement | Value | @@ -834,18 +841,43 @@ Signature: sig1=:6G4i8TS6oUkGrx8KnCFUpsSPwd74...: | **On duplicate (mismatched payload)** | Reject with `409 Conflict` (REST) / `-32000` (MCP); do not execute | | **On storage failure** | Fail closed (reject request with 503) | -**Payload Matching:** Businesses **MUST** detect whether the payload of -a duplicate-key request matches the payload of the original by -comparing the SHA-256 hash of the raw body bytes — the same digest -RFC 9530 mandates as `Content-Digest`. When signing is in use, this -value is supplied in the `Content-Digest` header and the Intermediary -Warning above guarantees byte fidelity end-to-end; businesses persist -it alongside the idempotency key. For unsigned requests, businesses -compute the same digest from the received body bytes. Platforms -therefore **MUST** generate a fresh idempotency key whenever they -modify the request payload — including retries with modified payment -instruments, updated shipping addresses, swapped line items, or any -other change to the request body. +**Payload Matching:** Payload identity is defined per operation, by +whether the operation's arguments carry anything beyond `meta` and the +target resource identifier — a class decidable from the operation's +input schema alone, not from the transport envelope: + +* **Target-only operations.** The operation's arguments carry nothing + beyond `meta` and the target resource identifier (the path parameter + for REST, the top-level `id` argument for MCP) — `cancel_checkout` is + in this class by schema. Payload identity is the pair (idempotency + key, target resource identifier); Businesses persist the identifier + alongside the key. Businesses **MUST** treat a request as a replay, + and return the stored result, when its key matches a stored key and + its target resource identifier matches the identifier stored with + that key. Businesses **MUST** reject, without executing, a request + whose key matches a stored key but whose target resource identifier + differs from the identifier stored with that key. No hashing or + canonicalization applies to this class. +* **Payload-carrying operations.** The operation's arguments carry + anything beyond `meta` and the target resource identifier — + `complete_checkout` is in this class by schema. Businesses **MUST** + detect a payload mismatch by comparing the SHA-256 hash of the + operation's arguments with the transport envelope excluded (the + request body for REST; the `params.arguments` object with `meta` + removed for MCP) — not the full JSON-RPC message bytes, whose + envelope fields (the JSON-RPC `id`, `meta`) are guaranteed to vary + per retry. Businesses persist this hash alongside the key; for REST + the hashed input remains the raw body bytes, the same digest RFC 9530 + mandates as `Content-Digest`. The Business computes both the stored + and the compared hash itself, from each request as received, applying + the same deterministic procedure to exclude `meta`; no cross-party + canonical form is defined or needed, which preserves the spec's + no-canonicalization posture. + +Platforms **MUST** generate a fresh idempotency key whenever they modify +a payload-carrying operation's arguments — including retries with +modified payment instruments, updated shipping addresses, swapped line +items, or any other change to the request payload. **Note:** For **default UCP** signatures, the RFC 9421 `created` parameter is **OPTIONAL** and replay protection is handled at the From d36f2fb61e6bb3c9f51d115d9e5833ead08e6a1f Mon Sep 17 00:00:00 2001 From: Vishal Katyal Date: Sat, 29 Aug 2026 08:03:20 -0400 Subject: [PATCH 2/4] fix(idempotency): bind the stored key to its operation and target Wire probes of both reference implementations against a literal reading of the class rules surfaced two gaps: the class 2 identity omitted the REST path identifier, recreating for updates the cross resource replay the class 1 rule fixes for cancels, and cross operation key reuse was undefined while update and complete share identical argument shapes. A stored key now binds its operation and target resource identifier, both classes resolve reuse mismatches by rejecting before any payload comparison, and the class definitions are unchanged. Both references already persist exactly this binding, so the rule codifies existing practice. --- docs/specification/signatures.md | 33 ++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/docs/specification/signatures.md b/docs/specification/signatures.md index da2112ced..0f30068ef 100644 --- a/docs/specification/signatures.md +++ b/docs/specification/signatures.md @@ -841,23 +841,26 @@ requests, the payload copy alone is authoritative. | **On duplicate (mismatched payload)** | Reject with `409 Conflict` (REST) / `-32000` (MCP); do not execute | | **On storage failure** | Fail closed (reject request with 503) | -**Payload Matching:** Payload identity is defined per operation, by -whether the operation's arguments carry anything beyond `meta` and the -target resource identifier — a class decidable from the operation's -input schema alone, not from the transport envelope: +**Payload Matching:** A stored idempotency key binds the operation it +was first used with and, where one exists, the target resource +identifier (the path parameter for REST, the top-level `id` argument +for MCP). Businesses **MUST** reject, without executing, any request +that reuses a stored key with a different operation or a different +target resource identifier. Within that binding, payload identity is +defined per operation, by whether the operation's arguments carry +anything beyond `meta` and the target resource identifier — a class +decidable from the operation's input schema alone, not from the +transport envelope: * **Target-only operations.** The operation's arguments carry nothing beyond `meta` and the target resource identifier (the path parameter for REST, the top-level `id` argument for MCP) — `cancel_checkout` is - in this class by schema. Payload identity is the pair (idempotency - key, target resource identifier); Businesses persist the identifier - alongside the key. Businesses **MUST** treat a request as a replay, - and return the stored result, when its key matches a stored key and - its target resource identifier matches the identifier stored with - that key. Businesses **MUST** reject, without executing, a request - whose key matches a stored key but whose target resource identifier - differs from the identifier stored with that key. No hashing or - canonicalization applies to this class. + in this class by schema. Payload identity is the key binding itself: + Businesses persist the operation and the target resource identifier + alongside the key, and **MUST** treat a request as a replay, and + return the stored result, when key, operation, and identifier all + match the stored record. No hashing or canonicalization applies to + this class. * **Payload-carrying operations.** The operation's arguments carry anything beyond `meta` and the target resource identifier — `complete_checkout` is in this class by schema. Businesses **MUST** @@ -866,7 +869,9 @@ input schema alone, not from the transport envelope: request body for REST; the `params.arguments` object with `meta` removed for MCP) — not the full JSON-RPC message bytes, whose envelope fields (the JSON-RPC `id`, `meta`) are guaranteed to vary - per retry. Businesses persist this hash alongside the key; for REST + per retry. The hash is compared within the key binding above, so a + reused key is rejected on an operation or target mismatch before any + hash comparison. Businesses persist this hash alongside the key; for REST the hashed input remains the raw body bytes, the same digest RFC 9530 mandates as `Content-Digest`. The Business computes both the stored and the compared hash itself, from each request as received, applying From 715d393942d424b2d37f0a2b6508dba0f971f484 Mon Sep 17 00:00:00 2001 From: Vishal Katyal Date: Sat, 29 Aug 2026 11:56:45 -0400 Subject: [PATCH 3/4] fix(idempotency): serialize the MCP class hash input with JCS Review on the PR identified that removing meta from params.arguments is an operation on a parsed value, not on bytes, so describing the hash input in raw byte terms hid an unnamed per implementation canonical form, and member order variance across retries would reproduce the guaranteed retry mismatch the issue opened on. The payload carrying class now serializes the arguments object, with its top level meta member removed, using the JSON Canonicalization Scheme per RFC 8785, the canonicalization this specification already uses for AP2 mandate artifacts. The no canonicalization statement is scoped to REST, where the hashed input really is the single byte string received. --- docs/specification/signatures.md | 35 ++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/docs/specification/signatures.md b/docs/specification/signatures.md index 0f30068ef..bed94daa3 100644 --- a/docs/specification/signatures.md +++ b/docs/specification/signatures.md @@ -865,19 +865,28 @@ transport envelope: anything beyond `meta` and the target resource identifier — `complete_checkout` is in this class by schema. Businesses **MUST** detect a payload mismatch by comparing the SHA-256 hash of the - operation's arguments with the transport envelope excluded (the - request body for REST; the `params.arguments` object with `meta` - removed for MCP) — not the full JSON-RPC message bytes, whose - envelope fields (the JSON-RPC `id`, `meta`) are guaranteed to vary - per retry. The hash is compared within the key binding above, so a - reused key is rejected on an operation or target mismatch before any - hash comparison. Businesses persist this hash alongside the key; for REST - the hashed input remains the raw body bytes, the same digest RFC 9530 - mandates as `Content-Digest`. The Business computes both the stored - and the compared hash itself, from each request as received, applying - the same deterministic procedure to exclude `meta`; no cross-party - canonical form is defined or needed, which preserves the spec's - no-canonicalization posture. + operation's arguments with the transport envelope excluded — not the + full JSON-RPC message bytes, whose envelope fields (the JSON-RPC + `id`, `meta`) are guaranteed to vary per retry. The hash is compared + within the key binding above, so a reused key is rejected on an + operation or target mismatch before any hash comparison, and + Businesses persist this hash alongside the key. The hashed input is + transport specific: + + * For REST, the raw request body bytes as received, the same digest + RFC 9530 mandates as `Content-Digest`. No canonicalization: the + body is a single byte string on the wire and is hashed as such. + * For MCP, the `params.arguments` object with its top-level + `meta` member removed, serialized with the JSON + Canonicalization Scheme + ([RFC 8785](https://datatracker.ietf.org/doc/html/rfc8785)), the + canonicalization this specification already uses for durable + artifacts in + [AP2 Mandates](payment/extensions/ap2-mandates.md#canonicalization). + Removing a member is an operation on a parsed value, not on bytes, + so this class adopts canonicalization explicitly: logically + identical retries hash identically regardless of member order or + serializer, tolerating re-serialization across client stacks. Platforms **MUST** generate a fresh idempotency key whenever they modify a payload-carrying operation's arguments — including retries with From 3b850567dbba56b3c9ca1da0fd002ad43472ea7c Mon Sep 17 00:00:00 2001 From: Vishal Katyal Date: Sun, 30 Aug 2026 19:54:23 -0400 Subject: [PATCH 4/4] fix(idempotency): scope the MCP no canonicalization sentence to signing The MCP Transport section stated no JSON canonicalization is required without qualification. That is true for signing, where Content-Digest binds the raw JSON-RPC body, but this PR defines JCS canonicalization for payload matching in the payload carrying class one section above. Scoping the sentence to signing removes the apparent contradiction and points at the Replay Protection rules for the matching path. --- docs/specification/signatures.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/specification/signatures.md b/docs/specification/signatures.md index bed94daa3..7056de19e 100644 --- a/docs/specification/signatures.md +++ b/docs/specification/signatures.md @@ -952,8 +952,10 @@ Signature: sig1=:6G4i8TS6oUkGrx8KnCFUpsSPwd74...: {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"complete_checkout","arguments":{"id":"chk_123","checkout":{...}}}} ``` -The JSON-RPC message is the HTTP body. `Content-Digest` binds it to the signature. -No JSON canonicalization is required. +The JSON-RPC message is the HTTP body. `Content-Digest` binds it to the signature; +no JSON canonicalization is required for signing. For payload matching, the +payload-carrying class canonicalizes `params.arguments` with JCS, as defined under +[Replay Protection](#replay-protection). ## Error Handling