@wix/pathgrade 0.32.0 → 0.33.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.
@@ -165,6 +165,26 @@ function buildToolEvent(block, turnNumber, answerStore) {
165
165
  const providerToolName = String(block.name || 'unknown');
166
166
  const rawInput = block.input ?? undefined;
167
167
  const toolUseId = typeof block.id === 'string' ? block.id : undefined;
168
+ const mcpTool = parseClaudeSdkMcpToolName(providerToolName);
169
+ if (mcpTool) {
170
+ const normalizedProviderToolName = `${mcpTool.server}.${mcpTool.tool}`;
171
+ const args = {
172
+ ...(rawInput ?? {}),
173
+ server: mcpTool.server,
174
+ tool: mcpTool.tool,
175
+ status: 'completed',
176
+ };
177
+ return {
178
+ action: 'mcp_tool_call',
179
+ provider: 'claude',
180
+ providerToolName: normalizedProviderToolName,
181
+ turnNumber,
182
+ arguments: args,
183
+ summary: `MCP tool ${normalizedProviderToolName} completed`,
184
+ confidence: 'high',
185
+ rawSnippet: JSON.stringify(block).slice(0, 200),
186
+ };
187
+ }
168
188
  const action = TOOL_NAME_MAP[providerToolName] ?? 'unknown';
169
189
  const args = action === 'ask_user'
170
190
  ? buildAskUserArguments(rawInput, answerStore?.get(toolUseId))
@@ -182,6 +202,12 @@ function buildToolEvent(block, turnNumber, answerStore) {
182
202
  rawSnippet,
183
203
  };
184
204
  }
205
+ function parseClaudeSdkMcpToolName(providerToolName) {
206
+ const match = providerToolName.match(/^mcp__([^_].*?)__(.+)$/);
207
+ if (!match)
208
+ return undefined;
209
+ return { server: match[1], tool: match[2] };
210
+ }
185
211
  /**
186
212
  * Boundary with the ask-user bridge.
187
213
  *
@@ -1,5 +1,5 @@
1
1
  import type { CanUseTool, Options, SpawnedProcess, SpawnOptions as SdkSpawnOptions } from '@anthropic-ai/claude-agent-sdk';
2
- import type { McpServersObject } from '../../providers/mcp-config.js';
2
+ import type { McpServersObject } from '../../providers/mcp-runtime-mounting.js';
3
3
  export interface ClaudeSdkOptionsInputs {
4
4
  /** Per-trial workspace; cwd for the SDK so project-staged skills resolve. */
5
5
  workspacePath: string;
@@ -31,8 +31,10 @@ export interface ClaudeSdkOptionsInputs {
31
31
  * `session_id` on every turn after.
32
32
  */
33
33
  resume?: string;
34
- /** MCP servers in the SDK's object form, from `loadMcpServersForSdk`. */
34
+ /** MCP servers in the SDK's object form, from `mountMcpForClaudeSdk`. */
35
35
  mcpServers?: McpServersObject;
36
+ /** Per-turn cancellation controller linked to PathGrade's turn timeout. */
37
+ abortController?: AbortController;
36
38
  }
37
39
  /**
38
40
  * Pick the Claude executable. Precedence:
@@ -58,6 +58,8 @@ export function buildClaudeSdkOptions(inputs) {
58
58
  opts.resume = inputs.resume;
59
59
  if (inputs.mcpServers !== undefined)
60
60
  opts.mcpServers = inputs.mcpServers;
61
+ if (inputs.abortController !== undefined)
62
+ opts.abortController = inputs.abortController;
61
63
  // Env composition ownership: the driver does NOT pluck specific keys.
62
64
  // `prepareWorkspace` curates the runtime env (safe host vars, sandbox
63
65
  // HOME/TMPDIR, resolveCredentials() output, user-supplied
@@ -8,7 +8,7 @@
8
8
  * - `sandboxedClaudeSpawn` — `Options.spawnClaudeCodeProcess` adapter
9
9
  * that filters env and (optionally) wraps
10
10
  * argv with macOS sandbox-exec.
11
- * - `loadMcpServersForSdk` — reads pathgrade's MCP config JSON into
11
+ * - `mountMcpForClaudeSdk` — reads pathgrade's MCP config JSON into
12
12
  * the SDK's `Options.mcpServers` shape.
13
13
  * - `buildClaudeSdkOptions` — pure builder for the per-turn `Options`.
14
14
  * - `createAskUserBridge` — live `canUseTool` that auto-allows
@@ -8,7 +8,7 @@
8
8
  * - `sandboxedClaudeSpawn` — `Options.spawnClaudeCodeProcess` adapter
9
9
  * that filters env and (optionally) wraps
10
10
  * argv with macOS sandbox-exec.
11
- * - `loadMcpServersForSdk` — reads pathgrade's MCP config JSON into
11
+ * - `mountMcpForClaudeSdk` — reads pathgrade's MCP config JSON into
12
12
  * the SDK's `Options.mcpServers` shape.
13
13
  * - `buildClaudeSdkOptions` — pure builder for the per-turn `Options`.
14
14
  * - `createAskUserBridge` — live `canUseTool` that auto-allows
@@ -24,12 +24,26 @@
24
24
  import { query as sdkQuery, } from '@anthropic-ai/claude-agent-sdk';
25
25
  import { BaseAgent, getRuntimeEnv, getWorkspacePath, } from '../types.js';
26
26
  import { createSandboxedClaudeSpawn } from '../providers/sandboxed-claude-spawn.js';
27
- import { loadMcpServersForSdk } from '../providers/mcp-config.js';
27
+ import { assertStdioMcpServersStartForClaudeSdk, mountMcpForClaudeSdk, } from '../providers/mcp-runtime-mounting.js';
28
28
  import { buildClaudeSdkOptions, resolveClaudeCodeExecutable, } from './claude/sdk-options.js';
29
29
  import { projectSdkMessages } from './claude/sdk-message-projector.js';
30
30
  import { createAskUserBridge } from './claude/ask-user-bridge.js';
31
31
  import { createAskUserAnswerStore } from './claude/ask-user-answer-store.js';
32
32
  import { requireAskBusForLiveBatches } from '../sdk/ask-bus/bus.js';
33
+ function createLinkedAbortController(signal) {
34
+ const controller = new AbortController();
35
+ if (!signal)
36
+ return controller;
37
+ if (signal.aborted) {
38
+ controller.abort();
39
+ return controller;
40
+ }
41
+ signal.addEventListener('abort', () => controller.abort(), { once: true });
42
+ return controller;
43
+ }
44
+ function getTurnAbortSignal(sessionOptions) {
45
+ return sessionOptions?.getAbortSignal?.() ?? sessionOptions?.abortSignal;
46
+ }
33
47
  export class ClaudeAgent extends BaseAgent {
34
48
  deps;
35
49
  opts;
@@ -57,7 +71,13 @@ export class ClaudeAgent extends BaseAgent {
57
71
  agentOptionsExecutable: this.opts.claudeCodeExecutable,
58
72
  envExecutable,
59
73
  });
60
- const mcpServers = await loadMcpServersForSdk(workspacePath);
74
+ const mcpMountOptions = {
75
+ workspacePath,
76
+ mcpConfigPath: sessionOptions?.mcpConfigPath,
77
+ runtimeEnv: getRuntimeEnv(runtime),
78
+ };
79
+ await assertStdioMcpServersStartForClaudeSdk(mcpMountOptions);
80
+ const mcpServers = await mountMcpForClaudeSdk(mcpMountOptions);
61
81
  let priorSessionId;
62
82
  let turnNumber = 0;
63
83
  // The live ask-user bridge resolves AskUserQuestion through the bus
@@ -87,6 +107,7 @@ export class ClaudeAgent extends BaseAgent {
87
107
  claudeCodeExecutable,
88
108
  resume: priorSessionId,
89
109
  mcpServers,
110
+ abortController: createLinkedAbortController(getTurnAbortSignal(sessionOptions)),
90
111
  });
91
112
  const messages = [];
92
113
  const stream = queryFn({ prompt: message, options: sdkOptions });
@@ -1,10 +1,45 @@
1
1
  import { BaseAgent, getRuntimeEnv, getWorkspacePath, } from '../../types.js';
2
+ import { mountMcpForCodexAppServer } from '../../providers/mcp-runtime-mounting.js';
3
+ import { assertMcpSecretReferencesReady } from '../../providers/mcp-config.js';
2
4
  import { requireAskBusForLiveBatches, } from '../../sdk/ask-bus/bus.js';
3
5
  import { toAskUserToolEvent } from '../../sdk/ask-bus/projection.js';
6
+ import { decideMcpToolCall, redactMcpSecrets, } from '../../sdk/mcp-safety.js';
4
7
  import { spawnAppServerTransport, } from './transport.js';
5
8
  import { normalizeUpstreamQuestion, toWireAnswerMap, } from './wire-translators.js';
6
9
  const DEFAULT_MODEL = 'gpt-5.3-codex';
7
10
  const TURN_COMPLETED_METHOD = 'turn/completed';
11
+ function recordFromUnknown(value) {
12
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
13
+ return value;
14
+ }
15
+ return {};
16
+ }
17
+ function isMcpToolCallApprovalRequest(params) {
18
+ const meta = recordFromUnknown(recordFromUnknown(params)._meta);
19
+ return meta.codex_approval_kind === 'mcp_tool_call';
20
+ }
21
+ function extractMcpToolApprovalRequest(params) {
22
+ if (!isMcpToolCallApprovalRequest(params))
23
+ return undefined;
24
+ const record = recordFromUnknown(params);
25
+ const meta = recordFromUnknown(record._meta);
26
+ const serverName = typeof record.serverName === 'string' ? record.serverName : undefined;
27
+ const toolName = typeof meta.toolName === 'string' ? meta.toolName
28
+ : typeof meta.tool_name === 'string' ? meta.tool_name
29
+ : typeof meta.name === 'string' ? meta.name
30
+ : typeof record.message === 'string' ? parseToolNameFromApprovalMessage(record.message)
31
+ : undefined;
32
+ if (!serverName || !toolName)
33
+ return undefined;
34
+ return {
35
+ serverName,
36
+ toolName,
37
+ arguments: recordFromUnknown(meta.tool_params),
38
+ };
39
+ }
40
+ function parseToolNameFromApprovalMessage(message) {
41
+ return message.match(/tool\s+"([^"]+)"/i)?.[1];
42
+ }
8
43
  function projectItemIntoTurn(item, turn) {
9
44
  if (item.type === 'agentMessage') {
10
45
  const msg = item;
@@ -43,6 +78,82 @@ function projectItemIntoTurn(item, turn) {
43
78
  }
44
79
  return;
45
80
  }
81
+ if (item.type === 'mcpToolCall') {
82
+ const call = item;
83
+ const args = recordFromUnknown(call.arguments);
84
+ const providerToolName = `${call.server}.${call.tool}`;
85
+ turn.nonAskToolEvents.push({
86
+ action: 'mcp_tool_call',
87
+ provider: 'codex',
88
+ providerToolName,
89
+ turnNumber: turn.turnNumber,
90
+ arguments: {
91
+ ...args,
92
+ server: call.server,
93
+ tool: call.tool,
94
+ status: call.status ?? 'unknown',
95
+ },
96
+ summary: `MCP tool ${providerToolName} ${call.status ?? 'unknown'}`,
97
+ confidence: 'high',
98
+ rawSnippet: JSON.stringify(call),
99
+ });
100
+ return;
101
+ }
102
+ }
103
+ function projectMcpStartupStatusIntoTurn(params, turn) {
104
+ const name = params.name ?? 'unknown';
105
+ const status = params.status ?? 'unknown';
106
+ const error = typeof params.error === 'string' ? params.error : undefined;
107
+ turn.nonAskToolEvents.push({
108
+ action: 'unknown',
109
+ provider: 'codex',
110
+ providerToolName: 'mcpServer/startupStatus/updated',
111
+ turnNumber: turn.turnNumber,
112
+ arguments: {
113
+ name,
114
+ status,
115
+ ...(error ? { error } : {}),
116
+ },
117
+ summary: `MCP server ${name} startup ${status}`,
118
+ confidence: 'high',
119
+ rawSnippet: JSON.stringify(params),
120
+ });
121
+ if (status === 'failed') {
122
+ const message = `MCP server ${name} failed to start${error ? `: ${error}` : ''}`;
123
+ turn.turnFailed = true;
124
+ turn.failureMessage = message;
125
+ turn.signalFailure?.(message);
126
+ }
127
+ }
128
+ function recordPolicyDeniedMcpToolCall(turn, request, decision, rawParams) {
129
+ if (!turn)
130
+ return;
131
+ const args = redactMcpSecrets(request.arguments);
132
+ const providerToolName = `${request.serverName}.${request.toolName}`;
133
+ turn.nonAskToolEvents.push({
134
+ action: 'mcp_tool_call',
135
+ provider: 'codex',
136
+ providerToolName,
137
+ turnNumber: turn.turnNumber,
138
+ arguments: {
139
+ ...args,
140
+ server: request.serverName,
141
+ tool: request.toolName,
142
+ status: 'policy_denied',
143
+ policyResult: {
144
+ action: 'deny',
145
+ reason: decision.reason,
146
+ message: decision.message,
147
+ },
148
+ },
149
+ summary: `MCP tool ${providerToolName} policy_denied`,
150
+ confidence: 'high',
151
+ rawSnippet: JSON.stringify(redactMcpSecrets(rawParams)),
152
+ });
153
+ }
154
+ function isLiveMcpSafetyMode(options) {
155
+ const runMode = options?.runMode ?? 'mock';
156
+ return runMode === 'live-readonly' || runMode === 'live-sandbox' || runMode === 'live';
46
157
  }
47
158
  export class CodexAppServerAgent extends BaseAgent {
48
159
  deps;
@@ -76,6 +187,7 @@ export class CodexAppServerAgent extends BaseAgent {
76
187
  askBus,
77
188
  activeTurn: () => activeTurn,
78
189
  onPermissionGrant: this.deps.onPermissionGrant,
190
+ mcpSafety: options?.mcpSafety,
79
191
  }));
80
192
  transport.onClose((info) => {
81
193
  closeInfo = info;
@@ -84,6 +196,13 @@ export class CodexAppServerAgent extends BaseAgent {
84
196
  if (process.env.PATHGRADE_CODEX_DEBUG) {
85
197
  console.error(`[codex app-server] notification method=${n.method} params=${JSON.stringify(n.params).slice(0, 300)}`);
86
198
  }
199
+ if (n.method === 'mcpServer/startupStatus/updated') {
200
+ const turn = activeTurn;
201
+ if (turn) {
202
+ projectMcpStartupStatusIntoTurn((n.params ?? {}), turn);
203
+ }
204
+ return;
205
+ }
87
206
  if (n.method !== 'item/completed')
88
207
  return;
89
208
  const turn = activeTurn;
@@ -117,7 +236,20 @@ export class CodexAppServerAgent extends BaseAgent {
117
236
  activeTurn = turn;
118
237
  try {
119
238
  if (threadId === null) {
120
- const resp = await t.sendRequest('thread/start', buildThreadStartParams({ cwd: workspacePath, model, sandboxMode }));
239
+ if (options?.mcpConfigPath && isLiveMcpSafetyMode(options.mcpSafety)) {
240
+ await assertMcpSecretReferencesReady({
241
+ workspacePath,
242
+ mcpConfigPath: options.mcpConfigPath,
243
+ env: runtimeEnv,
244
+ });
245
+ }
246
+ const mcpConfig = options?.mcpConfigPath
247
+ ? await mountMcpForCodexAppServer({
248
+ workspacePath,
249
+ mcpConfigPath: options.mcpConfigPath,
250
+ })
251
+ : undefined;
252
+ const resp = await t.sendRequest('thread/start', buildThreadStartParams({ cwd: workspacePath, model, sandboxMode, mcpConfig }));
121
253
  threadId = resp.thread.id;
122
254
  }
123
255
  // Wait for TurnCompleted OR subprocess crash OR dispatcher failure.
@@ -155,6 +287,9 @@ export class CodexAppServerAgent extends BaseAgent {
155
287
  turn.failureMessage = msg;
156
288
  resolve();
157
289
  };
290
+ if (turn.turnFailed) {
291
+ turn.signalFailure(turn.failureMessage ?? 'turn failed');
292
+ }
158
293
  // If already closed, settle immediately.
159
294
  if (closeInfo) {
160
295
  if (!settled) {
@@ -254,7 +389,7 @@ export class CodexAppServerAgent extends BaseAgent {
254
389
  };
255
390
  }
256
391
  dispatchServerRequest(req, ctx) {
257
- const { transport, askBus, activeTurn, onPermissionGrant } = ctx;
392
+ const { transport, askBus, activeTurn, onPermissionGrant, mcpSafety } = ctx;
258
393
  switch (req.method) {
259
394
  case 'item/tool/requestUserInput':
260
395
  void handleRequestUserInput(req, { transport, askBus, activeTurn });
@@ -288,7 +423,30 @@ export class CodexAppServerAgent extends BaseAgent {
288
423
  transport.sendResponse(req.id, { status: 'declined' });
289
424
  return;
290
425
  case 'mcpServer/elicitation/request':
291
- transport.sendResponse(req.id, { action: 'decline' });
426
+ if (isMcpToolCallApprovalRequest(req.params)) {
427
+ const toolRequest = extractMcpToolApprovalRequest(req.params);
428
+ if (toolRequest) {
429
+ const decision = decideMcpToolCall(mcpSafety, toolRequest);
430
+ if (decision.action === 'deny') {
431
+ recordPolicyDeniedMcpToolCall(activeTurn(), toolRequest, decision, req.params);
432
+ transport.sendResponse(req.id, {
433
+ action: 'decline',
434
+ content: null,
435
+ _meta: {
436
+ pathgrade_policy_denial: {
437
+ reason: decision.reason,
438
+ message: decision.message,
439
+ },
440
+ },
441
+ });
442
+ return;
443
+ }
444
+ }
445
+ transport.sendResponse(req.id, { action: 'accept', content: {}, _meta: null });
446
+ }
447
+ else {
448
+ transport.sendResponse(req.id, { action: 'decline', content: null, _meta: null });
449
+ }
292
450
  return;
293
451
  case 'account/chatgptAuthTokens/refresh': {
294
452
  const message = 'codex app-server requires OPENAI_API_KEY for pathgrade and honors OPENAI_BASE_URL when set; ChatGPT/cached auth unsupported under transport=app-server';
@@ -347,6 +505,7 @@ function buildThreadStartParams(opts) {
347
505
  experimentalRawEvents: false,
348
506
  persistExtendedHistory: false,
349
507
  model: opts.model,
508
+ ...(opts.mcpConfig ? { config: opts.mcpConfig } : {}),
350
509
  };
351
510
  }
352
511
  function assembleTurnResult(args) {
@@ -1,7 +1,11 @@
1
1
  import { TOOL_NAME_MAP, buildSummary, inferCodexExecAction, enrichSkillEvents } from '../tool-events.js';
2
2
  import { TranscriptAgent } from './transcript-agent.js';
3
+ import { assertMcpRuntimeMountingSupportedForCodexExec } from '../providers/mcp-runtime-mounting.js';
3
4
  export class CodexAgent extends TranscriptAgent {
4
5
  async runTurn(instruction, runCommand, options) {
6
+ await assertMcpRuntimeMountingSupportedForCodexExec({
7
+ mcpConfigPath: options?.mcpConfigPath,
8
+ });
5
9
  const promptPath = await this.writePromptFile(instruction, runCommand);
6
10
  const command = buildCodexExecCommand(promptPath, options?.model);
7
11
  const result = await runCommand(command);
@@ -1,9 +1,8 @@
1
- import fs from 'fs-extra';
2
- import path from 'path';
3
1
  import { BaseAgent, getWorkspacePath } from '../types.js';
4
2
  import { TOOL_NAME_MAP, buildSummary, enrichSkillEvents } from '../tool-events.js';
5
3
  import { getVisibleAssistantMessage } from '../sdk/visible-turn.js';
6
4
  import { prependRuntimePolicies } from '../sdk/runtime-policy.js';
5
+ import { mountMcpForCursor } from '../providers/mcp-runtime-mounting.js';
7
6
  /**
8
7
  * Cursor tool_call discriminants observed in the discovery spike. Probed in
9
8
  * order — first present key wins. Explicit probing (vs. `Object.keys(…)[0]`)
@@ -188,18 +187,12 @@ export class CursorAgent extends BaseAgent {
188
187
  const promptPath = '"${TMPDIR:-/tmp}/.pathgrade-cursor-prompt.md"';
189
188
  const b64 = Buffer.from(effectiveInstruction).toString('base64');
190
189
  await runCommand(`mkdir -p "\${TMPDIR:-/tmp}" && echo '${b64}' | base64 -d > ${promptPath}`);
191
- // Materialize .cursor/mcp.json from pathgrade's MCP config so the
192
- // CLI's native loader picks it up. Idempotent per turn.
193
- if (options?.mcpConfigPath) {
194
- const srcMcp = path.join(workspacePath, options.mcpConfigPath);
195
- if (await fs.pathExists(srcMcp)) {
196
- const cursorDir = path.join(workspacePath, '.cursor');
197
- await fs.ensureDir(cursorDir);
198
- await fs.copy(srcMcp, path.join(cursorDir, 'mcp.json'), { overwrite: true });
199
- }
200
- }
190
+ const mcpMount = await mountMcpForCursor({
191
+ workspacePath,
192
+ mcpConfigPath: options?.mcpConfigPath,
193
+ });
201
194
  const modelFlag = options?.model ? ` --model ${options.model}` : '';
202
- const mcpFlag = options?.mcpConfigPath ? ' --approve-mcps' : '';
195
+ const mcpFlag = mcpMount.approveMcps ? ' --approve-mcps' : '';
203
196
  const resumeFlag = sessionId ? ` --resume ${this.sanitizeSessionId(sessionId)}` : '';
204
197
  const command = `${CURSOR_EXECUTABLE} -p --output-format stream-json --trust --force --workspace "${workspacePath}"${resumeFlag}${modelFlag}${mcpFlag} "$(cat ${promptPath})" < /dev/null`;
205
198
  const result = await runCommand(command);
@@ -3,32 +3,40 @@ import type { MockMcpServerDescriptor } from '../core/mcp-mock.types.js';
3
3
  * Stdio-server shape pathgrade writes to `.pathgrade-mcp.json`. Lines up
4
4
  * directly with the SDK's `McpStdioServerConfig` (`sdk.d.ts:1005`) — the
5
5
  * optional `type: 'stdio'` discriminator is omitted because the JSON
6
- * `writeMcpConfig` produces does not include it.
6
+ * `stageMcpConfig` produces does not include it.
7
7
  */
