MCP server
zuke mcp runs a
Model Context Protocol server
over your build. MCP is the open standard that lets an AI client — Claude
Desktop, Claude Code, an IDE, any agent — discover a server's tools
(typed, schema-described functions) and call them. Pointing a client at
zuke mcp lets an agent operate the pipeline through typed
calls — list the targets, inspect the graph, run one with the right
parameters — instead of guessing shell invocations.
It's a natural extension of what Zuke already does: it publishes
llms.txt, a --list --json self-description, and shell
completions from a single registry. MCP is the live counterpart — the
same build surface, callable. The server is dependency-free
(Zuke ships no MCP SDK): it speaks newline-delimited JSON-RPC 2.0 on stdio, the
standard MCP local transport.
Running it
zuke mcp # read-only: inspect the build, never execute
zuke mcp --allow-run # also expose run:<target> tools that execute targets
The process reads JSON-RPC from stdin and writes responses to stdout; its one
startup line goes to stderr so it never corrupts the protocol stream. It runs
until stdin closes. (Read zuke as deno run -A zuke.ts
until the launcher binary ships.)
Registering with a client
Most clients take a command to launch the server. Give it
deno run -A zuke.ts mcp (add --allow-run when you want
the agent to execute targets):
# Claude Code — any client that speaks stdio MCP works the same way
claude mcp add zuke -- deno run -A zuke.ts mcp
# add --allow-run when you want the agent to execute targets
claude mcp add zuke -- deno run -A zuke.ts mcp --allow-run HTTP transport
Alongside stdio, --http <host:port> serves the same
server over streamable HTTP. Each request is a POST of one JSON-RPC
message; the reply is that message's JSON-RPC response. A notification
(no id) gets a bare 202; GET gets
405 — there's no SSE stream, just spec-compliant POST-only
JSON-RPC.
deno run -A zuke.ts mcp --http 7777 # loopback only: 127.0.0.1, ::1, localhost
ZUKE_MCP_TOKEN=… deno run -A zuke.ts mcp --http 0.0.0.0:7777 --allow-run
# non-loopback --http REQUIRES ZUKE_MCP_TOKEN, else the server refuses to start
A bare port (--http 7777) binds loopback only —
127.0.0.1, ::1, localhost. Binding
a non-loopback host requires a bearer token
(ZUKE_MCP_TOKEN) — the server refuses to start without one.
Every request then needs an
Authorization: Bearer <token> header; missing or wrong
gets a 401. Set ZUKE_MCP_TOKEN and the token is
also enforced on a loopback bind.
On a loopback bind Zuke also guards against a browser drive-by /
DNS-rebinding page: a request carrying an Origin header is
accepted only when that origin is itself loopback, and rejected
403 otherwise. A client that sends no Origin (a
CLI/MCP client, not a browser) is always allowed, so this is invisible to
normal use. Permit a specific extra origin with
--allowed-origin <origin> (repeatable); once set, a
present Origin must match one exactly. A non-loopback bind
runs no default Origin check — front it with your own edge policy.
# loopback bind: browser Origins must be loopback; permit an extra one (repeatable):
deno run -A zuke.ts mcp --http 7777 --allowed-origin https://studio.example.com
For the single-build server, messages are processed one at a
time, mirroring stdio, so two concurrent runs of the one build
can't race. The registry server —
which has no shared in-process run state — instead handles requests
concurrently (see its cap on that page), so one long
run: call never head-of-line-blocks another client's read.
Tools
Read tools are always available:
| Tool | Returns |
|---|---|
list_targets | Every target with its description and dependencies. |
describe_build | The full build surface — commands, flags, targets, parameters (the --list --json payload). |
graph | Each target and the targets it depends on. |
list_runs | Runs newest-first, when a state store resolves. Optional status/target/since filters. |
show_run | One run's full record — status, per-target results, signals, audit trail — when a state store resolves. |
With --allow-run, the server also exposes one
run:<target> tool per (allow-listed)
target. Its input schema is built from the build's declared
parameters — a required
parameter is required, .options(...) becomes an enum,
a .number() is typed as a number — plus a dryRun flag
that plans without executing. These tools carry MCP's
destructiveHint annotation by default, or
readOnlyHint when the target declares
.readOnly(), so a client can prompt before running. A run
resolves parameters exactly like the CLI (MCP argument → the environment
→ the declared default) and returns the target's captured output with a
pass/fail marker.
// tools/call
{ "name": "run:test", "arguments": { "environment": "dev", "coverage": true } } --allow-run also adds mutating run-state tools, gated by the
same allow-list/operator-token policy and audited like a run:
signal_run (deliver a signal and resume, exactly-once),
resume_check (re-check suspended runs), and
cancel_run (cancel plus compensations).
Authorization tiers
Once execution is on, three independent tiers narrow what an agent can actually do:
-
--allow-run[=<globs>]— bare--allow-runexposes every target;--allow-run=deploy,checks*exposes only matching targets as run tools. Everything else stays invisible — not merely refused. -
--protect <globs>— matching targets additionally require an operator token (ZUKE_OPERATOR_TOKEN) as a tool-call argument. NoZUKE_OPERATOR_TOKENset means every protected call is denied — fail-closed, not open. -
--confirm-destructive— a destructive run tool returns its plan instead of executing unless called withconfirm: true. A target that declares.readOnly()is exempt (it carriesreadOnlyHintinstead ofdestructiveHint), anddryRunalways skips the gate.
# expose everything to run, gate promoteToProd behind an operator token, confirm destructive, authenticated HTTP:
ZUKE_OPERATOR_TOKEN=… ZUKE_MCP_TOKEN=… zuke mcp --http 0.0.0.0:7777 --allow-run --protect promoteToProd --confirm-destructive Audit log
Every mutating or denied call is appended as a RunEvent to a
fixed run record id, "mcp-audit" (AUDIT_RUN_ID).
Read it with zuke runs show mcp-audit, or via
show_run:
export type RunEventOutcome = "ok" | "denied" | "error";
export interface RunEvent {
at: string; tool: string; // "run:deploy", "signal_run"
actor: string; outcome: RunEventOutcome;
args: Record<string, string>; // redacted — secrets masked, operator token dropped
detail?: string; // short redacted reason
} Actor precedence: the identity
hook → --actor → ZUKE_ACTOR → the CI actor
→ the client's initialize name → "anonymous".
The client-supplied name is an untrusted label — it lands in the trail,
never in an authorization decision.
Trusted per-call identity
On a shared, multi-user endpoint "who did this" must not be
self-reported. Front the server with an authenticating reverse proxy
(e.g. OAuth 2.1) that injects the real caller in a header it strips from
client input, and resolve it with a per-request identity
hook — override mcpIdentity() on the build:
class ControlPlane extends Build {
override mcpIdentity() {
return (ctx: McpRequestContext) => {
const sub = ctx.headers.get("x-forwarded-user"); // proxy-injected
if (!sub) throw new Error("no identity from proxy"); // any throw rejects
return { actor: sub, via: "oauth-proxy" };
};
}
}
The hook runs once per request, before any dispatch. Its
actor overrides --actor, the environment, and
the client label for that call, and flows to the audit trail, run
records, lock-holder identity, and — for a registry-spawned build — the
child's ZUKE_ACTOR.
Fail-closed: a hook that throws or yields no
usable actor (an empty string, e.g. from
headers.get(…) ?? "" when the header is missing) rejects
the request with an auth error — nothing executes, nothing is written,
and it never falls back to the static actor, so the hook's precedence
stays absolute. Without a hook, stdio/local behavior is unchanged. This
is deliberately the minimal seam — TLS, the OAuth flow, and
header stripping are the proxy's job, not Zuke's.
Structured errors
A denied or failed tool call is still a successful MCP call — it comes
back as a tool result with isError: true and a small
structured JSON body, never a transport-level error:
{ "error": "lock_conflict", "holder": "…", "guidance": "…" }
{ "error": "unauthorized", "reason": "…" }
{ "error": "already_resumed" }
{ "error": "run_failed", "runId": "…", "message": "…" }
{ "error": "invalid_status" }
{ "error": "missing_argument" }
{ "error": "no_run" }
A hidden (non-allow-listed) target answers exactly like a nonexistent
tool — "Unknown tool: run:<name>" — so a denial never
reveals which protected targets exist.
Dynamic discovery
--registry switches the server from describing this one
build to serving a whole catalog of registered pipelines, re-read on
every call — a build registered after the server started shows up with
no restart. See Build registry for
BuildDescriptor, the FS/HTTP backends, and the
run:<buildId>:<target> tool shape.
Safety
Trust model. Over stdio, the server speaks only to the
process that launched it, so its trust boundary is the local machine:
anyone who can start zuke mcp already has a shell there and
could run deno run -A zuke.ts <target> directly — the
server grants no capability beyond what launching it already implies.
--http changes that: it gives the server a real network
endpoint, so it defaults to loopback (no remote party at all) and
requires a bearer token the moment it binds off-loopback, refusing to
start otherwise. Treat a non-loopback deployment like any other network
service — front it with TLS and your own authentication at the edge, and
don't wire an untrusted client to it.
Running a target executes real build code, so execution is
off by default: a freshly-connected agent can only
inspect the build. Add --allow-run deliberately — for an
environment where you want the agent to drive the pipeline. Without it, the
run: tools are not advertised at all, and a direct
run: call is refused with a message pointing at the flag. When
enabled, run tools carry MCP's destructiveHint, so a well-behaved
client can prompt before each execution.
Secret values stay protected: a run's output is captured through the same
reporter pipeline as the console, so parameter().secret() values
are redacted from what the agent sees.
Protocol notes
- Transport: newline-delimited JSON-RPC 2.0 on stdio.
- Lifecycle: the server answers
initialize(negotiating theprotocolVersion),notifications/initialized(no reply),ping,tools/list, andtools/call. Unknown requests get a JSON-RPC-32601 Method not found; notifications never get a reply. - Errors: a bad tool call (unknown tool, unknown target, a failed run) is reported through the tool result (
isError: true) so the model sees it, rather than as a transport-level error — matching the MCP convention.