diff --git a/crates/eryx-python/python/eryx/__main__.py b/crates/eryx-python/python/eryx/__main__.py index adf4cbf6..e75111dd 100644 --- a/crates/eryx-python/python/eryx/__main__.py +++ b/crates/eryx-python/python/eryx/__main__.py @@ -6,12 +6,14 @@ python -m eryx -c 'print("hi")' # Execute a string echo 'print("hi")' | python -m eryx - # Execute from stdin python -m eryx serve # Start MCP server + python -m eryx wrap -- npx ... # Wrap MCP servers Examples: uvx --with pyeryx eryx -c 'import sys; print(sys.version)' uvx --with pyeryx eryx --timeout 5000 -c 'print("hello")' uvx --with pyeryx eryx --net --allow-host '*.example.com' -c 'import urllib.request; ...' uvx --with 'pyeryx[serve]' eryx serve --mcp + uvx --with 'pyeryx[serve]' eryx wrap -- npx @anthropic/mcp-filesystem . """ from __future__ import annotations @@ -40,6 +42,7 @@ def _build_parser() -> argparse.ArgumentParser: eryx --net -c 'import requests' enable network access eryx serve start MCP server eryx serve --mcp MCP server with inner tools + eryx wrap -- npx ... wrap MCP servers """), ) @@ -199,6 +202,12 @@ def main(argv: list[str] | None = None) -> int: return serve(raw_args[1:]) + # Subcommand: eryx wrap + if raw_args and raw_args[0] == "wrap": + from eryx.wrap import wrap + + return wrap(raw_args[1:]) + parser = _build_parser() args = parser.parse_args(argv) diff --git a/crates/eryx-python/python/eryx/wrap.py b/crates/eryx-python/python/eryx/wrap.py new file mode 100644 index 00000000..bf22474d --- /dev/null +++ b/crates/eryx-python/python/eryx/wrap.py @@ -0,0 +1,312 @@ +"""MCP meta-server that wraps other MCP servers behind three tools. + +Instead of exposing every tool from every server directly, ``eryx wrap`` +presents a simplified interface: ``list_tools``, ``call_tool``, and +``execute_python``. An LLM client sees 3 tools instead of N, and can +orchestrate across multiple servers via Python code in a single call. + +Start with:: + + eryx wrap -- npx @anthropic/mcp-filesystem # single inline server + eryx wrap --config servers.json # multi-server from config + eryx wrap --mcp # discover from IDE configs + eryx wrap --mcp --timeout 60000 --net # with sandbox options + eryx wrap --server-name fs -- npx ... # custom name for inline +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import textwrap + +import eryx + +from eryx._cli import add_sandbox_args, make_net_config, make_resource_limits +from eryx._eryx import MCPManager as _RustMCPManager +from eryx.mcp import _expand_env_vars, connect_servers, discover_servers + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="eryx wrap", + description="Wrap MCP servers behind a meta-tool interface.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=textwrap.dedent("""\ + examples: + eryx wrap -- npx @anthropic/mcp-filesystem . + eryx wrap --config servers.json + eryx wrap --mcp + eryx wrap --mcp --timeout 60000 --net + eryx wrap --server-name fs -- npx -y @anthropic/mcp-filesystem . + """), + ) + + parser.add_argument( + "--config", + action="append", + default=[], + metavar="PATH", + help="path to MCP server config file (JSON with mcpServers key, can be repeated)", + ) + parser.add_argument( + "--server-name", + default=None, + metavar="NAME", + help="custom name for the inline server specified after --", + ) + + add_sandbox_args(parser) + + return parser + + +def _derive_name(cmd: list[str]) -> str: + """Derive a short server name from an inline command. + + Looks for the last argument that looks like a package name + (e.g. ``@anthropic/mcp-filesystem`` → ``filesystem``). + Falls back to the command basename. + """ + # Walk args in reverse looking for a package-like token + for token in reversed(cmd): + # npm scoped package: @scope/mcp-server-foo → foo + m = re.match(r"@[\w-]+/(?:mcp-(?:server-)?)?(.+)", token) + if m: + return m.group(1) + # Plain package: mcp-server-foo → foo + m = re.match(r"mcp-(?:server-)?(.+)", token) + if m: + return m.group(1) + + # Fallback to command basename + return cmd[0].rsplit("/", 1)[-1] if cmd else "server" + + +def _split_argv(raw_args: list[str]) -> tuple[list[str], list[str]]: + """Split raw argv at ``--`` into (wrap_args, inline_cmd). + + Returns (wrap_args, []) if no ``--`` separator is found. + """ + try: + idx = raw_args.index("--") + return raw_args[:idx], raw_args[idx + 1 :] + except ValueError: + return raw_args, [] + + +def _connect_servers( + args: argparse.Namespace, + inline_cmd: list[str], +) -> _RustMCPManager: + """Build an MCPManager from all server sources (inline + config + discovery). + + Raises SystemExit if no servers could be connected. + """ + manager = _RustMCPManager() + connected = 0 + + # 1. Inline command from -- separator + if inline_cmd: + name = args.server_name or _derive_name(inline_cmd) + command = inline_cmd[0] + cmd_args = inline_cmd[1:] + try: + tool_count = manager.connect(name, command, cmd_args) + connected += 1 + print(f"MCP: connected to '{name}' ({tool_count} tools)", file=sys.stderr) + except Exception as exc: + print(f"MCP: failed to connect to '{name}': {exc}", file=sys.stderr) + + # 2. Config files (--config) + if args.config: + servers = discover_servers(config_paths=args.config) + for name, config in servers.items(): + env = {k: _expand_env_vars(str(v)) for k, v in config.get("env", {}).items()} + try: + tool_count = manager.connect( + name, config["command"], config.get("args", []), env + ) + connected += 1 + print( + f"MCP: connected to '{name}' ({tool_count} tools)", + file=sys.stderr, + ) + except Exception as exc: + print(f"MCP: failed to connect to '{name}': {exc}", file=sys.stderr) + + # 3. IDE discovery (--mcp / --mcp-config) + if args.mcp or args.mcp_config: + config_paths = args.mcp_config if args.mcp_config else None + servers = discover_servers(config_paths=config_paths) + for name, config in servers.items(): + # Skip if already connected (from inline or --config) + if name in manager.server_names: + continue + env = {k: _expand_env_vars(str(v)) for k, v in config.get("env", {}).items()} + try: + tool_count = manager.connect( + name, config["command"], config.get("args", []), env + ) + connected += 1 + print( + f"MCP: connected to '{name}' ({tool_count} tools)", + file=sys.stderr, + ) + except Exception as exc: + print(f"MCP: failed to connect to '{name}': {exc}", file=sys.stderr) + + if connected == 0: + print( + "eryx wrap: no MCP servers connected.\n" + "Specify servers with --, --config, or --mcp.", + file=sys.stderr, + ) + raise SystemExit(1) + + return manager + + +def wrap(argv: list[str] | None = None) -> int: + """Run the eryx wrap meta-server over stdio.""" + try: + from mcp.server.fastmcp import FastMCP + except ImportError: + print( + "eryx wrap requires the 'mcp' package.\n" + "Install it with: pip install 'pyeryx[serve]'\n" + "Or run with: uvx --with 'pyeryx[serve]' eryx wrap", + file=sys.stderr, + ) + return 1 + + raw_args = argv if argv is not None else sys.argv[1:] + wrap_args, inline_cmd = _split_argv(raw_args) + args = _build_parser().parse_args(wrap_args) + + # Connect to all wrapped MCP servers + try: + mcp_manager = _connect_servers(args, inline_cmd) + except SystemExit: + return 1 + + # Build session kwargs for the sandbox + session_kwargs: dict = {} + limits = make_resource_limits(args) + if limits is not None: + session_kwargs["execution_timeout_ms"] = limits.execution_timeout_ms + net = make_net_config(args) + if net is not None: + session_kwargs["network"] = net + if args.volume: + session_kwargs["volumes"] = args.volume + session_kwargs["mcp"] = mcp_manager + + # Mutable buffers for capturing output per-execution + stdout_chunks: list[str] = [] + stderr_chunks: list[str] = [] + session_kwargs["on_stdout"] = lambda chunk: stdout_chunks.append(chunk) + session_kwargs["on_stderr"] = lambda chunk: stderr_chunks.append(chunk) + + session = eryx.Session(**session_kwargs) + + # Build tool descriptions + all_tools = mcp_manager.list_tools() + tool_summary = ", ".join(t["name"].split(".")[-1] for t in all_tools) + + server = FastMCP("eryx-wrap") + + @server.tool( + description=( + "List tools available from wrapped MCP servers. " + "Returns tool names and descriptions by default; " + "set include_schemas=true for full input schemas." + ) + ) + def list_tools( + server: str | None = None, + include_schemas: bool = False, + ) -> str: + """List available tools from wrapped MCP servers.""" + tools = mcp_manager.list_tools() + + if server is not None: + tools = [t for t in tools if t["name"].startswith(f'mcp["{server}"].')] + + result = [] + for t in tools: + entry: dict = {"name": t["name"], "description": t["description"]} + if include_schemas: + entry["inputSchema"] = t["schema"] + result.append(entry) + + return json.dumps(result, indent=2) + + @server.tool( + description=( + "Call a tool on a wrapped MCP server. " + "Use server.tool or mcp[\"server\"].tool notation for the name. " + f"Available tools: {tool_summary}" + ) + ) + def call_tool(name: str, arguments: dict | None = None) -> str: + """Invoke a tool on a wrapped MCP server.""" + result = mcp_manager.call_tool(name, arguments) + if isinstance(result, str): + return result + return json.dumps(result, indent=2) + + @server.tool( + description=( + "Execute Python code in a persistent sandboxed environment. " + "State (variables, imports, functions) persists across calls. " + "All wrapped MCP tools are available via await, e.g.:\n" + ' data = await mcp["server"].tool(arg="value")\n' + "Use print() to produce output." + ) + ) + def execute_python(code: str, timeout_ms: int | None = None) -> str: + """Execute Python code in the eryx sandbox.""" + old_timeout = session.execution_timeout_ms + if timeout_ms is not None: + session.execution_timeout_ms = timeout_ms + + stdout_chunks.clear() + stderr_chunks.clear() + + try: + session.execute(code) + stdout = "".join(stdout_chunks) + stderr = "".join(stderr_chunks) + parts = [] + if stdout: + parts.append(stdout) + if stderr: + parts.append(f"[stderr]\n{stderr}") + return "\n".join(parts) if parts else "(no output)" + except ( + eryx.ExecutionError, + eryx.TimeoutError, + eryx.ResourceLimitError, + ) as exc: + stdout = "".join(stdout_chunks) + stderr = "".join(stderr_chunks) + parts = [] + if stdout: + parts.append(stdout) + if stderr: + parts.append(f"[stderr]\n{stderr}") + parts.append(str(exc)) + return "\n".join(parts) + finally: + if timeout_ms is not None: + session.execution_timeout_ms = old_timeout + + try: + server.run(transport="stdio") + finally: + mcp_manager.close() + + return 0 diff --git a/crates/eryx-python/src/mcp.rs b/crates/eryx-python/src/mcp.rs index c3f2888a..a7fdf0c8 100644 --- a/crates/eryx-python/src/mcp.rs +++ b/crates/eryx-python/src/mcp.rs @@ -212,6 +212,107 @@ impl MCPManager { }) } + /// Call a tool on a connected MCP server. + /// + /// The tool name can use either dot notation (``server.tool``) or bracket + /// notation (``mcp["server"].tool``). + /// + /// Args: + /// name: The qualified tool name (e.g. ``"fs.read_file"``). + /// arguments: Optional dict of arguments to pass to the tool. + /// + /// Returns: + /// The tool result as a Python object (parsed JSON). + /// + /// Raises: + /// ValueError: If the tool name format is invalid or the server/tool is not found. + /// RuntimeError: If the tool call fails. + #[pyo3(signature = (name, arguments=None))] + fn call_tool( + &self, + py: Python<'_>, + name: String, + arguments: Option>, + ) -> PyResult> { + let (server_name, tool_name) = + parse_tool_name(&name).map_err(|e| pyo3::exceptions::PyValueError::new_err(e))?; + + let conn = self + .connections + .iter() + .find(|c| c.name == server_name) + .ok_or_else(|| { + pyo3::exceptions::PyValueError::new_err(format!( + "unknown MCP server '{server_name}'. Connected servers: {}", + self.connections + .iter() + .map(|c| c.name.as_str()) + .collect::>() + .join(", ") + )) + })?; + + // Verify the tool exists on this server + if !conn.tools.iter().any(|t| t.name.as_ref() == tool_name) { + let available: Vec<&str> = conn.tools.iter().map(|t| t.name.as_ref()).collect(); + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "unknown tool '{tool_name}' on server '{server_name}'. Available: {}", + available.join(", ") + ))); + } + + // Convert Python dict to serde_json Map + let json_args: Option> = if let Some(dict) = arguments { + let val: Value = pythonize::depythonize(&dict).map_err(|e| { + pyo3::exceptions::PyValueError::new_err(format!("failed to convert arguments: {e}")) + })?; + match val { + Value::Object(map) => Some(map), + _ => { + return Err(pyo3::exceptions::PyValueError::new_err( + "arguments must be a dict", + )); + } + } + } else { + None + }; + + let peer = conn.service.peer().clone(); + let runtime = self.runtime.clone(); + let tool_name_cow: Cow<'static, str> = tool_name.to_string().into(); + + py.detach(|| { + let result_value = runtime.block_on(async { + let result = peer + .call_tool(CallToolRequestParams { + meta: None, + name: tool_name_cow, + arguments: json_args, + task: None, + }) + .await + .map_err(|e| { + pyo3::exceptions::PyRuntimeError::new_err(format!( + "MCP call_tool failed: {e}" + )) + })?; + + mcp_result_to_value(result) + })?; + + Python::attach(|py| { + pythonize::pythonize(py, &result_value) + .map(|obj| obj.unbind()) + .map_err(|e| { + pyo3::exceptions::PyRuntimeError::new_err(format!( + "failed to convert result: {e}" + )) + }) + }) + }) + } + fn __repr__(&self) -> String { let servers: Vec<&str> = self.connections.iter().map(|c| c.name.as_str()).collect(); let tool_count: usize = self.connections.iter().map(|c| c.tools.len()).sum(); @@ -223,6 +324,76 @@ impl MCPManager { } } +/// Parse a qualified tool name into (server, tool) components. +/// +/// Accepts: +/// - ``server.tool`` — dot notation +/// - ``mcp["server"].tool`` — bracket notation (as returned by `list_tools`) +fn parse_tool_name(name: &str) -> Result<(&str, &str), String> { + // Bracket notation: mcp["server"].tool + if let Some(rest) = name.strip_prefix("mcp[\"") { + if let Some((server, rest)) = rest.split_once("\"].") + && !rest.is_empty() + { + return Ok((server, rest)); + } + return Err(format!( + "invalid tool name '{name}': expected mcp[\"server\"].tool format" + )); + } + + // Dot notation: server.tool + if let Some((server, tool)) = name.split_once('.') + && !server.is_empty() + && !tool.is_empty() + { + return Ok((server, tool)); + } + + Err(format!( + "invalid tool name '{name}': expected server.tool or mcp[\"server\"].tool format" + )) +} + +/// Convert an MCP `CallToolResult` to a `serde_json::Value`. +fn mcp_result_to_value(result: rmcp::model::CallToolResult) -> PyResult { + // Check for error + if result.is_error == Some(true) { + let error_text: String = result + .content + .iter() + .filter_map(|c| c.raw.as_text().map(|t| t.text.as_str())) + .collect::>() + .join("\n"); + return Err(pyo3::exceptions::PyRuntimeError::new_err(format!( + "MCP tool error: {error_text}" + ))); + } + + // Extract text content from the result + let text_parts: Vec<&str> = result + .content + .iter() + .filter_map(|c| c.raw.as_text().map(|t| t.text.as_str())) + .collect(); + + // If there's structured content, prefer it + if let Some(structured) = result.structured_content { + return Ok(structured); + } + + // Try to parse the first text content as JSON + if text_parts.len() == 1 + && let Ok(parsed) = serde_json::from_str(text_parts[0]) + { + return Ok(parsed); + } + + // Return text content as a JSON object + let combined = text_parts.join("\n"); + Ok(serde_json::json!({ "text": combined })) +} + impl MCPManager { /// Convert all MCP tools into `DynamicCallback` instances. /// diff --git a/crates/eryx-python/tests/test_wrap.py b/crates/eryx-python/tests/test_wrap.py new file mode 100644 index 00000000..c504ca9e --- /dev/null +++ b/crates/eryx-python/tests/test_wrap.py @@ -0,0 +1,248 @@ +"""Integration tests for ``eryx wrap`` MCP meta-server. + +Starts the wrap server wrapping a mock MCP server, connects as a real MCP +client over stdio, and exercises list_tools / call_tool / execute_python. + +Requires the ``mcp`` package (``pip install 'pyeryx[serve]'``). +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + +MOCK_SERVER = str(Path(__file__).parent / "mock_mcp_server.py") +SERVER_NAME = "mock" + + +async def _run_with_session( + fn, + extra_args: list[str] | None = None, + server_name: str = SERVER_NAME, +): + """Start ``eryx wrap`` wrapping the mock server, run *fn(session)*, tear down.""" + args = ["-m", "eryx", "wrap", "--server-name", server_name] + if extra_args: + args.extend(extra_args) + args.extend(["--", sys.executable, MOCK_SERVER]) + + params = StdioServerParameters( + command=sys.executable, + args=args, + ) + async with stdio_client(params) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + return await fn(session) + + +# -- Tool discovery ---------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_exposes_three_meta_tools(): + """Wrap server exposes exactly list_tools, call_tool, and execute_python.""" + + async def body(session): + result = await session.list_tools() + names = sorted(t.name for t in result.tools) + assert names == ["call_tool", "execute_python", "list_tools"] + + await _run_with_session(body) + + +# -- list_tools -------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_list_tools_returns_wrapped_tools(): + """list_tools returns tools from the wrapped mock server.""" + + async def body(session): + result = await session.call_tool("list_tools", {}) + tools = json.loads(result.content[0].text) + names = [t["name"] for t in tools] + assert any("echo" in n for n in names) + assert any("add" in n for n in names) + + await _run_with_session(body) + + +@pytest.mark.asyncio +async def test_list_tools_include_schemas(): + """list_tools with include_schemas=true includes inputSchema.""" + + async def body(session): + result = await session.call_tool("list_tools", {"include_schemas": True}) + tools = json.loads(result.content[0].text) + assert len(tools) > 0 + assert "inputSchema" in tools[0] + + await _run_with_session(body) + + +@pytest.mark.asyncio +async def test_list_tools_filter_by_server(): + """list_tools with server filter returns only matching tools.""" + + async def body(session): + result = await session.call_tool("list_tools", {"server": SERVER_NAME}) + tools = json.loads(result.content[0].text) + assert len(tools) >= 2 # echo + add + for t in tools: + assert f'mcp["{SERVER_NAME}"].' in t["name"] + + await _run_with_session(body) + + +@pytest.mark.asyncio +async def test_list_tools_filter_unknown_server(): + """list_tools with unknown server filter returns empty list.""" + + async def body(session): + result = await session.call_tool("list_tools", {"server": "nonexistent"}) + tools = json.loads(result.content[0].text) + assert tools == [] + + await _run_with_session(body) + + +# -- call_tool --------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_call_tool_echo(): + """call_tool routes to the echo tool on the mock server.""" + + async def body(session): + result = await session.call_tool( + "call_tool", + {"name": f"{SERVER_NAME}.echo", "arguments": {"message": "hello wrap"}}, + ) + text = result.content[0].text + assert "hello wrap" in text + + await _run_with_session(body) + + +@pytest.mark.asyncio +async def test_call_tool_add(): + """call_tool routes to the add tool on the mock server.""" + + async def body(session): + result = await session.call_tool( + "call_tool", + {"name": f"{SERVER_NAME}.add", "arguments": {"a": 3, "b": 7}}, + ) + text = result.content[0].text + parsed = json.loads(text) + assert parsed["result"] == 10 + + await _run_with_session(body) + + +@pytest.mark.asyncio +async def test_call_tool_bracket_notation(): + """call_tool accepts bracket notation from list_tools output.""" + + async def body(session): + result = await session.call_tool( + "call_tool", + { + "name": f'mcp["{SERVER_NAME}"].echo', + "arguments": {"message": "bracket test"}, + }, + ) + text = result.content[0].text + assert "bracket test" in text + + await _run_with_session(body) + + +@pytest.mark.asyncio +async def test_call_tool_unknown_server(): + """call_tool with unknown server returns an error.""" + + async def body(session): + result = await session.call_tool( + "call_tool", + {"name": "nonexistent.echo", "arguments": {"message": "test"}}, + ) + text = result.content[0].text + assert "unknown" in text.lower() or "error" in text.lower() + + await _run_with_session(body) + + +# -- execute_python ---------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_execute_python_basic(): + """execute_python runs Python code and returns output.""" + + async def body(session): + result = await session.call_tool( + "execute_python", {"code": 'print("hello from wrap")'} + ) + assert "hello from wrap" in result.content[0].text + + await _run_with_session(body) + + +@pytest.mark.asyncio +async def test_execute_python_state_persistence(): + """Variables set in one execute_python call persist to the next.""" + + async def body(session): + await session.call_tool("execute_python", {"code": "x = 42"}) + result = await session.call_tool("execute_python", {"code": "print(x)"}) + assert "42" in result.content[0].text + + await _run_with_session(body) + + +@pytest.mark.asyncio +async def test_execute_python_mcp_access(): + """execute_python can call wrapped MCP tools via await.""" + + async def body(session): + result = await session.call_tool( + "execute_python", + {"code": f'result = await mcp["{SERVER_NAME}"].echo(message="from python")\nprint(result)'}, + ) + assert "from python" in result.content[0].text + + await _run_with_session(body) + + +@pytest.mark.asyncio +async def test_execute_python_no_output(): + """execute_python with no print returns (no output).""" + + async def body(session): + result = await session.call_tool("execute_python", {"code": "y = 1"}) + assert result.content[0].text == "(no output)" + + await _run_with_session(body) + + +# -- Custom server name ------------------------------------------------------ + + +@pytest.mark.asyncio +async def test_custom_server_name(): + """--server-name sets the server name for inline commands.""" + + async def body(session): + result = await session.call_tool("list_tools", {}) + tools = json.loads(result.content[0].text) + names = [t["name"] for t in tools] + assert any('mcp["myserver"].' in n for n in names) + + await _run_with_session(body, server_name="myserver")