Skip to content

feat(core): plugins passthrough and full MCP server config (#444, #445) - #446

Merged
edspencer merged 3 commits into
mainfrom
fix/444-445-plugins-and-mcp-schema
Aug 6, 2026
Merged

feat(core): plugins passthrough and full MCP server config (#444, #445)#446
edspencer merged 3 commits into
mainfrom
fix/444-445-plugins-and-mcp-schema

Conversation

@edspencer

@edspencer edspencer commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Fixes #444. Fixes #445.

Both issues are the same defect class — herdctl narrowing what the Agent SDK supports underneath it, silently — and they touch adjacent code, so they ship together.

What changed

#445McpServerSchema dropped headers and type. The schema was {command, args, env, url}, a plain z.object, so every other key was stripped at addAgent; transformMcpServer then rewrote every url to type: "http". It now also accepts headers, an explicit type ("stdio" | "sse" | "http"), timeout and alwaysLoad, mirroring the SDK's own McpServerConfig. An explicit type wins; a bare url still infers http, so existing configs are untouched.

#444 — no plugins passthrough. New optional plugins field on an agent config and on fleet defaults, plumbed through toSDKOptions to the SDK's plugins option. Entries are a bare path string or {type: "local", path, skipMcpDiscovery?}; the shorthand normalises to the object form.

Both runtimes honour plugins: the SDK runtime sets the option, and the CLI runtime emits --plugin-dir / --plugin-dir-no-mcp per entry — which is exactly what the SDK does with its own option, verified by reading the SDK's bundle. Leaving the CLI runtime out would have reproduced the same silent-narrowing bug one runtime over.

Design notes for the downstream consumer

Paddock reaches both levers from ordinary agent config, so paddock#700 is unblocked as written:

  • agent.mcp_servers[name] now survives verbatim, so the claude.mcpServers: host merge can stop stripping and stop warning.
  • agent.plugins is a plain array on the same agent config object addAgent already takes.

MCP server field names deliberately mirror the SDK's McpServerConfig rather than herdctl's usual snake_case (hence camelCase alwaysLoad). An entry is passed through verbatim, so an operator — or Paddock reading ~/.claude.json — can hand over a block unchanged. A translation layer is exactly where #445's fields got lost.

I did not widen the default setting_sources, and I'd argue against it. It would fix plugin enablement with no passthrough at all, but it pulls in every other user-source key — user CLAUDE.md, agents/, commands/, settings.json hooks — which is the boundary Paddock's claude.instructions / claude.hooks levers exist to hold closed. A blanket widening would silently undo that. The explicit plugins list needs no such grant.

One correction to #444

Allow settingSources to be configured per agent (or include user when the embedder asks).

This already exists and already accepts "user"setting_sources on AgentConfigSchema, honoured by toSDKOptions ahead of the ["project"] default. #444's second blocker therefore already had an operator-facing lever before this PR; what was missing was only the passthrough and the documentation of the trade-off. There is a test in this PR asserting that behaviour, and it is one of the three (of twelve) that already passed on main.

Everything else in both issues held up. In particular the non-obvious claim in #444 — that the SDK discovers plugins and never enables them, rather than ignoring them — is consistent with what the SDK bundle does: plugins becomes a --plugin-dir CLI flag directly, independent of enabledPlugins, which is why the passthrough works without any settings-source change.

Tests

The important one is packages/core/src/runner/__tests__/mcp-and-plugin-passthrough.test.ts. It drives the whole tripFleetManager.addAgent() (the real AgentConfigSchema.parse) → getAgents()toSDKOptions() → the options object actually handed to the SDK's query(), with query mocked to capture it. That framing is the point: the stripping happened at the config boundary, one layer above where the existing transformMcpServer unit tests all sat happily passing. 9 of its 12 cases fail on main; the 3 that pass are the deliberate no-regression guards (legacy urlhttp inference, the no-plugins case, and the setting_sources behaviour noted above).

Also added:

  • sdk-type-contract.test.ts — compile-time assertions pinning the hand-written SDKQueryOptions / SDKMcpServerConfig / SDKPluginConfig mirrors against the SDK's own types. sdk-runtime.ts widens them with as Record<string, unknown> before calling query(), so nothing else in the build would notice the SDK drifting — which is how type?: "http" stayed wrong after the SDK grew sse. Reverting the union to "http" reproduces error TS2344: Type '"sse"' does not satisfy the constraint '"http"'.
  • CLI-runtime tests for --plugin-dir / --plugin-dir-no-mcp and for headers + type: sse reaching --mcp-config.
  • Schema tests for the new fields and both plugin entry forms; a mergeAgentConfig test for defaults.plugins (that function is a per-field allowlist, not a deep merge, so a new field is silently dropped from defaults unless wired in explicitly — it is).

What the tests do not prove

They prove the config survives to the SDK/CLI invocation boundary. They do not prove a planted plugin directory actually loads — that needs a real claude run against the API, which I did not do. The strongest evidence short of that is from reading the SDK bundle directly: plugins is translated to --plugin-dir <path> (or --plugin-dir-no-mcp when skipMcpDiscovery is set), and a non-local type throws. So the shape is right and reaches the CLI; whether the CLI then loads it is the SDK's contract, not herdctl's.

Verification

pnpm typecheck (11/11), pnpm build and pnpm lint all pass. pnpm test passes except one pre-existing failure unrelated to this branch — state-manager "throws StateDirectoryCreateError when parent directory is not writable", which fails on my box because it runs as uid 0, where chmod 0555 does not prevent writes. It fails identically on unmodified main; CI runs non-root, so it should be green here.

Not done

  • No web UI surface for plugins (the dashboard does not expose mcp_servers either).
  • McpServerSchema stays permissive — it does not enforce "url xor command", or that headers is only meaningful for a remote server. Adding refinements could reject configs that parse today, and that is a separate call.
  • Plugin path is used as given, not resolved relative to the agent config file. For a dockerized agent it must resolve inside the container; documented rather than solved.
  • tools (McpServerToolPolicy[]) is still not in McpServerSchema — a nested union, not asked for, and no consumer for it yet.

Release

Yes — Paddock needs a release to consume this. There is a changeset (@herdctl/core, minor), so merging produces a Version Packages PR, and @herdctl/core has to be published and Paddock's dependency bumped before paddock#700 can be built against it.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added support for configuring and passing through local plugins for agents and fleet defaults.
    • Added plugin options to control MCP discovery behavior.
    • Expanded MCP server configuration with HTTP, SSE, and stdio transports, authentication headers, timeouts, and deferred tool loading.
    • Preserved compatibility with existing bare-URL HTTP and stdio configurations.
  • Documentation

    • Updated configuration and runner guides with plugin and MCP setup details, including authenticated SSE examples.

HomeLab Agent and others added 3 commits August 5, 2026 19:27
Both issues are the same defect: herdctl narrowing what the Agent SDK
supports underneath it, silently.

#445 — `McpServerSchema` was `{command, args, env, url}`, a plain
`z.object`, so every other key was stripped at `addAgent`; and
`transformMcpServer` rewrote every `url` to `type: "http"`. Authenticated
remote servers lost their bearer token and SSE servers were misconfigured
as HTTP. Worse than a dropped field: Claude Code keys a remote server's
stored OAuth token on `${name}|sha256({type,url,headers}).slice(0,16)`,
so a stripped key also made a previously-authorised server unrecognisable.

The schema now also accepts `headers`, an explicit `type`
("stdio" | "sse" | "http"), `timeout` and `alwaysLoad`, mirroring the
SDK's own `McpServerConfig`. An explicit `type` wins; a bare `url` still
infers `http`, so existing configs are unchanged.

#444 — there was no channel at all for an embedder to name Claude Code
plugins. New optional `plugins` field on an agent and on fleet `defaults`,
plumbed through `toSDKOptions` to the SDK's `plugins` option. Entries are
a bare path string or `{type: "local", path, skipMcpDiscovery?}`; the
shorthand normalises to the object form. `mergeAgentConfig` is a per-field
allowlist rather than a deep merge, so the defaults path is wired in
explicitly and follows the `tools` convention (agent array replaces).

Deliberately not widening the default `setting_sources`. The SDK's plugin
auto-discovery is gated on `enabledPlugins`, a user-source settings key,
so adding "user" to the default would enable it — but would also inherit
user `CLAUDE.md`, `agents/`, `commands/` and `settings.json` hooks, which
is a security boundary embedders (Paddock) deliberately hold closed. The
per-agent `setting_sources` lever already exists for operators who want
CLI parity; the explicit `plugins` list needs no such grant.

Tests drive the whole trip — `addAgent` (real schema parse) → `getAgents`
→ `toSDKOptions` → the options object handed to the SDK's `query()` —
because the stripping happened at the config boundary, one layer above
where the existing adapter unit tests all passed. A compile-time contract
test pins the hand-written SDK mirrors against the SDK's own types;
`sdk-runtime.ts` widens them with `as Record<string, unknown>`, so
nothing else in the build would catch the SDK drifting again.

Co-Authored-By: Claude <noreply@anthropic.com>
`runtime: "cli"` agents would have silently ignored the new `plugins`
field — the same silent-narrowing failure the field exists to fix, just
one runtime over. Emit `--plugin-dir` per entry (`--plugin-dir-no-mcp`
when `skipMcpDiscovery` is set), which is exactly what the Agent SDK does
with its own `plugins` option, so a config behaves identically on either
runtime.

`mcp_servers` needed no equivalent change: the CLI runtime already builds
`--mcp-config` from the shared `transformMcpServers`, so #445's `headers`
and `type` flow through it for free — covered by a test rather than left
to inference.

Also annotate the string branch of `PluginSchema`'s union: without an
explicit return type the two branches infer different object types and
the union's output drops `skipMcpDiscovery`.

Co-Authored-By: Claude <noreply@anthropic.com>
Documents the new `plugins` field (agent + fleet defaults, both entry
forms, the replace-not-merge rule, and the Docker path caveat) and the
`type`/`headers`/`timeout`/`alwaysLoad` MCP server fields.

Two things worth their own callouts: why a remote server's `type` and
`headers` matter beyond the wire (Claude Code keys the stored OAuth token
on them), and that auto-discovered plugins need `setting_sources:
["user", …]` to be enabled — along with what else that grant pulls in, so
the trade-off against naming plugins explicitly is visible.

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

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds plugin configuration for agents and fleet defaults. It normalizes and forwards plugins through SDK and CLI runtimes. MCP configuration now supports explicit transports, headers, timeouts, and alwaysLoad while retaining existing URL and stdio behavior.

Changes

Plugin and MCP passthrough

Layer / File(s) Summary
Configuration schemas and fleet inheritance
packages/core/src/config/..., docs/src/content/docs/configuration/...
Schemas support normalized plugins and expanded MCP fields. Fleet plugins apply when agents omit plugins, while agent arrays replace fleet defaults.
SDK types and option conversion
packages/core/src/runner/types.ts, packages/core/src/runner/sdk-adapter.ts, packages/core/src/runner/__tests__/*, docs/src/content/docs/architecture/runner.md, docs/src/content/docs/configuration/mcp-servers.md, .changeset/plugins-and-mcp-server-passthrough.md
SDK types and conversion preserve MCP transport, headers, timeout, and loading fields. Configured plugins are cloned and passed to SDK queries.
CLI plugin argument forwarding
packages/core/src/runner/runtime/cli-runtime.ts, packages/core/src/runner/runtime/__tests__/cli-runtime.test.ts
The CLI emits ordered plugin directory flags and uses --plugin-dir-no-mcp when MCP discovery is disabled.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related issues

  • edspencer/herdctl#444 — The PR adds the plugin passthrough across agent configuration, SDK options, and CLI arguments.

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the pull request's main changes: plugin passthrough and expanded MCP server configuration.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/444-445-plugins-and-mcp-schema

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying herdctl with  Cloudflare Pages  Cloudflare Pages

Latest commit: 7b7dbd1
Status: ✅  Deploy successful!
Preview URL: https://7f47368f.herdctl.pages.dev
Branch Preview URL: https://fix-444-445-plugins-and-mcp.herdctl.pages.dev

View logs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/core/src/config/schema.ts`:
- Around line 522-535: Update McpServerSchema to enforce transport-specific
fields: require command for type "stdio" and url for types "sse" and "http",
while preserving inferred transport behavior when type is omitted. Adjust the
explicit transport cases in the agent configuration tests so stdio supplies
command and SSE/http supply url.
- Around line 528-530: The McpServerSchema currently permits authentication
headers on cleartext MCP URLs. Update McpServerSchema with validation or
transformMcpServer() so non-empty headers are accepted only when url uses
https://, while preserving header passthrough for secure URLs; also update the
MCP documentation to document this restriction.

In `@packages/core/src/runner/runtime/__tests__/cli-runtime.test.ts`:
- Around line 436-447: Update the test named “emits one --plugin-dir per plugin,
in order” to extract each --plugin-dir together with its immediately following
path and assert the complete ordered sequence of plugin flag/path pairs. Replace
the standalone jira containment check while preserving validation of both
plugins and their declared order.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d1e3b839-04b2-44fd-811c-e71675cb15e6

📥 Commits

Reviewing files that changed from the base of the PR and between a82cbd8 and 7b7dbd1.

📒 Files selected for processing (17)
  • .changeset/plugins-and-mcp-server-passthrough.md
  • docs/src/content/docs/architecture/runner.md
  • docs/src/content/docs/configuration/agent-config.md
  • docs/src/content/docs/configuration/fleet-config.md
  • docs/src/content/docs/configuration/mcp-servers.md
  • packages/core/src/config/__tests__/agent.test.ts
  • packages/core/src/config/__tests__/merge.test.ts
  • packages/core/src/config/index.ts
  • packages/core/src/config/merge.ts
  • packages/core/src/config/schema.ts
  • packages/core/src/runner/__tests__/mcp-and-plugin-passthrough.test.ts
  • packages/core/src/runner/__tests__/sdk-type-contract.test.ts
  • packages/core/src/runner/index.ts
  • packages/core/src/runner/runtime/__tests__/cli-runtime.test.ts
  • packages/core/src/runner/runtime/cli-runtime.ts
  • packages/core/src/runner/sdk-adapter.ts
  • packages/core/src/runner/types.ts

Comment on lines 522 to 535
export const McpServerSchema = z.object({
/** Transport. Inferred when omitted: `http` if `url` is set, else stdio. */
type: z.enum(["stdio", "sse", "http"]).optional(),
command: z.string().optional(),
args: z.array(z.string()).optional(),
env: z.record(z.string(), z.string()).optional(),
url: z.string().optional(),
/** Request headers for `sse`/`http` servers — carries bearer / API-key auth. */
headers: z.record(z.string(), z.string()).optional(),
/** Per-server tool-call timeout in ms. Values below 1000 are ignored by the SDK. */
timeout: z.number().int().positive().optional(),
/** Always include this server's tools in the prompt instead of deferring them. */
alwaysLoad: z.boolean().optional(),
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

version="0.3.215"
metadata="$(curl -fsSL "https://registry.npmjs.org/@anthropic-ai%2fclaude-agent-sdk/${version}")"
tarball="$(printf '%s' "$metadata" | jq -r '.dist.tarball')"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT

curl -fsSL "$tarball" -o "$tmp/sdk.tgz"
tar -xzf "$tmp/sdk.tgz" -C "$tmp"

# Verify the SDK definitions for stdio, SSE, and HTTP MCP server configs.
rg -n -C 6 'Mcp(Stdio|SSE|Http)ServerConfig|McpServerConfig' "$tmp/package"

Repository: edspencer/herdctl

Length of output: 14417


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Schema definitions around McpServerSchema:"
sed -n '500,550p' packages/core/src/config/schema.ts

echo
echo "Relevant package references:"
if [ -f package.json ]; then cat package.json | sed -n '1,140p'; fi
if [ -f packages/core/package.json ]; then cat packages/core/package.json | sed -n '1,180p'; fi

echo
echo "Agent schema/test MCP server examples:"
rg -n -C 8 'mcp_servers|McpServer|mcpServers|stdio|http|sse|alwaysLoad' packages/core/src packages/core/src/config/__tests__/agent.test.ts 2>/dev/null || true

echo
echo "Check whether schema is validated from YAML/external config path:"
rg -n -C 4 'parse|McpServerSchema|agent|fleet|yaml|read.*config|mcp' packages/core/src/config packages/core/src | head -n 240

Repository: edspencer/herdctl

Length of output: 50374


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Schema definitions around McpServerSchema:"
sed -n '500,550p' packages/core/src/config/schema.ts

echo
echo "Agent test around MCP server examples:"
if [ -f packages/core/src/config/__tests__/agent.test.ts ]; then
  sed -n '400,450p' packages/core/src/config/__tests__/agent.test.ts
else
  fd -a 'agent.test.ts' . | head -5
fi

echo
echo "Focused MCP-related schema/test references:"
rg -n -C 4 'McpServerSchema|mcp_servers|mcpServers|McpServerConfig|type:\s*"stdio"|type:\s*"sse"|type:\s*"http"|command\s*[:=]|url\s*[:=]|headers' packages/core/src/config packages/core/src/runner packages/core/src/config/__tests__/agent.test.ts 2>/dev/null || true

echo
echo "Behavioral model of McpServerSchema accepts current shapes:"
node - <<'JS'
const schemas = process.argv.slice(2);
// We can only inspect static source text because dependency/runtime artifacts are unavailable.
const fs = require('fs');
const source = fs.readFileSync('packages/core/src/config/schema.ts','utf8');
const model = {
  stdioWithOnlyUrl: { command: undefined, url: 'http://example.test' },
  httpWithoutUrl: { type: 'http', url: undefined },
  sseWithoutUrl: { type: 'sse', url: undefined },
  stdioWithCommand: { command: 'node', args: ['./server.js'] },
};
for (const [name, value] of Object.entries(model)) {
  const hasCommand = Boolean(value.command != null);
  const hasUrl = Boolean(value.url != null);
  const hasType = Boolean(value.type);
  let ok = false, reason = '';
  if (value.type === 'stdio') {
    ok = hasCommand;
    reason = hasCommand ? '' : 'stdio requires command';
  } else if (value.type === 'http' || value.type === 'sse') {
    ok = hasUrl;
    reason = !hasUrl ? (`${value.type} requires url` || '') : '';
  } else {
    ok = true;
  }
  console.log(`${name}: ${ok ? 'consistent with SDK shapes' : 'rejected: ' + reason}`);
}
JS

Repository: edspencer/herdctl

Length of output: 50374


Enforce the required MCP transport fields.

McpServerSchema accepts invalid SDK transport shapes, so invalid config can pass to the SDK. Require command when type: "stdio" is set, and require url when type: "sse" or type: "http" is set. Update packages/core/src/config/__tests__/agent.test.ts around the explicit type tests so stdio includes command while SSE/http include url.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/config/schema.ts` around lines 522 - 535, Update
McpServerSchema to enforce transport-specific fields: require command for type
"stdio" and url for types "sse" and "http", while preserving inferred transport
behavior when type is omitted. Adjust the explicit transport cases in the agent
configuration tests so stdio supplies command and SSE/http supply url.

Source: Coding guidelines

Comment on lines 528 to +530
url: z.string().optional(),
/** Request headers for `sse`/`http` servers — carries bearer / API-key auth. */
headers: z.record(z.string(), z.string()).optional(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

fd -a 'sdk-adapter.ts' packages/core/src |
  while IFS= read -r file; do
    rg -n -C 8 'mcp_servers|mcpServers|headers|url' "$file"
  done

Repository: edspencer/herdctl

Length of output: 2947


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== schema relevant section =="
nl -ba packages/core/src/config/schema.ts | sed -n '500,545p'

echo
echo "== sdk-adapter transform section =="
nl -ba packages/core/src/SDKTransform.ts | sed -n '40,115p'

echo
echo "== references to transformMcpServers / SDKTransform =="
rg -n -C 4 'transformMcpServers|SDKTransform|McpServer' packages/core/src --glob '*.ts' --glob '*.tsx'

Repository: edspencer/herdctl

Length of output: 225


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate files =="
fd -a 'schema.ts|sdk-adapter.ts|SDKTransform.ts' packages/core/src

echo
echo "== schema relevant section =="
awk 'NR>=500 && NR<=545 { printf "%6d %s\n", NR, $0 }' packages/core/src/config/schema.ts

echo
echo "== sdk adapter relevant section by candidate path =="
while IFS= read -r file; do
  if awk 'NR==1{exit}' "$file"; then
    echo "-- $file --"
    awk 'NR>=40 && NR<=115 { printf "%6d %s\n", NR, $0 }' "$file"
  fi
done < <(fd 'sdk-adapter.ts|SDKTransform.ts' packages/core/src)

echo
echo "== references =="
rg -n -C 3 'transformMcpServers|SDKTransform|McpServer|headers|url' packages/core/src --glob '*.ts' --glob '*.tsx' | awk 'NR<=220'

Repository: edspencer/herdctl

Length of output: 2942


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== runner/sdk-adapter.ts transform section =="
awk 'NR>=40 && NR<=130 { printf "%6d %s\n", NR, $0 }' packages/core/src/runner/sdk-adapter.ts

echo
echo "== references to transformMcpServers / agent.mcp_servers =="
rg -n -C 4 'transformMcpServers|agent\.mcp_servers|\.mcp_servers|SDKTransform' packages/core/src --glob '*.ts' --glob '*.tsx'

echo
echo "== schema validation imports and agents config schemas =="
awk 'NR>=1 && NR<=90 { printf "%6d %s\n", NR, $0 }' packages/core/src/config/schema.ts

Repository: edspencer/herdctl

Length of output: 22449


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate docs mentioning MCP headers/authentication =="
rg -n -i -C 4 'mcp|headers|Authorization|bearer|sse|https://.*mcp|url' README.md docs packages --glob '*.md' --glob '*.mdx' | awk 'NR<=260'

echo
echo "== deterministic schema behavior probe =="
python3 - <<'PY'
from urllib.parse import urlparse

def current_transform(server):
    result = {}
    if server.get("type"):
        result["type"] = server["type"]
    elif server.get("url"):
        result["type"] = "http"
    if server.get("url"):
        result["url"] = server["url"]
    if server.get("headers") and len(server["headers"]) > 0:
        result["headers"] = server["headers"]
    return result

cases = [
    {"name": "https auth", "url": "https://example.example/sse", "headers": {"Authorization": "Bearer secret"}},
    {"name": "http auth no type", "url": "http://example.example/sse", "headers": {"Authorization": "Bearer secret"}},
    {"name": "http auth explicit sse", "url": "http://example.example/sse", "headers": {"Authorization": "Bearer secret"}, "type": "sse"},
]
for case in cases:
    server = dict(case)
    cfg = current_transform(server)
    print(cfg["name"], "url=", urlparse(cfg.get("url", "")).scheme, "type=", cfg.get("type"), "headers=", cfg.get("headers"))
PY

Repository: edspencer/herdctl

Length of output: 24351


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== docs content tree candidates =="
fd -a '\.md$|\.mdx$' docs/src/content/docs packages | awk 'NR<=120'

echo
echo "== focused MCP/header docs search =="
rg -n -i -C 5 'mcp|headers|Authorization|bearer|authenticated|authorization|url' docs/src/content docs packages --glob '*.md' --glob '*.mdx' --glob '*.yaml' | awk 'NR<=260'

echo
echo "== deterministic schema behavior probe =="
python3 - <<'PY'
from urllib.parse import urlparse

def current_transform(server):
    result = {}
    if server.get("type"):
        result["type"] = server["type"]
    elif server.get("url"):
        result["type"] = "http"
    if server.get("url"):
        result["url"] = server["url"]
    if server.get("headers") and len(server["headers"]) > 0:
        result["headers"] = server["headers"]
    return result

for name, case in {"http auth no type": {"url":"http://example.example/sse","headers":{"Authorization":"Bearer secret"}},
                    "http auth explicit sse": {"url":"http://example.example/sse","headers":{"Authorization":"Bearer secret"},"type":"sse"}}.items():
    cfg = current_transform(case)
    print(name, "url_scheme=", urlparse(cfg.get("url", "")).scheme, "type=", cfg.get("type"), "headers=", cfg.get("headers"))
PY

Repository: edspencer/herdctl

Length of output: 25312


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== docs MCP doc =="
awk 'NR>=1 && NR<=260 { printf "%6d %s\n", NR, $0 }' docs/src/content/docs/configuration/mcp-servers.md

echo
echo "== docs config header docs =="
rg -n -i -C 6 'mcp|headers|Authorization|bearer|sse|https|http|authenticated' docs/src/content/docs/configuration docs/src/content/docs/security docs/src/content/docs/reference --glob '*.md' --glob '*.mdx'

Repository: edspencer/herdctl

Length of output: 50376


Sensitive Data Exposure (CWE-319): Cleartext Transmission of Sensitive Information

Reachability: External

Reachability path
● Entry
  packages/core/src/config/__tests__/merge.test.ts
│
▼
● Hop
  packages/core/src/config/merge.ts:132
  Plugin
│
▼
● Sink
  packages/core/src/config/schema.ts

Reject headers on cleartext MCP URLs.

McpServerSchema currently accepts headers with any url, and transformMcpServer() passes non-empty headers through before Claude Code makes the request. Add a Zod refine that requires https:// when headers is non-empty, or withhold auth headers from HTTP/sse servers before passthrough. Update the MCP documentation to state this restriction.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/config/schema.ts` around lines 528 - 530, The
McpServerSchema currently permits authentication headers on cleartext MCP URLs.
Update McpServerSchema with validation or transformMcpServer() so non-empty
headers are accepted only when url uses https://, while preserving header
passthrough for secure URLs; also update the MCP documentation to document this
restriction.

Source: Coding guidelines

Comment on lines +436 to +447
it("emits one --plugin-dir per plugin, in order", async () => {
const args = await captureArgs({
plugins: [
{ type: "local", path: "/opt/plugins/slack" },
{ type: "local", path: "/opt/plugins/jira" },
],
});

expect(args.filter((a) => a === "--plugin-dir")).toHaveLength(2);
expect(args[args.indexOf("--plugin-dir") + 1]).toBe("/opt/plugins/slack");
expect(args).toContain("/opt/plugins/jira");
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Assert each plugin flag and path as an ordered pair.

The test can pass if the second --plugin-dir has the wrong adjacent path, because it only checks that /opt/plugins/jira occurs somewhere in args. Extract the plugin flag/path pairs and compare the complete ordered sequence.

Proposed test update
-    expect(args.filter((a) => a === "--plugin-dir")).toHaveLength(2);
-    expect(args[args.indexOf("--plugin-dir") + 1]).toBe("/opt/plugins/slack");
-    expect(args).toContain("/opt/plugins/jira");
+    const pluginArgs = args.flatMap((arg, index) =>
+      arg === "--plugin-dir" ? [arg, args[index + 1]] : [],
+    );
+    expect(pluginArgs).toEqual([
+      "--plugin-dir",
+      "/opt/plugins/slack",
+      "--plugin-dir",
+      "/opt/plugins/jira",
+    ]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it("emits one --plugin-dir per plugin, in order", async () => {
const args = await captureArgs({
plugins: [
{ type: "local", path: "/opt/plugins/slack" },
{ type: "local", path: "/opt/plugins/jira" },
],
});
expect(args.filter((a) => a === "--plugin-dir")).toHaveLength(2);
expect(args[args.indexOf("--plugin-dir") + 1]).toBe("/opt/plugins/slack");
expect(args).toContain("/opt/plugins/jira");
});
it("emits one --plugin-dir per plugin, in order", async () => {
const args = await captureArgs({
plugins: [
{ type: "local", path: "/opt/plugins/slack" },
{ type: "local", path: "/opt/plugins/jira" },
],
});
const pluginArgs = args.flatMap((arg, index) =>
arg === "--plugin-dir" ? [arg, args[index + 1]] : [],
);
expect(pluginArgs).toEqual([
"--plugin-dir",
"/opt/plugins/slack",
"--plugin-dir",
"/opt/plugins/jira",
]);
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/runner/runtime/__tests__/cli-runtime.test.ts` around lines
436 - 447, Update the test named “emits one --plugin-dir per plugin, in order”
to extract each --plugin-dir together with its immediately following path and
assert the complete ordered sequence of plugin flag/path pairs. Replace the
standalone jira containment check while preserving validation of both plugins
and their declared order.

@edspencer

Copy link
Copy Markdown
Owner Author

Thanks — both CodeRabbit findings are declined, with reasons. Summarising here so the decision is on the record rather than buried in a resolved thread.

1. "Enforce the required MCP transport fields" — declined

The suggestion is to refine McpServerSchema so type: "stdio" requires command and type: "sse"|"http" requires url.

The problem is what layer that schema validates. McpServerSchema parses an agent's own config fragment, at AgentConfigSchema.parse() time — which happens before mergeAgentConfig folds in fleet defaults.mcp_servers. mcp_servers is deep-merged, so a partial per-server override is a supported pattern, and the fragment is legitimately incomplete on its own.

I checked this rather than assuming it. Both of these work today and would start throwing at parse time under the proposed refine:

// Agent overrides only the transport; url comes from fleet defaults.
AgentConfigSchema.parse({ name: "a", mcp_servers: { linear: { type: "sse" } } })
// → merged with defaults {type: "http", url: "https://mcp.linear.app/sse"}
// → {type: "sse", url: "https://mcp.linear.app/sse"}   ✅ valid only after merge
// Agent adds only auth to a server the fleet defines.
AgentConfigSchema.parse({ name: "a", mcp_servers: { linear: { headers: { Authorization: "Bearer x" } } } })

A cross-field requirement is meaningful on the merged config, not on a fragment. Enforcing it here would reject valid fleet configurations, so if this belongs anywhere it is a post-merge validation pass — out of scope for this PR, and worth its own issue. The PR body already lists the permissive schema as a deliberate non-goal; this is the concrete reason.

2. "Reject headers on cleartext MCP URLs" — declined

Requiring https:// whenever headers is non-empty would break the most common authenticated-MCP setup there is: a server on loopback. herdctl itself already emits exactly that shape — packages/core/src/runner/runtime/cli-runtime.ts:298 injects http://127.0.0.1:${bridge.port}/mcp for its own MCP HTTP bridge. Auth headers against 127.0.0.1 are not cleartext transmission over a network in any meaningful sense.

More importantly, .mcp.json and the Agent SDK both accept headers with an http:// URL. Rejecting one here would mean a config that works in claude fails under herdctl — which is precisely the defect class this PR exists to fix (#444/#445 are both "herdctl narrowing a shape the layer beneath it supports"). Adding a new narrowing while removing two would be self-defeating.

Worth noting the finding's own reachability path lists packages/core/src/config/__tests__/merge.test.ts as the external entry point, which is a test file, not an external input.

If cleartext-plus-auth is worth surfacing, the right shape is a warn at boot naming the server — loud, not fatal — and that is a separate change from this one.

@edspencer
edspencer merged commit d45d7f9 into main Aug 6, 2026
8 checks passed
@edspencer
edspencer deleted the fix/444-445-plugins-and-mcp-schema branch August 6, 2026 01:38
@github-actions github-actions Bot mentioned this pull request Aug 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant