Skip to content

Commit f7012b6

Browse files
committed
fix(http): isolate modern rate limit bucket
1 parent 4546783 commit f7012b6

3 files changed

Lines changed: 62 additions & 5 deletions

File tree

CONFIGURATION.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,7 @@ In stateless mode, every POST creates a fresh MCP server and transport, ignores
339339

340340
## Rate Limiting (HTTP mode)
341341

342-
Rate limiting is always active in HTTP mode to prevent resource exhaustion. Before the MCP handler runs, each request is counted by resolved client IP against exactly one limit. In stateful mode, POST requests with a currently live session use the session limit, other POST requests use the initialization limit, and GET/DELETE requests always use the session limit. In stateless mode, only a single parsed request object recognized by the SDK as `initialize` uses the initialization limit; all other POST bodies, including notifications and batches, use the session limit, and GET/DELETE still use the session limit. Malformed or oversized JSON is rejected by parsing before rate limiting or MCP server construction.
342+
Rate limiting is always active in HTTP mode to prevent resource exhaustion. Before the MCP handler runs, each request is counted by resolved client IP against exactly one limit. In stateful mode, retained legacy POST requests with a currently live session use the session limit; modern sessionless POST requests and all other POST requests use the initialization limit, even if they present a live legacy `mcp-session-id`. GET/DELETE requests always use the session limit. In stateless mode, only a single parsed request object recognized by the SDK as `initialize` uses the initialization limit; all other POST bodies, including notifications and batches, use the session limit, and GET/DELETE still use the session limit. Malformed or oversized JSON is rejected by parsing before rate limiting or MCP server construction.
343343

344344
Each `MCP_RATE_*` value must be a positive decimal safe integer after JavaScript whitespace trimming. A leading `+` and leading zeros are accepted; fractions, suffixes, exponents, hexadecimal forms, non-positive values, and integers above `Number.MAX_SAFE_INTEGER` are rejected. An invalid value uses the documented default and emits one startup warning per variable without copying the raw value into diagnostics. Blank or unset variables use the default silently.
345345

@@ -348,8 +348,8 @@ Before this correction, spellings such as `20requests`, `12.5`, or `1e3` could b
348348
| Variable | Required | Default | Description |
349349
|---|---|---|---|
350350
| `MCP_RATE_WINDOW_MS` | No | `60000` | Sliding window duration in milliseconds for all rate limits |
351-
| `MCP_RATE_INIT_MAX` | No | `20` | Max POST `/mcp` requests per window when `mcp-session-id` is missing or does not identify a currently live session. Guards initialization, invalid, unknown-session, and stale-session flooding. |
352-
| `MCP_RATE_SESSION_MAX` | No | `300` | Max POST `/mcp` requests for currently live sessions and all GET/DELETE `/mcp` requests per window, including GET/DELETE requests with missing or invalid session IDs. Intentionally generous for AI agents. |
351+
| `MCP_RATE_INIT_MAX` | No | `20` | Max POST `/mcp` requests for modern sessionless traffic and requests without a currently live retained legacy session in stateful mode, plus SDK-recognized initialize requests in stateless mode. Guards initialization, invalid, unknown-session, stale-session, and legacy-session-header borrowing. |
352+
| `MCP_RATE_SESSION_MAX` | No | `300` | Max POST `/mcp` requests for currently live retained legacy sessions and all GET/DELETE `/mcp` requests per window, including GET/DELETE requests with missing or invalid session IDs. Intentionally generous for AI agents. |
353353

354354
Requests exceeding a limit receive HTTP 429 with a JSON-RPC error body (`code: -32029`). `/health` has a fixed limit of 60 requests per minute. Standard `RateLimit-*` headers are included on all responses.
355355

__tests__/integration/http-server.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1823,6 +1823,58 @@ async function runTests() {
18231823
assert.equal(blocked.body.error.code, -32029);
18241824
}, results);
18251825

1826+
await testFunction('Rate limiting: modern POSTs cannot borrow a live legacy session bucket', async () => {
1827+
envManager.set('MCP_RATE_INIT_MAX', '2');
1828+
envManager.set('MCP_RATE_SESSION_MAX', '11');
1829+
envManager.set('MCP_RATE_WINDOW_MS', '60000');
1830+
const app = await createHttpServer((modern) => createMcpServer(new ToolAdmissionController({
1831+
rateWindowMs: 60_000,
1832+
rateMax: 100,
1833+
maxInFlight: 4,
1834+
}), modern));
1835+
1836+
const initRes = await request(app)
1837+
.post('/mcp')
1838+
.set('Content-Type', 'application/json')
1839+
.set('Accept', 'application/json, text/event-stream')
1840+
.send({
1841+
jsonrpc: '2.0', id: 1, method: 'initialize',
1842+
params: { protocolVersion: '2024-11-05', capabilities: {},
1843+
clientInfo: { name: 'rate-limit-era-client', version: '1.0.0' } }
1844+
});
1845+
const sessionId = initRes.headers['mcp-session-id'];
1846+
assert.equal(initRes.status, 200);
1847+
assert.ok(sessionId, 'legacy initialize should return a session id');
1848+
1849+
const modernBody = (id: number) => ({
1850+
jsonrpc: '2.0', id, method: 'server/discover',
1851+
params: {
1852+
_meta: {
1853+
'io.modelcontextprotocol/protocolVersion': '2026-07-28',
1854+
'io.modelcontextprotocol/clientCapabilities': {},
1855+
},
1856+
},
1857+
});
1858+
const modernPost = (id: number) => request(app)
1859+
.post('/mcp')
1860+
.set('Content-Type', 'application/json')
1861+
.set('Accept', 'application/json')
1862+
.set('MCP-Protocol-Version', '2026-07-28')
1863+
.set('MCP-Method', 'server/discover')
1864+
.set('mcp-session-id', sessionId)
1865+
.send(modernBody(id));
1866+
1867+
const accepted = await modernPost(2);
1868+
const blocked = await modernPost(3);
1869+
envManager.restore();
1870+
1871+
assert.equal(accepted.status, 200);
1872+
assert.equal(accepted.headers['ratelimit-limit'], '2');
1873+
assert.equal(blocked.status, 429);
1874+
assert.equal(blocked.headers['ratelimit-limit'], '2');
1875+
assert.equal(blocked.body.error.code, -32029);
1876+
}, results);
1877+
18261878
await testFunction('Rate limiting: non-live session identifiers use the init limiter', async () => {
18271879
envManager.set('MCP_RATE_INIT_MAX', '7');
18281880
envManager.set('MCP_RATE_SESSION_MAX', '11');

src/http-server.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -427,9 +427,14 @@ export async function createHttpServer(
427427
return;
428428
}
429429
const sessionId = req.headers['mcp-session-id'];
430+
const claimsModernProtocol = req.headers['mcp-protocol-version'] === MODERN_PROTOCOL_VERSION
431+
|| modernRequestWithClaim(req.body) !== undefined;
430432
// Node comma-joins duplicate custom headers. Only one exact live session ID
431-
// selects the generous bucket; every other value stays initialization-limited.
432-
const selectedLimiter = typeof sessionId === 'string' && sessions.has(sessionId)
433+
// on a retained legacy request selects the generous bucket. Modern requests
434+
// are sessionless and cannot borrow capacity through a legacy session header.
435+
const selectedLimiter = !claimsModernProtocol
436+
&& typeof sessionId === 'string'
437+
&& sessions.has(sessionId)
433438
? sessionLimiter
434439
: initLimiter;
435440
selectedLimiter(req, res, next);

0 commit comments

Comments
 (0)