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-resourceandGET /.well-known/oauth-protected-resource/mcp(RFC 9728) answer{ resource, authorization_servers: [AUTH_ISSUER_URL], bearer_methods_supported: ["header"], scopes_supported }.resourceis the canonical form ofMCP_PUBLIC_URL; clients pass it as the RFC 8707resourceparameter.- Every request to
/mcpneedsAuthorization: Bearer <token>. Anything else answers401withWWW-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
joseagainst the issuer JWKS (AUTH_JWKS_URL, default<AUTH_ISSUER_URL>/oauth/v2/keys):issmust equalAUTH_ISSUER_URLandaudmust contain the Keel project id (AUTH_AUDIENCE) or thekeel-mcpclient id. Zitadel puts the project id and every client id of the project inaud, so tokens minted by the dashboard, the CLI and the MCP client all verify. Expired, foreign key, wrong issuer and wrong audience tokens are401. 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_organizationslists the organizations the token can act in and which one is selected.keel_select_organizationbinds the session by id or slug. Every other tool refuses withorganization_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_orgclaim 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=truehides every write tool fromtools/list; a direct call answers unknown tool.MCP_WRITE_TOOLS_DISABLED=keel_cancel_run,keel_deploy_imagekeeps the tools listed but every call is refused withtool_disabledand the reason; unknown names fail startup.MCP_POLICY_FILE=/etc/keel/mcp-policy.jsonadds 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.
- "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}thenkeel_get_runfor the timings and error class,keel_get_run_traceto findfailingStep, thenkeel_get_step {sequence: failingStep}for the failing request and its input, andkeel_get_run_logs {run, stream: "stderr"}when the failure happened outside a recorded step. - "Run invoice-reconciler for 2026-09-15 now and follow it until it finishes."
keel_trigger_run {agent, payload: {date: "2026-09-15"}}, thenkeel_tail_run {cursor: 0}repeatedly with the returned cursor untildone, thenkeel_get_runfor the final summary. - "Is invoice-reconciler paused? If so resume it and run it once."
keel_get_agent,keel_resume_agentwhenlifecycleispaused,keel_trigger_run. - "How close are we to this period's run quota?"
keel_get_usage, compareruns.usedwithruns.includedandweightedMinutes. - "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), thenkeel_redeliver_callback {callback}. - "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_agentto checkcurrentRevisionIdequals the new revision,keel_list_builds {limit: 1}to show the push build. - "Build the current main branch of invoice-reconciler and show me the build log."
keel_start_build {agent}, thenkeel_get_build_logs {build}(again withnextTokenwhile it is not null). - "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. - "Which agents have a webhook trigger and what are their urls?"
keel_list_agents, thenkeel_list_triggers {agent}per agent, keeping thewebhookentries and theirwebhookUrl. - "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, thenkeel_restart_service {agent}(orkeel_start_servicewhen it is stopped) andkeel_get_serviceagain to confirmup. - "Where do I point the Telegram webhook for hook-bot?"
keel_get_ingress {agent: "hook-bot"}and answer withhostnameplus 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-mcpapp must be registered by hand and the client configured with its id, or an API key used.