agent-relay-runner 0.89.2 → 0.90.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
- "version": "0.89.2",
3
+ "version": "0.90.0",
4
4
  "description": "Unified provider lifecycle runner for Agent Relay",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,7 +20,7 @@
20
20
  "directory": "runner"
21
21
  },
22
22
  "dependencies": {
23
- "agent-relay-sdk": "0.2.68"
23
+ "agent-relay-sdk": "0.2.69"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/bun": "latest",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
3
  "description": "Thin Agent Relay runner bridge for Claude Code",
4
- "version": "0.89.2",
4
+ "version": "0.90.0",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -13,6 +13,7 @@ import {
13
13
  resolveClaudeProvisionedPlugins,
14
14
  resolveClaudeProvisionedSkills,
15
15
  resolveCodexProvisionedSkills,
16
+ resolveProjectMcpServers,
16
17
  profileProvisioning,
17
18
  } from "./provisioning";
18
19
  import {
@@ -73,6 +74,11 @@ export interface AssembledMcp {
73
74
  strict: boolean;
74
75
  /** Relay-provisioned server names (excludes the relay endpoint itself). */
75
76
  servers: string[];
77
+ /** #667 — project `.mcp.json` server names projected from the spawn cwd's committed
78
+ * `.mcp.json` (via `--mcp-config`, so they survive strict). Sourced from the project,
79
+ * not ambient host MCP. Empty unless the profile/spawn opted into `projectMcp`, and a
80
+ * name already declared by relay-provisioned `servers` wins (not double-listed here). */
81
+ projectServers: string[];
76
82
  /** #662 — host USER-scope MCP server names (`~/.claude.json` `mcpServers`) re-injected
77
83
  * into `--mcp-config` for an `mcp.mode:"host"` profile, so `--strict-mcp-config` keeps
78
84
  * the ambient host MCP (callmux/qmd/gh) instead of dropping it. Empty for non-host mcp
@@ -202,6 +208,20 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
202
208
  const mcpServers = Object.keys(provisionedMcp).filter((name) => name !== RELAY_MCP_SERVER_NAME);
203
209
  const relayEndpoint = profileAllowsRelayFeature(config, "mcp");
204
210
 
211
+ // #667 — project `.mcp.json` projection (opt-in). The spawn cwd's committed servers are
212
+ // injected through the SAME `--mcp-config` channel as relay-provisioned servers, so they
213
+ // survive `--strict-mcp-config` without the ambient project-discovery modal. A relay-
214
+ // provisioned server of the same name wins (the profile is the explicit, relay-owned
215
+ // source); the relay endpoint name is reserved. `declaredMcp` is the union relay injects.
216
+ const projectMcpRaw = resolveProjectMcpServers(config);
217
+ const projectMcp = Object.fromEntries(
218
+ Object.entries(projectMcpRaw).filter(
219
+ ([name]) => name !== RELAY_MCP_SERVER_NAME && !(name in provisionedMcp),
220
+ ),
221
+ );
222
+ const projectServers = Object.keys(projectMcp);
223
+ const declaredMcp = { ...projectMcp, ...provisionedMcp };
224
+
205
225
  // #662 — EVERY managed Claude launch is --strict-mcp-config, not just vanilla. A managed
206
226
  // agent runs headless (no human at the keyboard), so Claude's blocking "N new MCP servers
207
227
  // found in this project — select to enable" modal (raised on UNDECIDED cwd `.mcp.json`
@@ -224,7 +244,7 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
224
244
  && (!config.agentProfile || config.agentProfile.mcp.mode === "host");
225
245
  const hostMcp = usesHostMcp ? hostUserScopeMcpServers() : {};
226
246
  const hostMcpServers = Object.keys(hostMcp).filter(
227
- (name) => name !== RELAY_MCP_SERVER_NAME && !(name in provisionedMcp),
247
+ (name) => name !== RELAY_MCP_SERVER_NAME && !(name in declaredMcp),
228
248
  );
229
249
 
230
250
  const instructions = assembleInstructions(config);
@@ -238,7 +258,7 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
238
258
  relayUrl: config.relayUrl,
239
259
  ...(config.relayMcpEndpoint ? { endpoint: config.relayMcpEndpoint } : {}),
240
260
  includeRelay: relayEndpoint,
241
- servers: provisionedMcp,
261
+ servers: declaredMcp,
242
262
  hostServers: hostMcp,
243
263
  strict: strictMcp,
244
264
  }),
@@ -258,7 +278,7 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
258
278
  statusLine,
259
279
  skills: skills.map((s) => ({ name: s.name, dir: s.dir })),
260
280
  plugins: provisionedPlugins.map((p) => ({ name: p.name, dir: p.dir })),
261
- mcp: { relayEndpoint, strict: strictMcp, servers: mcpServers, hostServers: hostMcpServers },
281
+ mcp: { relayEndpoint, strict: strictMcp, servers: mcpServers, projectServers, hostServers: hostMcpServers },
262
282
  instructions,
263
283
  args,
264
284
  env: configHome ? { CLAUDE_CONFIG_DIR: configHome } : {},
@@ -278,6 +298,18 @@ function assembleCodex(config: RunnerSpawnConfig, _providerConfig: ProviderConfi
278
298
  const provisionedMcp = profileProvisioning(config).mcpServers;
279
299
  const mcpServers = Object.keys(provisionedMcp).filter((name) => name !== RELAY_MCP_SERVER_NAME);
280
300
 
301
+ // #667 — project `.mcp.json` projection (opt-in), same union rule as Claude: a relay-
302
+ // provisioned server of the same name wins; the relay endpoint name is reserved. Codex has
303
+ // no `.mcp.json` discovery of its own, so this is purely additive (no strict/modal concern).
304
+ const projectMcpRaw = resolveProjectMcpServers(config);
305
+ const projectMcp = Object.fromEntries(
306
+ Object.entries(projectMcpRaw).filter(
307
+ ([name]) => name !== RELAY_MCP_SERVER_NAME && !(name in provisionedMcp),
308
+ ),
309
+ );
310
+ const projectServers = Object.keys(projectMcp);
311
+ const declaredMcp = { ...projectMcp, ...provisionedMcp };
312
+
281
313
  const instructions = assembleInstructions(config);
282
314
 
283
315
  // The skills/MCP/project-doc block; core launch config (model/approval/listen) stays
@@ -285,7 +317,7 @@ function assembleCodex(config: RunnerSpawnConfig, _providerConfig: ProviderConfi
285
317
  const args = [
286
318
  ...bundledSkillConfigArgs(skillDirs),
287
319
  ...(relayEndpoint ? relayMcpCodexConfigArgs(config.relayUrl, config.relayMcpEndpoint) : []),
288
- ...codexProvisionedMcpArgs(provisionedMcp),
320
+ ...codexProvisionedMcpArgs(declaredMcp),
289
321
  ...codexProjectDocSuppressionArgs(config),
290
322
  ];
291
323
 
@@ -301,7 +333,7 @@ function assembleCodex(config: RunnerSpawnConfig, _providerConfig: ProviderConfi
301
333
  statusLine: false,
302
334
  skills: provisionedSkills.map((s) => ({ name: s.name, dir: s.dir })),
303
335
  plugins: [],
304
- mcp: { relayEndpoint, strict: false, servers: mcpServers, hostServers: [] },
336
+ mcp: { relayEndpoint, strict: false, servers: mcpServers, projectServers, hostServers: [] },
305
337
  instructions,
306
338
  args,
307
339
  env: configHome ? { CODEX_HOME: configHome } : {},
@@ -33,6 +33,7 @@ export function agentProfileProjectionReport(input: AgentProfileProjectionInput)
33
33
  add(provisioningSkillsEntry(assembled));
34
34
  add(provisioningPluginsEntry(assembled));
35
35
  add(provisioningMcpEntry(assembled));
36
+ add(projectMcpEntry(assembled));
36
37
  if (profile) add(filesystemEntry(profile));
37
38
  add(configHomeEntry(assembled));
38
39
  if (assembled.vanilla) add(vanillaLaunchEntry(provider));
@@ -143,6 +144,20 @@ function provisioningMcpEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
143
144
  : `Relay-provisioned MCP servers emitted as -c mcp_servers.* overrides (${names.join(", ")}).`);
144
145
  }
145
146
 
147
+ // #667 — project `.mcp.json` projection provenance. `not-applicable` when the profile/spawn
148
+ // didn't opt in (the default); `applied` lists the spawn-cwd servers relay injected through
149
+ // the controlled --mcp-config / -c path (sourced from the project, not ambient host MCP).
150
+ function projectMcpEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
151
+ const optedIn = Boolean(a.profile?.projectMcp);
152
+ const names = a.mcp.projectServers;
153
+ if (!optedIn) return notApplicable("projectMcp", "off", "Project .mcp.json projection is off (opt in via the projectMcp profile/spawn option).");
154
+ if (!names.length) return applied("projectMcp", "none", "Project .mcp.json projection is on, but the spawn cwd declared no injectable MCP servers.");
155
+ const label = `${names.length} server${names.length === 1 ? "" : "s"}`;
156
+ return applied("projectMcp", label, a.provider === "claude"
157
+ ? `Project .mcp.json servers composed into --mcp-config (${names.join(", ")})${a.mcp.strict ? "; survives --strict-mcp-config" : ""}.`
158
+ : `Project .mcp.json servers emitted as -c mcp_servers.* overrides (${names.join(", ")}).`);
159
+ }
160
+
146
161
  function globalInstructionsEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
147
162
  const disposition = a.instructions.global;
148
163
  if (disposition === "allow") return applied("instructions.global", "allow", "Host/global provider instructions are left available.");
@@ -1,7 +1,7 @@
1
- import { existsSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
2
2
  import { dirname, isAbsolute, join, resolve } from "node:path";
3
3
  import { sanitizeFsName } from "agent-relay-sdk/fs-name";
4
- import type { ProvisioningAssetFile, ResolvedProvisioning } from "agent-relay-sdk";
4
+ import type { ProvisioningAssetFile, ProvisioningMcpServer, ResolvedProvisioning } from "agent-relay-sdk";
5
5
  import type { RunnerSpawnConfig } from "./adapter";
6
6
 
7
7
  // Runner-side materialization of the relay-resolved provisioning bundle
@@ -24,6 +24,56 @@ export function profileProvisioning(config: Pick<RunnerSpawnConfig, "agentProfil
24
24
  return { skills: p.skills ?? [], plugins: p.plugins ?? [], mcpServers: p.mcpServers ?? {} };
25
25
  }
26
26
 
27
+ // The `mcpServers` keys a Claude `.mcp.json` entry may carry; coerced into the
28
+ // provider-neutral ProvisioningMcpServer shape so the launch assembler projects them the
29
+ // same way it does relay-provisioned servers (Claude `--mcp-config`, Codex `-c mcp_servers.*`).
30
+ function coerceProjectMcpServer(raw: unknown): ProvisioningMcpServer | null {
31
+ if (!raw || typeof raw !== "object") return null;
32
+ const r = raw as Record<string, unknown>;
33
+ const server: ProvisioningMcpServer = {};
34
+ if (r.type === "stdio" || r.type === "http" || r.type === "sse") server.type = r.type;
35
+ if (typeof r.command === "string") server.command = r.command;
36
+ if (Array.isArray(r.args) && r.args.every((a) => typeof a === "string")) server.args = r.args as string[];
37
+ if (r.env && typeof r.env === "object") server.env = r.env as Record<string, string>;
38
+ if (typeof r.url === "string") server.url = r.url;
39
+ if (r.headers && typeof r.headers === "object") server.headers = r.headers as Record<string, string>;
40
+ // Drop an entry that names no transport (neither a command nor a url) — nothing to launch.
41
+ return server.command || server.url ? server : null;
42
+ }
43
+
44
+ // #667 — project `.mcp.json` MCP projection. OPT-IN via the resolved profile's `projectMcp`
45
+ // (set per-spawn or by profile, relay-side). Reads the spawn cwd's COMMITTED `.mcp.json` and
46
+ // returns its `mcpServers` as a provider-neutral record, so the launch assembler injects them
47
+ // through the CONTROLLED `--mcp-config` (Claude) / `-c mcp_servers.*` (Codex) path that
48
+ // survives `--strict-mcp-config` — the project's own servers travel with the repo like they do
49
+ // for a human who clones it, WITHOUT enabling Claude's ambient project-discovery trust modal
50
+ // (which wedges headless agents, the reason #662 made every managed launch strict). The runner
51
+ // is the filesystem authority for the spawn cwd (relay may run on another host), so the read
52
+ // lands here. Best-effort: a missing/malformed file (or one with no `mcpServers`) yields {} and
53
+ // never aborts the launch. Returns {} when projection is OFF.
54
+ export function resolveProjectMcpServers(
55
+ config: Pick<RunnerSpawnConfig, "agentProfile" | "cwd">,
56
+ ): Record<string, ProvisioningMcpServer> {
57
+ if (!config.agentProfile?.projectMcp) return {};
58
+ const file = join(config.cwd, ".mcp.json");
59
+ if (!existsSync(file)) return {};
60
+ let parsed: unknown;
61
+ try {
62
+ parsed = JSON.parse(readFileSync(file, "utf8"));
63
+ } catch (err) {
64
+ console.warn(`project .mcp.json projection: failed to parse ${file}: ${(err as Error).message}`);
65
+ return {};
66
+ }
67
+ const servers = (parsed as { mcpServers?: unknown } | null)?.mcpServers;
68
+ if (!servers || typeof servers !== "object") return {};
69
+ const out: Record<string, ProvisioningMcpServer> = {};
70
+ for (const [name, raw] of Object.entries(servers as Record<string, unknown>)) {
71
+ const server = coerceProjectMcpServer(raw);
72
+ if (server) out[name] = server;
73
+ }
74
+ return out;
75
+ }
76
+
27
77
  // A resolved provisioning asset: WHERE it lands (`dir`) and HOW it materializes
28
78
  // (`source`). `files` writes the inline set into `dir`; `link` symlinks a pre-existing
29
79
  // source dir at `dir`; `inPlace` uses the source dir as-is (no write, dir IS the source).
@@ -39,6 +39,7 @@ function launchInjectionEvents(input: RunnerLaunchInjectionInput): KeyedRelayInj
39
39
  if (!supportsLaunchAssembly(input.provider)) return events;
40
40
  const assembled = assembleLaunch(input.provider, input.config, input.providerConfig);
41
41
  const toolCount = assembled.skills.length + assembled.plugins.length + assembled.mcp.servers.length
42
+ + assembled.mcp.projectServers.length
42
43
  + (assembled.relaySkills ? 1 : 0)
43
44
  + (assembled.relayPlugin ? 1 : 0)
44
45
  + (assembled.mcp.relayEndpoint ? 1 : 0)
@@ -52,6 +53,7 @@ function launchInjectionEvents(input: RunnerLaunchInjectionInput): KeyedRelayInj
52
53
  assembled.skills.length ? `${assembled.skills.length} skill${assembled.skills.length === 1 ? "" : "s"}` : "",
53
54
  assembled.plugins.length ? `${assembled.plugins.length} plugin${assembled.plugins.length === 1 ? "" : "s"}` : "",
54
55
  assembled.mcp.servers.length ? `${assembled.mcp.servers.length} MCP server${assembled.mcp.servers.length === 1 ? "" : "s"}` : "",
56
+ assembled.mcp.projectServers.length ? `${assembled.mcp.projectServers.length} project MCP server${assembled.mcp.projectServers.length === 1 ? "" : "s"}` : "",
55
57
  ].filter(Boolean);
56
58
  events.push({
57
59
  key: "tools",