Skip to content
BuildStackHQHosting
All docs

Docs

The MCP server

Giving an editor or an agent tools that reach your organization.

apps/mcp is a remote Model Context Protocol server over streamable HTTP, so a developer inside Claude Code or another MCP client can deploy and debug Keel agents without leaving the editor. It is the third client of the public API next to the dashboard and the CLI: every tool is a thin, task-shaped wrapper over @keel/api-client, it never reaches the database or an internal service, and every mutation it performs is audited by the API under the acting token.

Adding it to Claude Code

claude mcp add --transport http keel https://mcp.<domain>/mcp

Claude Code discovers the authorization server from the protected resource metadata, runs the OAuth flow in the browser and stores the token. With an API key instead (CI, a shared assistant, or while the keel-mcp OAuth client is not registered yet):

claude mcp add --transport http keel https://mcp.<domain>/mcp --header "Authorization: Bearer keel_..."

The MCP Inspector works the same way: npx @modelcontextprotocol/inspector, transport Streamable HTTP, url https://mcp.<domain>/mcp, and either the OAuth flow or a bearer header.

Authorization

The server is an OAuth 2.1 resource server per the MCP authorization specification (2025-06-18):

  • GET /.well-known/oauth-protected-resource and GET /.well-known/oauth-protected-resource/mcp (RFC 9728) answer { resource, authorization_servers: [AUTH_ISSUER_URL], bearer_methods_supported: ["header"], scopes_supported }. resource is the canonical form of MCP_PUBLIC_URL; clients pass it as the RFC 8707 resource parameter.
  • Every request to /mcp needs Authorization: Bearer <token>. Anything else answers 401 with WWW-Authenticate: Bearer resource_metadata="https://mcp.<domain>/.well-known/oauth-protected-resource/mcp", error="invalid_token", error_description="..." and a JSON body { error, error_description }.
  • Zitadel JWT access tokens are verified with jose against the issuer JWKS (AUTH_JWKS_URL, default <AUTH_ISSUER_URL>/oauth/v2/keys): iss must equal AUTH_ISSUER_URL and aud must contain the Keel project id (AUTH_AUDIENCE) or the keel-mcp client id. Zitadel puts the project id and every client id of the project in aud, so tokens minted by the dashboard, the CLI and the MCP client all verify. Expired, foreign key, wrong issuer and wrong audience tokens are 401.
  • keel_... API keys are accepted as bearer tokens too. A key belongs to exactly one organization and carries scopes, so it is the recommended credential for read-only assistants; the server checks the format and passes it through to the API, which resolves and audits it.
  • Tokens are never logged; the session id is the only per-token identifier in logs.

