Jeremiah Lowin Claude commited on
Commit
a9a1a98
·
1 Parent(s): e0dcb2d

Add mirrored component support for proxy servers

Browse files

Prevents enable/disable operations on mirrored components (tools, resources, prompts retrieved from proxy servers). Users must create local copies with .copy() and add them to their server to modify them. Local components take precedence over mirrored ones.

Closes #1102

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

docs/docs.json CHANGED
@@ -81,13 +81,13 @@
81
  "icon": "stars",
82
  "pages": [
83
  "servers/context",
 
 
84
  "servers/elicitation",
85
  "servers/logging",
86
  "servers/progress",
87
  "servers/sampling",
88
- "servers/middleware",
89
- "servers/composition",
90
- "servers/proxy"
91
  ]
92
  },
93
  {
 
81
  "icon": "stars",
82
  "pages": [
83
  "servers/context",
84
+ "servers/proxy",
85
+ "servers/composition",
86
  "servers/elicitation",
87
  "servers/logging",
88
  "servers/progress",
89
  "servers/sampling",
90
+ "servers/middleware"
 
 
91
  ]
92
  },
93
  {
docs/servers/composition.mdx CHANGED
@@ -1,6 +1,6 @@
1
  ---
2
  title: Server Composition
3
- sidebarTitle: Composition
4
  description: Combine multiple FastMCP servers into a single, larger application using mounting and importing.
5
  icon: puzzle-piece
6
  ---
 
1
  ---
2
  title: Server Composition
3
+ sidebarTitle: Server Composition
4
  description: Combine multiple FastMCP servers into a single, larger application using mounting and importing.
5
  icon: puzzle-piece
6
  ---
docs/servers/proxy.mdx CHANGED
@@ -65,6 +65,10 @@ This single setup gives you:
65
  - Session isolation to prevent context mixing
66
  - Full compatibility with all MCP clients
67
 
 
 
 
 
68
  ## Session Isolation & Concurrency
69
 
70
  <VersionBadge version="2.10.3" />
@@ -240,23 +244,35 @@ composite_proxy = FastMCP.as_proxy(config, name="Composite Proxy")
240
  # - weather://weather/icons/sunny, calendar://calendar/events/today
241
  ```
242
 
243
- ## Alternative Approaches
244
 
245
- The examples above show the recommended approach using `ProxyClient` or transport strings. For advanced use cases, you can also work directly with the underlying classes.
246
 
247
- ### Using Regular Client
248
 
249
- You can pass a regular `Client` instance to `as_proxy()`. The proxy will automatically create an appropriate session strategy:
250
 
251
  ```python
252
- from fastmcp import FastMCP, Client
 
253
 
254
- # Using regular Client (session strategy auto-detected)
255
- client = Client("backend_server.py")
256
- proxy = FastMCP.as_proxy(client)
 
 
 
 
 
 
 
 
 
 
 
 
257
  ```
258
 
259
- This approach provides session isolation but doesn't include advanced MCP feature forwarding (sampling, elicitation, etc.) unless you configure handlers manually.
260
 
261
  ## `FastMCPProxy` Class
262
 
@@ -308,4 +324,5 @@ def custom_client_factory():
308
  return client
309
 
310
  proxy = FastMCPProxy(client_factory=custom_client_factory)
311
- ```
 
 
65
  - Session isolation to prevent context mixing
66
  - Full compatibility with all MCP clients
67
 
68
+ You can also pass a FastMCP [client transport](/clients/transports) (or parameter that can be inferred to a transport) to `as_proxy()`. This will automatically create a `ProxyClient` instance for you.
69
+
70
+ Finally, you can pass a regular FastMCP `Client` instance to `as_proxy()`. This will work for many use cases, but may break if advanced MCP features like sampling or elicitation are invoked by the server.
71
+
72
  ## Session Isolation & Concurrency
73
 
74
  <VersionBadge version="2.10.3" />
 
244
  # - weather://weather/icons/sunny, calendar://calendar/events/today
245
  ```
246
 
247
+ ## Mirrored Components
248
 
249
+ When you access tools, resources, or prompts from a proxy server, they are "mirrored" from the remote server. Mirrored components cannot be modified directly directly since they reflect the state of the remote server. For example, you can not simply "disable" a mirrored component.
250
 
251
+ However, you can create a copy of a mirrored component and store it as a new locally-defined component. Local components always take precedence over mirored ones because the proxy server will check its own registry before it attempts to engage the remote server.
252
 
253
+ Therefore, to enable or disable a proxy tool, resource, or prompt, you should first create a local copy and add it to your own server. Here's an example of how to do that for a tool:
254
 
255
  ```python
256
+ # Create your own server
257
+ my_server = FastMCP("MyServer")
258
 
259
+ # Get a proxy server
260
+ proxy = FastMCP.as_proxy("backend_server.py")
261
+
262
+ # Add mirrored components to your server
263
+ async with proxy:
264
+ mirrored_tool = await proxy.get_tool("useful_tool")
265
+
266
+ # Create a local copy that you can modify
267
+ local_tool = mirrored_tool.copy()
268
+
269
+ # Add the local copy to your server
270
+ my_server.add_tool(local_tool)
271
+
272
+ # Now you can disable YOUR copy
273
+ local_tool.disable()
274
  ```
275
 
 
276
 
277
  ## `FastMCPProxy` Class
278
 
 
324
  return client
325
 
326
  proxy = FastMCPProxy(client_factory=custom_client_factory)
327
+ ```
328
+
src/fastmcp/cli/install/claude_code.py CHANGED
@@ -1,5 +1,6 @@
1
  """Claude Code integration for FastMCP install using Cyclopts."""
2
 
 
3
  import subprocess
4
  import sys
5
  from pathlib import Path
@@ -16,22 +17,50 @@ logger = get_logger(__name__)
16
 
17
 
18
  def find_claude_command() -> str | None:
19
- """Find the Claude Code CLI command."""
20
- # Check the default installation location
21
- default_path = Path.home() / ".claude" / "local" / "claude"
22
- if default_path.exists():
 
 
 
 
23
  try:
24
  result = subprocess.run(
25
- [str(default_path), "--version"],
26
  check=True,
27
  capture_output=True,
28
  text=True,
29
  )
30
  if "Claude Code" in result.stdout:
31
- return str(default_path)
32
  except (subprocess.CalledProcessError, FileNotFoundError):
33
  pass
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  return None
36
 
37
 
 
1
  """Claude Code integration for FastMCP install using Cyclopts."""
2
 
3
+ import shutil
4
  import subprocess
5
  import sys
6
  from pathlib import Path
 
17
 
18
 
19
  def find_claude_command() -> str | None:
20
+ """Find the Claude Code CLI command.
21
+
22
+ Checks common installation locations since 'claude' is often a shell alias
23
+ that doesn't work with subprocess calls.
24
+ """
25
+ # First try shutil.which() in case it's a real executable in PATH
26
+ claude_in_path = shutil.which("claude")
27
+ if claude_in_path:
28
  try:
29
  result = subprocess.run(
30
+ [claude_in_path, "--version"],
31
  check=True,
32
  capture_output=True,
33
  text=True,
34
  )
35
  if "Claude Code" in result.stdout:
36
+ return claude_in_path
37
  except (subprocess.CalledProcessError, FileNotFoundError):
38
  pass
39
 
40
+ # Check common installation locations (aliases don't work with subprocess)
41
+ potential_paths = [
42
+ # Default Claude Code installation location (after migration)
43
+ Path.home() / ".claude" / "local" / "claude",
44
+ # npm global installation on macOS/Linux (default)
45
+ Path("/usr/local/bin/claude"),
46
+ # npm global installation with custom prefix
47
+ Path.home() / ".npm-global" / "bin" / "claude",
48
+ ]
49
+
50
+ for path in potential_paths:
51
+ if path.exists():
52
+ try:
53
+ result = subprocess.run(
54
+ [str(path), "--version"],
55
+ check=True,
56
+ capture_output=True,
57
+ text=True,
58
+ )
59
+ if "Claude Code" in result.stdout:
60
+ return str(path)
61
+ except (subprocess.CalledProcessError, FileNotFoundError):
62
+ continue
63
+
64
  return None
65
 
66
 
src/fastmcp/server/proxy.py CHANGED
@@ -36,6 +36,7 @@ from fastmcp.server.dependencies import get_context
36
  from fastmcp.server.server import FastMCP
37
  from fastmcp.tools.tool import Tool, ToolResult
38
  from fastmcp.tools.tool_manager import ToolManager
 
39
  from fastmcp.utilities.logging import get_logger
40
 
41
  if TYPE_CHECKING:
@@ -226,7 +227,7 @@ class ProxyPromptManager(PromptManager):
226
  return result
227
 
228
 
229
- class ProxyTool(Tool):
230
  """
231
  A Tool that represents and executes a tool on a remote server.
232
  """
@@ -245,6 +246,7 @@ class ProxyTool(Tool):
245
  parameters=mcp_tool.inputSchema,
246
  annotations=mcp_tool.annotations,
247
  output_schema=mcp_tool.outputSchema,
 
248
  )
