feat(server): claude.mcpServers — your own MCP servers can reach Paddock's agents (#691) - #699
Merged
Merged
Conversation
…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>
Deploying paddock with
|
| 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 |
Merged
This was referenced 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Step 5 of #691 — the last of the five levers.
hostattaches the servers declared in the user's~/.claude.json: the top-levelmcpServers(user scope, every project) plusprojects.<absolute-dir>.mcpServers, which a project gets only when that directory is its own working directory.ownis the default. AlsoPADDOCK_CLAUDE_MCP_SERVERS, env > file > default. Underownthe file is never opened.Why this key is not a symlink like the other four
MCP servers are not inside
~/.claudeat all. Claude Code resolves them fromjoin(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: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.
hostis 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. ButSDKQueryOptionsis built by herdctl (toSDKOptions), never by Paddock, and itsmcpServersis populated fromtransformMcpServers(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_serversis the one seam covering both runtimes — the SDK adapter turns it intosdkOptions.mcpServers(andbuildSdkOptionsis shared byexecuteandopenSession, 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 lessonSkilltaught. MCP tools aremcp__<server>__<tool>. herdctl auto-adds those patterns for injected servers only (cli-runtime.js: "only needed when the agent has an explicit allowlist"); configmcp_serversget nothing, which is exactly whymcp__playwright__*is hard-coded into the defaults.So a keeper with host servers restates
FLEET_ALLOWED_TOOLS(extracted for this — herdctl'smergeAgentConfigreplaces 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"), thepluginsoption 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:enabledPlugins, which lives insettings.json'suserSettingssource, and herdctl invokes the SDK with--setting-sources=projectfor every agent with a working directory — so the flag is never read;pluginspassthrough at all.SDKQueryOptionshas no such field andtoSDKOptionsnever sets one (onepluginshit in the whole ofdist/, 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.credentialsFrom the bundled CLI binary, not inferred. Tokens are stored under a top-level
mcpOAuthkey in the same credential store asclaudeAiOauth—<securestorage-dir>/.credentials.json, or the oneClaude Code-credentialskeychain item. There is no MCP-specific service name and no separate token file. The store resolves fromCLAUDE_SECURESTORAGE_CONFIG_DIR ?? CLAUDE_CONFIG_DIR ?? ~/.claude, which is precisely the variableclaude.credentialsdrives.So
credentials: hostcarries MCP tokens, and they are not separable from the Anthropic login in either direction.credentials: ownmeans re-authorising inside Paddock (mcpAuthenticateexists 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
McpServerSchemais{command?, args?, env?, url?}— a plainz.object, so anything else is stripped ataddAgent. Two consequences #691 does not mention, made worse by that hash:headerstype: "sse"transformMcpServermaps everyurltotype: "http"; same hash problemBoth need a herdctl schema change. What Paddock does here is refuse to be silent: each affected server is named in a
warnat 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 neithercommandnorurl, which cannot be started at all.Tests
Server 1779 → 1813 (+34, +3 files), web 941 unchanged. Every fixture synthetic; no real
~/.claude.jsonis read anywhere.The one that matters is
test/unit/herdctl-host-mcp.test.ts→ "hands the runtime nothing underownand the user's servers underhost": 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 realbuildApp()boot intest/integration/claude-mcp-servers.test.ts, which also proves the file is read from beside the throwaway HOME and not read at all underown.Deliberately not done
pluginspassthrough in herdctl, or wideningsetting_sourcesto includeuserso the already-bridgedplugins/dir and the already-preservedenabledPluginskey start working together. The second needs no herdctl change but would also start loading the home'spermissions,modeland — underhooks: 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 therepos/layout the brief predicted, and astrict: falsemarketplace entry may have noplugin.jsonon disk at all — so "local only is probably not a blocker" is mostly right with an exception.)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 toHostMcpSource.user.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.~/.claude.json. Read once at boot, likeclaude.hooksmaterialisessettings.json; restart to pick up a new server, and the notice says so.One thing that could not be verified locally:
npm run typecheckfails onreapChatSessioninherdctl.ts—@herdctl/core@5.29.1installed against a declared^5.31.0. It is present onmainuntouched, and it also means local E2E cannot run. CI installs fresh.Closes the
mcpServersrow of #691.🤖 Generated with Claude Code