From 40b5c6f7b5d1e8b07887ce8a6b0c55248ba3ecc0 Mon Sep 17 00:00:00 2001 From: Stephen Tyree Date: Tue, 25 Aug 2026 14:52:39 -0400 Subject: [PATCH 1/3] Add Rate-Limiting SEP, standardizing Rate Limiting Errors --- seps/0000-rate-limited-error.md | 125 ++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 seps/0000-rate-limited-error.md diff --git a/seps/0000-rate-limited-error.md b/seps/0000-rate-limited-error.md new file mode 100644 index 000000000..0c9ad3369 --- /dev/null +++ b/seps/0000-rate-limited-error.md @@ -0,0 +1,125 @@ +# SEP-0000: Standardize Rate-Limiting Errors + +- **Status**: Draft +- **Type**: Standards Track +- **Created**: 2026-08-25 +- **Author(s)**: Stephen Tyree (@tyree731) +- **Sponsor**: None (seeking sponsor) +- **PR**: None + +## Abstract + +The current MCP specification recommends clients and servers rate-limit in different circumstances, but has no +protocol-level way to indicate that a request has been rate limited, or to say when to retry. + +This SEP standardizes on `-32023` / `RateLimited` from the reserved sub-range, with a typed response indicating when to +retry after, with optional quota fields, binding the response to the HTTP 429 to additionally allow for middleware +proxies to detect this. + +## Motivation + +There are a couple of reasons for this specification. stdio has no HTTP layer: For the stdio protocol, no header +exchange can occur at present, meaning that relying on the HTTP layer to communicate a rate limit being hit cannot work +with the stdio protocol. The Transports WG, in the current roadmap, has committed to changing the stdio protocol to +speak Streamable HTTP, but there is no timeframe for this as of now. + +In addition, the current SDK implementations do not define rate limiting errors, and are inconsistent in how they +surface HTTP 429 errors: + +### How Each SDK Surfaces an HTTP 429 + +| SDK | HTTP 429 Surfaces As | Status Preserved | Source | +| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| TypeScript | `SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, "Error POSTing to endpoint: ...")` | Yes, as `status` on the error object | [streamableHttp.ts#L1101](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/client/src/client/streamableHttp.ts#L1101) | +| Python | `ErrorData(code=INTERNAL_ERROR, message="Server returned an error response")` (`-32603`) | No, collapsed to an internal error | [streamable_http.py#L370](https://github.com/modelcontextprotocol/python-sdk/blob/main/src/mcp/client/streamable_http.py#L370) | +| C# | `HttpRequestException` with the response body appended to the message | Yes, via `HttpRequestException.StatusCode` | [HttpResponseMessageExtensions.cs#L22](https://github.com/modelcontextprotocol/csharp-sdk/blob/main/src/Common/HttpResponseMessageExtensions.cs#L22) | +| Rust | `StreamableHttpError::UnexpectedServerResponse("HTTP 429 Too Many Requests: ")` | Formatted text only | [streamable_http_client.rs#L302](https://github.com/modelcontextprotocol/rust-sdk/blob/main/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs#L302) | +| Java | `McpTransportException("Invalid request. Status code: 429")` | Message string only | [HttpClientStreamableHttpTransport.java#L700](https://github.com/modelcontextprotocol/java-sdk/blob/main/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java#L700) | +| Go | Classified as transient alongside 500/502/503/504 and wrapped as `jsonrpc2.ErrRejected`, so the connection is preserved | `http.StatusText` only | [streamable.go#L2824](https://github.com/modelcontextprotocol/go-sdk/blob/main/mcp/streamable.go#L2824), [streamable.go#L2580](https://github.com/modelcontextprotocol/go-sdk/blob/main/mcp/streamable.go#L2580) | +| Kotlin | `StreamableHttpError(code = 429, message = body)` | Yes, as a numeric `code` property | [StreamableHttpClientTransport.kt#L195](https://github.com/modelcontextprotocol/kotlin-sdk/blob/main/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTransport.kt#L195) | +| PHP | Not detected. `send()` branches only on `Content-Type` and never inspects the status code, so a 429 with a non-JSON body is silently dropped | No | [HttpTransport.php#L165](https://github.com/modelcontextprotocol/php-sdk/blob/main/src/Client/Transport/HttpTransport.php#L165) | +| Ruby | Falls to the `Faraday::Error` catch-all, raising `RequestHandlerError(error_type: :internal_error)` | Only via `original_error.response[:status]` | [http.rb#L509](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/lib/mcp/client/http.rb#L509) | +| Swift | `MCPError.internalError("Too many requests")` | No, mapped to a fixed string | [HTTPClientTransport.swift#L418](https://github.com/modelcontextprotocol/swift-sdk/blob/main/Sources/MCP/Base/Transports/HTTPClientTransport.swift#L418) | + +This inconsistency means that clients cannot reliably report or detect rate limits being hit across implementations. + +## Specification + +If a user has sent too many requests in a given amount of time, and the server wishes to indicate +this, the server MUST return a JSON-RPC error response. For HTTP, the response status code MUST +be `429 Too Many Requests`. The error MUST conform to the following structure: + +```ts +export const RATE_LIMITED = -32023; + +export interface RateLimitedError extends Omit { + error: Error & { + code: typeof RATE_LIMITED; + data: { + /** Milliseconds the client SHOULD wait before retrying. @minimum 0 */ + retryAfterMs: number; + /** Requests permitted in the current window. @minimum 0 */ + limit?: number; + /** Requests remaining in the current window. @minimum 0 */ + remaining?: number; + }; + }; +} +``` + +Rules for different parties in the protocol to follow: + +| ID | Party | Rule | +| ---- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| RL-1 | Server | Servers **MUST** include `retryAfterMs` in `error.data` whenever they emit `-32023`. | +| RL-2 | Server | For HTTP, servers **MUST** respond with status `429 Too Many Requests`. | +| RL-3 | Server | For HTTP, servers **SHOULD** set an integer decimal `Retry-After` header consistent with `retryAfterMs`. | +| RL-4 | Server | For HTTP, servers **MUST** set an integer decimal `RateLimit-Limit` header consistent with `limit`, if `limit` is set. | +| RL-5 | Server | For HTTP, servers **MUST** set an integer decimal `RateLimit-Remaining` header consistent with `remaining`, if `remaining` is set. | +| RL-6 | Server | Servers **MUST NOT** emit `-32023` for a rate limit encountered by an upstream API inside a tool; those remain tool execution errors (`isError: true`). | +| RL-7 | Client | Clients **SHOULD** apply jitter to the retry delay. | +| RL-8 | Client | Clients **SHOULD** clamp implausibly large `retryAfterMs` values to a locally configured maximum. | +| RL-9 | Client | When both a `Retry-After` header and `retryAfterMs` are present, clients **MUST** use `retryAfterMs`. | + +## Rationale + +### Is this an error? + +Yes. For much the same reason that `InvalidParamsError` is an error, namely that the client has made a mistake which +it needs to respond to, `RateLimitedError` communicates that the client has requested the server too rapidly, and needs +to back off appropriately. + +### Why milliseconds for retryAfterMs? + +The [Retry-After](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Retry-After) HTTP header supports +both a decimal integer for its value, in addition to an [HTTP date](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Date), +so it's worth asking why we would use milliseconds here. The rationale is that other SEPs have standardized on +milliseconds for their post-dated timings, such as [SEP-2549](./2549-TTL-for-list-results.md), so we do so for +consistency. + +### Related SEPs + +- [SEP-1699](./1699-support-sse-polling-via-server-side-disconnect.md): Support SSE polling via server-side disconnect +- [SEP-2243](./2243-http-standardization.md): HTTP Header Standardization for Streamable HTTP Transport +- [SEP-2549](./2549-TTL-for-list-results.md): TTL for List Results +- [SEP-2663](./2663-tasks-extension.md): Tasks Extension + +## Backward Compatibility + +The error code being used here is within the range of reserved codes for the MCP specification, so no existing client +should be relying on it. Older clients will see the same error shape they have previously with HTTP 429 errors, so only +clients which have implemented custom request and response handling using HTTP 429 errors will need to consider the new +response shape. + +## Security Implications + +- `retryAfterMs` is an attacker-controlled input to a client, allowing a malicious or compromised server to pin a client + down for an arbitrary amount of time. Clients **SHOULD** clamp the `retryAfterMs` to reasonable local maximums +- Synchronized retries after identical `retryAfterMs` can lead to the thundering herd problem. Clients **SHOULD** apply + jitter to the returned `retryAfterMs` value in order to mitigate this. +- `limit` / `remaining` are information oracles for fingerprinting and quota enumeration. Servers **MAY** omit these + values as they see fit. + +## Reference Implementation + +TBD. From fb7f5ce85c081e11c26f9b2824ce81ecd86f48e3 Mon Sep 17 00:00:00 2001 From: Stephen Tyree Date: Tue, 25 Aug 2026 14:57:27 -0400 Subject: [PATCH 2/3] Update with proper SEP number --- .../{0000-rate-limited-error.md => 3304-rate-limited-errors.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename seps/{0000-rate-limited-error.md => 3304-rate-limited-errors.md} (99%) diff --git a/seps/0000-rate-limited-error.md b/seps/3304-rate-limited-errors.md similarity index 99% rename from seps/0000-rate-limited-error.md rename to seps/3304-rate-limited-errors.md index 0c9ad3369..14937c6ab 100644 --- a/seps/0000-rate-limited-error.md +++ b/seps/3304-rate-limited-errors.md @@ -1,4 +1,4 @@ -# SEP-0000: Standardize Rate-Limiting Errors +# SEP-3304: Standardize Rate-Limiting Errors - **Status**: Draft - **Type**: Standards Track From 1e14bb15ebb4fe2da9562a2174a2d657c70dff99 Mon Sep 17 00:00:00 2001 From: Stephen Tyree Date: Tue, 25 Aug 2026 15:05:40 -0400 Subject: [PATCH 3/3] Regenerate SEPs --- docs/docs.json | 6 ++ docs/seps/3304-rate-limited-errors.mdx | 144 +++++++++++++++++++++++++ docs/seps/index.mdx | 2 + 3 files changed, 152 insertions(+) create mode 100644 docs/seps/3304-rate-limited-errors.mdx diff --git a/docs/docs.json b/docs/docs.json index f2aa8e7e1..676fe6c17 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -845,6 +845,12 @@ "seps/2596-spec-feature-lifecycle-and-deprecation", "seps/2663-tasks-extension" ] + }, + { + "group": "Draft", + "pages": [ + "seps/3304-rate-limited-errors" + ] } ] }, diff --git a/docs/seps/3304-rate-limited-errors.mdx b/docs/seps/3304-rate-limited-errors.mdx new file mode 100644 index 000000000..119eab0ea --- /dev/null +++ b/docs/seps/3304-rate-limited-errors.mdx @@ -0,0 +1,144 @@ +--- +title: "SEP-3304: Standardize Rate-Limiting Errors" +sidebarTitle: "SEP-3304: Standardize Rate-Limiting Errors" +description: "Standardize Rate-Limiting Errors" +--- + +
+ + Draft + + + Standards Track + +
+ +| Field | Value | +| ------------- | ------------------------------------------------------------------------------- | +| **SEP** | 3304 | +| **Title** | Standardize Rate-Limiting Errors | +| **Status** | Draft | +| **Type** | Standards Track | +| **Created** | 2026-08-25 | +| **Author(s)** | Stephen Tyree ([@tyree731](https://github.com/tyree731)) | +| **Sponsor** | None (seeking sponsor) | +| **PR** | [#3304](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3304) | + +--- + +## Abstract + +The current MCP specification recommends clients and servers rate-limit in different circumstances, but has no +protocol-level way to indicate that a request has been rate limited, or to say when to retry. + +This SEP standardizes on `-32023` / `RateLimited` from the reserved sub-range, with a typed response indicating when to +retry after, with optional quota fields, binding the response to the HTTP 429 to additionally allow for middleware +proxies to detect this. + +## Motivation + +There are a couple of reasons for this specification. stdio has no HTTP layer: For the stdio protocol, no header +exchange can occur at present, meaning that relying on the HTTP layer to communicate a rate limit being hit cannot work +with the stdio protocol. The Transports WG, in the current roadmap, has committed to changing the stdio protocol to +speak Streamable HTTP, but there is no timeframe for this as of now. + +In addition, the current SDK implementations do not define rate limiting errors, and are inconsistent in how they +surface HTTP 429 errors: + +### How Each SDK Surfaces an HTTP 429 + +| SDK | HTTP 429 Surfaces As | Status Preserved | Source | +| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| TypeScript | `SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, "Error POSTing to endpoint: ...")` | Yes, as `status` on the error object | [streamableHttp.ts#L1101](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/client/src/client/streamableHttp.ts#L1101) | +| Python | `ErrorData(code=INTERNAL_ERROR, message="Server returned an error response")` (`-32603`) | No, collapsed to an internal error | [streamable_http.py#L370](https://github.com/modelcontextprotocol/python-sdk/blob/main/src/mcp/client/streamable_http.py#L370) | +| C# | `HttpRequestException` with the response body appended to the message | Yes, via `HttpRequestException.StatusCode` | [HttpResponseMessageExtensions.cs#L22](https://github.com/modelcontextprotocol/csharp-sdk/blob/main/src/Common/HttpResponseMessageExtensions.cs#L22) | +| Rust | `StreamableHttpError::UnexpectedServerResponse("HTTP 429 Too Many Requests: ")` | Formatted text only | [streamable_http_client.rs#L302](https://github.com/modelcontextprotocol/rust-sdk/blob/main/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs#L302) | +| Java | `McpTransportException("Invalid request. Status code: 429")` | Message string only | [HttpClientStreamableHttpTransport.java#L700](https://github.com/modelcontextprotocol/java-sdk/blob/main/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java#L700) | +| Go | Classified as transient alongside 500/502/503/504 and wrapped as `jsonrpc2.ErrRejected`, so the connection is preserved | `http.StatusText` only | [streamable.go#L2824](https://github.com/modelcontextprotocol/go-sdk/blob/main/mcp/streamable.go#L2824), [streamable.go#L2580](https://github.com/modelcontextprotocol/go-sdk/blob/main/mcp/streamable.go#L2580) | +| Kotlin | `StreamableHttpError(code = 429, message = body)` | Yes, as a numeric `code` property | [StreamableHttpClientTransport.kt#L195](https://github.com/modelcontextprotocol/kotlin-sdk/blob/main/kotlin-sdk-client/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTransport.kt#L195) | +| PHP | Not detected. `send()` branches only on `Content-Type` and never inspects the status code, so a 429 with a non-JSON body is silently dropped | No | [HttpTransport.php#L165](https://github.com/modelcontextprotocol/php-sdk/blob/main/src/Client/Transport/HttpTransport.php#L165) | +| Ruby | Falls to the `Faraday::Error` catch-all, raising `RequestHandlerError(error_type: :internal_error)` | Only via `original_error.response[:status]` | [http.rb#L509](https://github.com/modelcontextprotocol/ruby-sdk/blob/main/lib/mcp/client/http.rb#L509) | +| Swift | `MCPError.internalError("Too many requests")` | No, mapped to a fixed string | [HTTPClientTransport.swift#L418](https://github.com/modelcontextprotocol/swift-sdk/blob/main/Sources/MCP/Base/Transports/HTTPClientTransport.swift#L418) | + +This inconsistency means that clients cannot reliably report or detect rate limits being hit across implementations. + +## Specification + +If a user has sent too many requests in a given amount of time, and the server wishes to indicate +this, the server MUST return a JSON-RPC error response. For HTTP, the response status code MUST +be `429 Too Many Requests`. The error MUST conform to the following structure: + +```ts +export const RATE_LIMITED = -32023; + +export interface RateLimitedError extends Omit { + error: Error & { + code: typeof RATE_LIMITED; + data: { + /** Milliseconds the client SHOULD wait before retrying. @minimum 0 */ + retryAfterMs: number; + /** Requests permitted in the current window. @minimum 0 */ + limit?: number; + /** Requests remaining in the current window. @minimum 0 */ + remaining?: number; + }; + }; +} +``` + +Rules for different parties in the protocol to follow: + +| ID | Party | Rule | +| ---- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| RL-1 | Server | Servers **MUST** include `retryAfterMs` in `error.data` whenever they emit `-32023`. | +| RL-2 | Server | For HTTP, servers **MUST** respond with status `429 Too Many Requests`. | +| RL-3 | Server | For HTTP, servers **SHOULD** set an integer decimal `Retry-After` header consistent with `retryAfterMs`. | +| RL-4 | Server | For HTTP, servers **MUST** set an integer decimal `RateLimit-Limit` header consistent with `limit`, if `limit` is set. | +| RL-5 | Server | For HTTP, servers **MUST** set an integer decimal `RateLimit-Remaining` header consistent with `remaining`, if `remaining` is set. | +| RL-6 | Server | Servers **MUST NOT** emit `-32023` for a rate limit encountered by an upstream API inside a tool; those remain tool execution errors (`isError: true`). | +| RL-7 | Client | Clients **SHOULD** apply jitter to the retry delay. | +| RL-8 | Client | Clients **SHOULD** clamp implausibly large `retryAfterMs` values to a locally configured maximum. | +| RL-9 | Client | When both a `Retry-After` header and `retryAfterMs` are present, clients **MUST** use `retryAfterMs`. | + +## Rationale + +### Is this an error? + +Yes. For much the same reason that `InvalidParamsError` is an error, namely that the client has made a mistake which +it needs to respond to, `RateLimitedError` communicates that the client has requested the server too rapidly, and needs +to back off appropriately. + +### Why milliseconds for retryAfterMs? + +The [Retry-After](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Retry-After) HTTP header supports +both a decimal integer for its value, in addition to an [HTTP date](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Date), +so it's worth asking why we would use milliseconds here. The rationale is that other SEPs have standardized on +milliseconds for their post-dated timings, such as [SEP-2549](./2549-TTL-for-list-results.md), so we do so for +consistency. + +### Related SEPs + +- [SEP-1699](./1699-support-sse-polling-via-server-side-disconnect.md): Support SSE polling via server-side disconnect +- [SEP-2243](./2243-http-standardization.md): HTTP Header Standardization for Streamable HTTP Transport +- [SEP-2549](./2549-TTL-for-list-results.md): TTL for List Results +- [SEP-2663](./2663-tasks-extension.md): Tasks Extension + +## Backward Compatibility + +The error code being used here is within the range of reserved codes for the MCP specification, so no existing client +should be relying on it. Older clients will see the same error shape they have previously with HTTP 429 errors, so only +clients which have implemented custom request and response handling using HTTP 429 errors will need to consider the new +response shape. + +## Security Implications + +- `retryAfterMs` is an attacker-controlled input to a client, allowing a malicious or compromised server to pin a client + down for an arbitrary amount of time. Clients **SHOULD** clamp the `retryAfterMs` to reasonable local maximums +- Synchronized retries after identical `retryAfterMs` can lead to the thundering herd problem. Clients **SHOULD** apply + jitter to the returned `retryAfterMs` value in order to mitigate this. +- `limit` / `remaining` are information oracles for fingerprinting and quota enumeration. Servers **MAY** omit these + values as they see fit. + +## Reference Implementation + +TBD. diff --git a/docs/seps/index.mdx b/docs/seps/index.mdx index 658464479..9190d870b 100644 --- a/docs/seps/index.mdx +++ b/docs/seps/index.mdx @@ -12,12 +12,14 @@ Specification Enhancement Proposals (SEPs) are the primary mechanism for proposi ## Summary +- **Draft**: 1 - **Final**: 41 ## All SEPs | SEP | Title | Status | Type | Created | | ------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- | ----------------------------------------------- | ---------------- | ---------- | +| [SEP-3304](/seps/3304-rate-limited-errors) | Standardize Rate-Limiting Errors | Draft | Standards Track | 2026-08-25 | | [SEP-2663](/seps/2663-tasks-extension) | Tasks Extension | Final | Extensions Track | 2026-04-27 | | [SEP-2596](/seps/2596-spec-feature-lifecycle-and-deprecation) | Specification Feature Lifecycle and Deprecation Policy | Final | Process | 2026-04-17 | | [SEP-2577](/seps/2577-deprecate-roots-sampling-and-logging) | Deprecate Roots, Sampling, and Logging | Final | Standards Track | 2026-04-14 |