Skip to content

Commit 60e9b0c

Browse files
fix(mcp): resolve 307 redirect issue by enforcing trailing slash in URLs
- Updated MCP documentation to include trailing slash in all URL examples - Modified frontend MCP page to reflect the correct endpoint with trailing slash - Adjusted FastAPI middleware to handle requests to /mcp without redirecting
1 parent 953d600 commit 60e9b0c

File tree

8 files changed

+187
-27
lines changed

8 files changed

+187
-27
lines changed

api/main.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,29 @@ async def add_cache_headers(request: Request, call_next):
148148
app.include_router(debug_router)
149149

150150

151+
# ASGI middleware to handle /mcp without trailing slash
152+
# This runs BEFORE FastAPI routing, avoiding the 307 redirect
153+
# MCP clients like Claude CLI don't follow redirects
154+
class MCPTrailingSlashMiddleware:
155+
"""Rewrite /mcp to /mcp/ before routing to avoid 307 redirect."""
156+
157+
def __init__(self, asgi_app):
158+
self.asgi_app = asgi_app
159+
160+
async def __call__(self, scope, receive, send):
161+
if scope["type"] == "http" and scope["path"] == "/mcp":
162+
scope = scope.copy()
163+
scope["path"] = "/mcp/"
164+
await self.asgi_app(scope, receive, send)
165+
166+
167+
# Wrap the FastAPI app with the middleware
168+
# This must be done after all routers are registered
169+
# Keep reference to FastAPI instance for tests
170+
fastapi_app = app
171+
app = MCPTrailingSlashMiddleware(app)
172+
173+
151174
if __name__ == "__main__":
152175
import uvicorn
153176

api/routers/seo.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ async def get_sitemap(db: AsyncSession | None = Depends(optional_db)):
7171
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
7272
" <url><loc>https://pyplots.ai/</loc></url>",
7373
" <url><loc>https://pyplots.ai/catalog</loc></url>",
74+
" <url><loc>https://pyplots.ai/mcp</loc></url>",
7475
" <url><loc>https://pyplots.ai/legal</loc></url>",
7576
]
7677

