Skip to content

Commit 2e42985

Browse files
alistair3149claude
andauthored
Rate limit tool calls on the HTTP transport (#521)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent af870f0 commit 2e42985

17 files changed

Lines changed: 1047 additions & 24 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
1313

1414
### Breaking changes
1515

16+
- The HTTP transport now rate limits `tools/call`: each authenticated caller gets its own allowance (default 30 per second, burst 60) and anonymous callers share one (default 100 per second). A request over the limit is refused with `429` and a `Retry-After` header. Raise `MCP_RATE_LIMIT` / `MCP_RATE_LIMIT_BURST` / `MCP_RATE_LIMIT_ANONYMOUS` if you run high-throughput automation, or set `MCP_RATE_LIMIT=0` to disable.
1617
- The HTTP transport no longer forwards a caller's `Authorization: Bearer` header to MediaWiki. Such a request is refused with `401`, because a token minted by the wiki was not issued for this server. Use [hosted OAuth sign-in](docs/deployment.md#hosted-oauth-sign-in), or set `MCP_ALLOW_BEARER_PASSTHROUGH=true` to keep the old behaviour while you migrate; it is deprecated and will be removed. The server now warns at startup when a wiki requires a signed-in user but neither hosted sign-in nor forwarding is available, since no request could then succeed.
1718
- The server no longer advertises the wikis' own authorization servers, so a client can no longer discover where to mint a token to send here. Without hosted OAuth sign-in enabled, `/.well-known/oauth-protected-resource` now answers `404`, and `list-wikis` stops reporting each wiki's `authorizationServer`. Deployments running the hosted sign-in are unaffected.
1819
- The `Origin` header is now validated on every bind, and a request carrying an unlisted origin is refused with `403`. If you serve a browser-based client from a public bind, set `MCP_ALLOWED_ORIGINS` before upgrading. Clients that send no `Origin` header, which is most of them, are unaffected.

docs/deployment.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ Serve a single wiki for anonymous reads: no sign-in, no writes. Good for public
4949

5050
`readOnly: true` together with `allowWikiManagement: false` hides the wiki-management tools (`add-wiki`, `remove-wiki`) and the six write tools (`create-page`, `update-page`, `delete-page`, `undelete-page`, `upload-file`, `upload-file-from-url`) from `tools/list`. What remains is an anonymous, read-only interface.
5151

52-
Then run it with `MCP_TRANSPORT=http` behind a reverse proxy that terminates TLS and applies rate limiting (Cloudflare, nginx, and Caddy all work), then set the [Host and Origin allowlists](#security-checklist).
52+
Then run it with `MCP_TRANSPORT=http` behind a reverse proxy that terminates TLS (Cloudflare, nginx, and Caddy all work), then set the [Host and Origin allowlists](#security-checklist). The server itself [rate limits tool calls](#rate-limiting); IP-level limiting against anonymous floods still belongs at the proxy, which knows the caller's address when this server does not.
5353

5454
## Hosted OAuth sign-in
5555

@@ -242,6 +242,9 @@ Set `MCP_TRANSPORT=http` to select this transport (the Docker image defaults to
242242
| `MCP_TRUSTED_HOSTS` | unset | Comma-separated **outbound** SSRF-guard exemptions for internal destinations (e.g. `mediawiki.svc`). See [Outbound SSRF guard](#outbound-ssrf-guard). |
243243
| `MCP_ALLOW_STATIC_FALLBACK` | unset | Allow HTTP startup when a wiki has static credentials, making them a shared fallback identity. See [Security checklist](#security-checklist). |
244244
| `MCP_ALLOW_BEARER_PASSTHROUGH` | unset | Deprecated. Forward a caller's `Authorization` header to MediaWiki as that caller. Without it such a request is refused with `401`. See [Per-request bearer token](#per-request-bearer-token-http-transport-deprecated). |
245+
| `MCP_RATE_LIMIT` | `30` | Sustained `tools/call` per second per authenticated caller. `0` disables rate limiting. See [Rate limiting](#rate-limiting). |
246+
| `MCP_RATE_LIMIT_BURST` | 2 × rate | How far a caller's burst can run ahead of the sustained rate. |
247+
| `MCP_RATE_LIMIT_ANONYMOUS` | `100` | Sustained `tools/call` per second across **all** anonymous callers combined (burst 2 × the rate, not separately tunable). `0` leaves anonymous traffic unlimited. |
245248

246249
`MCP_MAX_REQUEST_BODY` matches nginx's `client_max_body_size 1m`. Raise it if `update-page` calls return 413 on legitimately large edits or your wiki has raised `$wgMaxArticleSize` (MediaWiki default 2 MB). Lower it for a tighter DoS guard.
247250

@@ -308,6 +311,12 @@ wiki.example.org:8443 [::1]:3000 localhost:3000
308311

309312
A request carrying an `Origin` the server cannot parse at all is rejected with a 403. Requests with no `Origin` header pass, because non-browser MCP clients do not send one.
310313

314+
### Rate limiting
315+
316+
`tools/call` is rate limited per caller: each authenticated caller gets its own allowance (`MCP_RATE_LIMIT`, burst `MCP_RATE_LIMIT_BURST`), and all anonymous callers share one (`MCP_RATE_LIMIT_ANONYMOUS`). A request over the limit is refused with `429` and a `Retry-After` header, and never reaches the wiki. Only `tools/call` is limited; every other request, including subscription streams, passes untouched.
317+
318+
The limiter is per-process — replicas each enforce their own allowance — and `mcp_rate_limited_total` on [`/metrics`](operations.md#metrics) counts refusals for tuning.
319+
311320
### v1 limitations
312321

313322
These apply to the [hosted OAuth sign-in](#hosted-oauth-sign-in) setup:
@@ -335,9 +344,9 @@ The server accepts a standard OAuth 2.1 `Authorization: Bearer` header on each r
335344
Authorization: Bearer <oauth2-access-token>
336345
```
337346

338-
Use a MediaWiki OAuth2 access token obtained from `Special:OAuthConsumerRegistration/propose/oauth2` on the target wiki, with [Extension:OAuth](https://www.mediawiki.org/wiki/Extension:OAuth) installed. The server forwards it to MediaWiki as that caller's token, so writes are attributable and MediaWiki's per-user rate limits apply. A bearer is scoped to a single MediaWiki OAuth2 realm, and the server pins nothing across requests: one client can address wikis on different authorization servers by sending the right token per request. `list-wikis` reports each OAuth wiki's `authorizationServer`.
347+
Use a MediaWiki OAuth2 access token obtained from `Special:OAuthConsumerRegistration/propose/oauth2` on the target wiki, with [Extension:OAuth](https://www.mediawiki.org/wiki/Extension:OAuth) installed. The server forwards it to MediaWiki as that caller's token, so writes are attributable and MediaWiki's per-user rate limits apply. A bearer is scoped to a single MediaWiki OAuth2 realm, and the server pins nothing across requests: one client can address wikis on different authorization servers by sending the right token per request. While forwarding is enabled, `list-wikis` reports each OAuth wiki's `authorizationServer` so a caller can see which realm a wiki belongs to.
339348

340-
The server no longer advertises the wikis' own authorization servers, so a client cannot discover where to mint such a token: obtain it yourself and configure it on the caller. Only [Hosted OAuth sign-in](#hosted-oauth-sign-in) publishes a protected-resource document, naming this server. While `MCP_ALLOW_BEARER_PASSTHROUGH=true` is set, a bearer-less request is challenged with `401` when no configured wiki is usable without a token; a deployment mixing OAuth and non-OAuth wikis still serves tokenless clients on the wikis that allow anonymous access.
349+
No OAuth discovery points at the wikis' authorization servers: the only protected-resource document is the one [Hosted OAuth sign-in](#hosted-oauth-sign-in) publishes, and it names this server. A client cannot discover where to mint a wiki token — obtain it yourself and configure it on the caller. While `MCP_ALLOW_BEARER_PASSTHROUGH=true` is set, a bearer-less request is challenged with `401` when no configured wiki is usable without a token; a deployment mixing OAuth and non-OAuth wikis still serves tokenless clients on the wikis that allow anonymous access.
341350

342351
**Precedence:** request header (only while `MCP_ALLOW_BEARER_PASSTHROUGH=true`; otherwise refused with `401`) → `config.json` `token``config.json` `username`/`password` → anonymous. The HTTP transport refuses to start with static credentials in `config.json` unless `MCP_ALLOW_STATIC_FALLBACK=true` is set; see [the Security checklist](#security-checklist) for why.
343352

docs/operations.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ Exposed series:
9090
- `mcp_inflight_requests` — gauge of `/mcp` requests currently being served. Subscription streams are excluded: they are held open by design.
9191
- `mcp_subscription_streams` — gauge of open change-notification streams (`subscriptions/listen`), the closest measure of connected clients.
9292
- `mcp_ready_failures_total` — counter of `/ready` probes that returned non-200.
93+
- `mcp_rate_limited_total{caller}` — counter of `tools/call` requests refused with `429`; the `caller` label is `caller` for authenticated callers and `anonymous` for the shared bucket. A rising `caller` series means authenticated callers hit `MCP_RATE_LIMIT`; a rising `anonymous` series means the shared backstop is engaging.
9394
- `mcp_proxy_store_upstream_tokens` — gauge of upstream MediaWiki tokens held in the hosted OAuth proxy store. This set grows with cumulative sign-ins over the process lifetime; watch it to size memory and the flush cost below.
9495
- `mcp_proxy_store_clients` — gauge of registered clients held in the hosted OAuth proxy store. FIFO-capped at 10,000, so this plateaus rather than growing without bound.
9596
- `mcp_proxy_store_flush_duration_seconds` — histogram of hosted-proxy store durable-flush durations (serialize + encrypt + write). Every upstream-token write flushes the whole store synchronously, so this scales with the token count above. Records successful flushes only.

src/auth/upstreamBearer.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,28 +93,37 @@ async function performUpstreamRefresh(
9393
// UpstreamBearerError for a transient upstream failure, or a non-retryable one for
9494
// a dead refresh token. verifyAccessToken throws on an invalid/expired/mis-
9595
// audienced JWT; the caller maps that (and a missing upstream token) to a 401.
96+
export interface ResolvedUpstreamBearer {
97+
accessToken: string;
98+
// The proxy JWT's jti: stable per signed-in user and client across both proxy
99+
// JWT refreshes and upstream token refreshes, which makes it the per-caller
100+
// rate-limit key.
101+
upstreamTokenId: string;
102+
}
103+
96104
export async function resolveUpstreamBearer(
97105
proxyJwt: string,
98106
pc: ProxyConfig,
99107
store: ProxyStore,
100108
refresh: RefreshFn = defaultRefresh,
101-
): Promise<string> {
109+
): Promise<ResolvedUpstreamBearer> {
102110
const { upstreamTokenId } = await verifyAccessToken(proxyJwt, pc);
103111
const upstream = store.getUpstreamToken(upstreamTokenId);
104112
if (!upstream) {
105113
throw new Error('upstream token not found');
106114
}
107115
if (!(upstream.expiresAt <= Date.now() + UPSTREAM_REFRESH_SKEW_MS && upstream.refreshToken)) {
108-
return upstream.accessToken;
116+
return { accessToken: upstream.accessToken, upstreamTokenId };
109117
}
110118
const currentRefreshToken = upstream.refreshToken;
111119
try {
112-
return await coalesceUpstreamRefresh(upstreamTokenId, () =>
120+
const accessToken = await coalesceUpstreamRefresh(upstreamTokenId, () =>
113121
performUpstreamRefresh(upstreamTokenId, currentRefreshToken, pc, store, refresh),
114122
);
123+
return { accessToken, upstreamTokenId };
115124
} catch (err) {
116125
if (Date.now() < upstream.expiresAt) {
117-
return upstream.accessToken;
126+
return { accessToken: upstream.accessToken, upstreamTokenId };
118127
}
119128
throw new UpstreamBearerError(
120129
classifyRefreshError(err) === 'retryable',

src/runtime/metrics.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ type ProxyStoreStats = { readonly upstreamTokens: number; readonly clients: numb
1818
interface Recorder {
1919
recordToolCall(input: RecordToolCallInput): void;
2020
recordReadyFailure(): void;
21+
recordRateLimited(caller: 'caller' | 'anonymous'): void;
2122
setInFlightProvider(fn: () => number): void;
2223
setSubscriptionStreamsProvider(fn: () => number): void;
2324
recordStoreFlush(durationMs: number): void;
@@ -41,6 +42,7 @@ function makeDisabledRecorder(): Recorder {
4142
return {
4243
recordToolCall: () => {},
4344
recordReadyFailure: () => {},
45+
recordRateLimited: () => {},
4446
setInFlightProvider: () => {},
4547
setSubscriptionStreamsProvider: () => {},
4648
recordStoreFlush: () => {},
@@ -84,6 +86,13 @@ function makeLiveRecorder(): Recorder {
8486
registers: [registry],
8587
});
8688

89+
const rateLimited = new Counter({
90+
name: 'mcp_rate_limited_total',
91+
help: 'Total tools/call requests refused with 429, labelled by whether the caller was authenticated.',
92+
labelNames: ['caller'] as const,
93+
registers: [registry],
94+
});
95+
8796
new Gauge({
8897
name: 'mcp_inflight_requests',
8998
help: 'Number of requests under /mcp currently being served (subscription streams excluded).',
@@ -153,6 +162,9 @@ function makeLiveRecorder(): Recorder {
153162
recordReadyFailure() {
154163
readyFailures.inc();
155164
},
165+
recordRateLimited(caller) {
166+
rateLimited.inc({ caller });
167+
},
156168
setInFlightProvider(fn) {
157169
inFlightProvider = fn;
158170
},
@@ -197,6 +209,10 @@ export function recordReadyFailure(): void {
197209
recorder.recordReadyFailure();
198210
}
199211

212+
export function recordRateLimited(caller: 'caller' | 'anonymous'): void {
213+
recorder.recordRateLimited(caller);
214+
}
215+
200216
export function setInFlightProvider(fn: () => number): void {
201217
recorder.setInFlightProvider(fn);
202218
}

src/transport/errorCodes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@
77
export const AUTHENTICATION_REQUIRED_ERROR_CODE = -31001;
88
export const UPSTREAM_UNAVAILABLE_ERROR_CODE = -31002;
99
export const PAYLOAD_TOO_LARGE_ERROR_CODE = -31003;
10+
export const RATE_LIMITED_ERROR_CODE = -31004;

src/transport/httpConfig.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { RateLimitSettings } from './rateLimit.ts';
2+
13
export interface HttpConfig {
24
host: string;
35
port: number;
@@ -7,6 +9,8 @@ export interface HttpConfig {
79
// request", which is the safe default rather than an absent control.
810
allowedOrigins: string[];
911
maxRequestBody: string;
12+
// null when the operator disabled rate limiting with MCP_RATE_LIMIT=0.
13+
rateLimit: RateLimitSettings | null;
1014
warnings: string[];
1115
}
1216

@@ -109,11 +113,58 @@ function resolveMaxRequestBody(): { value: string; warning?: string } {
109113
return { value: trimmed };
110114
}
111115

116+
const DEFAULT_RATE_LIMIT = 30;
117+
const DEFAULT_ANONYMOUS_RATE_LIMIT = 100;
118+
119+
// Accepts a non-negative number; undefined means unset, null means unparseable.
120+
function parseRateValue(raw: string | undefined): number | null | undefined {
121+
if (raw === undefined || raw.trim() === '') {
122+
return undefined;
123+
}
124+
const parsed = Number(raw);
125+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
126+
}
127+
128+
// Rate limiting for tools/call, on by default. MCP_RATE_LIMIT=0 disables it
129+
// entirely; MCP_RATE_LIMIT_ANONYMOUS=0 leaves anonymous traffic unlimited while
130+
// signed-in callers stay limited. Bursts default to twice the sustained rate,
131+
// the headroom an agent firing a batch of calls needs; only the per-caller
132+
// burst is separately tunable.
133+
function resolveRateLimit(): { value: RateLimitSettings | null; warnings: string[] } {
134+
const warnings: string[] = [];
135+
const read = (name: string, fallback: number): number => {
136+
const parsed = parseRateValue(process.env[name]);
137+
if (parsed === null) {
138+
warnings.push(
139+
`${name}=${process.env[name]} is not a non-negative number; using default ${fallback}`,
140+
);
141+
return fallback;
142+
}
143+
return parsed ?? fallback;
144+
};
145+
const rate = read('MCP_RATE_LIMIT', DEFAULT_RATE_LIMIT);
146+
if (rate === 0) {
147+
return { value: null, warnings };
148+
}
149+
const burst = read('MCP_RATE_LIMIT_BURST', rate * 2);
150+
const anonymousRate = read('MCP_RATE_LIMIT_ANONYMOUS', DEFAULT_ANONYMOUS_RATE_LIMIT);
151+
return {
152+
value: {
153+
ratePerSecond: rate,
154+
burst: Math.max(1, burst),
155+
anonymousRatePerSecond: anonymousRate,
156+
anonymousBurst: Math.max(1, anonymousRate * 2),
157+
},
158+
warnings,
159+
};
160+
}
161+
112162
export function resolveHttpConfig(): HttpConfig {
113163
const host = resolveHost();
114164
const port = resolvePort();
115165
const body = resolveMaxRequestBody();
116-
const warnings: string[] = [];
166+
const rateLimit = resolveRateLimit();
167+
const warnings: string[] = [...rateLimit.warnings];
117168
if (body.warning) {
118169
warnings.push(body.warning);
119170
}
@@ -132,6 +183,7 @@ export function resolveHttpConfig(): HttpConfig {
132183
allowedHosts: resolveAllowedHosts(),
133184
allowedOrigins: resolveAllowedOrigins(host, port),
134185
maxRequestBody: body.value,
186+
rateLimit: rateLimit.value,
135187
warnings,
136188
};
137189
}

0 commit comments

Comments
 (0)