Skip to content

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

Merged
edspencer merged 4 commits into
mainfrom
feat/700-consume-herdctl-5.32
Aug 6, 2026
Merged

feat(server): inherit host Claude Code plugins, carry MCP headers and sse (#700)#705
edspencer merged 4 commits into
mainfrom
feat/700-consume-herdctl-5.32

Conversation

@edspencer

Copy link
Copy Markdown
Owner

Closes #700. Bumps @herdctl/core to 5.32.0 and consumes both capabilities
herdctl#446 added.

What changed

1. Host Claude Code plugins reach keepers (#700). New claude-plugins.ts
enumerates the host's installed plugin directories from the CLI's own
~/.claude/plugins/installed_plugins.json registry (v1 and v2) and passes them as
agent.plugins. A --plugin-dir plugin is a session plugin, whose enablement is
enabledPlugins["<name>@inline"] ?? manifest.defaultEnabled !== false — i.e.
enabled by default, needing no settings-source grant. That is precisely why the
passthrough works where the symlink did not, and #446 was right to refuse to widen
setting_sources.

2. headers and type: sse survive, so #699's warnings are gone. Proven, not
inferred — see below.

Two things that contradict the brief / #446's design notes

#446 says "paddock#700 is unblocked as written". As written is where it breaks.
#700 (and this PR's brief) scope plugin enumeration to claude.mcpServers: host.
But plugins/ is bridged by claude.instructions, alongside CLAUDE.md,
agents/ and commands/ — because most of a plugin is instructions. Under
instructions: own Paddock withdraws that symlink and prints a boot notice saying
the host's plugins are not loaded; passing them anyway because a different key
says host would contradict Paddock's own log. So both levers apply, to the two
halves of a plugin, and the SDK has exactly the flag for it:

claude.instructions claude.mcpServers a keeper gets
host host the plugin, whole
host own the plugin with skipMcpDiscovery: true (commands/agents/skills/hooks only)
own any no plugins — plus a notice naming the key to flip, when mcpServers: host is set

The allowlist is not "the same treatment". The brief says a plugin-provided
server "needs the same treatment — work out what its server names will be". They
are not the declared names. The CLI registers a plugin's servers as
`plugin:${pluginName}:${serverName}` and then normalises [^a-zA-Z0-9_-] → _
before it becomes a tool prefix, so the pattern is
mcp__plugin_<plugin>_<server>__*. Using the declared name would have produced
a pattern matching nothing — i.e. exactly the silent-auto-deny failure the rule
exists to prevent, with the allowlist code looking correct.

That derivation is not a guess: it is i8g() in the bundled CLI, and it lands on
the Agent SDK's own documented example, mcp__plugin_documents_docs__doc_export.

Evidence, and its limits

headers / type: sse — proven at the boundary, twice. Against the installed
5.32.0, FleetManager.addAgent()getAgents()toSDKOptions() returns
{type: "sse", url, headers} unchanged (and still infers type: "http" for a bare
url). The permanent form of that proof is a new integration assertion that reads
the agent back out of the live fleet, after AgentConfigSchema.parse — the one
place a stripped field is observable. I installed 5.31.0 and confirmed both new
integration tests fail there and pass on 5.32.0
, so they are the dependency-floor
guard, not just code coverage. Every unit test in this PR passes on 5.31.x, which is
the point: the unit layer is where #699's bug hid.

Plugins — the enumeration is tested, the loading is not, by anyone. This box is
Linux with an empty plugin root, no marketplace and no credentials for a real turn.
The tests plant fake plugin directories and assert the config Paddock produces; none
is named as though a plugin loaded. herdctl's tests carry the next layer
(config → the SDK query() options); whether the CLI, handed --plugin-dir <dir>,
then loads it is the SDK's contract and is verified in neither repo.

Needs a real host with a plugin actually installed (a Mac work laptop is the
reported case):

  • that a marketplace-installed plugin loads via type: "local" — still the open
    question Plugin-provided MCP servers cannot reach Paddock agents — needs a herdctl plugins passthrough #700 flags, and this PR does not close it;
  • that a live plugin server really registers as plugin:<plugin>:<server>, so the
    derived allowlist pattern is the one the runtime checks;
  • that a real installed_plugins.json matches the v2 shape read here. If it does
    not, Paddock enumerates nothing — it fails closed, quietly, which is the right
    direction but worth knowing.

A new cost, which the design notes do not mention

Carrying headers widens an existing exposure. herdctl's CLI runtime serialises
the whole mcp_servers record into one --mcp-config '{…}' argv element, readable
from /proc/<pid>/cmdline by any process of the same user. That was already true of
an env value; it is now true of an Authorization bearer, which is the likelier
place a long-lived token lives. Scope: driveMode: batch only (chats default to the
SDK runtime; the sweeper and triggers never get these servers). Not a reason to keep
stripping — a dropped header fails authentication for everyone, while this is a
same-user disclosure on one non-default drive mode — but documented in
claude-mcp.ts, mcp-servers.ts and CLAUDE.md rather than left to be discovered.

Also in scope, deliberately

The instance's own mcpServers: block hard-errored on headers and type: sse,
for the same reason the host lever warned. Leaving those in place would have refused
declarations that now work, with an error message stating something false about the
engine. Both are accepted; headers values take env:VAR_NAME references like
everything else in that block, are counted-not-printed by describeServer, and get
the same credential-shaped-key warning env values get.

Docs — for the overhaul chat, not touched here

website/**, README.md and NPM-README.md are untouched per the brief. Three
passages in website/src/content/docs/configuration/config-file.md are now wrong:

  1. "Plugins are not covered by this key" (under claude.mcpServers, ~L389) —
    "none of them reach Paddock… the engine also has no way to pass a plugin path per
    session… until then, a plugin's MCP server has to be declared directly with
    claude mcp add." All three clauses are now false. Replacement is the lever table
    above.
  2. "What cannot be declared" caution block (~L478) — the headers: and
    type: sse bullets should be deleted; the unrecognised-key and
    command-xor-url bullets stay.
  3. The claude.mcpServers section could gain a line that headers and sse
    servers are now carried faithfully, which is what makes credentials: host +
    mcpServers: host work for an OAuth server at all.

Not done

  • No web UI surface for plugins (the dashboard does not expose mcp_servers
    either).
  • Did not widen setting_sources. Agreeing with docs(website): Running Paddock on Proxmox (LXC) guide (#414) #446, with one Paddock-specific
    observation recorded rather than acted on: Paddock's "user" scope is its own
    curated home, whose CLAUDE.md and settings.json the instructions/hooks
    levers already decide the contents of — so docs(website): Running Paddock on Proxmox (LXC) guide (#414) #446's blast-radius argument is weaker
    here than for a generic embedder. It is not zero, though: plugins/ in that home
    is a symlink to the host's real directory, so reading user settings would pull the
    host's enabledPlugins semantics back in and re-couple the levers. The explicit
    list needs no grant and keeps them apart.
  • Project-scope plugin installs are passed like user-scope ones rather than being
    matched to a Paddock project's working directory. Doing that properly means
    deciding what a host projectPath means over here; not guessed at.
  • No MCPB download. A plugin whose manifest points mcpServers at a bundle is
    attached, but its server names cannot be read statically, so its tools would be
    auto-denied. That is a boot warning naming the plugin and the
    mcp__plugin_<x>_<server>__* pattern to add by hand — the one case where this
    feature degrades, made loud instead of silent.

One correction to this PR's brief

npm run typecheck has been failing on this box with Property 'reapChatSession' does not exist on type 'FleetManager' — that is precisely the stale-install symptom
your bump should CURE.

It was a stale install, but the bump did not cure it — a clean npm install in
a fresh worktree did. Typecheck was already green here on 5.31.0, before the
dependency was touched. The declared ^5.31.0 range was fine; only the main
checkout's node_modules was old.

Verification

  • env -u NODE_ENV npm testgreen: server 1876 passed / 148 files, web 941
    passed / 70 files. Baseline on main in this worktree was also green, so nothing
    moved that was not meant to.
  • env -u NODE_ENV npm run typecheck — clean, both packages.
  • The two new integration tests were confirmed red on @herdctl/core@5.31.0 and
    green on 5.32.0.

🤖 Generated with Claude Code

HomeLab Agent and others added 4 commits August 5, 2026 22:12
… 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>
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>
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>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 6, 2026

Copy link
Copy Markdown

Deploying paddock with  Cloudflare Pages  Cloudflare Pages

Latest commit: 463e191
Status: ✅  Deploy successful!
Preview URL: https://7483655e.paddock-7u2.pages.dev
Branch Preview URL: https://feat-700-consume-herdctl-5-3.paddock-7u2.pages.dev

View logs

@edspencer

Copy link
Copy Markdown
Owner Author

Merged origin/main (f5cf1d2, PR #702) and integrated it rather than just resolving it.

#702 landed the same finding this PR documents independently — a declared server's env reaching the claude command line under driveMode: batch — but it measures it (declared-mcp-argv.test.ts reads the token back out of a real spawned process's argv) and turns it into a boot notice, which is strictly better than the prose I had written. So my duplicate module-doc paragraph is now a pointer to argvExposure.

One thing the merge made newly wrong, fixed here. #702's warning fires on servers that declare env values. This PR makes headers carryable, which puts an Authorization bearer into the same argv element — and a bearer is the likelier long-lived credential of the two. A url server declared with headers: and no env: was, after the textual merge, the one shape that warning silently missed. argvExposure now covers both, with a test.

Also updated: the website/src/content/docs/configuration/config-file.md caution block #702 added is correct as written, but the other block on that page ("What cannot be declared") still lists headers: and type: sse as refusals — that is in the docs-handoff list in the PR body, for the overhaul chat.

@edspencer
edspencer merged commit 571def3 into main Aug 6, 2026
5 checks passed
@edspencer
edspencer deleted the feat/700-consume-herdctl-5.32 branch August 6, 2026 02:24
@github-actions github-actions Bot mentioned this pull request Aug 6, 2026
edspencer added a commit that referenced this pull request Aug 8, 2026
… releases (#762)

* chore(docs): start the 0.56-0.66 What's New pass

* docs(website): split What's New, archiving 0.52 and older

The page had grown to 1,150 lines and 30 releases, which is past the
point where anyone scrolls it. Everything from 0.52 back to 0.29 moves
verbatim to a new 'What's New — earlier releases' page; the main page
keeps 0.53 and newer.

Entries are moved unchanged — the archive is append-only and its
entries are never rewritten, which is the same promise the page already
makes about describing each release as it shipped. Image paths are
unchanged because both pages sit in the same content directory.

Adds the sidebar entry (Starlight does not auto-discover, so an
unlisted page is invisible), a cross-link in both directions, and a
note in the maintainer footer describing how to trim next time.

* docs(website): What's New for 0.63 through 0.66.1

Five new entries. 0.66.0 leads on the Config screen redesign (#740) —
the largest UX change in the range and the one a user meets every time —
with a screenshot of the rail, live filter, env-override legend and the
dirty dot. Also carries the breaking default-port move 4000 -> 7233
(#741) with the operator action spelled out, schemaVersion (#735), the
import -> adopt rename (#748), the destructive-op interlock (#743) and
the sub-agent bar fixes (#750).

0.66.1 is the queued-message release (#751): three silent-loss paths and
the Stop-hands-it-back decision. 0.65 is promote_project (#668). 0.64
leads on path:/managed replacing repoBacked (#709) plus the Changes tab
finally reporting on the checkout (#597). 0.63 is host plugin
inheritance and MCP fidelity (#705), including the batch-mode argv
credential disclosure (#702).

Two claims deliberately qualified against the changelog's framing: the
delete-then-send fix (#742) is called out as batch-only, since the
default session mode was never affected; and the sub-agent bar is not
described as absolute, because the settle heuristic can hold an
interrupted sub-agent for ten minutes.

Also corrects a live error: the 0.59.1-0.60 entry claimed in bold that
--here leaves your ~/.claude alone. That was false for exactly those
versions — --here linked ~/.claude/projects/<dir> at the workspace, and
one report lost 30 transcripts to it before 0.61.1 stopped it. Replaced
with a caution box pointing forward.

* docs(website): a recorded demo of the 0.66 Config screen

The filter and the rail only read in motion, so the entry gets both a
still (rail counts, env chips, legend, dirty dot) and a 22s clip:
filter by env-var name, Modified-only lens, then a rail jump that
scrolls rather than swapping tabs.

Shot on a v0.66.1 rig with no credentials. Two leaks were caught by
looking at the frames rather than trusting the launcher:
PADDOCK_GIT_AUTHOR_EMAIL was inherited and put a real internal address
in the Git identity section, and the Advanced section printed the rig's
scratch paths. The launcher is now an ALLOW-list — it drops every
inherited PADDOCK_* and sets only what it needs — which is the fix
issue #567 argues for, and the frames now show stock defaults and a
~/.paddock install path.

* docs(website): backfill 0.56, 0.57, 0.58 and 0.61.0

These four were never written up — a gap in the middle of the page, not
a tail. 0.61.0 in particular was only reachable by reading the 0.62
entry backwards, where it appears as the thing being removed.

0.61.0 leads on Paddock taking ownership of its Claude home, with a
caution box narrating the whiplash: 0.61.0 isolates the home, 0.61.1
un-isolates the CLI because an isolated home cannot see a macOS
Keychain login, and 0.62 replaces the mechanism entirely. Without that
sentence the three entries read as contradicting each other.

0.58 carries its own caution: everything in it shipped in a CLI that,
installed through npm, printed nothing and exited zero, and stayed that
way across 0.57 and 0.59.0 until 0.59.1. An entry recommending it
without that note would be recommending a broken install. The cause is
described only as the run-directly guard, not attributed to a specific
PR, because the changelog's own attribution does not line up with the
release it shipped in.

0.57 is the environment system prompt, with the audit numbers that
justified it and a note that three candidate rules were measured and
cut. 0.56 is the npm publish, plus the correction that the claude CLI
was never a prerequisite for chats.

Also corrects 'several hundred boot log lines' in the 0.59.1-0.60 entry
to match the changelog's actual figure (about thirty, down to nine).

* docs(website): cut the new What's New entries roughly in half

The nine entries added this pass ran to 314 lines. They read as
changelog archaeology rather than release notes — explaining how each
bug worked before saying what changed, and carrying detail nobody
scanning a What's New page needs.

Now 180 lines, of which 12 are the image and video markup. Each bullet
is one idea in three or four lines: what a user notices first, then only
the context that makes it land. What survives unchanged is the material
that is genuinely load-bearing — the port change's operator action, the
'tabs partition' argument, the batch-only qualifier on the delete-then-
send fix, and the two caution boxes, since those exist to stop a reader
believing something false.

* docs(website): plainer titles, and another pass on length

Titles were making claims rather than saying what changed — 'Nothing you
typed while it was busy goes missing', 'A Config screen you can
navigate', 'A command-line worth running'. They now name the subject:
Queued messages; Config screen, and a new default port; CLI output and
flags; Environment system prompt; npm package.

Prose down from 168 lines to 137 (originally 302). Mostly by cutting
restatement — 0.61.0's four small turn-level fixes were four bullets
saying the same shape of thing and are now one.

* docs(website): bring the older entries into the same style

Retitles every remaining entry to name its subject rather than make a
claim, and cuts them to the same length as the new ones. 0.62's title
becomes 'Granular host Claude inheritance options' as requested.

  Five levers instead of one            -> Granular host Claude inheritance options
  Your ~/.claude, left alone and ...    -> CLI login, and symlinks into your Claude home
  One command, on your own history      -> npx install, --here, and confirmed adoption
  Bring your terminal history with you  -> Adopting Claude Code CLI chats
  Claude, not "the keeper"              -> The "keeper" rename, and Home's attention feeds
  Home says what it's holding           -> Unread badge on the Home link

Lengths: 0.62 115->51, 0.61.1 57->36, 0.59.1-0.60 45->29, 0.55 81->29,
0.54 70->42, 0.53 62->14. The page is 379 lines, down from 1,150.

Everything load-bearing is kept: the breaking env-var table, the
instructions: own warning, the leftover-symlink check with its shell
command, and both caution boxes. All five media assets verified present
in the built output. Frontmatter description rewritten — it still
described 0.62 as 'five independent levers'.

---------

Co-authored-by: HomeLab Agent <homelab-infra@valfenda.net>
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.

Plugin-provided MCP servers cannot reach Paddock agents — needs a herdctl plugins passthrough

1 participant