249
 
250
  async def run(
@@ -266,7 +268,7 @@ class ProxyTool(Tool):
266
  )
267
 
268
 
269
- class ProxyResource(Resource):
270
  """
271
  A Resource that represents and reads a resource from a remote server.
272
  """
@@ -298,6 +300,7 @@ class ProxyResource(Resource):
298
  name=mcp_resource.name,
299
  description=mcp_resource.description,
300
  mime_type=mcp_resource.mimeType or "text/plain",
 
301
  )
302
 
303
  async def read(self) -> str | bytes:
@@ -315,7 +318,7 @@ class ProxyResource(Resource):
315
  raise ResourceError(f"Unsupported content type: {type(result[0])}")
316
 
317
 
318
- class ProxyTemplate(ResourceTemplate):
319
  """
320
  A ResourceTemplate that represents and creates resources from a remote server template.
321
  """
@@ -336,6 +339,7 @@ class ProxyTemplate(ResourceTemplate):
336
  description=mcp_template.description,
337
  mime_type=mcp_template.mimeType or "text/plain",
338
  parameters={}, # Remote templates don't have local parameters
 
339
  )
340
 
341
  async def create_resource(
@@ -371,7 +375,7 @@ class ProxyTemplate(ResourceTemplate):
371
  )
372
 
373
 
374
- class ProxyPrompt(Prompt):
375
  """
376
  A Prompt that represents and renders a prompt from a remote server.
377
  """
@@ -400,6 +404,7 @@ class ProxyPrompt(Prompt):
400
  name=mcp_prompt.name,
401
  description=mcp_prompt.description,
402
  arguments=arguments,
 
403
  )
404
 
405
  async def render(self, arguments: dict[str, Any]) -> list[PromptMessage]:
 
36
  from fastmcp.server.server import FastMCP
37
  from fastmcp.tools.tool import Tool, ToolResult
38
  from fastmcp.tools.tool_manager import ToolManager
39
+ from fastmcp.utilities.components import MirroredComponent
40
  from fastmcp.utilities.logging import get_logger
41
 
42
  if TYPE_CHECKING:
 
227
  return result
228
 
229
 
230
+ class ProxyTool(Tool, MirroredComponent):
231
  """
232
  A Tool that represents and executes a tool on a remote server.
233
  """
 
246
  parameters=mcp_tool.inputSchema,
247
  annotations=mcp_tool.annotations,
248
  output_schema=mcp_tool.outputSchema,
249
+ _mirrored=True,
250
  )
251
 
252
  async def run(
 
268
  )
269
 
270
 
271
+ class ProxyResource(Resource, MirroredComponent):
272
  """
273
  A Resource that represents and reads a resource from a remote server.
274
  """
 
300
  name=mcp_resource.name,
301
  description=mcp_resource.description,
302
  mime_type=mcp_resource.mimeType or "text/plain",
303
+ _mirrored=True,
304
  )
305
 
306
  async def read(self) -> str | bytes:
 
318
  raise ResourceError(f"Unsupported content type: {type(result[0])}")
319
 
320
 
321
+ class ProxyTemplate(ResourceTemplate, MirroredComponent):
322
  """
323
  A ResourceTemplate that represents and creates resources from a remote server template.
324
  """
 
339
  description=mcp_template.description,
340
  mime_type=mcp_template.mimeType or "text/plain",
341
  parameters={}, # Remote templates don't have local parameters
342
+ _mirrored=True,
343
  )
344
 
345
  async def create_resource(
 
375
  )
376
 
377
 
378
+ class ProxyPrompt(Prompt, MirroredComponent):
379
  """
380
  A Prompt that represents and renders a prompt from a remote server.
381
  """
 
404
  name=mcp_prompt.name,
405
  description=mcp_prompt.description,
406
  arguments=arguments,
407
+ _mirrored=True,
408
  )
409
 
410
  async def render(self, arguments: dict[str, Any]) -> list[PromptMessage]:
src/fastmcp/server/server.py CHANGED
@@ -759,7 +759,7 @@ class FastMCP(Generic[LifespanResultT]):
759
  )
760
  return await self._apply_middleware(mw_context, _handler)
761
 
762
- def add_tool(self, tool: Tool) -> None:
763
  """Add a tool to the server.
764
 
765
  The tool function can optionally request a Context object by adding a parameter
@@ -767,6 +767,9 @@ class FastMCP(Generic[LifespanResultT]):
767
 
768
  Args:
769
  tool: The Tool instance to register
 
 
 
770
  """
771
  self._tool_manager.add_tool(tool)
772
  self._cache.clear()
@@ -780,6 +783,8 @@ class FastMCP(Generic[LifespanResultT]):
780
  except RuntimeError:
781
  pass # No context available
782
 
 
 
783
  def remove_tool(self, name: str) -> None:
784
  """Remove a tool from the server.
785
 
@@ -958,13 +963,15 @@ class FastMCP(Generic[LifespanResultT]):
958
  enabled=enabled,
959
  )
960
 
961
- def add_resource(self, resource: Resource) -> None:
962
  """Add a resource to the server.
963
 
964
  Args:
965
  resource: A Resource instance to add
966
- """
967
 
 
 
 
968
  self._resource_manager.add_resource(resource)
969
  self._cache.clear()
970
 
@@ -977,11 +984,16 @@ class FastMCP(Generic[LifespanResultT]):
977
  except RuntimeError:
978
  pass # No context available
979
 
980
- def add_template(self, template: ResourceTemplate) -> None:
 
 
981
  """Add a resource template to the server.
982
 
983
  Args:
984
  template: A ResourceTemplate instance to add
 
 
 
985
  """
986
  self._resource_manager.add_template(template)
987
 
@@ -994,6 +1006,8 @@ class FastMCP(Generic[LifespanResultT]):
994
  except RuntimeError:
995
  pass # No context available
996
 
 
 
997
  def add_resource_fn(
998
  self,
999
  fn: AnyFunction,
@@ -1159,11 +1173,14 @@ class FastMCP(Generic[LifespanResultT]):
1159
 
1160
  return decorator
1161
 
1162
- def add_prompt(self, prompt: Prompt) -> None:
1163
  """Add a prompt to the server.
1164
 
1165
  Args:
1166
  prompt: A Prompt instance to add
 
 
 
