Newer
Older
navi-1 / manuals / create_mcp_server.md

Writing MCP Servers for Navi

This manual describes how to create, test, register, and maintain MCP servers that extend Navi's capabilities. Read this before you start building a new server.

1. Philosophy: Why MCP instead of user tools?

MCP servers run in isolated processes and communicate via the Model Context Protocol. They cannot crash Navi's core, they can be reloaded without restarting the server, and they scale to complex external integrations (APIs, databases, browsers, etc.). The trade-off is slightly more boilerplate than a single tools/foo.py file.

Rule: Every new capability that is not trivial (more than a simple datetime or notes lookup) should be built as an MCP server.

2. Directory structure

MCP servers live under:

mcp-servers/<server_name>/
├── pyproject.toml
├── README.md
└── app/
    ├── __init__.py
    └── mcp_server.py
  • <server_name> — snake_case or kebab-case. Must match the key you will use in mcp_servers.d/<name>.json.
  • pyproject.toml — Python package metadata and dependencies.
  • app/mcp_server.py — the actual server code (FastMCP).

3. Creating a new server from the template

Use the built-in tool create_mcp_server (preferred) or copy the template manually:

cp -r mcp-servers/_template mcp-servers/my_server
cd mcp-servers/my_server
# Edit pyproject.toml: change name, description, add dependencies
# Edit app/mcp_server.py: add your tools and instructions

3.1 pyproject.toml

Minimal required fields:

