diff --git a/navi/tools/__init__.py b/navi/tools/__init__.py index 8ace7c1..6ea9689 100644 --- a/navi/tools/__init__.py +++ b/navi/tools/__init__.py @@ -1,5 +1,6 @@ from .base import Tool, ToolResult from .code_exec import CodeExecTool +from .create_mcp_server import CreateMcpServerTool from .delete_tool import DeleteToolTool from .filesystem import FilesystemTool from .image_view import ImageViewTool @@ -7,6 +8,7 @@ from .spawn_agent import SpawnAgentTool from .terminal import TerminalTool from .memory import MemoryTool +from .test_mcp_tool import TestMcpToolTool from .test_tool import TestToolTool from .todo import TodoTool from .scratchpad import ScratchpadTool @@ -17,6 +19,7 @@ __all__ = [ "Tool", "ToolResult", + "CreateMcpServerTool", "DeleteToolTool", "FilesystemTool", "CodeExecTool", @@ -24,6 +27,7 @@ "SshExecTool", "ImageViewTool", "MemoryTool", + "TestMcpToolTool", "TestToolTool", "SpawnAgentTool", "TodoTool", diff --git a/navi/tools/create_mcp_server.py b/navi/tools/create_mcp_server.py new file mode 100644 index 0000000..7a0a829 --- /dev/null +++ b/navi/tools/create_mcp_server.py @@ -0,0 +1,320 @@ +"""Built-in tool that scaffolds a new MCP server directory.""" + +import asyncio +import json +import shutil +import subprocess +from pathlib import Path + +from navi.config import settings + +from .base import Tool, ToolResult + +# Template for pyproject.toml — placeholders {name}, {description}, {deps} +_PYPROJECT_TEMPLATE = """[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "mcp-server-{name}" +version = "0.1.0" +description = "{description}" +requires-python = ">=3.11" +dependencies = [ + "mcp>=1.27", + "pydantic>=2.0", +{deps} +] + +[project.scripts] +mcp-server-{name} = "app.mcp_server:main" + +[tool.setuptools.packages.find] +where = ["."] +include = ["app*"] +""" + +_README_TEMPLATE = """# MCP Server: {name} + +{description} + +## Tools + +TODO: list tools and their purposes. + +## Setup + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -e . +``` + +## Navi registration + +Create a file `mcp_servers.d/{name}.json` with the server config: +- transport: stdio +- command: absolute path to `.venv/bin/python` +- args: `["-m", "app.mcp_server"]` +- cwd: absolute path to this directory +- groups: map tool names to logical groups +""" + + +class CreateMcpServerTool(Tool): + name = "create_mcp_server" + description = ( + "Scaffold a new MCP server directory under mcp-servers// with " + "pyproject.toml, app/mcp_server.py (from the template with inline comments), " + "README.md, and a virtual environment. Then installs dependencies. " + "Optionally auto-registers the server by creating mcp_servers.d/.json. " + "After this returns, you must still edit app/mcp_server.py and call reload_tools." + ) + parameters = { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Server directory name (snake_case or kebab-case). Will become mcp-servers//.", + }, + "description": { + "type": "string", + "description": "Short description for pyproject.toml and README.", + }, + "dependencies": { + "type": "array", + "items": {"type": "string"}, + "description": "Extra pip dependencies (e.g. httpx, asyncpg). mcp and pydantic are added automatically.", + }, + "auto_register": { + "type": "boolean", + "description": "If true, create mcp_servers.d/.json automatically so no manual filesystem edit is needed.", + }, + }, + "required": ["name", "description"], + } + + async def execute(self, params: dict) -> ToolResult: + name = (params.get("name") or "").strip() + description = params.get("description", "") + dependencies: list[str] = params.get("dependencies") or [] + auto_register: bool = params.get("auto_register", False) + + if not name: + return ToolResult(success=False, output="name is required.", error="missing name") + if name.startswith("_"): + return ToolResult(success=False, output="name must not start with '_'.", error="invalid name") + + base_dir = Path("mcp-servers") / name + if base_dir.exists(): + return ToolResult( + success=False, + output=f"Directory already exists: {base_dir}", + error="already exists", + ) + + # 1. Create directories + app_dir = base_dir / "app" + app_dir.mkdir(parents=True) + + # 2. Write pyproject.toml + deps_lines = "" + if dependencies: + deps_lines = "\n".join(f' "{d}",' for d in dependencies) + pyproject = _PYPROJECT_TEMPLATE.format( + name=name, + description=description.replace('"', '\\"'), + deps=deps_lines, + ) + (base_dir / "pyproject.toml").write_text(pyproject, encoding="utf-8") + + # 3. Copy template mcp_server.py + template_server = Path("mcp-servers") / "_template" / "app" / "mcp_server.py" + if template_server.exists(): + server_code = template_server.read_text(encoding="utf-8") + else: + server_code = _FALLBACK_SERVER_TEMPLATE + (app_dir / "mcp_server.py").write_text(server_code, encoding="utf-8") + + # 4. __init__.py + (app_dir / "__init__.py").write_text("", encoding="utf-8") + + # 5. README + readme = _README_TEMPLATE.format(name=name, description=description) + (base_dir / "README.md").write_text(readme, encoding="utf-8") + + # 6. Create venv and install (off-loaded to a thread to avoid any + # event-loop/subprocess interaction issues under uvicorn/anyio). + abs_dir = base_dir.resolve() + venv_dir = abs_dir / ".venv" + python_bin = venv_dir / "bin" / "python" + pip_bin = venv_dir / "bin" / "pip" + + def _setup() -> tuple[bool, str]: + """Return (ok, error_msg). Runs in a worker thread.""" + # --- Create venv --- + result = subprocess.run( + ["python", "-m", "venv", str(venv_dir)], + capture_output=True, + text=True, + timeout=30.0, + ) + if result.returncode != 0: + return False, f"venv creation failed:\n{result.stderr}" + + # --- Install deps --- + install_args = [ + str(pip_bin), "install", "-e", str(base_dir), + "mcp>=1.27", "pydantic>=2.0", + ] + for dep in dependencies: + install_args.append(dep) + + result = subprocess.run( + install_args, + capture_output=True, + text=True, + timeout=120.0, + ) + if result.returncode != 0: + return False, f"pip install failed:\n{result.stderr}" + + return True, "" + + try: + ok, err_msg = await asyncio.wait_for( + asyncio.to_thread(_setup), + timeout=150.0, + ) + if not ok: + return ToolResult( + success=False, + output=err_msg, + error="setup failed", + ) + except asyncio.TimeoutError: + return ToolResult( + success=False, + output="Setup timed out (venv or pip install took too long).", + error="timeout", + ) + except Exception as exc: + return ToolResult( + success=False, + output=f"Setup error: {exc}", + error="setup error", + ) + + # 7. Verify syntax + server_py_path = app_dir / "mcp_server.py" + try: + import py_compile + py_compile.compile(str(server_py_path), doraise=True) + except Exception as exc: + return ToolResult( + success=False, + output=f"Syntax check failed (unexpected — template should be valid): {exc}", + error="syntax error", + ) + + # 8. Optional auto-register in mcp_servers.d/ + register_note = "" + if auto_register: + from navi.mcp.config import McpServerConfig, save_mcp_servers, load_mcp_servers + try: + configs = load_mcp_servers() + configs[name] = McpServerConfig( + transport="stdio", + command=str(python_bin), + args=["-m", "app.mcp_server"], + cwd=str(abs_dir), + env={"MCP_TRANSPORT": "stdio"}, + groups={"default": []}, + ) + save_mcp_servers(configs) + register_note = ( + f"\nAuto-registered in mcp_servers.d/{name}.json.\n" + f"Call reload_tools to connect.\n" + ) + except Exception as exc: + register_note = ( + f"\nWARNING: auto_register failed: {exc}\n" + f"You must manually create mcp_servers.d/{name}.json.\n" + ) + + # Return connection instructions + output = ( + f"Created MCP server at: {abs_dir}\n\n" + f"Next steps:\n" + f"1. Edit {abs_dir / 'app' / 'mcp_server.py'} — add your tools and INSTRUCTIONS.\n" + ) + if not auto_register: + output += ( + f"2. Create mcp_servers.d/{name}.json with:\n" + f'{{\n' + f' "transport": "stdio",\n' + f' "command": "{python_bin}",\n' + f' "args": ["-m", "app.mcp_server"],\n' + f' "cwd": "{abs_dir}",\n' + f' "env": {{"MCP_TRANSPORT": "stdio"}},\n' + f' "groups": {{\n' + f' "default": []\n' + f' }}\n' + f'}}\n' + f"3. Call reload_tools to connect.\n" + f"4. Call test_mcp_tool to verify each tool.\n" + ) + else: + output += register_note + "2. Call test_mcp_tool to verify each tool.\n" + return ToolResult(success=True, output=output) + + +_FALLBACK_SERVER_TEMPLATE = '''"""MCP server — replace with your own tools and instructions.""" + +from __future__ import annotations + +import json +import os +from typing import Annotated, Any + +from mcp.server.fastmcp import FastMCP +from pydantic import Field + +INSTRUCTIONS = """ +Replace this with instructions for Navi. +Describe what this server does, when to use it, and the workflow. +Add an ABSOLUTE RULE about never bypassing these tools. +""".strip() + +mcp = FastMCP("server", instructions=INSTRUCTIONS) + + +def _json(data: Any) -> str: + return json.dumps(data, ensure_ascii=False, indent=2) + + +@mcp.tool(name="hello") +async def hello_tool( + name: Annotated[str, Field(description="Name to greet.")], +) -> str: + """Say hello to someone.""" + return f"Hello, {name}!" + + +@mcp.tool(name="add") +async def add_tool( + a: Annotated[int, Field(description="First number.")], + b: Annotated[int, Field(description="Second number.")], +) -> str: + """Add two numbers.""" + return _json({"a": a, "b": b, "result": a + b}) + + +def main() -> None: + transport = os.environ.get("MCP_TRANSPORT", "stdio") + mcp.run(transport=transport) + + +if __name__ == "__main__": + main() +''' diff --git a/navi/tools/test_mcp_tool.py b/navi/tools/test_mcp_tool.py new file mode 100644 index 0000000..f4c061d --- /dev/null +++ b/navi/tools/test_mcp_tool.py @@ -0,0 +1,125 @@ +"""Built-in tool to test a single MCP tool call in isolation.""" + +import asyncio + +from .base import Tool, ToolResult + + +class TestMcpToolTool(Tool): + name = "test_mcp_tool" + description = ( + "Execute a single MCP tool call for testing. " + "Pass server_name, tool_name, and arguments. " + "Returns the raw output and whether the tool reported an error. " + "Always use this after writing or editing an MCP server to verify " + "each tool works before reporting success to the user. " + "Do NOT use mcp_status for testing individual tools — mcp_status is for discovery only." + ) + parameters = { + "type": "object", + "properties": { + "server_name": { + "type": "string", + "description": "MCP server name as listed in mcp_servers.d/.json.", + }, + "tool_name": { + "type": "string", + "description": "Tool name exposed by the server (without mcp: prefix).", + }, + "arguments": { + "type": "object", + "description": "Arguments dict to pass to the tool. Omit or pass {} for tools with no required params.", + }, + }, + "required": ["server_name", "tool_name"], + } + + def __init__(self, mcp_manager=None) -> None: + self._mcp_manager = mcp_manager + + async def execute(self, params: dict) -> ToolResult: + server_name = (params.get("server_name") or "").strip() + tool_name = (params.get("tool_name") or "").strip() + arguments: dict = params.get("arguments") or {} + + if not server_name: + return ToolResult(success=False, output="server_name is required.", error="missing server_name") + if not tool_name: + return ToolResult(success=False, output="tool_name is required.", error="missing tool_name") + + manager = self._mcp_manager + if manager is None: + # Fallback to module-level global in case startup wiring was + # skipped because of an earlier exception in the same block. + from navi.api.deps import _mcp_manager as _global_mcp_manager + manager = _global_mcp_manager + if manager is None: + return ToolResult( + success=False, + output="MCP manager not available. Is the server running and MCP configured?", + error="no manager", + ) + + # Quick check: is the server even connected? + client = manager.clients.get(server_name) + if client is None: + return ToolResult( + success=False, + output=( + f"MCP server '{server_name}' is not connected.\n\n" + "Possible causes:\n" + "1. The server config file is missing from mcp_servers.d/ — run create_mcp_server again.\n" + "2. The server exited immediately on startup (check that main() is called at the bottom of mcp_server.py).\n" + "3. The server threw a traceback on startup — run `timeout 5 .venv/bin/python -m app.mcp_server` to see it.\n\n" + "Next steps:\n" + "1. Call `mcp_status` to see which servers are listed.\n" + "2. Inspect `mcp_servers.d/.json` for correct absolute paths.\n" + "3. Call `reload_tools` to reconnect.\n" + "4. If still failing, read mcp_server.py and verify main() is called." + ), + error="not_connected", + ) + + try: + output, is_error = await asyncio.wait_for( + manager.call_tool(server_name, tool_name, arguments), + timeout=30.0, + ) + except asyncio.TimeoutError: + return ToolResult( + success=False, + output="Tool call timed out after 30 seconds.", + error="timeout", + ) + except Exception as exc: + import traceback as _tb + hint = "" + if "not connected" in str(exc).lower(): + hint = "\n\nHint: The server disconnected during the call. This usually means the server process crashed. Check the server code for runtime errors." + return ToolResult( + success=False, + output=f"Tool call failed: {exc}{hint}\n\nTraceback:\n{_tb.format_exc()}", + error="call failed", + ) + + if is_error: + return ToolResult( + success=False, + output=f"Tool reported an error.\n\nOutput:\n{output}", + error="tool error", + ) + + if not output or output.strip() == "": + return ToolResult( + success=True, + output=( + "Tool returned successfully but the response was EMPTY.\n\n" + "This is suspicious — the tool may have an early return or silent failure. " + "Check the tool implementation for missing return values or empty branches." + ), + ) + + return ToolResult( + success=True, + output=f"OK — tool returned successfully.\n\nOutput:\n{output}", + )