Jeremiah Lowin commited on
Commit
ff0c73b
ยท
unverified ยท
2 Parent(s): 73ba121fee3201

Merge pull request #782 from jlowin/proxy

Browse files

Ensure proxies can overwrite remote tools without falling back to the remote

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
- tool_proxy = await ProxyTool.from_client(self.client, tool)
190
- tools[tool_proxy.name] = tool_proxy
 
 
191
 
192
  return tools
193
 
@@ -203,8 +205,12 @@ class FastMCPProxy(FastMCP):
203
  else:
204
  raise e
205
  for resource in client_resources:
206
- resource_proxy = await ProxyResource.from_client(self.client, resource)
207
- resources[str(resource_proxy.uri)] = resource_proxy
 
 
 
 
208
 
209
  return resources
210
 
@@ -220,8 +226,12 @@ class FastMCPProxy(FastMCP):
220
  else:
221
  raise e
222
  for template in client_templates:
223
- template_proxy = await ProxyTemplate.from_client(self.client, template)
224
- templates[template_proxy.uri_template] = template_proxy
 
 
 
 
225
 
226
  return templates
227
 
@@ -237,8 +247,11 @@ class FastMCPProxy(FastMCP):
237
  else:
238
  raise e
239
  for prompt in client_prompts:
240
- prompt_proxy = await ProxyPrompt.from_client(self.client, prompt)
241
- prompts[prompt_proxy.name] = prompt_proxy
 
 
 
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,