1167
  """
1168
  self._prompt_manager.add_prompt(prompt)
1169
  self._cache.clear()
@@ -1177,6 +1194,8 @@ class FastMCP(Generic[LifespanResultT]):
1177
  except RuntimeError:
1178
  pass # No context available
1179
 
 
 
1180
  @overload
1181
  def prompt(
1182
  self,
 
759
  )
760
  return await self._apply_middleware(mw_context, _handler)
761
 
762
+ def add_tool(self, tool: Tool) -> Tool:
763
  """Add a tool to the server.
764
 
765
  The tool function can optionally request a Context object by adding a parameter
 
767
 
768
  Args:
769
  tool: The Tool instance to register
770
+
771
+ Returns:
772
+ The tool instance that was added to the server.
773
  """
774
  self._tool_manager.add_tool(tool)
775
  self._cache.clear()
 
783
  except RuntimeError:
784
  pass # No context available
785
 
786
+ return tool
787
+
788
  def remove_tool(self, name: str) -> None:
789
  """Remove a tool from the server.
790
 
 
963
  enabled=enabled,
964
  )
965
 
966
+ def add_resource(self, resource: Resource) -> Resource:
967
  """Add a resource to the server.
968
 
969
  Args:
970
  resource: A Resource instance to add
 
971
 
972
+ Returns:
973
+ The resource instance that was added to the server.
974
+ """
975
  self._resource_manager.add_resource(resource)
976
  self._cache.clear()
977
 
 
984
  except RuntimeError:
985
  pass # No context available
986
 
987
+ return resource
988
+
989
+ def add_template(self, template: ResourceTemplate) -> ResourceTemplate:
990
  """Add a resource template to the server.
991
 
992
  Args:
993
  template: A ResourceTemplate instance to add
994
+
995
+ Returns:
996
+ The template instance that was added to the server.
997
  """
998
  self._resource_manager.add_template(template)
999
 
 
1006
  except RuntimeError:
1007
  pass # No context available
1008
 
