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.

MCP lets an agent operate a build; the agent skills are the complement — they teach it to author one, and install from the repo's skills marketplace into Claude Code, OpenAI Codex, and the Gemini CLI.

Running it

Read-only — the agent can inspect the build, never execute it:

zuke mcp

--allow-run also exposes run:<target> tools that execute targets:

zuke mcp --allow-run

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

The scaffolder can do it for you. zuke setup --mcp (and zuke import --mcp) writes a project-scoped .mcp.json next to zuke.ts that registers this server as zuke, in the format Claude Code, Codex and most stdio clients read — so the build is agent-ready from its first commit. --allow-run registers it with execution enabled. An existing file is merged around its other servers.

# read-only registration
zuke setup --mcp

# …and let the agent run targets
zuke setup --mcp --allow-run

What it writes:

{
  "mcpServers": {
    "zuke": { "command": "deno", "args": ["run", "-A", "zuke.ts", "mcp"] }
  }
}

Any client also takes the command directly — any client that speaks stdio MCP works the same way. In Claude Code, give it deno run -A zuke.ts mcp:

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

With a bearer token set, the server can bind a non-loopback host:

ZUKE_MCP_TOKEN= deno run -A zuke.ts mcp --http 0.0.0.0:7777 --allow-run

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.

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. The allow-list gates invocation: invoking a target runs its dependencies, which is what depending on a target means, so allow-listing release allows everything release does. Scope it to the entry points you want an agent to have, not to individual steps. The read tools narrow to match — with an allow-list they describe the allow-listed targets and their dependency closure and nothing else, so a target outside it is genuinely unreachable rather than merely undisplayed.
  • --protect <globs> — a run that would execute a matching target gains a required operatorToken argument, checked in constant time against ZUKE_OPERATOR_TOKEN. Protection is a property of the operation, so it is enforced across the whole plan: a protected deploy reached as a dependency of an unprotected release still demands the token, and run:release advertises the requirement in its schema. This is fail-closed — no token configured means every protected target is denied, and a call whose plan cannot be resolved (a run record whose root target no longer exists) is denied rather than assumed harmless, so a misconfigured server can never silently expose one. A denial is a structured {"error": "unauthorized", …} result, and the token is never written to the audit log or any output.
  • --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

Roles

The three tiers above are process-wide: they say what anyone reaching this server may do. Once the server authenticates its callers it can say what this caller may do, which is what roles are for.

Three built-in roles are ordered — read < run < operator — so an operator satisfies a requirement for run without being granted it separately. Any other name is matched exactly: an identity provider's own group (sre, release-manager) works with requiresRole without being ranked into a hierarchy it never agreed to. Map your groups onto the three tiers for the general permission and use your own names for the specific one.

CallNeeds
list_* / show_* / describe_* / graphread
run:<target>run, and every requiresRole declared anywhere in the plan it would run
cancel_run / signal_run / force_target, and resume_check on one runthe run's initiator or operator, and every requiresRole in the run's plan
resume_check sweeping every runoperator

requiresRole can only raise the bar — run is the floor for executing anything, so declaring requiresRole("read") does not let a read-only caller run a target. It is enforced across the whole plan, like --protect and for the same reason: invoking a target runs its dependencies, so a requirement that guarded only the entry point would be bypassed by invoking anything that depends on it. A run-scoped mutation executes the run's plan too, so the same requirements apply to resuming, signalling, cancelling, or forcing it.

A run whose initiator is a service may be steered by any caller holding run: a scheduler's run has no person to ask, and treating the scheduler as its owner would mean nobody could intervene. Presenting a valid ZUKE_OPERATOR_TOKEN grants the operator role for that call, so the shared secret and the role model are one policy rather than two that can disagree — and a deployment can move to roles a piece at a time.

Override the whole decision with mcpAuthorize when the rule is something the engine cannot know — a change window, team ownership, a freeze:

class ControlPlane extends Build {
  override mcpAuthorize(identity: McpIdentity, call: McpCall) {
    if (call.tool === "run:promote" && !inChangeWindow()) {
      return { allow: false, reason: "outside the change window" };
    }
    return defaultMcpAuthorize(identity, call);
  }
}

It runs after the allow-list and operator-token checks, so it can narrow what those permit and never widen it.

Two servers are deliberately unaffected. One with no authenticator at all treats every caller as holding every role, so --allow-run, --protect and the operator token remain exactly the gates they were; and a build using the legacy mcpIdentity() hook — which never had roles to give — is not constrained by the policy either. Those two are what keep a local stdio server, and every deployment that predates roles, working unchanged. Enforcement is a property of the seam, not of one identity: declaring mcpAuth() opts in, and an identity it returns without a roles list settles to none and is denied — inferring it per identity would mean a token carrying less information bought more privilege. The one exception is requiresRole: a target that declares one is refused for a caller whose authenticator cannot express roles, naming the seam, because silently ignoring the declaration would tell you a target is gated when nothing is checking.

