1- from collections .abc import AsyncIterator
2- from contextlib import asynccontextmanager
3- from dataclasses import dataclass , field
41from types import SimpleNamespace
52from urllib .parse import parse_qs , urlparse
63from uuid import uuid4
74
85import pytest
9- from fastapi import FastAPI
10- from fastapi .testclient import TestClient
116from pydantic import AnyUrl
127
138pytest .importorskip ("mcp" )
2015from belgie_oauth_server .provider import AccessToken as OAuthAccessToken , AuthorizationParams , SimpleOAuthProvider
2116from belgie_oauth_server .settings import OAuthServer
2217from mcp .server .auth .provider import AccessToken
23- from mcp .server .mcpserver import MCPServer
2418
2519
2620def _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-
366187async def _issue_dynamic_client_access_token (
367188 provider : SimpleOAuthProvider ,
368189 * ,
0 commit comments