Management API (MCP)
Paddock can expose itself as an MCP server at /mcp, so a caller
outside the instance — a Claude Code session on your laptop, a CI job, or a
peer Paddock — can drive the same operations a keeper reaches through its
in-process paddock_manage tools.
External callers get the same toolset a keeper receives, minus whatever their
credential’s scope hides. Nothing is redefined for the external surface, so a
tool added to the self-management MCP appears over /mcp for free and the two
can’t drift.
Three things to know first
Section titled “Three things to know first”- It authenticates itself. The
/mcpgate is completely independent ofPADDOCK_AUTH_MODEand of any reverse proxy. The endpoint stays credential-gated even on an instance runningauth.mode: none, and running Paddock with no proxy at all is fully supported./mcpis exempt from the browser auth hook precisely because it runs its own authenticator in its place. - It fails closed. With no
managementApi.clients— or nopublicUrl—/mcpreturns 404. The endpoint does not exist until an operator deliberately turns it on./mcpand/.well-known/are also excluded from the SPA catch-all, so an unconfigured instance 404s honestly instead of answering a machine surface with the app shell and a200. - Token material is referenced, never inlined.
paddock.config.yamlis git-tracked (and editable from the instance Settings screen), so a literaltoken:orsecret:in it is a hard config error, not a warning.
Endpoints
Section titled “Endpoints”| Method | Path | Auth | What it is |
|---|---|---|---|
POST | /mcp | Bearer token | The streamable-HTTP JSON-RPC MCP endpoint. |
GET, DELETE | /mcp | Bearer token | 405 + Allow: POST — once authenticated. The gate runs first, so without a valid token these are a 401 like any other request. |
GET | /.well-known/oauth-protected-resource/mcp | None | RFC 9728 protected-resource metadata (path-inserted form). |
GET | /.well-known/oauth-protected-resource | None | The same document at the bare root. |
POST /mcp
Section titled “POST /mcp”The transport is stateless: a fresh MCP server and transport are built per request, bound to the authenticated principal, with no session store and no cross-request state. Restarts are transparent to clients, and one caller’s tool visibility can never leak into another’s session.
The MCP server identifies itself as paddock in the initialize handshake, with
the Paddock package version as its version string.
A successful POST is answered as Content-Type: text/event-stream, not
application/json — the streamable-HTTP transport frames its reply as a
single SSE event:
event: messagedata: {"result":{"tools":[…]},"jsonrpc":"2.0","id":1}That is a normal 200. It matters mostly when you’re testing by hand, since
a curl expecting bare JSON will look like it failed.
GET/DELETE /mcp → 405
Section titled “GET/DELETE /mcp → 405”Refused explicitly rather than silently. In stateless mode the transport answers
a GET with an SSE stream that never emits anything, so a client would hang
forever on a socket that never gets headers. Paddock replies 405 with
Allow: POST and a JSON-RPC error body instead.
The auth gate runs before the method check, though, so this is what an
authenticated GET gets. An unauthenticated one — opening /mcp in a browser,
say — is a plain 401.
The discovery document
Section titled “The discovery document”GET /.well-known/oauth-protected-resource/mcp is unauthenticated by design —
a client fetches it before it holds any credential, so gating it would make
discovery impossible. The document names the authorization server and the
supported scopes; it never contains a secret. It is served with
Access-Control-Allow-Origin: * and Cache-Control: public, max-age=300.
Two details matter:
- The URL is path-inserted, not path-appended. For a resource at
https://paddock.example.com/mcpthe metadata lives athttps://paddock.example.com/.well-known/oauth-protected-resource/mcp. A verified trace of a real Claude Code session showed it requests only that form and never the bare root. Paddock serves both and relies on the path-inserted one. - It is published only when
authorizationServersis set. RFC 9728 makesauthorization_serversoptional, but the MCP specification makes it mandatory, and a token-only deployment has no authorization server. Rather than publish a document the governing spec calls invalid, Paddock publishes nothing — the URL404s. A client holding a static bearer token never performs discovery, so nothing is lost on the supported path.
{ "resource": "https://paddock.example.com/mcp", "authorization_servers": ["https://idp.example.com/application/o/paddock/"], "scopes_supported": ["paddock:read", "paddock:write"], "bearer_methods_supported": ["header"], "resource_name": "Paddock Management API"}resource is built from the operator-configured publicUrl, never from the
Host header: RFC 9728 §3.3 requires the client to byte-match it against the URL
it used, behind a TLS-terminating proxy the derived scheme would be wrong, and
Host is attacker-controlled anyway.
The response matrix
Section titled “The response matrix”The gate runs in Fastify’s onRequest hook — before body parsing — so a
malformed or oversized body can never preempt the auth decision. The checks run
in this order:
| Status | When | Body / headers |
|---|---|---|
| 404 | managementApi.clients is empty, or publicUrl is unset. | { "error": "not found" } |
| 403 | Plaintext request from a non-loopback client (caveat). | code: "insecure_transport" |
| 401 | Credential missing, malformed, or matching no configured client. | WWW-Authenticate: Bearer …, code: "auth_required" |
| 405 | GET or DELETE on /mcp, after the gate has passed. | Allow: POST, JSON-RPC error -32000 |
| 503 | The surface is configured but the route has no ops context (a wiring error, not a client error). | code: "ops_unavailable" |
| 406 | The request didn’t send Accept: application/json, text/event-stream. Enforced by the MCP transport, so it lands after the gate above. | JSON-RPC error -32000, Not Acceptable: Client must accept both application/json and text/event-stream |
| 200 | Everything else — the JSON-RPC response, including in-band tool errors. SSE-framed (above), not bare JSON. | Content-Type: text/event-stream |
Note the ordering: the gate is method-agnostic, so an unauthenticated GET is a
401 rather than the 405 you might expect, and a request missing its Accept
header still has to get past auth before the 406.
Never a 302
Section titled “Never a 302”An unauthenticated request gets 401 plus a WWW-Authenticate challenge —
never a redirect to a login page. An MCP client cannot follow an HTML login
redirect, and OAuth discovery reads this exact challenge. This is the single
biggest reason the endpoint must not be left to an SSO proxy.
WWW-Authenticate: Bearer realm="paddock", error="invalid_token", error_description="the access token is invalid", resource_metadata="https://paddock.example.com/.well-known/oauth-protected-resource/mcp"The error/error_description parameters are omitted when no credential was
presented at all, and resource_metadata is present only when a discovery
document will actually be served — pointing a client at a URL that then 404s is
worse than omitting the pointer.
Plaintext is refused — as defence in depth
Section titled “Plaintext is refused — as defence in depth”A request counts as secure if it arrived over real TLS at the Paddock process, if
a TLS-terminating proxy set X-Forwarded-Proto: https, or if the client is on
loopback (nothing left the host, so there is no wire to sniff). Anything else is
a bearer token readable in transit, and gets 403 insecure_transport.
One case where this bites in normal operation: a container’s published port is
not loopback from inside. Docker publishing 127.0.0.1:4000 still NATs the
peer address to something like the bridge gateway, so Paddock sees a non-loopback
client and an in-container plaintext smoke test 403s even though nothing left
the host. Adding -H "X-Forwarded-Proto: https" is a legitimate workaround
there — and precisely the habit that turns dangerous when copy-pasted onto a
real network.
There is a config-time half of the same rule, and it is not header-spoofable: a
non-loopback publicUrl must be https, or the whole management API is disabled
at startup.
How a scope refusal actually surfaces
Section titled “How a scope refusal actually surfaces”Two distinct mechanisms, and it’s worth being precise:
- Out-of-scope tools are hidden. A tool a principal isn’t granted is simply
absent from
tools/list, so a client never offers its model a verb it can’t use. Calling it by name anyway gets the same answer as a typo — an MCP tool error,Unknown tool: …. - A denial during a call is reported in-band. A policy refusal comes back
as an MCP tool result with
isError: trueand a readable message (not permitted: operation "…" is outside this client's scope), carried on an HTTP200. That is deliberate: the model needs to read it, and blowing up the JSON-RPC layer would not tell it anything.
Authentication
Section titled “Authentication”A caller presents a bearer token:
POST /mcp HTTP/1.1Authorization: Bearer pdk_my-paddock_1a2b3c…Content-Type: application/json- Config tokens only in this release.
auth.typeacceptstoken; anything else is a config error. - Constant-time comparison. Both sides are hashed to a fixed-width digest before comparison, so the check leaks neither content nor length through timing. Every configured client is scanned without early exit, so total work doesn’t depend on which client matched.
- Minimum length 24 characters, measured across the whole token including
any
pdk_<instanceId>_prefix. Not a strength guarantee — a floor that stopschangemefrom ever authenticating a turn-spawning client. A shorter token drops the client with a warning. - The
pdk_prefix binds a token to one instance. A token shapedpdk_<instanceId>_<secret>is refused unless its embedded instance id matchesmanagementApi.instanceId, so copying a credential to a second Paddock does not make it work there even though the bytes are identical. An unprefixed token still works, but logs a warning that it is not bound — and the prefix gives secret scanners something to match on. Binding is only enforced wheninstanceIdis configured; with noinstanceId, apdk_anything_…token is accepted as-is.
Generate one like this:
printf 'pdk_%s_%s' my-paddock "$(openssl rand -hex 24)"Independent of PADDOCK_AUTH_MODE
Section titled “Independent of PADDOCK_AUTH_MODE”This is the invariant to hold on to: Paddock authenticates the management surface itself. The browser auth modes are actively wrong for it —
jwtmode readsAuthorization, which collides head-on with the MCP client’s ownAuthorization: Bearer <management token>;- an SSO proxy answers with an HTML login redirect that no MCP client can follow.
So /mcp and /.well-known/oauth-protected-resource* are exempt from the
browser auth hook, and this authenticator gates them instead. The exemption is
safe only because that authenticator exists. See
Securing Paddock for what that means
at your edge proxy — including a deploy-ordering hazard worth reading before you
touch a proxy config.
Scopes and policy
Section titled “Scopes and policy”Policy is enforced at the operations layer, not in the transport. Any transport that obtains a principal inherits identical checks for free, and a new one cannot forget them — so there is no per-transport bypass, and no drift between MCP and the REST surface that will follow.
Read-only by default
Section titled “Read-only by default”A client configured with no scope gets:
projects: ["*"]allow: ["list_*", "read_chat"]deny: []which covers list_projects, list_chats, list_triggers and read_chat and
excludes every mutating verb.
The scope fields
Section titled “The scope fields”| Field | Default | Meaning |
|---|---|---|
projects | ["*"] | Project slugs this client may touch. Empty reaches nothing. |
allow | ["list_*", "read_chat"] | Operations it may invoke. Empty grants nothing. |
deny | [] | Operations refused. Deny always beats allow. |
denyProjects | (none) | Projects refused. Beats projects. |
maxSpawnDepth | (instance/project default) | Recursion bound on turns this client starts. |
A call must satisfy both dimensions: the operation and the project.
The operation names
Section titled “The operation names”allow/deny entries are the self-MCP tool names, one for one — writing
allow: [read_chat] names the same thing a keeper sees as
mcp__paddock_manage__read_chat.
| Class | Operations |
|---|---|
| Read | list_projects, list_chats, read_chat |
| Write | create_project, create_chat, fork_chat, send_message, fork_chat_batch, archive_chat, unarchive_chat |
| Triggers | list_triggers, set_trigger, remove_trigger, run_trigger |
Matching is deliberately not a general glob — a security predicate should be
trivially auditable. Exactly two forms are supported: the bare "*", and a
trailing-* prefix ("list_*"). Anything fancier (?, [], an embedded *)
is treated as a literal, so a typo’d pattern fails closed rather than
accidentally widening a grant. An operation outside the catalogue above is
refused regardless of the allow-list, so a stale "*" can’t reach a tool policy
hasn’t been taught about.
Some consequences worth knowing:
- Enumerating filters; addressing refuses.
list_projects/list_chatsreturn a filtered view for a scoped client — “show me what I can see” is a reasonable request. An operation that names a target explicitly is asserted instead, and an out-of-scope slug is refused loudly. - A read-only client’s write tools are absent, not present-and-refusing. If a principal is granted no write or trigger operation, the whole write bag is dropped before the toolset is assembled.
fork_chat_batchneedsfork_chat. The batch fan-out executes throughfork_chat, so it is hidden without that grant rather than offered and denied on every call.- The keeper-side capability gates do not apply here.
PADDOCK_SELF_MCP,PADDOCK_SELF_MCP_WRITE,PADDOCK_SELF_MCP_PROJECTSandPADDOCK_HOOKS_MCPbound what a keeper may reach in-process. An external client is bounded by its credential instead: it getscreate_project(or any other verb) only by naming it inallow, and the read-only default excludes them all.
paddock:read / paddock:write
Section titled “paddock:read / paddock:write”Two granularities exist on purpose. Internally a scope is a list of operation
names — the right granularity for an operator writing a config file, who wants to
say exactly which verbs a CI token may call. Over OAuth, scopes are coarse
(paddock:read, paddock:write) because they are shown to a human on a consent
screen: “grant write access” is a prompt someone reads; a list of fourteen verbs
is not.
The coarse names are a projection used only in the discovery document and in
challenge scope parameters. Authorization is always decided on the
fine-grained list. list_triggers maps to paddock:read; everything that
mutates state or starts a turn maps to paddock:write.
Config schema
Section titled “Config schema”The managementApi block is file-only — there is no PADDOCK_MANAGEMENT_*
environment equivalent, because a client list doesn’t express well as a scalar.
It lives in paddock.config.yaml.
managementApi: # Identifies THIS instance. A token minted as `pdk_<instanceId>_<secret>` is # refused unless this matches. instanceId: my-paddock
# The canonical public origin clients reach this instance at, no trailing # slash. REQUIRED once `clients` is set. Must be https unless it's loopback. publicUrl: https://paddock.example.com
# OAuth issuers, advertised in the RFC 9728 document. Leave empty (the # default) for a token-only deployment — no document is published. authorizationServers: []
clients: my-laptop: auth: # `env:VAR_NAME` is the ONLY supported form. An inline token: or # secret: here is a hard config error. ref: env:PADDOCK_MCP_TOKEN_MY_LAPTOP # Omit `scope` entirely for the read-only default.
ci: auth: ref: env:PADDOCK_MCP_TOKEN_CI scope: projects: [website] # `["*"]` for all; omit for all allow: [list_*, read_chat, create_chat] deny: [archive_chat] # deny always beats allow maxSpawnDepth: 1| Field | Default | Purpose |
|---|---|---|
instanceId | — | Binds pdk_<instanceId>_… tokens to this instance. Absent ⇒ binding is not enforced. |
publicUrl | — | Required whenever clients is set. Canonical public origin, optionally with a path for a path-mounted deployment; https unless loopback; no query string or fragment; trailing slash stripped. |
authorizationServers | [] | OAuth issuer URLs. Gates whether the discovery document is published at all. |
clients.<id>.auth.type | token | Credential type. Only token is supported. |
clients.<id>.auth.ref | — | Required. env:VAR_NAME holding the token. |
clients.<id>.scope.* | read-only | See the scope fields. |
The client key (my-laptop, ci) is the clientId — the stable identity that
gets logged and stamped as provenance. The credential itself never is.
Failure posture
Section titled “Failure posture”Two kinds of problem, handled differently on purpose:
- Malformed config is an error. An inline secret, an unknown
auth.type, a missing or non-env:ref— the operator wrote something meaningless, so it is logged at error level and that client is skipped. A badpublicUrl(or a missing one when clients exist) disables the whole management API. - An unresolvable reference drops that client with a loud warning, leaving
the others working. The env var being unset, blank, or under 24 characters
means the credential simply doesn’t exist, so nothing can authenticate as that
client. If every client drops,
/mcpreverts to its unconfigured404— the endpoint ceases to exist rather than opening up.
A scope that grants any code-execution operation is called out at boot with an explicit warning naming the client. Watch your logs after changing this block.
At boot the surface reports itself one way or the other:
management API: /mcp enabled (self-authenticated — independent of PADDOCK_AUTH_MODE and of any proxy)management API: /mcp disabled (no managementApi.clients configured) — the endpoint 404sThe enabled line carries the enabled client ids and the instanceId as
structured fields. The disabled line is worded a little too narrowly: it prints
whenever the resolved client list is empty, which includes a config that has
clients but whose publicUrl was missing or invalid — that discards every
client. The error-level line immediately above names the real cause.
Setting one up
Section titled “Setting one up”The whole minimal configuration is a token in the environment and this in
paddock.config.yaml:
managementApi: instanceId: my-paddock publicUrl: https://paddock.example.com clients: my-laptop: auth: ref: env:PADDOCK_MCP_TOKEN_MY_LAPTOP # no scope ⇒ read-only across all projectsFor the step-by-step version of that — minting the token, where to put it for
systemd / Docker / Compose, the claude mcp add invocation, how to reach the
endpoint over TLS with no proxy of your own, and a troubleshooting table keyed by
status code — see Connect Claude Code to
Paddock.
To grant that client writes later, add an explicit allow — and re-read the
warning at the top of this page first.
See also
Section titled “See also”- Connect Claude Code to Paddock — the guide-level walkthrough of the read-only setup above.
- Securing Paddock — the edge-proxy exemption every auth scheme needs, and the deploy-ordering hazard.
- Config file (YAML) — where
managementApilives and how it layers with the environment. - API overview — how
/mcprelates to REST and/ws.