agent-relay-server 0.89.1 → 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/docs/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Relay API",
5
- "version": "0.89.1",
5
+ "version": "0.90.0",
6
6
  "description": "Real-time message bus for inter-agent communication. Agent-first: this spec is designed for machine consumption — agents can self-discover the full API surface via GET /api/spec.",
7
7
  "license": {
8
8
  "name": "MIT",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-server",
3
- "version": "0.89.1",
3
+ "version": "0.90.0",
4
4
  "description": "Lightweight HTTP message relay for inter-agent communication across machines",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -33,7 +33,7 @@
33
33
  "CONTRIBUTING.md"
34
34
  ],
35
35
  "dependencies": {
36
- "agent-relay-sdk": "0.2.68",
36
+ "agent-relay-sdk": "0.2.69",
37
37
  "ajv": "^8.20.0"
38
38
  },
39
39
  "scripts": {
@@ -5,6 +5,7 @@ import { DEFAULT_QUOTA_THRESHOLD_CONFIG, validateQuotaThresholdConfig } from "./
5
5
  import { DEFAULT_SELF_RESUME_CONFIG, validateSelfResumeConfig } from "./self-resume-config";
6
6
  import { TEAM_TEMPLATE_NAMESPACE, validateTeamTemplate } from "./team-template-config";
7
7
  import { PROJECT_INSTRUCTION_POLICY_NAMES } from "./project-instructions";
8
+ import { PROJECT_MCP_POLICY_NAMES } from "./project-mcp";
8
9
  import { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog";
9
10
  import { errMessage, isRecord, SPAWN_PROVIDERS, VALID_WORKSPACE_MODES } from "agent-relay-sdk"; import { effectiveProviderCatalogList } from "./provider-catalog-store";
10
11
  import type {
@@ -225,6 +226,17 @@ function cleanAgentProfileProjectInstructions(value: unknown): AgentProfile["pro
225
226
  return { policy: cleanEnum(policy, "projectInstructions.policy", PROJECT_INSTRUCTION_POLICY_NAMES) as NonNullable<AgentProfile["projectInstructions"]>["policy"] };
226
227
  }
227
228
 
229
+ // #667 — project `.mcp.json` projection profile default. Mirrors projectInstructions:
230
+ // boolean | { policy } → normalized { policy? } | undefined (OFF).
231
+ function cleanAgentProfileProjectMcp(value: unknown): AgentProfile["projectMcp"] {
232
+ if (value === undefined || value === null || value === false) return undefined;
233
+ if (value === true) return {};
234
+ if (!isRecord(value)) throw new ValidationError("projectMcp must be a boolean or { policy } object");
235
+ const policy = value.policy;
236
+ if (policy === undefined || policy === null) return {};
237
+ return { policy: cleanEnum(policy, "projectMcp.policy", PROJECT_MCP_POLICY_NAMES) as NonNullable<AgentProfile["projectMcp"]>["policy"] };
238
+ }
239
+
228
240
  function agentProfileDefaults(input: Pick<AgentProfile, "name" | "base"> & Partial<AgentProfile>): AgentProfile {
229
241
  const isolated = input.base !== "host";
230
242
  // "vanilla" (#552) additionally suppresses the REPO instruction cascade
@@ -245,6 +257,7 @@ function agentProfileDefaults(input: Pick<AgentProfile, "name" | "base"> & Parti
245
257
  globalInstructions: input.instructions?.globalInstructions ?? (isolated ? "ignore" : "allow"),
246
258
  },
247
259
  ...(input.projectInstructions ? { projectInstructions: input.projectInstructions } : {}),
260
+ ...(input.projectMcp ? { projectMcp: input.projectMcp } : {}),
248
261
  relay: {
249
262
  context: input.relay?.context ?? !isolated,
250
263
  skills: input.relay?.skills ?? !isolated,
@@ -319,6 +332,7 @@ function validateAgentProfile(key: string, value: unknown): AgentProfile {
319
332
  : cleanEnum(instructions.globalInstructions, "instructions.globalInstructions", VALID_PROFILE_INSTRUCTION_POLICIES),
320
333
  },
321
334
  projectInstructions: cleanAgentProfileProjectInstructions(value.projectInstructions),
335
+ projectMcp: cleanAgentProfileProjectMcp(value.projectMcp),
322
336
  relay: {
323
337
  context: relay.context === undefined ? defaults.relay.context : cleanBoolean(relay.context, "relay.context"),
324
338
  skills: relay.skills === undefined ? defaults.relay.skills : cleanBoolean(relay.skills, "relay.skills"),
@@ -5,6 +5,9 @@ import { parseStringArray } from "./shared.ts";
5
5
  import type { AgentCard, ProviderQuotaError, ProviderQuotaMatrix, ProviderQuotaRecord, ProviderQuotaSnapshot, ProviderQuotaUpdateInput, QuotaState } from "../types";
6
6
 
7
7
  const PROVIDER_QUOTA_STALE_AFTER_MS = 10 * 60_000;
8
+ const QUOTA_UTILIZATION_EPSILON = 1e-9;
9
+ const QUOTA_RESET_BOUNDARY_ADVANCE_MS = 5_000;
10
+ const QUOTA_RESET_UTILIZATION_MAX = 0.01;
8
11
 
9
12
  export interface ProviderQuotaAdvisoryInput {
10
13
  provider: string;
@@ -193,6 +196,30 @@ function quotaWindowKey(window: QuotaState["windows"][number]): string {
193
196
  return `${window.name}:${window.unit}`;
194
197
  }
195
198
 
199
+ function quotaResetBoundaryAdvanced(existing: QuotaState["windows"][number], incoming: QuotaState["windows"][number]): boolean {
200
+ return quotaResetBoundaryMoved(existing, incoming)
201
+ && incoming.resetsAt! - existing.resetsAt! > QUOTA_RESET_BOUNDARY_ADVANCE_MS;
202
+ }
203
+
204
+ function quotaResetBoundaryMoved(existing: QuotaState["windows"][number], incoming: QuotaState["windows"][number]): boolean {
205
+ return typeof existing.resetsAt === "number"
206
+ && Number.isFinite(existing.resetsAt)
207
+ && typeof incoming.resetsAt === "number"
208
+ && Number.isFinite(incoming.resetsAt)
209
+ && incoming.resetsAt > existing.resetsAt;
210
+ }
211
+
212
+ function quotaWindowLooksReset(existing: QuotaState["windows"][number], incoming: QuotaState["windows"][number]): boolean {
213
+ return incoming.utilization <= QUOTA_RESET_UTILIZATION_MAX
214
+ && quotaResetBoundaryMoved(existing, incoming);
215
+ }
216
+
217
+ function quotaWindowUtilizationRegressed(existing: QuotaState["windows"][number], incoming: QuotaState["windows"][number]): boolean {
218
+ return incoming.utilization + QUOTA_UTILIZATION_EPSILON < existing.utilization
219
+ && !quotaResetBoundaryAdvanced(existing, incoming)
220
+ && !quotaWindowLooksReset(existing, incoming);
221
+ }
222
+
196
223
  function publicQuotaState(quota: StoredQuotaState): QuotaState {
197
224
  return {
198
225
  windows: quota.windows.map((window) => ({ ...window })),
@@ -283,6 +310,10 @@ function mergeQuotaWindows(existing: StoredQuotaState | undefined, incoming: Quo
283
310
  mergedTimes.set(key, existingUpdatedAt);
284
311
  return window;
285
312
  }
313
+ if (quotaWindowUtilizationRegressed(window, incomingWindow)) {
314
+ mergedTimes.set(key, existingUpdatedAt);
315
+ return window;
316
+ }
286
317
  acceptedIncomingWindow = true;
287
318
  updatedAt = Math.max(updatedAt, incoming.updatedAt);
288
319
  source = incoming.source;
@@ -427,7 +458,7 @@ export function upsertProviderQuota(input: ProviderQuotaUpdateInput, now = Date.
427
458
  provider,
428
459
  accountKey,
429
460
  previousQuota: existingQuota ? publicQuotaState(existingQuota) : undefined,
430
- quota: input.quota,
461
+ quota: publicQuotaState(mergedQuota.quota),
431
462
  sourceAgentIds,
432
463
  previousAdvisoryState: existing?.quota_advisory_state,
433
464
  now,
@@ -4,6 +4,7 @@ import { ShutdownAuthError, ShutdownTargetError, shutdownAgent } from "../servic
4
4
  import { buildSpawnTargets } from "../spawn-targets";
5
5
  import { McpAuthError, McpNotFoundError } from "../mcp-errors";
6
6
  import { parseProjectInstructions, PROJECT_INSTRUCTION_POLICY_NAMES } from "../project-instructions";
7
+ import { parseProjectMcp, PROJECT_MCP_POLICY_NAMES } from "../project-mcp";
7
8
  import { countLiveSpawnedAgents, listAgents, listWorkspaces, ValidationError } from "../db";
8
9
  import { listCommands } from "../commands-db";
9
10
  import { DEFAULT_SPAWN_PROFILE_NAME } from "../config-store";
@@ -34,6 +35,7 @@ export const SPAWN_TOOLS: ToolDefinition[] = [
34
35
  expectReply: { type: "boolean", description: "Legacy compatibility flag. Spawned workers now report up automatically: each turn-final response is routed to the spawner by lineage, without a reply obligation or idle nag." },
35
36
  systemPromptAppend: { type: "string" },
36
37
  projectInstructions: { type: "object", description: "#409 — project instruction projection (default OFF). When set, relay composes a knowledge preamble from the spawn cwd's resolved project (own + inherited entries) and prepends it to systemPromptAppend, instead of relying on host CLAUDE.md. `policy: \"all\"` includes ingested host content; `\"vanilla\"` excludes it (manual+promoted only). Omit to keep current behavior.", properties: { policy: { type: "string", enum: PROJECT_INSTRUCTION_POLICY_NAMES } }, additionalProperties: false },
38
+ projectMcp: { type: "object", description: "#667 — project `.mcp.json` MCP projection (default OFF). When set, the worker gets the MCP servers committed in the spawn cwd's `.mcp.json` (e.g. an app's playtest/manuscript servers), injected by relay through the controlled `--mcp-config` path that survives `--strict-mcp-config` — sourced from the project, not ambient host MCP, and without triggering Claude's project-trust modal. `policy: \"all\"` (the default) injects every declared server. Omit to keep current host-only behavior.", properties: { policy: { type: "string", enum: PROJECT_MCP_POLICY_NAMES } }, additionalProperties: false },
37
39
  profile: { type: "string", description: `Agent profile name to apply (env, instructions, permissions, MCP/skills, spawn quota). Omit to use \`${DEFAULT_SPAWN_PROFILE_NAME}\`.` },
38
40
  tags: { type: "array", items: { type: "string" } },
39
41
  capabilities: { type: "array", items: { type: "string" } },
@@ -108,6 +110,7 @@ export async function relaySpawnAgent(auth: McpAuthContext, args: Record<string,
108
110
  expectReply: typeof args.expectReply === "boolean" ? args.expectReply : undefined,
109
111
  systemPromptAppend: optionalString(args.systemPromptAppend, "systemPromptAppend", 64_000),
110
112
  projectInstructions: parseProjectInstructions(args.projectInstructions, (m) => new ValidationError(m)),
113
+ projectMcp: parseProjectMcp(args.projectMcp, (m) => new ValidationError(m)),
111
114
  tags: optionalStringArray(args.tags, "tags"),
112
115
  capabilities: optionalStringArray(args.capabilities, "capabilities"),
113
116
  providerArgs: optionalStringArray(args.providerArgs, "providerArgs"),
@@ -0,0 +1,52 @@
1
+ // Project `.mcp.json` MCP projection (#667) — the relay-owned, OPT-IN path that hands a
2
+ // spawned worker its spawn-cwd's COMMITTED `.mcp.json` servers, sourced from the PROJECT
3
+ // (in-repo, trusted) rather than ambient host MCP. Sibling seam to project-instructions
4
+ // (#409): same opt-in + projection shape, default OFF.
5
+ //
6
+ // PROVENANCE, NOT TYPE: "vanilla/zero-host-bleed" means relay OWNS provisioning and sources
7
+ // it from the project, never the host. A repo's `.mcp.json` is part of the code the agent
8
+ // works in; it should travel with the repo like it does for a human who clones it.
9
+ //
10
+ // DIVISION OF LABOR: relay only carries the OPT-IN DECISION (this module + the spawn
11
+ // param/profile field). The actual file read + `--mcp-config` injection happens RUNNER-side
12
+ // (launch-assembly.ts → resolveProjectMcpServers), which is the filesystem authority for the
13
+ // spawn cwd (relay may run on a different host than the orchestrator). It composes with #662
14
+ // `--strict-mcp-config` — the controlled inject path strict PRESERVES — and deliberately does
15
+ // NOT enable Claude's ambient project-`.mcp.json` discovery, whose trust modal wedges headless
16
+ // agents (the very reason #662 added strict to all managed launches).
17
+
18
+ export const PROJECT_MCP_POLICY_NAMES = ["all"] as const;
19
+ export type ProjectMcpPolicyName = (typeof PROJECT_MCP_POLICY_NAMES)[number];
20
+
21
+ /** Per-spawn projection request. Presence (a non-undefined value) means "inject project
22
+ * MCP"; absence means OFF. `policy` selects the server set (`all` = every declared server;
23
+ * the sole value today). */
24
+ export interface ProjectMcpPolicy {
25
+ policy?: ProjectMcpPolicyName;
26
+ }
27
+
28
+ /**
29
+ * Parse a spawn-transport's `projectMcp` wire value into the normalized policy (or
30
+ * undefined = OFF). Shared by the MCP and HTTP spawn paths so they cannot drift. Mirrors
31
+ * parseProjectInstructions exactly:
32
+ * - undefined / null / false → undefined (OFF)
33
+ * - true → {} (ON, default policy)
34
+ * - { policy } → validated policy
35
+ * Throws `onInvalid(message)` for a malformed shape or unknown policy name.
36
+ */
37
+ export function parseProjectMcp(
38
+ value: unknown,
39
+ onInvalid: (message: string) => Error,
40
+ ): ProjectMcpPolicy | undefined {
41
+ if (value === undefined || value === null || value === false) return undefined;
42
+ if (value === true) return {};
43
+ if (typeof value !== "object") {
44
+ throw onInvalid("projectMcp must be a boolean or { policy } object");
45
+ }
46
+ const policy = (value as Record<string, unknown>).policy;
47
+ if (policy === undefined || policy === null) return {};
48
+ if (typeof policy !== "string" || !PROJECT_MCP_POLICY_NAMES.includes(policy as ProjectMcpPolicyName)) {
49
+ throw onInvalid(`projectMcp.policy must be one of: ${PROJECT_MCP_POLICY_NAMES.join(", ")}`);
50
+ }
51
+ return { policy: policy as ProjectMcpPolicyName };
52
+ }
@@ -32,6 +32,7 @@ type AgentRecipientRow = {
32
32
 
33
33
  const RESET_ROUNDING_PERCENT = 99;
34
34
  const QUOTA_THRESHOLD_EPSILON = 1e-9;
35
+ const QUOTA_RESET_BOUNDARY_ADVANCE_MS = 5_000;
35
36
 
36
37
  export function installQuotaAdvisoryHandler(): void {
37
38
  setProviderQuotaAdvisoryHandler(handleProviderQuotaAdvisory);
@@ -46,7 +47,7 @@ function handleProviderQuotaAdvisory(input: ProviderQuotaAdvisoryInput): string
46
47
  const previousWindow = input.previousQuota?.windows.find((candidate) => candidate.name === window.name);
47
48
  const remaining = quotaRemaining(window.utilization);
48
49
  const remainingPercent = percent(remaining);
49
- const previous = nextState[window.name];
50
+ let previous = nextState[window.name];
50
51
  const reset = hasLastQuotaAdvisory(previous) && isQuotaReset(remaining, previousWindow, window, config.resetRemaining);
51
52
  if (reset) {
52
53
  notifyQuotaAdvisory({
@@ -65,8 +66,12 @@ function handleProviderQuotaAdvisory(input: ProviderQuotaAdvisoryInput): string
65
66
  continue;
66
67
  }
67
68
 
69
+ if (previous && hasLastQuotaAdvisory(previous) && quotaAdvisoryResetBoundaryAdvanced(previous, window)) {
70
+ delete nextState[window.name];
71
+ previous = undefined;
72
+ }
73
+
68
74
  if (remaining - config.warnRemaining > QUOTA_THRESHOLD_EPSILON) {
69
- if (previous) delete nextState[window.name];
70
75
  continue;
71
76
  }
72
77
 
@@ -101,6 +106,17 @@ function hasLastQuotaAdvisory(previous: QuotaAdvisoryWindowState | undefined): b
101
106
  return previous?.lastQuotaAdvisoryStage !== undefined || previous?.lastQuotaAdvisoryBand !== undefined;
102
107
  }
103
108
 
109
+ function quotaAdvisoryResetBoundaryAdvanced(
110
+ previous: QuotaAdvisoryWindowState,
111
+ window: QuotaState["windows"][number],
112
+ ): boolean {
113
+ return typeof previous.lastResetsAt === "number"
114
+ && Number.isFinite(previous.lastResetsAt)
115
+ && typeof window.resetsAt === "number"
116
+ && Number.isFinite(window.resetsAt)
117
+ && window.resetsAt - previous.lastResetsAt > QUOTA_RESET_BOUNDARY_ADVANCE_MS;
118
+ }
119
+
104
120
  function lastQuotaAdvisoryStage(
105
121
  previous: QuotaAdvisoryWindowState | undefined,
106
122
  criticalRemaining: number,
@@ -7,6 +7,7 @@ import { cleanMeta, cleanNullableString, cleanString, cleanStringArray, optional
7
7
  import { registerAgent } from "../services/register-agent";
8
8
  import { spawnAgent, type SpawnAgentInput } from "../services/spawn-agent";
9
9
  import { parseProjectInstructions } from "../project-instructions";
10
+ import { parseProjectMcp } from "../project-mcp";
10
11
  import { authContextFromRequest } from "../services/auth-context";
11
12
  import { listHostDirectories } from "../agent-spawn";
12
13
  import { rankRouteCandidates, type RouteAdvisorInput } from "../context-router";
@@ -121,6 +122,7 @@ export const postAgentSpawn: Handler = async (req) => {
121
122
  prompt: cleanString(parsed.body.prompt, "prompt", { max: 16000 }),
122
123
  systemPromptAppend: cleanString(parsed.body.systemPromptAppend, "systemPromptAppend", { max: 64_000 }),
123
124
  projectInstructions: parseProjectInstructions(parsed.body.projectInstructions, (m) => new ValidationError(m)),
125
+ projectMcp: parseProjectMcp(parsed.body.projectMcp, (m) => new ValidationError(m)),
124
126
  tags: cleanStringArray(parsed.body.tags, "tags", { itemMax: 80, maxItems: 50 }),
125
127
  capabilities: cleanStringArray(parsed.body.capabilities, "capabilities", { itemMax: 80, maxItems: 50 }),
126
128
  providerArgs: cleanStringArray(parsed.body.providerArgs, "providerArgs", { itemMax: 80, maxItems: 50 }),
@@ -1,7 +1,8 @@
1
1
  import { isRecord } from "agent-relay-sdk";
2
- import { acquireProviderQuotaLease, getOrchestrator, listProviderQuotas, releaseProviderQuotaLease, upsertProviderQuota } from "../db";
2
+ import { acquireProviderQuotaLease, getOrchestrator, getProviderQuotaLease, listProviderQuotas, releaseProviderQuotaLease, upsertProviderQuota } from "../db";
3
3
  import { emitRelayEvent } from "../events";
4
4
  import { quotaStateFromUnknown, providerQuotaErrorFromUnknown } from "../quota";
5
+ import { authContextFromRequest } from "../services/auth-context";
5
6
  import { cleanString } from "../validation";
6
7
  import { authorizeRoute, error, json, parseBody, parseQueryInt, type Handler } from "./_shared";
7
8
  import type { ProviderQuotaUpdateInput } from "../types";
@@ -45,6 +46,8 @@ export const postProviderQuotaRoute: Handler = async (req) => {
45
46
  if (!parsed.ok) return error(parsed.error, parsed.status);
46
47
  const input = cleanProviderQuotaUpdate(parsed.body);
47
48
  if (typeof input === "string") return error(input);
49
+ const providerWriteGate = authorizeProviderQuotaWrite(req, input);
50
+ if (providerWriteGate) return providerWriteGate;
48
51
  const record = upsertProviderQuota(input);
49
52
  emitRelayEvent({ type: "provider-quota.updated", source: "server", subject: `${record.provider}:${record.accountKey}`, data: {} });
50
53
  return json(record, 201);
@@ -82,12 +85,36 @@ function cleanPublisherLeaseBody(body: unknown): {
82
85
 
83
86
  const runnerLeaseHolderId = (sourceAgentId: string) => `runner:${sourceAgentId}`;
84
87
 
88
+ function authorizeProviderQuotaSourceAgent(req: Request, sourceAgentId: string): Response | null {
89
+ const ctx = authContextFromRequest(req);
90
+ if (ctx.component?.role !== "provider") return null;
91
+ const denied = authorizeRoute(req, { scope: "quota:write" });
92
+ if (denied) return denied;
93
+ return ctx.callerAgentId === sourceAgentId ? null : error("forbidden", 403);
94
+ }
95
+
96
+ function authorizeProviderQuotaWrite(req: Request, input: ProviderQuotaUpdateInput): Response | null {
97
+ const ctx = authContextFromRequest(req);
98
+ if (ctx.component?.role !== "provider") return null;
99
+ if (!input.sourceAgentId) return error("sourceAgentId required for provider quota writer", 403);
100
+ const denied = authorizeProviderQuotaSourceAgent(req, input.sourceAgentId);
101
+ if (denied) return denied;
102
+ const lease = getProviderQuotaLease(input.provider, input.accountKey);
103
+ const activeLease = lease && lease.expiresAt > Date.now() ? lease : null;
104
+ if (activeLease?.orchestratorId === runnerLeaseHolderId(input.sourceAgentId)) return null;
105
+ return json({
106
+ skipped: true,
107
+ reason: "provider quota lease not held by source agent",
108
+ ...(activeLease ? { holder: { orchestratorId: activeLease.orchestratorId, expiresAt: activeLease.expiresAt } } : {}),
109
+ }, 202);
110
+ }
111
+
85
112
  export const postProviderQuotaPublisherLeaseAcquireRoute: Handler = async (req) => {
86
113
  const parsed = await parseBody<unknown>(req);
87
114
  if (!parsed.ok) return error(parsed.error, parsed.status);
88
115
  const input = cleanPublisherLeaseBody(parsed.body);
89
116
  if (typeof input === "string") return error(input);
90
- const denied = authorizeRoute(req, { scope: "quota:write", resource: { agentId: input.sourceAgentId } });
117
+ const denied = authorizeProviderQuotaSourceAgent(req, input.sourceAgentId);
91
118
  if (denied) return denied;
92
119
  return json(acquireProviderQuotaLease({
93
120
  provider: input.provider,
@@ -104,7 +131,7 @@ export const postProviderQuotaPublisherLeaseReleaseRoute: Handler = async (req)
104
131
  const input = cleanPublisherLeaseBody(parsed.body);
105
132
  if (typeof input === "string") return error(input);
106
133
  if (!input.leaseToken) return error("leaseToken required");
107
- const denied = authorizeRoute(req, { scope: "quota:write", resource: { agentId: input.sourceAgentId } });
134
+ const denied = authorizeProviderQuotaSourceAgent(req, input.sourceAgentId);
108
135
  if (denied) return denied;
109
136
  return json({
110
137
  released: releaseProviderQuotaLease({
@@ -33,6 +33,7 @@ import { runnerRuntimeTokenEnv } from "../runtime-tokens";
33
33
  import { selectSpawnOrchestrator } from "../spawn-targets";
34
34
  import { DEFAULT_SPAWN_PROFILE_NAME, getAgentProfile } from "../config-store";
35
35
  import { composeSpawnInstructionsWithEvents, type ProjectInstructionPolicy } from "../project-instructions";
36
+ import type { ProjectMcpPolicy } from "../project-mcp";
36
37
  import { autoSeedProjectForCwd } from "./project";
37
38
  import { hasComponentScope, isComponentAuthorizedFor } from "../security";
38
39
  import { isPathWithinBase } from "../utils";
@@ -74,6 +75,15 @@ export interface SpawnAgentInput {
74
75
  * `vanilla` (manual+promoted; excludes `ingested` host-CLAUDE.md content).
75
76
  */
76
77
  projectInstructions?: ProjectInstructionPolicy;
78
+ /**
79
+ * #667 — project `.mcp.json` MCP projection. When set, the runner reads the spawn cwd's
80
+ * COMMITTED `.mcp.json` and injects those servers via the controlled `--mcp-config`
81
+ * (composing with #662 `--strict-mcp-config`), so a worker spawned into a repo gets that
82
+ * repo's MCP servers — sourced from the project, never ambient host MCP. Additive-from-
83
+ * empty + opt-in: absent (undefined) = OFF → the launch is byte-identical to today. Sibling
84
+ * to projectInstructions; carried to the runner on the in-flight `agentProfile.projectMcp`.
85
+ */
86
+ projectMcp?: ProjectMcpPolicy;
77
87
  /**
78
88
  * Legacy report-back contract flag. Spawned children now report up by lineage: their
79
89
  * turn-final response is delivered to the spawner automatically, without a reply obligation.
@@ -170,6 +180,14 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
170
180
  const agentProfile = withResolvedProvisioning(getAgentProfile(profileName)?.value, input.provider);
171
181
  if (!agentProfile) throw new McpNotFoundError(input.profile ? "agent profile not found" : `default agent profile ${DEFAULT_SPAWN_PROFILE_NAME} not found`);
172
182
 
183
+ // #667 — project `.mcp.json` projection: a per-spawn `projectMcp` overrides the profile
184
+ // default. The runner does the filesystem read + injection from `agentProfile.projectMcp`
185
+ // (it owns the spawn cwd), so thread the resolved decision onto the in-flight profile copy
186
+ // (withResolvedProvisioning returned a fresh object; mutating it never touches the cached
187
+ // config). Like projectInstructions, an explicit `false` parses to undefined and so cannot
188
+ // turn a profile default OFF — opt-in is the trust gate, not opt-out.
189
+ if (input.projectMcp !== undefined) agentProfile.projectMcp = input.projectMcp;
190
+
173
191
  // --- THE GATE (uniform across transports) ---------------------------------
174
192
  // Coarse capability: spawning requires command:spawn. The MCP dispatcher already enforced
175
193
  // this; the HTTP route did not — asserting here makes it transport-invariant.