1009
+ return template
1010
+
1011
  def add_resource_fn(
1012
  self,
1013
  fn: AnyFunction,
 
1173
 
1174
  return decorator
1175
 
1176
+ def add_prompt(self, prompt: Prompt) -> Prompt:
1177
  """Add a prompt to the server.
1178
 
1179
  Args:
1180
  prompt: A Prompt instance to add
1181
+
1182
+ Returns:
1183
+ The prompt instance that was added to the server.
1184
  """
1185
  self._prompt_manager.add_prompt(prompt)
1186
  self._cache.clear()
 
1194
  except RuntimeError:
1195
  pass # No context available
1196
 
1197
+ return prompt
1198
+
1199
  @overload
1200
  def prompt(
1201
  self,
src/fastmcp/utilities/components.py CHANGED
@@ -77,3 +77,46 @@ class FastMCPComponent(FastMCPBaseModel):
77
  def disable(self) -> None:
78
  """Disable the component."""
79
  self.enabled = False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  def disable(self) -> None:
78
  """Disable the component."""
79
  self.enabled = False
80
+
81
+ def copy(self) -> Self:
82
+ """Create a copy of the component."""
83
+ return self.model_copy()
84
+
85
+
86
+ class MirroredComponent(FastMCPComponent):
87
+ """Base class for components that are mirrored from a remote server.
88
+
89
+ Mirrored components cannot be enabled or disabled directly. Call copy() first
90
+ to create a local version you can modify.
91
+ """
92
+
93
+ _mirrored: bool = PrivateAttr(default=False)
94
+
95
+ def __init__(self, *, _mirrored: bool = False, **kwargs: Any) -> None:
96
+ super().__init__(**kwargs)
97
+ self._mirrored = _mirrored
98
+
99
+ def enable(self) -> None:
100
+ """Enable the component."""
101
+ if self._mirrored:
102
+ raise RuntimeError(
103
+ f"Cannot enable mirrored component '{self.name}'. "
104
+ f"Create a local copy first with {self.name}.copy() and add it to your server."
105
+ )
106
+ super().enable()
107
+
108
+ def disable(self) -> None:
109
+ """Disable the component."""
110
+ if self._mirrored:
111
+ raise RuntimeError(
112
+ f"Cannot disable mirrored component '{self.name}'. "
113
+ f"Create a local copy first with {self.name}.copy() and add it to your server."
114
+ )
115
+ super().disable()
116
+
117
+ def copy(self) -> Self:
118
+ """Create a copy of the component that can be modified."""
119
+ # Create a copy and mark it as not mirrored
120
+ copied = self.model_copy()
121
+ copied._mirrored = False
122
+ return copied
tests/server/proxy/test_proxy_server.py CHANGED
@@ -516,3 +516,161 @@ async def test_proxy_handles_multiple_concurrent_tasks_correctly(
516
  assert list(results["tools"]) == Contains(
517
  "greet", "add", "error_tool", "tool_without_description"
518
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
516
  assert list(results["tools"]) == Contains(
517
  "greet", "add", "error_tool", "tool_without_description"
518
  )
519
+
520
+
521
+ class TestMirroredComponents:
522
+ """Test mirrored component functionality - components retrieved from proxy servers."""
523
+
524
+ async def test_mirrored_tool_cannot_be_enabled(self, proxy_server):
525
+ """Test that mirrored tools cannot be enabled directly."""
526
+ tools = await proxy_server.get_tools()
527
+ mirrored_tool = tools["greet"]
528
+
529
+ # Verify it's mirrored
530
+ assert mirrored_tool._mirrored is True
531
+
532
+ # Should raise error when trying to enable
533
+ with pytest.raises(RuntimeError, match="Cannot enable mirrored component"):
534
+ mirrored_tool.enable()
535
+
536
+ async def test_mirrored_tool_cannot_be_disabled(self, proxy_server):
537
+ """Test that mirrored tools cannot be disabled directly."""
538
+ tools = await proxy_server.get_tools()
539
+ mirrored_tool = tools["greet"]
540
+
541
+ # Verify it's mirrored
542
+ assert mirrored_tool._mirrored is True
543
+
544
+ # Should raise error when trying to disable
545
+ with pytest.raises(RuntimeError, match="Cannot disable mirrored component"):
546
+ mirrored_tool.disable()
547
+
548
+ async def test_mirrored_resource_cannot_be_enabled(self, proxy_server):
549
+ """Test that mirrored resources cannot be enabled directly."""
550
+ resources = await proxy_server.get_resources()
551
+ mirrored_resource = resources["resource://wave"]
552
+
553
+ # Verify it's mirrored
554
+ assert mirrored_resource._mirrored is True
555
+
556
+ # Should raise error when trying to enable
557
+ with pytest.raises(RuntimeError, match="Cannot enable mirrored component"):
558
+ mirrored_resource.enable()
559
+
560
+ async def test_mirrored_resource_cannot_be_disabled(self, proxy_server):
561
+ """Test that mirrored resources cannot be disabled directly."""
562
+ resources = await proxy_server.get_resources()
563
+ mirrored_resource = resources["resource://wave"]
564
+
565
+ # Verify it's mirrored
566
+ assert mirrored_resource._mirrored is True
567
+
568
+ # Should raise error when trying to disable
569
+ with pytest.raises(RuntimeError, match="Cannot disable mirrored component"):
570
+ mirrored_resource.disable()
571
+
572
+ async def test_mirrored_prompt_cannot_be_enabled(self, proxy_server):
573
+ """Test that mirrored prompts cannot be enabled directly."""
574
+ prompts = await proxy_server.get_prompts()
575
+ mirrored_prompt = prompts["welcome"]
576
+
577
+ # Verify it's mirrored
578
+ assert mirrored_prompt._mirrored is True
579
+
580
+ # Should raise error when trying to enable
581
+ with pytest.raises(RuntimeError, match="Cannot enable mirrored component"):
582
+ mirrored_prompt.enable()
583
+
584
+ async def test_mirrored_prompt_cannot_be_disabled(self, proxy_server):
585
+ """Test that mirrored prompts cannot be disabled directly."""
586
+ prompts = await proxy_server.get_prompts()
587
+ mirrored_prompt = prompts["welcome"]
588
+
589
+ # Verify it's mirrored
590
+ assert mirrored_prompt._mirrored is True
591
+
592
+ # Should raise error when trying to disable
593
+ with pytest.raises(RuntimeError, match="Cannot disable mirrored component"):
594
+ mirrored_prompt.disable()
595
+
596
+ async def test_copy_creates_non_mirrored_component(self, proxy_server):
597
+ """Test that copy() creates a non-mirrored component that can be modified."""
598
+ tools = await proxy_server.get_tools()
599
+ mirrored_tool = tools["greet"]
600
+
601
+ # Create a copy
602
+ local_tool = mirrored_tool.copy()
603
+
604
+ # Copy should not be mirrored
605
+ assert local_tool._mirrored is False
606
+
607
+ # Should be able to enable/disable the copy
608
+ local_tool.enable()
609
+ assert local_tool.enabled is True
610
+
611
+ local_tool.disable()
612
+ assert local_tool.enabled is False
613
+
614
+ async def test_local_component_takes_precedence_over_mirrored(self, proxy_server):
615
+ """Test that local components take precedence over mirrored ones."""
616
+ # Get the mirrored tool
617
+ tools = await proxy_server.get_tools()
618
+ mirrored_tool = tools["greet"]
619
+
620
+ # Create a local copy and add it
621
+ local_tool = mirrored_tool.copy()
622
+ proxy_server.add_tool(local_tool)
623
+
624
+ # Disable the local copy
625
+ local_tool.disable()
626
+
627
+ # The local disabled tool should take precedence
628
+ updated_tools = await proxy_server.get_tools()
629
+ final_tool = updated_tools["greet"]
630
+
631
+ # Should be the local tool (not mirrored) and disabled
632
+ assert final_tool is local_tool
633
+ assert final_tool._mirrored is False
634
+ assert final_tool.enabled is False
635
+
636
+ async def test_error_messages_mention_copy_method(self, proxy_server):
637
+ """Test that error messages guide users to use copy() method."""
638
+ tools = await proxy_server.get_tools()
639
+ mirrored_tool = tools["greet"]
640
+
641
+ # Check enable error message
642
+ with pytest.raises(RuntimeError) as exc_info:
643
+ mirrored_tool.enable()
644
+ assert "copy()" in str(exc_info.value)
645
+
646
+ # Check disable error message
647
+ with pytest.raises(RuntimeError) as exc_info:
648
+ mirrored_tool.disable()
649
+ assert "copy()" in str(exc_info.value)
650
+
651
+ async def test_client_cannot_call_disabled_proxy_tool(self, proxy_server):
652
+ """Test that clients cannot call a tool when local copy is disabled."""
653
+ # Get the mirrored tool
654
+ tools = await proxy_server.get_tools()
655
+ mirrored_tool = tools["greet"]
656
+
657
+ # Verify the tool works initially
658
+ async with Client(proxy_server) as client:
659
+ result = await client.call_tool("greet", {"name": "Alice"})
660
+ assert result.data == "Hello, Alice!"
661
+
662
+ # Create a local copy and disable it
663
+ local_tool = mirrored_tool.copy()
664
+ proxy_server.add_tool(local_tool)
665
+ local_tool.disable()
666
+
667
+ # Client should now get "Unknown tool" error
668
+ async with Client(proxy_server) as client:
669
+ with pytest.raises(ToolError, match="Unknown tool"):
670
+ await client.call_tool("greet", {"name": "Alice"})
671
+
672
+ # Tool should not appear in tool list either
673
+ async with Client(proxy_server) as client:
674
+ tools_list = await client.list_tools()
675
+ tool_names = [tool.name for tool in tools_list]
676
+ assert "greet" not in tool_names