Zitadel and dynamic client registration: Zitadel does not implement RFC 7591, so the MCP client cannot self-register. Register a native app keel-mcp in the keel project (authorization code with PKCE, JWT access tokens, redirect URIs http://localhost:*/callback as the client requires) the same way keel-cli is registered in the identity notes, and have the client use that id. Until that app exists, use an API key.

Token audience and passthrough

The MCP specification forbids forwarding the token a client presented to a downstream API when that API is a separate resource. In Keel the MCP server and the public API are two entry points of the same resource, the keel Zitadel project: both validate the same aud, both map the same subject to the same user, and the API is the only place the permission check and the audit entry happen. The MCP server therefore forwards the verified token as-is and adds identifying headers (below). Moving to a distinct MCP audience with RFC 8693 token exchange at Zitadel is the next step if the two ever need separate trust boundaries.

Organization binding

A Zitadel token is not organization scoped: a user can belong to several organizations and the API asks for Keel-Organization when that is the case. The MCP server binds a session to one organization and sends that header on every call:

  • The session is keyed on the token (SHA-256, in memory, expiring with the token or after 12 hours). Streamable HTTP runs in stateless JSON mode, so nothing depends on an Mcp-Session-Id. The binding lives in one process: behind several replicas without sticky routing a client may be asked to select again, which is harmless because the selection is one call.
  • keel_list_organizations lists the organizations the token can act in and which one is selected. keel_select_organization binds the session by id or slug. Every other tool refuses with organization_required (and the candidate organizations) until the session is bound.
  • A token with exactly one membership, and every API key, binds automatically on the first call.
  • Zitadel can carry a custom claim through an action, but no organization claim is defined for Keel tokens today, so the selection lives in the MCP session. Documenting a keel_org claim is the alternative if sessions ever need to be stateless across MCP instances.

Tools

Names are keel_<verb>_<object>. Every tool has a zod input schema (enums over free strings, uuids checked), an outputSchema, returns structuredContent plus a compact text rendering, and sets the MCP annotations honestly so clients can gate confirmations on them. agent parameters accept an id or a slug.

Session

Tool What it does readOnly destructive idempotent openWorld
keel_list_organizations Organizations the token can act in and the selected one yes no yes no
keel_select_organization Bind the session to one organization by id or slug no no yes no

Read

Tool What it does readOnly destructive idempotent openWorld
keel_list_agents Agents with lifecycle and current revision, paged yes no yes no
keel_get_agent One agent by id or slug yes no yes no
keel_list_runs Runs newest first, filters agent and status, cursor paged yes no yes no
keel_get_run Run summary: status, error class, queue and execution timings, latest attempt, resumable with the latest checkpoint sequence, and cost from the run's usage record (billableSeconds, weightedMinutes, sizeWeight; metered: false with nulls while the run is not metered) yes no yes no
keel_get_run_trace One line per step (sequence, kind, name, status, duration, tokens, http status, error class), at most 200 per call with a cursor, never payloads, failingStep pointer. The step lines are wrapped in <untrusted-data> (see below) yes no yes no
keel_get_step One step in full from GET /v1/runs/{id}/steps/{sequence} with input and output payloads fetched through the presigned urls the endpoint returns inline, each capped at 32 KB with a truncation note and wrapped in <untrusted-data>; include selects both, input, output or none yes no yes no
keel_get_run_logs Container stdout and stderr of an attempt, redacted server side, addressed by run or by agent (a service resolves to its current run, a run-mode agent to its newest run; a stopped service answers service_not_running): the last tail lines (default 200, max 2000) or a forward range from afterChunk with nextAfterChunk to continue, filter by stream, answers capped at 32 KB with dropped and a next step hint. The lines are wrapped in <untrusted-data> yes no yes no
keel_tail_run Waits up to waitSeconds (server bound MCP_TAIL_MAX_WAIT_SECONDS) on the run's SSE stream and returns the steps after cursor plus the latest status and done yes no yes no
keel_list_builds Image builds of an agent, paged yes no yes no
keel_get_build_logs Build status plus a page of log lines (nextToken), lines truncated at 400 chars, 500 lines per call yes no yes no
keel_get_usage Period usage against the plan: runs, weighted minutes, concurrency, spend cap yes no yes no
keel_list_triggers Manual, schedule and webhook triggers of an agent; secrets never returned yes no yes no
keel_list_callbacks Callback deliveries of a run with every attempt yes no yes no
keel_list_services Every service agent with its kind (agent or app), state, current run, restarts, up since and hostname yes no yes no
keel_get_service One service by agent id or slug: kind and price per replica-month, state, current run and its status, restarts, last exit code, uptime, workspace usage against the plan size, hostname yes no yes no
keel_get_ingress The public hostname, alias and connection state of an agent, or ingress: null without ingress yes no yes no

Write

Tool What it does readOnly destructive idempotent openWorld
keel_trigger_run Queue a manual run with an optional JSON payload and callback url no no no yes
keel_cancel_run Cancel a queued or running run no yes no no
keel_resume_run Resume a failed or timed out run from its checkpoint no no no yes
keel_pause_agent Pause a live agent no no no no
keel_resume_agent Resume a paused agent no no no no
keel_deploy_image Register a pushed image and create a revision from the agent's current manifest no no no no
keel_start_build Build a commit or the bound branch head; an existing build for the commit is returned with created: false no no yes no
keel_redeliver_callback Queue a callback for immediate redelivery no no no yes
keel_start_service Start the service of an agent in service mode on its current revision (idempotent while up) no no yes yes
keel_stop_service Stop a service and end its current run, keeping the workspace (idempotent when stopped) no no yes no
keel_restart_service End the current run and start a new one on the current revision; refused when stopped no no no yes

The three service write tools are revocable like every other write tool: hidden by MCP_READ_ONLY, refused by MCP_WRITE_TOOLS_DISABLED or a policy file entry.

There is deliberately no keel_set_secret. A secret value passed as a tool argument travels through the model's context window, the host's transcript and whatever logging sits in between, so the "never echoed" promise only covered the answer, not the request. Secrets are set with keel secrets set (hidden prompt, file or stdin) or in the dashboard, where the value never leaves the operator's machine except over TLS to the API. Task 8 below shows the intended flow.

Untrusted data

Step names, failure messages and payloads come from the customer's agent container and from whatever triggered the run. They can contain text written to look like instructions. keel_get_run_trace wraps its step lines, keel_get_run_logs wraps its log lines and keel_get_step wraps each payload excerpt in <untrusted-data> ... </untrusted-data> and precedes them with one line stating that the content is data, not instructions. The structured input and output excerpts carry the same wrapper in text and repeat the note in warning, so a host that shows only structured content still sees the framing.

openWorldHint is set on the tools that start work which leaves the platform (an agent run reaches its allowlisted hosts, a callback posts to a customer endpoint). keel_cancel_run is the only destructive tool: it ends work that cannot be resumed.

Errors

Errors are tool results (isError: true), never protocol errors or stack traces. The text is <kind>: <detail> (requestId ...). Next step: <hint> and the same object is under _meta.error (kind, detail, nextStep, status, requestId, plus problem extras such as organizations). API problem+json kinds map to hints, for example agent_not_found says to call keel_list_agents, agent_not_live points at keel_resume_agent, run_not_resumable at keel_get_run, organization_required at keel_select_organization. Unreachable API is api_unreachable, a refused write tool is tool_disabled.

Read-only mode and revocation

  • MCP_READ_ONLY=true hides every write tool from tools/list; a direct call answers unknown tool.
  • MCP_WRITE_TOOLS_DISABLED=keel_cancel_run,keel_deploy_image keeps the tools listed but every call is refused with tool_disabled and the reason; unknown names fail startup.
  • MCP_POLICY_FILE=/etc/keel/mcp-policy.json adds per principal and per organization rules:
{
  "disabledWriteTools": ["keel_deploy_image"],
  "principals": {
    "sub:314159265358979": { "readOnly": true },
    "apikey:8f3a...": { "disabledWriteTools": ["keel_cancel_run"] }
  },
  "organizations": {
    "acme": { "disabledWriteTools": ["keel_cancel_run"] }
  }
}

Principal keys are sub:<zitadel subject> (known before any API call, so write tools are hidden from tools/list for a read-only subject), user:<keel user id> and apikey:<key id> (known once the session is bound). Organization keys are the id or the slug. The decision is taken on every call before the API is touched.

Next step: an organization level setting served by the API (GET /v1/organizations/me/mcp-policy, managed by owners in the dashboard) so a customer can grant an assistant read-only access without an operator editing a file. The policy file format above is the shape that endpoint would return.

Audit

Every mutating request the MCP server makes is audited by the API's AuditInterceptor with the acting principal (actor is the user id or apikey:<id>), exactly as for the dashboard and the CLI. To identify MCP calls in that trail the server sends on every request:

  • X-Keel-Client: mcp/<version>
  • X-Keel-MCP-Session: <random 128 bit session id>
  • X-Request-Id: mcp.<session id>.<tool name>.<n>

The API stores requestId in the audit entry metadata together with metadata.client (from X-Keel-Client) and metadata.mcpSession (from X-Keel-MCP-Session), each trimmed to 200 characters, so a GET /v1/audit-log entry reads metadata.requestId = "mcp.<session>.keel_trigger_run.7", metadata.client = "mcp/0.1.0", metadata.mcpSession = "<session>". Requests without the headers carry neither field.

Read tools are audited as client-side events: after a successful read tool call inside a bound organization the server posts POST /v1/audit-log/events with source: "mcp", action: <tool name> (stored as mcp:keel_get_step), target (the run, agent, build, callback or trigger id from the input, otherwise the tool name) and metadata.session. The API writes it through the same AuditWriter with the acting principal, so GET /v1/audit-log shows read and write activity in one trail. The call is fire-and-forget: it never delays or fails the tool result, and a failure is logged at debug level. Session and organization selection tools are not recorded.

Running it

Variable Purpose
MCP_PORT Listen port, default 3100.
MCP_PUBLIC_URL Canonical url clients use, for example https://mcp.keel.example/mcp. Its path is the MCP endpoint, its origin serves the well-known metadata.
MCP_READ_ONLY true hides every write tool.
MCP_WRITE_TOOLS_DISABLED Comma separated write tool names that are refused.
MCP_POLICY_FILE Optional JSON policy file, see above.
MCP_DOCUMENTATION_URL Optional resource_documentation in the metadata.
MCP_TAIL_MAX_WAIT_SECONDS Upper bound of keel_tail_run's wait, default 25.
AUTH_ISSUER_URL, AUTH_AUDIENCE, AUTH_JWKS_URL Same values as the API (the identity notes).
KEEL_API_URL The public API, for example https://api.keel.example.
LOG_LEVEL, NODE_ENV pino level and environment.

GET /healthz answers { status: "ok", version }. Logs are structured pino lines with the tool name, kind, session id, principal kind (subject or key prefix), organization, duration and outcome; never arguments, payloads, secrets or tokens.

Local: pnpm --filter @keel/mcp build && MCP_PUBLIC_URL=http://localhost:3100/mcp AUTH_ISSUER_URL=http://localhost:8080 AUTH_AUDIENCE=<project id> KEEL_API_URL=http://localhost:3000 pnpm --filter @keel/mcp start, then claude mcp add --transport http keel http://localhost:3100/mcp --header "Authorization: Bearer $(pnpm -s --filter @keel/api token)".

Container: apps/mcp/Dockerfile (pinned node:22-alpine, runs as node, health check on /healthz). Deployment behind the ALB as mcp.<domain> is not wired in infra/ yet.

Task prompts

These are the prompts the server is designed around. apps/mcp/test/task-prompts.test.ts runs every sequence against the real API booted in-process with in-memory ports and the MCP SDK client over HTTP.

  1. "Find why last night's invoice-reconciler run failed and show me the failing step." keel_list_runs {agent: "invoice-reconciler", status: "failed", limit: 1} then keel_get_run for the timings and error class, keel_get_run_trace to find failingStep, then keel_get_step {sequence: failingStep} for the failing request and its input, and keel_get_run_logs {run, stream: "stderr"} when the failure happened outside a recorded step.
  2. "Run invoice-reconciler for 2026-09-15 now and follow it until it finishes." keel_trigger_run {agent, payload: {date: "2026-09-15"}}, then keel_tail_run {cursor: 0} repeatedly with the returned cursor until done, then keel_get_run for the final summary.
  3. "Is invoice-reconciler paused? If so resume it and run it once." keel_get_agent, keel_resume_agent when lifecycle is paused, keel_trigger_run.
  4. "How close are we to this period's run quota?" keel_get_usage, compare runs.used with runs.included and weightedMinutes.
  5. "The callback for the last run never reached our webhook; check the attempts and send it again." keel_list_callbacks {run} to read the attempts (status, response excerpt, failure reason), then keel_redeliver_callback {callback}.
  6. "Deploy the image I just pushed as :v12 to invoice-reconciler and confirm it is the current revision." keel_deploy_image {agent, imageReference}, keel_get_agent to check currentRevisionId equals the new revision, keel_list_builds {limit: 1} to show the push build.
  7. "Build the current main branch of invoice-reconciler and show me the build log." keel_start_build {agent}, then keel_get_build_logs {build} (again with nextToken while it is not null).
  8. "Rotate the OPENAI_API_KEY secret for this organization." No tool does this on purpose (see the tools table). The assistant answers with the CLI command keel secrets set OPENAI_API_KEY (hidden prompt) or points at the dashboard's secrets page, and can confirm the name afterwards with the API's secret list once such a read tool exists.
  9. "Which agents have a webhook trigger and what are their urls?" keel_list_agents, then keel_list_triggers {agent} per agent, keeping the webhook entries and their webhookUrl.
  10. "Is the support bot up? Show me what it logged in the last minute and restart it if it keeps crashing." keel_get_service {agent: "support-bot"} for the state, restarts and last exit code, keel_get_run_logs {agent: "support-bot", stream: "stderr"} for the current run's stderr, then keel_restart_service {agent} (or keel_start_service when it is stopped) and keel_get_service again to confirm up.
  11. "Where do I point the Telegram webhook for hook-bot?" keel_get_ingress {agent: "hook-bot"} and answer with hostname plus the bot's webhook path; the alias is rotated from the CLI or the dashboard when a url leaks.

API gaps met while building this

  • No organization level MCP policy endpoint (see Revocation).
  • No container logs (HTTP API), so there is no keel_get_run_logs; the trace and the events stream are the observability surface.
  • Zitadel has no dynamic client registration, so the keel-mcp app must be registered by hand and the client configured with its id, or an API key used.