fix: serve MCP at /mcp and /mcp/ directly (no redirect) for OAuth clients#44
Merged
Conversation
Strict OAuth MCP clients (Claude.ai web / Cowork) POST to the exact
advertised resource URL (now <base>/mcp, no trailing slash) and do not
follow redirects. The MCP app was mounted at /mcp with the endpoint at
"/", so /mcp returned a 307 to /mcp/ that the client wouldn't follow,
leaving the authenticated session unestablished ("Authorization with the
MCP server failed") even though discovery and token exchange succeeded.
Build the FastMCP endpoint at path="/mcp", add an explicit /mcp/ alias
route, and mount the app at the root (registered LAST so it doesn't
shadow the other routes). Both /mcp and /mcp/ now resolve directly with
auth enforced. Add a regression test asserting both variants return 200,
and document the mounting invariant.
https://claude.ai/code/session_01U3EtN3puoZRq2t7nedcnHY
There was a problem hiding this comment.
Pull request overview
This PR addresses strict MCP OAuth clients (Claude.ai web / Cowork) failing when POST /mcp returns a 307 redirect to /mcp/ by ensuring the MCP endpoint is served directly at both /mcp and /mcp/ without redirects, while preserving precedence of the REST/OAuth/health/metrics routes.
Changes:
- Reworked MCP app wiring to serve the MCP endpoint at
/mcpand add an explicit/mcp/alias, and mounted the MCP app at/as a last-registered catch-all. - Added a regression test asserting both
/mcpand/mcp/return 200 without redirects; adjusted the logging test to avoid route shadowing. - Documented the “root mount must be registered last” invariant in contributor docs.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| app/main.py | Serves MCP at /mcp + /mcp/ without redirects; mounts MCP app at / last. |
| tests/test_main.py | Adds regression coverage for both /mcp and /mcp/ POST behavior. |
| tests/test_logging.py | Ensures test route is registered before the root catch-all mount. |
| docs/DEVELOPER_GUIDE.md | Documents the mounting/ordering invariant and slash-alias behavior. |
| CLAUDE.md | Records the invariant and canonical resource URI details for agent guidance. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # 307 redirect. Strict MCP clients (Claude.ai web / Cowork) POST to the exact | ||
| # advertised resource URL and don't follow the redirect, so a redirect breaks them. | ||
| mcp_app = mcp.http_app(path="/mcp", stateless_http=True, transport="streamable-http") | ||
| _mcp_route = next(r for r in mcp_app.router.routes if getattr(r, "path", None) == "/mcp") |
Comment on lines
+113
to
+116
| # Mounted at the root LAST so the specific routes above (/api/v1, /oauth, | ||
| # /.well-known, /metrics, /healthz) take precedence; the MCP app only owns | ||
| # /mcp and /mcp/ and 404s everything else. | ||
| app.mount("/", mcp_app) |
Comment on lines
+41
to
+46
| 3a. **The MCP app is mounted at the root (`app.mount("/", mcp_app)`), registered LAST**, with the | ||
| FastMCP endpoint built at `path="/mcp"` plus an explicit `/mcp/` alias route. This serves both | ||
| `/mcp` and `/mcp/` directly (no 307 redirect) — strict clients like Claude.ai web POST to the | ||
| exact resource URL and won't follow a redirect. Because the root mount is a catch-all, every | ||
| other route (`/api/v1`, `/oauth`, `/.well-known`, `/metrics`, `/healthz`) MUST be registered | ||
| before it or it will be shadowed. |
Address review feedback on the root-mount change: - raise a clear error if FastMCP's /mcp route isn't found, instead of an opaque StopIteration at import time - the root mount leaves no matched route at the middleware level, so MCP requests were bucketing under "__unmatched__"; derive a stable "/mcp" label (both slash variants) and keep real fallthrough 404s unmatched - renumber the developer-guide invariant list (3a -> 4) so it renders https://claude.ai/code/session_01U3EtN3puoZRq2t7nedcnHY
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Follow-up to #43. The OAuth handshake now fully succeeds (register → authorize → token), but Claude.ai web / Cowork still report "Authorization with the MCP server failed." The latest container logs show why:
Root cause
After #43 made the advertised resource canonical (
https://<host>/mcp, no trailing slash), Claude POSTs its authenticated MCP requests to/mcp(the exact resource URL). But the server mounted the MCP app at/mcpwith the endpoint at"/", so/mcpreturned a 307 redirect to/mcp/. Strict MCP clients POST to the exact URL and do not follow the redirect, so the session never established — even though the token is valid (earlier/mcp/calls returned 200).Fix
Serve the MCP endpoint at both
/mcpand/mcp/directly, with no redirect:path="/mcp"and add an explicit/mcp/aliasRoutesharing the same endpoint.app.mount("/", mcp_app)), registered last so the catch-all doesn't shadow/api/v1,/oauth,/.well-known,/metrics,/healthz.Auth is still enforced (unauthenticated
/mcp→ 401 with theresource_metadatadiscovery pointer intact).Verification
Against the real
app.mainwith OAuth enabled:/mcpand/mcp/return 200 (no 307).CLAUDE.mdand the Developer Guide.ruffclean.https://claude.ai/code/session_01U3EtN3puoZRq2t7nedcnHY
Generated by Claude Code