Bridge the command catalog into MCP; expand gh-traffic - #185
Merged
Conversation
Views alone answer "did anyone read this"; clones answer a different
question, and the gap between them is the interesting signal — a repo
with 3 unique viewers and 208 unique cloners is being fetched by CI
runners and mirrors, not read by people. The script only ever fetched
views, so that comparison meant dropping to raw `gh api` by hand.
Adds --clones, --referrers, and --all. Views stay the default when no
section flag is given, so existing invocations are unchanged. Also
accepts several owner/repo arguments in one pass, since comparing repos
was the case that drove this.
Totals need care: GitHub's top-level `uniques` is deduplicated across the
whole 14-day window, so it is not the sum of the daily uniques column.
Printed as a separate labelled row rather than folded into "Total".
Three fixes found along the way:
- --days was parsed and validated but never applied, so every run
showed the full window regardless.
- A 403 (the traffic API is maintainer-only) exited 0, reporting
nothing as if it were nothing to report. Now explained, non-fatal
for the other repos in the run, and reflected in the exit code.
- `gh api` writes the API error body to stdout on a 4xx, which would
have emitted a second JSON value into --json output. Captured rather
than streamed, and a failed section becomes an explicit null.
--json now wraps its sections in a per-repo object, since it can carry
three of them for N repos. Nothing in the repo consumed the old shape.
jq moves from optional to required, and catalog.json is regenerated for
the new manifest.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 14 tools here are hand-written wrappers, one per capability, so an MCP client sees only what someone got round to porting. The repo has ~74 commands. A client asked for anything outside those 14 doesn't fail loudly — it reasons its way to a confident "that's out of scope for this server" while the script sits in the tree. That happened with GitHub repo traffic against scripts/git/gh-traffic.sh. command_search reads go/internal/catalog/catalog.json (the file the Go CLI embeds) rather than shelling out to `wp-ops list --json`. Two reasons: search works when the binary hasn't been built, and the generated file carries args, flags, examples, and platform, where list --json deliberately exposes a frozen subset it treats as an external contract. Matching mirrors catalog.Search so the CLI and MCP can't disagree about what exists; a single match returns full usage inline, since "how do I call it" is always the next question. command_run goes through the wp-ops binary instead of exec'ing scripts, which reuses the Ansible and WP-CLI executors, the server-side guard, and --help formatting rather than reimplementing them in TypeScript. It dispatches by full key, never basename, so a future basename collision can't turn a working call into an ambiguity error. Read-only commands (audits, scans, log analysis) run directly; anything that writes, deploys, syncs, or deletes needs confirm: true. --help and --where are always free — executeEntry handles both before any executor runs, so they're safe whatever the command does. The allowlist is hardcoded because the manifest has no @mutates directive yet; adding one alongside @Runs and @platform would make this catalog data instead. Until then a load-time check warns on stderr when a listed key leaves the catalog, so a rename shows up as a warning rather than as a read-only command quietly starting to demand confirmation. The gate is a speed bump, not a security boundary — the model can set confirm itself. Its job is to surface destructive commands as a decision rather than letting them disappear into a chain of tool calls; the README says so explicitly so it isn't mistaken for enforcement. Verified over real stdio JSON-RPC: 16 tools listed, read-only command runs unconfirmed, write-capable command blocked, --help on that same blocked command allowed, unknown command and ambiguity errors readable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
An MCP client talking to this server sees 14 hand-written tool wrappers. The repo has ~74 commands. Asked for anything outside those 14, a client doesn't fail loudly — it reasons its way to a confident "that's out of scope for this server" while the script sits in the tree.
That happened with GitHub repo traffic: the answer correctly named the
/repos/{owner}/{repo}/trafficendpoints as the alternative, without ever discovering thatscripts/git/gh-traffic.shalready existed two directories away and was findable viawp-ops search gh.This adds a bridge from the existing command catalog to MCP, so the answer to "can wp-ops do X" stops depending on whether someone got round to writing a wrapper for X.
The bridge
Two tools in
mcp-server/src/tools/catalog.ts:command_searchsearches the full catalog by name or description, with optionalplatformandcategoryfilters. Readsgo/internal/catalog/catalog.json— the file the Go CLI embeds — directly rather than shelling out towp-ops list --json. Two reasons: search works when the binary hasn't been built, and the generated file carriesargs/flags/examples/platform, wherelist --jsondeliberately exposes a frozen subset thatprintJSONtreats as an external contract. Matching mirrorscatalog.Searchso the CLI and MCP can't disagree about what exists. A single match returns full usage inline, since "how do I call it" is always the next question.command_rundispatches through thewp-opsbinary rather than exec'ing scripts, reusing the Ansible and WP-CLI executors, the server-side guard, and--helpformatting instead of reimplementing them in TypeScript. Dispatches by full key, never basename, so a future basename collision can't turn a working call into an ambiguity error. Resolves the binary fromWP_OPS_BIN, thengo/wp-ops, thenPATH.The write gate
Read-only commands (audits, scans, log analysis, traffic stats) run directly. Anything that writes, deploys, syncs, or deletes needs
confirm: true.--helpand--whereare always free —executeEntryhandles both before any executor runs, so they're safe whatever the command does, which means a model can always read a command's usage before asking for approval to run it.The allowlist is hardcoded in
catalog.tsbecause the manifest has no "does this mutate anything" directive yet. An@mutatesfield alongside@runsand@platformwould make this catalog data instead; until then a load-time check warns on stderr when a listed key leaves the catalog, so a rename surfaces as a warning rather than as a read-only command quietly starting to demand confirmation.Worth being explicit: the gate is a speed bump, not a security boundary — the model can set
confirmitself. Its job is to make destructive commands surface as a distinct decision rather than disappearing into a chain of tool calls. Client-side tool-approval settings are what actually enforce anything. The README says this so it isn't mistaken for enforcement.A command still deserves its own first-class tool when it needs typed parameters, site-registry integration, or output shaping that argv and raw stdout can't give it. The bridge is the floor, not a replacement for that.
gh-traffic
Since it was the command that exposed the gap, it also got the data it was missing.
Views alone answer "did anyone read this". Clones answer a different question, and the gap between them is the signal — a repo with 3 unique viewers and 208 unique cloners is being fetched by CI runners and mirrors, not read by people. The script only fetched views, so that comparison meant dropping to raw
gh apiby hand.Adds
--clones,--referrers, and--all. Views stay the default when no section flag is given, so existing invocations are unchanged. Also accepts severalowner/repoarguments in one pass, since comparing repos was the case that drove this.Totals needed care: GitHub's top-level
uniquesis deduplicated across the whole 14-day window, so it is not the sum of the daily uniques column. It's printed as a separate labelled row rather than folded intoTotal, which would have overcounted.Three bugs found along the way:
--dayswas parsed and validated (rejecting> 14) but never applied, so every run showed the full window regardless of the flag.gh apiwrites the API's error body to stdout on a 4xx, which would have emitted a second JSON value into--jsonoutput. Captured rather than streamed, and a failed section becomes an explicitnull.--jsonnow wraps its sections in a per-repo object, since it can carry three of them for N repos. Nothing in the repo consumed the old shape.jqmoves from optional to required.Catalog regeneration
catalog.jsonis generated from the scripts' manifest headers. Editing gh-traffic's header meant rebuilding it. Verified blast radius: exactly one entry changed (scripts/git/gh-traffic), no keys added or removed.Verification
End-to-end over real stdio JSON-RPC against the built server:
command_searchandcommand_runboth presentcommand_search("traffic")returns 7 matches across git and monitoring, correctly badgedcommand_search("gh-traffic")returns full usage inline for the single matchgh-traffic --all) runs unconfirmed and returns real datarelease-theme) is blocked with an actionable message--helpon that same blocked command is allowedgh-traffictotals were cross-checked against directgh apicalls for six repos and match exactly.