Build registry

The build registry is a catalog of pipelines and where they live. Each build registers a small versioned BuildDescriptor — its id, CLI surface, and launch location — into a pluggable store. Point zuke mcp --registry at that store and it re-reads the catalog on every tools/list and tools/call, so a newly-registered pipeline shows up as a runnable tool with no restart.

BuildDescriptor & BuildRegistry

A registry is anything that satisfies BuildRegistry: get one build, register (or update) one with optimistic-concurrency versioning, deregister one, or list them by BuildQuery.

export type PutBuildResult = { ok: true; version: string } | { ok: false; conflict: true };
export interface BuildRegistry {
  getBuild(id: string): Promise<{ descriptor: BuildDescriptor; version: string } | null>;
  register(descriptor: BuildDescriptor, expectedVersion: string | null): Promise<PutBuildResult>;
  deregister(id: string): Promise<void>;
  listBuilds(query: BuildQuery): Promise<BuildSummary[]>;
}
export type BuildLocation =
  | { kind: "module"; module: string; cwd: string; repo?: string }
  | { kind: "command"; command: string[]; cwd: string; repo?: string };
export interface BuildDescriptor {
  id: string; name: string; location: BuildLocation;
  surface: CliDescription;   // exactly describeCli(build) — flags only, no values
  actor: string; createdAt: string; updatedAt: string;
}
export interface BuildQuery { name?: string; since?: string }

A descriptor is secret-free by construction: surface is exactly describeCli(build) (flags only, no values), module-URL credentials are stripped, and register resolves no parameter values.

FS vs HTTP backends

Two backends ship, both behind the same BuildRegistry interface:

BackendStorage
FileSystemBuildRegistry constructor(dir: string, host?) — defaults to .zuke/builds, one <id>.json file per build, written with an O_EXCL+rename compare-and-swap.
HttpBuildRegistry { url: string; token?: string; fetch?: typeof fetch }GET/PUT/DELETE /builds/:id and GET /builds, ETag/If-Match compare-and-swap, bearer auth.

Override registry() on a build to point it at an HTTP-backed registry — this is the same shape as the run state store, but a separate concern (.zuke/builds sits beside .zuke/runs; over HTTP it rides a /builds REST contract beside /runs):

class CD extends Build {
  registryUrl = parameter("registry URL").required();
  registryToken = parameter("registry token").secret();
  override registry() {
    return new HttpBuildRegistry({ url: this.registryUrl.value, token: this.registryToken.value });
  }
}

Resolution & env vars

Which registry a build uses is resolved in order:

  1. An explicit registry passed in code (false disables it).
  2. The build's registry() override.
  3. Env: ZUKE_REGISTRY_URL (+ ZUKE_REGISTRY_TOKEN) selects an HTTP registry; otherwise ZUKE_REGISTRY_DIR selects an FS registry.
  4. For zuke register and zuke mcp --registry, an FS registry under .zuke/builds by default.
Env varEffect
ZUKE_REGISTRY_URLSelects the HTTP backend; base URL for the /builds contract.
ZUKE_REGISTRY_TOKENBearer token sent with every HTTP registry request.
ZUKE_REGISTRY_DIRSelects the FS backend at this directory (default .zuke/builds).

Registering a build

zuke register writes (or idempotently updates) this build's descriptor into the resolved registry, excluding secrets:

deno run -A zuke.ts register            # writes .zuke/builds/<id>.json by default
deno run -A zuke.ts register --json
zuke mcp --registry --allow-run

Dynamic MCP discovery

zuke mcp --registry serves the whole catalog instead of one build. Registry-mode discovery adds two shapes to the tools already documented for zuke mcp: list_builds returns the catalog, and describe_build (given a build id) returns one build's surface. Each registered target becomes a run:<buildId>:<target> tool, re-read live — a build registered after the server started appears with no restart.

Execution here is a spawn of the descriptor's registered launch location, not a call into a live instance — so it's off unless --allow-run is set, exactly like the single-build server. Authorization is matched against the qualified <buildId>:<target> name, e.g. --allow-run=Api:* or --protect=Api:deploy.

Run tool parameters

A run tool exposes the registered build's declared parameters as its input schema — keyed by the parameter's property name (e.g. skipE2e), with the kind, description, enum, and default from the descriptor. Supplied values are validated against their kinds before the build spawns (a type mismatch — a bare string where an array is required, a non-numeric number, an out-of-set enum, or an unknown parameter — is a clean tool error, not a failed subprocess), then forwarded to the child as --flag=value arguments alongside the target. A value still set in the server's environment applies when the call omits it. Because a descriptor does not record whether a target is read-only, every registry run tool is treated as destructive.

Secrets never cross the boundary

.secret() parameters are omitted from the descriptor entirely (zuke register writes the secret-free surface), so a secret can neither be requested nor forwarded — it is rejected as an unknown parameter if a client tries. The spawned build resolves a secret from its own environment / .from() source instead. In the audit log only a recognised parameter's value is recorded; any unknown argument keeps its name but its value is elided, so a value mistakenly supplied under a secret's name is never written to the durable trail.

The spawned build inherits the server's environment minus ZUKE_OPERATOR_TOKEN and ZUKE_MCP_TOKEN, so a spawned pipeline can never read the very tokens gating it.

Concurrency + cap

Unlike the single-build server, the registry server handles requests concurrently — a read tool (list_builds, describe_build) is never blocked behind a running run: call, and independent runs proceed in parallel. Concurrent run-tool spawns are capped (default 4, --max-concurrent-runs <n>); a call past the cap gets an immediate structured at_capacity busy error ({ running, cap, hint }) rather than an unbounded queue. Read tools are never counted against the cap. Cross-process state safety rides the store's CAS, so no new coordination is introduced.