Skip to content

fix: serve MCP at /mcp and /mcp/ directly (no redirect) for OAuth clients#44

Merged
imonroe merged 2 commits into
mainfrom
claude/zen-babbage-eOZ3p
May 23, 2026
Merged

fix: serve MCP at /mcp and /mcp/ directly (no redirect) for OAuth clients#44
imonroe merged 2 commits into
mainfrom
claude/zen-babbage-eOZ3p

Conversation

@imonroe
Copy link
Copy Markdown
Owner

@imonroe imonroe commented May 23, 2026

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:

POST /oauth/token → 200
POST /mcp → 307
POST /mcp → 307
POST /mcp → 307   (repeats; never reaches /mcp/, no authenticated session)

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 /mcp with the endpoint at "/", so /mcp returned 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 /mcp and /mcp/ directly, with no redirect:

  • Build the FastMCP app at path="/mcp" and add an explicit /mcp/ alias Route sharing the same endpoint.
  • Mount the app at the root (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 the resource_metadata discovery pointer intact).

Verification

Against the real app.main with OAuth enabled:

POST /mcp  (auth)    → 200      POST /mcp/ (auth)   → 200
POST /mcp  (no auth) → 401 with resource_metadata
/metrics → 200   /api/v1/memories (no auth) → 401   /.well-known/* → 200
  • Added a regression test asserting both /mcp and /mcp/ return 200 (no 307).
  • Adjusted the logging test to register its route before the catch-all mount.
  • Documented the mounting invariant in CLAUDE.md and the Developer Guide.
  • 65 tests pass; ruff clean.

https://claude.ai/code/session_01U3EtN3puoZRq2t7nedcnHY


Generated by Claude Code

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
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 /mcp and add an explicit /mcp/ alias, and mounted the MCP app at / as a last-registered catch-all.
  • Added a regression test asserting both /mcp and /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.

Comment thread app/main.py Outdated
# 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 thread app/main.py
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 thread docs/DEVELOPER_GUIDE.md Outdated
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
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

@imonroe imonroe merged commit fea1435 into main May 23, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants