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
Stop forwarding caller-supplied bearers, and stop advertising the wikis' issuers
With the hosted proxy disabled, an Authorization header was forwarded to
MediaWiki verbatim and the protected-resource document named the wikis' own
authorization servers. Together those steered a client into minting a token
at the wiki and presenting it here - the shape MCP forbids, since a server
must not accept tokens that were not issued for it.
Forwarding now requires MCP_ALLOW_BEARER_PASSTHROUGH=true, is deprecated,
and warns at startup. Without it a request carrying a bearer is refused with
401 rather than served: ignoring the header would run the request anonymously
or as a configured identity while the caller believed it was acting as
itself, which is worse than refusing it.
The refusal is deliberately unconditional and NOT keyed on whether a wiki
sets oauth2ClientId. That option describes how this server runs browser
sign-in, not whether the wiki accepts bearers - any wiki with Extension:OAuth
does - so keying on it would keep forwarding tokens on exactly the documented
public read-only and manual-token deployments. A test covers that shape.
The advertisement is removed unconditionally, including under the opt-in.
Only the hosted proxy makes this server an authorization server, so with the
proxy off /.well-known/oauth-protected-resource now answers 404. The
documented use for forwarding is callers that already hold a token, so they
need no discovery, and restoring it would reinstate the steering this closes.
Answering before the upstream fetches also means an unauthenticated request
no longer costs one outbound metadata fetch per OAuth wiki.
Because that document can now be absent, the 401 challenge only advertises
resource_metadata when it exists; RFC 6750 admits a challenge without it.
Otherwise every challenge pointed a client at a URL that 404s.
The all-OAuth 401 challenge is gated on the opt-in: it asks a caller to
supply a wiki token, and with no way to forward one the same condition is an
operator misconfiguration instead. So the server now warns at startup when a
wiki needs a signed-in user but neither hosted sign-in nor forwarding can
provide one, and the tool-layer error names the operator action rather than
asking for a token the transport would refuse.
Tests that cover the forwarding shape now run under the flag, since the
behaviour still exists there. The multi-wiki issuer listing is deleted rather
than gated - that behaviour is gone. The fake authorization server gained a
metadata-request counter so the no-fetch claim is actually asserted.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copy file name to clipboardExpand all lines: CHANGELOG.md
+2Lines changed: 2 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -13,6 +13,8 @@ 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 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.
17
+
- 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.
16
18
- 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: README.md
+2-2Lines changed: 2 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -166,7 +166,7 @@ For the full field reference, env-var substitution, secret sources, change tags,
166
166
Tools marked 🔐 require authentication. Write tools (including extension-pack writes) are hidden from `tools/list` when the configured default wiki has `readOnly: true` — see [Deployment](#deployment).
167
167
168
168
-**Browser-based OAuth (recommended).** Sign in through a browser tab the first time a tool needs auth. Set `oauth2ClientId` and `oauth2CallbackPort` per wiki — see [docs/configuration.md — OAuth (browser-based)](docs/configuration.md#oauth-browser-based).
169
-
-**Per-request bearer token (HTTP).** Each request carries `Authorization: Bearer <token>`; the server forwards it to MediaWiki. See [docs/deployment.md — per-request bearer token](docs/deployment.md#per-request-bearer-token-http-transport).
169
+
-**Per-request bearer token (HTTP), deprecated.** Each request carries `Authorization: Bearer <token>` and the server forwards it to MediaWiki. Off by default, because an MCP server must not accept tokens that were not issued for it. See [docs/deployment.md — per-request bearer token](docs/deployment.md#per-request-bearer-token-http-transport-deprecated).
170
170
-**Hosted OAuth proxy (HTTP).** The server fronts one MediaWiki consumer as an OAuth 2.1 Authorization Server, so an OAuth-aware client signs each user in — no manual tokens. Point it at `https://<wiki>/mcp`; anonymous read still works. See [docs/deployment.md — hosted OAuth sign-in](docs/deployment.md#hosted-oauth-sign-in).
171
171
-**Manual OAuth2 access token.** Paste a long-lived token into `config.json`. See [docs/configuration.md — manual OAuth2 access token](docs/configuration.md#manual-oauth2-access-token).
172
172
-**Bot password.** Fallback when Extension:OAuth isn't installed. See [docs/configuration.md — bot password](docs/configuration.md#bot-password).
@@ -284,7 +284,7 @@ Running the server as a remote HTTP endpoint for other users has its own configu
284
284
285
285
Defaults are safe for single-user use. Before exposing the HTTP transport to others, lock down three things:
286
286
287
-
-**Trust the proxy, not the header.**The server forwards any `Authorization: Bearer` header straight to MediaWiki — authentication is the reverse proxy's job. Terminate TLS there, and don't expose the MCP port directly on an untrusted network. See [docs/deployment.md — security checklist](docs/deployment.md#security-checklist).
287
+
-**Terminate TLS at your reverse proxy.**Don't expose the MCP port directly on an untrusted network. See [docs/deployment.md — security checklist](docs/deployment.md#security-checklist).
288
288
-**Pair `MCP_BIND` with `MCP_ALLOWED_HOSTS` and `MCP_ALLOWED_ORIGINS`.** The HTTP transport binds to `127.0.0.1` by default. When you open it up with `MCP_BIND=0.0.0.0`, set `MCP_ALLOWED_HOSTS` to the hostnames your proxy forwards and `MCP_ALLOWED_ORIGINS` to the browser origins allowed to call the server — these block DNS-rebinding and cross-origin attacks respectively.
289
289
-**Uploads are opt-in.**`upload-file` is disabled until you list allowed directories in `uploadDirs` or `MCP_UPLOAD_DIRS`. See [docs/configuration.md — upload directories](docs/configuration.md#upload-directories).
290
290
-**Internal destinations need `MCP_TRUSTED_HOSTS`.** Outbound fetches are SSRF-guarded: a destination resolving to a private or loopback address (e.g. a Docker-network alias like `mediawiki.svc`) is refused until you list its host in `MCP_TRUSTED_HOSTS`. See [docs/deployment.md — outbound SSRF guard](docs/deployment.md#outbound-ssrf-guard).
Copy file name to clipboardExpand all lines: docs/configuration.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
@@ -201,7 +201,7 @@ If your wiki doesn't have an OAuth consumer set up, omit `oauth2ClientId`. Stati
201
201
202
202
#### HTTP transport behaviour
203
203
204
-
Over the HTTP transport, OAuth runs through discovery and `401` challenges instead of a local browser flow, and each request carries the bearer for its target wiki. That behaviour is in [deployment.md — per-request bearer token](deployment.md#per-request-bearer-token-http-transport).
204
+
Over the HTTP transport, OAuth runs through discovery and `401` challenges instead of a local browser flow, and each request is served with whichever identity the deployment provides. That behaviour is in [deployment.md — per-request bearer token](deployment.md#per-request-bearer-token-http-transport-deprecated).
Copy file name to clipboardExpand all lines: docs/deployment.md
+12-8Lines changed: 12 additions & 8 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -206,8 +206,9 @@ The `GIT_SHA` build arg populates the image's `org.opencontainers.image.revision
206
206
207
207
Defaults are safe for a localhost bind. Before exposing the HTTP transport to others, confirm all of these:
208
208
209
-
-**Terminate TLS at a reverse proxy; never expose the port directly.** The server trusts any `Authorization: Bearer` header it receives without origin checks, so authentication is the proxy's job. Run it behind Caddy, nginx, or Traefik, or bind it to `127.0.0.1`; never put the raw HTTP port on an untrusted network.
210
-
-**Forward the `Authorization` header intact.** Proxy configs that strip or consume it (`header_up -Authorization`, `proxy_set_header Authorization ""`, a proxy-level basic-auth handler on the MCP route) leave the server with no token, falling back to config or anonymous. On any untrusted inbound path, strip the client-supplied `Authorization` instead, so a caller cannot inject a bearer the server would trust.
209
+
-**Terminate TLS at a reverse proxy; never expose the port directly.** Run it behind Caddy, nginx, or Traefik, or bind it to `127.0.0.1`; never put the raw HTTP port on an untrusted network.
210
+
-**A caller-supplied `Authorization` header is refused, not trusted.** The server no longer forwards one to the wiki, so a caller cannot inject a bearer it would act on. Only enable `MCP_ALLOW_BEARER_PASSTHROUGH` if you have callers holding their own wiki tokens.
211
+
-**Let the `Authorization` header reach the server.** With hosted OAuth sign-in it carries a token this server issued, and with `MCP_ALLOW_BEARER_PASSTHROUGH` set it carries the caller's own; either way `header_up -Authorization`, `proxy_set_header Authorization ""` and a proxy-level basic-auth handler on the MCP route all break sign-in.
211
212
-**Set `MCP_ALLOWED_HOSTS`** to the hostnames your proxy forwards (e.g. `wiki.example.org`). This engages the SDK's DNS-rebinding check; requests to `/mcp` with a non-matching `Host` get a 403. Unset on a public bind turns the check off (with a startup warning); unset on a localhost bind is safe.
212
213
-**Set `MCP_ALLOWED_ORIGINS`** to the browser origins allowed to call `/mcp` (e.g. `https://app.example.org`). A present-but-unlisted `Origin` gets a 403. The match is on hostname; see [Host and Origin matching](#host-and-origin-matching). Leaving it unset on a public bind refuses every browser request, so set it if you serve one. This is a separate decision from `MCP_ALLOWED_HOSTS`, which names hosts this server answers to rather than origins allowed to script it.
213
214
-**List internal destinations in `MCP_TRUSTED_HOSTS`.** Outbound fetches are SSRF-guarded, so a wiki `server` on a private or Docker-internal address (e.g. `mediawiki.svc`) is refused until you exempt it; otherwise extension tools silently disappear. See [outbound SSRF guard](#outbound-ssrf-guard).
@@ -240,6 +241,7 @@ Set `MCP_TRANSPORT=http` to select this transport (the Docker image defaults to
240
241
|`MCP_ALLOWED_ORIGINS`| auto on localhost | Comma-separated `Origin`-header allowlist. Unset on a public bind refuses every browser request. See [Security checklist](#security-checklist). |
241
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). |
242
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
+
|`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). |
243
245
244
246
`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.
245
247
@@ -259,7 +261,7 @@ Set `MCP_TRANSPORT=http` to select this transport (the Docker image defaults to
259
261
260
262
When enabled, the [hosted OAuth sign-in](#hosted-oauth-sign-in) setup makes this server the OAuth authorization server the MCP client talks to, through the endpoints routed in [step 4](#4-route-the-oauth-endpoints-through-your-proxy). The bearer a client sends to `/mcp` is a token the proxy minted, not a MediaWiki token. The user's MediaWiki token stays server-side, keyed to that bearer, and is refreshed server-to-server through the confidential consumer — this is what keeps users signed in past the wiki's ~1-hour access-token lifetime, and it is the state the [store file](#proxy-state-persistence) persists.
261
263
262
-
How the sign-in challenge is issued depends on the wiki. On a **public wiki**, a tokenless request is served anonymously; a write that needs authentication returns an authentication error, and an invalid or expired bearer gets a `401` + `WWW-Authenticate` challenge. A **private wiki** (`private: true`, MediaWiki's `$wgGroupPermissions['*']['read'] = false`) answers every request, including the initial connection, with that challenge, so a client prompts for sign-in at connect. The connection-time challenge requires the wiki's `oauth2ClientId`; without it, the `401` advertises an authorization server the wiki does not have, and the server logs a warning at startup.
264
+
How the sign-in challenge is issued depends on the wiki. On a **public wiki**, a tokenless request is served anonymously; a write that needs authentication returns an authentication error, and an invalid or expired bearer gets a `401` + `WWW-Authenticate` challenge. A **private wiki** (`private: true`, MediaWiki's `$wgGroupPermissions['*']['read'] = false`) answers every request, including the initial connection, with that challenge, so a client prompts for sign-in at connect. That challenge names an authorization server only when [hosted OAuth sign-in](#hosted-oauth-sign-in) is configured; otherwise it is a bare `Bearer` challenge, because there is no document for a client to fetch. A private wiki with no hosted sign-in and no forwarding cannot serve any request, and the server says so at startup.
263
265
264
266
#### Three-base topology
265
267
@@ -321,9 +323,11 @@ The proxy persists its sign-in state to a local file so a restart or deploy does
321
323
322
324
**In Docker, mount a persistent volume at the store path.** The image declares one at `/app/data`, but you must mount a named volume or a writable host path there, or a container restart wipes it. A host-path bind mount must be writable by the container's non-root user; a named volume handles that automatically.
For **programmatic or non-interactive clients that already hold a MediaWiki OAuth2 access token** (a script, a CI job, an automation backend), the HTTP transport also accepts the token directly, with no browser flow. Most deployments serving humans should use [Hosted OAuth sign-in](#hosted-oauth-sign-in) instead; this is the lower-level primitive it is built on.
328
+
> **Deprecated.** An MCP server must not accept tokens that were not issued for it, so forwarding a caller's MediaWiki token is off by default and will be removed. Use [Hosted OAuth sign-in](#hosted-oauth-sign-in), which gets this server its own tokens. Set `MCP_ALLOW_BEARER_PASSTHROUGH=true` to keep the old behaviour while you migrate; the server logs a warning at startup while it is set.
329
+
330
+
For **programmatic or non-interactive clients that already hold a MediaWiki OAuth2 access token** (a script, a CI job, an automation backend), the HTTP transport can accept the token directly, with no browser flow.
327
331
328
332
The server accepts a standard OAuth 2.1 `Authorization: Bearer` header on each request, as described in the [MCP authorization specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization):
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`.
335
339
336
-
When a wiki sets `oauth2ClientId` (see [configuration.md: OAuth (browser-based)](configuration.md#oauth-browser-based)), the server also advertises OAuth discovery on this path: the protected-resource document lists every OAuth-configured wiki's authorization server, and a capable client can run the authorization-code flow against the wiki's **own** authorization server and fetch that token itself instead of you pasting one in. A bearer-less request is challenged with `401`only when no configured wiki is usable without a token; a deployment that mixes OAuth and non-OAuth wikis still serves tokenless clients on the wikis that allow anonymous access.
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.
337
341
338
-
**Precedence:** request header → `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.
342
+
**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.
339
343
340
344
HTTP serving is per-request, following MCP protocol revision 2026-07-28: the server issues no session ids, serves 2026-07-28 clients natively, and serves earlier 2025-era clients statelessly. Each request builds an independent MediaWiki session from the token it carries, so rotation and revocation take effect on the very next request; run the transport behind TLS so bearers stay confidential in transit.
341
345
342
-
Example with Claude Code:
346
+
Example with Claude Code, which works only on a server started with `MCP_ALLOW_BEARER_PASSTHROUGH=true`:
343
347
344
348
```sh
345
349
claude mcp add --transport http my-wiki https://wiki.example.org/mcp \
-**`auth_shape`** — `anonymous`, `static-credential`, or `bearer-passthrough`.
34
+
-**`auth_shape`** — `anonymous`, `static-credential`, `oauth-proxy` (hosted sign-in configured), or `bearer-passthrough` (only while the deprecated `MCP_ALLOW_BEARER_PASSTHROUGH` is set).
35
35
-**`host`, `port`, `allowed_hosts`, `allowed_origins`** — HTTP transport only. `allowed_hosts` is omitted when not configured. `allowed_origins` is always present: an empty array means every browser request is refused, not that the check is off.
36
36
-**`upload_dirs_configured`** — `true` when `uploadDirs` (config) or `MCP_UPLOAD_DIRS` (env) is set. The actual paths are not logged.
37
37
-**`max_request_body`** — HTTP transport only. The resolved `MCP_MAX_REQUEST_BODY` value.
0 commit comments