-
Notifications
You must be signed in to change notification settings - Fork 0
Pitfall Anonymous vs Authenticated Server
Affects: Scope boundary, tool visibility Category: Architecture, Security
Updated for #450. This page originally described a four-instance architecture (
anonServer/readServer/writeServer/siteAdminServer, one MCP server per scope tier) that no longer exists in the code. Since #450 collapsed the scope model to two tiers (read/write), the server runs exactly two MCP server instances. The invariant below is rewritten to match; nothing about the reasoning changed, only the mechanism.
Correction (2026-07-18, #498). The claim below that
publicServeris reachable with "noAuthorizationheader at all" is only true whencfg.OAuth.Enabled == false(e.g. local dev, or a deployment with OAuth off entirely). Production runs with OAuth enabled. In that mode,internal/server/server.go's/mcphandler deliberately returns401with aWWW-Authenticatechallenge for every request with noAuthorizationheader — including ones that would otherwise resolve to the anonymous/read tier — so that OAuth-capable clients (Claude.ai, ChatGPT) discover the auth server and start PKCE instead of silently getting an anonymous tool list (RFC 6750 §3.1 discovery). This is deliberate, working-as-designed behavior, not a bug or a proxy/infra issue — confirmed by reading the handler directly, which carries an explicit comment to this effect.Net effect for an OAuth-enabled deployment (i.e. production): there is no way to get a genuinely bearerless response from
/mcp. The two-tierpublicServer/writeServersplit described below is real and correctly enforces theread/writeboundary once a caller has a token — reader access in production is obtained via self-serve DCR registration + PKCE auth-code flow (which grantsread, per #497/#505), not via a bearerless request. See #498 for the live reproduction and the matching discovery- metadata wording problem.
The server (internal/server/server.go) runs two MCP server instances:
| Instance | Reachable via | Tools registered |
|---|---|---|
publicServer |
no bearer token, when OAuth is disabled (cfg.OAuth.Enabled == false); when OAuth is enabled (production), reached only after obtaining a read-scope bearer via self-serve DCR + PKCE (see correction above) |
Anonymous tools + the full read tool set (ungated at the scope-ACL layer since #450 — see below) |
writeServer |
a valid bearer token for a registered OAuth client | Everything on publicServer plus every write-scoped tool (create/update/delete content, build, admin, diagnostics) |
Since #450, tools that used to require content.read (e.g. validate_site, validate_frontmatter, build_agent_context) are ungated at the scope-ACL layer — they are registered on publicServer and, once a caller has any valid token (even read), answer without needing write. This is an intentional design decision (full read visibility, including drafts, requires no elevated scope), not a regression — see docs/mcp-contract.md §6.12. Only write tools require write scope specifically. Whether a bearer token is required at all to reach that scope-ACL layer is a separate question, governed by cfg.OAuth.Enabled (see correction above) — do not conflate the two.
If a future change reintroduces a restricted "reader" tier that needs content.read-only tools hidden from fully anonymous callers, that would require re-adding a third server instance — this page should be updated again if that happens.
writeServer — reachable only with a valid bearer token for a registered client — is where the ACL boundary lives now: the scope check gates write-tier tools specifically, not the general read surface. The scope ACL layer (oauth.ScopePolicy.AllowRequest) is the enforcement mechanism; the correct first defence is still registering each tool on the right server instance in internal/server/server.go.
TestAnonymousServerExposesReadTools in internal/server/server_test.go enforces the current (post-#450) invariant: it spins up a server with no Authorization header and asserts that the full read tool set — including tools formerly gated behind content.read (validate_site, validate_frontmatter, build_agent_context, export_agent_context, get_broken_links, get_site_health, diff_page, search_content, and more) — is present, while write-tier tools remain absent.
Claude.ai labels tools as "Outils en lecture seule" (read-only) based on the tool's ReadOnlyHint annotation — whether the tool modifies data. This is Claude.ai's own approval model and has nothing to do with OAuth scope.
Since #450, every read-only tool (ReadOnlyHint=true) requires no more than read scope at the ACL layer — the historical distinction ("read-only but still requires content.read/write") no longer exists. They appear under "Outils en lecture seule" in Claude.ai for the same reason (their ReadOnlyHint). This does not mean they're reachable with zero bearer token in production — see the correction at the top of this page; a read-scope token (from self-serve DCR) is still required whenever cfg.OAuth.Enabled == true.
To verify the current tool set from the command line against a production (OAuth-enabled) deployment:
# No Authorization header → 401 challenge in production (OAuth enabled by design, see
# correction above), NOT the anonymous tool list. This is expected, not a bug.
curl -isk https://mcp.arleo.eu/mcp \
-H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# A read-scope token (obtain via self-serve DCR + PKCE, or #498's reproduction) → should
# see the full read tool set (including validate_site, build_agent_context, etc.)
curl -s https://mcp.arleo.eu/mcp \
-H "Authorization: Bearer $READ_TOKEN" \
-H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq '[.result.tools[].name]'
# With a write-scoped token → should additionally see create_page/build_site/etc.
curl -s https://mcp.arleo.eu/mcp \
-H "Authorization: Bearer $WRITE_TOKEN" \
-H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq '[.result.tools[].name]'If you need to test the true bearerless invariant described in "The invariant" table above (no Authorization header at all resolving to the anonymous+read tool set), that only happens with cfg.OAuth.Enabled: false — e.g. a local build with OAuth off. internal/server/server_test.go's TestAnonymousServerExposesReadTools uses exactly that config; it is not a live-production reproduction.