Skip to content

feat(server): claude.mcpServers — your own MCP servers can reach Paddock's agents (#691) - #699

Merged
edspencer merged 1 commit into
mainfrom
feat/691-mcp-servers
Aug 5, 2026
Merged

feat(server): claude.mcpServers — your own MCP servers can reach Paddock's agents (#691)#699
edspencer merged 1 commit into
mainfrom
feat/691-mcp-servers

Conversation

@edspencer

Copy link
Copy Markdown
Owner

Step 5 of #691 — the last of the five levers.

claude:
  transcripts:  own    # own | host
  credentials:  host   # own | host
  instructions: own    # own | host
  hooks:        own    # own | host
  mcpServers:   own    # own | host  ← this PR

host attaches the servers declared in the user's ~/.claude.json: the top-level mcpServers (user scope, every project) plus projects.<absolute-dir>.mcpServers, which a project gets only when that directory is its own working directory. own is the default. Also PADDOCK_CLAUDE_MCP_SERVERS, env > file > default. Under own the file is never opened.

Why this key is not a symlink like the other four

MCP servers are not inside ~/.claude at all. Claude Code resolves them from join(CLAUDE_CONFIG_DIR ?? homedir(), ".claude.json") — a sibling of the home. Read straight out of the SDK bundle, where the two paths sit one expression apart and disagree:

let d = CLAUDE_CONFIG_DIR,
    f = d ?? join(homedir(), ".claude");        // .credentials.json lives under here
 readFile(join(f, ".credentials.json"))
 copy(join(d ?? homedir(), ".claude.json"), ) // ← homedir, NOT ~/.claude

So once Paddock owns its home, no bridge of entries inside the home could ever have reached it. That is why MCP inheritance broke silently and separately from everything else. host is therefore a read: Paddock reads that file once at boot and passes the servers programmatically. It deliberately does not symlink it — Claude Code writes per-project trust, approvals and migration flags there, and bridging it would mean a Paddock instance mutating the user's real config.

Three things that contradict #691

1. "Paddock sets none today" is false, and the seam is a different one. #691 says to pass servers via herdctl's runner option SDKQueryOptions.mcpServers. But SDKQueryOptions is built by herdctl (toSDKOptions), never by Paddock, and its mcpServers is populated from transformMcpServers(agent.mcp_servers) — the agent config. Paddock already sets that key: browserMcpServers (#269) puts the headless-Chromium stdio server there on every keeper and trigger.

That makes the work smaller. agent.mcp_servers is the one seam covering both runtimes — the SDK adapter turns it into sdkOptions.mcpServers (and buildSdkOptions is shared by execute and openSession, so live chats included), and the CLI runtime serialises the same record into --mcp-config '{"mcpServers":…}'. The runner option would have covered one.

2. The allowlist would have made this a silent no-op. Paddock's fleet defaults carry an explicit allowed_tools, and both runtimes auto-deny any tool absent from it, with no prompt — the lesson Skill taught. MCP tools are mcp__<server>__<tool>. herdctl auto-adds those patterns for injected servers only (cli-runtime.js: "only needed when the agent has an explicit allowlist"); config mcp_servers get nothing, which is exactly why mcp__playwright__* is hard-coded into the defaults.

