Jeremiah Lowin commited on
Commit
15f96d2
·
1 Parent(s): 2e6dbad

Ensure quoted URIs work; fix proxy template issue

Browse files
src/fastmcp/server/proxy.py CHANGED
@@ -1,4 +1,5 @@
1
  from typing import Any, cast
 
2
 
3
  import mcp.types
4
  from mcp.types import BlobResourceContents, TextResourceContents
@@ -104,8 +105,14 @@ class ProxyTemplate(ResourceTemplate):
104
  )
105
 
106
  async def create_resource(self, uri: str, params: dict[str, Any]) -> ProxyResource:
 
 
 
 
 
 
107
  async with self._client:
108
- result = await self._client.read_resource(uri)
109
 
110
  if isinstance(result[0], TextResourceContents):
111
  value = result[0].text
@@ -116,7 +123,7 @@ class ProxyTemplate(ResourceTemplate):
116
 
117
  return ProxyResource(
118
  client=self._client,
119
- uri=uri,
120
  name=self.name,
121
  description=self.description,
122
  mime_type=result[0].mimeType,
 
1
  from typing import Any, cast
2
+ from urllib.parse import quote
3
 
4
  import mcp.types
5
  from mcp.types import BlobResourceContents, TextResourceContents
 
105
  )
106
 
107
  async def create_resource(self, uri: str, params: dict[str, Any]) -> ProxyResource:
108
+ # dont use the provided uri, because it may not be the same as the
109
+ # uri_template on the remote server.
110
+ # quote params to ensure they are valid for the uri_template
111
+ parameterized_uri = self.uri_template.format(
112
+ **{k: quote(v, safe="") for k, v in params.items()}
113
+ )
114
  async with self._client:
115
+ result = await self._client.read_resource(parameterized_uri)
116
 
117
  if isinstance(result[0], TextResourceContents):
118
  value = result[0].text
 
123
 
124
  return ProxyResource(
125
  client=self._client,
126
+ uri=parameterized_uri,
127
  name=self.name,
128
  description=self.description,
129
  mime_type=result[0].mimeType,
tests/resources/test_resource_template.py CHANGED
@@ -374,3 +374,11 @@ class TestMatchUriTemplate:
374
  uri_template = "prefix+test://{x}/test/{y}"
375
  result = match_uri_template(uri=uri, uri_template=uri_template)
376
  assert result == expected_params
 
 
 
 
 
 
 
 
 
374
  uri_template = "prefix+test://{x}/test/{y}"
375
  result = match_uri_template(uri=uri, uri_template=uri_template)
376
  assert result == expected_params
377
+
378
+ def test_quoted_params(self):
379
+ uri_template = "user://{name}/{email}"
380
+ quoted_name = quote("John Doe", safe="")
381
+ quoted_email = quote("john@example.com", safe="")
382
+ uri = f"user://{quoted_name}/{quoted_email}"
383
+ result = match_uri_template(uri=uri, uri_template=uri_template)
384
+ assert result == {"name": "John Doe", "email": "john@example.com"}
tests/server/test_mount.py CHANGED
@@ -1,6 +1,7 @@
1
  import contextlib
 
 
2
 
3
- import pytest
4
  from mcp.types import TextContent
5
 
6
  from fastmcp.server.server import FastMCP
@@ -190,7 +191,6 @@ async def test_mount_multiple_prompts():
190
  assert "sql_explain_sql" in main_app._prompt_manager._prompts
191
 
192
 
193
- @pytest.mark.anyio
194
  async def test_mount_lifespan():
195
  """Test that the lifespan of a mounted app is properly handled."""
196
  # Create apps
@@ -346,3 +346,83 @@ async def test_mount_with_proxy_tools():
346
  result = await main_app.call_tool("api_get_data", {"query": "test"})
347
  assert isinstance(result[0], TextContent)
348
  assert result[0].text == "Data for query: test"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import contextlib
2
+ import json
3
+ from urllib.parse import quote
4
 
 
5
  from mcp.types import TextContent
6
 
7
  from fastmcp.server.server import FastMCP
 
191
  assert "sql_explain_sql" in main_app._prompt_manager._prompts
192
 
193
 
 
194
  async def test_mount_lifespan():
195
  """Test that the lifespan of a mounted app is properly handled."""
196
  # Create apps
 
346
  result = await main_app.call_tool("api_get_data", {"query": "test"})
347
  assert isinstance(result[0], TextContent)
348
  assert result[0].text == "Data for query: test"
349
+
350
+
351
+ async def test_mount_with_proxy_prompts():
352
+ """
353
+ Test mounting with prompts that have custom keys.
354
+
355
+ This tests that the prompt's name doesn't change even though the registered
356
+ key does, which is important for correct rendering.
357
+ """
358
+ # Create apps
359
+ main_app = FastMCP("MainApp")
360
+ api_app = FastMCP("APIApp")
361
+
362
+ @api_app.prompt()
363
+ def greeting(name: str) -> str:
364
+ return f"Hello, {name} from API!"
365
+
366
+ main_app.mount("api", await FastMCP.as_proxy(api_app))
367
+
368
+ result = await main_app.get_prompt("api_greeting", {"name": "World"})
369
+ assert len(result) > 0
370
+ assert isinstance(result[0].content, TextContent)
371
+ assert result[0].content.text == "Hello, World from API!"
372
+
373
+
374
+ async def test_mount_with_proxy_resources():
375
+ """
376
+ Test mounting with resources that have custom keys.
377
+
378
+ This tests that the resource's name doesn't change even though the registered
379
+ key does, which is important for correct access.
380
+ """
381
+ # Create apps
382
+ main_app = FastMCP("MainApp")
383
+ api_app = FastMCP("APIApp")
384
+
385
+ # Create a resource in the API app
386
+ @api_app.resource(uri="config://settings")
387
+ def get_config():
388
+ return {
389
+ "api_key": "12345",
390
+ "base_url": "https://api.example.com",
391
+ }
392
+
393
+ main_app.mount("api", await FastMCP.as_proxy(api_app))
394
+
395
+ # Access the resource through the main app with the prefixed key
396
+ resource = await main_app.read_resource("api+config://settings")
397
+ assert resource is not None
398
+ resource = json.loads(resource)
399
+ assert resource["api_key"] == "12345"
400
+ assert resource["base_url"] == "https://api.example.com"
401
+
402
+
403
+ async def test_mount_with_proxy_resource_templates():
404
+ """
405
+ Test mounting with resource templates that have custom keys.
406
+
407
+ This tests that the template's name doesn't change even though the registered
408
+ key does, which is important for correct instantiation.
409
+ """
410
+ # Create apps
411
+ main_app = FastMCP("MainApp")
412
+ api_app = FastMCP("APIApp")
413
+
414
+ # Create a resource template in the API app
415
+ @api_app.resource(uri="user://{name}/{email}")
416
+ def create_user(name: str, email: str):
417
+ return {"name": name, "email": email}
418
+
419
+ main_app.mount("api", await FastMCP.as_proxy(api_app))
420
+
421
+ # Instantiate the template through the main app with the prefixed key
422
+ quoted_name = quote("John Doe", safe="")
423
+ quoted_email = quote("john@example.com", safe="")
424
+ user = await main_app.read_resource(f"api+user://{quoted_name}/{quoted_email}")
425
+ assert user is not None
426
+ user = json.loads(user)
427
+ assert user["name"] == "John Doe"
428
+ assert user["email"] == "john@example.com"