Skip to content
Back to work

Case study

A read-only MCP server for Woodpecker CI, so a human or agent reviewer can check pipeline status and read a failing step's logs without leaving the conversation.

2026

TypeScriptNode.jsMCP SDKZodVitestGitHub Actions
The five read-only tools woodpecker-mcp advertises over MCP, shown as a terminal listing

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. The constraint I set from the start: it only reads. A reviewer should be able to see anything and change nothing.

What

A stdio Model Context Protocol server with five tools against the Woodpecker v3 REST API (verified on v3.16.0):

  • list_instances: the configured instances, with an optional check that verifies connectivity and the token on each one.
  • list_repos: repositories the token can see, with the ids the other tools accept.
  • list_pipelines: recent pipelines for a repo, filterable by branch, event and status.
  • get_pipeline: one pipeline with its workflows, steps and exit codes, plus a ready-made failed_step_ids list. number: "latest" works, optionally per branch.
  • get_step_logs: a step's output, tailed to the last 100 lines by default.

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. Credentials can also live in a .env.woodpecker file in the project the client launches it from. Registering it in Claude Code is one npx -y github:RikiGomes/woodpecker-mcp line in .mcp.json.

There is deliberately no restart, approve or cancel tool. Adding one would change what the server is, so the repo's CLAUDE.md records "read-only by contract" as an invariant.

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. Most of the work went into the edges, where a thin API wrapper tends to break:

  • Context-friendly logs. Woodpecker returns a step's entire stored log in one response, base64-encoded line by line. The server decodes it, keeps only the tail (100 lines by default, 2000 max) so a chatty build can't flood the caller's context, and labels exit-code entries so the final status survives the tailing. A step with no output comes back as HTTP 200 with a null body, which is treated as an empty log instead of an error.
  • Path traversal. Repos can be addressed as owner/name, resolved once through /api/repos/lookup/ and cached. encodeURIComponent leaves . alone and new URL() collapses ../, so an unchecked slug could walk the authenticated request to any path on the instance. Empty, . and .. segments are rejected before a URL is built.
  • The token stays in the header. It never appears in logs, errors or URLs, and upstream error bodies are scrubbed of it in case a misconfigured proxy echoes the Authorization header back.
  • Deliberate config failures. A half-configured default instance fails loudly at startup. A half-configured named pair only warns, because WOODPECKER_GITHUB_URL-style variables legitimately exist in environments that also run a Woodpecker server or agent.
  • Real clients. Some MCP clients omit arguments entirely when every field is optional, which the SDK rejects. list_instances wraps its schema's internal parse step to treat a missing object as {}, while staying a plain zod object so the advertised JSON schema doesn't change.
  • stdout is the protocol. Nothing reachable from the server writes to stdout; status and warnings go to stderr.

Testing runs without a network: 52 Vitest tests inject a fake fetch and use generic fixtures. GitHub Actions runs typecheck, tests, build and a stdio smoke test on Node 20.12, 22 and 24. Vitest's toolchain can't run on 20.12, so on the engines floor the smoke test, which spawns the built server and speaks JSON-RPC using only Node built-ins, is the check. A separate job fails on high-severity advisories in runtime dependencies, and a weekly scheduled run catches new ones between commits.

One lesson made it 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

Three things:

  • Publish it to npm, so installing doesn't go through GitHub.
  • Automate maintenance with Claude Code: a recurring cycle that picks up dependency updates and advisories and keeps the repo current.
  • Harden the repository against bad actors. Anyone who runs it through npx executes whatever the published code is, so the supply chain matters as much as the code.