So a keeper with host servers restates FLEET_ALLOWED_TOOLS (extracted for this — herdctl's mergeAgentConfig replaces arrays rather than merging) plus one pattern per server. Without it the lever would have shipped looking correct: servers attached, every call refused, nothing in the logs.

3. The plugins arm is blocked, and not for the reason #691 gives. #691 says "the SDK does not auto-discover installed plugins; the interactive CLI does". It does auto-discover them — the plugin root is join(CLAUDE_CONFIG_DIR, "plugins"), the plugins option is merged with what is found there, and a live probe in step 4 loaded a plugin from a planted home with no option passed. What actually blocks it is two other things:

  • discovery is gated on enabledPlugins, which lives in settings.json's userSettings source, and herdctl invokes the SDK with --setting-sources=project for every agent with a working directory — so the flag is never read;
  • herdctl has no plugins passthrough at all. SDKQueryOptions has no such field and toSDKOptions never sets one (one plugins hit in the whole of dist/, and it is a comment).

So there is no channel to write to. I did not ship an enumerator that nothing can consume — see "What I deliberately did not do".

Open question answered: MCP OAuth tokens do follow claude.credentials

From the bundled CLI binary, not inferred. Tokens are stored under a top-level mcpOAuth key in the same credential store as claudeAiOauth<securestorage-dir>/.credentials.json, or the one Claude Code-credentials keychain item. There is no MCP-specific service name and no separate token file. The store resolves from CLAUDE_SECURESTORAGE_CONFIG_DIR ?? CLAUDE_CONFIG_DIR ?? ~/.claude, which is precisely the variable claude.credentials drives.

So credentials: host carries MCP tokens, and they are not separable from the Anthropic login in either direction. credentials: own means re-authorising inside Paddock (mcpAuthenticate exists for that).

With one sharp edge: the per-server key is `${name}|${sha256({type,url,headers}).slice(0,16)}`.

What cannot be carried, and is warned about rather than hidden

herdctl's McpServerSchema is {command?, args?, env?, url?} — a plain z.object, so anything else is stripped at addAgent. Two consequences #691 does not mention, made worse by that hash:

Dropped Effect
headers bearer auth is lost — and the OAuth token, keyed on a hash including the headers, is not found either
type: "sse" transformMcpServer maps every url to type: "http"; same hash problem

Both need a herdctl schema change. What Paddock does here is refuse to be silent: each affected server is named in a warn at boot. The server is still passed — this is a capability lever, not a security one, so a user who declared a server gets it plus a warning rather than nothing plus a warning. The only case actually dropped is a server with neither command nor url, which cannot be started at all.

Tests

Server 1779 → 1813 (+34, +3 files), web 941 unchanged. Every fixture synthetic; no real ~/.claude.json is read anywhere.

The one that matters is test/unit/herdctl-host-mcp.test.ts"hands the runtime nothing under own and the user's servers under host": it asserts on the agent config, which is what both runtimes read their MCP servers from, and pins that nothing else about the agent moves between the two modes. Alongside it, "widens the allowlist by each host server's tool pattern, or they are all denied" is the one that would have caught finding 2.

The per-directory scope is covered twice — mcpServersFor (exact match, no parent walk, so Paddock's answer equals the terminal's) and end-to-end through a real buildApp() boot in test/integration/claude-mcp-servers.test.ts, which also proves the file is read from beside the throwaway HOME and not read at all under own.

Deliberately not done

  • The plugins arm — blocked upstream (above), and an enumerator with no consumer is dead code. Two routes exist for whoever picks it up: a plugins passthrough in herdctl, or widening setting_sources to include user so the already-bridged plugins/ dir and the already-preserved enabledPlugins key start working together. The second needs no herdctl change but would also start loading the home's permissions, model and — under hooks: host — its hooks on the SDK path, which is a security decision well outside this brief and adjacent to claude.hooks: own stops hooks, not host commands — nine other settings.json keys name a command to run #698. Unverified either way: zero plugins are installed on this box, so the disk-layout claims come from reading the binary, not from observing one. (Marketplace installs land at <root>/cache/<marketplace>/<plugin>/<version>/, not the repos/ layout the brief predicted, and a strict: false marketplace entry may have no plugin.json on disk at all — so "local only is probably not a blocker" is mostly right with an exception.)
  • Step 6 (an instance-level mcpServers: block) — out of scope by the brief. Built so it is an addition: everything below the parse is source-agnostic, and step 6 adds a second contributor to HostMcpSource.user.
  • claude.hooks: own stops hooks, not host commands — nine other settings.json keys name a command to run #698 (widening HOST_ONLY_SETTINGS_KEYS) and A chat deleted in a Claude home Paddock doesn't own is released but still listed #693's tombstone — out of scope.
  • Triggers and the sweeper get no host servers. The sweeper is tool-less; each trigger declares its own narrow allowlist. Attaching servers to either would spawn a stdio process per fire that nothing could call. Asserted, not assumed.
  • A file-watcher on ~/.claude.json. Read once at boot, like claude.hooks materialises settings.json; restart to pick up a new server, and the notice says so.
  • Anything requiring a live MCP server, macOS, or real credentials — none exist here. Nothing in this PR starts a server or calls a tool; an agent config is data until a turn runs.

One thing that could not be verified locally: npm run typecheck fails on reapChatSession in herdctl.ts@herdctl/core@5.29.1 installed against a declared ^5.31.0. It is present on main untouched, and it also means local E2E cannot run. CI installs fresh.

Closes the mcpServers row of #691.

🤖 Generated with Claude Code

…ock's agents (#691)

The last of #691's five levers. `claude.mcpServers: own | host`, default `own`,
with `PADDOCK_CLAUDE_MCP_SERVERS`; `host` attaches the servers declared in the
user's `~/.claude.json` — top-level `mcpServers` plus
`projects.<abs-dir>.mcpServers`, the latter only to the project whose working
directory it names.

This key is not a symlink bridge like the other four, because MCP servers are not
in `~/.claude` at all: Claude Code resolves them from
`join(CLAUDE_CONFIG_DIR ?? homedir(), ".claude.json")`, a SIBLING of the home.
Once paddock owns its home no bridge of entries inside the home could reach it,
which is why MCP inheritance broke silently and separately. So `host` reads that
file once at boot and passes the servers programmatically; it never links or
writes it, since Claude Code keeps mutable state there that is the user's.

Two corrections to the design, both found by reading the code rather than
assuming it:

- #691 says to pass the servers via the runner option `SDKQueryOptions.mcpServers`
  and that "paddock sets none today". Paddock already sets `mcp_servers` — the
  browser server (#269) — and `SDKQueryOptions` is built BY herdctl from that
  agent-config key. `agent.mcp_servers` is also the one seam reaching BOTH
  runtimes: the SDK adapter turns it into `sdkOptions.mcpServers`, the CLI runtime
  serialises it into `--mcp-config`.
- The fleet's explicit `allowed_tools` would have made this a silent no-op. Both
  runtimes auto-deny any tool absent from it, and herdctl auto-adds `mcp__…__*`
  patterns for INJECTED servers only. So a keeper with host servers restates
  FLEET_ALLOWED_TOOLS (extracted for this) plus one pattern per server; an
  instance with none stays byte-identical.

Two things herdctl's `McpServerSchema` (`{command,args,env,url}`) cannot carry are
passed through with a named boot warning rather than silently: an http/sse
server's `headers`, and the `sse` transport. Both matter more than they look —
MCP OAuth tokens are keyed on a hash of `{type,url,headers}`.

Answers an open question on #691: MCP OAuth tokens live under an `mcpOAuth` key in
the SAME credential store as the Anthropic login, resolved by the same variable
`claude.credentials` drives, so `credentials: host` carries them. There is no
separate MCP token store.

The plugins arm is NOT implemented and is reported blocked. #691's reason ("the
SDK does not auto-discover plugins") is wrong — it does, from the Claude home —
but discovery is gated on `enabledPlugins` in a setting source paddock's agents
never load, and herdctl exposes no `plugins` option at all.

Co-Authored-By: Claude <noreply@anthropic.com>
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying paddock with  Cloudflare Pages  Cloudflare Pages

Latest commit: a71703f
Status: ✅  Deploy successful!
Preview URL: https://55dcb1e6.paddock-7u2.pages.dev
Branch Preview URL: https://feat-691-mcp-servers.paddock-7u2.pages.dev

View logs

@edspencer
edspencer merged commit 019a3ed into main Aug 5, 2026
5 checks passed
@edspencer
edspencer deleted the feat/691-mcp-servers branch August 5, 2026 22:18
@github-actions github-actions Bot mentioned this pull request Aug 5, 2026
edspencer added a commit that referenced this pull request Aug 6, 2026
… sse (#700) (#705)

* feat(server): inherit host Claude Code plugins, carry MCP headers and sse (#700)

Bump `@herdctl/core` to 5.32.0 and consume both capabilities herdctl#446 added.

**Plugins.** A host plugin that provides an MCP server was invisible in Paddock
on every setting: the SDK enables a discovered plugin from `enabledPlugins`, a
user-settings-source key, and herdctl invokes every agent with
`setting_sources: ["project"]`. `claude-plugins.ts` enumerates the host's
installed plugin directories from the CLI's own `plugins/installed_plugins.json`
registry and passes them as `agent.plugins`, which is a *session* plugin and
needs no settings-source grant.

Gated by `claude.instructions` rather than `claude.mcpServers` as #700 assumes:
`plugins/` is bridged by `instructions`, alongside `agents/` and `commands/`,
and under `instructions: own` Paddock already prints "your ~/.claude plugins are
NOT loaded". `claude.mcpServers` decides only whether the plugins' own servers
come too, via the SDK's `skipMcpDiscovery`.

Each plugin server's allowlist pattern is derived, not read: the CLI registers a
plugin's servers as `plugin:<plugin>:<server>` and normalises the name, so the
pattern is `mcp__plugin_<plugin>_<server>__*` — matching the SDK's own documented
`mcp__plugin_documents_docs__doc_export`. Using the declared name would have
been silently wrong, and a missing pattern auto-denies every call with no prompt
and nothing in the logs.

**MCP server fields.** 5.32.0 carries `headers` and an explicit `type` verbatim,
verified against the installed package through `addAgent` → `getAgents()` →
`toSDKOptions()`. So #699's two stripping warnings are removed and both fields
are passed on — which is what lets an OAuth server's stored token be found, as
its key is a hash of `{type, url, headers}`. The instance's own `mcpServers:`
block accepts both too, with `env:VAR` resolution and the never-print rule
applied to header values.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(server): collapse duplicate plugin install paths before passing them

One plugin can be recorded under several scopes in installed_plugins.json, and
--plugin-dir'ing the same path twice loads it twice under one name.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(server): widen #702's argv-exposure warning to `headers`

herdctl 5.32.0 carries `headers` verbatim, so an `Authorization` bearer now
rides in the same `--mcp-config` argv element #702 read an `env` token out of —
and a bearer is the likelier long-lived credential of the two. A url server
declared with headers and no `env` was the one shape that warning missed.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: HomeLab Agent <homelab-infra@valfenda.net>
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant