diff --git a/docs/design-sketch-proposal.md b/docs/design-sketch-proposal.md index f561c8b..39215a3 100644 --- a/docs/design-sketch-proposal.md +++ b/docs/design-sketch-proposal.md @@ -107,16 +107,14 @@ There are three delivery modes with different subscription mechanisms: | Code | Message | Meaning | |------|---------|---------| -| `-32602` | `InvalidParams` | Params don't match inputSchema (standard JSON-RPC invalid params) | -| `-32011` | `EventNotFound` | Unknown event name | -| `-32012` | `Unauthorized` | User lacks permission for this event/params combination | -| `-32013` | `TooManySubscriptions` | Server-imposed subscription limit reached | -| `-32014` | `EndpointVerificationFailed` | Callback endpoint was reachable but failed the verification handshake — did not echo the challenge (webhook mode only) | -| `-32015` | `InvalidCallbackUrl` | Webhook URL is unreachable or rejected by the server (webhook mode only) | -| `-32016` | `SubscriptionNotFound` | No subscription matches the supplied key for `events/unsubscribe` (webhook mode only) | -| `-32017` | `DeliveryModeUnsupported` | Event type does not support the requested delivery mode | +| `-32602` | `InvalidParams` | Request is statically invalid — params don't match the event's inputSchema, the callback `delivery.url` is malformed or non-`https`, or `delivery.secret` is not a valid `whsec_` value (standard JSON-RPC invalid params). | +| `-32011` | `NotFound` | A referenced entity does not exist — an unknown event name, or no subscription matching the key on `events/unsubscribe`. The method implies which; `data.kind` (`"event"` \| `"subscription"`) MAY disambiguate. | +| `-32012` | `Forbidden` | The authenticated principal is not permitted for this event/params combination, or its access was revoked. | +| `-32013` | `ResourceExhausted` | A server-imposed limit or quota was reached. `data.limit` names it (e.g. `"subscriptions"`); `data.max` MAY give the ceiling. | +| `-32014` | `Unsupported` | The request is well-formed but a requested capability or option is not supported here — e.g. a delivery mode the event type does not offer. `data` identifies it (e.g. `{ "feature": "deliveryMode", "value": "push" }`). | +| `-32015` | `CallbackEndpointError` | A client-supplied callback endpoint failed verification or could not be reached (webhook mode only). `data.reason` is one of the `lastError` categories (`connection_refused`, `timeout`, `tls_error`, `http_4xx`, `http_5xx`, `challenge_failed`). | -These codes are drawn from `-32011..-32017` to avoid collision with base MCP error codes (e.g., `-32002 ResourceNotFound`). +These are **general-purpose** codes — named for reuse across MCP, not scoped to events — carried in the JSON-RPC implementation-defined server range `[-32000, -32099]` alongside MCP's existing `-32003`/`-32004`/`-32042`. Each conveys its specifics through a typed `data` payload (the same pattern as `UnsupportedProtocolVersionError.data`), so one code spans a family of conditions without minting new numbers, while clients still distinguish every case they branch on (by code, by method, or by a typed `data` discriminator). They are intended as candidates for promotion to the base MCP error registry; until then this extension defines them and future SEPs SHOULD reuse them rather than introduce overlapping codes. (`-32002 ResourceNotFound`, cited in earlier drafts, is not present in the current MCP schema; `NotFound` is its general replacement.) ### Poll-Based Delivery @@ -198,7 +196,7 @@ Each entry in `events[]`, the params of `notifications/events/event`, and the bo - `nextPollMs` allows the server to dynamically adjust polling frequency (e.g., back off when rate-limited upstream, speed up when activity is detected). Ignored when `hasMore` is `true`. Clients SHOULD apply a configurable floor (default 1000 ms) to guard against a misbehaving server inducing a tight loop. - Empty `events` array means nothing happened — this is the common case and should be cheap. - The server holds no protocol-required per-client subscription state. Each poll request is self-contained: the client provides the event name, params, and cursor. The server does not need to "remember" previous poll requests to answer them. (The SDK MAY hold ephemeral derived state — a poll-lease table to drive `on_subscribe`/`on_unsubscribe`, and for emit-only event types a ring buffer of recent events — but neither is required to answer a poll, both are reconstructable, and neither is owed to any particular client. See *Unsubscribe timing by mode* and *Emit-only event types* under Server SDK Guidance.) -- Errors (`EventNotFound`, `Unauthorized`, `InvalidParams`, `DeliveryModeUnsupported`) are returned as a standard JSON-RPC error response for the request — there is no partial-success model since each request carries one subscription. +- Errors (`NotFound`, `Forbidden`, `InvalidParams`, `Unsupported`) are returned as a standard JSON-RPC error response for the request — there is no partial-success model since each request carries one subscription. ### Push-Based Delivery @@ -246,7 +244,7 @@ sequenceDiagram #### Event Delivery -If the subscription is invalid (`EventNotFound`, `Unauthorized`, `InvalidParams`, `DeliveryModeUnsupported`), the server responds immediately with a JSON-RPC error and no stream is opened. Otherwise the server confirms the subscription and delivers events as notifications. Every `notifications/events/*` message carries the JSON-RPC `id` of the parent `events/stream` request in `params._meta["io.modelcontextprotocol/subscriptionId"]` (per [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575)'s correlation convention) so a client with multiple concurrent streams (especially on stdio, where all share one stdout) can route notifications to the correct stream. +If the subscription is invalid (`NotFound`, `Forbidden`, `InvalidParams`, `Unsupported`), the server responds immediately with a JSON-RPC error and no stream is opened. Otherwise the server confirms the subscription and delivers events as notifications. Every `notifications/events/*` message carries the JSON-RPC `id` of the parent `events/stream` request in `params._meta["io.modelcontextprotocol/subscriptionId"]` (per [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575)'s correlation convention) so a client with multiple concurrent streams (especially on stdio, where all share one stdout) can route notifications to the correct stream. ```jsonc // Confirmation @@ -362,7 +360,7 @@ Unlike poll and push, webhook delivery requires an explicit subscribe step becau Webhook subscriptions are keyed by a compound **subscription key** that determines which `events/subscribe` calls refer to the same logical subscription. The server uses this key for idempotent upsert on subscribe, for lookup on unsubscribe, and to isolate tenants from one another. -**Authentication required.** `events/subscribe` and `events/unsubscribe` MUST be called with an authenticated principal; servers MUST reject unauthenticated calls with `-32012 Unauthorized`. Without a principal in the key, the tuple `(delivery.url, name, params)` is guessable and any caller could unsubscribe or rotate another tenant's secret. Unauthenticated MCP servers may offer poll and push, but not webhook. +**Authentication required.** `events/subscribe` and `events/unsubscribe` MUST be called with an authenticated principal; servers MUST reject calls without an authorized principal with `-32012 Forbidden`. Without a principal in the key, the tuple `(delivery.url, name, params)` is guessable and any caller could unsubscribe or rotate another tenant's secret. Unauthenticated MCP servers may offer poll and push, but not webhook. **Key composition.** The subscription key is `(principal, delivery.url, name, params)`, where `principal` is the server's canonical identifier for the authenticated subject (e.g., OAuth `sub`, API key ID, service-account name). `params` is compared by canonical-JSON equality. There is no client-generated `id` — a subscription is fully determined by what it listens for, where it delivers, and who asked. @@ -461,19 +459,19 @@ The `events/subscribe` response MAY include a `deliveryStatus` object when refre } ``` -`deliveryStatus` is OPTIONAL — servers MAY omit it entirely. When present, `active` indicates whether the server is currently delivering events (`false` means it has suspended retries after repeated failures; the refresh that returned this status has just reactivated it). `lastError` MUST be a server-generated category string — one of `connection_refused`, `timeout`, `tls_error`, `http_4xx`, `http_5xx`, `challenge_failed` — and MUST NOT include raw response bodies, headers, or status lines from the endpoint, to avoid serving as a response oracle for attacker-chosen URLs. The client can use this information to diagnose connectivity or authentication issues with the webhook endpoint. +`deliveryStatus` is OPTIONAL — servers MAY omit it entirely. When present, `active` indicates whether the server is currently delivering events (`false` means it has suspended retries after repeated failures; the refresh that returned this status has just reactivated it). `lastError` MUST be a server-generated category string — one of `connection_refused`, `timeout`, `tls_error`, `http_4xx`, `http_5xx`, `challenge_failed` — and MUST NOT include raw response bodies, headers, or status lines from the endpoint, to avoid serving as a response oracle for attacker-chosen URLs. The client can use this information to diagnose connectivity or authentication issues with the webhook endpoint. The same categories also appear as `data.reason` on a `-32015 CallbackEndpointError` returned synchronously from `events/subscribe`. #### Webhook Security **SSRF prevention.** The server MUST validate callback URLs. Servers SHOULD reject URLs whose resolved IP is not globally routable per the [IANA IPv4](https://www.iana.org/assignments/iana-ipv4-special-registry/) and [IPv6](https://www.iana.org/assignments/iana-ipv6-special-registry/) Special-Purpose Address Registries (illustrative examples: `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16`, `::1`, `fc00::/7`, `fe80::/10`) unless explicitly configured to allow them. To prevent DNS rebinding, this validation MUST be performed at delivery time, not only at subscribe time: the server resolves the hostname, checks the resolved IP against the blocklist, and connects directly to that validated IP (sending the original hostname in the `Host` header / TLS SNI) so the address cannot change between check and connect. Subscribe-time validation alone is insufficient — a rebinding attacker returns a public IP at subscribe time and a private IP at delivery time. Webhook delivery requests MUST NOT follow HTTP redirects, since a redirect can target an internal address that bypasses the blocklist. Servers MAY additionally maintain an allowlist of permitted callback URL patterns. -**Endpoint verification (anti-flooding).** Client-supplied HMAC stops a third party from being *deceived* (forged deliveries fail verification) but not from being *flooded* — an attacker can subscribe a victim's URL with an attacker-chosen secret and the victim still receives every POST. So a server MUST NOT begin delivering to a callback URL until the endpoint's intent to receive deliveries is confirmed, by **either**: (a) a **verification handshake** — before activating, the server POSTs a `verification` control envelope carrying a single-use, short-lived `challenge` nonce (signed and headed like any delivery), and the endpoint proves intent by echoing the nonce in a `2xx` body (`{"challenge":""}`), which the server compares in constant time; or (b) the URL matching a server-configured **allowlist**. A reachable endpoint that fails to echo yields `-32014 EndpointVerificationFailed`; an unreachable one yields `-32015 InvalidCallbackUrl`. +**Endpoint verification (anti-flooding).** Client-supplied HMAC stops a third party from being *deceived* (forged deliveries fail verification) but not from being *flooded* — an attacker can subscribe a victim's URL with an attacker-chosen secret and the victim still receives every POST. So a server MUST NOT begin delivering to a callback URL until the endpoint's intent to receive deliveries is confirmed, by **either**: (a) a **verification handshake** — before activating, the server POSTs a `verification` control envelope carrying a single-use, short-lived `challenge` nonce (signed and headed like any delivery), and the endpoint proves intent by echoing the nonce in a `2xx` body (`{"challenge":""}`), which the server compares in constant time; or (b) the URL matching a server-configured **allowlist**. A reachable endpoint that fails to echo yields `-32015 CallbackEndpointError` with `data.reason: "challenge_failed"`; an unreachable one yields the same code with the relevant connection-failure category (`connection_refused`, `timeout`, or `tls_error`). Verification is cached per `(principal, url)`: a successful handshake or allowlist hit covers that principal's subscriptions to that URL across refreshes and `params`, so varying `params` cannot multiply verification POSTs at a victim and one principal's verification never waives the challenge for another. The cache is in-memory TTL-scoped soft state like the subscription itself; after a restart the server re-verifies on the next subscribe. The verification POST MUST use the same SSRF-hardened path as deliveries (delivery-time IP validation, no redirects) and SHOULD be rate-limited per destination host; failures surface only via the `lastError` category `challenge_failed`, never raw endpoint responses. **Server identity (optional).** A server MAY additionally sign deliveries and verification POSTs with an asymmetric key — Standard Webhooks `v1a,` (ed25519), appended alongside the `v1,` HMAC — so a gateway can verify *which* server sent a delivery, enforce a known-server allowlist, and retain protection if an HMAC secret leaks. The verifying public key MUST be discovered from an origin the client already authenticates, and never from the challenge or delivery body, which the attacker controls (that would be trust-on-first-use). The discovery mechanism is a SEP-review-time decision contingent on [SEP-2127](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2127) (server cards), still draft: if server cards are published before this extension, the key is carried inline on the server's card (provisionally a `webhooks.signingKeys` member holding an ed25519 JWK set); otherwise it is published as a standalone JWKS document at a well-known path on the server's origin, `/.well-known/mcp-webhook-jwks.json`. Exactly one of these will be normative in the final SEP — they are not two options offered to implementers. Either way the key is fetched from the same origin the gateway connects to for MCP, and is distinct from the OAuth `jwks_uri` ([RFC 8414](https://www.rfc-editor.org/rfc/rfc8414)), which carries the authorization server's token-signing keys, not the MCP server's webhook-signing key. Keys carry a `kid`; rotation publishes the new key alongside the old. Endpoints that do not enforce server identity ignore `v1a,`. -**TLS requirement.** Callback URLs MUST use `https://`. Servers MUST reject `events/subscribe` with a non-`https` `delivery.url` (`-32015 InvalidCallbackUrl`). Event payloads and the `delivery.secret` (sent in the subscribe request) would otherwise transit in cleartext. +**TLS requirement.** Callback URLs MUST use `https://`. Servers MUST reject `events/subscribe` with a non-`https` `delivery.url` (`-32602 InvalidParams`). Event payloads and the `delivery.secret` (sent in the subscribe request) would otherwise transit in cleartext. **Signature scheme (Standard Webhooks profile).** MCP webhook delivery is a profile of [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md). Every delivery MUST include `webhook-id` (the `eventId` for event deliveries; `msg__` for control envelopes), `webhook-timestamp` (Unix seconds), and `webhook-signature`. The signature is `HMAC-SHA256(secret, webhook-id + "." + webhook-timestamp + "." + body)` encoded as base64 with a `v1,` prefix. In this formula, `body` is the raw HTTP request body bytes exactly as received, before any parsing or re-serialization; the `webhook-id + "." + webhook-timestamp + "."` prefix is UTF-8; and `secret` is the base64-decoded bytes of the value after the `whsec_` prefix. Receivers MUST compute the HMAC over the raw body, never over a re-serialized JSON object. The header MAY contain multiple space-delimited signatures (`v1, v1,`) during secret rotation; the receiver accepts if any verifies. Each retry attempt MUST regenerate the timestamp and signature so retries are not rejected by the receiver's freshness window. The receiver SHOULD reject deliveries where `webhook-timestamp` is more than 5 minutes old. Off-the-shelf Standard Webhooks verifiers (e.g., Svix libraries) work without modification. @@ -686,7 +684,7 @@ async def on_unsubscribe(context, params, subscription_id): await slack.leave_channel(params["channel"]) ``` -These hooks are called by the SDK across all delivery modes. The server author writes the upstream setup/teardown logic once; the SDK handles the delivery mechanics. Servers SHOULD enforce `TooManySubscriptions` before invoking `on_subscribe`, so a rejected subscription never provisions upstream resources. +These hooks are called by the SDK across all delivery modes. The server author writes the upstream setup/teardown logic once; the SDK handles the delivery mechanics. Servers SHOULD enforce the subscription limit (`ResourceExhausted`) before invoking `on_subscribe`, so a rejected subscription never provisions upstream resources. **Unsubscribe timing by mode.** Push and webhook have explicit end-of-life signals — push fires `on_unsubscribe` when the stream closes, webhook when `events/unsubscribe` is called or the TTL lapses. Poll does not: the client simply stops calling `events/poll`, and the server never sees a goodbye. To prevent poll-provisioned upstream resources from leaking, the SDK treats poll subscriptions as leased. The lease is keyed on `(principal-or-null, eventName, canonicalHash(params))` — on unauthenticated servers all callers share one lease per `(name, params)`. `on_subscribe` fires the first time a given key appears in a poll request; each subsequent poll for that key renews the lease; `on_unsubscribe` fires when the lease expires without renewal. This lease table is ephemeral SDK state, not protocol state: it is never persisted, and a server restart simply re-fires `on_subscribe` on the next poll. The lease window is SDK-configurable and SHOULD default to a small multiple of the server's typical `nextPollMs` so that a well-behaved client never lapses between polls: @@ -772,7 +770,7 @@ The threats this extension is designed to defend against, and the mechanism addr |---|---| | Prompt injection via event payloads | Payloads are untrusted data — clients sanitize/sandbox before the LLM (as with tool results); servers keep payloads minimal to shrink the injection surface. *(Event Payloads Are Untrusted Data; Payload Minimality)* | | PII / sensitive-data exposure in event infrastructure (logs, queues, buffers) | Payload minimality — deliver only triage fields (e.g. sender, subject, id) and fetch full content via tools on demand. *(Payload Minimality)* | -| Unauthorized subscription, or continued delivery after access is revoked | Subscribe-time permission check (`-32012 Unauthorized`); periodic delivery-time re-verification; a `terminated` envelope ends the subscription when access is lost. *(Authorization)* | +| Unauthorized subscription, or continued delivery after access is revoked | Subscribe-time permission check (`-32012 Forbidden`); periodic delivery-time re-verification; a `terminated` envelope ends the subscription when access is lost. *(Authorization)* | | Treating event receipt as authority to act | Action-time authorization — the agent's response to an event goes through normal MCP authorization; receipt grants no privilege. *(Authorization)* | | SSRF / DNS rebinding (server coerced into requesting internal hosts) | Delivery-time IP validation against the IANA special-purpose registries, connecting to the validated IP with the original Host/SNI, and no redirects; optional callback-URL allowlist. *(Webhook Security → SSRF prevention)* | | Reflection / amplification DDoS (attacker subscribes a victim's URL) | Mandatory intent challenge (or allowlist) before activating delivery, cached per `(principal, url)`; subscribe and per-destination rate limits. *(Endpoint verification)* | @@ -783,7 +781,7 @@ The threats this extension is designed to defend against, and the mechanism addr | Eavesdropping in transit (payloads and `delivery.secret`) | TLS required — `https` callback URLs only; non-`https` subscribe is rejected. *(TLS requirement)* | | Cross-tenant subscription tampering (guessing the tuple to unsubscribe or rotate another tenant's secret) | Webhook requires an authenticated principal; subscription identity includes `principal`; the derived `id` is a non-capability routing handle. *(Subscription Identity)* | | Response oracle / internal-service probing via error detail | `deliveryStatus.lastError` is a fixed category string only — never raw endpoint bodies, headers, or status lines. *(Webhook Delivery Status)* | -| Server-side resource exhaustion (subscription flooding) | `-32013 TooManySubscriptions` limits; webhook subscriptions are TTL'd soft state that expire without refresh. *(Error Codes)* | +| Server-side resource exhaustion (subscription flooding) | `-32013 ResourceExhausted` limits; webhook subscriptions are TTL'd soft state that expire without refresh. *(Error Codes)* | ### Event Payloads Are Untrusted Data @@ -811,14 +809,14 @@ This reduces PII exposure in event infrastructure (logs, queues, buffers) and li |---|---| | Push | `notifications/events/terminated` on the SSE stream | | Poll | JSON-RPC error response to the next `events/poll` request | -| Webhook | Signed `{"type":"terminated",...}` envelope POSTed to the callback URL (see *Non-event webhook bodies*); the subscription no longer exists, so a subsequent refresh is a fresh subscribe and returns `-32012 Unauthorized` if the cause of termination still applies | +| Webhook | Signed `{"type":"terminated",...}` envelope POSTed to the callback URL (see *Non-event webhook bodies*); the subscription no longer exists, so a subsequent refresh is a fresh subscribe and returns `-32012 Forbidden` if the cause of termination still applies | ``` // Push -data: {"jsonrpc":"2.0","method":"notifications/events/terminated","params":{"error":{"code":-32012,"message":"Unauthorized","data":{"reason":"Access revoked"}},"_meta":{"io.modelcontextprotocol/subscriptionId":1}}} +data: {"jsonrpc":"2.0","method":"notifications/events/terminated","params":{"error":{"code":-32012,"message":"Forbidden","data":{"reason":"Access revoked"}},"_meta":{"io.modelcontextprotocol/subscriptionId":1}}} // Webhook (POST body) -{"type":"terminated","error":{"code":-32012,"message":"Unauthorized","data":{"reason":"Access revoked"}}} +{"type":"terminated","error":{"code":-32012,"message":"Forbidden","data":{"reason":"Access revoked"}}} ``` `notifications/events/terminated` has the shape `{error: {code: integer, message: string, data?: object}, _meta: {"io.modelcontextprotocol/subscriptionId": string|integer}}` — identical to `notifications/events/error` but indicates the subscription has ended, not just a per-event failure. The client SDK SHOULD remove the subscription and notify the application. @@ -919,6 +917,7 @@ These are the choices where reasonable alternatives existed; recorded so reviewe | `eventId` source | Server-assigned; SHOULD be the upstream's stable identifier | Dual-path delivery (e.g., webhook emit + poll backfill) of the same upstream event must collapse under client-side dedup. SDK auto-generates only when the author supplies none. | | Protocol-level flow control | None in v1 | The dominant bottleneck is LLM inference (seconds–minutes per event), not delivery. TCP back-pressure, `nextPollMs`, and client-side prioritization are sufficient for the target event rates. Credit-based control can be added later without breaking changes. | | New primitive vs extending `resources/subscribe` | New `events/*` primitive | Resources are content-addressed (URI → representation) without parameters or cursors; bolting filter params, replay, and three delivery modes onto them would distort that model more than a parallel primitive costs. Folding `resources/subscribe` into events later is left as an open question. | +| Error code surface | Five general-purpose codes (`NotFound`, `Forbidden`, `ResourceExhausted`, `Unsupported`, `CallbackEndpointError`) carrying specifics in typed `data`, plus base `InvalidParams` — not events-specific names | An earlier draft minted seven events-scoped codes (`EventNotFound`, `TooManySubscriptions`, `EndpointVerificationFailed`, `InvalidCallbackUrl`, `SubscriptionNotFound`, `DeliveryModeUnsupported`). Collapsing each family behind one general code with a typed `data` discriminator (the `-32003`/`-32004`/`-32042` pattern) keeps the shared `[-32000,-32099]` range uncluttered and lets future SEPs reuse rather than re-mint, while clients still distinguish every case they act on (by code, by method, or by `data`). Static request errors fold into `InvalidParams`. Defined here pending promotion to the base MCP error registry. | ## Appendix: End-to-End Example — GitHub MCP Server (Webhook Delivery)