Skip to content

feat(mcp): serve a usage spec to an agent over stdio - #746

Merged
jdx merged 5 commits into
mainfrom
feat/usage-mcp
Jul 27, 2026
Merged

feat(mcp): serve a usage spec to an agent over stdio#746
jdx merged 5 commits into
mainfrom
feat/usage-mcp

Conversation

@jdx

@jdx jdx commented Jul 27, 2026

Copy link
Copy Markdown
Owner

usage mcp -f mycli.usage.kdl speaks the Model Context Protocol, so an agent can ask what a command does before running it. Against pitchfork's real spec:

pitchfork logs            effect=read
  --clear                 effect=destructive   Delete logs
pitchfork daemons remove  effect=destructive

This is the payoff for effect=. Five CLIs now declare it across roughly 385 commands — and until now nothing could read any of it. The data existed and no consumer did.

Why local rather than usage.sh

An agent doing real work is in a project, in front of a CLI that is installed. "What does this do" is a local question. Answering it locally costs nothing to run, has no abuse surface, and works for private and internal CLIs a public service could never see.

usage.sh stays the right home for the other case — a CLI you don't have and want to ask about — which is also where the vendor skills live (jdx/usage-sh#8).

The tools

list_commands the tree, each command with its effect
describe_command one command's help, flags, arguments, and the effect of each

Effect is reported as an attribute beside help and aliases, not as a separate concept — per your call on #742. An unset effect stays null rather than defaulting to something reassuring, and the server instructions spell out what the three values mean and that a missing one means ask, since the client sees those before it sees any command.

Hidden commands are excluded with their subtrees — a visible child of a hidden parent isn't a documented path, the same bug found in jdx/usage-sh#8 — with include_hidden to opt back in.

Hand-written protocol

JSON-RPC 2.0 over newline-delimited stdio. A read-only server needs initialize, tools/list and tools/call; an SDK would have pulled an async runtime into a CLI that has no tokio and doesn't need one. serde_json was already a dependency, so this adds none.

Tests

13, covering the protocol edges as much as the data:

  • notifications get no reply (every client sends notifications/initialized; answering it is a violation)
  • unparseable input answers with a null id, as JSON-RPC prescribes
  • an unknown method is a protocol error, but an unknown command is a tool error the agent can recover from — and its message points at list_commands
  • structured results also carry their JSON as text, which the spec asks servers to do
  • a flag with no effect doesn't inherit the command's
  • serve handles a full session: two requests plus a notification produce exactly two responses

Verified end to end by piping a real session into the built binary against pitchfork.usage.kdl, not only through unit tests.

cargo test -p usage-cli, clippy --all-targets, fmt --check clean. mise run render regenerated the spec, man page, fig completions and CLI docs.

Worth noting

usage's own spec declares no effects yet — usage mcp can describe every CLI except the one it ships in. Dogfooding that is a natural follow-up and I left it out to keep this reviewable.

This PR was generated by an AI coding assistant.


Note

Medium Risk
New async MCP surface and dependency stack (tokio/rmcp); incorrect effect or flag metadata could mislead agents, though the server only reads the loaded spec.

Overview
Adds usage mcp (alias mcp-server), a local Model Context Protocol server on stdio via rmcp and a minimal tokio runtime. Clients load a spec with -f / -s; --file - is rejected because stdin is the transport.

The server exposes list_commands and describe_command, returning help plus effect on commands, flags, and args (unset stays null). Server instructions explain read / write / destructive. Hidden commands are omitted from listings unless requested, but can still be described.

usage-lib gains public available_flags so flag reporting matches parser merge rules (globals, re-declarations). command_effects classifies mcp as read-only. Generated CLI docs, man page, and Fig completions are updated.

Reviewed by Cursor Bugbot for commit 259021a. Bugbot is set up for automated code reviews on this repo. Configure here.

Summary by CodeRabbit

  • New Features

    • Added the usage mcp command (also available as mcp-server) to serve usage specifications through the Model Context Protocol.
    • Supports loading specifications from a file or directly from a provided string.
    • Exposes tools for listing available commands and viewing detailed command descriptions, including flags, arguments, aliases, and effects.
    • Includes hidden-command handling and inherited global options in command details.
  • Documentation

    • Added CLI reference documentation, usage instructions, and completion support for the new command.

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Central YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: 1940024e-0187-4d68-96cb-4cc2ac27249b

📥 Commits

Reviewing files that changed from the base of the PR and between d97b2c7 and 259021a.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (12)
  • cli/Cargo.toml
  • cli/assets/fig.ts
  • cli/assets/usage.1
  • cli/src/cli/mcp.rs
  • cli/src/cli/mod.rs
  • cli/src/command_effects.rs
  • cli/usage.usage.kdl
  • docs/cli/reference/commands.json
  • docs/cli/reference/index.md
  • docs/cli/reference/mcp.md
  • lib/src/lib.rs
  • lib/src/parse.rs

📝 Walkthrough

Walkthrough

Adds a new usage mcp command that serves usage specifications over MCP stdio, exposes command-listing and command-description tools, and documents inherited flags through a new public parser helper.

Changes

MCP usage server

Layer / File(s) Summary
Public flag discovery API
lib/src/lib.rs, lib/src/parse.rs
Exports available_flags, which computes recognized flags across command paths and tests inheritance and redeclaration behavior.
MCP command and tools
cli/Cargo.toml, cli/src/cli/mcp.rs, cli/src/cli/mod.rs, cli/src/command_effects.rs
Adds MCP stdio serving, list_commands and describe_command tools, command resolution, response conversion, CLI dispatch, and read-effect classification.
CLI contract and reference documentation
cli/usage.usage.kdl, cli/assets/fig.ts, cli/assets/usage.1, docs/cli/reference/commands.json, docs/cli/reference/index.md, docs/cli/reference/mcp.md
Defines, autocompletes, manpages, and documents the mcp command and its options.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant MCPClient
  participant UsageMcp
  participant SpecServer
  participant UsageSpec
  MCPClient->>UsageMcp: Send JSON-RPC over stdin
  UsageMcp->>SpecServer: Start MCP stdio transport
  SpecServer->>UsageSpec: Resolve commands and flags
  SpecServer-->>MCPClient: Return tool response over stdout
Loading

Possibly related PRs

  • jdx/usage#751: Adds effect-classification machinery used by the new mcp read-effect entry.

Poem

A rabbit found a usage scroll,
And served it through the MCP hole.
Commands hopped out, flags stood tall,
Aliases answered every call.
“Read-only magic!” thumped the doe.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread cli/src/cli/mcp.rs Outdated
@greptile-apps

greptile-apps Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds a local stdio MCP server for querying usage specifications.

  • Introduces list_commands and describe_command tools with command, argument, flag, and effect metadata.
  • Rejects stdin-based spec loading because stdin is reserved for MCP transport.
  • Exposes parser-aligned inherited flag resolution through usage::available_flags.
  • Registers the new command and regenerates CLI documentation, manpage, Fig completions, and dependency metadata.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
cli/src/cli/mcp.rs Implements the MCP server and fully addresses the prior stdin-transport and inherited-flag reporting findings.
lib/src/parse.rs Adds a public flag-resolution helper that follows the parser’s existing global inheritance and re-declaration behavior.
lib/src/lib.rs Re-exports the new available-flags API for CLI consumers.
cli/src/cli/mod.rs Registers and dispatches the new MCP subcommand.
cli/usage.usage.kdl Adds the MCP command to the CLI’s generated self-description.
cli/Cargo.toml Adds the runtime and protocol dependencies required by the MCP server.

Reviews (5): Last reviewed commit: "fix(mcp): make the two tools agree on ho..." | Re-trigger Greptile

Comment thread cli/src/cli/mcp.rs Outdated
Comment thread cli/src/cli/mcp.rs Outdated
@jdx

jdx commented Jul 27, 2026

Copy link
Copy Markdown
Owner Author

Switched the implementation to rmcp, matching mise mcp and fnox mcp.

My original reason for hand-rolling the protocol — avoiding an async runtime in a synchronous CLI — was asserted rather than measured. Measured, the cost is binary size and nothing else:

main this branch
release binary 8,417,552 B 11,365,072 B (+2.8 MiB)
complete-word, mise's 208 KB spec, 120 runs 117.8 ms median 118.1 ms median

