ADR-006: Serve MCP as a second transport over the same tools, not a second implementation¶
Status: Accepted Date: 2026-07-10 (recorded as an ADR 2026-07-18) Deciders: San Lee
Context¶
agent/tools.py owns the tool layer: the tool functions themselves, their hand-written JSON
schemas in TOOLS, and the descriptions inside those schemas that steer when a model calls
each tool. Every tool returns a SYS-003 observation — a JSON string with a status field,
payload/source on success and next_actions on failure — built through _success/_problem.
Four tools exist, and they are not alike (CLAUDE.md:93-100). search_kb and list_projects
are local: they read ChromaDB and projects.yaml on disk, and need nothing else running.
classify_snippet and search_notes are cross-repo HTTP seams: they POST to the
defense-news-classifier service's /classify endpoint and GET the notes-api service's
/notes endpoint, so each one only works while a separate service is up.
Exposing the KB over the Model Context Protocol meant a second way in — an MCP host
(Claude Code, Claude Desktop) querying the KB directly, without going through agent.py.
That raised two questions at once: how much of the tool layer to rebuild for the new protocol,
and how much of it to expose.
This ADR records a decision that was already made and already written down. It landed with
the MCP server itself on 2026-07-10 (commit 2184f28, "feat: expose the KB's local tools as an
MCP server (stdio)", PR #31) and is stated in three places: the mcp_server/server.py module
docstring (lines 1-17), CLAUDE.md Architecture §6 (lines 115-120) and the Conventions bullet
"The tool layer has one home" (lines 144-147), and the README's "MCP server" section
(lines 110-124). Nothing here is new reasoning. The decisions/README.md "Still to migrate"
list named this as an ADR-class decision living in prose; this is that migration.
Decision¶
mcp_server/server.py is a transport adapter, not a second implementation. The mechanism:
- It imports
search_kbandlist_projectsfromagent/tools.pyand returns their SYS-003 observation JSON unchanged — the same bytes the Anthropic tool-use loop feeds back to the model are the bytes handed to an MCP client. One contract, two transports. - It reads tool descriptions out of
TOOLSrather than retyping them (_DESCRIPTIONS = {tool["name"]: tool["description"] for tool in TOOLS},server.py:49), so the wording that drives tool-selection accuracy cannot fork between transports. - It spends the FastMCP
instructionsfield on the one thing a client cannot infer from the schemas: results are SYS-003 observations, so branch onstatusrather than parsing prose.
Only the two local tools are exposed. classify_snippet and search_notes are deliberately
excluded — they need another service running, and an MCP server that quietly depends on two
background processes is a bad install.
The prose marks this exclusion "for now" (server.py:17, README.md:124). No explicit
revisit trigger was written down; the stated condition — the seams requiring separate running
services — is what would have to change.
The general rule this decision installs is in CLAUDE.md Conventions and stays there: never
reimplement a tool or retype a description in mcp_server/, because a fork there would
silently drift.
Downstream surfaces¶
CLAUDE.mdArchitecture §6 and Conventions ("The tool layer has one home") — the operative instruction stays there; this ADR carries the reasoning. Not modified by this ADR.mcp_server/server.pymodule docstring (lines 1-17) — the original statement of the decision, kept as an inline summary at the point of use.README.md— the "MCP server" section (transport-adapter framing, the exposed-tools table, the exclusion paragraph), the component list entry formcp_server/server.py, and the Status section.kb/projects/kb-agent.md— kb-agent's self-description in its own KB, which states the two-transports/one-implementation framing and the exclusion (lines 8-9, 20-29). It is hand-written and sits outsideingest.py's pipeline, so nothing regenerates it;ingest.py --checklists it asunmanaged. If this decision is ever reversed, this stub must be edited by hand andscripts/index.pyrerun, orsearch_kbwill answer from a stale description.tests/test_mcp_server.py— drives the server through a real in-memory client session (initialize→tools/list→tools/call), so the protocol hop is under test rather than just the underlying functions.decisions/README.md— the index table gains this row, and the "Still to migrate" entry "MCP server as a second transport, not a second implementation —CLAUDE.md" is now satisfied.system/SYS-003— unaffected, and load-bearing here: the observation envelope is precisely what makes one implementation serviceable over two transports.
Consequences¶
- One place to change a tool. Editing a tool function or its description updates both
transports at once. A new local tool needs a thin wrapper in
mcp_server/, not a reimplementation. - Selection behaviour stays identical across transports. Descriptions are prescriptive
about when to call each tool; reading them from
TOOLSmeans that tuning cannot diverge. - The MCP surface is narrower than the agent's. A host reaching kb-agent over MCP can search
the KB and list projects, but cannot drive the classifier or read live notes. Those
capabilities exist only through
agent.py/app.py. This asymmetry is the accepted cost. - The install stays honest. Registering the server yields something that works immediately from a clone plus an index — no hidden prerequisite of two other repos' services running, and no tools that fail at call time for environmental reasons a host cannot fix.
- A thin coupling to
agent/tools.py's shape is accepted. The server depends onTOOLSentries havingnameanddescriptionkeys, and onsys.pathmanipulation to importagent.toolsat all (an MCP host launches the server by absolute path from an arbitrary CWD,server.py:35-40).
Alternatives Considered¶
| Option | Reason Not Chosen |
|---|---|
Reimplement search_kb/list_projects natively inside mcp_server/ |
Two implementations of one tool drift silently — the failure mode is a query that behaves differently depending on which transport a model reached it through. CLAUDE.md Conventions states the rule directly: "Never reimplement a tool or retype a description in mcp_server/" |
| Keep the shared functions but retype the tool descriptions for MCP | The descriptions are prescriptive about when to call each tool and are what drive selection accuracy; retyping forks that tuning between transports with nothing to catch the divergence (server.py:46-48) |
Expose all four tools, including classify_snippet and search_notes |
The two seams require the defense-news-classifier and notes-api services to be running. An MCP server that silently depends on two background processes is a bad install experience — a host would see four tools advertised and get failures from two of them for reasons outside its control |
| Translate observations into an MCP-native result shape instead of passing SYS-003 JSON through | Would create a second wire format to keep in sync with SYS-003. The observation envelope already carries status/payload/source/next_actions, which is what a client needs; the server instead documents that contract once in FastMCP instructions |
No MCP server at all — reach the KB only through agent.py/app.py |
The record does not preserve a rejected form of this. The stated motivation is that any MCP host can query the KB directly, without going through agent.py (README.md:106-108); it is not written up as a choice against a considered alternative |