Blog · AI Harness

How to build an MCP server in Python and configure it in Claude Code (claude mcp add, .mcp.json and scopes)

Capiva · October 2026 · 12 min read

An MCP (Model Context Protocol) server is a process that exposes tools, resources and prompts in a standard format, so any compatible host (Claude Code, Claude Desktop, IDEs) can use them. The host starts the server, asks what it offers and passes that to the model. With the local transport, stdio, the conversation is JSON-RPC over the process's stdin and stdout. You write normal Python functions, and the SDK generates the schema and handles the protocol.

Why this is a context engineering channel, and where RAG fits in, is covered in Harness, RAG and context engineering. This post is hands-on, in two parts: build the server in Python, then configure it in Claude Code (scopes, .mcp.json, approval). The example is a server that lets the agent look up the project's ADRs (architecture decision records) before proposing a change. That is context the agent does not have and that does not fit in CLAUDE.md.

Everything below uses v2 of the official SDK (mcp 2.3.0 on PyPI, Python 3.10+). If you have seen tutorials with from mcp.server.fastmcp import FastMCP, they are from v1. The class is now MCPServer.

Install the SDK

uv init mcp-adr && cd mcp-adr
uv add "mcp[cli]"     # or: pip install "mcp[cli]"
uv add --dev pytest anyio
mkdir -p docs/adr     # put your 0001-title.md, 0002-... files here

The [cli] extra brings the mcp command, which runs the server and opens the Inspector.

Decide what is a tool and what is a resource

The SDK separates the three primitives by who decides to use each one:

PrimitiveWho decidesIn our server
ToolThe modelsearch_adrs, read_adr
ResourceThe application (the host)adr://index
PromptThe usernone

Search is a tool because the model decides when it needs it. The index is a resource: you attach it when you want to give the agent the overview. In Claude Code, a resource comes in through an @ mention, in the format @server:protocol://path.

Write the server

"""MCP server that exposes the project's ADRs for the agent to look up."""

import os
import re
from pathlib import Path
from typing import Annotated

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

ROOT = Path(os.environ.get("CLAUDE_PROJECT_DIR", Path(__file__).parent))
ADR_DIR = Path(os.environ.get("ADR_DIR", ROOT / "docs" / "adr")).resolve()

mcp = MCPServer(
    "adrs",
    instructions="Before proposing an architecture change, search for related ADRs.",
)


class ADRSummary(BaseModel):
    number: int = Field(description="ADR number, e.g. 8 for ADR-0008.")
    title: str
    status: str
    excerpt: str = Field(description="Line where the term appears.")


def _adrs() -> dict[int, Path]:
    if not ADR_DIR.is_dir():
        # Fail loudly: a wrong folder must not look like "no results".
        raise ToolError(f"ADR folder not found: {ADR_DIR}. Set ADR_DIR.")
    files = {}
    for p in sorted(ADR_DIR.glob("[0-9][0-9][0-9][0-9]-*.md")):
        files[int(p.name[:4])] = p
    return files


def _title_and_status(text: str) -> tuple[str, str]:
    title = text.splitlines()[0].lstrip("# ").strip()
    m = re.search(r"^## Status\s*\n+\s*(.+)$", text, flags=re.MULTILINE)
    return title, (m.group(1).strip() if m else "unknown")


@mcp.tool(annotations=ToolAnnotations(read_only_hint=True, open_world_hint=False))
def search_adrs(
    term: Annotated[
        str, Field(min_length=2, description="Word or phrase to look for.")
    ],
    limit: Annotated[int, Field(ge=1, le=20)] = 5,
) -> list[ADRSummary]:
    """Search ADRs (architecture decision records) that mention the term."""
    found = []
    for number, path in _adrs().items():
        text = path.read_text(encoding="utf-8")
        line = next(
            (li for li in text.splitlines() if term.lower() in li.lower()), None
        )
        if line:
            title, status = _title_and_status(text)
            found.append(
                ADRSummary(
                    number=number,
                    title=title,
                    status=status,
                    excerpt=line.strip()[:200],
                )
            )
        if len(found) == limit:
            break
    return found


@mcp.tool(annotations=ToolAnnotations(read_only_hint=True, open_world_hint=False))
def read_adr(number: Annotated[int, Field(ge=1, le=9999)]) -> str:
    """Return the full text of an ADR by number."""
    path = _adrs().get(number)
    if path is None:
        existing = ", ".join(str(n) for n in _adrs()) or "none"
        raise ToolError(f"ADR {number} does not exist. Available numbers: {existing}.")
    return path.read_text(encoding="utf-8")


@mcp.resource("adr://index")
def index() -> str:
    """List of all ADRs with title and status."""
    lines = []
    for number, path in _adrs().items():
        title, status = _title_and_status(path.read_text(encoding="utf-8"))
        lines.append(f"- ADR-{number:04d} [{status}] {title}")
    return "\n".join(lines)

