Newer
Older
navi-1 / navi / tools / create_mcp_server.py
"""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 ._internal.base import Tool, ToolContext, 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/<name>/ 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/<name>.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/<name>/.",
            },
            "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/<name>.json automatically so no manual filesystem edit is needed.",
            },
        },
        "required": ["name", "description"],
    }

    async def execute(self, params: dict, ctx: ToolContext | None = None) -> 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()
'''