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:

ToolReturns
list_targetsEvery target with its description and dependencies.
describe_buildThe full build surface — commands, flags, targets, parameters (the --list --json payload).
graphEach target and the targets it depends on.
list_runsRuns newest-first, when a state store resolves. Optional status/target/since filters.
show_runOne 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-run exposes 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. No ZUKE_OPERATOR_TOKEN set means every protected call is denied — fail-closed, not open.
  • --confirm-destructive — a destructive run tool returns its plan instead of executing unless called with confirm: true. A target that declares .readOnly() is exempt (it carries readOnlyHint instead of destructiveHint), and dryRun always 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--actorZUKE_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 hookoverride 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 the protocolVersion), notifications/initialized (no reply), ping, tools/list, and tools/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.