Spaces:
Running
Running
Merge pull request #782 from jlowin/proxy
Browse filesEnsure proxies can overwrite remote tools without falling back to the remote
- src/fastmcp/server/proxy.py +21 -8
- tests/server/test_proxy.py +248 -0
src/fastmcp/server/proxy.py
CHANGED
|
@@ -186,8 +186,10 @@ class FastMCPProxy(FastMCP):
|
|
| 186 |
else:
|
| 187 |
raise e
|
| 188 |
for tool in client_tools:
|
| 189 |
-
|
| 190 |
-
|
|
|
|
|
|
|
| 191 |
|
| 192 |
return tools
|
| 193 |
|
|
@@ -203,8 +205,12 @@ class FastMCPProxy(FastMCP):
|
|
| 203 |
else:
|
| 204 |
raise e
|
| 205 |
for resource in client_resources:
|
| 206 |
-
|
| 207 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
|
| 209 |
return resources
|
| 210 |
|
|
@@ -220,8 +226,12 @@ class FastMCPProxy(FastMCP):
|
|
| 220 |
else:
|
| 221 |
raise e
|
| 222 |
for template in client_templates:
|
| 223 |
-
|
| 224 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
|
| 226 |
return templates
|
| 227 |
|
|
@@ -237,8 +247,11 @@ class FastMCPProxy(FastMCP):
|
|
| 237 |
else:
|
| 238 |
raise e
|
| 239 |
for prompt in client_prompts:
|
| 240 |
-
|
| 241 |
-
|
|
|
|
|
|
|
|
|
|
| 242 |
return prompts
|
| 243 |
|
| 244 |
async def _call_tool(
|
|
|
|
| 186 |
else:
|
| 187 |
raise e
|
| 188 |
for tool in client_tools:
|
| 189 |
+
# don't overwrite tools defined in the server
|
| 190 |
+
if tool.name not in tools:
|
| 191 |
+
tool_proxy = await ProxyTool.from_client(self.client, tool)
|
| 192 |
+
tools[tool_proxy.name] = tool_proxy
|
| 193 |
|
| 194 |
return tools
|
| 195 |
|
|
|
|
| 205 |
else:
|
| 206 |
raise e
|
| 207 |
for resource in client_resources:
|
| 208 |
+
# don't overwrite resources defined in the server
|
| 209 |
+
if str(resource.uri) not in resources:
|
| 210 |
+
resource_proxy = await ProxyResource.from_client(
|
| 211 |
+
self.client, resource
|
| 212 |
+
)
|
| 213 |
+
resources[str(resource_proxy.uri)] = resource_proxy
|
| 214 |
|
| 215 |
return resources
|
| 216 |
|
|
|
|
| 226 |
else:
|
| 227 |
raise e
|
| 228 |
for template in client_templates:
|
| 229 |
+
# don't overwrite templates defined in the server
|
| 230 |
+
if template.uriTemplate not in templates:
|
| 231 |
+
template_proxy = await ProxyTemplate.from_client(
|
| 232 |
+
self.client, template
|
| 233 |
+
)
|
| 234 |
+
templates[template_proxy.uri_template] = template_proxy
|
| 235 |
|
| 236 |
return templates
|
| 237 |
|
|
|
|
| 247 |
else:
|
| 248 |
raise e
|
| 249 |
for prompt in client_prompts:
|
| 250 |
+
# don't overwrite prompts defined in the server
|
| 251 |
+
if prompt.name not in prompts:
|
| 252 |
+
prompt_proxy = await ProxyPrompt.from_client(self.client, prompt)
|
| 253 |
+
prompts[prompt_proxy.name] = prompt_proxy
|
| 254 |
+
|
| 255 |
return prompts
|
| 256 |
|
| 257 |
async def _call_tool(
|
tests/server/test_proxy.py
CHANGED
|
@@ -144,6 +144,61 @@ class TestTools:
|
|
| 144 |
async with Client(proxy_server) as client:
|
| 145 |
await client.call_tool("error_tool", {})
|
| 146 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
|
| 148 |
class TestResources:
|
| 149 |
async def test_get_resources(self, proxy_server):
|
|
@@ -184,6 +239,64 @@ class TestResources:
|
|
| 184 |
async with Client(proxy_server) as client:
|
| 185 |
await client.read_resource("resource://nonexistent")
|
| 186 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
|
| 188 |
class TestResourceTemplates:
|
| 189 |
async def test_get_resource_templates(self, proxy_server):
|
|
@@ -212,6 +325,77 @@ class TestResourceTemplates:
|
|
| 212 |
proxy_result = await client.read_resource("data://user/1")
|
| 213 |
assert proxy_result == result
|
| 214 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
|
| 216 |
class TestPrompts:
|
| 217 |
async def test_get_prompts_server_method(self, proxy_server: FastMCPProxy):
|
|
@@ -240,6 +424,70 @@ class TestPrompts:
|
|
| 240 |
assert result.messages[0].role == "user"
|
| 241 |
assert result.messages[0].content.text == "Welcome to FastMCP, Alice!" # type: ignore[attr-defined]
|
| 242 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
|
| 244 |
async def test_proxy_handles_multiple_concurrent_tasks_correctly(
|
| 245 |
proxy_server: FastMCPProxy,
|
|
|
|
| 144 |
async with Client(proxy_server) as client:
|
| 145 |
await client.call_tool("error_tool", {})
|
| 146 |
|
| 147 |
+
async def test_proxy_can_overwrite_proxied_tool(self, proxy_server):
|
| 148 |
+
"""
|
| 149 |
+
Test that a tool defined on the proxy can overwrite the proxied tool with the same name.
|
| 150 |
+
"""
|
| 151 |
+
|
| 152 |
+
@proxy_server.tool
|
| 153 |
+
def greet(name: str, extra: str = "extra") -> str:
|
| 154 |
+
return f"Overwritten, {name}! {extra}"
|
| 155 |
+
|
| 156 |
+
async with Client(proxy_server) as client:
|
| 157 |
+
result = await client.call_tool("greet", {"name": "Marvin", "extra": "abc"})
|
| 158 |
+
assert result[0].text == "Overwritten, Marvin! abc" # type: ignore[attr-defined]
|
| 159 |
+
|
| 160 |
+
async def test_proxy_errors_if_overwritten_tool_is_disabled(self, proxy_server):
|
| 161 |
+
"""
|
| 162 |
+
Test that a tool defined on the proxy is not listed if it is disabled,
|
| 163 |
+
and it doesn't fall back to the proxied tool with the same name
|
| 164 |
+
"""
|
| 165 |
+
|
| 166 |
+
@proxy_server.tool(enabled=False)
|
| 167 |
+
def greet(name: str, extra: str = "extra") -> str:
|
| 168 |
+
return f"Overwritten, {name}! {extra}"
|
| 169 |
+
|
| 170 |
+
async with Client(proxy_server) as client:
|
| 171 |
+
with pytest.raises(ToolError, match="Unknown tool"):
|
| 172 |
+
await client.call_tool("greet", {"name": "Marvin", "extra": "abc"})
|
| 173 |
+
|
| 174 |
+
async def test_proxy_can_list_overwritten_tool(self, proxy_server):
|
| 175 |
+
"""
|
| 176 |
+
Test that a tool defined on the proxy is listed instead of the proxied tool
|
| 177 |
+
"""
|
| 178 |
+
|
| 179 |
+
@proxy_server.tool
|
| 180 |
+
def greet(name: str, extra: str = "extra") -> str:
|
| 181 |
+
return f"Overwritten, {name}! {extra}"
|
| 182 |
+
|
| 183 |
+
async with Client(proxy_server) as client:
|
| 184 |
+
tools = await client.list_tools()
|
| 185 |
+
greet_tool = next(t for t in tools if t.name == "greet")
|
| 186 |
+
assert "extra" in greet_tool.inputSchema["properties"]
|
| 187 |
+
|
| 188 |
+
async def test_proxy_can_list_overwritten_tool_if_disabled(self, proxy_server):
|
| 189 |
+
"""
|
| 190 |
+
Test that a tool defined on the proxy is not listed if it is disabled,
|
| 191 |
+
and it doesn't fall back to the proxied tool with the same name
|
| 192 |
+
"""
|
| 193 |
+
|
| 194 |
+
@proxy_server.tool(enabled=False)
|
| 195 |
+
def greet(name: str, extra: str = "extra") -> str:
|
| 196 |
+
return f"Overwritten, {name}! {extra}"
|
| 197 |
+
|
| 198 |
+
async with Client(proxy_server) as client:
|
| 199 |
+
tools = await client.list_tools()
|
| 200 |
+
assert not any(t.name == "greet" for t in tools)
|
| 201 |
+
|
| 202 |
|
| 203 |
class TestResources:
|
| 204 |
async def test_get_resources(self, proxy_server):
|
|
|
|
| 239 |
async with Client(proxy_server) as client:
|
| 240 |
await client.read_resource("resource://nonexistent")
|
| 241 |
|
| 242 |
+
async def test_proxy_can_overwrite_proxied_resource(self, proxy_server):
|
| 243 |
+
"""
|
| 244 |
+
Test that a resource defined on the proxy can overwrite the proxied resource with the same URI.
|
| 245 |
+
"""
|
| 246 |
+
|
| 247 |
+
@proxy_server.resource(uri="resource://wave")
|
| 248 |
+
def overwritten_wave() -> str:
|
| 249 |
+
return "Overwritten wave! ๐"
|
| 250 |
+
|
| 251 |
+
async with Client(proxy_server) as client:
|
| 252 |
+
result = await client.read_resource("resource://wave")
|
| 253 |
+
assert result[0].text == "Overwritten wave! ๐" # type: ignore[attr-defined]
|
| 254 |
+
|
| 255 |
+
async def test_proxy_errors_if_overwritten_resource_is_disabled(self, proxy_server):
|
| 256 |
+
"""
|
| 257 |
+
Test that a resource defined on the proxy is not accessible if it is disabled,
|
| 258 |
+
and it doesn't fall back to the proxied resource with the same URI
|
| 259 |
+
"""
|
| 260 |
+
|
| 261 |
+
@proxy_server.resource(uri="resource://wave", enabled=False)
|
| 262 |
+
def overwritten_wave() -> str:
|
| 263 |
+
return "Overwritten wave! ๐"
|
| 264 |
+
|
| 265 |
+
async with Client(proxy_server) as client:
|
| 266 |
+
with pytest.raises(McpError, match="Unknown resource"):
|
| 267 |
+
await client.read_resource("resource://wave")
|
| 268 |
+
|
| 269 |
+
async def test_proxy_can_list_overwritten_resource(self, proxy_server):
|
| 270 |
+
"""
|
| 271 |
+
Test that a resource defined on the proxy is listed instead of the proxied resource
|
| 272 |
+
"""
|
| 273 |
+
|
| 274 |
+
@proxy_server.resource(uri="resource://wave", name="overwritten_wave")
|
| 275 |
+
def overwritten_wave() -> str:
|
| 276 |
+
return "Overwritten wave! ๐"
|
| 277 |
+
|
| 278 |
+
async with Client(proxy_server) as client:
|
| 279 |
+
resources = await client.list_resources()
|
| 280 |
+
wave_resource = next(
|
| 281 |
+
r for r in resources if str(r.uri) == "resource://wave"
|
| 282 |
+
)
|
| 283 |
+
assert wave_resource.name == "overwritten_wave"
|
| 284 |
+
|
| 285 |
+
async def test_proxy_can_list_overwritten_resource_if_disabled(self, proxy_server):
|
| 286 |
+
"""
|
| 287 |
+
Test that a resource defined on the proxy is not listed if it is disabled,
|
| 288 |
+
and it doesn't fall back to the proxied resource with the same URI
|
| 289 |
+
"""
|
| 290 |
+
|
| 291 |
+
@proxy_server.resource(uri="resource://wave", enabled=False)
|
| 292 |
+
def overwritten_wave() -> str:
|
| 293 |
+
return "Overwritten wave! ๐"
|
| 294 |
+
|
| 295 |
+
async with Client(proxy_server) as client:
|
| 296 |
+
resources = await client.list_resources()
|
| 297 |
+
wave_resources = [r for r in resources if str(r.uri) == "resource://wave"]
|
| 298 |
+
assert len(wave_resources) == 0
|
| 299 |
+
|
| 300 |
|
| 301 |
class TestResourceTemplates:
|
| 302 |
async def test_get_resource_templates(self, proxy_server):
|
|
|
|
| 325 |
proxy_result = await client.read_resource("data://user/1")
|
| 326 |
assert proxy_result == result
|
| 327 |
|
| 328 |
+
async def test_proxy_can_overwrite_proxied_resource_template(self, proxy_server):
|
| 329 |
+
"""
|
| 330 |
+
Test that a resource template defined on the proxy can overwrite the proxied template with the same URI template.
|
| 331 |
+
"""
|
| 332 |
+
|
| 333 |
+
@proxy_server.resource(uri="data://user/{user_id}", name="overwritten_get_user")
|
| 334 |
+
def overwritten_get_user(user_id: str) -> dict[str, Any]:
|
| 335 |
+
return {
|
| 336 |
+
"id": user_id,
|
| 337 |
+
"name": "Overwritten User",
|
| 338 |
+
"active": True,
|
| 339 |
+
"extra": "data",
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
async with Client(proxy_server) as client:
|
| 343 |
+
result = await client.read_resource("data://user/1")
|
| 344 |
+
user_data = json.loads(result[0].text) # type: ignore[attr-defined]
|
| 345 |
+
assert user_data["name"] == "Overwritten User"
|
| 346 |
+
assert user_data["extra"] == "data"
|
| 347 |
+
|
| 348 |
+
async def test_proxy_errors_if_overwritten_resource_template_is_disabled(
|
| 349 |
+
self, proxy_server
|
| 350 |
+
):
|
| 351 |
+
"""
|
| 352 |
+
Test that a resource template defined on the proxy is not accessible if it is disabled,
|
| 353 |
+
and it doesn't fall back to the proxied template with the same URI template
|
| 354 |
+
"""
|
| 355 |
+
|
| 356 |
+
@proxy_server.resource(uri="data://user/{user_id}", enabled=False)
|
| 357 |
+
def overwritten_get_user(user_id: str) -> dict[str, Any]:
|
| 358 |
+
return {"id": user_id, "name": "Overwritten User", "active": True}
|
| 359 |
+
|
| 360 |
+
async with Client(proxy_server) as client:
|
| 361 |
+
with pytest.raises(McpError, match="Unknown resource"):
|
| 362 |
+
await client.read_resource("data://user/1")
|
| 363 |
+
|
| 364 |
+
async def test_proxy_can_list_overwritten_resource_template(self, proxy_server):
|
| 365 |
+
"""
|
| 366 |
+
Test that a resource template defined on the proxy is listed instead of the proxied template
|
| 367 |
+
"""
|
| 368 |
+
|
| 369 |
+
@proxy_server.resource(uri="data://user/{user_id}", name="overwritten_get_user")
|
| 370 |
+
def overwritten_get_user(user_id: str) -> dict[str, Any]:
|
| 371 |
+
return {"id": user_id, "name": "Overwritten User", "active": True}
|
| 372 |
+
|
| 373 |
+
async with Client(proxy_server) as client:
|
| 374 |
+
templates = await client.list_resource_templates()
|
| 375 |
+
user_template = next(
|
| 376 |
+
t for t in templates if t.uriTemplate == "data://user/{user_id}"
|
| 377 |
+
)
|
| 378 |
+
assert user_template.name == "overwritten_get_user"
|
| 379 |
+
|
| 380 |
+
async def test_proxy_can_list_overwritten_resource_template_if_disabled(
|
| 381 |
+
self, proxy_server
|
| 382 |
+
):
|
| 383 |
+
"""
|
| 384 |
+
Test that a resource template defined on the proxy is not listed if it is disabled,
|
| 385 |
+
and it doesn't fall back to the proxied template with the same URI template
|
| 386 |
+
"""
|
| 387 |
+
|
| 388 |
+
@proxy_server.resource(uri="data://user/{user_id}", enabled=False)
|
| 389 |
+
def overwritten_get_user(user_id: str) -> dict[str, Any]:
|
| 390 |
+
return {"id": user_id, "name": "Overwritten User", "active": True}
|
| 391 |
+
|
| 392 |
+
async with Client(proxy_server) as client:
|
| 393 |
+
templates = await client.list_resource_templates()
|
| 394 |
+
user_templates = [
|
| 395 |
+
t for t in templates if t.uriTemplate == "data://user/{user_id}"
|
| 396 |
+
]
|
| 397 |
+
assert len(user_templates) == 0
|
| 398 |
+
|
| 399 |
|
| 400 |
class TestPrompts:
|
| 401 |
async def test_get_prompts_server_method(self, proxy_server: FastMCPProxy):
|
|
|
|
| 424 |
assert result.messages[0].role == "user"
|
| 425 |
assert result.messages[0].content.text == "Welcome to FastMCP, Alice!" # type: ignore[attr-defined]
|
| 426 |
|
| 427 |
+
async def test_proxy_can_overwrite_proxied_prompt(self, proxy_server):
|
| 428 |
+
"""
|
| 429 |
+
Test that a prompt defined on the proxy can overwrite the proxied prompt with the same name.
|
| 430 |
+
"""
|
| 431 |
+
|
| 432 |
+
@proxy_server.prompt
|
| 433 |
+
def welcome(name: str, extra: str = "friend") -> str:
|
| 434 |
+
return f"Overwritten welcome, {name}! You are my {extra}."
|
| 435 |
+
|
| 436 |
+
async with Client(proxy_server) as client:
|
| 437 |
+
result = await client.get_prompt(
|
| 438 |
+
"welcome", {"name": "Alice", "extra": "colleague"}
|
| 439 |
+
)
|
| 440 |
+
assert result.messages[0].role == "user"
|
| 441 |
+
assert (
|
| 442 |
+
result.messages[0].content.text # type: ignore[attr-defined]
|
| 443 |
+
== "Overwritten welcome, Alice! You are my colleague."
|
| 444 |
+
)
|
| 445 |
+
|
| 446 |
+
async def test_proxy_errors_if_overwritten_prompt_is_disabled(self, proxy_server):
|
| 447 |
+
"""
|
| 448 |
+
Test that a prompt defined on the proxy is not accessible if it is disabled,
|
| 449 |
+
and it doesn't fall back to the proxied prompt with the same name
|
| 450 |
+
"""
|
| 451 |
+
|
| 452 |
+
@proxy_server.prompt(enabled=False)
|
| 453 |
+
def welcome(name: str, extra: str = "friend") -> str:
|
| 454 |
+
return f"Overwritten welcome, {name}! You are my {extra}."
|
| 455 |
+
|
| 456 |
+
async with Client(proxy_server) as client:
|
| 457 |
+
with pytest.raises(McpError, match="Unknown prompt"):
|
| 458 |
+
await client.get_prompt("welcome", {"name": "Alice"})
|
| 459 |
+
|
| 460 |
+
async def test_proxy_can_list_overwritten_prompt(self, proxy_server):
|
| 461 |
+
"""
|
| 462 |
+
Test that a prompt defined on the proxy is listed instead of the proxied prompt
|
| 463 |
+
"""
|
| 464 |
+
|
| 465 |
+
@proxy_server.prompt
|
| 466 |
+
def welcome(name: str, extra: str = "friend") -> str:
|
| 467 |
+
return f"Overwritten welcome, {name}! You are my {extra}."
|
| 468 |
+
|
| 469 |
+
async with Client(proxy_server) as client:
|
| 470 |
+
prompts = await client.list_prompts()
|
| 471 |
+
welcome_prompt = next(p for p in prompts if p.name == "welcome")
|
| 472 |
+
# Check that the overwritten prompt has the additional 'extra' parameter
|
| 473 |
+
param_names = [arg.name for arg in welcome_prompt.arguments or []]
|
| 474 |
+
assert "extra" in param_names
|
| 475 |
+
|
| 476 |
+
async def test_proxy_can_list_overwritten_prompt_if_disabled(self, proxy_server):
|
| 477 |
+
"""
|
| 478 |
+
Test that a prompt defined on the proxy is not listed if it is disabled,
|
| 479 |
+
and it doesn't fall back to the proxied prompt with the same name
|
| 480 |
+
"""
|
| 481 |
+
|
| 482 |
+
@proxy_server.prompt(enabled=False)
|
| 483 |
+
def welcome(name: str, extra: str = "friend") -> str:
|
| 484 |
+
return f"Overwritten welcome, {name}! You are my {extra}."
|
| 485 |
+
|
| 486 |
+
async with Client(proxy_server) as client:
|
| 487 |
+
prompts = await client.list_prompts()
|
| 488 |
+
welcome_prompts = [p for p in prompts if p.name == "welcome"]
|
| 489 |
+
assert len(welcome_prompts) == 0
|
| 490 |
+
|
| 491 |
|
| 492 |
async def test_proxy_handles_multiple_concurrent_tasks_correctly(
|
| 493 |
proxy_server: FastMCPProxy,
|