Book a call
BUILD40mVERIFIED 2026-08-03 · CLAUDE CODE 2.1.220 · CODEX CLI 0.146.0 · GITHUB COPILOT CLI 1.0.77 · KIMI CODE CLI 0.31.1

Your first server: one tool, one schema

One tool, one schema that says exactly what the model may pass, and the standalone run that proves the server works before any harness is involved.

What you're building

This is the "build" chapter so let's describe what we build:

  • A single stdio server, seventy-odd lines of Python, exposing exactly one tool that retrieves deployment history for a service. This way we can ask it what's been released to production recently and if it worked for the checkout service

  • To make this exercise fast we opted to keep the deploy log as an SQLite file on our computer so everything runs in minutes on our machine. In your case the backing store is just whatever you read using your browser at the moment — the issue tracker, the error dashboard, the warehouse etc. Same process, one tool, one schema, stdio

  • No HTTP transport, no authentication, no resources, no prompts, not even more than a single tool — we're purposefully aiming for a one-feature first server in this chapter; the previous lesson makes the case for why number of tools matter

Requirements are Python 3.10+ and location to create a virtual environment

The import every tutorial still gets wrong

We'd say this is the number-one thing that makes people waste the first twenty minutes, let's cover it:

BASH
uv venv .venv
uv pip install mcp
.venv/bin/python -c "import importlib.metadata as m; print(m.version('mcp'))"

This prints 2.0.0 on our machine for 2026-08-03

Now, if you try to import from this package it's not gonna work as expected:

BASH
.venv/bin/python -c "from mcp.server.fastmcp import FastMCP"
TEXT
ModuleNotFoundError: No module named 'mcp.server.fastmcp'

This yields ModuleNotFoundError because fastmcp is a submodule in mcp (see the dot), and it was removed in v2. The new class under mcp.server is MCPServer:

PYTHON
from mcp.server import MCPServer

The README is blunt about the consequence:

Since pip install mcp now installs 2.x, keep a <2 upper bound on your requirement (for example mcp>=1.28,<2) until you've migrated.

This way if you were following some older tutorial, you can version-pin instead of wasting time with the import; if you're starting a new project, go for v2 and MCPServer

The server

The entire thing is only two files. The first one is a seed script that creates a sample table in an SQLite file with deploys for two services — checkout and billing. There's a version, timestamp, status (succeeded or failed), and duration in seconds:

PYTHON
import sqlite3

db = sqlite3.connect("deploys.db")
db.executescript("""
CREATE TABLE IF NOT EXISTS deploys (
  service TEXT NOT NULL, version TEXT NOT NULL, deployed_at TEXT NOT NULL,
  status TEXT NOT NULL, duration_s INTEGER NOT NULL
);
INSERT INTO deploys VALUES
 ('checkout','1.4.2','2026-08-01T09:12:00Z','succeeded',94),
 ('checkout','1.4.1','2026-07-30T16:40:00Z','failed',31),
 ('checkout','1.4.0','2026-07-29T11:02:00Z','succeeded',88),
 ('billing','2.9.0','2026-07-31T08:15:00Z','succeeded',142),
 ('billing','2.8.7','2026-07-24T13:55:00Z','failed',12);
""")
db.commit()

And here's the server itself, a 75-lines-long file in tools/deploy_log.py that we'll end up with in this lesson:

PYTHON
"""One tool over the deploy log: read-only, no SQL from the caller."""

import os
import sqlite3
from typing import Annotated, Literal

from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from pydantic import BaseModel, Field

DB_PATH = os.environ.get("DEPLOY_DB", "deploys.db")

mcp = MCPServer("deploy-log", version="0.1.0")


class Deploy(BaseModel):
    """One row of the deploy log."""

    service: str
    version: str
    deployed_at: str
    status: str
    duration_s: int


def connect() -> sqlite3.Connection:
    # mode=ro is the guarantee, not a comment: this connection cannot write.
    db = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True)
    db.row_factory = sqlite3.Row
    return db


