Spaces:
Running
Running
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(
|
| 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=
|
| 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"
|