diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index e69de29..7f4b6ca 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -0,0 +1 @@ +{"id":"py-code-mode-6y4","title":"Put mcp tools under their own namespaces instead of merging with top level namespace","description":"Right now, if one enables for example the semgrep MCP, all the MCP tools will go under tools.* namespace; they will not be grouped like tools.semgrep.*; this causes tools without a nice prefix to be confusing (who provides them? what are they for?)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T01:44:05.333773-08:00","created_by":"actae0n","updated_at":"2026-01-02T02:37:35.39444-08:00","closed_at":"2026-01-02T02:37:35.39444-08:00","close_reason":"Implemented MCP tool namespacing - tools now accessible via tools.namespace.tool_name pattern"} diff --git a/docs/tools.md b/docs/tools.md index 685fce4..4d00b7f 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -130,13 +130,15 @@ description: Fetch web pages with full content extraction ### Agent Usage -MCP tools expose their server-defined interface: +MCP tools are namespaced by their YAML `name` field: ```python # Use tools as defined by the MCP server -content = tools.fetch(url="https://example.com") +content = tools.web.fetch(url="https://example.com") ``` +**Namespace naming:** Choose names that describe the capability domain, not the tool name. For example, use `web` instead of `fetch` (avoids `tools.fetch.fetch()`), or `datetime` instead of `time`. + ## HTTP Tools Wrap REST APIs (defined in Python): diff --git a/examples/autogen-direct/test_skills/get_hn_headlines.py b/examples/autogen-direct/test_skills/get_hn_headlines.py index 2e7f6cf..825175a 100644 --- a/examples/autogen-direct/test_skills/get_hn_headlines.py +++ b/examples/autogen-direct/test_skills/get_hn_headlines.py @@ -12,7 +12,7 @@ def run(count: int = 10) -> list[str]: import re # Fetch the HackerNews front page with enough content to get all headlines - html_content = tools.fetch(url="https://news.ycombinator.com/", raw=True, max_length=20000) + html_content = tools.web.fetch(url="https://news.ycombinator.com/", raw=True, max_length=20000) # Extract the HTML content (remove any prefix text) html_start = html_content.find(" list[dict[str, Any]]: if _state.registry is None: return [] - tools = _state.registry.get_all_tools() - return [ - { - "name": tool.name, - "description": tool.description, - "tags": list(tool.tags) if tool.tags else [], - } - for tool in tools - ] + return [tool.to_dict() for tool in _state.registry.get_all_tools()] @app.get("/api/tools/search", dependencies=[Depends(require_auth)]) async def api_search_tools(query: str, limit: int = 10) -> list[dict[str, Any]]: @@ -783,14 +775,7 @@ async def api_search_tools(query: str, limit: int = 10) -> list[dict[str, Any]]: get_description=lambda t: t.description, limit=limit, ) - return [ - { - "name": tool.name, - "description": tool.description, - "tags": list(tool.tags) if tool.tags else [], - } - for tool in tools - ] + return [tool.to_dict() for tool in tools] # ========================================================================== # Skills API Endpoints diff --git a/src/py_code_mode/execution/in_process/executor.py b/src/py_code_mode/execution/in_process/executor.py index 6328bbb..0343a42 100644 --- a/src/py_code_mode/execution/in_process/executor.py +++ b/src/py_code_mode/execution/in_process/executor.py @@ -543,14 +543,11 @@ async def list_tools(self) -> list[dict[str, Any]]: """List all available tools. Returns: - List of dicts with name, description, and tags for each tool. + List of dicts with name, description, tags, and callables for each tool. """ if self._registry is None: return [] - return [ - {"name": t.name, "description": t.description or "", "tags": list(t.tags)} - for t in self._registry.list_tools() - ] + return [t.to_dict() for t in self._registry.list_tools()] async def search_tools(self, query: str, limit: int = 10) -> list[dict[str, Any]]: """Search tools by name/description. @@ -560,14 +557,11 @@ async def search_tools(self, query: str, limit: int = 10) -> list[dict[str, Any] limit: Maximum number of results to return. Returns: - List of dicts with name, description, and tags for matching tools. + List of dicts with name, description, tags, and callables for matching tools. """ if self._registry is None: return [] - return [ - {"name": t.name, "description": t.description or "", "tags": list(t.tags)} - for t in self._registry.search(query, limit=limit) - ] + return [t.to_dict() for t in self._registry.search(query, limit=limit)] async def __aenter__(self) -> InProcessExecutor: return self diff --git a/src/py_code_mode/execution/subprocess/executor.py b/src/py_code_mode/execution/subprocess/executor.py index ee607ca..ab48b2a 100644 --- a/src/py_code_mode/execution/subprocess/executor.py +++ b/src/py_code_mode/execution/subprocess/executor.py @@ -140,30 +140,14 @@ async def list_tools(self) -> list[dict[str, Any]]: registry = self._get_tool_registry() if registry is None: return [] - tools = registry.list_tools() - return [ - { - "name": tool.name, - "description": tool.description or "", - "tags": list(tool.tags), - } - for tool in tools - ] + return [tool.to_dict() for tool in registry.list_tools()] async def search_tools(self, query: str, limit: int) -> list[dict[str, Any]]: """Search tools by query.""" registry = self._get_tool_registry() if registry is None: return [] - tools = registry.search(query, limit=limit) - return [ - { - "name": tool.name, - "description": tool.description or "", - "tags": list(tool.tags), - } - for tool in tools - ] + return [tool.to_dict() for tool in registry.search(query, limit=limit)] async def list_tool_recipes(self, name: str) -> list[dict[str, Any]]: """List recipes for a specific tool.""" diff --git a/src/py_code_mode/tools/adapters/mcp.py b/src/py_code_mode/tools/adapters/mcp.py index 6b2a28e..090b624 100644 --- a/src/py_code_mode/tools/adapters/mcp.py +++ b/src/py_code_mode/tools/adapters/mcp.py @@ -39,15 +39,16 @@ class MCPAdapter: """Adapter for MCP (Model Context Protocol) servers. Connects to an MCP server and exposes its tools through the ToolAdapter interface. + All MCP tools are grouped under a single namespace. Usage: # With existing session - adapter = MCPAdapter(session=client_session) + adapter = MCPAdapter(session=client_session, namespace="time") # Or connect via stdio - adapter = await MCPAdapter.connect_stdio("python", ["server.py"]) + adapter = await MCPAdapter.connect_stdio("python", ["server.py"], namespace="time") - # Use through registry + # Use through registry - tools accessible as tools.time.get_current_time() registry = ToolRegistry() await registry.register_adapter(adapter) """ @@ -55,15 +56,18 @@ class MCPAdapter: def __init__( self, session: MCPSession, + namespace: str, exit_stack: AsyncExitStack | None = None, ) -> None: """Initialize adapter with MCP session. Args: session: MCP ClientSession instance. + namespace: Name for the tool namespace (e.g., "time", "web"). exit_stack: Optional AsyncExitStack for resource management. """ self._session = session + self._namespace = namespace self._exit_stack = exit_stack self._tools_cache: list[Tool] | None = None @@ -73,6 +77,8 @@ async def connect_stdio( command: str, args: list[str] | None = None, env: dict[str, str] | None = None, + *, + namespace: str, ) -> MCPAdapter: """Connect to an MCP server via stdio transport. @@ -80,6 +86,7 @@ async def connect_stdio( command: Command to run (e.g., "python", "node"). args: Command arguments (e.g., ["server.py"]). env: Optional environment variables. + namespace: Name for the tool namespace (e.g., "time", "web"). Returns: Connected MCPAdapter instance. @@ -112,7 +119,7 @@ async def connect_stdio( await session.initialize() - return cls(session=session, exit_stack=exit_stack) + return cls(session=session, namespace=namespace, exit_stack=exit_stack) @classmethod async def connect_sse( @@ -121,6 +128,8 @@ async def connect_sse( headers: dict[str, str] | None = None, timeout: float = 5.0, sse_read_timeout: float = 300.0, + *, + namespace: str, ) -> MCPAdapter: """Connect to an MCP server via SSE transport. @@ -129,6 +138,7 @@ async def connect_sse( headers: Optional HTTP headers (e.g., for authentication). timeout: Connection timeout in seconds. sse_read_timeout: Read timeout for SSE events in seconds. + namespace: Name for the tool namespace (e.g., "time", "web"). Returns: Connected MCPAdapter instance. @@ -157,7 +167,7 @@ async def connect_sse( await session.initialize() - return cls(session=session, exit_stack=exit_stack) + return cls(session=session, namespace=namespace, exit_stack=exit_stack) def list_tools(self) -> list[Tool]: """List all tools from the MCP server. @@ -176,32 +186,38 @@ def list_tools(self) -> list[Tool]: async def _refresh_tools(self) -> list[Tool]: """Fetch tools from MCP server and update cache. + Returns a single Tool named after the namespace, with all MCP tools + as callables on that Tool. + Returns: - List of Tool objects. + List containing one Tool with namespace as name. """ response = await self._session.list_tools() - tools = [] + callables = [] + descriptions = [] for mcp_tool in response.tools: - # Build parameters from MCP input schema params = self._extract_parameters(mcp_tool.inputSchema) - # Create a single callable for the tool (MCP tools don't have recipes) callable_obj = ToolCallable( name=mcp_tool.name, description=mcp_tool.description or "", parameters=tuple(params), ) + callables.append(callable_obj) + if mcp_tool.description: + descriptions.append(f"{mcp_tool.name}: {mcp_tool.description}") + + namespace_tool = Tool( + name=self._namespace, + description="; ".join(descriptions) + if descriptions + else f"MCP tools under {self._namespace}", + callables=tuple(callables), + ) - tool = Tool( - name=mcp_tool.name, - description=mcp_tool.description or "", - callables=(callable_obj,), - ) - tools.append(tool) - - self._tools_cache = tools - return tools + self._tools_cache = [namespace_tool] + return self._tools_cache def _extract_parameters(self, schema: dict[str, Any]) -> list[ToolParameter]: """Extract ToolParameter list from MCP input schema.""" @@ -231,8 +247,8 @@ async def call_tool( """Call a tool on the MCP server. Args: - name: Tool name. - callable_name: Ignored for MCP (no recipes). Kept for interface compatibility. + name: Namespace name (e.g., "time"). + callable_name: The actual MCP tool name to call (e.g., "get_current_time"). args: Tool arguments. Returns: @@ -242,9 +258,9 @@ async def call_tool( ToolNotFoundError: If tool not found. ToolCallError: If tool execution fails. """ - # MCP tools don't have recipes - callable_name is ignored + mcp_tool_name = callable_name if callable_name else name try: - result = await self._session.call_tool(name, args) + result = await self._session.call_tool(mcp_tool_name, args) except Exception as e: # MCP SDK errors, I/O errors, and timeouts from tool execution error_msg = str(e).lower() @@ -273,9 +289,9 @@ async def describe(self, tool_name: str, callable_name: str) -> dict[str, str]: tools = self.list_tools() for tool in tools: if tool.name == tool_name: - # MCP tools have one callable with same name as tool for c in tool.callables: - return {p.name: p.description for p in c.parameters} + if c.name == callable_name: + return {p.name: p.description for p in c.parameters} return {} def _extract_text(self, result: Any) -> str: diff --git a/src/py_code_mode/tools/registry.py b/src/py_code_mode/tools/registry.py index 728ac0f..036cffb 100644 --- a/src/py_code_mode/tools/registry.py +++ b/src/py_code_mode/tools/registry.py @@ -41,11 +41,13 @@ async def _load_mcp_adapter( command=mcp_config["command"], args=mcp_config.get("args", []), env=mcp_config.get("env", {}), + namespace=tool_name, ) elif transport == "sse": adapter = await MCPAdapter.connect_sse( url=mcp_config["url"], headers=mcp_config.get("headers"), + namespace=tool_name, ) else: raise ValueError(f"Unknown MCP transport: {transport}") diff --git a/src/py_code_mode/tools/types.py b/src/py_code_mode/tools/types.py index 8a1559d..a5e0304 100644 --- a/src/py_code_mode/tools/types.py +++ b/src/py_code_mode/tools/types.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass, field +from typing import Any @dataclass(frozen=True) @@ -40,6 +41,15 @@ def signature_fragment(self) -> str: return f"{self.name}: {self.type} | None = None" + def to_dict(self) -> dict[str, str | bool | None]: + return { + "name": self.name, + "type": self.type, + "required": self.required, + "default": self.default, + "description": self.description, + } + @dataclass(frozen=True) class ToolCallable: @@ -74,6 +84,13 @@ def __repr__(self) -> str: """Format as: signature: description""" return f"{self.signature()}: {self.description}" + def to_dict(self) -> dict[str, Any]: + return { + "name": self.name, + "description": self.description, + "parameters": [p.to_dict() for p in self.parameters], + } + @dataclass(frozen=True) class Tool: @@ -98,3 +115,11 @@ def __repr__(self) -> str: for c in self.callables: lines.append(f" {c!r}") return "\n".join(lines) + + def to_dict(self) -> dict[str, Any]: + return { + "name": self.name, + "description": self.description or "", + "tags": list(self.tags), + "callables": [c.to_dict() for c in self.callables], + } diff --git a/tests/test_error_handling.py b/tests/test_error_handling.py index 18d7ac5..4b291b3 100644 --- a/tests/test_error_handling.py +++ b/tests/test_error_handling.py @@ -656,7 +656,7 @@ async def raise_keyboard_interrupt(): mock_exit_stack.aclose = raise_keyboard_interrupt - adapter = MCPAdapter(session=mock_session, exit_stack=mock_exit_stack) + adapter = MCPAdapter(session=mock_session, namespace="test", exit_stack=mock_exit_stack) # After fix: KeyboardInterrupt should propagate # Currently it's caught by `except BaseException: pass` @@ -683,7 +683,7 @@ async def raise_runtime_error(): mock_exit_stack.aclose = raise_runtime_error - adapter = MCPAdapter(session=mock_session, exit_stack=mock_exit_stack) + adapter = MCPAdapter(session=mock_session, namespace="test", exit_stack=mock_exit_stack) # Regular exceptions should be caught (don't crash on cleanup) await adapter.close() # Should not raise diff --git a/tests/test_mcp_adapter.py b/tests/test_mcp_adapter.py index 965e145..46f1827 100644 --- a/tests/test_mcp_adapter.py +++ b/tests/test_mcp_adapter.py @@ -33,9 +33,8 @@ def test_implements_tool_adapter_protocol(self) -> None: from py_code_mode.tools.adapters import ToolAdapter from py_code_mode.tools.adapters.mcp import MCPAdapter - # Create with mocked session mock_session = MagicMock() - adapter = MCPAdapter(session=mock_session) + adapter = MCPAdapter(session=mock_session, namespace="test") assert isinstance(adapter, ToolAdapter) @@ -80,43 +79,45 @@ def mock_session(self): async def adapter(self, mock_session): from py_code_mode.tools.adapters.mcp import MCPAdapter - adapter = MCPAdapter(session=mock_session) - await adapter._refresh_tools() # Populate the cache + adapter = MCPAdapter(session=mock_session, namespace="test") + await adapter._refresh_tools() return adapter @pytest.mark.asyncio - async def test_list_tools_returns_tool_objects(self, adapter) -> None: - """list_tools() returns list of Tool objects.""" + async def test_list_tools_returns_single_namespaced_tool(self, adapter) -> None: + """list_tools() returns single Tool named after namespace.""" tools = adapter.list_tools() - assert len(tools) == 2 - assert all(isinstance(t, Tool) for t in tools) + assert len(tools) == 1 + assert isinstance(tools[0], Tool) + assert tools[0].name == "test" @pytest.mark.asyncio - async def test_list_tools_maps_names(self, adapter) -> None: - """Tool names are preserved from MCP.""" + async def test_list_tools_has_mcp_tools_as_callables(self, adapter) -> None: + """MCP tools become callables on the namespaced Tool.""" tools = adapter.list_tools() - names = {t.name for t in tools} + tool = tools[0] + callable_names = {c.name for c in tool.callables} - assert names == {"read_file", "write_file"} + assert callable_names == {"read_file", "write_file"} @pytest.mark.asyncio - async def test_list_tools_maps_descriptions(self, adapter) -> None: - """Tool descriptions are preserved from MCP.""" + async def test_list_tools_callable_has_description(self, adapter) -> None: + """Callable descriptions are preserved from MCP.""" tools = adapter.list_tools() - tool = next(t for t in tools if t.name == "read_file") + tool = tools[0] + read_callable = next(c for c in tool.callables if c.name == "read_file") - assert tool.description == "Read contents of a file" + assert read_callable.description == "Read contents of a file" @pytest.mark.asyncio - async def test_list_tools_has_callable_with_parameters(self, adapter) -> None: - """Tools have callables with parameters from input schema.""" + async def test_list_tools_callable_has_parameters(self, adapter) -> None: + """Callables have parameters from MCP input schema.""" tools = adapter.list_tools() - tool = next(t for t in tools if t.name == "read_file") + tool = tools[0] + read_callable = next(c for c in tool.callables if c.name == "read_file") - assert len(tool.callables) == 1 - callable_obj = tool.callables[0] - param_names = {p.name for p in callable_obj.parameters} + param_names = {p.name for p in read_callable.parameters} assert "path" in param_names @@ -151,7 +152,7 @@ def mock_session(self): def adapter(self, mock_session): from py_code_mode.tools.adapters.mcp import MCPAdapter - return MCPAdapter(session=mock_session) + return MCPAdapter(session=mock_session, namespace="test") @pytest.mark.asyncio async def test_call_tool_invokes_session(self, adapter, mock_session) -> None: @@ -217,12 +218,10 @@ async def test_close_cleans_up(self) -> None: from py_code_mode.tools.adapters.mcp import MCPAdapter mock_session = AsyncMock() - adapter = MCPAdapter(session=mock_session) + adapter = MCPAdapter(session=mock_session, namespace="test") await adapter.close() - # Should not raise - class TestMCPAdapterSSETransport: """Tests for SSE transport connection.""" @@ -253,9 +252,9 @@ async def test_connect_sse_uses_sse_client(self) -> None: mock_session_cm.__aexit__.return_value = None mock_session_class.return_value = mock_session_cm - await MCPAdapter.connect_sse("http://localhost:8080/sse") + await MCPAdapter.connect_sse("http://localhost:8080/sse", namespace="test") - # Verify SSE client was called with URL + mock_sse_client.assert_called_once() mock_sse_client.assert_called_once() call_args = mock_sse_client.call_args assert call_args[0][0] == "http://localhost:8080/sse" @@ -284,7 +283,9 @@ async def test_connect_sse_passes_headers(self) -> None: mock_session_class.return_value = mock_session_cm headers = {"Authorization": "Bearer token123"} - await MCPAdapter.connect_sse("http://localhost:8080/sse", headers=headers) + await MCPAdapter.connect_sse( + "http://localhost:8080/sse", headers=headers, namespace="test" + ) call_kwargs = mock_sse_client.call_args[1] assert call_kwargs.get("headers") == headers @@ -312,7 +313,7 @@ async def test_connect_sse_initializes_session(self) -> None: mock_session_cm.__aexit__.return_value = None mock_session_class.return_value = mock_session_cm - await MCPAdapter.connect_sse("http://localhost:8080/sse") + await MCPAdapter.connect_sse("http://localhost:8080/sse", namespace="test") mock_session.initialize.assert_called_once() @@ -325,6 +326,129 @@ async def test_connect_sse_method_exists(self) -> None: assert callable(MCPAdapter.connect_sse) +class TestMCPAdapterNamespacing: + """Tests for MCP tool namespacing - tools grouped under namespace like CLI tools.""" + + @pytest.fixture + def mock_session(self): + """Create a mock MCP ClientSession with multiple tools.""" + session = AsyncMock() + session.list_tools = AsyncMock( + return_value=MockListToolsResult( + tools=[ + MockMCPTool( + name="get_current_time", + description="Get the current time", + inputSchema={"type": "object", "properties": {}}, + ), + MockMCPTool( + name="convert_timezone", + description="Convert time between timezones", + inputSchema={ + "type": "object", + "properties": { + "time": {"type": "string"}, + "from_tz": {"type": "string"}, + "to_tz": {"type": "string"}, + }, + }, + ), + ] + ) + ) + return session + + @pytest.mark.asyncio + async def test_namespaced_adapter_returns_single_tool(self, mock_session) -> None: + """MCPAdapter with namespace returns ONE Tool with namespace as name.""" + from py_code_mode.tools.adapters.mcp import MCPAdapter + + adapter = MCPAdapter(session=mock_session, namespace="time") + await adapter._refresh_tools() + + tools = adapter.list_tools() + + # Should return single tool named after namespace + assert len(tools) == 1 + assert tools[0].name == "time" + + @pytest.mark.asyncio + async def test_namespaced_adapter_tool_has_all_mcp_tools_as_callables( + self, mock_session + ) -> None: + """The single namespaced Tool has callables for each MCP tool.""" + from py_code_mode.tools.adapters.mcp import MCPAdapter + + adapter = MCPAdapter(session=mock_session, namespace="time") + await adapter._refresh_tools() + + tools = adapter.list_tools() + tool = tools[0] + + # Should have 2 callables (one for each MCP tool) + assert len(tool.callables) == 2 + callable_names = {c.name for c in tool.callables} + assert callable_names == {"get_current_time", "convert_timezone"} + + @pytest.mark.asyncio + async def test_namespaced_adapter_callable_has_correct_params(self, mock_session) -> None: + """Callables preserve parameter info from MCP tools.""" + from py_code_mode.tools.adapters.mcp import MCPAdapter + + adapter = MCPAdapter(session=mock_session, namespace="time") + await adapter._refresh_tools() + + tools = adapter.list_tools() + tool = tools[0] + + # Find convert_timezone callable + convert_callable = next(c for c in tool.callables if c.name == "convert_timezone") + param_names = {p.name for p in convert_callable.parameters} + assert param_names == {"time", "from_tz", "to_tz"} + + @pytest.mark.asyncio + async def test_namespaced_call_tool_uses_callable_name(self, mock_session) -> None: + """call_tool with namespace uses callable_name to identify MCP tool.""" + from py_code_mode.tools.adapters.mcp import MCPAdapter + + # Setup call_tool mock + mock_result = MagicMock() + mock_result.content = [MagicMock(type="text", text="2025-01-02T10:00:00Z")] + mock_result.isError = False + mock_session.call_tool = AsyncMock(return_value=mock_result) + + adapter = MCPAdapter(session=mock_session, namespace="time") + await adapter._refresh_tools() + + # Call using namespace tool name and MCP tool as callable_name + await adapter.call_tool("time", "get_current_time", {}) + + # Should call MCP session with the original MCP tool name + mock_session.call_tool.assert_called_once_with("get_current_time", {}) + + @pytest.mark.asyncio + async def test_connect_stdio_with_namespace(self) -> None: + """connect_stdio accepts namespace parameter.""" + # Verify the method signature accepts namespace + import inspect + + from py_code_mode.tools.adapters.mcp import MCPAdapter + + sig = inspect.signature(MCPAdapter.connect_stdio) + assert "namespace" in sig.parameters + + @pytest.mark.asyncio + async def test_connect_sse_with_namespace(self) -> None: + """connect_sse accepts namespace parameter.""" + # Verify the method signature accepts namespace + import inspect + + from py_code_mode.tools.adapters.mcp import MCPAdapter + + sig = inspect.signature(MCPAdapter.connect_sse) + assert "namespace" in sig.parameters + + class TestMCPAdapterWithRegistry: """Tests for MCPAdapter integration with ToolRegistry.""" @@ -346,18 +470,20 @@ async def test_register_with_registry(self) -> None: ) ) - adapter = MCPAdapter(session=mock_session) + adapter = MCPAdapter(session=mock_session, namespace="test_mcp") await adapter._refresh_tools() # Populate the cache registry = ToolRegistry() registry.register_adapter(adapter) tools = registry.list_tools() - assert any(t.name == "test" for t in tools) + # Should find the namespace tool, not individual MCP tools + assert any(t.name == "test_mcp" for t in tools) + assert not any(t.name == "test" for t in tools) @pytest.mark.asyncio async def test_call_through_registry(self) -> None: - """Can call MCP tools through registry.""" + """Can call MCP tools through registry using namespace.""" from py_code_mode.tools.adapters.mcp import MCPAdapter from py_code_mode.tools.registry import ToolRegistry @@ -375,10 +501,10 @@ async def test_call_through_registry(self) -> None: mock_result.isError = False mock_session.call_tool = AsyncMock(return_value=mock_result) - adapter = MCPAdapter(session=mock_session) - await adapter._refresh_tools() # Populate the cache + adapter = MCPAdapter(session=mock_session, namespace="greeter") + await adapter._refresh_tools() registry = ToolRegistry() registry.register_adapter(adapter) - result = await registry.call_tool("greet", None, {}) + result = await registry.call_tool("greeter", "greet", {}) assert result == "Hello!"