The tokio runtime is built inside mcp run, so no other subcommand pays for it. In exchange the server inherits version negotiation, pagination, cancellation, tools/list schemas generated from the params structs, and protocol-vs-tool error framing, and mcp.rs loses ~170 lines.

Two bugs surfaced while doing it:

  • get_info now sets server_info explicitly. rmcp's default is Implementation::from_build_env(), whose env! expands inside the rmcp crate — so a server that omits it introduces itself to clients as rmcp 2.2.0. (mise mcp has this today; worth a separate fix.)
  • --file - is rejected instead of accepted. It reads stdin to EOF, which is the transport the server then wants to serve on.

Verified end to end against a real client handshake — initializenotifications/initializedtools/listtools/call — returning {"name":"usage","version":"4.0.0"}, protocol 2025-06-18, both tools with generated schemas, and effect present on the command and its flags.

This comment was generated by an AI coding assistant.

@socket-security

socket-security Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedcargo/​tokio@​1.53.15810093100100
Addedcargo/​rmcp@​2.2.089100100100100

View full report

@socket-security

socket-security Bot commented Jul 27, 2026

Copy link
Copy Markdown

Warning

Review the following alerts detected in dependencies.

According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.

Action Severity Alert  (click "▶" to expand/collapse)
Warn High
Obfuscated code: cargo tokio is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: Cargo.lockcargo/tokio@1.53.1

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore cargo/tokio@1.53.1. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

Comment thread cli/src/cli/mcp.rs Outdated
@jdx

jdx commented Jul 27, 2026

Copy link
Copy Markdown
Owner Author

Addressed the review:

Stdin spec exhausts MCP stream (cursor, greptile) — fixed in 4d831d9. --file - is rejected outright rather than producing a session that ends before it begins.

Inherited global flags are omitted (greptile) — correct and fixed in e9bc59f. usage resolves ancestors' global=#true flags while parsing an invocation instead of copying them onto each subcommand (parse.rs:40), so cmd.flags was short by exactly the options an agent reaches for. describe_command now appends them marked "inherited": true, and a nearer redefinition shadows a farther one the way the parser resolves it — otherwise a subcommand that redefines a global without an effect would be reported as carrying the global's.

Describe exposes hidden commands (cursor) — deliberate, now commented and tested rather than incidental. list_commands omits hidden subtrees because they aren't documented paths. But an agent that names a hidden command already knows it exists — it saw it in a script or in shell history — and refusing to describe it doesn't stop the run, it just removes the effect from what the agent knows before running. The response carries "hidden": true so the caller can weigh it.

Verified end to end against a client handshake; pitchfork daemons remove now reports the inherited --yes with its effect: write.

This comment was generated by an AI coding assistant.

Comment thread cli/src/cli/mcp.rs Outdated
Comment thread cli/src/cli/mcp.rs Outdated
jdx added a commit that referenced this pull request Jul 27, 2026
My previous commit walked the ancestor chain itself and let a nearer
declaration shadow a farther one. That is backwards. `merge_subcommand_flags`
treats a non-global re-declaration sharing a global's long name as the *same*
logical flag: the global's declaration survives and only the re-declaration's
extra aliases are unioned in. So a subcommand re-declaring `-y --yes` without
an effect was reported as `effect: null` when the flag actually carries the
global's `write` — the exact failure this server exists to prevent, and worse
than the omission it replaced.

Rather than restate those rules a second time, `usage::available_flags(chain)`
exposes the parser's own resolution and `describe_command` calls it. A test
asserts it agrees with `parse_partial` for every command in a spec, so the two
cannot drift.

Two things fell out:

- The merge can leave one logical flag under two `Arc`s — the merged
  declaration on the long key, the pre-merge one on the short — so
  `available_flags` collapses by name after the pointer dedup. Harmless for
  parsing, which looks up by key, visible to anything listing flags.
- The `"inherited"` field is gone. Under these rules there is no clean
  local-vs-inherited split, and asserting one would be another small lie.
  `"global"` already says the flag is accepted everywhere.

Reported by cursor and greptile on #746.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jdx
jdx force-pushed the feat/usage-mcp branch from e9bc59f to 81fbf53 Compare July 27, 2026 02:01
@jdx

jdx commented Jul 27, 2026

Copy link
Copy Markdown
Owner Author