[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"

[project]
name = "mcp-server-myserver"
version = "0.1.0"
description = "What this server does"
requires-python = ">=3.11"
dependencies = [
    "mcp>=1.27",
    "pydantic>=2.0",
    # add your own: httpx, asyncpg, playwright, etc.
]

[project.scripts]
mcp-server-myserver = "app.mcp_server:main"

[tool.setuptools.packages.find]
where = ["."]
include = ["app*"]

3.2 app/mcp_server.py

Read the template at mcp-servers/_template/app/mcp_server.py first. It contains a working hello-world server with extensive inline comments.

Key sections you must edit:

  1. INSTRUCTIONS — These are injected into Navi's system prompt. Describe:

    • What this server does and when to use it.
    • Recommended workflow (order of tool calls).
    • ABSOLUTE RULE about never bypassing these tools with filesystem/terminal.

    Write it in four parts, in this order:

    1. What the server does — one sentence.
    2. When to use it — the concrete scenarios, not a restatement of the summary.
    3. Workflow — the order the tools are meant to be called in, numbered.
    4. ABSOLUTE RULE — that operations covered by this server must not be done through filesystem, terminal, code_exec or direct file access.
    MyServer provides X and Y tools.
    
    Use it when the task involves:
    - doing something only this server handles;
    - ...
    
    Workflow:
    1. tool_a — step one.
    2. tool_b — step two.
    
    ABSOLUTE RULE — NEVER bypass MCP tools:
    You MUST NOT use filesystem, terminal, code_exec, or any direct file access for operations covered by this server. Use only the MCP tools listed above.
  2. mcp = FastMCP("name", instructions=INSTRUCTIONS) — The name should match the directory key.

  3. Tool functions — Each tool:

    • Is an async def.
    • Uses @mcp.tool(name="tool_name").
    • Parameters use Annotated[T, Field(description="...")] — never plain types.
    • Returns a plain str (JSON string for structured data is fine).
    • Raises on real errors.
    • Validates required params explicitly.

Example:

@mcp.tool(name="search_docs")
async def search_docs_tool(
    query: Annotated[str, Field(description="Search query string.")],
    limit: Annotated[int, Field(description="Max results.")] = 10,
) -> str:
    """Search the documentation index."""
    if not query.strip():
        raise ValueError("query is required and cannot be empty.")
    results = await _do_search(query, limit)
    return json.dumps(results, ensure_ascii=False, indent=2)

4. Environment and installation

After creating files, you must:

  1. Create a virtual environment:

    python -m venv .venv
    source .venv/bin/activate
    pip install -e .
  2. Smoke-test startup and read the exit code — it is the whole signal:

    cd mcp-servers/<name> && timeout 5 .venv/bin/python -m app.mcp_server; echo "EXIT_CODE=$?"

    | Exit code | Meaning | |---|---| | 124 | timeout killed a server that was still running. Success. | | 0 | The server exited on its own before 5 seconds. Failure — usually main() is missing its parentheses at the bottom of the file, but see the note below before you go looking for one. | | anything else | Traceback or crash. Read it and fix. |

    Never run the server without timeout: an MCP server blocks forever and the terminal hangs. Repeat until you get 124, then move on to registration.

    A 0 with no output at all often means stdin was already closed rather than a crash: an stdio server exits cleanly the moment it reads EOF, and a command run through terminal or code_exec can hand it a closed pipe. Hold stdin open to tell the two apart — sleep 30 | timeout 5 .venv/bin/python -m app.mcp_server gives 124 for a healthy server — and only then go hunting for the missing main().

5. Registering the server in Navi

Create a file mcp_servers.d/<name>.json in the project root. The filename (without .json) becomes the server name. Example for a server named my_server:

{
  "transport": "stdio",
  "command": "./mcp-servers/my_server/.venv/bin/python",
  "args": ["-m", "app.mcp_server"],
  "cwd": "./mcp-servers/my_server",
  "env": {
    "MCP_TRANSPORT": "stdio"
  },
  "groups": {
    "default": ["search_docs", "read_doc"]
  },
  "instructions": "Optional extra instructions merged with the server's own INSTRUCTIONS."
}

Critical fields:

  • command — path to the venv's Python binary.
  • cwd — path to the server directory.
  • The filename must be <name>.json (e.g. my_server.json).
  • args — usually ["-m", "app.mcp_server"].
  • groups — organize tools into named groups so profiles can reference them cleanly.

Write command and cwd relative to the project root, as ./mcp-servers/<name>/.... This file is tracked in git: an absolute path would name the machine you happened to write it on, and the server would then fail to start anywhere else with [Errno 2] No such file or directory. Relative paths are resolved against the project root — the directory holding mcp_servers.d/ — when the server is connected. Absolute paths still work, so an existing config is not broken; new ones should be relative. In args and env, a value is resolved the same way only when it starts with ./ or ../ (those fields also carry flags and URLs, which must be left alone).

Never put a live credential in this file. It is tracked in git, so a token written here is in history for good, and GET /admin/mcp/config hands the same value to the admin client. Put a placeholder in the config and the value in .env (chmod 600, untracked — the canonical store is gnexus-creds):

"headers": { "Authorization": "Bearer ${NAVI_MCP_MY_SERVER_TOKEN}" }

${VAR} is substituted in headers and env values when the server is connected; the environment wins over .env, a blank value counts as missing, and a missing one refuses the connection instead of dropping the header. See docs/mcp.md#secrets, and add the variable name to deploy/env.template and .env.example.

After editing mcp_servers.d/<name>.json, call reload_tools to connect the server and register its tools.

6. Testing

reload_tools comes first, always. A newly written config is not read and a newly registered server is not connected until reload_tools runs: it re-reads mcp_servers.d/, reconnects every server, and registers their tools. Calling test_mcp_tool before it fails with "not connected" every time and burns an iteration. The auto-registration some tools do when they write the config does not connect anything.

reload_tools is likewise what ends every later edit — to the server code or to its config — since nothing else picks those up. It is not needed for tool arguments, nor for data files the server reads per call. The server's own INSTRUCTIONS, though, is read at connect time and merged into the system prompt, so editing it does need the reconnect.

6.1 Check connection

Call mcp_status. You should see your server as connected with the correct tool count. This step is discovery only — it tells you the server is up, never that a tool works.

6.2 Test each tool

Call test_mcp_tool for every tool your server exposes:

test_mcp_tool(server_name="my_server", tool_name="search_docs", arguments={"query": "hello", "limit": 3})

If any tool fails, read the error output, fix the code in app/mcp_server.py, and repeat — which means back to reload_tools before the next test.

If test_mcp_tool answers "MCP server '' is not connected", work the list in order rather than retrying blindly:

  1. mcp_status — is the server listed, and as connected or disconnected?
  2. Read mcp_servers.d/<name>.json and check that command and cwd point at paths that exist — relative paths resolve against the project root, so a wrong prefix lands elsewhere.
  3. reload_tools.
  4. test_mcp_tool again.
  5. Still failing — the code is at fault: back to syntax check and the timeout smoke test, then repeat from here.

6.3 Manual stderr inspection

If mcp_status shows disconnected but the code looks correct, inspect stderr manually:

cd mcp-servers/my_server
.venv/bin/python -m app.mcp_server 2>&1 | head -n 20

6.4 Code review before connecting

Before the first reload_tools, run a review pass over the file with filesystem action query — it reads the file for you and answers, instead of pulling the whole thing into context:

filesystem(action="query", path="mcp-servers/<name>/app/mcp_server.py",
  question="Check these 4 critical patterns: 1) main() is called with parentheses at the very end, 2) all @mcp.tool decorators appear before main(), 3) every parameter uses Annotated[..., Field(description=...)], 4) INSTRUCTIONS is not empty.")

Those four are what the timeout smoke test and mcp_status cannot see: a server whose tools are defined after mcp.run() starts fine, stays connected, and silently exposes nothing.

7. Updating an MCP server

  1. Edit the code in mcp-servers/<name>/app/mcp_server.py.
  2. (Optional) If you added new dependencies, edit pyproject.toml and run pip install -e . inside the venv.
  3. Call reload_tools to reconnect the server and re-register tools.
  4. Call test_mcp_tool to verify.

8. Deleting an MCP server

  1. Remove the server directory or move it to a backup location.
  2. Remove the entry from mcp_servers.d/<name>.json.
  3. Call reload_tools.

9. Connecting an external MCP server

If the server was written by someone else:

  1. Clone or place the server code on disk.
  2. Create its venv and install dependencies.
  3. Read its README to learn tool names and required environment variables.
  4. Add an entry to mcp_servers.d/<name>.json with the correct command, cwd, args, and env.
  5. Define groups mapping the tools into logical sets.
  6. Call reload_tools.
  7. Call test_mcp_tool for a representative tool.

10. Common mistakes and debugging

Symptom Cause Fix
mcp_status shows disconnected Wrong command or cwd path Check the path; if it is relative it resolves against the project root
Server works on one machine, [Errno 2] on another Absolute path in command/cwd/env Rewrite it as ./mcp-servers/<name>/...
Traceback on startup Syntax error or missing import Run python -m py_compile app/mcp_server.py
test_mcp_tool returns is_error=True Tool raised an exception Fix the tool logic; check parameter validation
Tool schema missing descriptions Used plain types instead of Annotated[..., Field(...)] Add Field(description=...) to every parameter
Navi never calls the server Profile does not map the server in mcp_servers Edit the profile's config.json and add the server groups
Navi bypasses MCP with filesystem INSTRUCTIONS missing ABSOLUTE RULE Add explicit rule in server INSTRUCTIONS
test_mcp_tool: "not connected" reload_tools not called after writing the config reload_tools, then test again
mcp_status: connected, 0 tools Tools declared after mcp.run() Move every @mcp.tool above main()

10b. Delegating the implementation

A sub-agent is a good fit for "write this server" when the tool set is large, the logic is involved, or an external API is in play — the write-debug loop would otherwise run 10+ tool calls in the main context. Give it, in the briefing:

  • the exact server directory and file to edit;
  • every tool name with its parameters, description and expected return format;
  • how to verify: python -m py_compile, then the timeout smoke test with exit code 124 as the pass condition.

The sub-agent cannot register, connect or test the server: reload_tools, test_mcp_tool and mcp_status are the main agent's. It also does not inherit the main agent's memory or conversation — write the briefing into the context_transfer scratchpad section before spawning, since that is injected automatically. Registration, connection, per-tool testing and the report stay inline.

11. Workflow checklist for Navi

When asked to create a new MCP server:

  1. Read this manual (manuals/create_mcp_server.md).
  2. Read the template (mcp-servers/_template/app/mcp_server.py).
  3. Call create_mcp_server(name=..., description=...) to scaffold the directory.
  4. Edit app/mcp_server.py iteratively using filesystem — tools and a full INSTRUCTIONS (§3.2; it is read at connect time, so it must be there before step 9).
  5. Code review: filesystem action query for the four critical patterns (§6.4).
  6. Validate syntax: code_exec or terminal with python -m py_compile ....
  7. Smoke-test startup under timeout until the exit code is 124 (§4.2).
  8. Edit mcp_servers.d/<name>.json via filesystem to register the server.
  9. Call reload_tools — mandatory before any test_mcp_tool.
  10. Call mcp_status to confirm the server is connected.
  11. Call test_mcp_tool for every tool.
  12. Report results to the user.