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:
| 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. The allow-list gates invocation: invoking a target runs its dependencies, which is what depending on a target means, so allow-listingreleaseallows everythingreleasedoes. 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 requiredoperatorTokenargument, checked in constant time againstZUKE_OPERATOR_TOKEN. Protection is a property of the operation, so it is enforced across the whole plan: a protecteddeployreached as a dependency of an unprotectedreleasestill demands the token, andrun:releaseadvertises 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 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 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.
| Call | Needs |
|---|---|
list_* / show_* / describe_* / graph | read |
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 run | the run's initiator or operator, and every requiresRole in the run's plan |
resume_check sweeping every run | operator |
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 → --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.
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.
| Field | Meaning |
|---|---|
actor | the 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 |
roles | the 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 |
via | how 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
POSTover the HTTP transport (--http). - Lifecycle: the server answers
initialize(echoing the client's requestedprotocolVersion),notifications/initialized(no reply),ping,tools/list, andtools/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-26and2024-11-05. A client's requested version is echoed when this server implements it, and otherwise answered with the newest. Over HTTP, anMCP-Protocol-Versionheader naming an unimplemented revision is refused400— after 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.