In registry mode the policy decides on the tiers alone — read to list or describe a registered build, run to spawn one — because a registry descriptor carries no per-target requiresRole.

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.

Authentication

By default the server does not identify its callers: on stdio it inherits the trust of the shell that launched it, and over HTTP ZUKE_MCP_TOKEN is a shared secret, not an identity. On a shared, multi-user endpoint "who did this" must not be self-reported, so a build can declare an authenticator that runs once per request, before any dispatch, on both transports.

override mcpAuth() is the general seam. It returns an object with an authenticate(ctx) method, which may be asynchronous (verifying a token signature is), and either returns an identity{ actor, kind?, roles?, via? } — or refuses with an McpAuthReject:

class ControlPlane extends Build {
  override mcpAuth(): McpAuthenticator {
    return {
      authenticate: async (ctx: McpRequestContext) => {
        const claims = await verifyBearer(ctx.headers.get("authorization"));
        if (claims === null) {
          return {
            status: 401,
            error: "invalid_token",
            detail: "expired or unknown bearer token",
            challenge: 'Bearer realm="zuke", error="invalid_token"',
          };
        }
        return { actor: claims.sub, kind: "service", roles: claims.roles };
      },
    };
  }
}

ctx is the request context: its headers, plus the underlying request (so an authenticator can read the method and URL) when the caller arrived over HTTP. On stdio both are empty — an authenticator that insists on a header refuses every stdio call, which is the point: declare one for the endpoint you actually expose.

FieldMeaning
actorthe authenticated caller. Required and non-empty — anything else refuses the request
kind"human" or "service". Omitted reads as "human", so a service claim must be stated
rolesthe caller's roles, in three states. Omitted means this authenticator does not speak roles — only meaningful for the legacy mcpIdentity() seam, whose callers the policy leaves alone. An empty list means the question was considered and nothing granted, which denies. A non-empty list is evaluated. An mcpAuth() identity that omits the list settles to empty, so a token carrying less information never buys more privilege
viahow the identity was established ("oauth-proxy"). Informational only

The resolved actor overrides --actor, the environment, and the client label for that call, and flows to the audit trail, run records, lock-holder identity, and a registry-spawned child's ZUKE_ACTOR. kind and roles reach that child as ZUKE_ACTOR_KIND and ZUKE_ACTOR_ROLES (each role percent-encoded, so a name containing the separator survives).

override mcpIdentity() is the older, synchronous seam, unchanged for authors: 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 return that caller. Any throw rejects the request.

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" };
    };
  }
}

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. Zuke is only ever the resource: TLS, minting tokens, and running the OAuth flow belong to the identity provider you already have.

Telling a client where to authenticate

Verifying a token only helps a client that already has one. A fresh claude mcp add --transport http <url> has nothing, and no way to guess where to get one — so it asks, the way OAuth 2.0 defines: it reads the WWW-Authenticate challenge on the 401, fetches the metadata document that challenge names, and finds the authorization server there.

mcpProtectedResource() publishes that document. Zuke issues no tokens and hosts no /authorize, /token or /register endpoint — those belong to whatever identity provider you already run (Auth0, Okta, Entra, Keycloak, Dex), and mcpAuth() is where the tokens it mints are verified.

import { Build, protectedResource, run, target } from "jsr:@zuke/core";

class ControlPlane extends Build {
  deploy = target().executes(() => {});

  override mcpProtectedResource() {
    return protectedResource("https://build.example.com/mcp")
      .authorizationServer("https://acme.eu.auth0.com")
      .scopes("zuke:run")
      .name("Acme build server");
  }
}

await run(ControlPlane);

That is the whole configuration. An unauthenticated call is then answered with:

401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://build.example.com/.well-known/oauth-protected-resource/mcp"

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, or one JSON-RPC message per POST over the HTTP transport (--http).
  • Lifecycle: the server answers initialize (echoing the client's requested 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.
  • Protocol revisions: 2025-11-25 (the newest offered), 2025-06-18, 2025-03-26 and 2024-11-05. A client's requested version is echoed when this server implements it, and otherwise answered with the newest. Over HTTP, an MCP-Protocol-Version header naming an unimplemented revision is refused 400after authentication, so the supported list is not something an unauthenticated caller can enumerate. An absent header is fine.
  • 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.