@edspencer/paddock 0.61.1 → 0.63.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -16
- package/package.json +2 -2
- package/packages/server/dist/app.js +48 -8
- package/packages/server/dist/claude-credentials.d.ts +116 -0
- package/packages/server/dist/claude-credentials.js +103 -0
- package/packages/server/dist/claude-home.d.ts +102 -30
- package/packages/server/dist/claude-home.js +437 -97
- package/packages/server/dist/claude-instructions.d.ts +121 -0
- package/packages/server/dist/claude-instructions.js +114 -0
- package/packages/server/dist/claude-mcp.d.ts +158 -0
- package/packages/server/dist/claude-mcp.js +389 -0
- package/packages/server/dist/claude-plugins.d.ts +131 -0
- package/packages/server/dist/claude-plugins.js +394 -0
- package/packages/server/dist/claude-settings.d.ts +159 -0
- package/packages/server/dist/claude-settings.js +159 -0
- package/packages/server/dist/cli/args.d.ts +1 -9
- package/packages/server/dist/cli/args.js +46 -19
- package/packages/server/dist/cli/paddock.js +20 -39
- package/packages/server/dist/config.d.ts +166 -30
- package/packages/server/dist/config.js +106 -26
- package/packages/server/dist/herdctl-agent-config.d.ts +17 -1
- package/packages/server/dist/herdctl-agent-config.js +69 -7
- package/packages/server/dist/herdctl-agent-names.d.ts +11 -0
- package/packages/server/dist/herdctl-agent-names.js +31 -0
- package/packages/server/dist/herdctl.d.ts +75 -13
- package/packages/server/dist/herdctl.js +77 -18
- package/packages/server/dist/instance-config.js +36 -0
- package/packages/server/dist/mcp-servers.d.ts +187 -0
- package/packages/server/dist/mcp-servers.js +399 -0
- package/packages/server/dist/routes/chats.js +10 -4
- package/packages/server/dist/transcripts.d.ts +61 -30
- package/packages/server/dist/transcripts.js +141 -46
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/{InstanceConfigPage-BgNXqAox.js → InstanceConfigPage-CCk2hVT9.js} +1 -1
- package/packages/web/dist/assets/{MarkdownRenderer-DfD_UtqU.js → MarkdownRenderer-B6gST_0U.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-1EXdHXmO.js → ProjectRedirect-BCmKT4mf.js} +1 -1
- package/packages/web/dist/assets/{ProjectView-DHb5vNUp.js → ProjectView-CCN98MWu.js} +3 -3
- package/packages/web/dist/assets/{ProjectsGrid-Br-EhLZE.js → ProjectsGrid-CYzuN_yD.js} +1 -1
- package/packages/web/dist/assets/{arc-C2Jekujs.js → arc-BxvtMCZQ.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-CflCA-em.js → architectureDiagram-3BPJPVTR-Bp6ffJ_u.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-Ds7GLNnC.js → blockDiagram-GPEHLZMM-BIY2X8nW.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-D4WyGO6I.js → c4Diagram-AAUBKEIU-Br9stEJS.js} +1 -1
- package/packages/web/dist/assets/channel-CMdgNeFH.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-tQunO_M5.js → chunk-2J33WTMH-Djc7uqMV.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-DT5dOWd2.js → chunk-4BX2VUAB-yEVMLksS.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-CfTUqUEF.js → chunk-55IACEB6-tJaEdPZg.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-sK5bOAA0.js → chunk-727SXJPM-bWEqh672.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-DcMgVe-P.js → chunk-AQP2D5EJ-B8U9fB-T.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-In3h_-7v.js → chunk-FMBD7UC4-BeEITYig.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-DuaIeRaC.js → chunk-ND2GUHAM-L2rpLaoK.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-DeT5mR5T.js → chunk-QZHKN3VN-qqzPMZL_.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DU4HSESS.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DU4HSESS.js +2 -0
- package/packages/web/dist/assets/{core-DgOgLvnM.js → core-CSaNbmLO.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-CyO-rznJ.js → cose-bilkent-S5V4N54A-BRF7Nech.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-B_9Ihzgn.js → dagre-BM42HDAG-D10rJWOR.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-z5mvZ5YT.js → diagram-2AECGRRQ-DGjT5mAO.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-Bsz0cgfn.js → diagram-5GNKFQAL-DaH3bxHh.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-B29k5f4q.js → diagram-KO2AKTUF-QrLW9Wn6.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-CFrwsHoI.js → diagram-LMA3HP47-BX15wgYN.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-D9Sjc2HR.js → diagram-OG6HWLK6-DXQ_p63s.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-C3vbkD7M.js → erDiagram-TEJ5UH35-C_ioRruE.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-DkMIgntd.js → flowDiagram-I6XJVG4X-7d-xLXLM.js} +1 -1
- package/packages/web/dist/assets/{format-BFDqyFuw.js → format-BSDZezwD.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-nvHanjpW.js → ganttDiagram-6RSMTGT7-DZYx5xcj.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-DbCNfOu7.js → gitGraphDiagram-PVQCEYII-dimo-osm.js} +1 -1
- package/packages/web/dist/assets/{index-DGCs1YIO.js → index-CUnFKMga.js} +2 -2
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-aJjH9x3D.js → infoDiagram-5YYISTIA-BzApY25x.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-DoifwNnZ.js → ishikawaDiagram-YF4QCWOH-DtX47_9p.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-Cz9kG9HN.js → journeyDiagram-JHISSGLW-C7fITKg1.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-BoXnfr7A.js → kanban-definition-UN3LZRKU-BOrWLf_W.js} +1 -1
- package/packages/web/dist/assets/{linear-BlTTO9yR.js → linear-DuyAaBu6.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-YAG8eSPQ.js → mermaid.core-BQlzuVvO.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-DtUBKfkr.js → mindmap-definition-RKZ34NQL-CmP748B5.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-B3Rwpmy0.js → pieDiagram-4H26LBE5-CRd5Pdfq.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-B8eJtTyk.js → quadrantDiagram-W4KKPZXB-DpJm0Yw_.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-CpAluiY9.js → requirementDiagram-4Y6WPE33-B0o2PRMC.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-Cl_Rd1tN.js → sankeyDiagram-5OEKKPKP-CBi3Ek3v.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-CRpqKSGm.js → sequenceDiagram-3UESZ5HK-C1stxfQg.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-h3h8gXv_.js → stateDiagram-AJRCARHV-lCkiETWK.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-ZgEZRNqp.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-PVr5V_pG.js → timeline-definition-PNZ67QCA-D3DkWJMC.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-De-4NPAX.js → vennDiagram-CIIHVFJN-CO2-yC7K.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-Dtv10D2d.js → wardley-L42UT6IY-Dfjp0kqX.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-p2fJuQ6C.js → wardleyDiagram-YWT4CUSO-CIl0OPwS.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-DyYF-k92.js → xychartDiagram-2RQKCTM6-CuvlQuup.js} +1 -1
- package/packages/web/dist/index.html +1 -1
- package/packages/web/dist/sw.js +1 -1
- package/packages/server/dist/cli/claude-home-choice.d.ts +0 -44
- package/packages/server/dist/cli/claude-home-choice.js +0 -108
- package/packages/web/dist/assets/channel-B1K_gwAH.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DYQQOKrW.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DYQQOKrW.js +0 -2
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BKRI9TnH.js +0 -2
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `claude.mcpServers` lever (#691): whose MCP servers this instance's agents
|
|
3
|
+
* get.
|
|
4
|
+
*
|
|
5
|
+
* The last of the five levers, and the only one that is not about a file inside
|
|
6
|
+
* `~/.claude`. Everything else in the `claude:` block is a bridge decision about
|
|
7
|
+
* an entry of the home directory; MCP servers are declared in **`~/.claude.json`**,
|
|
8
|
+
* which is a SIBLING of that directory rather than a member of it. Claude Code
|
|
9
|
+
* resolves it as `join(CLAUDE_CONFIG_DIR ?? homedir(), ".claude.json")` — read
|
|
10
|
+
* out of the SDK bundle, where the two paths sit one expression apart and do not
|
|
11
|
+
* agree:
|
|
12
|
+
*
|
|
13
|
+
* ```js
|
|
14
|
+
* let d = n?.CLAUDE_CONFIG_DIR ?? process.env.CLAUDE_CONFIG_DIR,
|
|
15
|
+
* f = d ?? Dr(sO(), ".claude"); // .credentials.json lives under here
|
|
16
|
+
* … await eV(Dr(f, ".credentials.json"), "utf-8")
|
|
17
|
+
* … await KMe(Dr(d ?? sO(), ".claude.json"), …) // ← homedir, NOT ~/.claude
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* So the moment paddock owns its own home (#620/#691) the user's `.claude.json`
|
|
21
|
+
* stops being on the path, and NO symlink bridge inside the home structurally
|
|
22
|
+
* can reach it. That is why MCP inheritance broke silently, separately from
|
|
23
|
+
* everything else, and why this lever is a read rather than a link.
|
|
24
|
+
*
|
|
25
|
+
* ## Why not just symlink `.claude.json`
|
|
26
|
+
*
|
|
27
|
+
* Because Claude Code WRITES to it — per-project trust, `enabledMcpjsonServers`,
|
|
28
|
+
* onboarding/migration flags, cached tips. Bridging it means paddock's instance
|
|
29
|
+
* mutating the user's real config, which is the exact thing #691 exists to stop.
|
|
30
|
+
* Read it, take the two keys that declare servers, hand those to the runtime.
|
|
31
|
+
*
|
|
32
|
+
* ## The seam, which is not the one #691 names
|
|
33
|
+
*
|
|
34
|
+
* #691 says to pass the servers through herdctl's runner option
|
|
35
|
+
* (`SDKQueryOptions.mcpServers`, merged at `sdk-runtime.js`) and that **"paddock
|
|
36
|
+
* sets none today"**. Both halves are wrong in a way that makes the work
|
|
37
|
+
* smaller, not larger:
|
|
38
|
+
*
|
|
39
|
+
* 1. `SDKQueryOptions` is built BY herdctl (`toSDKOptions`), not by paddock.
|
|
40
|
+
* Paddock never constructs one. The field is populated from
|
|
41
|
+
* `transformMcpServers(agent.mcp_servers)` — i.e. from the AGENT CONFIG.
|
|
42
|
+
* 2. Paddock already sets `mcp_servers`: the headless-Chromium server from
|
|
43
|
+
* `browserMcpServers` (#269) is an external stdio MCP server passed exactly
|
|
44
|
+
* this way, on every keeper and every trigger, whenever `PADDOCK_BROWSER_MCP`
|
|
45
|
+
* is on.
|
|
46
|
+
*
|
|
47
|
+
* `agent.mcp_servers` is also the seam that covers BOTH runtimes for free: the
|
|
48
|
+
* SDK runtime transforms it into `sdkOptions.mcpServers`, and the CLI runtime
|
|
49
|
+
* serialises the same record into `--mcp-config '{"mcpServers":…}'`
|
|
50
|
+
* (`cli-runtime.js`). Passing it as a per-run option would have covered one.
|
|
51
|
+
*
|
|
52
|
+
* `injectedMcpServers` is, as #691 says, the wrong hook — `InjectedMcpServerDef`
|
|
53
|
+
* is `{name, version, tools}` with in-process JS handlers (`send_file`), and both
|
|
54
|
+
* runtimes materialise it as an HTTP bridge. It cannot express a stdio command.
|
|
55
|
+
*
|
|
56
|
+
* ## The allowlist, without which this lever is a no-op
|
|
57
|
+
*
|
|
58
|
+
* Paddock's fleet defaults carry an explicit `allowed_tools` array, and **both
|
|
59
|
+
* runtimes auto-deny any tool not on it, with no prompt** (the lesson `Skill`
|
|
60
|
+
* taught, recorded in `ensureConfigFile`). Tools from an MCP server are named
|
|
61
|
+
* `mcp__<server>__<tool>`, so a host server that is passed but not allowlisted
|
|
62
|
+
* connects and then has every one of its tools denied — an instance that looks
|
|
63
|
+
* configured and does nothing.
|
|
64
|
+
*
|
|
65
|
+
* herdctl handles this for INJECTED servers only ("Auto-add injected MCP tool
|
|
66
|
+
* patterns to allowedTools… only needed when the agent has an explicit
|
|
67
|
+
* allowlist", `cli-runtime.js`); config `mcp_servers` get no such treatment,
|
|
68
|
+
* which is why `mcp__playwright__*` is hard-coded into the defaults today. So
|
|
69
|
+
* this module also produces {@link mcpToolPattern} for each host server, and
|
|
70
|
+
* `buildAgentConfig` widens the keeper's allowlist by exactly those entries —
|
|
71
|
+
* only when there are some, so an instance with no host servers stays
|
|
72
|
+
* byte-identical to before.
|
|
73
|
+
*
|
|
74
|
+
* ## What could not be carried, and now can (herdctl 5.32.0)
|
|
75
|
+
*
|
|
76
|
+
* Until `@herdctl/core@5.32.0`, `McpServerSchema` was `{command?, args?, env?,
|
|
77
|
+
* url?}`, so `headers` and `type` were silently STRIPPED at `addAgent` and
|
|
78
|
+
* `transformMcpServer` rewrote every `url` to `type: "http"`. A
|
|
79
|
+
* header-authenticated server arrived unauthenticated and an `sse` server was
|
|
80
|
+
* downgraded to HTTP, so #699 shipped a boot warning naming every server that
|
|
81
|
+
* lost a field.
|
|
82
|
+
*
|
|
83
|
+
* herdctl#446 (in 5.32.0) widened the schema to mirror the SDK's own
|
|
84
|
+
* `McpServerConfig` — `type`, `headers`, `timeout` and `alwaysLoad` — and an
|
|
85
|
+
* explicit `type` now wins over the bare-`url` inference. Verified against the
|
|
86
|
+
* installed 5.32.0 rather than assumed: `addAgent` → `getAgents()` →
|
|
87
|
+
* `toSDKOptions()` returns `{type: "sse", url, headers}` unchanged for a server
|
|
88
|
+
* declared that way, and still infers `type: "http"` for a bare `url`. So the
|
|
89
|
+
* two `degraded` caveats are gone and {@link McpServerDef} carries both fields.
|
|
90
|
+
*
|
|
91
|
+
* Why that mattered more than it looked, and why it is worth not regressing:
|
|
92
|
+
* MCP OAuth tokens are stored under a top-level **`mcpOAuth`** key in the SAME
|
|
93
|
+
* credential store as `claudeAiOauth` — `<securestorage-dir>/.credentials.json`
|
|
94
|
+
* on Linux, the one `Claude Code-credentials` keychain item on darwin. There is
|
|
95
|
+
* no MCP-specific service name and no `mcp-oauth/` directory. The store is
|
|
96
|
+
* resolved by `CLAUDE_SECURESTORAGE_CONFIG_DIR ?? CLAUDE_CONFIG_DIR ?? ~/.claude`,
|
|
97
|
+
* which is exactly the variable `claude.credentials` drives — **so
|
|
98
|
+
* `credentials: host` DOES carry MCP OAuth tokens**, in either direction. But
|
|
99
|
+
* the per-server key is `` `${serverName}|${sha256({type,url,headers}).slice(0,16)}` ``,
|
|
100
|
+
* so a dropped header or a coerced `type` changed the hash and the stored token
|
|
101
|
+
* was simply not found. Carrying both fields verbatim is what makes
|
|
102
|
+
* `credentials: host` + `mcpServers: host` work for an OAuth server at all.
|
|
103
|
+
*
|
|
104
|
+
* `z.object` still strips what it has no field for (`tools`, and anything Claude
|
|
105
|
+
* Code grows next), so this narrowing is still a narrowing — it is just no longer
|
|
106
|
+
* one that breaks authentication. The one case that is still DROPPED is a server
|
|
107
|
+
* with neither `command` nor `url`, which cannot be started at all.
|
|
108
|
+
*
|
|
109
|
+
* ## The cost of carrying `headers`, which is real and is upstream
|
|
110
|
+
*
|
|
111
|
+
* herdctl's **CLI runtime** serialises the whole `mcp_servers` record into a
|
|
112
|
+
* single `--mcp-config '{"mcpServers":…}'` argv element, so everything in it is
|
|
113
|
+
* readable from `/proc/<pid>/cmdline` by any process of the same user. That was
|
|
114
|
+
* already true of an `env` value; it is now true of an `Authorization` header as
|
|
115
|
+
* well, which is the more likely place a bearer token lives. The SDK runtime
|
|
116
|
+
* passes the record in-process and is unaffected.
|
|
117
|
+
*
|
|
118
|
+
* Paddock's chats default to the SDK runtime (`driveMode: session`), but the
|
|
119
|
+
* sweeper and triggers are always one-shot CLI runs and a `driveMode: batch`
|
|
120
|
+
* project's turns are too — and neither the sweeper nor a trigger is given these
|
|
121
|
+
* servers, so the exposure is `driveMode: batch` only. Not paddock's to fix (the
|
|
122
|
+
* argv shape is herdctl's) and not a reason to go back to dropping the header:
|
|
123
|
+
* a stripped header is an authentication failure for everyone, while this is a
|
|
124
|
+
* same-user disclosure on one non-default drive mode. Worth knowing before
|
|
125
|
+
* putting a long-lived token in a `headers` block on a shared box.
|
|
126
|
+
*
|
|
127
|
+
* #702 found and measured this for a DECLARED server's `env` (it reads the token
|
|
128
|
+
* back out of a real spawned process's argv), and `mcp-servers.ts`'s
|
|
129
|
+
* `argvExposure` is the boot warning — now widened to `headers` for the same
|
|
130
|
+
* reason. There is no equivalent warning for a HOST server, deliberately: this
|
|
131
|
+
* module does not look at a value it did not resolve, and a user who ran
|
|
132
|
+
* `claude mcp add` on the box is already in the argv-exposure position with their
|
|
133
|
+
* own terminal.
|
|
134
|
+
*
|
|
135
|
+
* ## Plugins are the other half of this lever
|
|
136
|
+
*
|
|
137
|
+
* A Claude Code **plugin** can provide MCP servers too, and they are invisible to
|
|
138
|
+
* everything above: they are declared inside the plugin, not in `.claude.json`
|
|
139
|
+
* (#700). `claude-plugins.ts` is that half — it enumerates the host's installed
|
|
140
|
+
* plugin directories for `agent.plugins`, the second thing 5.32.0 added.
|
|
141
|
+
*
|
|
142
|
+
* ## Scope: keepers only
|
|
143
|
+
*
|
|
144
|
+
* The sweeper is tool-less by design and the trigger agents declare their own
|
|
145
|
+
* narrow `allowed_tools` per trigger, so neither would be able to call a host
|
|
146
|
+
* server even if it were attached. Attaching one anyway would spawn a stdio
|
|
147
|
+
* process per fire for nothing. Keepers are what a user means by "my MCP servers
|
|
148
|
+
* in paddock".
|
|
149
|
+
*
|
|
150
|
+
* ## Step 6 landed here, as an addition
|
|
151
|
+
*
|
|
152
|
+
* #691 step 6 — an instance-level `mcpServers:` block in `paddock.config.yaml`
|
|
153
|
+
* for servers a user declares themselves — is `mcp-servers.ts`. It contributes
|
|
154
|
+
* {@link McpSources.declared} and nothing else: the shape, {@link mcpServersFor},
|
|
155
|
+
* and the allowlist widening in `buildAgentConfig` were already source-agnostic
|
|
156
|
+
* and did not change. What it does NOT share is validation posture — a host
|
|
157
|
+
* server paddock cannot carry faithfully is passed with a warning (the user
|
|
158
|
+
* declared it elsewhere, for something else), while a declared one is refused
|
|
159
|
+
* with an error (they typed it here, at us, and can fix it).
|
|
160
|
+
*/
|
|
161
|
+
import { promises as fs } from "node:fs";
|
|
162
|
+
import path from "node:path";
|
|
163
|
+
/** Isolation is the default, as everywhere except `credentials`. */
|
|
164
|
+
export const DEFAULT_MCP_SERVERS_MODE = "own";
|
|
165
|
+
/** Type guard, so an unknown config value falls back instead of failing a boot. */
|
|
166
|
+
export function isKnownMcpServersMode(value) {
|
|
167
|
+
return value === "own" || value === "host";
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The file MCP servers are declared in — a SIBLING of the Claude home, not an
|
|
171
|
+
* entry inside it. See the module doc for the SDK expression that settles this.
|
|
172
|
+
*/
|
|
173
|
+
export const HOST_MCP_CONFIG_FILE = ".claude.json";
|
|
174
|
+
/**
|
|
175
|
+
* Where the user's own `.claude.json` is.
|
|
176
|
+
*
|
|
177
|
+
* Derived from `cfg.legacyClaudeHome` (always `~/.claude`) rather than read from
|
|
178
|
+
* `os.homedir()` again, so it moves with the one value the rest of the bridge
|
|
179
|
+
* already treats as "the host's Claude Code" — and so a test can point the whole
|
|
180
|
+
* lever at a temp dir by setting that one field.
|
|
181
|
+
*/
|
|
182
|
+
export function hostMcpConfigPath(legacyClaudeHome) {
|
|
183
|
+
return path.join(path.dirname(legacyClaudeHome), HOST_MCP_CONFIG_FILE);
|
|
184
|
+
}
|
|
185
|
+
/** No servers from anywhere: `mcpServers: own` + an empty block, and where tests start. */
|
|
186
|
+
export const EMPTY_MCP_SOURCES = Object.freeze({
|
|
187
|
+
user: {},
|
|
188
|
+
byDir: {},
|
|
189
|
+
declared: {},
|
|
190
|
+
caveats: [],
|
|
191
|
+
});
|
|
192
|
+
/** The allowlist entry that lets a server's tools actually be called. */
|
|
193
|
+
export function mcpToolPattern(name) {
|
|
194
|
+
return `mcp__${name}__*`;
|
|
195
|
+
}
|
|
196
|
+
function isRecord(v) {
|
|
197
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Narrow one declared server to what herdctl can carry, recording what was lost.
|
|
201
|
+
*
|
|
202
|
+
* Returns `undefined` for a server that cannot be started at all — no `command`
|
|
203
|
+
* and no `url` — which is the only case where dropping beats degrading.
|
|
204
|
+
*/
|
|
205
|
+
function narrowServer(name, raw, caveats) {
|
|
206
|
+
if (!isRecord(raw)) {
|
|
207
|
+
caveats.push({ name, kind: "dropped", reason: "its declaration is not a JSON object" });
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
const server = {};
|
|
211
|
+
if (typeof raw.command === "string" && raw.command !== "")
|
|
212
|
+
server.command = raw.command;
|
|
213
|
+
if (Array.isArray(raw.args) && raw.args.every((a) => typeof a === "string")) {
|
|
214
|
+
if (raw.args.length > 0)
|
|
215
|
+
server.args = [...raw.args];
|
|
216
|
+
}
|
|
217
|
+
if (isRecord(raw.env)) {
|
|
218
|
+
const env = {};
|
|
219
|
+
for (const [k, v] of Object.entries(raw.env))
|
|
220
|
+
if (typeof v === "string")
|
|
221
|
+
env[k] = v;
|
|
222
|
+
if (Object.keys(env).length > 0)
|
|
223
|
+
server.env = env;
|
|
224
|
+
}
|
|
225
|
+
if (typeof raw.url === "string" && raw.url !== "")
|
|
226
|
+
server.url = raw.url;
|
|
227
|
+
// Both carried verbatim since herdctl 5.32.0 (#700). Before that they were
|
|
228
|
+
// stripped at `addAgent` and this function pushed a `degraded` caveat naming
|
|
229
|
+
// the server; the fields reaching `toSDKOptions()` intact is what retired that
|
|
230
|
+
// warning. A `type` we do not recognise is left off rather than passed on —
|
|
231
|
+
// herdctl's enum would strip it anyway, and the bare-`url` inference is the
|
|
232
|
+
// better fallback than nothing.
|
|
233
|
+
if (raw.type === "stdio" || raw.type === "sse" || raw.type === "http")
|
|
234
|
+
server.type = raw.type;
|
|
235
|
+
if (isRecord(raw.headers)) {
|
|
236
|
+
const headers = {};
|
|
237
|
+
for (const [k, v] of Object.entries(raw.headers))
|
|
238
|
+
if (typeof v === "string")
|
|
239
|
+
headers[k] = v;
|
|
240
|
+
if (Object.keys(headers).length > 0)
|
|
241
|
+
server.headers = headers;
|
|
242
|
+
}
|
|
243
|
+
if (server.command === undefined && server.url === undefined) {
|
|
244
|
+
caveats.push({
|
|
245
|
+
name,
|
|
246
|
+
kind: "dropped",
|
|
247
|
+
reason: "it declares neither a `command` nor a `url`, so there is nothing to start",
|
|
248
|
+
});
|
|
249
|
+
return undefined;
|
|
250
|
+
}
|
|
251
|
+
return server;
|
|
252
|
+
}
|
|
253
|
+
function serversOf(raw, caveats) {
|
|
254
|
+
if (!isRecord(raw))
|
|
255
|
+
return {};
|
|
256
|
+
const out = {};
|
|
257
|
+
for (const [name, decl] of Object.entries(raw)) {
|
|
258
|
+
const server = narrowServer(name, decl, caveats);
|
|
259
|
+
if (server)
|
|
260
|
+
out[name] = server;
|
|
261
|
+
}
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Parse a `.claude.json` into the two server scopes. Pure, so the whole
|
|
266
|
+
* selection is testable from a string with no filesystem and no real home.
|
|
267
|
+
*
|
|
268
|
+
* Fails soft in both directions on purpose. Unparseable JSON, a non-object top
|
|
269
|
+
* level, a `projects` value that is not a map — none of them throw, because the
|
|
270
|
+
* user's `.claude.json` is a file paddock does not own, cannot validate and must
|
|
271
|
+
* never turn into a boot failure. What it does instead is return nothing and let
|
|
272
|
+
* the caller say so.
|
|
273
|
+
*/
|
|
274
|
+
export function parseHostMcpConfig(raw) {
|
|
275
|
+
const caveats = [];
|
|
276
|
+
let parsed;
|
|
277
|
+
try {
|
|
278
|
+
parsed = JSON.parse(raw);
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
return { user: {}, byDir: {}, declared: {}, caveats };
|
|
282
|
+
}
|
|
283
|
+
if (!isRecord(parsed))
|
|
284
|
+
return { user: {}, byDir: {}, declared: {}, caveats };
|
|
285
|
+
const user = serversOf(parsed.mcpServers, caveats);
|
|
286
|
+
const byDir = {};
|
|
287
|
+
if (isRecord(parsed.projects)) {
|
|
288
|
+
for (const [dir, entry] of Object.entries(parsed.projects)) {
|
|
289
|
+
if (!isRecord(entry))
|
|
290
|
+
continue;
|
|
291
|
+
const servers = serversOf(entry.mcpServers, caveats);
|
|
292
|
+
if (Object.keys(servers).length > 0)
|
|
293
|
+
byDir[dir] = servers;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return { user, byDir, declared: {}, caveats };
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* The servers that apply to one agent's working directory.
|
|
300
|
+
*
|
|
301
|
+
* Precedence, weakest first: host user scope, host directory scope, then this
|
|
302
|
+
* instance's own `mcpServers:` block.
|
|
303
|
+
*
|
|
304
|
+
* Host per-directory wins over host user scope because it is the more specific
|
|
305
|
+
* declaration, and it is the one `claude mcp add` writes by default, so a user
|
|
306
|
+
* who re-declared a server inside a project meant that one. {@link
|
|
307
|
+
* McpSources.declared} wins over both because it is a statement about THIS
|
|
308
|
+
* instance while `~/.claude.json` is ambient machine state — a user who wrote a
|
|
309
|
+
* server into paddock's own config was answering "what should paddock have?",
|
|
310
|
+
* which is a narrower question than "what does this machine have?".
|
|
311
|
+
*
|
|
312
|
+
* The host lookup is exact rather than a prefix walk. `.claude.json`'s `projects`
|
|
313
|
+
* keys are the literal cwd Claude Code was started in, and Claude Code does not
|
|
314
|
+
* inherit a parent directory's entry either — matching that keeps paddock's
|
|
315
|
+
* answer the same as the terminal's, which is the whole point of `host`.
|
|
316
|
+
*/
|
|
317
|
+
export function mcpServersFor(source, workingDir) {
|
|
318
|
+
return { ...source.user, ...(source.byDir[workingDir] ?? {}), ...source.declared };
|
|
319
|
+
}
|
|
320
|
+
/** Every directory-scoped key, for the boot notice. */
|
|
321
|
+
export function hostMcpScopedDirs(source) {
|
|
322
|
+
return Object.keys(source.byDir);
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Read the host's `.claude.json` — once, at boot — and say what came of it.
|
|
326
|
+
*
|
|
327
|
+
* Under `own` the file is not opened at all. That is not an optimisation: "own
|
|
328
|
+
* everywhere means nothing outside the data dir is read" is the guarantee #691
|
|
329
|
+
* exists to make sayable, and a lever that read the file and then discarded it
|
|
330
|
+
* would make the guarantee false in the only place anyone could check.
|
|
331
|
+
*
|
|
332
|
+
* Read once rather than per turn, for the same reason `claude.hooks` materialises
|
|
333
|
+
* its `settings.json` once: a `.claude.json` that grows a server mid-run is picked
|
|
334
|
+
* up at the next restart, and the boot notice is where a user looks to confirm
|
|
335
|
+
* what this instance actually has.
|
|
336
|
+
*/
|
|
337
|
+
export async function loadHostMcpSource(cfg) {
|
|
338
|
+
if (cfg.claude.mcpServers !== "host")
|
|
339
|
+
return { source: EMPTY_MCP_SOURCES, notices: [] };
|
|
340
|
+
const file = hostMcpConfigPath(cfg.legacyClaudeHome);
|
|
341
|
+
const notices = [];
|
|
342
|
+
let raw;
|
|
343
|
+
try {
|
|
344
|
+
raw = await fs.readFile(file, "utf8");
|
|
345
|
+
}
|
|
346
|
+
catch (err) {
|
|
347
|
+
notices.push({
|
|
348
|
+
level: "info",
|
|
349
|
+
message: `\`claude.mcpServers: host\` is set but ${file} could not be read (${String(err)}) — ` +
|
|
350
|
+
`no host MCP servers are attached. That file is where \`claude mcp add\` writes; ` +
|
|
351
|
+
`it does not exist until you have added one.`,
|
|
352
|
+
});
|
|
353
|
+
return { source: EMPTY_MCP_SOURCES, notices };
|
|
354
|
+
}
|
|
355
|
+
const source = parseHostMcpConfig(raw);
|
|
356
|
+
const userNames = Object.keys(source.user);
|
|
357
|
+
const scoped = hostMcpScopedDirs(source);
|
|
358
|
+
const total = userNames.length + scoped.reduce((n, d) => n + Object.keys(source.byDir[d]).length, 0);
|
|
359
|
+
if (total === 0) {
|
|
360
|
+
notices.push({
|
|
361
|
+
level: "info",
|
|
362
|
+
message: `\`claude.mcpServers: host\`: no MCP servers are declared in ${file} ` +
|
|
363
|
+
`(neither at the top level nor under any \`projects\` entry).`,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
notices.push({
|
|
368
|
+
level: "info",
|
|
369
|
+
message: `Claude MCP servers: host (\`claude.mcpServers: host\`) — ` +
|
|
370
|
+
(userNames.length > 0 ? `user scope: ${userNames.join(", ")}` : "no user-scope servers") +
|
|
371
|
+
(scoped.length > 0
|
|
372
|
+
? `; directory scope: ${scoped
|
|
373
|
+
.map((d) => `${d} (${Object.keys(source.byDir[d]).join(", ")})`)
|
|
374
|
+
.join(", ")}`
|
|
375
|
+
: "") +
|
|
376
|
+
`. A project's keeper gets the user-scope servers plus any scoped to its own ` +
|
|
377
|
+
`working directory.`,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
for (const caveat of source.caveats) {
|
|
381
|
+
notices.push({
|
|
382
|
+
level: "warn",
|
|
383
|
+
message: caveat.kind === "dropped"
|
|
384
|
+
? `MCP server "${caveat.name}" in ${file} was NOT attached: ${caveat.reason}.`
|
|
385
|
+
: `MCP server "${caveat.name}" from ${file} is attached but degraded: ${caveat.reason}.`,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
return { source, notices };
|
|
389
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { McpServersMode } from "./claude-mcp.js";
|
|
2
|
+
import type { InstructionsMode } from "./claude-instructions.js";
|
|
3
|
+
/**
|
|
4
|
+
* One plugin to load, in herdctl's `PluginSchema` object form.
|
|
5
|
+
*
|
|
6
|
+
* The schema also accepts a bare path string and normalises it to this, but
|
|
7
|
+
* paddock always needs the object because {@link HostPlugin.skipMcpDiscovery} is
|
|
8
|
+
* how the two levers are kept apart.
|
|
9
|
+
*/
|
|
10
|
+
export interface HostPlugin {
|
|
11
|
+
type: "local";
|
|
12
|
+
path: string;
|
|
13
|
+
skipMcpDiscovery?: boolean;
|
|
14
|
+
}
|
|
15
|
+
/** Something about a host plugin worth telling the operator at boot. */
|
|
16
|
+
export interface HostPluginCaveat {
|
|
17
|
+
/** The plugin's id (`<name>@<marketplace>`) or its directory name. */
|
|
18
|
+
name: string;
|
|
19
|
+
/** `dropped` = not passed at all; `degraded` = passed, but incompletely. */
|
|
20
|
+
kind: "dropped" | "degraded";
|
|
21
|
+
reason: string;
|
|
22
|
+
}
|
|
23
|
+
/** What {@link enumerateHostPlugins} found. */
|
|
24
|
+
export interface HostPluginSource {
|
|
25
|
+
/** Ready for `agent.plugins`, in registry order. */
|
|
26
|
+
plugins: HostPlugin[];
|
|
27
|
+
/**
|
|
28
|
+
* `mcp__<server>__*` patterns for every plugin-provided server paddock could
|
|
29
|
+
* name statically. Empty whenever MCP discovery is off.
|
|
30
|
+
*/
|
|
31
|
+
toolPatterns: string[];
|
|
32
|
+
caveats: HostPluginCaveat[];
|
|
33
|
+
}
|
|
34
|
+
/** Nothing inherited: `instructions: own`, and where tests start. */
|
|
35
|
+
export declare const EMPTY_HOST_PLUGINS: HostPluginSource;
|
|
36
|
+
/** The plugin root, `join(CLAUDE_CONFIG_DIR, "plugins")` in the CLI's terms. */
|
|
37
|
+
export declare function hostPluginRoot(legacyClaudeHome: string): string;
|
|
38
|
+
/** The CLI's own registry of what is installed, under {@link hostPluginRoot}. */
|
|
39
|
+
export declare const INSTALLED_PLUGINS_FILE = "installed_plugins.json";
|
|
40
|
+
/** A plugin's manifest, relative to its directory. */
|
|
41
|
+
export declare const PLUGIN_MANIFEST: string;
|
|
42
|
+
/** A plugin's out-of-manifest MCP declaration, relative to its directory. */
|
|
43
|
+
export declare const PLUGIN_MCP_FILE = ".mcp.json";
|
|
44
|
+
/**
|
|
45
|
+
* The allowlist pattern for one server of one plugin.
|
|
46
|
+
*
|
|
47
|
+
* Deliberately not `mcpToolPattern` from `claude-mcp.ts`: the name a plugin's
|
|
48
|
+
* server is registered under is not the name it is declared under, and that
|
|
49
|
+
* derivation is the whole content of this function.
|
|
50
|
+
*/
|
|
51
|
+
export declare function pluginMcpToolPattern(pluginName: string, serverName: string): string;
|
|
52
|
+
/** One plugin's name and the MCP servers it declares, as far as they are static. */
|
|
53
|
+
export interface PluginServerNames {
|
|
54
|
+
/** `manifest.name`, else the directory name — the CLI's own fallback order. */
|
|
55
|
+
pluginName: string;
|
|
56
|
+
/** Server names as declared, BEFORE the `plugin:<plugin>:` prefixing. */
|
|
57
|
+
servers: string[];
|
|
58
|
+
/**
|
|
59
|
+
* A `mcpServers` the manifest points at rather than spells out, and that could
|
|
60
|
+
* not be resolved to a file inside the plugin directory. Non-empty means the
|
|
61
|
+
* allowlist for this plugin is incomplete.
|
|
62
|
+
*/
|
|
63
|
+
unresolved: string[];
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Read one plugin directory's declared MCP server names.
|
|
67
|
+
*
|
|
68
|
+
* Mirrors the CLI's own discovery order — `<dir>/.mcp.json` first, then whatever
|
|
69
|
+
* `manifest.mcpServers` adds — with the one deliberate gap described in the
|
|
70
|
+
* module doc: a manifest that points at an MCPB source (or at any path outside
|
|
71
|
+
* its own directory) is recorded in {@link PluginServerNames.unresolved} rather
|
|
72
|
+
* than fetched. Paddock does not download anything to build an allowlist.
|
|
73
|
+
*/
|
|
74
|
+
export declare function readPluginServerNames(dir: string): Promise<PluginServerNames>;
|
|
75
|
+
/** One installed plugin: its id, and where it landed on disk. */
|
|
76
|
+
interface InstalledPlugin {
|
|
77
|
+
id: string;
|
|
78
|
+
installPath: string;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Parse `installed_plugins.json`, v1 or v2. Pure, so the whole selection is
|
|
82
|
+
* testable from a string.
|
|
83
|
+
*
|
|
84
|
+
* v2 keys each `<name>@<marketplace>` id to an ARRAY of installations (one per
|
|
85
|
+
* scope), each carrying an explicit `installPath`. v1 keyed it to a single record
|
|
86
|
+
* with no path, which the CLI derives as
|
|
87
|
+
* `<root>/cache/<marketplace>/<name>/<version>` with each segment sanitised
|
|
88
|
+
* `[^a-zA-Z0-9\-_] → -` (and the version segment additionally allowing `.`).
|
|
89
|
+
*
|
|
90
|
+
* Fails soft in every direction: this is a file paddock does not own and must
|
|
91
|
+
* never turn into a boot failure.
|
|
92
|
+
*/
|
|
93
|
+
export declare function parseInstalledPlugins(raw: string, root: string): InstalledPlugin[];
|
|
94
|
+
/** A line for the boot log, at a level (mirrors `HostMcpNotice`). */
|
|
95
|
+
export interface HostPluginNotice {
|
|
96
|
+
level: "info" | "warn";
|
|
97
|
+
message: string;
|
|
98
|
+
}
|
|
99
|
+
/** What {@link loadHostPlugins} found, ready for the agent config and the log. */
|
|
100
|
+
export interface HostPluginReport {
|
|
101
|
+
source: HostPluginSource;
|
|
102
|
+
notices: HostPluginNotice[];
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Enumerate the host's installed plugin directories. Pure apart from reading the
|
|
106
|
+
* plugin tree, and takes the two files' contents rather than paths so the whole
|
|
107
|
+
* selection is testable without a home directory.
|
|
108
|
+
*/
|
|
109
|
+
export declare function enumerateHostPlugins(opts: {
|
|
110
|
+
root: string;
|
|
111
|
+
installed: string | undefined;
|
|
112
|
+
settings: unknown;
|
|
113
|
+
/** Whether the plugins' own MCP servers come too. */
|
|
114
|
+
mcp: boolean;
|
|
115
|
+
}): Promise<HostPluginSource>;
|
|
116
|
+
/**
|
|
117
|
+
* Read the host's plugin registry — once, at boot — and say what came of it.
|
|
118
|
+
*
|
|
119
|
+
* Under `instructions: own` nothing is opened at all, for the same reason
|
|
120
|
+
* `loadHostMcpSource` does not open `.claude.json` under `mcpServers: own`: "own
|
|
121
|
+
* everywhere means nothing outside the data dir is read" is a guarantee that has
|
|
122
|
+
* to be true in the place someone would check it.
|
|
123
|
+
*/
|
|
124
|
+
export declare function loadHostPlugins(cfg: {
|
|
125
|
+
legacyClaudeHome: string;
|
|
126
|
+
claude: {
|
|
127
|
+
instructions: InstructionsMode;
|
|
128
|
+
mcpServers: McpServersMode;
|
|
129
|
+
};
|
|
130
|
+
}): Promise<HostPluginReport>;
|
|
131
|
+
export {};
|