Skip to content

feat(mcp): use --format llm where the installed CLI supports it - #23

Merged
josephismikhail merged 5 commits into
mainfrom
feat/llm-fastpath
Aug 11, 2026
Merged

feat(mcp): use --format llm where the installed CLI supports it#23
josephismikhail merged 5 commits into
mainfrom
feat/llm-fastpath

Conversation

@KageBinary

Copy link
Copy Markdown
Collaborator

Why

Every read tool called _json() and handed the result to _ok(), which re-serialises it with indent=2. So the JSON was parsed only to be printed straight back to the model — and printed larger than it arrived. --format llm is the same content rendered 2-4x smaller on tree- and table-shaped output.

Ported from ix-cursor-plugin's mcp/lib/llm.ts, which has run this design since v0.7.0: try the fast-path, and on anything short of a confident success return None so the caller runs its unchanged JSON path.

The part that is not a straight port

The version floor has to be per command

--format llm did not arrive all at once. Cursor's single MIN_LLM_VERSION = 0.7.0 is correct only because none of its twelve tools touches a command that landed later. This server exposes two that do.

commands renderer landed in
Tier 1-4 — map subsystems impact smells overview stats inventory rank depends trace callers callees imports imported-by text history locate diff v0.7.0
Tier 5 — explain read v0.9.2

Confirmed against the tags containing each tier's commit, not inferred from the docs.

Getting it wrong would not have raised

ix does not validate --format. Every renderer is if json: … elif llm: … else: text, so an unrecognised value falls through to human text and exits 0. On 0.9.1, explain --format llm returns prose, successfully. A single 0.7.0 gate would have forwarded that to the model as though it were records — no exception, no non-zero exit, nothing to catch.

The same property is what makes the whole change safe: there is no version of ix on which asking for llm breaks. The floors buy output quality, not crash-avoidance. That is worth being explicit about, because it means the downside of this PR being wrong somewhere is degraded output, never a broken tool.

Pro commands are excluded outright

briefing and decisions come from @ix/pro, whose commands declare only text|json — there is no llm renderer at any version, so no version gate can help. They are absent from the table, which is what makes ix_decisions safe despite routing through the same helper. There is a test pinning their absence.

Also carried over

  • error code= deferral. ix reports some failures as a record on stdout with exit 0. A caller checking only the exit status would forward an error line as a result; detecting it defers to the JSON path, so the error contract is unchanged.
  • IX_DISABLE_LLM_FORMAT=1 forces the JSON path everywhere.
  • diff --content stays on text — verbatim hunks have no record form (documented as a deliberate exception in docs/llm-format.md). ix_diff does not pass --content today, so this guards a future edit.

Packaging

The gate lives in a new mcp/ix_llm.py rather than inside server.py, and install_mcp now ships both files — it previously copied server.py alone.

server.py imports it defensively: a partial or hand-copied install loses the fast-path rather than failing to start. An ImportError there would take down all 23 tools to save tokens on some of them, which is not a trade worth making.

Verified end to end: a real --mcp install lands both files, and ix_llm.py loads standalone from the installed location.

Tests

26 new, most of them on the boundary: each floor from both sides (0.6.0/0.7.0, and 0.7.0→0.9.1 refused for Tier 5 vs 0.9.2 allowed), Pro commands refused at any version, an unreadable or failed or raising probe disabling the path, the probe running exactly once, the kill switch, error code= deferral, and that llm output is never parsed.

All five existing test files pass; shellcheck -S error clean.

Note for reviewers

Touches mcp/server.py, which #13 and #16 also change. The edits here are confined to a new helper plus one-line tool bodies, but #13 is based on an older main and will need a rebase regardless.

KageBinary and others added 5 commits August 10, 2026 17:35
Every read tool called `_json()` and handed the result to `_ok()`, which
re-serialises it with `indent=2`. The JSON was parsed only to be printed
straight back to the model — and printed *larger* than it arrived. `--format
llm` is the same content rendered 2-4x smaller on tree- and table-shaped
output, which is what it was added to the CLI for.

Ported from ix-cursor-plugin's `mcp/lib/llm.ts`, which has run this design since
v0.7.0: try the fast-path, and on anything short of a confident success return
None so the caller runs its unchanged JSON path. Two things had to change for
this surface.

**The version floor is per command, not global.** `--format llm` landed tier by
tier. Cursor's single `MIN_LLM_VERSION = 0.7.0` is correct only because none of
its twelve tools touches a command that came later; this server exposes two that
do. `explain` and `read` are Tier 5 and only grew renderers in **0.9.2**, so
they carry their own floor.

Getting that wrong would not have raised. `ix` does not validate `--format` —
every renderer is `if json: ... elif llm: ... else: text` — so an unrecognised
value falls through to human text and exits 0. On 0.9.1, `explain --format llm`
returns prose, successfully. A single 0.7.0 gate would have forwarded that to
the model as though it were records, with nothing to catch.

The same property is what makes this safe: there is no version of `ix` on which
asking for `llm` breaks. The floors buy output quality, not crash-avoidance.

