Changelog
0.4.0 - 2026-10-15
Added
WOODPECKER_API_PREFIXpoints the server at an API that is not under/api— Crow CI 5 and later serve theirs under/api/v1(#30). The default stays/api,/versionand/healthzstay at the root, and anything but a plain path stops the server at start-up, because the prefix is part of the URL that carries the token. A URL ending in/api/v1with the default prefix gets a warning, an HTML answer to an API call names the prefix it went under, andget_server_infoadds anotewhen the server is Crow — including the hint to set the prefix. Checked against Crow 6.10.0 with the integration suite; the writes that behave differently there are listed in the documentation.
Fixed
get_server_infokeeps working once Woodpecker drops the root/versionand/healthz(#43). Woodpecker 3.19 serves both under/apias well and marks the root routes for removal in 4.0. The root is still asked first; on a 404 or an HTML answer the call is repeated under the API prefix, still without a token. A server error is not retried.A host named like the API prefix keeps its name.
WOODPECKER_URL=http://apiwas trimmed tohttp:/, because the/apisuffix was looked for in the whole URL rather than in its path.A
__proto__key from the instance no longer makes the two channels disagree. The text block kept it, while the client's schema parse ofstructuredContentturned it into a prototype assignment and lost it. The key is now dropped at every depth, checked after cleaning, so both channels carry the same answer.
0.3.1 - 2026-09-07
Security
- A Bitbucket Data Center forge's service-account password reached the model.
GET /forges/{id},GET /forgesand the answers tocreate_forgeandupdate_forgehand an administrator the rawForge,additional_optionsincluded — and forbitbucket-dcthat map holdsgit-usernameandgit-password. The credential scrubber matched field names exactly:passwordwas on the list,git-passwordwas not. Any key that ends inpassword,passwd,passphrase,secret,token,api_keyorprivate_key— however separated or cased — is now redacted wherever it sits. create_forgeasks a person. Woodpecker decides who is an administrator by comparing the login name againstWOODPECKER_ADMINand nothing else, not the forge the login came from. A forge somebody else controls, with an account on it spelled like an administrator's, is an administrator on its first login — andupdate_forgewas two-step while the tool that adds one applied on the first call. The confirmation is bound to the type, URL, client id, OAuth host,skip_verifyand a fingerprint of the whole body. Twenty-four tools ask now.- An admin grant is bound to the whole call.
create_userandupdate_userkeyed their confirmation on the login and theadminflag; a token issued for "make octocat an administrator" also confirmed the same call with an email address the person was never shown. The body is in the key, and the sentence says when an address travels with the grant. - An error is decided by its status before its body is read. Every response was read under the 32 MB ceiling and then checked for
ok, so a reverse proxy answering 401 with a two-megabyte login page surfaced as "the answer exceeds the ceiling" — the size, not the status, no word about the token — after buffering the page. Error bodies are read under a 16 kB ceiling of their own that cuts rather than refuses. - Every string in a result is cleaned, not only build logs. Commit messages, branch names, step errors, secret notes and the rest reached
structuredContentwith terminal escapes, C1 controls and bidi overrides intact (the text block was safe by JSON escaping). The classmcp-approvalkeeps out of a confirmation prompt is now kept out of every string this server hands to a model, build logs included — which also gain the C1 and bidi half — andscheme://user:password@hostin any URL-shaped field loses its credential. - What the instance wrote into an error is cleaned and labelled. The
content-typeheader quoted by the "this is the web UI, not the API" error was the proxy's raw text, up to 16 kB with control characters; an error body was trimmed and cut but not cleaned, and nothing said whose words they were. Both go through one helper now: control characters out, cut, and(untrusted text from the instance):in front. ELICITATIONis not echoed. A value that was neithertruenorfalsewas printed verbatim into the fatal startup line — and that variable sits one line belowWOODPECKER_TOKENin every Compose file. A short word is quoted so a typo stays readable; anything else is described by its length. The scheme of a rejectedWOODPECKER_URLis treated the same way.- The release job installs with
--ignore-scripts. It is the one job that holds an OIDC token for npm Trusted Publishing, and it ran every dependency's install hook while it did.gh release createverifies the tag. The CI workflow gains a dependency review on pull requests, and the runtime image loses yarn and corepack along with npm, and no longer carries the lockfile.
Fixed
get_step_logsfailed as a whole — log included — when the exit-code entry was not what the output schema promised:Number("1.5")is not an integer,1e20is past the safe range, andNumber("")is0, which reported a step as succeeded on no evidence. A run of digits is an exit code; anything else is no exit code, and the log is still answered.- A
"__proto__"key in a response — legal JSON, an own property afterJSON.parse— was assigned into the redacted copy, which set its prototype and dropped the field; an oversized string under that key went through a thousand shortening rounds without ever getting shorter. Copies are built withObject.fromEntries, shortened slots are written as own properties, and the record of dropped arrays is aMap. - A
truncatednote a tool had already written —get_pipeline_config's count of unread files — was replaced when the budget then dropped array entries. The two are merged. - An empty
200from a pass-through endpoint crashed intoBuffer.byteLength(undefined); it is an empty object now, and the tools that cast a response instead of checking it (get_agent,update_agent,get_user,create_user,update_user) say "expected a user object" rather than "Cannot read properties of undefined". - A
nullentry in a log, or a numericdatafield, threw out of the decoder; both are skipped. forge_idon the user tools had no upper bound, so1e21became1e+21in the query string and in the key a confirmation is bound to; it shares the id bound now. Pipelinevariablesand agentcustom_labelsare capped at 100 entries — each key and value had a length, the number of them had none.normalizeServerRoottrimmed trailing slashes with/\/+$/, which is quadratic on a run of slashes that is not at the end. Operator input only, and one line to fix.- The test harness lists the tools once per connection, so the SDK's client-side check of
structuredContentagainst eachoutputSchemaruns on the success path of every tool in every suite — which is how the exit-code finding above would have been caught.
0.3.0 - 2026-09-07
Added
- The server introduces itself in full.
title,description,websiteUrlandiconsnow travel withnameandversion, so a client that shows a server to a person has something to show. All four were already inserver.jsonfor the registry and reached no client at all; a test compares the two so they cannot drift. - Server
instructions. Results carry anuntrustedmarker, but that is read after the fact — this is the channel a model sees before it calls anything. - An OpenSSF Scorecard run, weekly and on every push to
main, reporting into the Security tab next to CodeQL and Trivy. The badge is the second in the row.
Security
- mcp-approval 0.8.2. A sealed dialog answer is single-use since 0.8.1: the same
requestStatepresented again within its lifetime used to be accepted again, and with a resource key that is the same every time — a whole stream, a fixed set of targets — every replay landed. npm users on^0.8.0already had the fix; the Docker image is built from the lockfile and carried 0.8.0 until this release. - Approval keys bound to positions. Every guarded tool binds its confirmation to an ordered tuple —
approve_pipelineto(repo_id, number),delete_step_logsto(repo_id, number, step_id),delete_agent,delete_userand the rest to theirs. A sorted set key made["5","12"]and["12","5"]the same key, so a token issued for "approve pipeline 12 of repository 5" also confirmed pipeline 5 of repository 12 — a different fork, and the secrets of a repository nobody mentioned. This server has carried its owntupleResourceKeyagainst that since 0.1.x; the keys now come fromorderedResourceKeyin mcp-approval 0.8.2, which prefixes each part with its position before fingerprinting, and the local copy is gone.
0.2.0 - 2026-09-03
Added
Every tool declares an
outputSchemaand answers withstructuredContentbeside the text block. A client no longer has to parse prose to use a result — which seventeen of them made unavoidable, since they answered with a sentence. The sentence stays, in the text block.get_step_logskeeps its rendered header-plus-log there too, and states the exit code, the line count and the output as fields.The tools that report pushed content carry
untrusted: trueandsource: "woodpecker"as fields, not only as a preamble in the text. Branch names, commit messages, pipeline titles and above all build logs are written by whoever can push, so a client that reads the structured half must not get them unframed. The list follows the call sites.Woodpecker's objects are described as open objects with the top-level keys this server builds — the upstream Go models change what they serialize between releases, and a strict shape would turn that into a failed call.
The tools that need a confirmation now ask the user, on clients that can show a prompt. The two-call
confirm_tokenremains for clients that cannot, so nothing that works today stops working — but where a person can be asked, one is, instead of a token that only proves the same call was made twice.Two more places ask, and both were gaps the code itself pointed at.
create_usertakes anadminflag and did not ask, whileupdate_userright beside it asks on exactly that flag — the same privilege, granted the same way, with a dialog in front of one of them. The description even advertised it: "which is how you make someone an admin before they first log in." It is now guarded on the same field and only that field.chown_repositorytransfers which forge token a repository's pipelines run under, so the calling account's reach over the forge becomes the repository's reach.delete_useralready cited that in its own reasoning; the tool that performs the transfer did not ask.ELICITATIONswitches the dialog off —falsesends a client that could have been asked down the two-call-token path instead. For a scheduled job or a test harness, where a dialog is the wrong shape rather than an unwanted one.It does not remove the guard: there is no setting in which a guarded call goes unannounced. Two deliberate rough edges come with it. The variable is not prefixed, so one
export ELICITATION=falsereaches every MCP server in the environment — which is why a server started with it off prints a line saying so, and why the fallback text names the server instead of blaming a client that was working fine. And a value that is neithertruenorfalsestops the server, through the samefail()every other refusal inconfig.tsuses: it is the only variable here that defaults to on. It is read afterWOODPECKER_TOKENis wiped from the environment, so that exit cannot leave the token behind.A
docs/guide/approval.mdpage.test/docs.test.tscompared the 👤 marks in the tool reference against the realconfirm_tokenschemas and failed until the page named the two new tools, which is exactly what it is for.
Changed
The advertised schemas avoid a spelling that is legal JSON Schema and still gets a tool refused, or its constraint silently dropped, by some MCP clients: an open object now writes
"additionalProperties": truerather than the empty schema{}zod emits for it. What the tools accept and return is unchanged; only the way the schema says so is.Eight "Nothing to update — pass …" answers are error results rather than plain ones. Each read like an answer while being a refusal.
A result too large to shrink is an error rather than an envelope saying so. The envelope was a different shape from what the tool declares it returns, which the SDK refuses.
The two-call
confirm_tokenprompt is an error result. What was asked for did not happen, which is whatisErrorsays. The text is unchanged and still carries the token.move_repositoryno longer describes the HTTP 500 it answered with as the behaviour of the endpoint. On 3.11 it performed the move and then failed on a permission record with no repository attached; on 3.18 the call answers cleanly. The warning stays, scoped to the older instances it is true of, since a server points at whichever instance it was given.The confirmation prompt is a plain result rather than an error. Asking a question is not a failure, and the rest of the family answers it this way.
A
confirm_tokenthat does not match its arguments is refused with the reason in the same words as every other server in the family. The binding is unchanged: a token issued for one repository still cannot authorise the whole-instance variant ofrepair_repository.Runs on MCP SDK 2.0. Existing clients see the same protocol revision they always did; the change is the package layout behind it, and it is what lets the dialog above work on both protocol eras from one code path — including behind a stateless gateway, where the older mechanism silently fell back to the weaker token for every client.
The linter is oxlint instead of eslint plus typescript-eslint, which lifts the TypeScript ceiling: typescript-eslint pins
typescriptbelow 6.1, so this repository was held on TypeScript 6 by its linter rather than by its code.The tool filter, the confirmation store, the host classifier and the documentation-asset generator now come from
mcp-tool-allowlist,mcp-approval,mcp-internal-hostsandsvg-asset-setrather than from copies kept here — 916 fewer lines, and one place to fix each. None of them has a runtime dependency of its own.stdio is served through
serveStdio, so the connection's era is negotiated on the opening exchange rather than assumed. A client that pins the2026-07-28era is served it; until now itsserver/discoverprobe was answered with "Method not found" and only2025-11-25was on offer. A client that speaks the older era sees no change — it is still pinned to one instance for the life of the connection, exactly as a hand-wiredStdioServerTransportserved it.
Fixed
Confirmation tokens are compared with a constant-time comparison, in the library's implementation rather than in the copy kept here.
budgetedJsoncould stop the server for good. It replaced the longest string in a result with 200 characters plus a note saying how many were omitted — 230 characters, which is longer than the 200-character threshold that made it a candidate. So the same slot was picked again, rewritten to the identical text, and the document measured to the identical size, for ever. Any result that shortening alone could not bring under budget reached that fixpoint:get_queue_infoon an instance with about four hundred waiting tasks needed no attacker at all, and a.woodpecker.ymlwith five hundred long step names is a repository's own choice. Node is single-threaded, so the server then answered nothing — not the tool that triggered it and not any other — until the process was killed.Shortened strings are now excluded from the next round by a sentinel, as in
wikijs-mcp, so the loop runs out of candidates and falls through to the pass that drops list entries; both passes carry a hard round limit, because a fixpoint must never be a hang. Shortening also happens in doubling batches instead of re-rendering the whole document once per string.A ReDoS in the build-log cleaner. The rule that keeps only the last state of a line a progress bar rewrote was
[^\n]*\r: the star runs to the end of the line, finds no carriage return, and backtracks a character at a time, from every start position. Quadratic in the line's length — 200 000 characters took 10.5 seconds and a megabyte over 48, with the event loop blocked throughout. A single four-megabyte comment line is legal YAML, andget_pipeline_configstrips whatever the repository put in.woodpecker.yml, with no limit on how many files it reads. It is now one backwards scan per line.get_pipeline_configalso bounds what it decodes — at most 20 files, at most 200 000 bytes each — andget_step_logsapplies its byte budget to each chunk before stripping it rather than to the joined text afterwards.get_secretandlist_secretsdid not remove a secret's value. A comment innormalize.tssaid they did, and nothing there or intools/secrets.tsremoved anything: the confidentiality of the field rested entirely on Woodpecker'smodel.Secret.Copy()stripping it upstream. On a healthy instance that is true, which is why nobody noticed. It is now this server's own control as well, and the comment describes what the code does.WOODPECKER_READ_ONLYis read as1,yesortruein any case, not only as the exact stringtrue.WOODPECKER_READ_ONLY=1— what a Compose file or a systemd unit is most likely to say — used to leave every write tool registered while the operator believed the server could not write.WOODPECKER_INSECURE_TLSstays strict on purpose: a protection should fail on, a permission should not.
Security
A confirmation authorised the operation nobody was asked about. The resource key came from
setResourceKey, which sorts its targets — correct for a set, wrong for every tool here, whose targets are ordered tuples of small integers that look alike.["5","12"]and["12","5"]hashed to the same key, and the second call's arguments were never compared with the first's. A person who read "approve blocked pipeline 12 of repository 5 … runs that fork's code with this repository's secrets" and agreed was, with the same token, able to approve pipeline 5 of repository 12: a different fork, and the secrets of a repository that was never mentioned.delete_pipeline,delete_step_logs,delete_cron,delete_agent,delete_useranddelete_secretcollided the same way.Targets now carry their role (
repo:5,pipeline:12) and the key preserves their order. Where a call decides more than which object it touches, a fingerprint of the request body is part of the binding too — so a token shown as "granttrusted_network" no longer executes a second call that also carriesvisibility: "public".mcp-approvalis unchanged; a server whose targets are ordered says so at the call site.Two ungated tools could switch the fork gate off.
approve_pipelineis the most carefully reasoned guard in this server, andupdate_repositoryletrequire_approval: "none"andvisibility: "public"through on the first call (only thetrusted_*flags were guarded), whileupdate_secretleteventsandimagesthrough (onlyvaluewas guarded). Together: a fork's pull request runs unapproved, reads a secret it was newly made visible to, prints it, and the log is world-readable — without a single prompt.Both now gate on direction, the way
set_log_leveland thetrusted_*flags already did. Loweringrequire_approval, making a repository public, adding apull_requestevent to a secret or emptying itsimageslist asks first; tightening any of them, and every other field, still applies on the first call. Naming one of those fields costs one extra read, because "lowering" is a comparison with the current setting.update_registryrotated an unrecoverable credential without asking. Its own annotation states the rule — "the old password is not readable through the API and cannot be recovered" —update_secretis guarded by that exact sentence, anddelete_registrybeside it is two-step for the same damage. Passingpasswordis now two-step; correcting ausernamealone still applies on the first call. That makes twenty-three guarded operations, not twenty-two.
0.1.0 - 2026-08-29
First public release.
Added
- MCP server for the Woodpecker CI API, verified against Woodpecker 3.18.0.
- 71 tools covering the whole API: repositories, pipelines, build logs, secrets, registries, cron jobs, organizations, users, agents, forges, and the server queue and log level.
get_step_logsdecodes what the API actually returns — an array of base64-encoded chunks — reassembles it in line order, reports the step's exit code, and returns the end of the log by default, because that is where a failing step explains itself.- Secrets and registries exist at repository, organization and instance level in Woodpecker; here that is a
scopeparameter on five tools each rather than fifteen tool names. WOODPECKER_ALLOW_TOOLS/WOODPECKER_DENY_TOOLSnarrow the tool list by name orlist_*prefix, andessentialselects the eight tools that cover finding a repository, reading its pipelines and logs, and running one again.WOODPECKER_READ_ONLY=trueregisters only the 34 read tools; the 37 write tools never reach the client's tool list.- Fifteen operations that cannot be undone — every
delete_*, plusmove_repository, the whole-instancerepair_repository,update_forgeandpause_queue— are two-step: the first call returns a short-lived confirmation token bound to those exact arguments.
Security
- Agent tokens are redacted on read. The Woodpecker API returns each agent's token in clear text from
GET /agents, including the list call, and that token is enough to attach a machine to the server and receive pipeline workloads with their secrets.list_agents,get_agentandupdate_agentreplace it;create_agentstill returns it, because that is the only way to get it, and says what it is. - Build logs, commit messages and pipeline metadata are marked as untrusted content — all of it is written by whoever can push to the repository.
- The access token is deleted from the environment after start-up, never sent to a redirect target, and never echoed into an error message.
- Results are budgeted: list results drop whole entries rather than overflowing the model's context, and logs are capped separately and far lower. Single objects are budgeted too, by shortening the longest text anywhere in the tree and then dropping entries from the longest list, so an oversized answer comes back smaller rather than not at all.
- Twenty operations are two-step, not fifteen.
approve_pipelinejoined them outright — a blocked pipeline is usually one from a fork, approving it runs that fork's code with the repository's secrets, and the model deciding whether to approve is typically holding a build log written by whoever opened the pull request. Four more are two-step only in the direction that escalates:update_usergrantingadmin,update_repositorygranting atrusted_*flag,update_secretoverwriting a value that cannot be recovered, andset_log_levelsilencing the server. The reverse of each, and every other field, still applies on the first call. - Credential-shaped fields are scrubbed from every pass-through result, not just the agent token that is known to leak today. Which fields Woodpecker's Go models serialize is not this server's decision, and a forge addon or a reverse proxy can reshape a body; a field named
password,client_secret,private_keyortokenis now redacted wherever it appears.create_agentis the one deliberate exception. - Build output is stripped of terminal control sequences. A step's stdout is whatever its container wrote — ANSI colour, cursor movement, progress bars that rewrite their own line, and sometimes raw binary. None of it survives as meaning in a JSON result, all of it costs budget, and the escape sequences are a rendering vector in whatever terminal displays the answer.
- The invariant that no attacker-controlled prose reaches a confirmation prompt is now enforced where the interpolation happens, rather than resting on three input-schema regexes two files away.
- Repository-controlled text carries the untrusted-content marker in every result that returns it, including the pipeline echoed back by
trigger_pipeline,restart_pipeline,approve_pipeline,decline_pipelineandrun_cron, the instance-wide pipeline queue, and forge branch names. - The fatal-error handler prints the message and stack rather than the error object, whose
causechain can carry the failed request's headers.