@commonlyai/cli 0.1.53 → 0.1.55

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.53",
3
+ "version": "0.1.55",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -98,6 +98,11 @@ const buildPrompt = buildMemoryPreamble;
98
98
  // Codex-specific constraints:
99
99
  // - stdio/command servers only (no url transport here); url-only entries
100
100
  // are skipped rather than half-wired.
101
+ // - Only entries that DECLARE stdio (or name no transport) are emitted, even
102
+ // when they also carry a `command`. `auditDeclaredMcp` classifies by
103
+ // `transport` and never judges the command of an entry that declared an
104
+ // http one, so emitting it here executes a command the guard did not
105
+ // approve — the case Vera measured on 2026-09-18 (Connectors 69774).
101
106
  // - Token-bearing values ride through `env_vars`, never a `-c ...env=...`
102
107
  // argv override. Command lines are visible to other same-user processes
103
108
  // unless the OS sandbox blocks process inspection; keeping bearer tokens
@@ -126,7 +131,9 @@ const buildMcpOverrideArgs = (mcpServers, ctx = {}) => {
126
131
  const flags = [];
127
132
  const forwardedEnv = {};
128
133
  for (const server of mcpServers || []) {
129
- if (!server?.name || !Array.isArray(server.command) || !server.command.length) continue;
134
+ const transport = typeof server?.transport === 'string' ? server.transport.trim().toLowerCase() : 'stdio';
135
+ if (!server?.name || transport !== 'stdio'
136
+ || !Array.isArray(server.command) || !server.command.length) continue;
130
137
  const [command, ...rest] = server.command.map((a) => substitutePlaceholders(a, ctx));
131
138
  flags.push('-c', `mcp_servers.${server.name}.command=${toml(command)}`);
132
139
  // The user opted into every server present in the environment spec.
@@ -1,14 +1,19 @@
1
1
  /**
2
- * pi extension: Commonly's tools for a pi seat, over MCP stdio.
2
+ * pi extension: Commonly's tools for a pi seat, over MCP.
3
3
  *
4
4
  * Loaded by adapters/pi.js with `-e`. Reads COMMONLY_PI_MCP — a JSON list of
5
- * `{ name, command: [...], env: {...} }` starts each server on stdio, asks
6
- * it for its tools, and registers every one with pi under its own name, so a
7
- * pi seat calls `commonly_post_message` exactly as a claude or codex seat
5
+ * `{ name, command: [...], env: {...} }` for stdio servers and
6
+ * `{ name, url, headers: {...} }` for Streamable HTTP ones connects to each,
7
+ * asks it for its tools, and registers every one with pi under its own name, so
8
+ * a pi seat calls `commonly_post_message` exactly as a claude or codex seat
8
9
  * does. Tool calls are forwarded as MCP `tools/call`; results come back as
9
10
  * text. The client lives in pi-mcp-client.mjs (jest-tested); this file only
10
11
  * binds it to pi's `registerTool`. `typebox` resolves through pi's extension
11
12
  * loader, which aliases its bundled copy — it is not a CLI dependency.
13
+ *
14
+ * pi ships no MCP support of its own (its README: "No MCP. … build an
15
+ * extension that adds MCP support"), so this file is the whole transport story
16
+ * for a pi seat: a server that is not reachable from here is not reachable.
12
17
  */
13
18
 
14
19
  import { Type } from 'typebox';
@@ -1,15 +1,72 @@
1
1
  /**
2
- * A minimal MCP stdio client for the pi bridge (pi-commonly-mcp.mjs).
2
+ * A minimal MCP client for the pi bridge (pi-commonly-mcp.mjs), over both
3
+ * transports the environment spec admits: stdio (newline-delimited JSON-RPC)
4
+ * and Streamable HTTP.
3
5
  *
4
- * The stdio transport is newline-delimited JSON-RPC; a client for four
5
- * methods (initialize, tools/list, tools/call, the initialized notification)
6
- * is smaller than a dependency, and keeping it in a file with no pi imports
7
- * means the CLI's own jest can test it `typebox` only resolves inside pi's
8
- * extension loader, so the extension file stays thin and untested by jest.
6
+ * A client for four methods (initialize, tools/list, tools/call, the
7
+ * initialized notification) is smaller than a dependency, and keeping it in a
8
+ * file with no pi imports means the CLI's own jest can test it `typebox`
9
+ * only resolves inside pi's extension loader, so the extension file stays thin
10
+ * and untested by jest.
11
+ *
12
+ * pi itself has no MCP support by design (its README: "No MCP. … build an
13
+ * extension that adds MCP support"); this bridge IS that extension, so an
14
+ * HTTP MCP server reaches a pi seat only through the client below. A
15
+ * `url`-only entry used to be dropped by the two filters here and in pi.js,
16
+ * which is how a granted pi seat ended up holding the grant broker — a
17
+ * Streamable HTTP server — and getting nothing, silently.
9
18
  */
10
19
 
11
20
  import { spawn } from 'node:child_process';
12
21
 
22
+ /**
23
+ * The grant broker's path. wren's ruling for the daemon-side half of TASK-063:
24
+ * pi confines on no host — `pi.js assertNoSandboxDeclared` refuses a DECLARED
25
+ * sandbox and nothing ever derives one — so a grant broker must not reach a pi
26
+ * seat by ANY route: not the server's projection (the backend refuses it there
27
+ * too), and not an older backend that still projects it, which is this layer's
28
+ * job. Keyed on the PATH and not on the entry's `name`, because the name is
29
+ * whatever the declaration says while the path is the broker's. A url that does
30
+ * not parse is not this predicate's business — the origin rule in
31
+ * `pi.js resolveMcpServers` refuses those before they are carried.
32
+ */
33
+ export const GRANT_BROKER_PATH = '/api/mcp/grants/';
34
+
35
+ /**
36
+ * The reason string BOTH halves of this refusal use, verbatim from the server's
37
+ * typed refusal (`backend/services/grantBrokerConfinement.ts`: code
38
+ * `grant_broker_unconfined`, reason `adapter_cannot_confine`). wren's ruling
39
+ * (69829): "Same reason string on both halves" — so a daemon log line and the
40
+ * server's `grantBrokerRefusal` field can be read side by side, and neither
41
+ * layer grows a vocabulary the other one does not have. Mirrored rather than
42
+ * imported: this package does not depend on the backend, so the literal is
43
+ * pinned by a test instead.
44
+ */
45
+ export const GRANT_BROKER_REFUSAL = 'grant_broker_unconfined: adapter_cannot_confine';
46
+
47
+ /**
48
+ * Is this url the instance's grant broker? Path-keyed, and the comparison is
49
+ * case-INSENSITIVE because the route it mirrors is: express matches paths
50
+ * case-insensitively unless `caseSensitive` is set, so the live API answers
51
+ * `/API/MCP/GRANTS/x` from the same handler as `/api/mcp/grants/x` (measured
52
+ * 2026-09-19: POST both → 401, POST `/api/nothing/x` → 404, i.e. the control is
53
+ * what distinguishes a matched route from a missing one). A case-sensitive
54
+ * predicate therefore refuses the spelled-canonically broker and hands pi the
55
+ * uppercased spelling of it — the real runtime token included. Vera measured
56
+ * exactly that against the running instance (69839).
57
+ *
58
+ * Case is the only spelling the router forgives: `%67rants`, `//`, `./` and
59
+ * `GRANTS%2F` all 404 on the live API, and a trailing slash is already inside
60
+ * the prefix.
61
+ */
62
+ export const isGrantBrokerUrl = (url) => {
63
+ try {
64
+ return new URL(url).pathname.toLowerCase().startsWith(GRANT_BROKER_PATH);
65
+ } catch {
66
+ return false;
67
+ }
68
+ };
69
+
13
70
  const PROTOCOL_VERSION = '2024-11-05';
14
71
  const MAX_TEXT = 50 * 1024;
15
72
 
@@ -17,8 +74,19 @@ const truncate = (text) => (text.length <= MAX_TEXT
17
74
  ? text
18
75
  : `${text.slice(0, MAX_TEXT)}\n… [truncated ${text.length - MAX_TEXT} bytes]`);
19
76
 
77
+ /**
78
+ * Connect to one declared server, stdio or Streamable HTTP. `resolveMcpServers`
79
+ * emits exactly one of `command`/`url` per entry, and `readServers` keeps that
80
+ * invariant, so the shape decides; `url` wins if a hand-built entry still
81
+ * carries both, because a server that advertises an endpoint is not a command
82
+ * to spawn.
83
+ */
84
+ export const connectMcp = (server, opts = {}) => (typeof server?.url === 'string' && server.url
85
+ ? connectHttpMcp(server, opts)
86
+ : connectStdioMcp(server, opts));
87
+
20
88
  /** A minimal MCP stdio client: initialize, tools/list, tools/call. */
21
- export const connectMcp = ({ name, command, env }, { spawnImpl = spawn, timeoutMs = 60_000 } = {}) => {
89
+ export const connectStdioMcp = ({ name, command, env }, { spawnImpl = spawn, timeoutMs = 60_000 } = {}) => {
22
90
  const [cmd, ...args] = command;
23
91
  const proc = spawnImpl(cmd, args, { env: { ...process.env, ...(env || {}) }, stdio: ['pipe', 'pipe', 'pipe'] });
24
92
  const pending = new Map();
@@ -71,6 +139,137 @@ export const connectMcp = ({ name, command, env }, { spawnImpl = spawn, timeoutM
71
139
  return { initialize, listTools, callTool, close, proc };
72
140
  };
73
141
 
142
+ /**
143
+ * Split an SSE body into its `data:` payloads. Streamable HTTP lets a server
144
+ * answer a POST either with one JSON object or with an event stream holding
145
+ * the JSON-RPC messages, so both shapes are parsed.
146
+ */
147
+ const ssePayloads = (text) => text.split(/\r?\n\r?\n/)
148
+ .map((block) => block.split(/\r?\n/)
149
+ .filter((line) => line.startsWith('data:'))
150
+ .map((line) => line.slice(5).trimStart())
151
+ .join('\n'))
152
+ .filter(Boolean);
153
+
154
+ const parseMessages = (text, contentType) => {
155
+ const body = text.trim();
156
+ if (!body) return [];
157
+ if (contentType.includes('text/event-stream')) return ssePayloads(body);
158
+ // A server MAY answer a request with plain JSON regardless of the request's
159
+ // `accept`, so the body decides before the header does.
160
+ if (body.startsWith('{') || body.startsWith('[')) return [body];
161
+ return ssePayloads(body);
162
+ };
163
+
164
+ /**
165
+ * A minimal MCP Streamable HTTP client: initialize, tools/list, tools/call.
166
+ *
167
+ * `headers` is where a declared `Authorization` arrives, already substituted
168
+ * with the seat's runtime token by the adapter. The token therefore rides in an
169
+ * HTTP header built from the JSON list the bridge takes out of its own
170
+ * environment (see takeServers) and never on argv. NOT on argv is the whole of
171
+ * that guarantee: deleting the variable from the bridge's own process scrubs
172
+ * Node's copy, not the kernel's, so a same-user child of this process can still
173
+ * read the parent's environment (`ps eww $PPID`, `/proc/$PPID/environ`) and find
174
+ * both the token and the server list. Treat this as "not in argv" and not as a
175
+ * secrecy boundary; the fix is to hand the list over a 0600 file the bridge
176
+ * unlinks on load (row filed against the bridge's env channel, Vera, Connectors).
177
+ */
178
+ export const connectHttpMcp = ({ name, url, headers }, { fetchImpl = globalThis.fetch, timeoutMs = 60_000 } = {}) => {
179
+ if (typeof fetchImpl !== 'function') throw new Error(`${name}: no fetch implementation for the HTTP MCP transport`);
180
+ const declared = headers || {};
181
+ let nextId = 1;
182
+ let sessionId = null;
183
+ let protocolVersion = PROTOCOL_VERSION;
184
+ let negotiated = false;
185
+ const requestHeaders = () => ({
186
+ 'content-type': 'application/json',
187
+ // Both, per the spec: a server may answer with JSON or with an event stream.
188
+ accept: 'application/json, text/event-stream',
189
+ ...declared,
190
+ ...(sessionId ? { 'mcp-session-id': sessionId } : {}),
191
+ // The version header follows NEGOTIATION, not the session. Our own broker is
192
+ // stateless (`mcpGrants.ts` sets `sessionIdGenerator: undefined`), so it
193
+ // never mints a session id — gating this on one meant the only broker we
194
+ // have never received the version it negotiated (Vera, Connectors).
195
+ ...(negotiated ? { 'mcp-protocol-version': protocolVersion } : {}),
196
+ });
197
+ const post = async (payload) => {
198
+ const controller = new AbortController();
199
+ let timer;
200
+ // Race an explicit timer as well as aborting: a client whose fetch ignores
201
+ // the signal must not leave a seat waiting forever on a dead endpoint.
202
+ const expired = new Promise((_resolve, reject) => {
203
+ timer = setTimeout(() => {
204
+ controller.abort();
205
+ reject(new Error(`${name}: timed out after ${timeoutMs}ms`));
206
+ }, timeoutMs);
207
+ });
208
+ try {
209
+ const res = await Promise.race([fetchImpl(url, {
210
+ method: 'POST',
211
+ headers: requestHeaders(),
212
+ body: JSON.stringify(payload),
213
+ signal: controller.signal,
214
+ // These requests carry the seat token in a header. A redirect is
215
+ // allowed to point at another origin, and whether undici strips an
216
+ // Authorization header when it follows one is not something a seat may
217
+ // rely on — so no redirect is followed at all.
218
+ redirect: 'error',
219
+ }), expired]);
220
+ const text = await res.text();
221
+ const header = res.headers?.get?.('mcp-session-id');
222
+ if (header && !sessionId) sessionId = header;
223
+ return { res, text, contentType: res.headers?.get?.('content-type') || '' };
224
+ } catch (error) {
225
+ if (error?.message?.startsWith(`${name}:`)) throw error;
226
+ throw new Error(`${name}: ${error?.name === 'AbortError' ? `timed out after ${timeoutMs}ms` : `${payload?.method || 'request'} failed: ${error?.message || error}`}`);
227
+ } finally {
228
+ clearTimeout(timer);
229
+ }
230
+ };
231
+ const request = async (method, params) => {
232
+ const id = nextId++;
233
+ const { res, text, contentType } = await post({ jsonrpc: '2.0', id, method, params: params || {} });
234
+ if (!res.ok) throw new Error(`${name}: ${method} failed with HTTP ${res.status}${text ? `: ${truncate(text)}` : ''}`);
235
+ const messages = parseMessages(text, contentType)
236
+ .map((raw) => { try { return JSON.parse(raw); } catch { return null; } })
237
+ .filter(Boolean);
238
+ const answer = messages.find((m) => m.id === id);
239
+ if (!answer) throw new Error(`${name}: ${method} returned no JSON-RPC answer (HTTP ${res.status})`);
240
+ if (answer.error) throw new Error(`${name}: ${answer.error.message || JSON.stringify(answer.error)}`);
241
+ return answer.result;
242
+ };
243
+ const notify = async (method, params) => {
244
+ const { res, text } = await post({ jsonrpc: '2.0', method, params: params || {} });
245
+ // The spec answers a notification with 202 and no body; anything else is
246
+ // an error the seat should see rather than a silent drop.
247
+ if (!res.ok) throw new Error(`${name}: ${method} failed with HTTP ${res.status}${text ? `: ${truncate(text)}` : ''}`);
248
+ };
249
+ const initialize = async () => {
250
+ const result = await request('initialize', {
251
+ protocolVersion: PROTOCOL_VERSION,
252
+ capabilities: {},
253
+ clientInfo: { name: 'commonly-pi-bridge', version: '1.0.0' },
254
+ });
255
+ // The server's answer is authoritative; echo what it negotiated from here on.
256
+ if (result && typeof result.protocolVersion === 'string') protocolVersion = result.protocolVersion;
257
+ negotiated = true;
258
+ await notify('notifications/initialized');
259
+ return result;
260
+ };
261
+ const listTools = async () => (await request('tools/list')).tools || [];
262
+ const callTool = (toolName, args) => request('tools/call', { name: toolName, arguments: args || {} });
263
+ const close = () => {
264
+ if (!sessionId) return;
265
+ try {
266
+ const pending = fetchImpl(url, { method: 'DELETE', headers: requestHeaders(), redirect: 'error' });
267
+ if (pending && typeof pending.catch === 'function') pending.catch(() => {});
268
+ } catch { /* best effort: the session expires on its own */ }
269
+ };
270
+ return { initialize, listTools, callTool, close, sessionId: () => sessionId };
271
+ };
272
+
74
273
  /** MCP `{ content, isError }` → pi tool result. Non-text parts are named, not dropped silently. */
75
274
  export const toPiResult = (result) => {
76
275
  const parts = (result?.content || []).map((c) => (c?.type === 'text' ? String(c.text ?? '') : `[${c?.type || 'content'} omitted]`));
@@ -79,11 +278,18 @@ export const toPiResult = (result) => {
79
278
  };
80
279
 
81
280
  /**
82
- * Read the server list and REMOVE it from the environment. The list carries each
83
- * server's substituted envthe seat's bearer token among it — and pi's `bash`
281
+ * Read the server list and REMOVE it from the environment. The list carries every
282
+ * server's substituted secretsa stdio server's env and an HTTP server's
283
+ * `Authorization` header, the seat's bearer token among them — and pi's `bash`
84
284
  * tool spawns with `{ ...process.env }` (pi's getShellEnv), so leaving it in place
85
- * lets one `env` from the model print the token. The MCP children already hold
86
- * their own env from spawn time; nothing else reads this variable.
285
+ * lets one `env` from the model print the token. The clients already hold what
286
+ * they need from spawn time; nothing else reads this variable.
287
+ *
288
+ * What this does NOT do is hide the value from a process that reads the parent's
289
+ * environment directly: `delete` removes the key from this process's own copy,
290
+ * while the kernel keeps the copy this process was started with, so
291
+ * `ps eww $PPID` on macOS and `/proc/$PPID/environ` on Linux still show it to a
292
+ * same-user child. This closes the accidental vector, not a determined one.
87
293
  */
88
294
  export const takeServers = (env = process.env) => {
89
295
  const servers = readServers(env.COMMONLY_PI_MCP);
@@ -91,8 +297,30 @@ export const takeServers = (env = process.env) => {
91
297
  return servers;
92
298
  };
93
299
 
300
+ /**
301
+ * Entries with a non-empty `command` and no `url` are stdio; entries with a
302
+ * non-empty `url` and no `command` are Streamable HTTP. The two are mutually
303
+ * exclusive on purpose: `resolveMcpServers` emits exactly one field per entry
304
+ * (the transport decides which), so an entry carrying BOTH did not come from it
305
+ * and is dropped rather than spawned — the adapter is the layer that executes,
306
+ * and a shape it cannot classify is not one it should run. Both the adapter
307
+ * (pi.js resolveMcpServers) and this filter have to agree, or a server reaches
308
+ * the bridge as an unstartable entry.
309
+ */
310
+ export const isStdioServer = (s) => Array.isArray(s?.command) && s.command.length > 0
311
+ && !(typeof s?.url === 'string' && s.url.length > 0);
312
+ export const isHttpServer = (s) => typeof s?.url === 'string' && s.url.length > 0
313
+ && !(Array.isArray(s?.command) && s.command.length > 0);
314
+
94
315
  export const readServers = (raw) => {
95
316
  if (!raw) return [];
96
- try { return JSON.parse(raw).filter((s) => s?.name && Array.isArray(s.command) && s.command.length); } catch { return []; }
317
+ try {
318
+ // The broker is dropped here as well as in pi.js (same reason string, see
319
+ // GRANT_BROKER_REFUSAL): this is the last layer before a client is started,
320
+ // and the two filters have to agree or a server reaches the bridge as an
321
+ // entry the adapter would not have carried.
322
+ return JSON.parse(raw).filter((s) => s?.name
323
+ && (isStdioServer(s) || (isHttpServer(s) && !isGrantBrokerUrl(s.url))));
324
+ } catch { return []; }
97
325
  };
98
326
 
@@ -39,6 +39,7 @@ import { homedir } from 'os';
39
39
  import { dirname, join } from 'path';
40
40
  import { fileURLToPath } from 'url';
41
41
  import { buildMemoryPreamble } from '../memory-bridge.js';
42
+ import { GRANT_BROKER_REFUSAL, isGrantBrokerUrl } from './pi-mcp-client.mjs';
42
43
 
43
44
  const DEFAULT_TIMEOUT_MS = (() => {
44
45
  const fallback = 15 * 60 * 1000;
@@ -79,14 +80,135 @@ const substitutePlaceholders = (value, ctx) => {
79
80
  return value.replace(PLACEHOLDER_RE, (whole, key) => (SUBSTITUTION_KEYS.includes(key) && subs[key] ? subs[key] : whole));
80
81
  };
81
82
 
82
- /** stdio MCP servers from the environment spec, placeholders filled; url-only entries are skipped. */
83
- export const resolveMcpServers = (mcpServers, ctx = {}) => (mcpServers || [])
84
- .filter((server) => server?.name && Array.isArray(server.command) && server.command.length)
85
- .map((server) => ({
86
- name: server.name,
87
- command: server.command.map((a) => substitutePlaceholders(a, ctx)),
88
- env: Object.fromEntries(Object.entries(server.env || {}).map(([k, v]) => [k, substitutePlaceholders(v, ctx)])),
89
- }));
83
+ /**
84
+ * The daemon's own predicate, applied verbatim: `auditDeclaredMcp` reads
85
+ * `server.transport || 'stdio'` and compares it as an exact string. It is
86
+ * deliberately not friendlier than the guard's — a normalized `'HTTP'` or a
87
+ * padded `' http '` is a shape the guard refuses as an unknown transport, and an
88
+ * adapter that accepted one would be running something the guard never judged
89
+ * (Vera, Connectors 69776). The schema admits `http`/`stdio`/`sse`
90
+ * (environment.js:236) and admits an ABSENT transport, which the guard reads as
91
+ * stdio; that is what this reads too.
92
+ */
93
+ const transportOf = (server) => server.transport || 'stdio';
94
+
95
+ const originOf = (value) => {
96
+ try {
97
+ return new URL(value).origin;
98
+ } catch {
99
+ return null;
100
+ }
101
+ };
102
+
103
+ /**
104
+ * Declared MCP servers from the environment spec, placeholders filled. Both
105
+ * transports the spec admits are carried through: stdio (`command` + `env`) and
106
+ * Streamable HTTP (`url` + `headers`). An entry with neither is skipped — there
107
+ * is nothing to start.
108
+ *
109
+ * The HTTP half matters beyond a user-declared remote server: the grant broker
110
+ * is a Streamable HTTP server (`agentBinding.ts` grantBrokerServer), so before
111
+ * this pi dropped the one entry that carries a grant to a seat, silently.
112
+ * Filling the headers here reuses the same substitution as the stdio env, and
113
+ * the result rides in COMMONLY_PI_MCP — which the bridge removes from its own
114
+ * process environment at load (see takeServers). That closes the direct vector
115
+ * (pi's `bash` spawns with `{ ...process.env }`, so a bare `env` used to print
116
+ * the token); it is not a secrecy boundary, because deleting the variable
117
+ * scrubs Node's copy and not the kernel's — a same-user child of the bridge can
118
+ * still read this process's environment via `ps eww` / `/proc/$PPID/environ`.
119
+ * The durable fix is to hand the list over a 0600 file the bridge unlinks on
120
+ * load, which is a row against this env channel, not against this PR (Vera,
121
+ * Connectors).
122
+ *
123
+ * WHICH shape an entry becomes is decided by `transport` — the same field, read
124
+ * with the same default and the same exact comparison the daemon's
125
+ * `auditDeclaredMcp` judges it by — and the field that transport does not select
126
+ * is dropped unsent. Classifying by which field is PRESENT instead is a bypass,
127
+ * not a shorthand: an entry declaring `transport: 'http'` with the instance's own
128
+ * url passes the guard (its http rule checks only the url origin, and
129
+ * `${COMMONLY_AGENT_TOKEN}` in the command is one of the known placeholders), and
130
+ * a presence-classifier then ran that command as stdio with the real token
131
+ * substituted. The guard's judgement and the adapter's disagreed, and the adapter
132
+ * is what executes (Vera, Connectors 69774).
133
+ *
134
+ * The http half also enforces the guard's own origin rule, so the HTTP half does
135
+ * not depend on a guard that may not be on the machine: `auditDeclaredMcp`
136
+ * admits a declared http server only when its url resolves to the INSTANCE's
137
+ * origin, because the seat token rides its headers. Everything else — an
138
+ * off-instance host, an unparseable url, an instance url we do not know — is
139
+ * refused here as well. A transport pi cannot speak (`sse`, which the schema and
140
+ * the guard both admit) is refused rather than reinterpreted as one it can.
141
+ *
142
+ * The grant broker is refused outright, by PATH rather than by entry name: it is
143
+ * the one http entry that carries authority rather than data, and pi confines on
144
+ * no host. wren's ruling for TASK-063 (`isGrantBrokerUrl` in pi-mcp-client.mjs),
145
+ * the daemon-side half of the same refusal the server makes at the projection —
146
+ * this is the half that holds on deploy skew, when the row names no adapter for
147
+ * the server to key on, and in any backend older than the refusal.
148
+ *
149
+ * The stdio half does NOT have that property and must not be read as if it did:
150
+ * a declared stdio command is executed with no allowlist check, here and in both
151
+ * sibling adapters, so it depends entirely on the guard — `auditDeclaredMcp`
152
+ * admits only the shipped commonly MCP server or a command already present in the
153
+ * local record, and that rule exists nowhere else (Vera, 69778; TASK-069).
154
+ */
155
+ export const resolveMcpServers = (mcpServers, ctx = {}) => {
156
+ const carried = [];
157
+ for (const server of mcpServers || []) {
158
+ if (!server?.name || typeof server.name !== 'string') continue;
159
+ const transport = transportOf(server);
160
+ if (transport === 'http') {
161
+ if (typeof server.url !== 'string' || !server.url) continue;
162
+ const url = substitutePlaceholders(server.url, ctx);
163
+ const origin = originOf(url);
164
+ const instanceOrigin = originOf(ctx.instanceUrl);
165
+ if (!origin || !instanceOrigin || origin !== instanceOrigin) {
166
+ // eslint-disable-next-line no-console
167
+ console.warn(`[pi] declared MCP server '${server.name}' points at ${origin || '(unparseable)'}, not this instance (${instanceOrigin || 'unknown'}) — not starting it`);
168
+ continue;
169
+ }
170
+ // The grant broker is the one http entry that carries AUTHORITY rather
171
+ // than data: a granted seat acts on external systems as the granter. A pi
172
+ // seat cannot be confined on any host (see assertNoSandboxDeclared), so
173
+ // the reach is refused here as well as at the server's projection — this
174
+ // layer is what holds when the backend predates that refusal, when the
175
+ // row names no adapter for the server to key on, or when a deploy leaves
176
+ // the two on different clocks.
177
+ if (isGrantBrokerUrl(url)) {
178
+ // eslint-disable-next-line no-console
179
+ console.warn(`[pi] ${GRANT_BROKER_REFUSAL} — refusing the grant broker '${server.name}': pi has no enforced sandbox, so this seat must not act with a granter's authority — move the seat to the claude or codex adapter, or remove the grant`);
180
+ continue;
181
+ }
182
+ carried.push({
183
+ name: server.name,
184
+ url,
185
+ headers: Object.fromEntries(Object.entries(server.headers || {}).map(([k, v]) => [k, substitutePlaceholders(v, ctx)])),
186
+ });
187
+ continue;
188
+ }
189
+ if (transport === 'stdio') {
190
+ if (!Array.isArray(server.command) || !server.command.length) {
191
+ // The url-only record that names no transport lands here: the guard reads
192
+ // an absent transport as stdio too, and refuses it for having no command,
193
+ // so the daemon never adopts it and this drop matches that judgement.
194
+ if (typeof server.url === 'string' && server.url) {
195
+ // eslint-disable-next-line no-console
196
+ console.warn(`[pi] declared MCP server '${server.name}' names no transport, so it is judged stdio, and has no command — not starting it`);
197
+ }
198
+ continue;
199
+ }
200
+ carried.push({
201
+ name: server.name,
202
+ command: server.command.map((a) => substitutePlaceholders(a, ctx)),
203
+ env: Object.fromEntries(Object.entries(server.env || {}).map(([k, v]) => [k, substitutePlaceholders(v, ctx)])),
204
+ });
205
+ continue;
206
+ }
207
+ // eslint-disable-next-line no-console
208
+ console.warn(`[pi] declared MCP server '${server.name}' asks for transport '${transport}', which this adapter cannot speak — not starting it`);
209
+ }
210
+ return carried;
211
+ };
90
212
 
91
213
  /** The provider block for models.json: the env spec's `provider` over the LiteLLM default. */
92
214
  export const resolveProvider = (environment = {}) => {