Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .beads/issues.jsonl
Original file line number Diff line number Diff line change
@@ -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"}
6 changes: 4 additions & 2 deletions docs/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion examples/autogen-direct/test_skills/get_hn_headlines.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("<html")
Expand Down
2 changes: 1 addition & 1 deletion examples/shared/mcp-tools/fetch.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: fetch
name: web
type: mcp
transport: stdio
command: uvx
Expand Down
6 changes: 3 additions & 3 deletions examples/shared/tools/example_mcp.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@
# - sse: Server runs remotely, communicates via Server-Sent Events
#
# Usage in agent code:
# tools.list() # Discover available tools
# tools.search("my-mcp") # Find tools by name/description
# tools.my_tool(arg="value") # Call a tool exposed by the server
# tools.list() # Discover available tools
# tools.search("my-mcp") # Find tools by name/description
# tools.my_mcp_server.my_tool(arg="value") # Call a tool exposed by the server

# --- STDIO Transport (most common) ---
# Server runs as a child process. Command + args launch the server.
Expand Down
19 changes: 2 additions & 17 deletions src/py_code_mode/execution/container/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -758,15 +758,7 @@ async def api_list_tools() -> 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]]:
Expand All @@ -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
Expand Down
14 changes: 4 additions & 10 deletions src/py_code_mode/execution/in_process/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
20 changes: 2 additions & 18 deletions src/py_code_mode/execution/subprocess/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
64 changes: 40 additions & 24 deletions src/py_code_mode/tools/adapters/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,31 +39,35 @@ 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)
"""

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

Expand All @@ -73,13 +77,16 @@ 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.

Args:
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.
Expand Down Expand Up @@ -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(
Expand All @@ -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.

Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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."""
Expand Down Expand Up @@ -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:
Expand All @@ -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()
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions src/py_code_mode/tools/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
25 changes: 25 additions & 0 deletions src/py_code_mode/tools/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any


@dataclass(frozen=True)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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],
}
Loading
Loading