**Pro commands are excluded outright.** `briefing` and `decisions` come from
`@ix/pro`, whose commands declare only `text|json`; there is no llm renderer at
any version, so no gate can help. They are absent from the table, which is what
makes `ix_decisions` safe despite routing through the same helper.

Also carried over from the reference: the `error code=` check. `ix` reports some
failures as a record on stdout *with exit 0*, so a caller that only checked the
exit status would forward an error line as a result. Detecting it defers to the
JSON path, leaving the error contract identical.

`IX_DISABLE_LLM_FORMAT=1` forces the JSON path everywhere.

The gate lives in a new `mcp/ix_llm.py` rather than in `server.py`, and
`install_mcp` now ships both files. `server.py` imports it defensively: a
partial install loses the fast-path rather than failing to start, because an
ImportError there would take down all 23 tools to save tokens on some of them.

26 tests, most of them on the boundary: each floor from both sides, Pro commands
refused at any version, an unreadable or failed probe disabling the path, the
probe running once, the kill switch, `error code=` deferral, and that the output
is never parsed.
The fast-path rewrites the format token, and whether it engages depends on a
version memoised in ix_llm for the life of the process. `test_mcp_cli_invocation`
never touched that cache, so its 23 argv assertions silently inherited whatever
ran before them:

  * file alone      -> cache already primed, fast-path off, `--format json`, green
  * unittest discover -> test_llm_fastpath's tearDown had just reset it, so the
    probe ran against this file's own fake `ix` (which reports 0.9.1), the
    fast-path engaged, and every expected `json` arrived as `llm`

Green on Windows and red on Linux, and green under test-local.sh either way --
it runs each file in its own process, so the leak cannot show there. Only
`unittest discover`, which is what CI now runs, puts them in one process.

Disabled rather than accommodated. This file pins that all 23 tools reach the
CLI and ask for a machine format; which token a current CLI is asked for is
test_llm_fastpath's job, and it already asserts `["stats", "--format", "llm"]`.
The cache is reset either side so ordering cannot matter again.

Verified on Linux, where it discriminates: Ian's version fails 3 under discover,
this passes, and alone / discover / llm-first / mcp-first all agree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… takes

Review follow-ups. The version floors themselves all check out -- I could not
falsify one against the Ix tags -- but two things did.

**`diff` must never use the fast-path, and no floor can express that.** Its
renderer has branches with no llm arm at any version. The textual-changes path
-- graph reports no change while the file text differs -- falls to the text
`else` and prints "<name> modified (<n> textual changes -- not captured by
parser)" at exit 0, and `ix_diff` reaches it with the arguments it already
sends. Prose at exit 0 is exactly what this module forwards as records, which is
the failure the table is supposed to prevent. It is a per-code-path gap, not a
version one, so the entry is gone rather than raised.

`map` and `smells` are gone too: neither goes through `_read`, so their entries
could never be consulted and read as support that had been considered and
granted. `ix_smells` in particular filters client-side on parsed candidates and
needs records.