app/src/pages/McpPage.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ export function McpPage() {
156156
<Typography sx={textStyle}>
157157
<strong>Endpoint</strong>:{' '}
158158
<code style={{ backgroundColor: '#f3f4f6', padding: '4px 8px', borderRadius: '4px', color: '#3776AB' }}>
159-
https://api.pyplots.ai/mcp
159+
https://api.pyplots.ai/mcp/
160160
</code>
161161
</Typography>
162162
</Paper>
@@ -179,7 +179,7 @@ export function McpPage() {
179179
</TableRow>
180180
<TableRow>
181181
<TableCell>URL</TableCell>
182-
<TableCell><code>https://api.pyplots.ai/mcp</code></TableCell>
182+
<TableCell><code>https://api.pyplots.ai/mcp/</code></TableCell>
183183
</TableRow>
184184
<TableRow>
185185
<TableCell>Transport</TableCell>
@@ -190,7 +190,7 @@ export function McpPage() {
190190

191191
<Typography sx={subheadingStyle}>Claude Code</Typography>
192192
<Box sx={codeBlockStyle}>
193-
{`claude mcp add pyplots --transport http https://api.pyplots.ai/mcp`}
193+
{`claude mcp add pyplots --transport http https://api.pyplots.ai/mcp/`}
194194
</Box>
195195

196196
<Typography sx={{ ...textStyle, mt: 3 }}>
@@ -318,7 +318,7 @@ export function McpPage() {
318318
<TableRow>
319319
<TableCell>MCP Inspector</TableCell>
320320
<TableCell>
321-
<code>npx @modelcontextprotocol/inspector https://api.pyplots.ai/mcp</code>
321+
<code>npx @modelcontextprotocol/inspector https://api.pyplots.ai/mcp/</code>
322322
</TableCell>
323323
</TableRow>
324324
</TableBody>

docs/reference/mcp.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
The pyplots MCP (Model Context Protocol) server enables AI assistants and tools to access pyplots programmatically. It provides a standardized interface for searching specifications, fetching implementation code, and integrating pyplots into AI-powered development workflows.
66

7-
**Endpoint**: `https://api.pyplots.ai/mcp`
7+
**Endpoint**: `https://api.pyplots.ai/mcp/`
88

99
---
1010

@@ -21,7 +21,7 @@ Add to your Claude Desktop config file:
2121
{
2222
"mcpServers": {
2323
"pyplots": {
24-
"url": "https://api.pyplots.ai/mcp"
24+
"url": "https://api.pyplots.ai/mcp/"
2525
}
2626
}
2727
}
@@ -35,7 +35,7 @@ Add to `.claude/config.json`:
3535
{
3636
"mcp": {
3737
"pyplots": {
38-
"url": "https://api.pyplots.ai/mcp"
38+
"url": "https://api.pyplots.ai/mcp/"
3939
}
4040
}
4141
}
@@ -421,7 +421,7 @@ api.pyplots.ai
421421
Test MCP tools directly in your browser:
422422

423423
```bash
424-
npx @anthropic-ai/mcp-inspector https://api.pyplots.ai/mcp
424+
npx @anthropic-ai/mcp-inspector https://api.pyplots.ai/mcp/
425425
```
426426

427427
### 2. Claude Desktop
@@ -436,7 +436,7 @@ npx @anthropic-ai/mcp-inspector https://api.pyplots.ai/mcp
436436
```python
437437
from mcp.client import Client
438438

439-
async with Client("https://api.pyplots.ai/mcp") as client:
439+
async with Client("https://api.pyplots.ai/mcp/") as client:
440440
tools = await client.list_tools()
441441
print(tools)
442442

@@ -450,7 +450,7 @@ async with Client("https://api.pyplots.ai/mcp") as client:
450450

451451
### "MCP server not responding"
452452

453-
1. Check endpoint is accessible: `curl https://api.pyplots.ai/mcp`
453+
1. Check endpoint is accessible: `curl https://api.pyplots.ai/mcp/`
454454
2. Verify config file location and syntax
455455
3. Restart Claude Desktop
456456

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# Bug: MCP server 307 redirect breaks Claude CLI integration
2+
3+
## Bug Description
4+
When users try to add the pyplots MCP server using the Claude CLI command:
5+
```bash
6+
claude mcp add pyplots --transport http https://api.pyplots.ai/mcp
7+
```
8+
9+
The server is added successfully, but when Claude CLI tries to connect, it fails with "Failed to connect" status. The server shows as configured but non-functional.
10+
11+
**Expected behavior**: Claude CLI should successfully connect to the MCP server and list available tools.
12+
13+
**Actual behavior**: Connection fails silently. Running `claude mcp get pyplots` shows `Status: ✗ Failed to connect`.
14+
15+
## Problem Statement
16+
The pyplots MCP endpoint at `https://api.pyplots.ai/mcp` returns a **307 Temporary Redirect** to `http://api.pyplots.ai/mcp/` when accessed without a trailing slash. This causes two issues:
17+
18+
1. **Claude CLI doesn't follow HTTP redirects** - MCP clients generally do not follow 307 redirects
19+
2. **HTTPS-to-HTTP downgrade** - The redirect location uses `http://` instead of `https://`, which is a security concern
20+
21+
## Solution Statement
22+
Update the documentation to instruct users to use the URL with a trailing slash: `https://api.pyplots.ai/mcp/`. This avoids the redirect issue entirely since the server responds correctly when the trailing slash is included.
23+
24+
This is the minimal fix that resolves the issue without requiring code changes to the MCP server or FastAPI configuration.
25+
26+
## Steps to Reproduce
27+
1. Add the MCP server using Claude CLI:
28+
```bash
29+
claude mcp add pyplots --transport http https://api.pyplots.ai/mcp
30+
```
31+
2. Check the server status:
32+
```bash
33+
claude mcp get pyplots
34+
```
35+
3. Observe: `Status: ✗ Failed to connect`
36+
37+
**Verification that trailing slash works:**
38+
```bash
39+
# Remove and re-add with trailing slash
40+
claude mcp remove pyplots
41+
claude mcp add pyplots --transport http https://api.pyplots.ai/mcp/
42+
claude mcp get pyplots
43+
# Observe: Status should show connected with tools listed
44+
```
45+
46+
## Root Cause Analysis
47+
The root cause is a combination of factors:
48+
49+
1. **Starlette/FastAPI redirect behavior**: When an app is mounted at a path, Starlette's router has `redirect_slashes=True` by default. This means requests to `/mcp` are redirected to `/mcp/` with a 307 status code.
50+
51+
2. **MCP client behavior**: Claude CLI (and other MCP clients) do not follow HTTP redirects per the MCP protocol specification. They expect the endpoint to respond directly without redirects.
52+
53+
3. **HTTP downgrade in redirect**: The 307 redirect location header contains `http://` instead of `https://`, likely due to how the request is proxied through Google Cloud Run.
54+
55+
**Technical verification:**
56+
```bash
57+
# Without trailing slash - returns 307 redirect
58+
curl -s -D - https://api.pyplots.ai/mcp 2>&1 | head -5
59+
# HTTP/2 307
60+
# location: http://api.pyplots.ai/mcp/
61+
62+
# With trailing slash - works correctly
63+
curl -s -H "Accept: application/json, text/event-stream" -H "Content-Type: application/json" \
64+
-X POST -d '{"jsonrpc":"2.0","method":"initialize",...}' https://api.pyplots.ai/mcp/
65+
# Returns 200 with MCP response
66+
```
67+
68+
## Relevant Files
69+
Use these files to fix the bug:
70+
71+
### Existing Files
72+
- `docs/reference/mcp.md` - MCP documentation that needs URL updates to include trailing slash
73+
- `app/src/pages/McpPage.tsx` - Frontend MCP page that shows configuration examples
74+
75+
### New Files
76+
None required.
77+
78+
## Step by Step Tasks
79+
80+
### 1. Update MCP Documentation
81+
Update `docs/reference/mcp.md` to use trailing slash in all URL examples:
82+
83+
- Change `https://api.pyplots.ai/mcp` to `https://api.pyplots.ai/mcp/` in:
84+
- Quick Start section (Claude Desktop config)
85+
- Claude Code (CLI) section
86+
- MCP Inspector command
87+
- Technical Details section
88+
- Troubleshooting section (curl command)
89+
- Any other occurrences
90+
91+
### 2. Update Frontend MCP Page
92+
Update `app/src/pages/McpPage.tsx` to use trailing slash in configuration examples:
93+
94+
- Update the JSON configuration examples shown to users
95+
- Ensure consistency with documentation
96+
97+
### 3. Verify the Fix
98+
Manually test that the trailing slash URL works:
99+
100+
```bash
101+
# Remove old config
102+
claude mcp remove pyplots
103+
104+
# Add with trailing slash
105+
claude mcp add pyplots --transport http https://api.pyplots.ai/mcp/
106+
107+
# Verify connection works
108+
claude mcp get pyplots
109+
# Should show: Status: ✓ Connected (or similar success indicator)
110+
```
111+
112+
### 4. Run Validation Commands
113+
Execute all validation commands to ensure no regressions.
114+
115+
## Validation Commands
116+
Execute every command to validate the bug is fixed with zero regressions.
117+
118+
- `uv run ruff check docs/reference/mcp.md && uv run ruff format docs/reference/mcp.md` - Lint documentation (will pass, md file)
119+
- `cd app && yarn build` - Verify frontend builds without errors
120+
- `claude mcp remove pyplots 2>/dev/null; claude mcp add pyplots --transport http https://api.pyplots.ai/mcp/; claude mcp get pyplots` - Test the fix works
121+
122+
## Final Check
123+
- Use `mcp__plugin_serena_serena__think_about_whether_you_are_done` to verify all tasks are complete.
124+
125+
## Notes
126+
- **Alternative solutions considered but not chosen:**
127+
1. **Disable redirect_slashes in FastAPI**: This would require code changes and may break other parts of the application
128+
2. **Register both `/mcp` and `/mcp/` routes**: More complex and requires FastMCP changes
129+
3. **Use a different mount pattern**: Would require significant refactoring
130+
131+
- **Future consideration**: If FastMCP or the MCP protocol adds better redirect handling, the server-side fix could be revisited. For now, the documentation fix is the safest and quickest solution.
132+
133+
- **Related issues**:
134+
- https://github.com/jlowin/fastmcp/issues/1544
135+
- https://github.com/jlowin/fastmcp/issues/1364
136+
- https://github.com/modelcontextprotocol/python-sdk/issues/1168

tests/integration/api/test_api_endpoints.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
from api.cache import clear_cache
1414
from api.dependencies import get_db
15-
from api.main import app
15+
from api.main import app, fastapi_app
1616

1717

1818
pytestmark = pytest.mark.integration
@@ -33,14 +33,14 @@ async def client(test_db_with_data):
3333
async def override_get_db():
3434
yield test_db_with_data
3535

36-
app.dependency_overrides[get_db] = override_get_db
36+
fastapi_app.dependency_overrides[get_db] = override_get_db
3737

3838
# Patch is_db_configured to return True (it checks env vars, not dependencies)
3939
with patch("api.dependencies.is_db_configured", return_value=True):
4040
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
4141
yield ac
4242

43-
app.dependency_overrides.clear()
43+
fastapi_app.dependency_overrides.clear()
4444

4545

4646
class TestSpecsEndpoints:

tests/unit/api/test_main.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import pytest
1313
from fastapi.testclient import TestClient
1414

15-
from api.main import app
15+
from api.main import app, fastapi_app
1616
from core.database import get_db
1717
from tests.conftest import TEST_IMAGE_URL, TEST_THUMB_URL
1818

@@ -54,7 +54,7 @@ async def mock_get_db():
5454
yield mock_session
5555

5656
# Override the dependency
57-
app.dependency_overrides[get_db] = mock_get_db
57+
fastapi_app.dependency_overrides[get_db] = mock_get_db
5858

5959
# Set up the mock to return specs
6060
mock_result = MagicMock()
@@ -67,7 +67,7 @@ async def mock_get_db():
6767
yield client
6868

6969
# Clean up
70-
app.dependency_overrides.clear()
70+
fastapi_app.dependency_overrides.clear()
7171

7272

7373
class TestRootEndpoint:
@@ -235,16 +235,16 @@ class TestAppConfiguration:
235235

236236
def test_app_title(self) -> None:
237237
"""App should have correct title."""
238-
assert app.title == "pyplots API"
238+
assert fastapi_app.title == "pyplots API"
239239

240240
def test_app_version(self) -> None:
241241
"""App should have correct version."""
242-
assert app.version == "1.0.0"
242+
assert fastapi_app.version == "1.0.0"
243243

244244
def test_app_description(self) -> None:
245245
"""App should have description."""
246-
assert "pyplots" in app.description.lower()
247-
assert "plotting" in app.description.lower()
246+
assert "pyplots" in fastapi_app.description.lower()
247+
assert "plotting" in fastapi_app.description.lower()
248248

249249

250250
class TestSpecsEndpoint:
@@ -324,12 +324,12 @@ def test_image_has_library_and_url(self, mock_db_client: TestClient) -> None:
324324
class TestGZipMiddleware:
325325
"""Tests for GZip compression middleware."""
326326

327-
def test_gzip_middleware_is_configured(self, client: TestClient) -> None:
327+
def test_gzip_middleware_is_configured(self) -> None:
328328
"""GZip middleware should be configured in the app."""
329329
from starlette.middleware.gzip import GZipMiddleware
330330

331331
# Check that GZipMiddleware is in the middleware stack
332-
middleware_classes = [m.cls for m in client.app.user_middleware]
332+
middleware_classes = [m.cls for m in fastapi_app.user_middleware]
333333
assert GZipMiddleware in middleware_classes
334334

335335
def test_gzip_not_used_for_small_responses(self, client: TestClient) -> None:
@@ -342,12 +342,12 @@ def test_gzip_not_used_for_small_responses(self, client: TestClient) -> None:
342342
# Either no encoding or not gzip for small responses
343343
assert content_encoding is None or content_encoding != "gzip"
344344

345-
def test_gzip_minimum_size_is_500(self, client: TestClient) -> None:
345+
def test_gzip_minimum_size_is_500(self) -> None:
346346
"""GZip middleware should have minimum_size of 500 bytes."""
347347
from starlette.middleware.gzip import GZipMiddleware
348348

349349
# Find the GZipMiddleware and check its configuration
350-
for middleware in client.app.user_middleware:
350+
for middleware in fastapi_app.user_middleware:
351351
if middleware.cls == GZipMiddleware:
352352
assert middleware.kwargs.get("minimum_size") == 500
353353
break

tests/unit/api/test_routers.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import pytest
1010
from fastapi.testclient import TestClient
1111

12-
from api.main import app
12+
from api.main import app, fastapi_app
1313
from api.routers.plots import (
1414
_calculate_contextual_counts,
1515
_calculate_global_counts,
@@ -43,13 +43,13 @@ def db_client():
4343
async def mock_get_db():
4444
yield mock_session
4545

46-
app.dependency_overrides[get_db] = mock_get_db
46+
fastapi_app.dependency_overrides[get_db] = mock_get_db
4747

4848
with patch(DB_CONFIG_PATCH, return_value=True):
4949
client = TestClient(app)
5050
yield client, mock_session
5151

52-
app.dependency_overrides.clear()
52+
fastapi_app.dependency_overrides.clear()
5353

5454

5555
@pytest.fixture

0 commit comments

Comments
 (0)