What each decision does:

  • Name, docstring and type hints become the contract. The SDK reads the function name, the docstring and the types and generates the JSON Schema the client receives in tools/list. The docstring is what the model reads to decide whether to call the tool. Write it for the model.
  • Annotated[..., Field(...)] becomes a schema constraint. limit between 1 and 20, term with at least 2 characters. If the model sends limit: 99, the call fails validation before your function runs.
  • A Pydantic return value becomes structured output. Every result carries content (text, for the model) and structured_content (data, for the application).
  • ToolError is the error the model sees. The message lands in the result's content, and the model can fix the call and try again, which is why it lists the numbers that exist. Any other exception (a KeyError, for example) also comes back as an error, but the model only gets Error executing tool ..., without the text. The traceback stays in the server log. An error the model can fix deserves a ToolError with a useful message.
  • Annotations are hints, not security. read_only_hint=True helps the host decide whether to ask for confirmation. The SDK docs are blunt about it: never depend on the client respecting them.
  • Fail loudly when the configuration is wrong. Without the ToolError in _adrs(), a missing folder would return an empty list, and the agent would conclude there is no ADR on the subject. That happened to me during testing (details in the troubleshooting section).

Test in memory with pytest

The SDK's Client accepts the server object itself and connects in memory, with no subprocess:

import pytest
from mcp import Client

from server import mcp


@pytest.fixture
def anyio_backend():
    return "asyncio"


@pytest.fixture
async def client():
    async with Client(mcp, raise_exceptions=True) as c:
        yield c


@pytest.mark.anyio
async def test_lists_both_tools(client: Client):
    names = {t.name for t in (await client.list_tools()).tools}
    assert names == {"search_adrs", "read_adr"}


@pytest.mark.anyio
async def test_search_returns_structure(client: Client):
    r = await client.call_tool("search_adrs", {"term": "PreToolUse"})
    assert not r.is_error
    items = r.structured_content["result"]
    assert items[0]["number"] == 8
    assert "PreToolUse" in items[0]["excerpt"]


@pytest.mark.anyio
async def test_limit_outside_schema_is_rejected(client: Client):
    r = await client.call_tool("search_adrs", {"term": "hook", "limit": 99})
    assert r.is_error


@pytest.mark.anyio
async def test_missing_adr_becomes_readable_error(client: Client):
    r = await client.call_tool("read_adr", {"number": 42})
    assert r.is_error
    assert "Available numbers" in r.content[0].text


@pytest.mark.anyio
async def test_index_as_resource(client: Client):
    r = await client.read_resource("adr://index")
    assert "ADR-0011" in r.contents[0].text


@pytest.mark.anyio
async def test_wrong_folder_fails_loudly(client: Client, monkeypatch, tmp_path):
    import server

    monkeypatch.setattr(server, "ADR_DIR", tmp_path / "does-not-exist")
    r = await client.call_tool("search_adrs", {"term": "hook"})
    assert r.is_error
    assert "ADR folder not found" in r.content[0].text
$ uv run pytest -q
......                                                                   [100%]
6 passed in 0.61s

The tests use four real ADRs from capivaOS, copied to docs/adr. Note that a ToolError does not become an exception in the test. The call returns normally with is_error=True, which is what the model will receive.

Run it as a subprocess, the way the host does

The in-memory test does not catch transport problems. This script starts the server over stdio:

"""Start the server as a subprocess (stdio), the way a host would."""

import anyio
from mcp import Client, StdioServerParameters

server = StdioServerParameters(
    command="mcp",
    args=["run", "server.py"],
    env={"ADR_DIR": "docs/adr"},  # the subprocess does NOT inherit your environment
)


async def main() -> None:
    async with Client(server) as client:
        print("protocol:", client.protocol_version)
        print("tools:", [t.name for t in (await client.list_tools()).tools])
        r = await client.call_tool("search_adrs", {"term": "fast lane", "limit": 2})
        for adr in r.structured_content["result"]:
            print(f"ADR-{adr['number']:04d}", adr["status"], "|", adr["title"])


anyio.run(main)
protocol: 2026-07-28
tools: ['search_adrs', 'read_adr']
ADR-0008 Accepted | ADR-0008: Hook-Enforced Phase Guards Parsing sprint-state.md Directly
ADR-0010 Accepted | ADR-0010: Fast-Lane Pipeline for Small, Low-Risk Tasks

Two transport details that break new servers:

  • stdout is the protocol channel. Anything written to stdout before the server starts serving (a print at import time, a wrapper script that runs echo) corrupts the protocol. For logs, use the logging module, which writes to stderr.
  • The subprocess only receives a minimal list of variables (such as HOME and PATH), not your environment. Whatever the server needs goes in env=.

To explore it with a UI, uv run mcp dev server.py opens the MCP Inspector, with a form for each tool generated from the types.

Configure MCP in Claude Code

claude mcp add --env ADR_DIR=docs/adr --transport stdio adrs -- uv run mcp run server.py
claude mcp list
adrs: uv run mcp run server.py - ✔ Connected

