Skip to content

Bridge the command catalog into MCP; expand gh-traffic - #185

Merged
jasperf merged 2 commits into
mainfrom
feature/mcp-catalog-bridge
Aug 6, 2026
Merged

Bridge the command catalog into MCP; expand gh-traffic#185
jasperf merged 2 commits into
mainfrom
feature/mcp-catalog-bridge

Conversation

@jasperf

@jasperf jasperf commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

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}/traffic endpoints as the alternative, without ever discovering that scripts/git/gh-traffic.sh already existed two directories away and was findable via wp-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_search searches the full catalog by name or description, with optional platform and category filters. Reads go/internal/catalog/catalog.json — the file the Go CLI embeds — directly 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/platform, where list --json deliberately exposes a frozen subset that printJSON 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 dispatches through the wp-ops binary rather than exec'ing scripts, reusing the Ansible and WP-CLI executors, the server-side guard, and --help formatting 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 from WP_OPS_BIN, then go/wp-ops, then PATH.

The write gate

Read-only commands (audits, scans, log analysis, traffic stats) 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, which means a model can always read a command's usage before asking for approval to run it.

The allowlist is hardcoded in catalog.ts because the manifest has no "does this mutate anything" directive yet. An @mutates field 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 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 confirm itself. 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 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 needed 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. It's printed as a separate labelled row rather than folded into Total, which would have overcounted.

Three bugs found along the way:

  • --days was parsed and validated (rejecting > 14) but never applied, so every run showed the full window regardless of the flag.
  • A 403 exited 0. The traffic endpoints are maintainer-only, so this is a routine outcome, and it reported nothing as if there were nothing to report. Now explained specifically, non-fatal for the other repos in the run, and reflected in the exit code.
  • gh api writes the API's 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.

Catalog regeneration

catalog.json is 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:

  • 16 tools listed; command_search and command_run both present
  • command_search("traffic") returns 7 matches across git and monitoring, correctly badged
  • command_search("gh-traffic") returns full usage inline for the single match
  • read-only command (gh-traffic --all) runs unconfirmed and returns real data
  • write-capable command (release-theme) is blocked with an actionable message
  • --help on that same blocked command is allowed
  • unknown command and platform-filter paths return readable errors

gh-traffic totals were cross-checked against direct gh api calls for six repos and match exactly.

jasperf and others added 2 commits August 7, 2026 06:21
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>
@jasperf
jasperf merged commit f61ea23 into main Aug 6, 2026
1 check passed
@jasperf
jasperf deleted the feature/mcp-catalog-bridge branch August 6, 2026 23:43
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