You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Close two ways a request could dodge the rate limiter
Review found both live: on a drained bucket, a JSON-RPC batch of 25 tool calls
returned 25 results, and a request sent as `application/json;` ran its tool every
time. Either one made the limiter decorative.
The legacy stateless leg executes a batch entry by entry, so a batch now costs one
token per tools/call entry and is refused whole when the cost does not fit —
wrapping calls in brackets buys nothing. The content-type hole was a mismatch
between two predicates: the SDK accepts a header body-parser's default matcher
rejects, so express left req.body undefined, the limiter saw no tool call, and the
handler read the raw stream anyway. express.json now types on the SDK's own
isJsonContentType, which cannot drift from what the handler accepts. That also
restores MCP_MAX_REQUEST_BODY, which the same gap had been bypassing.
Two further holes the review surfaced. A forwarded bearer is never verified by
this server, so a caller minting a random token per request minted a fresh
allowance per request; forwarded bearers now charge a shared allowance as well as
their own, capping the whole deprecated path. And an overflow key arriving when
every tracked bucket was mid-drain fell back to the anonymous bucket, which passes
everything when anonymous limiting is switched off; it is now refused outright.
The refusal also reports which allowance rejected it, so mcp_rate_limited_total
stops labelling a shared-bucket refusal as the caller's own.
The regression tests drive the real buildApp: both bypasses were invisible to
tests that stubbed the handler or built their own express app, and a first attempt
at a content-type test reproduced that mistake — it inlined the fix in its own
harness and passed against the unfixed server.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Copy file name to clipboardExpand all lines: CHANGELOG.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -13,7 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
13
13
14
14
### Breaking changes
15
15
16
-
- The HTTP transport now rate limits `tools/call`: each signed-in user gets their 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.
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.
17
17
- 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.
18
18
- 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.
19
19
- 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.
Copy file name to clipboardExpand all lines: docs/deployment.md
+6-6Lines changed: 6 additions & 6 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -49,7 +49,7 @@ Serve a single wiki for anonymous reads: no sign-in, no writes. Good for public
49
49
50
50
`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.
51
51
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 [rate limits tool calls itself](#rate-limiting); IP-level limiting against anonymous floods still belongs at the proxy, which knows the caller's address when this server does not.
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.
53
53
54
54
## Hosted OAuth sign-in
55
55
@@ -242,9 +242,9 @@ Set `MCP_TRANSPORT=http` to select this transport (the Docker image defaults to
242
242
|`MCP_TRUSTED_HOSTS`| unset | Comma-separated **outbound** SSRF-guard exemptions for internal destinations (e.g. `mediawiki.svc`). See [Outbound SSRF guard](#outbound-ssrf-guard). |
243
243
|`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). |
244
244
|`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 signed-in caller. `0` disables rate limiting. See [Rate limiting](#rate-limiting). |
245
+
|`MCP_RATE_LIMIT`|`30`| Sustained `tools/call` per second per authenticated caller. `0` disables rate limiting. See [Rate limiting](#rate-limiting). |
246
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 ×). `0` leaves anonymous traffic unlimited. |
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. |
248
248
249
249
`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.
250
250
@@ -313,9 +313,9 @@ A request carrying an `Origin` the server cannot parse at all is rejected with a
313
313
314
314
### Rate limiting
315
315
316
-
`tools/call` is rate limited per caller: each signed-in user gets their 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. Discovery calls and subscription streams are not limited.
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
317
318
-
The split follows who can see what: only this server knows which signed-in user a request acts as, so per-user fairness lives here; only the reverse proxy can tell anonymous callers apart by IP address, so the anonymous allowance is a flood backstop for the wiki, not fairness between anonymous callers. 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.
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.
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.
348
348
349
-
No OAuth discovery leads here: only [Hosted OAuth sign-in](#hosted-oauth-sign-in) publishes a protected-resource document, and it names this server, so an OAuth-aware client is never steered into minting a wiki token to present here. Obtain the token 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.
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.
350
350
351
351
**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.
Copy file name to clipboardExpand all lines: docs/operations.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -90,7 +90,7 @@ Exposed series:
90
90
-`mcp_inflight_requests` — gauge of `/mcp` requests currently being served. Subscription streams are excluded: they are held open by design.
91
91
-`mcp_subscription_streams` — gauge of open change-notification streams (`subscriptions/listen`), the closest measure of connected clients.
92
92
-`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`, labelled`caller`or `anonymous`. A rising `caller` series means signed-in users hit `MCP_RATE_LIMIT`; a rising `anonymous` series means the shared backstop is engaging.
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.
94
94
-`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.
95
95
-`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.
96
96
-`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.
0 commit comments