Rebased onto main (which now has #747) and fixed the flag resolution. cursor and greptile were both right, and my previous fix was worse than the bug it replaced.

I walked the ancestor chain by hand and let a nearer declaration shadow a farther one. merge_subcommand_flags does the opposite: a non-global re-declaration that shares a global's long name is the same logical flag, so the global's declaration survives and only the re-declaration's extra aliases are unioned in. A subcommand re-declaring -y --yes was therefore reported as effect: null when the flag actually carries the global's write — telling an agent a consequential flag is free of consequence, which is the precise failure this server exists to prevent.

The fix isn't to restate those rules more carefully. usage::available_flags(chain) now exposes the parser's own resolution and describe_command calls it, with a test asserting it agrees with parse_partial for every command in a spec so the two can't drift.

Two things fell out:

  • The merge can leave one logical flag under two Arcs — the merged declaration on the long key, the pre-merge one on the short key, because the !Arc::ptr_eq guard sees the freshly-merged flag as "a different global". Harmless for parsing (lookups are by key) but visible to anything listing flags, so available_flags collapses by name after the pointer dedup.
  • "inherited" is removed from the output. Under these rules there's no clean local-vs-inherited split, so the field could only be another small lie. "global" already tells the caller the flag is accepted everywhere.

Verified end to end: a spec with a long-only global --yes effect="write" re-declared as a non-global -y --yes on a subcommand now returns one flag, effect: write, with the -y short unioned in.

This comment was generated by an AI coding assistant.

Comment thread cli/src/cli/mcp.rs
jdx and others added 5 commits July 27, 2026 20:29
`usage mcp -f mycli.usage.kdl` speaks the Model Context Protocol, so an
agent can ask what a command does before running it:

    pitchfork logs            effect=read
      --clear                 effect=destructive   Delete logs
    pitchfork daemons remove  effect=destructive

That is the payoff for `effect=`. Five CLIs now declare it across roughly
385 commands, and until now nothing could read any of it — the data existed
and no consumer did.

Local rather than hosted, on purpose. An agent doing real work is in a
project, in front of a CLI that is installed, so "what does this do" is a
local question. It costs nothing to run, has no abuse surface, and works for
private and internal CLIs a public service could never see. usage.sh stays
the right place for the other case: a CLI you do not have and want to ask
about.

Two tools. `list_commands` gives the tree with each command's effect;
`describe_command` gives one command's help, flags and arguments with the
effect of each. Effect is reported as an attribute alongside help and
aliases, not as a separate concept, and an unset one stays null rather than
defaulting to something reassuring. The server instructions spell out what
the three values mean and that a missing one means ask, since the client
sees them before it sees any command.

Hidden commands are excluded with their subtrees, since a visible child of a
hidden parent is not a documented path, and `include_hidden` opts back in.

The protocol is hand-written: JSON-RPC 2.0 over newline-delimited stdio, and
a read-only server needs `initialize`, `tools/list` and `tools/call`. An SDK
would have pulled an async runtime into a CLI that has no tokio and does not
need one. serde_json was already here.

13 tests cover the protocol edges as well as the data — notifications
getting no reply, unparseable input answering with a null id, an unknown
method being a protocol error while an unknown command is a tool error the
agent can recover from, and structured results also carrying their JSON as
text, which the spec asks for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first pass implemented the protocol by hand to avoid pulling an async
runtime into a synchronous CLI. That justification was asserted, not
measured, and it diverges from `mise mcp` and `fnox mcp`, which both use
rmcp — three servers in one author's tools should not each reimplement a
different subset of MCP.

Measured, the cost is size only:

  binary       8,417,552 -> 11,365,072 bytes (+2.8 MiB)
  complete-word (mise's 208 KB spec, 120 runs)
               117.8 ms -> 118.1 ms median

The runtime is built inside `mcp run`, so nothing else pays for it. In
exchange the server gets version negotiation, pagination, cancellation,
`tools/list` schemas generated from the params structs, and correct
protocol-vs-tool error framing, and the module drops ~170 lines.

Two things the rewrite fixed on the way:

- `get_info` sets `server_info` explicitly. rmcp's default reads the
  `CARGO_*` vars of its own crate, so a server that omits it introduces
  itself to clients as "rmcp 2.2.0".
- `--file -` is now rejected. It reads stdin to EOF, which is the
  transport the server then wants to serve on, so it could only ever
  produce a session that ended before it began.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
usage resolves `global=#true` flags from ancestors while parsing an
invocation rather than copying them onto each subcommand, so
`describe_command` reporting only `cmd.flags` left them out entirely.
`pitchfork daemons remove` accepts `--yes`, and that flag declares
`effect="write"` — precisely the thing this server exists to surface.

Inherited flags are appended after the command's own and marked
`"inherited": true`. A nearer definition shadows a farther one, matching
how the parser resolves the collision, so a subcommand that redefines a
global without an effect is not reported as carrying the global's.

Describing a hidden command stays allowed, now with a comment and a test
saying so deliberately. `list_commands` omits them, but an agent that
names one already knows it exists; refusing would only mean it runs the
command without learning the effect. The response carries
`"hidden": true` either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
My previous commit walked the ancestor chain itself and let a nearer
declaration shadow a farther one. That is backwards. `merge_subcommand_flags`
treats a non-global re-declaration sharing a global's long name as the *same*
logical flag: the global's declaration survives and only the re-declaration's
extra aliases are unioned in. So a subcommand re-declaring `-y --yes` without
an effect was reported as `effect: null` when the flag actually carries the
global's `write` — the exact failure this server exists to prevent, and worse
than the omission it replaced.

Rather than restate those rules a second time, `usage::available_flags(chain)`
exposes the parser's own resolution and `describe_command` calls it. A test
asserts it agrees with `parse_partial` for every command in a spec, so the two
cannot drift.

Two things fell out:

- The merge can leave one logical flag under two `Arc`s — the merged
  declaration on the long key, the pre-merge one on the short — so
  `available_flags` collapses by name after the pointer dedup. Harmless for
  parsing, which looks up by key, visible to anything listing flags.
- The `"inherited"` field is gone. Under these rules there is no clean
  local-vs-inherited split, and asserting one would be another small lie.
  `"global"` already says the flag is accepted everywhere.

Reported by cursor and greptile on #746.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`list_commands` emitted `"daemons remove"` while `describe_command`
answered with `"pitchfork daemons remove"`. An agent doing the obvious
thing — read the list, describe an entry, describe something the first
response mentioned — got a tool error on a command that exists, because
`find_chain` read the binary name as a subcommand.

`describe_command` now reports the path alone and carries `bin` beside
it, the way `list_commands` already does. A test walks every row of
`list_commands` through `describe_command` and asserts the name comes
back unchanged, so the two ends cannot drift apart again.

A leading binary name is also accepted now, since an agent that has seen
the CLI in a shell writes the whole line. Only skipped when the root has
no subcommand by that name, so a CLI with a `usage usage` keeps
resolving its own command rather than losing it to the prefix.

Rebased onto main, which brought #751. Its `nothing_is_unclassified_by_accident`
test failed immediately on `mcp`, which is what it is for; classified
`read`, since every tool this serves only reads the spec it was handed.
Unlike `mise mcp`, which stays unclassified because it serves a tool that
runs tasks, nothing here can act on the CLI it describes.

Reported by cursor on #746.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jdx
jdx force-pushed the feat/usage-mcp branch from 81fbf53 to 259021a Compare July 27, 2026 20:31
@jdx

jdx commented Jul 27, 2026

Copy link
Copy Markdown
Owner Author

Rebased onto main (which brought #751) and fixed the path inconsistency.

Command path format inconsistent (cursor) — correct. list_commands emitted "daemons remove" while describe_command answered "pitchfork daemons remove", so an agent doing the obvious thing — read the list, describe an entry — got a tool error on a command that exists.

describe_command now reports the path alone with bin beside it, matching list_commands. A test walks every row of list_commands through describe_command and asserts the name comes back unchanged, so the two ends can't drift apart again.

A leading binary name is accepted too, since an agent that has seen the CLI in a shell will write the whole line. It's only skipped when the root has no subcommand by that name — a CLI with a usage usage keeps resolving its own command rather than losing it to the prefix, and there's a test for that.

One thing worth reporting: the rebase brought #751's nothing_is_unclassified_by_accident test, and it failed immediately on mcp — the new command this PR adds. That is exactly what that guard is for, one day after it landed. Classified read: every tool this serves only reads the spec it was handed. mise mcp stays unclassified by contrast, because it serves a tool that runs tasks.

Verified against cli/usage.usage.kdl itself, which now carries #751's effects:

describe_command "generate markdown"        → command="generate markdown"  effect=read  --out-file effect=write
describe_command "usage generate markdown"  → command="generate markdown"  effect=read  --out-file effect=write

This comment was generated by an AI coding assistant.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 259021a. Configure here.

Comment thread lib/src/parse.rs
let mut available = gather_flags(root);
for cmd in rest {
merge_subcommand_flags(&mut available, gather_flags(cmd), false);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Mount crossing ignored in available_flags

Medium Severity

The new available_flags helper always calls merge_subcommand_flags with crossing_mount set to false. During real parsing, that flag is true when entering a subcommand whose mounted field is set while the parent is not, which changes how inherited globals collide with the mounted program’s flags. Flag lists from available_flags can then disagree with parse_partial for the same command chain, including what the MCP server reports via describe_command when the chain includes mounted commands.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 259021a. Configure here.

@github-actions

Copy link
Copy Markdown
Contributor

Instruction counts

benchmark trend instructions Δ wall (min) Δ
markdown ▁▁▂▂█ 102,906,253 → 110,275,478 +7.16% ⚠️ 13.93 → 18.11ms +30.05%
startup ▁▁▁▁█ 1,160,310 → 1,230,306 +6.03% ⚠️ 1.01 → 1.22ms +20.07%

2 benchmark(s) above the 1% gate: markdown +7.16%, startup +6.03%

Only instruction counts gate. Wall clock is shown for context — on identical hardware it moves 4-20% run to run.

Measured by tak — instruction-counted CLI benchmarks, stored in this repository's git notes.

259021a981f1 vs d97b2c792735 · measured on the runner, not pushed to the history.

@jdx

jdx commented Jul 27, 2026

Copy link
Copy Markdown
Owner Author

The perf gate is red: startup +6.03%, markdown +7.16%. I split it into what caused what, because the two are different problems and only one of them is this PR's code.

Method: build main and this branch, then run the same binary against both spec files, 250 runs each, medians. Wall clock, not instructions — but the ratios land within 0.2pp of CI's instruction deltas, which is closer agreement than the 4-20% run-to-run noise would let happen by chance.

median Δ
usage --help, main 0.919 ms
usage --help, this branch 0.975 ms +6.13%
g markdown, main binary + main spec 10.726 ms
g markdown, this branch's binary + main spec 10.787 ms +0.58%
g markdown, this branch's binary + this branch's spec 11.513 ms +6.73%

markdown is the corpus again, not the code. Holding the binary fixed and swapping only the spec accounts for 6.73 of the 7.34 points. This PR adds a whole command — mcp, with a long_help block — to a CLI that had 15, and the bench parses that spec and renders a page per command. One more command out of sixteen is ~6%. Same structural thing I flagged on #751: adding a command is a gate failure by construction.

startup is real and it's mine. +6.13% is entirely binary weight — 8.43 MB → 11.36 MB from linking rmcp, tokio and schemars. Swapping the binary while holding the spec fixed moves markdown by only 0.58%, so it isn't spec parsing; it's the fixed cost of a larger image.

In absolute terms it's +56 µs. For the path that actually matters, complete-word on a real spec, I measured 117.8 → 118.1 ms earlier against mise's 208 KB spec — the startup delta is a rounding error next to parsing.

Your call, and I'd rather you made it than have me pick:

  1. Take the 56 µs. It buys an MCP server in the default binary, which is the only way an agent finds it without extra setup.
  2. Feature-gate rmcp, default off. Startup returns to baseline for anyone who builds without it — but usage mcp then doesn't exist in the standard build or the released binaries unless you enable it there, which puts the cost right back on the builds people use. I don't think this buys anything real, but it's available.

Happy to do either. What I won't do is quietly widen the gate to make my own check green.

This comment was generated by an AI coding assistant.

@jdx
jdx merged commit 12f849d into main Jul 27, 2026
6 of 8 checks passed
@jdx
jdx deleted the feat/usage-mcp branch July 27, 2026 20:46
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.

1 participant