Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,71 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [5.5.0] - 2026-08-07

### Added

- **`command_search` and `command_run` MCP tools — the catalog bridge.** Every
other MCP tool wraps one wp-ops capability by hand, which caps what a client
can see at whatever has been ported so far. The repo has ~74 commands, so a
client seeing only the 14 wrappers correctly answers "I can't do that" for the
rest — even when the exact script exists. (Observed in practice: a wp-ops-only
session asked for GitHub repo traffic gave a well-reasoned out-of-scope answer
while `scripts/git/gh-traffic.sh` sat two directories away.) These two tools
expose the whole catalog instead of growing the wrapper list one at a time.

`command_search` reads `go/internal/catalog/catalog.json` — the file the Go CLI
embeds — directly, so it works whether or not the binary has been built, and
gets the full manifest (args, flags, examples, platform) where `list --json`
deliberately exposes a frozen subset. Its matching mirrors `catalog.Search` so
`wp-ops search X` and `command_search(X)` can't disagree. A single match
returns full usage inline.

`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. Read-only commands run
directly; anything that writes, deploys, syncs, or deletes needs
`confirm: true`. `--help` and `--where` are always free, since `executeEntry`
handles both before any executor runs. The read-only allowlist is hardcoded in
`src/tools/catalog.ts` because the manifest has no `@mutates` directive yet; a
startup check warns on stderr if a listed key leaves the catalog.

- **`gh-traffic.sh` now reports clones and referrers**, not just views. New
`--clones`, `--referrers`, and `--all` flags; views remain the default when no
section flag is given, so existing invocations are unchanged. The script also
accepts multiple `owner/repo` arguments in one pass, and each day-series
section prints a `Total` row alongside a separate `Unique (14d)` row — GitHub's
top-level `uniques` is deduplicated across the whole window, so summing the
daily uniques column would overcount, and folding the two into one "Total"
would be wrong.

Clone counts are dominated by CI runners, mirrors, and package resolvers rather
than people, which is worth knowing before reading them as interest; `--help`
now says so.

### Fixed

- **`gh-traffic.sh --days N` had no effect.** The flag was parsed and validated
(rejecting `> 14`) but never applied to the output, so every invocation showed
the full 14-day window. It now limits the day-series sections to the last N
days with activity. Referrer data has no day series and is always the full
window, which `--help` now states.
- **`gh-traffic.sh` reported success when a repo could not be read.** The traffic
endpoints are maintainer-only, so a 403 is a routine outcome; the script now
explains that specifically, keeps going so one unreadable repo doesn't hide the
others, and exits 1 if any section failed. In `--json` mode a failed section
becomes an explicit `null` — `gh api` writes the API's error body to stdout on
a 4xx, which previously would have corrupted the document.

### Changed

- **`gh-traffic.sh --json` now emits a JSON array of per-repo objects**
(`{repo, views?, clones?, referrers?}`) rather than the raw views payload,
since it can now carry three sections for any number of repos. Nothing in the
repo consumed the old shape.
- **`gh-traffic.sh` now requires `jq`**, previously optional and used only for
`--json`. Filtering the three payloads locally needs it.

## [5.4.0] - 2026-08-06

### Added
Expand Down
45 changes: 34 additions & 11 deletions go/internal/catalog/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -312,49 +312,72 @@
{
"category": "scripts",
"key": "scripts/git/gh-traffic",
"description": "Fetch and display GitHub repository traffic statistics (14-day window)",
"description": "Fetch and display GitHub repo traffic: views, clones, and referrers (14-day window)",
"script_path": "scripts/git/gh-traffic.sh",
"runs_on": "local",
"runs": "local",
"requires": [
"gh"
"gh",
"jq"
],
"args": [
{
"name": "owner/repo",
"required_raw": "required",
"required": true,
"default": "imagewize/nynaeve",
"description": "GitHub repository",
"raw": "owner/repo required {imagewize/nynaeve} GitHub repository"
"description": "GitHub repository (repeatable)",
"raw": "owner/repo required {imagewize/nynaeve} GitHub repository (repeatable)"
}
],
"flags": [
{
"name": "--clones",
"required_raw": "optional",
"required": false,
"description": "Include clone counts (machine traffic: CI, mirrors, bots)",
"raw": "--clones optional {} Include clone counts (machine traffic: CI, mirrors, bots)"
},
{
"name": "--referrers",
"required_raw": "optional",
"required": false,
"description": "Include top referring sites",
"raw": "--referrers optional {} Include top referring sites"
},
{
"name": "--all",
"required_raw": "optional",
"required": false,
"description": "Include every section (views, clones, referrers)",
"raw": "--all optional {} Include every section (views, clones, referrers)"
},
{
"name": "--days",
"required_raw": "optional",
"required": false,
"default": "14",
"description": "Number of days to fetch (max: 14)",
"raw": "--days optional {14} Number of days to fetch (max: 14)"
"description": "Limit the day-series sections to the last N days (max: 14)",
"raw": "--days optional {14} Limit the day-series sections to the last N days (max: 14)"
},
{
"name": "--json",
"required_raw": "optional",
"required": false,
"description": "Output raw JSON instead of formatted table",
"raw": "--json optional {} Output raw JSON instead of formatted table"
"description": "Output raw JSON instead of formatted tables",
"raw": "--json optional {} Output raw JSON instead of formatted tables"
},
{
"name": "--quiet",
"required_raw": "optional",
"required": false,
"description": "Suppress header row in table output",
"raw": "--quiet optional {} Suppress header row in table output"
"description": "Suppress header rows in table output",
"raw": "--quiet optional {} Suppress header rows in table output"
}
],
"examples": [
"wp-ops gh-traffic imagewize/nynaeve --quiet"
"wp-ops gh-traffic imagewize/nynaeve --quiet",
"wp-ops gh-traffic imagewize/nynaeve imagewize/wp-ops --all"
],
"manifest_category": "git",
"platform": "any",
Expand Down
45 changes: 42 additions & 3 deletions mcp-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,48 @@ Scaffold — fourteen tools implemented so far:
to have `sshHost`. (`files_push` is deliberately not implemented, for the same production-risk reason
as `db_push`.)

More tools (PR creation, releases, image optimization, git/gh helpers) will follow the
same pattern. See the parent repo's `CLAUDE.md` and the relevant README in each
directory for the operations these will eventually wrap.
### The catalog bridge

Every tool above wraps one wp-ops capability by hand, which caps what a client can
see at whatever someone got round to porting. The repo has ~74 commands; a client
seeing only the wrappers correctly answers "I can't do that" for the rest — even
when the exact script exists. (This is not hypothetical: asking a wp-ops-only
session for GitHub repo traffic got a reasoned "out of scope" while
`scripts/git/gh-traffic.sh` sat two directories away.)

These two tools expose the whole catalog instead of growing that list one wrapper
at a time:

- **`command_search`** — searches the full command catalog by name or description,
with optional `platform` (`trellis`/`wordpress`/`any`) and `category` filters.
Mirrors `catalog.Search` in `go/internal/catalog/catalog.go` exactly, so
`wp-ops search X` and `command_search(X)` can't disagree about what exists. A
single match returns full usage — arguments, flags, examples, requirements.
Reads `go/internal/catalog/catalog.json` (the file the Go CLI embeds) directly,
so it works whether or not the binary has been built.
- **`command_run`** — runs a catalog command by key, args as separate argv tokens.
Dispatches through the `wp-ops` binary rather than exec'ing the script, which
reuses the Ansible and WP-CLI executors, the server-side guard, and `--help`
formatting instead of reimplementing them in TypeScript. Resolves the binary
from `WP_OPS_BIN`, then `go/wp-ops`, then `PATH`.

`command_run` gates on writes: read-only commands (audits, scans, log analysis,
traffic stats) run directly, and anything that writes, deploys, syncs, or deletes
needs `confirm: true`. `--help` and `--where` are always free — `executeEntry`
handles both before any executor runs. The allowlist lives in
`src/tools/catalog.ts` because the manifest has no "does this mutate anything"
directive yet; an `@mutates` field alongside `@runs` and `@platform` would replace
it with catalog data, and until then a startup check warns on stderr when a listed
key no longer exists.

Note that the gate is a speed bump, not a security boundary — the model can set
`confirm` itself. Its job is to make destructive commands surface to you as a
distinct decision rather than disappearing into a chain of tool calls. Client-side
tool-approval settings are what actually enforce anything.

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.

Two transports are implemented, both verified end-to-end (real MCP `initialize` +
`tools/call` round trip against the real scanner):
Expand Down
102 changes: 102 additions & 0 deletions mcp-server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ import { checkIpReputation, checkDenyList, type IpCheckResult } from "./tools/ip
import { runAdminUserCreate } from "./tools/adminUserCreate.js";
import { runDbPull } from "./tools/dbPull.js";
import { runFilesPull } from "./tools/filesPull.js";
import {
formatRunResult,
formatSearchResults,
isIntrospectionOnly,
isReadOnlyCommand,
loadCatalog,
resolveCommand,
runCatalogCommand,
searchCatalog,
} from "./tools/catalog.js";

// Building z.enum(...) schemas from the registry means a wrong site/env key gets caught
// by the client/model before the call is ever made, instead of costing a full round trip
Expand Down Expand Up @@ -708,5 +718,97 @@ export function createServer(): McpServer {
}
);

// The catalog bridge. Every other tool above is a hand-written wrapper around
// one wp-ops capability, which means an MCP client sees only the handful
// someone got round to porting — and correctly answers "I can't do that" for
// the ~74 commands in the repo that have no wrapper, even when the exact
// script exists. These two expose the whole catalog instead of growing that
// list one tool at a time.
server.tool(
"command_search",
"Search the full wp-ops command catalog (~74 commands: backups, monitoring, SEO and security " +
"audits, image processing, releases, GitHub repo traffic, and more) by name or description. " +
"Use this BEFORE concluding that wp-ops cannot do something — most capabilities live here as " +
"scripts rather than as dedicated MCP tools. A single match returns full usage: arguments, " +
"flags, and examples. Run what you find with command_run.",
{
query: z
.string()
.describe('Term matched against command names and descriptions, e.g. "traffic", "backup", "webp". Use "" to list everything.'),
platform: z
.enum(["trellis", "wordpress", "any"])
.optional()
.describe('Only commands for this stack: "trellis" needs a Trellis project, "wordpress" any WP install, "any" needs neither.'),
category: z
.string()
.optional()
.describe('Only commands in this category, e.g. "monitoring", "backup", "seo", "security", "images", "git".'),
},
async ({ query, platform, category }) => {
try {
const entries = loadCatalog();
const matches = searchCatalog(entries, query, { platform, category });
return { content: [{ type: "text" as const, text: formatSearchResults(matches, query) }] };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true };
}
}
);

server.tool(
"command_run",
"Run a wp-ops catalog command found via command_search, passing args as separate tokens. " +
'Pass ["--help"] to read a command\'s full usage, or ["--where"] to get its file path — both are ' +
"free of side effects and never need confirmation. Read-only commands (audits, scans, log " +
"analysis) run directly; anything that writes, deploys, syncs, or deletes needs confirm: true, " +
"which you may only set after the user has explicitly approved that specific command.",
{
command: z
.string()
.describe('Command key from command_search, e.g. "scripts/git/gh-traffic". A unique basename like "gh-traffic" also resolves.'),
args: z
.array(z.string())
.default([])
.describe('Arguments as separate argv tokens, e.g. ["--all", "imagewize/nynaeve"]. Omit the "wp-ops" prefix and the command name.'),
confirm: z
.boolean()
.default(false)
.describe("Required (true) for any command that isn't read-only. Only set after explicit user approval of this exact command."),
timeoutSeconds: z
.number()
.int()
.positive()
.max(1800)
.default(120)
.describe("Kill the command after this many seconds. Raise it for scanners and full-site backups."),
},
async ({ command, args, confirm, timeoutSeconds }) => {
try {
const entries = loadCatalog();
const entry = resolveCommand(entries, command);

if (!isIntrospectionOnly(args) && !isReadOnlyCommand(entry.key) && !confirm) {
throw new Error(
`"${entry.key}" is not on the read-only allowlist and may change data, files, or remote state. ` +
`Show the user what it does (run it with ["--help"], which needs no confirmation), get their ` +
`explicit approval, then re-run with confirm: true.`
);
}

const timeoutMs = timeoutSeconds * 1000;
const result = await runCatalogCommand(entry.key, args, timeoutMs);
const text = formatRunResult(entry.key, result, timeoutMs);
// A nonzero exit is the command's own verdict (a scanner finding
// something, an audit failing), not an MCP-level failure — surface the
// output rather than flagging the call itself as broken.
return { content: [{ type: "text" as const, text }] };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true };
}
}
);

return server;
}
Loading