feat: Initial commit

This commit is contained in:
2026-03-16 14:12:06 +01:00
parent 33b1e3747c
commit 156727e689
11 changed files with 522 additions and 0 deletions

5
src/mcp_api/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
from .config import ServerConfig, from_dict, load_claude_json
from .tool import MCPTool
from .toolbox import MCPToolbox
__all__ = ["MCPToolbox", "MCPTool", "ServerConfig", "from_dict", "load_claude_json"]

36
src/mcp_api/config.py Normal file
View File

@@ -0,0 +1,36 @@
"""Config loading from claude.json or plain dicts."""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
@dataclass
class ServerConfig:
name: str
command: str
args: list[str] = field(default_factory=list)
env: dict[str, str] | None = None
def load_claude_json(path: Path | None = None) -> dict[str, ServerConfig]:
"""Load mcpServers from ~/.claude.json (or a custom path)."""
if path is None:
path = Path.home() / ".claude.json"
data = json.loads(path.read_text())
return from_dict(data.get("mcpServers", {}))
def from_dict(servers: dict) -> dict[str, ServerConfig]:
"""Parse a dict in the claude.json mcpServers format."""
result: dict[str, ServerConfig] = {}
for name, cfg in servers.items():
result[name] = ServerConfig(
name=name,
command=cfg["command"],
args=cfg.get("args", []),
env=cfg.get("env"),
)
return result

59
src/mcp_api/session.py Normal file
View File

@@ -0,0 +1,59 @@
"""Lifecycle management for a single MCP server connection."""
from __future__ import annotations
from contextlib import AsyncExitStack
from mcp import ClientSession, StdioServerParameters
from mcp import types
from mcp.client.stdio import stdio_client
from .config import ServerConfig
class ServerSession:
"""Manages one MCP server process and its ClientSession."""
def __init__(self, config: ServerConfig) -> None:
self._config = config
self._stack = AsyncExitStack()
self._session: ClientSession | None = None
async def connect(self) -> None:
params = StdioServerParameters(
command=self._config.command,
args=self._config.args,
env=self._config.env,
)
read, write = await self._stack.enter_async_context(stdio_client(params))
self._session = await self._stack.enter_async_context(
ClientSession(read, write)
)
await self._session.initialize()
async def disconnect(self) -> None:
await self._stack.aclose()
self._session = None
async def list_tools(self) -> list[types.Tool]:
assert self._session is not None, "Not connected"
result = await self._session.list_tools()
return result.tools
async def call_tool(self, name: str, arguments: dict) -> str:
assert self._session is not None, "Not connected"
result = await self._session.call_tool(name, arguments)
if result.isError:
error_text = _extract_text(result.content)
raise RuntimeError(f"Tool '{name}' returned an error: {error_text}")
return _extract_text(result.content)
def _extract_text(content: list) -> str:
parts: list[str] = []
for item in content:
if hasattr(item, "text"):
parts.append(item.text)
else:
parts.append(str(item))
return "\n".join(parts)

31
src/mcp_api/tool.py Normal file
View File

