Documents / Decisions / adr-354a4270ecd8
MCP as a second transport over the dispatcher
Why docir mcp serve is a third client of the dispatcher rather than a second implementation.
Context¶
An agent can only use docir if it can reach the CLI. docir agent install
(adr-3a2d5ee7bc84) covers assistants that run shell commands — it teaches Claude Code or
an AGENTS.md reader to drive docir context, docir add and the rest. An
assistant that calls tools over the Model Context Protocol and never runs a
shell could not use docir at all, and that describes most clients now shipping:
Cursor, Codex, VS Code, ChatGPT and Claude Desktop discover capabilities as MCP
tools.
A survey of the adjacent field (ref-a6db21f52427) put this first among the
gaps: Basic Memory, qmd and sqlite-memory each ship an MCP server, and it is how
they are installed at all. It was also the cheapest gap to close, because the
command vocabulary already lives in exactly one place.
The design question was never whether — it was where the tools get their
behaviour from. A tool surface written against DocumentService would be a
second implementation of the same commands, free to drift from the CLI's
defaults, its validation and its output shape. The thing worth protecting is
that it cannot.
Decision¶
Add docir mcp serve as a third client of Dispatcher, beside the CLI and
the daemon socket. entry_points/mcp/ holds a server.py (the tool surface) and
a cmds.py (the Typer command); no business logic, per the entry_points rule.
- Every tool is one
Requestthrough aRequestExecutor— the same boundary the CLI crosses and the same one the daemon protocol crosses. There is exactly one tool per dispatcher command (19 of them), plus adocir_schematool for the one thing an agent needs that is not a command: the merged types, statuses and relation kinds it must write against.pingis deliberately unexposed — it is the daemon's liveness probe, not a document operation. - The daemon is the default executor. An MCP session is many calls over a
long-lived process, which is precisely the case the warm model exists for;
--no-daemonholds one in-process container open for the server's lifetime instead. Calls are serialized by a lock, because FastMCP runs sync tools in a thread pool and neither executor promises re-entrancy. - The read contract carries over unchanged.
docir_context/docir_search/docir_queryreturn body-less skeletons; onlydocir_getcarries a body. Every result passes through the sametrimthe piped CLI JSON uses — moved toentry_points/payload.pyso both transports share one implementation rather than two that agree today. - Errors keep their identity. A dispatcher failure and a transport failure
both become a
ToolErrorcarrying the docir message. An exit code cannot cross MCP, so the message is the whole of what the client gets. fastmcpships by default, not behind an extra. An extra is the smaller install and the wrong default: the audience for the MCP server is an agent that speaks only MCP, and such an agent cannot be told to install an extra, because it cannot reach docir to be told. Weighed against ~12 MB (onnxruntime, already a dependency, is 68 MB) that trade is not close. The cost that would have been real — fastmcp's ~0.3s import on everydocir get— is avoided instead by structure:server.pyimports fastmcp at module scope andcmds.pyimportsserverinside the command, so onlymcp servepays it. Theembeddingsextra, a no-op alias since fastembed became a hard dependency, was dropped in the same change rather than left as a second way to say nothing.
Consequences¶
- The vocabulary now has three surfaces and one definition. A new dispatcher
command must be given a tool, and a test asserts the names of the exposed
set against
Dispatcher._handlers— a count could not tell "all exposed" from "the mapping and the dispatcher drifted together". - Tool names are frozen-ish. They are prefixed (
docir_context, notcontext) because the CLI's names are generic verbs that collide across servers in a client's tool list. Renaming one breaks saved prompts, so the mapping is spelled out in the test rather than derived. - This is not the read-path improvement. MCP changes who can reach docir,
not what they get: retrieval is still document-level, with no chunking and no
reranking. Those remain open — gaps 2 and 3 of
ref-a6db21f52427. - The lazy import in
cmds.pyis now load-bearing. With fastmcp a default dependency, nothing but that deferred import keeps its ~0.3s off the read path every other command runs. Hoisting it to the top of the module would be invisible in the tests and would slow everydocir getin the project. docirhas no extras left.pip install dociris the whole product;docir[embeddings]anddocir[mcp]both resolve to it and warn.