Skip to content

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

Open
jdx wants to merge 4 commits into
mainfrom
feat/usage-mcp
Open

feat(mcp): serve a usage spec to an agent over stdio#746
jdx wants to merge 4 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 public available_flags must stay in sync with parsing; incorrect flag/effect reporting could mislead agents, but the change is read-only and well-tested.

Overview
Adds usage mcp (alias mcp-server), a local stdio MCP server built on rmcp and a minimal tokio runtime. Clients load a spec via --file or --spec; --file - is rejected because stdin is the JSON-RPC transport.

The server exposes list_commands (command tree with effect, optional hidden commands) and describe_command (help, args, flags, and per-item effect). Server instructions explain read/write/destructive semantics and that missing effect means “confirm first.” Flag reporting uses the new library helper usage::available_flags so inherited globals and re-declared globals match parser behavior.

usage-lib exports available_flags with tests that keep it aligned with parse_partial. CLI dependencies add rmcp, schemars, and tokio; generated usage spec, man page, Fig spec, and CLI reference docs are updated.

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

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@jdx, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 29 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

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

Review profile: CHILL

Plan: Pro Plus

Run ID: 779ca788-7021-41e7-8779-61cc0e93094e

📥 Commits

Reviewing files that changed from the base of the PR and between 89150e9 and 81fbf53.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (11)
  • cli/Cargo.toml
  • cli/assets/fig.ts
  • cli/assets/usage.1
  • cli/src/cli/mcp.rs
  • cli/src/cli/mod.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

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 stdio MCP server for querying usage specifications.

  • Adds list_commands and describe_command tools with command, argument, flag, and effect metadata.
  • Rejects stdin-backed spec loading because stdin is reserved for MCP transport.
  • Exposes parser-consistent inherited flags through the new available_flags API.
  • Adds MCP dependencies and regenerates CLI documentation, manpage, completion, and reference artifacts.

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 previously reported stdin and inherited-flag issues.
lib/src/parse.rs Exposes parser-consistent accepted-flag resolution, including inherited globals and redeclaration merging.
lib/src/lib.rs Re-exports the new accepted-flag resolution API for the CLI consumer.
cli/src/cli/mod.rs Registers and dispatches the new MCP subcommand.
cli/Cargo.toml Adds the runtime and protocol dependencies required by the MCP server.

Reviews (4): Last reviewed commit: "fix(mcp): resolve inherited flags throug..." | 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 and others added 4 commits July 27, 2026 01:57
`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>
@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.

@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 81fbf53. Configure here.

Comment thread cli/src/cli/mcp.rs
chain.push(chain.last().unwrap().find_subcommand(segment)?);
}
// Just the root means the caller passed no path, which is not a command.
(chain.len() > 1).then_some(chain)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Command path format inconsistent

Medium Severity

list_commands emits each entry’s command without the binary (e.g. logs), while describe_command returns command prefixed with spec.bin (e.g. pitchfork logs). find_chain only resolves subcommand segments, so a path copied from a describe response is treated as missing and yields a tool error even when the command exists.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 81fbf53. Configure here.

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