@@ -0,0 +1,31 @@
"""Callable wrapper around a single MCP tool."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from .session import ServerSession
class MCPTool:
"""A Python callable that invokes an MCP tool on a remote server."""
def __init__(self, name: str, description: str, input_schema: dict, session: ServerSession) -> None:
self.name = name
self.description = description
self.input_schema = input_schema
self._session = session
async def __call__(self, **kwargs: Any) -> str:
return await self._session.call_tool(self.name, kwargs)
def as_anthropic_tool(self) -> dict:
return {
"name": self.name,
"description": self.description,
"input_schema": self.input_schema,
}
def __repr__(self) -> str:
return f"MCPTool(name={self.name!r})"

141
src/mcp_api/toolbox.py Normal file
View File

@@ -0,0 +1,141 @@
"""MCPToolbox: main entry point for the library."""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Any
from .config import ServerConfig, from_dict, load_claude_json
from .session import ServerSession
from .tool import MCPTool
class _ServerNamespace:
"""Attribute-style access to tools belonging to one server.
Usage: ``toolbox.pin.list_plugins(output="/tmp/out.txt")``
"""
def __init__(self, session: ServerSession, all_tools: dict[str, MCPTool]) -> None:
self._session = session
self._tools = {
name: tool for name, tool in all_tools.items() if tool._session is session
}
def __getattr__(self, name: str) -> MCPTool:
if name in self._tools:
return self._tools[name]
raise AttributeError(name)
def __dir__(self) -> list[str]:
return list(self._tools)
def __repr__(self) -> str:
return f"_ServerNamespace(tools={list(self._tools)})"
class MCPToolbox:
"""Connects to one or more MCP servers and exposes their tools."""
def __init__(self, configs: dict[str, ServerConfig]) -> None:
self._configs = configs
self._sessions: dict[str, ServerSession] = {}
self._tools: dict[str, MCPTool] = {}
# ------------------------------------------------------------------ #
# Constructors #
# ------------------------------------------------------------------ #
@classmethod
def from_claude_json(cls, path: Path | None = None) -> MCPToolbox:
"""Load server config from ~/.claude.json (or a custom path)."""
return cls(load_claude_json(path))
@classmethod
def from_config(cls, servers: dict) -> MCPToolbox:
"""Load server config from a plain dict (claude.json mcpServers format)."""
return cls(from_dict(servers))
# ------------------------------------------------------------------ #
# Async context manager #
# ------------------------------------------------------------------ #
async def __aenter__(self) -> MCPToolbox:
await self.discover()
return self
async def __aexit__(self, *_: Any) -> None:
await asyncio.gather(
*(s.disconnect() for s in self._sessions.values()),
return_exceptions=True,
)
# ------------------------------------------------------------------ #
# Discovery #
# ------------------------------------------------------------------ #
async def discover(self) -> None:
"""Connect to all servers in parallel and discover their tools."""
await asyncio.gather(*(self._connect_server(cfg) for cfg in self._configs.values()))
async def _connect_server(self, cfg: ServerConfig) -> None:
session = ServerSession(cfg)
await session.connect()
self._sessions[cfg.name] = session
for mcp_tool in await session.list_tools():
tool = MCPTool(
name=mcp_tool.name,
description=mcp_tool.description or "",
input_schema=mcp_tool.inputSchema if isinstance(mcp_tool.inputSchema, dict)
else mcp_tool.inputSchema.model_dump(),
session=session,
)
self._tools[tool.name] = tool
# ------------------------------------------------------------------ #
# Tool access #
# ------------------------------------------------------------------ #
@property
def tools(self) -> list[MCPTool]:
return list(self._tools.values())
def __getattr__(self, name: str) -> _ServerNamespace | MCPTool:
# toolbox.pin → ServerNamespace for that server
if name in self._sessions:
return _ServerNamespace(self._sessions[name], self._tools)
# toolbox.list_plugins → MCPTool directly (flat access)
if name in self._tools:
return self._tools[name]
raise AttributeError(name)
def __getitem__(self, name: str) -> MCPTool:
return self._tools[name]
# ------------------------------------------------------------------ #
# LLM integration helpers #
# ------------------------------------------------------------------ #
def as_anthropic_tools(self) -> list[dict]:
"""Return all tools in Anthropic API format."""
return [t.as_anthropic_tool() for t in self._tools.values()]
async def call_tool(self, name: str, arguments: dict | None = None) -> str:
"""Call a tool by name and return its text output."""
return await self._tools[name]._session.call_tool(name, arguments or {})
async def handle_anthropic_tool_use(self, tool_use_block: Any) -> dict:
"""Dispatch an Anthropic ToolUseBlock and return a tool_result dict."""
try:
content = await self.call_tool(tool_use_block.name, tool_use_block.input)
is_error = False
except Exception as exc:
content = str(exc)
is_error = True
return {
"type": "tool_result",
"tool_use_id": tool_use_block.id,
"content": content,
"is_error": is_error,
}