**The argv sweep was measuring a path production does not take.** My previous
commit fixed its ordering dependence by switching the feature off, which left
the shipped path with no end-to-end coverage anywhere -- test_llm_fastpath only
drives ix_llm against a stub. The expected format token is now derived from the
same table production consults, so the sweep exercises both sides of the gate
(the fake reports 0.9.1: above the Tier 1-4 floor, below Tier 5's 0.9.2). The
version probe is filtered out of the argv log rather than assumed to land in a
particular place, which is what made ordering matter.

Also from review:

* `.strip()` broke a self-delimiting frame. `read` emits `content lines=<n>`
  then n raw lines, so stripping trailing whitespace deletes blank final lines
  and leaves the count over-reporting. Exactly one trailing newline now.
* `parse_semver` searched unanchored, so "node v20.11.0 / ix 0.7.0" gated on
  node's version. Decoration still parses -- "ix 0.9.2 (linux-amd64)" was a
  deliberate case -- but more than one version-shaped token now refuses, because
  reading the wrong version is the one error that turns the fast-path on for a
  CLI that renders prose.
* `_TEXT_ONLY_FLAGS` compared exact tokens, so `--content=x` slipped past the
  guard whose whole purpose is surviving a future edit.
* Four docstrings named fields only the JSON path produces (`risk_level`,
  `recommended_action`, callees-as-a-list). The docstring is the model's
  contract; promising keys that arrive on one path only is a promise the tool
  cannot keep. And `_read`'s own comment claimed Pro commands do not come
  through it -- `ix_decisions` does; they are excluded by the table.

Mutations: 14, all caught. The four that survived before -- treating a failed
probe as a good version, string-comparing versions (making 0.10.0 older than
0.9.2), deleting the exit-code guard, and the kill switch -- now fail, including
the previously vacuous failed-invocation test, which passed with its guard
deleted because its fake also returned empty output.

All suites green on Windows with and without NoDefaultCurrentDirectoryInExePath,
and on Linux, in every file ordering.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The argv sweep was still order-dependent, just in the other direction:
`test_llm_fastpath` loads `mcp/ix_llm.py` under the same module name, so
whichever test file was imported last owned `sys.modules["ix_llm"]`, and the
server could bind an instance whose memoised version this file never reset. With
`test_mcp_cli_invocation` first, every expected `llm` arrived as `json`.

`unittest discover` walks the files alphabetically, which happens to be the
order that works -- so CI was green and the dependence was invisible there.
`_load_server` now pins our instance for the exec, which is the only way the
reset in setUp can be about the module under test.

Verified across five file orderings on Windows and both on Linux, and proven
non-vacuous: neutering `_expected_argv` so it never swaps the token fails the
suite.

Also pinned two guards from this round that had no test: the single-trailing-
newline rule (a `read` payload ending in blank lines keeps them, so `lines=<n>`
still counts what follows), and the ambiguity refusal (`ix 0.9.2 (linux-amd64)`
parses, `node v20.11.0 / ix 0.7.0` does not). Mutating either, or reverting the
`--content=` prefix match, now fails.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@josephismikhail josephismikhail left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. Same disclosure as the rest: I pushed three commits here, so the gate was three rounds of independent review plus verification on Windows with and without NoDefaultCurrentDirectoryInExePath, and on Linux under WSL.

The homework in this one is real and it held up. I could not falsify a single version floor — swept the Ix trees at v0.6.0 through v0.9.2 and explain/read genuinely have no llm dispatch until 0.9.2, while every Tier 1-4 command has one at 0.7.0. The Pro exclusion is necessary rather than cautious (briefing declares text|json, decisions delegates to a formatter with no llm branch). A missing table entry means refused, not unrestricted. Every failure direction I could construct — junk version, empty, 0.9, raising probe, non-zero probe — falls closed to the byte-identical JSON path. And the thing I cared about most: try_llm receives _run itself, so the guards from #13/#19 all still apply on the new path. Driving the real server with the fast-path engaged, ix_locate("Widget&whoami") is still refused before exec.

diff had to come out

--format being unvalidated is the whole risk, and you documented it precisely: an unknown value falls through to human text at exit 0, so a wrong floor never errors, it just answers with prose. The table prevents that — except where the gap is per-code-path rather than per-version.

diff's renderer has branches with no llm arm at any version. The textual-changes path — graph reports no change while the file text differs — drops to the text else and prints <name> modified (<n> textual changes — not captured by parser) at exit 0. ix_diff reaches it with the arguments it already sends, and try_llm forwards it as records. No release makes that right, so the fast-path must never ask.

That distinction is also why the one other gap found — subsystems --explain, which has no llm renderer either — does not warrant removal: it needs both --explain and a --target, and ix_subsystems() takes no parameters. ix_diff exposes target as a model-settable argument; ix_subsystems exposes nothing. Worth knowing if that ever changes.

map and smells are gone too, for a duller reason: neither reaches _read, so their entries could never be consulted, and a listed command reads as support that was considered and granted. ix_smells in particular filters client-side on parsed candidates and needs records.

What the tests were not covering

The 23-tool sweep was the only thing driving all the tools against a real CLI, and it was measuring a path production does not take — which is why the diff defect was invisible. My own first fix made that worse: I made it deterministic by switching the fast-path off. The expected format token now derives from the same table production consults, so the sweep exercises both sides of the gate (the fake reports 0.9.1: above Tier 1-4, below Tier 5's 0.9.2). Neutering the token swap fails the suite, so it is not decorative.

It was also order-dependent twice over, and both times unittest discover's alphabetical order was the one that passed — so CI was green and the dependence invisible. First through the memoised version leaking between files; then, subtler, because test_llm_fastpath loads ix_llm under the same module name, so whichever file imported last owned sys.modules["ix_llm"] and the server could bind an instance the reset never touched. Pinned at exec time. Five orderings on Windows, both on Linux.

Four other things:

  • .strip() broke a self-delimiting frame — read emits content lines=<n> then n raw lines, so trailing blank lines vanished and the count over-reported. One trailing newline now.
  • parse_semver searched unanchored, so node v20.11.0 / ix 0.7.0 would gate on node's version. Your decorated case was right to support, so it still parses ix 0.9.2 (linux-amd64) — but two version-shaped tokens now refuse, because reading the wrong version is the one error that turns the fast-path on for a CLI that renders prose.
  • _TEXT_ONLY_FLAGS compared exact tokens, so --content=x slipped past the guard whose entire purpose is surviving a future edit.
  • Four docstrings named fields only the JSON path emits (risk_level, recommended_action, callees as a list). The docstring is the model's contract, and those keys arrive on one path only. _read's own comment also claimed Pro commands do not come through it — ix_decisions does; they are excluded by the table, which is a stronger guarantee and worth stating as such.

Mutations: 17, none surviving. The four that survived earlier — a failed probe treated as a good version, string-comparing versions (making 0.10.0 older than 0.9.2), the deleted exit-code guard, and the kill switch — are all pinned now, including the failed-invocation test that passed with its guard removed because its fake also returned empty output.

@josephismikhail
josephismikhail merged commit 6bcdeb1 into main Aug 11, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants