Skip to content

Commit d71091a

Browse files
authored
feat: remove mcplugin mount streamable http (#112)
* build: updated the project dependencies * fix: preserve configured mcp trailing slash * refactor: remove mcp streamable http mount helper
1 parent aaf3074 commit d71091a

File tree

9 files changed

+76
-358
lines changed

9 files changed

+76
-358
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,9 @@ Visit `http://localhost:8000/login/google` to sign in.
214214

215215
- `bind()` has been removed from plugins.
216216
- Register plugins with callable config objects: `auth.add_plugin(GoogleOAuth(...))`.
217+
- `McpPlugin.mount_streamable_http()` has been removed.
218+
- Mount and configure the MCP SDK streamable HTTP app in your FastAPI/Starlette app directly, using
219+
`plugin.auth`, `plugin.token_verifier`, and `plugin.server_path`.
217220

218221
## Router endpoints
219222

examples/mcp/README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,17 @@ The app runs at `http://localhost:8000`.
2828
- `GET|POST /auth/oauth/authorize`
2929
- `POST /auth/oauth/token`
3030
- `POST /auth/oauth/introspect`
31-
- `POST /mcp` or `POST /mcp/` (MCP streamable HTTP endpoint)
31+
- `POST /mcp/` (MCP streamable HTTP endpoint)
3232
- `GET /.well-known/oauth-protected-resource/mcp`
3333
- `GET /.well-known/oauth-protected-resource`
3434

3535
## Notes
3636

37-
- The MCP server is mounted at `/mcp` and accepts both `/mcp` and `/mcp/`.
38-
- Streamable HTTP transport security is derived from the configured MCP resource URL, so production hosts no longer
39-
need a custom `host="localhost"` override.
37+
- The example mounts the MCP SDK's `streamable_http_app(...)` directly with `app.mount(mcp_plugin.server_path, ...)`.
38+
- `McpPlugin` now only provides `auth`, `token_verifier`, and the derived `server_path`/`server_url`; mounting and
39+
streamable HTTP transport configuration are owned by the application.
40+
- If you need transport security settings such as allowed hosts/origins, pass them directly to
41+
`mcp_server.streamable_http_app(...)`.
4042
- OAuth discovery serving (`/.well-known/oauth-authorization-server*` and
4143
`/.well-known/oauth-protected-resource*`) is owned by `OAuthServerPlugin`.
4244
- Configure `OAuthServer.resources=[OAuthResource(prefix="/mcp", ...)]` so protected

examples/mcp/main.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,12 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
207207

208208
app.include_router(belgie.router)
209209

210-
_ = mcp_plugin.mount_streamable_http(app, mcp_server)
210+
app.mount(
211+
mcp_plugin.server_path,
212+
mcp_server.streamable_http_app(
213+
streamable_http_path="/",
214+
),
215+
)
211216

212217

213218
@mcp_server.tool()

packages/belgie-mcp/src/belgie_mcp/__tests__/test_plugin.py

Lines changed: 32 additions & 211 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,8 @@
1-
from collections.abc import AsyncIterator
2-
from contextlib import asynccontextmanager
3-
from dataclasses import dataclass, field
41
from types import SimpleNamespace
52
from urllib.parse import parse_qs, urlparse
63
from uuid import uuid4
74

85
import pytest
9-
from fastapi import FastAPI
10-
from fastapi.testclient import TestClient
116
from pydantic import AnyUrl
127

138
pytest.importorskip("mcp")
@@ -20,7 +15,6 @@
2015
from belgie_oauth_server.provider import AccessToken as OAuthAccessToken, AuthorizationParams, SimpleOAuthProvider
2116
from belgie_oauth_server.settings import OAuthServer
2217
from mcp.server.auth.provider import AccessToken
23-
from mcp.server.mcpserver import MCPServer
2418

2519

2620
def _belgie_settings() -> BelgieSettings:
@@ -90,7 +84,7 @@ def test_mcp_plugin_builds_server_url_from_base_url() -> None:
9084
assert str(plugin.auth.resource_server_url) == "https://example.com/mcp"
9185

9286

93-
def test_mcp_plugin_defaults_base_url_from_belgie_settings() -> None:
87+
def test_mcp_plugin_preserves_trailing_slash_in_server_path() -> None:
9488
settings = OAuthServer(
9589
base_url="https://auth.local",
9690
redirect_uris=["http://localhost/callback"],
@@ -103,266 +97,93 @@ def test_mcp_plugin_defaults_base_url_from_belgie_settings() -> None:
10397
_belgie_settings(),
10498
Mcp(
10599
oauth=settings,
106-
server_path="/mcp",
100+
base_url="https://example.com",
101+
server_path="/mcp/",
107102
),
108103
)
109104

110-
assert str(plugin.auth.resource_server_url) == "https://example.com/mcp"
105+
assert plugin.server_path == "/mcp/"
106+
assert str(plugin.auth.resource_server_url) == "https://example.com/mcp/"
111107

112108

113-
@pytest.mark.asyncio
114-
async def test_mcp_plugin_verifier_uses_linked_oauth_plugin_provider() -> None:
109+
def test_mcp_plugin_defaults_base_url_from_belgie_settings() -> None:
115110
settings = OAuthServer(
116111
base_url="https://auth.local",
117112
redirect_uris=["http://localhost/callback"],
118113
client_id="client",
119114
client_secret="secret",
120115
default_scope="user",
121116
)
122-
provider = SimpleOAuthProvider(settings, issuer_url=str(settings.issuer_url))
123-
oauth_plugin = OAuthServerPlugin(_belgie_settings(), settings)
124-
oauth_plugin._provider = provider
117+
125118
plugin = McpPlugin(
126119
_belgie_settings(),
127120
Mcp(
128121
oauth=settings,
129-
server_url="https://mcp.local/mcp",
122+
server_path="/mcp",
130123
),
131124
)
132-
_ = plugin.router(SimpleNamespace(plugins=[oauth_plugin, plugin]))
133-
token_value, stored_token = await _issue_dynamic_client_access_token(
134-
provider,
135-
user_id=str(uuid4()),
136-
resource="https://mcp.local/mcp",
137-
)
138125

139-
token = await plugin.token_verifier.verify_token(token_value)
140-
141-
assert token == AccessToken(
142-
token=token_value,
143-
client_id=stored_token.client_id,
144-
scopes=["user"],
145-
expires_at=stored_token.expires_at,
146-
resource="https://mcp.local/mcp",
147-
)
126+
assert str(plugin.auth.resource_server_url) == "https://example.com/mcp"
148127

149128

150-
def test_mount_streamable_http_accepts_alias_path_without_redirect() -> None:
129+
def test_mcp_plugin_preserves_trailing_slash_in_server_url() -> None:
151130
settings = OAuthServer(
152131
base_url="https://auth.local",
153132
redirect_uris=["http://localhost/callback"],
154133
client_id="client",
155134
client_secret="secret",
156135
default_scope="user",
157136
)
137+
158138
plugin = McpPlugin(
159139
_belgie_settings(),
160140
Mcp(
161141
oauth=settings,
162-
server_path="/mcp",
142+
server_url="https://mcp.local/mcp/",
163143
),
164144
)
165-
server = MCPServer(name="Belgie MCP")
166-
app = _build_test_app(plugin, server)
167-
168-
with TestClient(app, base_url="https://example.com") as client:
169-
alias_response = client.post(
170-
"/mcp",
171-
headers={"Content-Type": "application/json"},
172-
content="{}",
173-
follow_redirects=False,
174-
)
175-
mounted_response = client.post(
176-
"/mcp/",
177-
headers={"Content-Type": "application/json"},
178-
content="{}",
179-
follow_redirects=False,
180-
)
181-
182-
assert alias_response.status_code == 400
183-
assert mounted_response.status_code == 400
184-
assert alias_response.headers.get("location") is None
185-
assert mounted_response.headers.get("location") is None
186-
assert alias_response.json() == mounted_response.json()
187-
188-
189-
@pytest.mark.parametrize("host_header", ["localhost:8000", "127.0.0.1:8000", "[::1]:8000"])
190-
def test_mount_streamable_http_allows_loopback_hosts(host_header: str) -> None:
191-
plugin = _build_plugin(server_url="http://localhost:8000/mcp")
192-
server = MCPServer(
193-
name="Belgie MCP",
194-
auth=plugin.auth,
195-
token_verifier=_AllowingTokenVerifier(),
196-
)
197-
app = _build_test_app(plugin, server)
198-
199-
with TestClient(app, base_url="http://localhost:8000") as client:
200-
response = client.post(
201-
"/mcp",
202-
headers={
203-
"Authorization": "Bearer valid-token",
204-
"Host": host_header,
205-
},
206-
json=_build_initialize_request(),
207-
follow_redirects=False,
208-
)
209-
210-
assert response.status_code == 200
211-
assert response.headers["content-type"].startswith("text/event-stream")
212-
assert response.headers.get("mcp-session-id")
213-
214-
215-
def test_mount_streamable_http_allows_configured_external_host() -> None:
216-
plugin = _build_plugin(server_url="https://example.com/mcp")
217-
server = MCPServer(
218-
name="Belgie MCP",
219-
auth=plugin.auth,
220-
token_verifier=_AllowingTokenVerifier(),
221-
)
222-
app = _build_test_app(plugin, server)
223-
224-
with TestClient(app, base_url="https://example.com") as client:
225-
response = client.post(
226-
"/mcp",
227-
headers={
228-
"Authorization": "Bearer valid-token",
229-
"Host": "example.com",
230-
},
231-
json=_build_initialize_request(),
232-
follow_redirects=False,
233-
)
234-
235-
assert response.status_code == 200
236-
assert response.headers["content-type"].startswith("text/event-stream")
237-
assert response.headers.get("mcp-session-id")
238-
239-
240-
def test_mount_streamable_http_rejects_mismatched_host() -> None:
241-
plugin = _build_plugin(server_url="http://localhost:8000/mcp")
242-
server = MCPServer(
243-
name="Belgie MCP",
244-
auth=plugin.auth,
245-
token_verifier=_AllowingTokenVerifier(),
246-
)
247-
app = _build_test_app(plugin, server)
248145

249-
with TestClient(app, base_url="http://localhost:8000") as client:
250-
response = client.post(
251-
"/mcp",
252-
headers={
253-
"Authorization": "Bearer valid-token",
254-
"Host": "example.com",
255-
},
256-
json=_build_initialize_request(),
257-
follow_redirects=False,
258-
)
146+
assert plugin.server_path == "/mcp/"
147+
assert str(plugin.auth.resource_server_url) == "https://mcp.local/mcp/"
259148

260-
assert response.status_code == 421
261-
assert response.text == "Invalid Host header"
262149

263-
264-
def test_mount_streamable_http_preserves_auth_middleware() -> None:
150+
@pytest.mark.asyncio
151+
async def test_mcp_plugin_verifier_uses_linked_oauth_plugin_provider() -> None:
265152
settings = OAuthServer(
266153
base_url="https://auth.local",
267154
redirect_uris=["http://localhost/callback"],
268155
client_id="client",
269156
client_secret="secret",
270157
default_scope="user",
271158
)
159+
provider = SimpleOAuthProvider(settings, issuer_url=str(settings.issuer_url))
160+
oauth_plugin = OAuthServerPlugin(_belgie_settings(), settings)
161+
oauth_plugin._provider = provider
272162
plugin = McpPlugin(
273163
_belgie_settings(),
274164
Mcp(
275165
oauth=settings,
276-
server_path="/mcp",
166+
server_url="https://mcp.local/mcp",
277167
),
278168
)
279-
verifier = _StubTokenVerifier()
280-
server = MCPServer(
281-
name="Belgie MCP",
282-
auth=plugin.auth,
283-
token_verifier=verifier,
169+
_ = plugin.router(SimpleNamespace(plugins=[oauth_plugin, plugin]))
170+
token_value, stored_token = await _issue_dynamic_client_access_token(
171+
provider,
172+
user_id=str(uuid4()),
173+
resource="https://mcp.local/mcp",
284174
)
285-
app = _build_test_app(plugin, server)
286-
287-
with TestClient(app, base_url="https://example.com") as client:
288-
alias_response = client.post(
289-
"/mcp",
290-
headers={"Authorization": "Bearer alias-token"},
291-
follow_redirects=False,
292-
)
293-
mounted_response = client.post(
294-
"/mcp/",
295-
headers={"Authorization": "Bearer mounted-token"},
296-
follow_redirects=False,
297-
)
298-
299-
assert verifier.tokens == ["alias-token", "mounted-token"]
300-
assert alias_response.status_code == 401
301-
assert mounted_response.status_code == 401
302-
assert alias_response.headers.get("location") is None
303-
assert mounted_response.headers.get("location") is None
304-
assert alias_response.json() == mounted_response.json()
305-
306-
307-
def _build_test_app(plugin: McpPlugin, server: MCPServer) -> FastAPI:
308-
@asynccontextmanager
309-
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
310-
async with server.session_manager.run():
311-
yield
312-
313-
app = FastAPI(lifespan=lifespan)
314-
_ = plugin.mount_streamable_http(app, server)
315-
return app
316-
317-
318-
@dataclass(slots=True)
319-
class _StubTokenVerifier:
320-
tokens: list[str] = field(default_factory=list)
321-
322-
async def verify_token(self, token: str) -> None:
323-
self.tokens.append(token)
324-
325-
326-
@dataclass(slots=True)
327-
class _AllowingTokenVerifier:
328-
async def verify_token(self, token: str) -> AccessToken:
329-
return AccessToken(
330-
token=token,
331-
client_id="client",
332-
scopes=["user"],
333-
)
334175

176+
token = await plugin.token_verifier.verify_token(token_value)
335177

336-
def _build_plugin(*, server_url: str) -> McpPlugin:
337-
settings = OAuthServer(
338-
base_url="https://auth.local",
339-
redirect_uris=["http://localhost/callback"],
340-
client_id="client",
341-
client_secret="secret",
342-
default_scope="user",
343-
)
344-
return McpPlugin(
345-
_belgie_settings(),
346-
Mcp(
347-
oauth=settings,
348-
server_url=server_url,
349-
),
178+
assert token == AccessToken(
179+
token=token_value,
180+
client_id=stored_token.client_id,
181+
scopes=["user"],
182+
expires_at=stored_token.expires_at,
183+
resource="https://mcp.local/mcp",
350184
)
351185

352186

353-
def _build_initialize_request() -> dict[str, object]:
354-
return {
355-
"jsonrpc": "2.0",
356-
"id": 1,
357-
"method": "initialize",
358-
"params": {
359-
"protocolVersion": "2025-03-26",
360-
"capabilities": {},
361-
"clientInfo": {"name": "test-client", "version": "1"},
362-
},
363-
}
364-
365-
366187
async def _issue_dynamic_client_access_token(
367188
provider: SimpleOAuthProvider,
368189
*,

0 commit comments

Comments
 (0)