> ## Documentation Index
> Fetch the complete documentation index at: https://docs.whyfile.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# 0018 mcp server

# ADR-0018: MCP server (`graphify-intent --mcp`)

**Status:** Accepted (read-only posture extended by ADR-0023: one explicit `intent_capture` write tool)
**Date:** 2026-07-07

## Context

The intent query layer (ADR-0016) and the team bundle (ADR-0017) expose "why is this like
this?" as read-only, LLM-free CLI subcommands. But developers and their coding agents live in
the editor, and today the only way an agent reaches the intent layer is for a human to shell out
and paste the result back. ADR-0017 recorded an MCP server as a deferred follow-up, on the
explicit condition that the CLI/JSON contract see real use first. That contract is now settled,
so this ADR builds the MCP layer as a thin wrapper over it. The scope is exactly the read-only
query surface: the six `intent_*` tools map one-to-one to `explain` / `list-intent` / `why` /
`changed` / `coverage` / `digest`. Running the enrichment pipeline over MCP stays out of scope.

## Decision

**Optional `[mcp]` extra, lazy-imported.** The `mcp` SDK is a heavy dependency tree
(pydantic, anyio, httpx, starlette). It is declared as an optional extra
(`pip install 'graphify-intent[mcp]'`), added to the `dev` extra so CI always exercises it, and
imported only inside `mcp_server._build_server` / `serve`. Importing `graphify_intent`, and
running `graphify-intent --help`, never requires the SDK, so the default install and the
`clean-install` CI job stay lean. `graphify-intent --mcp` probes `importlib.util.find_spec("mcp")`
before serving and, if absent, exits `2` with a one-line install hint. This mirrors the precedent
set by graphify's own `--mcp` server, which lazy-imports `mcp` the same way.

**Parity by construction, not by convention.** The acceptance bar is that each tool returns a
result identical to its CLI counterpart. Rather than hand-copy the CLI's output shape into the
adapters (which would drift), the six output envelopes are extracted into pure builders in
`query.py` (`explain_result`, `list_intent_result`, `why_result`, `changed_result`,
`coverage_result`, `digest_result`). The CLI's `_run_query` / `_run_team` call these builders and
then print/exit; the MCP adapters call the same builders and return the dict. There is one copy of
each envelope, so the two surfaces cannot diverge. A parity test asserts each adapter's result
equals the CLI's captured stdout for the same input, which is only a meaningful test because both
paths route through the one builder.

**Schema-enforced input validation.** The low-level `mcp.server.Server` validates each call's
arguments against the tool's `inputSchema` (`jsonschema`) before the adapter runs. Rather than
duplicate that validation, every expressible constraint lives in the schema: `required` fields,
the `kind` enum (sourced from one shared `query.INTENT_KINDS` constant that the argparse `choices`
also use), numeric bounds (`top >= 1`, `0 <= min_confidence <= 1`), string/array size caps, and
`additionalProperties: false`. A schema violation surfaces to the client as a protocol-level
`isError` result with an "Input validation error" message. The adapters validate only what a
schema cannot express: the cross-field rule that `intent_changed` needs `files` or `base`.

**Explicit `CallToolResult`; set `isError` only on real failures.** Each `call_tool` handler
builds a `CallToolResult` carrying both a JSON `content` block and a `structuredContent` object
(the channel modern clients parse). `structuredContent` returned from the handler is supported
from mcp 1.19 (the floor, verified empirically: 1.18 fails, 1.19 passes), pinned `<2` to avoid the
breaking 2.0 line. Semantic outcomes
(`ok` / `not_found` / `ambiguous` / `no_intent_layer` / `no_strong_match` / `no_changes`) are
answers, not errors, so they keep `isError = false`. Only a `status: "error"` result (a bad
cross-field input, a resolver failure, or an unexpected exception) is wrapped in an explicit
`CallToolResult(isError = true)`, so a client surfaces genuine failures without having to parse
the JSON body.

**No tool call may terminate the server.** A one-shot CLI can `die()` (raise `SystemExit`) on bad
input; a long-lived server must not. Two changes enforce this. First, the shared git/snapshot
resolvers (`git_changed_files`, `load_snapshot`) were refactored to raise the catchable
`QueryInputError` instead of calling `die()`; the CLI callers convert that back to `die()` so CLI
stderr text and exit codes are unchanged, while the MCP adapters convert it to a structured error.
Second, the dispatcher wraps every adapter in `except (Exception, SystemExit)`, not
`except Exception`: `SystemExit` subclasses `BaseException`, so a bare `except Exception` would let
a stray `die()` kill the whole server. `BaseException` itself is deliberately not caught, so
`KeyboardInterrupt` and `CancelledError` still propagate.

**Security hardening at the agent trust boundary.** Tool arguments come from an LLM, which is
prompt-injection-reachable, so the git/snapshot resolvers are hardened (and the CLI benefits too).
`git rev-parse --verify` plus `--end-of-options` on every ref token neutralize option injection: an
agent-supplied `base` or `since` like `--output=/path` is rejected as an invalid ref instead of
being executed as a git option that writes a file. Snapshot reads reject non-regular and oversized
files (so `since=/dev/zero` or a FIFO cannot hang the single-threaded server), the graph load is
size-capped, and both git calls run with an explicit `cwd` (the graph's repo) and a `timeout`.
Unexpected-exception messages are generic (`"internal error"`, with detail logged to stderr) rather
than echoing `str(exc)` back to the agent.

**Local stdio only; read-only surface only.** The transport is stdio, the standard MCP default that
Claude Code and IDE agents speak. Remote/HTTP transport and authentication are out of scope for
this version. The server only reads a local graph: no mutations, no pipeline runs, no backend/LLM
calls, no secrets. `graph_path` is fixed at server start from `--graph`; it is not a per-call
argument. Diagnostics go to stderr only, because stdout is the JSON-RPC channel.

## Consequences

* An editor agent can call `intent_explain` / `intent_changed` before touching a file and see the
  constraints it is bound by, without a human shelling out. The `why` stays in the flow of work.
* The `mcp` SDK stays out of the default install; only users who run `--mcp` pull it in. A missing
  extra is a clean, actionable message, not an import traceback.
* The pure-builder extraction means `query.py` now owns the CLI's output envelopes, not just the
  raw query cores. `cli.py`'s `_run_query` / `_run_team` shrank to thin callers, and parity is a
  structural guarantee rather than a thing to remember.
* Bad tool input is reported the MCP-native way (`isError` + "Input validation error") rather than
  as a bespoke JSON envelope, so schema-aware clients validate client-side too. This differs from
  the CLI's argparse errors, which is expected: validation is a separate surface from result parity.
* The `die()`-to-raise refactor added the first regression tests for the git-diff-failure and
  bad-`--since` paths (previously untested), pinning their CLI stderr/exit behavior.
* The server is exercised two ways in CI: an in-memory `ClientSession` over the real SDK for each
  tool, and a subprocess that drives the real `graphify-intent --mcp` binary over real stdio, so the
  transport, argv routing, and the stdout-is-JSON-RPC invariant are all covered.
* Remote transport, auth, and running the pipeline over MCP remain out of scope; the read-only
  stdio surface is the whole first version.