@mcp.tool(
    title="Deploy history",
    description=(
        "Recent deploys of one service, newest first. Read-only. "
        "Use this instead of guessing from the changelog or the git log when you need to know "
        "what actually reached production, when, and whether it succeeded."
    ),
)
def deploy_history(
    service: Annotated[
        str,
        Field(description="Exact service name, e.g. 'checkout'. Case-sensitive."),
    ],
    status: Annotated[
        Literal["succeeded", "failed", "any"],
        Field(description="Filter by outcome. Defaults to any."),
    ] = "any",
    limit: Annotated[
        int,
        Field(ge=1, le=50, description="How many rows to return, newest first."),
    ] = 10,
) -> list[Deploy]:
    with connect() as db:
        known = [r["service"] for r in db.execute("SELECT DISTINCT service FROM deploys")]
        if service not in known:
            raise ToolError(
                f"No service named {service!r}. Known services: {', '.join(sorted(known))}."
            )

        sql = "SELECT * FROM deploys WHERE service = ?"
        args: list[object] = [service]
        if status != "any":
            sql += " AND status = ?"
            args.append(status)
        sql += " ORDER BY deployed_at DESC LIMIT ?"
        args.append(limit)

        return [Deploy(**dict(row)) for row in db.execute(sql, args)]


if __name__ == "__main__":
    mcp.run()

It's read-only by design, so the caller can't send any SQL. We set the path to the database from DEPLOY_DB environment variable (with "deploys.db" as a fallback) and define Deploy class that represents a single log row:

PYTHON
class Deploy(BaseModel):
    """One row of the deploy log."""

    service: str
    version: str
    deployed_at: str
    status: str
    duration_s: int

Then we connect to it, making the connection read-only using SQLite's mode=ro (the engine enforces this), and define a tool with title and description that says the model should use this tool instead of trying to guess from the changelog or git log what's been released when and if it worked:

PYTHON
def connect() -> sqlite3.Connection:
    # mode=ro is the guarantee, not a comment: this connection cannot write.
    db = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True)
    db.row_factory = sqlite3.Row
    return db


@mcp.tool(
    title="Deploy history",
    description=(
        "Recent deploys of one service, newest first. Read-only. "
        "Use this instead of guessing from the changelog or the git log when you need to know "
        "what actually reached production, when, and whether it succeeded."
    ),
)

In the handler we take parameters for service (the exact name, case-sensitive), status (succeeded/failed/any; any by default) and limit (1–50, 10 by default), query the db, return the results newest first:

PYTHON
def deploy_history(
    service: Annotated[
        str,
        Field(description="Exact service name, e.g. 'checkout'. Case-sensitive."),
    ],
    status: Annotated[
        Literal["succeeded", "failed", "any"],
        Field(description="Filter by outcome. Defaults to any."),
    ] = "any",
    limit: Annotated[
        int,
        Field(ge=1, le=50, description="How many rows to return, newest first."),
    ] = 10,
) -> list[Deploy]:

And raise if the service is unknown:

PYTHON
    with connect() as db:
        known = [r["service"] for r in db.execute("SELECT DISTINCT service FROM deploys")]
        if service not in known:
            raise ToolError(
                f"No service named {service!r}. Known services: {', '.join(sorted(known))}."
            )

It's 75 lines, including imports and blank lines. No transport code needed — run method sets the transport to stdio by default

The schema is the contract

But there's something really important about that signature we mustn't miss: it's a schema. As in JSON Schema. It's a decoration that the server's inner machinery transforms into the JSON Schema that the model consumes. The schema is what stands between the model and any attempt of calling it with invalid data — have a look before you assume.

To see this in action, run:

BASH
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}' \
  | .venv/bin/python tools/deploy_log.py

And you'll see:

JSON
{"type":"object","properties":{
  "service":{"description":"Exact service name, e.g. 'checkout'. Case-sensitive.","title":"Service","type":"string"},
  "status":{"default":"any","description":"Filter by outcome. Defaults to any.","enum":["succeeded","failed","any"],"title":"Status","type":"string"},
  "limit":{"default":10,"description":"How many rows to return, newest first.","maximum":50,"minimum":1,"title":"Limit","type":"integer"}},
 "required":["service"],"title":"deploy_historyArguments"}

