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 hereThe [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:
| Primitive | Who decides | In our server |
|---|---|---|
| Tool | The model | search_adrs, read_adr |
| Resource | The application (the host) | adr://index |
| Prompt | The user | none |
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.limitbetween 1 and 20,termwith at least 2 characters. If the model sendslimit: 99, the call fails validation before your function runs.- A Pydantic return value becomes structured output. Every result carries
content(text, for the model) andstructured_content(data, for the application). ToolErroris the error the model sees. The message lands in the result'scontent, and the model can fix the call and try again, which is why it lists the numbers that exist. Any other exception (aKeyError, for example) also comes back as an error, but the model only getsError executing tool ..., without the text. The traceback stays in the server log. An error the model can fix deserves aToolErrorwith a useful message.- Annotations are hints, not security.
read_only_hint=Truehelps 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
ToolErrorin_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.61sThe 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 TasksTwo transport details that break new servers:
- stdout is the protocol channel. Anything written to stdout before the server starts serving (a
printat import time, a wrapper script that runsecho) corrupts the protocol. For logs, use theloggingmodule, which writes to stderr. - The subprocess only receives a minimal list of variables (such as
HOMEandPATH), not your environment. Whatever the server needs goes inenv=.
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 listadrs: uv run mcp run server.py - ✔ ConnectedEverything 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
| Scope | Flag | Where it lives | Who sees it |
|---|---|---|---|
| Local (default) | none or --scope local | ~/.claude.json, under the project path | Only you, only in that project |
| Project | --scope project | .mcp.json at the root, goes into git | The whole team, after approving it |
| User | --scope user | ~/.claude.json | Only 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 localInside 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.mdwith 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 listin 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_DIRexported in the shell pointed to another folder and the search came back empty, with no error. Hence theToolErrorfor a missing folder. - [ ] An environment variable "disappeared". The subprocess does not inherit your environment. Pass it with
--envorenv=. - [ ]
Invalid environment variable format. The server name came right after--env. - [ ] A project server does not show up. It is in
⏸ Pending approval. Runclaudein the folder and approve it. - [ ] Slow startup. The
MCP_TIMEOUTvariable 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 whysearch_adrshas alimit. - [ ] An old tutorial does not work.
FastMCPandfrom mcp.server.fastmcpare from v1. In v2 it isMCPServer.
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
- Harness, RAG and context engineering (the hub)
- Claude Code is a harness, the main Claude Code guide
- How to configure hooks in Claude Code
- How to write a CLAUDE.md the agent follows
- How to create a Skill in Claude Code
- How to create an llms.txt, the same context problem on your website's side
Need to connect agents to your company's internal systems? Talk to Capiva.