Why
My self-hosted platform runs CI on Woodpecker, and a lot of code review now happens with an agent in the loop. Every "is CI green?" or "why did this fail?" meant leaving the conversation, opening the Woodpecker UI, finding the pipeline, then the step, then copying logs back in. I wanted the reviewer, human or agent, to ask directly.
So I wrote a small MCP server for it. It started read-only, and the version that shipped first could see anything and change nothing. Reading got good first; acting came later, behind guards.
What
A stdio Model Context Protocol server with 19 tools against the Woodpecker v3 REST API (verified on v3.16.0, with newer endpoints checked against the v3.19.0 server source). Most of them are for reading and diagnosing:
- Find the run:
list_instances,list_repos,recent_activity(the newest pipelines across all your repos),list_pipelines,list_branchesandlist_pull_requests. - Understand a failure:
get_pipelinewith a ready-madefailed_step_idslist,diagnose_pipelinefor one-call triage (failed steps with their exit codes, errors and log tails), andcompare_pipelines, which sets the newest failing run against the last green one on a branch and returns only the changed steps, the runs in between and the config files that changed. - Read the evidence:
get_step_logs, tailed by default and searchable withmatch, andget_pipeline_config. - Explain the platform:
list_crons,list_secrets(names only, never values),list_agentsandget_queue_infofor stuck pipelines. - Act, with guards:
restart_pipeline,cancel_pipeline,trigger_pipelineandrun_cron.
It talks to several self-hosted instances at once. Each one is a WOODPECKER_<NAME>_URL and WOODPECKER_<NAME>_TOKEN pair, and the name becomes the instance argument on every tool. Registering it in Claude Code is one npx -y github:RikiGomes/woodpecker-mcp line in .mcp.json.
What it will not do is as deliberate as what it does: it never approves a pipeline, never deletes anything and never reads a secret value.
How
TypeScript, plain ESM, on the official MCP SDK with zod input schemas. Node's type stripping runs src/ directly in development, and the build only rewrites the .ts import extensions. The interesting work is at the edges, where a thin API wrapper tends to break.
Guarded writes. Woodpecker tokens are not scoped, so the server has to be the guard. The four write tools are enabled by default and narrowed per instance with ALLOW_WRITE and WRITE_REPOS (for example cancel,trigger on acme/*). A tool is only advertised when at least one instance enables it. Beyond that:
- Restart and cancel read the pipeline first and refuse deployment, tag and release pipelines, which may deploy or publish.
- Restart refuses anything unfinished, runs that skipped the approval gate, and manual or cron runs carrying variables, because a restart would replay them. It sends no query string at all, since on that endpoint Woodpecker turns
eventanddeploy_tointo a deployment and every other parameter into an environment variable. - Trigger sends a body of only
{branch}for a branch the forge lists, nevervariables. Run-cron refuses disabled cron jobs and never creates or edits one. - If a write fails in transit, the error says it may still have been applied, so you check before retrying.
- The write tools are advertised with
readOnlyHint: false(and cancel withdestructiveHint: true), so MCP clients ask before calling them.
Context-friendly output. A step's whole stored log comes back in one response, base64-encoded line by line. The server decodes it, strips ANSI escapes and progress spinners, collapses runs of identical lines into [repeated N times], and keeps only the tail (60 lines by default, 2000 at most). match searches the whole log instead and returns up to 50 matching lines with context. Responses are compact JSON, and per-tool size-budget tests keep them from growing. A step with no output comes back as HTTP 200 with a null body, which is treated as an empty log.
Safe by construction.
- A repo slug like
owner/nameis resolved through/api/repos/lookup/and cached.encodeURIComponentleaves.alone andnew URL()collapses../, so empty,.and..segments are rejected before a URL is built. - The token never leaves the
Authorizationheader: it stays out of logs, errors and URLs, and upstream error bodies are scrubbed of it. - A
.env.woodpeckerfile is only trusted for a whole instance. A URL from the file paired with a token from the environment is refused, so a file in an untrusted checkout cannot send your token elsewhere. URLs with embedded credentials are rejected, and plainhttp://logs a warning. - A half-configured default instance fails loudly at startup, while a half-configured named pair only warns, because
WOODPECKER_GITHUB_URL-style variables legitimately exist in environments that also run a Woodpecker server. - stdout is the protocol channel, so nothing reachable from the server writes to it.
Tested and shipped like a library. 139 Vitest tests inject a fake fetch, so no test touches a network, and they use generic fixtures. GitHub Actions runs typecheck, tests, build and a stdio smoke test on Node 22.12, 22 and 24; the smoke test spawns the built server and speaks JSON-RPC with only Node built-ins. A separate CodeQL workflow scans the code, an audit job fails on high-severity advisories in runtime dependencies, a weekly scheduled run catches new ones between commits, and every action is pinned to a commit SHA. There is a SECURITY.md with private vulnerability reporting, and tagged releases (v0.1.0 to v0.3.0).
One lesson is written into the repo's notes. @types/node once typed against a newer Node than engines promised, so code using process.loadEnvFile compiled but would have crashed on the minimum supported version. The types now track the runtime floor, and Dependabot ignores their majors.
What I'd do next
Two things:
- Publish it to npm, so installing doesn't go through GitHub and a version can be pinned the usual way.
- Automate maintenance with Claude Code: a recurring cycle that picks up dependency updates and advisories and keeps the repo current, with changes still arriving as reviewable PRs.