So let's explore the translation:

  • This is a Literal of three values — it becomes an enum, so the model can't hallucinate a fourth option like "ok" and call it status:

    PYTHON
    Literal["succeeded", "failed", "any"]
  • This is a Field with a ge and an le — they become minimum and maximum, so the model can't ask for 10 thousand rows:

    PYTHON
    Field(ge=1, le=50)
  • This is a Field description — it becomes per-field description, which is the only channel through which things like case-sensitivity reaches the model:

    PYTHON
    Field(description=...)
  • These parameters don't have defaults — they end up in required; these do have defaults — they carry their default through:

    PYTHON
        service: Annotated[
            str,
            Field(description="Exact service name, e.g. 'checkout'. Case-sensitive."),
        ],
        status: Annotated[
            Literal["succeeded", "failed", "any"],
            Field(description="Filter by outcome. Defaults to any."),
        ] = "any",
        limit: Annotated[
            int,
            Field(ge=1, le=50, description="How many rows to return, newest first."),
        ] = 10,

We also get the outputSchema from the list-of-Deploy return annotation, and structuredContent with the results, what the client actually sends to the model

But there's also:

  • This: we never set additionalProperties to false, so the model can pass any other parameter and it'll just get silently ignored — the call comes back with normal results and isError false, and nothing tells it that it made the argument up:

    JSON
    {"name":"deploy_history","arguments":{"service":"checkout","nonsense":1}}

    You can force strictness by taking a single pydantic model as the argument with model_config = ConfigDict(extra="forbid"), but then every parameter drops a level under that model's name and behind a $ref, which is a worse shape for the thing that has to call it. Our preference is the flat signature plus validation in the body

  • And this: the client might refuse a tool name if it doesn't like it. The SDK's tool_name_validation.py does check, against ^[A-Za-z0-9._-]{1,128}$, but the warning it emits ends "Tool registration will proceed, but this may cause compatibility issues" — so it warns and carries on. The spec is specific about the safe set:

The following SHOULD be the only allowed characters: uppercase and lowercase ASCII letters (A-Z, a-z), digits (0-9), underscore (_), hyphen (-), and dot (.)

We saw someone posting on r/mcp that they used a slash (github/git_commit) and the client rejected the entire server, not just that tool; another person commented under it saying the same thing happened to them. So just be aware that a warning at registration time is the only notice you get

Run it before any harness sees it

Before we move on to wiring a harness around this, let's exercise the server itself, because feeding JSON-RPC into a process is the highest-return thing you can do in this chapter. No client, no model, no config, no cost, just server and wire:

BASH
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}},"name":"deploy_history","arguments":{"service":"checkout","status":"failed"}}}' \
  | .venv/bin/python tools/deploy_log.py

And it returns:

JSON
{"content":[{"text":"{\n  \"service\": \"checkout\",\n  \"version\": \"1.4.1\",\n  \"deployed_at\": \"2026-07-30T16:40:00Z\",\n  \"status\": \"failed\",\n  \"duration_s\": 31\n}","type":"text"}],"isError":false,"resultType":"complete","structuredContent":{"result":[{"service":"checkout","version":"1.4.1","deployed_at":"2026-07-30T16:40:00Z","status":"failed","duration_s":31}]}}

So as you can see, calling the tool for checkout with status set to failed returns the 1.4.1 deploy

A few early pitfalls we've encountered:

  • It's required to send _meta on every request; if you don't, it replies with -32602 about invalid request parameters and empty data, which is not a tool failure; if you send only part of the envelope, it'll tell you what key is missing:

    JSON
    {"jsonrpc":"2.0","id":1,"error":{"code":-32602,"message":"Invalid request parameters","data":""}}
    JSON
    {"code":-32602,"message":"params._meta is missing the required envelope key(s): io.modelcontextprotocol/clientCapabilities"}
  • You need to keep stdin open; if you close it, the process terminates and discards any in-flight work — we piped five requests in one go and let the pipe close behind them, and only ids 3 and 1 ever came back. So wrap it:

    BASH
    { printf '%s\n' "$REQ1" "$REQ2"; sleep 3; } | .venv/bin/python tools/deploy_log.py
  • Replies come back out of order; requests are served concurrently, so always correlate by id rather than by position. Re-sending the three we'd lost, with stdin held open, they came back 4, 2, 5

But if you're curious, the legacy handshake still works, which is handy for checking what your own harness is doing. Send an initialize at 2025-11-25, then notifications/initialized, and this same server answers a tools/call carrying no _meta at all. One file, both revisions — the client picks, not you

Two kinds of error, and which one the model can fix

In the spec there are two types of errors: protocol and tool execution errors, with the latter being the ones that a model can understand and self-correct by changing parameters. In this server they travel inside an ordinary result flagged isError true:

JSON
{"content":[{"text":"Error executing tool deploy_history: No service named 'chekout'. Known services: billing, checkout."}],"isError":true}

So the model can read them. The SDK even prepends "Error executing tool : ", so it's good to keep that in mind when composing a message:

PYTHON
            raise ToolError(
                f"No service named {service!r}. Known services: {', '.join(sorted(known))}."
            )

This way if the model tries to call the tool for some service and it doesn't exist, we tell it not only what's wrong but also list the known options. This way it's a dead-end that can be turned into a viable retry

The automatic thing is that the model also gets schema violations as tool execution errors — this is a raw dump of what pydantic validation produces if you try to call this tool with limit set to 500:

TEXT
Error executing tool deploy_history: 1 validation error for deploy_historyArguments
limit
  Input should be less than or equal to 50 [type=less_than_equal, input_value=500, input_type=int]
    For further information visit https://errors.pydantic.dev/2.13/v/less_than_equal

It says the limit of 500 exceeds the maximum of 50, which is true, and it's legible enough that the model can retry with a smaller number. But note the pydantic docs URL going straight into the context window along with it. What we mean is that this is unpolished output — when a bound gets crossed, what the model reads is a raw validation dump, not a sentence you wrote

The only thing worth verifying on your end is that in the spec "Unknown tool" is filed under protocol errors with JSON-RPC code -32602, while in this server, which runs on 2.0.0, it was returning them as tool execution errors with isError true and unknown-tool text. This is harmless in practice, but if you write a client based on the spec, don't assume

Printing to stdout

The traditional rule is that stdio servers can't print to stdout because stdout carries the protocol, but this SDK comes with a safeguard:

While serving, fd 0 points at the null device and fd 1 at stderr, so handlers and children read EOF and their stray output misses the wire; both descriptors are restored on exit.

This basically says that during serving fd 0 (stdin) points to the null device and fd 1 (stdout) to stderr, so any output from the handlers or their child processes reaches stderr. Both descriptors are restored when the server exits

So a debug print inside the tool body lands on stderr, and stdout carries only the JSON-RPC response:

PYTHON
print(f"DEBUG: looking up {service}", flush=True)

But if you do it at import time:

TEXT
loading deploy log from deploys.db
{"jsonrpc":"2.0","id":1,"result":{"cacheScope":"private",...

The first line is on stdout because we printed it before the run call took over fd 1 and assigned it to stderr

So we tried this with two clients, pointing them at this server. Claude Code 2.1.220 was happy with it and called the tool normally, and cursor-agent listed tools without any problems. So as you can see, the idea that a single stray line on stdout is a show-stopper — it's folklore

But anyway, keep the discipline: if you need logs during development, send them to stderr or to a file. It costs nothing to behave like this even if the client doesn't complain

IN YOUR HARNESS

Wiring it into Claude Code

In order to set up the server in the project scope, so the configuration file gets created in the repository, not in your home directory, make sure to provide absolute paths, as the config file doesn't specify a working dir for sub-processes. It's as simple as calling:

BASH
claude mcp add deploy-log --scope project \
  -e DEPLOY_DB=/abs/path/to/deploys.db \
  -- /abs/path/to/.venv/bin/python /abs/path/to/tools/deploy_log.py

That way, a .mcp.json file is created in the root of your repository with the following contents:

JSON
{
  "mcpServers": {
    "deploy-log": {
      "type": "stdio",
      "command": "/abs/path/to/.venv/bin/python",
      "args": ["/abs/path/to/tools/deploy_log.py"],
      "env": { "DEPLOY_DB": "/abs/path/to/deploys.db" }
    }
  }
}

You can check if it works by listing all servers using the claude mcp list command. It's a real health-check but with one caveat, spelled out in the help text of the command itself: "Unapproved .mcp.json servers are shown as ⏸ Pending approval and not connected to; approved servers are health-checked." So a newly added project-scope server sits in the pending state and isn't health-checked until you approve it in a session.

This means that after running claude mcp list for the first time you will see a prompt like this:

TEXT
deploy-log: ... - ⏸ Pending approval (run `claude` to approve)

Alternatively, if you'd like to try it without registering the server anywhere, you can always provide a config file directly in the headless mode, eg:

BASH
claude -p "Call deploy_history for checkout, limit 2." \
  --mcp-config .mcp.json --strict-mcp-config \
  --allowedTools "mcp__deploy-log__deploy_history"

Here, we use the --strict-mcp-config flag which is specific to the claude command (not for the mcp list subcommand), so it ignores all other potential MCP sources and isolates a single server.

The gotcha. Just remember that by default (in 2.1.220) the tool search is enabled so your custom tool won't appear in your model's list of tools at the beginning of the session. What we have observed for instance is that it tried to find it twice before calling it:

  • By selecting its name directly — select:deploy_history came back "No matching deferred tools found", because the real name is namespaced as mcp__deploy-log__deploy_history

  • By keyword search over the description — that one found it

So the inputs that decide whether your tool gets retrieved at all are the server's name and the tool's description. A vague description here doesn't just risk a wrong call; it risks the tool never surfacing.

Wiring it into Codex CLI

Let's register the server with Codex CLI:

BASH
codex mcp add deploy-log \
  --env DEPLOY_DB=/abs/path/to/deploys.db \
  -- /abs/path/to/.venv/bin/python /abs/path/to/tools/deploy_log.py

The CLI notifies you it has created a new global MCP server and appends a section to the ~/.codex/config.toml file:

TOML
[mcp_servers.deploy-log]
command = "/abs/path/to/.venv/bin/python"
args = ["/abs/path/to/tools/deploy_log.py"]

[mcp_servers.deploy-log.env]
DEPLOY_DB = "/abs/path/to/deploys.db"

Note that in version 0.146.0 of Codex, the add subcommand doesn't have the --scope option so you can't register servers per repository.

That means it's registered at a global level, but really globally — in the ~/.codex/config.toml, which usually doesn't align with the intention of having a server connected to the database of just a single project.

To avoid that, you can omit it from the base config and configure it via a profile; if you run codex --profile <name>, it'll layer $CODEX_HOME/<name>.config.toml on top of the user config.

To verify if it works, running codex mcp get deploy-log will show:

TEXT
deploy-log
  enabled: true
  transport: stdio
  command: /abs/path/to/.venv/bin/python
  args: /abs/path/to/tools/deploy_log.py
  cwd: -
  env: DEPLOY_DB=*****
  remove: codex mcp remove deploy-log

Thanks to the env values being hidden, you can share this output if you ever need to report a bug.

The gotcha. Keep in mind that neither list nor get run the server process itself; it just reads the config and tells you it's enabled, but it doesn't mean it's running — even if it says enabled: true, it could be for a server you've never launched. The claude mcp list actually health-checks servers it might connect to, but as the commands are almost identical, you can accidentally assume it works similarly for codex and think that it's online because it shows it as enabled in the output of the get command.

Make sure to always run a JSON-RPC request directly to verify if it's up, as mentioned in the previous part.

Wiring it into GitHub Copilot CLI

If you already have an MCP server up and running, you can connect it to GitHub Copilot by using the copilot mcp add command. But its own documentation says that it's for the user configuration located at ~/.copilot/mcp-config.json so let's assume you already have a server that is useful only in a particular repository. In this case, you want to add it to the workspace configuration and not the user one

If you run copilot mcp --help, it says it reads its configuration from three sources:

  • user

  • workspace — .mcp.json or .github/mcp.json

  • plugin

The configuration file for the workspace is the same that Claude Code creates when you run claude mcp add --scope project so for example:

JSON
{
  "mcpServers": {
    "deploy-log": {
      "type": "stdio",
      "command": "/abs/path/to/.venv/bin/python",
      "args": ["/abs/path/to/tools/deploy_log.py"],
      "env": { "DEPLOY_DB": "/abs/path/to/deploys.db" }
    }
  }
}

You need to make sure you use absolute paths in this file as it doesn't set the working directory for the subprocess

This is a configuration we created using claude mcp add --scope project and Copilot read it without doing anything else:

BASH
copilot mcp list
TEXT
Workspace servers:
  deploy-log (local)

If you prefer copilot mcp get (we prefer it as it's more informative), here's the details:

TEXT
deploy-log
  Status: Enabled
  Type: local
  Command: /abs/path/to/.venv/bin/python /abs/path/to/tools/deploy_log.py
  Tools: * (all)
  Source: Workspace (/abs/path/to/.mcp.json)

The gotcha. The wildcard in Tools: * (all) is the default, and it's a per-server allowlist you should start using the moment your server grows past one tool. Here's how copilot mcp add --tools documents itself:

TEXT
--tools <tools>    Tool filter: "*" for all, comma-separated list, or "" for none (default: "*")

With a single tool it makes no difference. With five, that field is the difference between exposing what the session needs and exposing everything you happen to have written

Wiring it into Cursor

We don't have built-in mcp add command so we did it manually — if you want to set it up for your repo the file needs to be placed here: .cursor/mcp.json or here (for all projects) ~/.cursor/mcp.json, it contains the following:

JSON
{
  "mcpServers": {
    "deploy-log": {
      "command": "/abs/path/to/.venv/bin/python",
      "args": ["/abs/path/to/tools/deploy_log.py"],
      "env": { "DEPLOY_DB": "/abs/path/to/deploys.db" }
    }
  }
}

After adding this, you should see a message from the cursor-agent saying that you have a new unapproved server, for example:

BASH
cursor-agent mcp list
TEXT
deploy-log: not loaded (needs approval)

To proceed, you need to approve it using:

BASH
cursor-agent mcp enable deploy-log

And then run the most thorough verification available in this tool:

BASH
cursor-agent mcp list-tools deploy-log
TEXT
Tools for deploy-log (1):
- deploy_history (service, status, limit)

It works because the command dials the server and fetches the tools list from it — including their names, defined by the user, no session required. Prior to approval, if you were to run this command, it would throw an error saying that it can't load the server, so the successful listing indicates that the process has started. What's more, any name discrepancy is immediately noticeable in the output.

The gotcha. The approval is bound to the project directory and stored outside of the repo here:

TEXT
~/.cursor/projects/<slugified-path>/mcp-approvals.json

It's keyed by absolute path, so basically whenever you run cursor-agent mcp enable deploy-log in a different project (in another directory, or even if you've already checked out this project in a different location) it will ask to approve the server. Same goes for anybody who pulls your .cursor/mcp.json file from git — they will also encounter the approval prompt despite the fact that the server is already set up.

Wiring it into Gemini CLI

gemini mcp add by default uses --scope project (unless you tell it otherwise) and hence writes the entry to .gemini/settings.json in the current directory. For example, this command:

BASH
gemini mcp add deploy-log \
  -e DEPLOY_DB=/abs/path/to/deploys.db \
  /abs/path/to/.venv/bin/python /abs/path/to/tools/deploy_log.py

will create an entry like this (with the command, arguments and environment variables):

JSON
{
  "mcpServers": {
    "deploy-log": {
      "command": "/abs/path/to/.venv/bin/python",
      "args": ["/abs/path/to/tools/deploy_log.py"],
      "env": { "DEPLOY_DB": "/abs/path/to/deploys.db" }
    }
  }
}

Worth mentioning that there are a few more useful options for gemini mcp add:

  • --timeout (in milliseconds)

  • --include-tools and/or --exclude-tools to publish only part of the toolset

  • --description

  • -t, --transport (either sse or http)

The gotcha. Make sure to run gemini mcp list — you might create a new directory, run the command there and see it say nothing is active. There's a reason for that. The output of gemini mcp list is like this:

TEXT
Warning: MCP servers are configured but disabled because this folder is untrusted.
User-level servers are also suppressed in untrusted folders to prevent accidental side-effects.

Configured MCP servers:

○ deploy-log: ... (stdio) - Disabled

The word "Disabled" describes the folder's trust status, not a problem with the server. Whenever you create a new directory, it becomes untrusted, and this also disables servers defined on the user level. That means that if you create a correct server in a newly created directory, it's indistinguishable from it being broken. So before you get back to the code, make sure to carefully read the output of gemini mcp list.

Wiring it into Kimi Code CLI

There's no dedicated mcp subcommand to set up a config, we need to create a JSON file manually — either at the project level (in .kimi-code/mcp.json within the current working directory) or at the user level (in ~/.kimi-code/mcp.json or $KIMI_CODE_HOME/mcp.json). If you have a config on both levels and they define the same server, the project-level one wins. For example:

JSON
{
  "mcpServers": {
    "deploy-log": {
      "command": "/abs/path/to/.venv/bin/python",
      "args": ["/abs/path/to/tools/deploy_log.py"],
      "env": { "DEPLOY_DB": "/abs/path/to/deploys.db" }
    }
  }
}

Confirming it is an in-session job. Per Moonshot's docs, /mcp shows the connection status of every configured server and /mcp-config adds, edits or deletes them without you hand-editing the file. The startup and tool timeouts can be set per server in mcp.json, or as environment variables (KIMI_MCP_STARTUP_TIMEOUT_MS and KIMI_MCP_TOOL_TIMEOUT_MS), or under [mcp] in config.toml — in that order of priority. The startup timeout defaults to 30000 ms.

The gotcha. If you run kimi doctor after setting up your first server, you might think that this is a validator for the config:

TEXT
Kimi doctor

OK config.toml  /Users/you/.kimi-code/config.toml
OK tui.toml     /Users/you/.kimi-code/tui.toml

All checked config files are valid.

But actually, it doesn't check mcp.json at all. We replaced that file with the literal text { this is not json, ran kimi doctor again, and got the same three lines and the same "All checked config files are valid." So read the list of files it enumerates, not the verdict — mcp.json isn't on it, and a green doctor says nothing whatsoever about the server you just wired up.

What to change before this is real

This sample is honest about structure and silent about everything else that a real server must have. Let's run through a few things:

  • Make sure you keep the connection read-only. SQLite is what enforces mode=ro, not your good intentions — a DELETE on that connection comes back OperationalError: attempt to write a readonly database. Find the equivalent in your own store

  • Don't be tempted to implement the sql string parameter — it turns one tool into every tool, breaks the schema as a contract and gives a model with untrusted text in its context a direct path to your database. Instead, name the supported query and take parameters for it

  • Set a timeout — the tool is a subprocess that the model may call repeatedly, and an unbounded query becomes a stuck session

  • Never read secrets from arguments — the spec says clients should show the tool inputs to the user before calling and log tool usage for audit, so arguments are a public surface; environment variables aren't part of the Tool type at all

  • Restart your harness after editing the server — it started a subprocess with your file at session start, and has no way of knowing you edited it; this is the first thing to check if an edit seems not to work

And last but not least, there are three more lessons in this chapter that continue from here:

  • The tool's description is prompt text, so deserves the same consideration

  • You need to understand the trust boundary to decide whether stdio is still the right transport

  • We'll share a recipe for when the model keeps calling the tool with incorrect parameters

THE FILEtools/deploy_log.py
PYTHON
"""One tool over the deploy log: read-only, no SQL from the caller."""

import os
import sqlite3
from typing import Annotated, Literal

from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from pydantic import BaseModel, Field

DB_PATH = os.environ.get("DEPLOY_DB", "deploys.db")

mcp = MCPServer("deploy-log", version="0.1.0")


class Deploy(BaseModel):
    """One row of the deploy log."""

    service: str
    version: str
    deployed_at: str
    status: str
    duration_s: int


def connect() -> sqlite3.Connection:
    # mode=ro is the guarantee, not a comment: this connection cannot write.
    db = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True)
    db.row_factory = sqlite3.Row
    return db


@mcp.tool(
    title="Deploy history",
    description=(
        "Recent deploys of one service, newest first. Read-only. "
        "Use this instead of guessing from the changelog or the git log when you need to know "
        "what actually reached production, when, and whether it succeeded."
    ),
)
def deploy_history(
    service: Annotated[
        str,
        Field(description="Exact service name, e.g. 'checkout'. Case-sensitive."),
    ],
    status: Annotated[
        Literal["succeeded", "failed", "any"],
        Field(description="Filter by outcome. Defaults to any."),
    ] = "any",
    limit: Annotated[
        int,
        Field(ge=1, le=50, description="How many rows to return, newest first."),
    ] = 10,
) -> list[Deploy]:
    with connect() as db:
        known = [r["service"] for r in db.execute("SELECT DISTINCT service FROM deploys")]
        if service not in known:
            raise ToolError(
                f"No service named {service!r}. Known services: {', '.join(sorted(known))}."
            )

        sql = "SELECT * FROM deploys WHERE service = ?"
        args: list[object] = [service]
        if status != "any":
            sql += " AND status = ?"
            args.append(status)
        sql += " ORDER BY deployed_at DESC LIMIT ?"
        args.append(limit)

        return [Deploy(**dict(row)) for row in db.execute(sql, args)]


if __name__ == "__main__":
    mcp.run()
j / k to move between lessons