Everything after -- is the server command, passed through untouched. The order of the options matters. --env accepts several KEY=value pairs, and if the server name comes right after it, the CLI reads the name as one more pair and fails with Invalid environment variable format. Put another option in between, like --transport stdio. I got this wrong on my first try.

Pick the scope

ScopeFlagWhere it livesWho sees it
Local (default)none or --scope local~/.claude.json, under the project pathOnly you, only in that project
Project--scope project.mcp.json at the root, goes into gitThe whole team, after approving it
User--scope user~/.claude.jsonOnly you, in every project

MCP's "local" is not .claude/settings.local.json; the docs themselves warn that these are different files. If the same name exists in more than one scope, only one applies, in the order local, project, user, and the fields do not merge.

Check, inspect and remove

claude mcp get adrs        # scope, status, command and variables
claude mcp remove adrs -s local

Inside a session, /mcp opens the panel with the servers, the status of each one and their tools. It is also where you re-enable a server that was turned off for that project only.

Share it with the team through .mcp.json

With --scope project, Claude Code writes a .mcp.json at the root to go into git:

{
  "mcpServers": {
    "adrs": {
      "type": "stdio",
      "command": "uv",
      "args": ["run", "mcp", "run", "tools/adrs/server.py"],
      "env": {}
    }
  }
}

Each person has to approve a server from .mcp.json before it runs. Until then, claude mcp list shows ⏸ Pending approval. That protects you from a cloned repository that starts processes on its own. Claude Code is what starts the server, and it sets CLAUDE_PROJECT_DIR to the project root in the server's environment. That is why server.py uses this variable as its default.

MCP, API or Skill?

The three show up together in searches and solve different problems:

  • An API is the system you already have: REST, a database, a queue. The agent can call it directly (through Bash and curl, for example), but then it has to figure out the format, build the authentication and interpret the error every time.
  • MCP is a standard adapter around that API: tools with a name, a description and a validated schema, structured errors, and credentials kept in the server instead of the prompt. The same server works in any compatible host.
  • A Skill is knowledge and procedure: a SKILL.md with instructions Claude loads when they are relevant. On its own, it connects to nothing.

Claude Code's features overview puts it this way: "MCP connects Claude to external services. Skills extend what Claude knows, including how to use those services effectively". The combination it suggests is the most useful one in practice. MCP gives the connection, and a skill teaches your team's way of using it. In our case, the server searches ADRs and a skill could say when to look them up and how to cite the decision in the PR. How to write that skill is in How to create a Skill in Claude Code.

The context cost also differs. According to the same page, Claude Code loads the names of MCP tools at startup and leaves the full schemas for when they are used. For a skill, it loads only the description, and the body comes in when the skill is invoked.

How we use it at Capiva

This server came from the way capivaOS records decisions. Every architecture change becomes a numbered ADR, and the argument in several of them is "this cannot depend on the agent remembering". Exposing the ADRs through MCP, instead of pasting everything into CLAUDE.md, follows the same logic as ADR-0011, which took content out of the always-loaded layer. The context comes in when the agent needs it, not in every session.

Troubleshooting checklist

  • [ ] The server does not connect. Run the exact command from claude mcp list in a terminal. If it prints anything to stdout on startup, that is the problem.
  • [ ] Search comes back empty and should not. Check which folder the server is reading. In my tests, a CLAUDE_PROJECT_DIR exported in the shell pointed to another folder and the search came back empty, with no error. Hence the ToolError for a missing folder.
  • [ ] An environment variable "disappeared". The subprocess does not inherit your environment. Pass it with --env or env=.
  • [ ] Invalid environment variable format. The server name came right after --env.
  • [ ] A project server does not show up. It is in ⏸ Pending approval. Run claude in the folder and approve it.
  • [ ] Slow startup. The MCP_TIMEOUT variable sets the startup timeout, in milliseconds.
  • [ ] Response too large. Claude Code warns above 10,000 output tokens from an MCP tool and caps it at 25,000 by default (MAX_MCP_OUTPUT_TOKENS). Paginate or summarize in the server. That is why search_adrs has a limit.
  • [ ] An old tutorial does not work. FastMCP and from mcp.server.fastmcp are from v1. In v2 it is MCPServer.

Frequently asked questions

Do I need MCP, or is a script the agent runs through Bash enough?▼

For personal use, a script works. MCP pays off when you want a validated schema, structured errors, automatic discovery and the same server working across several hosts.

Can I build a remote server?▼

Yes. The same server runs with mcp run server.py --transport streamable-http, and Claude Code connects with --transport http and the URL. Then authentication and networking come in, which are a topic for another post.

How do I limit what the server can do?▼

In the code itself: this one only reads files from one folder. In Claude Code, you can deny specific MCP tools in the permissions or filter them with a PreToolUse hook using the matcher mcp__adrs__.*.

Does it work outside Claude Code?▼

Yes. MCP is an open protocol, and any compatible host can start the same server.py.

Read next

Need to connect agents to your company's internal systems? Talk to Capiva.