8
8
  export interface McpStdioEntry {
9
9
  command: string;
10
10
  args?: string[];
11
11
  env?: Record<string, string>;
12
+ startup_timeout_sec?: number;
13
+ tool_timeout_sec?: number;
12
14
  }
13
- /** Object form of MCP server entries the SDK driver passes to `Options.mcpServers`. */
14
- export type McpServersObject = Record<string, McpStdioEntry>;
15
- export type McpSpec = {
15
+ export interface McpStreamableHttpEntry {
16
+ type?: 'streamable-http' | 'http';
17
+ url: string;
18
+ headers?: Record<string, string>;
19
+ http_headers?: Record<string, string>;
20
+ env_http_headers?: Record<string, string>;
21
+ bearer_token_env_var?: string;
22
+ startup_timeout_sec?: number;
23
+ tool_timeout_sec?: number;
24
+ }
25
+ export type McpConfigFileEntry = McpStdioEntry | McpStreamableHttpEntry;
26
+ export type McpDeclaration = {
16
27
  configFile: string;
17
28
  } | {
18
29
  mock: MockMcpServerDescriptor | MockMcpServerDescriptor[];
19
30
  };
31
+ export declare const MCP_CONFIG_FILENAME = ".pathgrade-mcp.json";
32
+ export declare function readStagedMcpServers(workspacePath: string, mcpConfigPath?: string): Promise<Record<string, McpConfigFileEntry> | undefined>;
33
+ export declare function missingMcpSecretReferences(mcpServers: Record<string, McpConfigFileEntry> | undefined, env: Record<string, string | undefined>): string[];
34
+ export declare function assertMcpSecretReferencesReady(opts: {
35
+ workspacePath: string;
36
+ mcpConfigPath?: string;
37
+ env: Record<string, string | undefined>;
38
+ }): Promise<void>;
20
39
  export interface McpConfigResult {
21
40
  mcpConfigPath: string | undefined;
22
41
  }
23
- export declare function writeMcpConfig(workspacePath: string, mcp: McpSpec | undefined): Promise<McpConfigResult>;
24
- /**
25
- * Read the JSON `writeMcpConfig` writes into `<workspace>/.pathgrade-mcp.json`
26
- * and return the inner `mcpServers` object the Claude SDK driver hands to
27
- * `Options.mcpServers`. Returns `undefined` when the file is absent (the
28
- * "no MCP" path through the driver — the fixture didn't declare an `mcp` spec).
29
- *
30
- * The on-disk path is preserved because the Cursor agent driver still
31
- * consumes it via `mcpConfigPath` (`src/agents/cursor.ts`) — Claude moves to
32
- * the object form, Cursor stays on the file.
33
- */
34
- export declare function loadMcpServersForSdk(workspacePath: string): Promise<McpServersObject | undefined>;
42
+ export declare function stageMcpConfig(workspacePath: string, mcp: McpDeclaration | undefined): Promise<McpConfigResult>;
@@ -1,7 +1,132 @@
1
1
  import fs from 'fs-extra';
2
2
  import * as path from 'path';
3
- const MCP_CONFIG_FILENAME = '.pathgrade-mcp.json';
4
- export async function writeMcpConfig(workspacePath, mcp) {
3
+ export const MCP_CONFIG_FILENAME = '.pathgrade-mcp.json';
4
+ const GENERATED_MOCK_SERVER_FILENAME = '.pathgrade-mcp-mock-server.cjs';
5
+ function isRecord(value) {
6
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
7
+ }
8
+ function isStringRecord(value) {
9
+ return isRecord(value)
10
+ && Object.values(value).every((entry) => typeof entry === 'string');
11
+ }
12
+ function assertOptionalStringRecord(serverName, fieldName, value) {
13
+ if (value !== undefined && !isStringRecord(value)) {
14
+ throw new Error(`Invalid MCP server "${serverName}": ${fieldName} must be a string record`);
15
+ }
16
+ }
17
+ function assertOptionalStringArray(serverName, fieldName, value) {
18
+ if (value !== undefined && (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string'))) {
19
+ throw new Error(`Invalid MCP server "${serverName}": ${fieldName} must be a string array`);
20
+ }
21
+ }
22
+ function assertOptionalNumber(serverName, fieldName, value) {
23
+ if (value !== undefined && typeof value !== 'number') {
24
+ throw new Error(`Invalid MCP server "${serverName}": ${fieldName} must be a number`);
25
+ }
26
+ }
27
+ function validateMcpConfigFileEntry(serverName, entry) {
28
+ if (!isRecord(entry)) {
29
+ throw new Error(`Invalid MCP server "${serverName}": entry must be an object`);
30
+ }
31
+ const hasCommand = entry.command !== undefined;
32
+ const hasUrl = entry.url !== undefined;
33
+ if (hasCommand && hasUrl) {
34
+ throw new Error(`Invalid MCP server "${serverName}": must not define both command and url`);
35
+ }
36
+ if (hasCommand) {
37
+ if (typeof entry.command !== 'string') {
38
+ throw new Error(`Invalid MCP server "${serverName}": command must be a string`);
39
+ }
40
+ assertOptionalStringArray(serverName, 'args', entry.args);
41
+ assertOptionalStringRecord(serverName, 'env', entry.env);
42
+ assertOptionalNumber(serverName, 'startup_timeout_sec', entry.startup_timeout_sec);
43
+ assertOptionalNumber(serverName, 'tool_timeout_sec', entry.tool_timeout_sec);
44
+ return entry;
45
+ }
46
+ if (!hasUrl) {
47
+ throw new Error(`Invalid MCP server "${serverName}": expected command or url`);
48
+ }
49
+ if (entry.type !== undefined && entry.type !== 'streamable-http' && entry.type !== 'http') {
50
+ throw new Error(`Unsupported MCP server type for "${serverName}": ${String(entry.type)}`);
51
+ }
52
+ if (typeof entry.url !== 'string') {
53
+ throw new Error(`Invalid MCP server "${serverName}": url must be a string`);
54
+ }
55
+ assertOptionalStringRecord(serverName, 'headers', entry.headers);
56
+ assertOptionalStringRecord(serverName, 'http_headers', entry.http_headers);
57
+ assertOptionalStringRecord(serverName, 'env_http_headers', entry.env_http_headers);
58
+ if (entry.bearer_token_env_var !== undefined && typeof entry.bearer_token_env_var !== 'string') {
59
+ throw new Error(`Invalid MCP server "${serverName}": bearer_token_env_var must be a string`);
60
+ }
61
+ assertOptionalNumber(serverName, 'startup_timeout_sec', entry.startup_timeout_sec);
62
+ assertOptionalNumber(serverName, 'tool_timeout_sec', entry.tool_timeout_sec);
63
+ return entry;
64
+ }
65
+ function readMcpServers(parsed) {
66
+ if (!isRecord(parsed) || !isRecord(parsed.mcpServers))
67
+ return undefined;
68
+ return Object.fromEntries(Object.entries(parsed.mcpServers).map(([name, entry]) => [
69
+ name,
70
+ validateMcpConfigFileEntry(name, entry),
71
+ ]));
72
+ }
73
+ export async function readStagedMcpServers(workspacePath, mcpConfigPath = MCP_CONFIG_FILENAME) {
74
+ const configPath = path.join(workspacePath, mcpConfigPath);
75
+ if (!(await fs.pathExists(configPath)))
76
+ return undefined;
77
+ return readMcpServers(await fs.readJson(configPath));
78
+ }
79
+ export function missingMcpSecretReferences(mcpServers, env) {
80
+ if (!mcpServers)
81
+ return [];
82
+ const missing = [];
83
+ for (const [serverName, entry] of Object.entries(mcpServers)) {
84
+ if ('env_http_headers' in entry) {
85
+ for (const envVar of Object.values(entry.env_http_headers ?? {})) {
86
+ if (!env[envVar])
87
+ missing.push(`${serverName}: env_http_headers references ${envVar}`);
88
+ }
89
+ }
90
+ if ('bearer_token_env_var' in entry && entry.bearer_token_env_var && !env[entry.bearer_token_env_var]) {
91
+ missing.push(`${serverName}: bearer_token_env_var references ${entry.bearer_token_env_var}`);
92
+ }
93
+ }
94
+ return missing;
95
+ }
96
+ export async function assertMcpSecretReferencesReady(opts) {
97
+ const mcpServers = await readStagedMcpServers(opts.workspacePath, opts.mcpConfigPath);
98
+ const missing = missingMcpSecretReferences(mcpServers, opts.env);
99
+ if (missing.length > 0) {
100
+ throw new Error(`Missing MCP secret references: ${missing.join('; ')}`);
101
+ }
102
+ }
103
+ async function resolveMockServerScript(workspacePath) {
104
+ const compiledScript = path.resolve(import.meta.dirname, '../mcp-mock-server.js');
105
+ if (await fs.pathExists(compiledScript))
106
+ return compiledScript;
107
+ const sourceScript = path.resolve(import.meta.dirname, '../mcp-mock-server.ts');
108
+ if (!await fs.pathExists(sourceScript)) {
109
+ throw new Error(`Mock MCP server source not found: ${sourceScript}`);
110
+ }
111
+ const ts = await import('typescript');
112
+ const source = await fs.readFile(sourceScript, 'utf-8');
113
+ const transpiled = ts.transpileModule(source, {
114
+ compilerOptions: {
115
+ target: ts.ScriptTarget.ES2022,
116
+ module: ts.ModuleKind.CommonJS,
117
+ esModuleInterop: true,
118
+ },
119
+ fileName: sourceScript,
120
+ });
121
+ const generatedScript = path.join(workspacePath, GENERATED_MOCK_SERVER_FILENAME);
122
+ await fs.writeFile(generatedScript, [
123
+ '// Generated by PathGrade from src/mcp-mock-server.ts.',
124
+ '// This trial-local file lets external agent runtimes spawn the mock server with plain node.',
125
+ transpiled.outputText,
126
+ ].join('\n'));
127
+ return generatedScript;
128
+ }
129
+ export async function stageMcpConfig(workspacePath, mcp) {
5
130
  if (!mcp)
6
131
  return { mcpConfigPath: undefined };
7
132
  const mcpConfigPath = MCP_CONFIG_FILENAME;
@@ -21,12 +146,12 @@ export async function writeMcpConfig(workspacePath, mcp) {
21
146
  }
22
147
  seen.add(mock.config.name);
23
148
  }
149
+ const mockServerScript = await resolveMockServerScript(workspacePath);
24
150
  const mcpServers = {};
25
151
  for (const mock of mocks) {
26
152
  const sanitizedName = mock.config.name.replace(/[^a-zA-Z0-9-]/g, '-');
27
153
  const fixturePath = path.join(workspacePath, `.pathgrade-mcp-mock-${sanitizedName}.json`);
28
154
  await fs.writeJson(fixturePath, mock.config, { spaces: 2 });
29
- const mockServerScript = path.resolve(import.meta.dirname, '../mcp-mock-server.js');
30
155
  mcpServers[mock.config.name] = {
31
156
  command: 'node',
32
157
  args: [mockServerScript, fixturePath],
@@ -36,20 +161,3 @@ export async function writeMcpConfig(workspacePath, mcp) {
36
161
  }
37
162
  return { mcpConfigPath };
38
163
  }
39
- /**
40
- * Read the JSON `writeMcpConfig` writes into `<workspace>/.pathgrade-mcp.json`
41
- * and return the inner `mcpServers` object the Claude SDK driver hands to
42
- * `Options.mcpServers`. Returns `undefined` when the file is absent (the
43
- * "no MCP" path through the driver — the fixture didn't declare an `mcp` spec).
44
- *
45
- * The on-disk path is preserved because the Cursor agent driver still
46
- * consumes it via `mcpConfigPath` (`src/agents/cursor.ts`) — Claude moves to
47
- * the object form, Cursor stays on the file.
48
- */
49
- export async function loadMcpServersForSdk(workspacePath) {
50
- const configPath = path.join(workspacePath, MCP_CONFIG_FILENAME);
51
- if (!(await fs.pathExists(configPath)))
52
- return undefined;
53
- const parsed = (await fs.readJson(configPath));
54
- return parsed.mcpServers;
55
- }