@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.
- package/dist/agents/claude/sdk-message-projector.js +26 -0
- package/dist/agents/claude/sdk-options.d.ts +4 -2
- package/dist/agents/claude/sdk-options.js +2 -0
- package/dist/agents/claude.d.ts +1 -1
- package/dist/agents/claude.js +24 -3
- package/dist/agents/codex-app-server/agent.js +162 -3
- package/dist/agents/codex.js +4 -0
- package/dist/agents/cursor.js +6 -13
- package/dist/providers/mcp-config.d.ts +24 -16
- package/dist/providers/mcp-config.js +128 -20
- package/dist/providers/mcp-runtime-mounting.d.ts +36 -0
- package/dist/providers/mcp-runtime-mounting.js +116 -0
- package/dist/providers/sandbox.d.ts +1 -1
- package/dist/providers/workspace.d.ts +1 -1
- package/dist/providers/workspace.js +2 -2
- package/dist/sdk/agent.js +7 -4
- package/dist/sdk/index.d.ts +6 -1
- package/dist/sdk/index.js +2 -0
- package/dist/sdk/managed-session.d.ts +3 -0
- package/dist/sdk/managed-session.js +2 -0
- package/dist/sdk/mcp-evidence.d.ts +29 -0
- package/dist/sdk/mcp-evidence.js +96 -0
- package/dist/sdk/mcp-safety.d.ts +30 -0
- package/dist/sdk/mcp-safety.js +61 -0
- package/dist/sdk/types.d.ts +12 -2
- package/dist/sdk/types.js +2 -2
- package/dist/tool-events.d.ts +1 -1
- package/dist/types.d.ts +6 -0
- package/dist/utils/timeout.js +11 -10
- package/package.json +3 -2
|
@@ -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-
|
|
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 `
|
|
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
|
package/dist/agents/claude.d.ts
CHANGED
|
@@ -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
|
-
* - `
|
|
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
|
package/dist/agents/claude.js
CHANGED
|
@@ -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
|
-
* - `
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/agents/codex.js
CHANGED
|
@@ -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);
|
package/dist/agents/cursor.js
CHANGED
|
@@ -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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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 =
|
|
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
|
-
* `
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
}
|