@wix/pathgrade 1.0.13 → 1.0.15
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/README.md +30 -0
- package/dist/adapters/jest/results.js +1 -1
- package/dist/adapters/node-test/index.js +1 -1
- package/dist/agents/claude/sdk-message-projector.js +3 -2
- package/dist/agents/claude/tool-permission-bridge.d.ts +4 -0
- package/dist/agents/claude/tool-permission-bridge.js +68 -1
- package/dist/agents/claude.d.ts +2 -0
- package/dist/agents/claude.js +55 -10
- package/dist/agents/codex-app-server/agent.js +94 -76
- package/dist/agents/codex-app-server/mcp-approval-correlator.d.ts +55 -0
- package/dist/agents/codex-app-server/mcp-approval-correlator.js +299 -0
- package/dist/core/canonical-json.d.ts +2 -0
- package/dist/core/canonical-json.js +51 -0
- package/dist/core/generated-mcp-protocol.d.ts +19 -0
- package/dist/core/generated-mcp-protocol.js +26 -0
- package/dist/core/mcp-mock.d.ts +1 -1
- package/dist/core/mcp-mock.js +24 -0
- package/dist/core/mcp-mock.types.d.ts +6 -0
- package/dist/core/mcp-schema-profile.d.ts +10 -0
- package/dist/core/mcp-schema-profile.js +91 -0
- package/dist/mcp-mock-server.js +29 -13
- package/dist/providers/mcp-config.js +4 -2
- package/dist/providers/sandbox.js +6 -0
- package/dist/providers/scripted-mcp-mock-host.d.ts +39 -0
- package/dist/providers/scripted-mcp-mock-host.js +368 -0
- package/dist/reporters/cli.js +4 -3
- package/dist/reporters/github-comment.js +1 -1
- package/dist/reporters/report-summary.js +1 -0
- package/dist/reporting/core.js +19 -5
- package/dist/reporting/types.d.ts +2 -1
- package/dist/runners/model-builders.js +1 -1
- package/dist/runners/model-validation.js +4 -1
- package/dist/runners/model.d.ts +1 -1
- package/dist/runners/report-projection.js +2 -2
- package/dist/runners/vitest-adapter.js +1 -1
- package/dist/sdk/agent.js +64 -19
- package/dist/sdk/diagnostics.d.ts +1 -0
- package/dist/sdk/diagnostics.js +6 -3
- package/dist/sdk/index.d.ts +4 -3
- package/dist/sdk/index.js +1 -1
- package/dist/sdk/lifecycle.js +3 -3
- package/dist/sdk/managed-session.d.ts +3 -0
- package/dist/sdk/managed-session.js +71 -26
- package/dist/sdk/mcp-event-input.d.ts +3 -0
- package/dist/sdk/mcp-event-input.js +8 -0
- package/dist/sdk/mcp-evidence.d.ts +39 -0
- package/dist/sdk/mcp-evidence.js +71 -12
- package/dist/sdk/mcp-mock-approvals.d.ts +40 -0
- package/dist/sdk/mcp-mock-approvals.js +235 -0
- package/dist/sdk/scripted-mcp-events.d.ts +24 -0
- package/dist/sdk/scripted-mcp-events.js +30 -0
- package/dist/sdk/types.d.ts +6 -2
- package/dist/tool-events.d.ts +16 -1
- package/dist/types.d.ts +3 -1
- package/dist/viewer.html +4 -4
- package/package.json +3 -2
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { redactMcpSecrets, } from '../../sdk/mcp-safety.js';
|
|
2
|
+
import { canonicalizeJson } from '../../core/canonical-json.js';
|
|
3
|
+
import { buildScriptedMcpApprovalEvent, buildScriptedMcpDeniedCallEvent, } from '../../sdk/scripted-mcp-events.js';
|
|
4
|
+
function isRecord(value) {
|
|
5
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
export function isMcpToolCallApprovalRequest(params) {
|
|
8
|
+
const meta = isRecord(params) && isRecord(params._meta) ? params._meta : {};
|
|
9
|
+
return meta.codex_approval_kind === 'mcp_tool_call';
|
|
10
|
+
}
|
|
11
|
+
export function hasScriptedApprovalPolicy(response) {
|
|
12
|
+
const policy = isRecord(response.approvalPolicy) ? response.approvalPolicy : {};
|
|
13
|
+
const granular = isRecord(policy.granular) ? policy.granular : {};
|
|
14
|
+
return response.approvalsReviewer === 'user'
|
|
15
|
+
&& granular.sandbox_approval === false && granular.rules === false
|
|
16
|
+
&& granular.skill_approval === false && granular.request_permissions === false
|
|
17
|
+
&& granular.mcp_elicitations === true
|
|
18
|
+
&& Object.keys(granular).length === 5 && Object.keys(policy).length === 1;
|
|
19
|
+
}
|
|
20
|
+
export function extractMcpToolApprovalRequest(params) {
|
|
21
|
+
if (!isMcpToolCallApprovalRequest(params) || !isRecord(params))
|
|
22
|
+
return undefined;
|
|
23
|
+
const meta = isRecord(params._meta) ? params._meta : {};
|
|
24
|
+
const serverName = typeof params.serverName === 'string' ? params.serverName : undefined;
|
|
25
|
+
const messageTool = typeof params.message === 'string' ? params.message.match(/tool\s+"([^"]+)"/i)?.[1] : undefined;
|
|
26
|
+
const toolName = typeof meta.toolName === 'string' ? meta.toolName
|
|
27
|
+
: typeof meta.tool_name === 'string' ? meta.tool_name
|
|
28
|
+
: typeof meta.name === 'string' ? meta.name : messageTool;
|
|
29
|
+
if (!serverName || !toolName)
|
|
30
|
+
return undefined;
|
|
31
|
+
return { serverName, toolName, arguments: isRecord(meta.tool_params) ? meta.tool_params : {} };
|
|
32
|
+
}
|
|
33
|
+
export function recordPolicyDeniedMcpToolCall(turn, request, decision, rawParams) {
|
|
34
|
+
if (!turn)
|
|
35
|
+
return;
|
|
36
|
+
const args = redactMcpSecrets(request.arguments);
|
|
37
|
+
const providerToolName = `${request.serverName}.${request.toolName}`;
|
|
38
|
+
turn.nonAskToolEvents.push({
|
|
39
|
+
action: 'mcp_tool_call', provider: 'codex', providerToolName, turnNumber: turn.turnNumber,
|
|
40
|
+
arguments: { ...args, server: request.serverName, tool: request.toolName, status: 'policy_denied',
|
|
41
|
+
policyResult: { action: 'deny', reason: decision.reason, message: decision.message } },
|
|
42
|
+
summary: `MCP tool ${providerToolName} policy_denied`, confidence: 'high',
|
|
43
|
+
rawSnippet: JSON.stringify(redactMcpSecrets(rawParams)),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
export class CodexMcpApprovalCorrelator {
|
|
47
|
+
host;
|
|
48
|
+
getTurnNumber;
|
|
49
|
+
getThreadId;
|
|
50
|
+
getRemainingMs;
|
|
51
|
+
live = new Map();
|
|
52
|
+
terminal = new Map();
|
|
53
|
+
decisions = new Map();
|
|
54
|
+
active = false;
|
|
55
|
+
authoritativeTurn;
|
|
56
|
+
authorityPromise = Promise.resolve(false);
|
|
57
|
+
resolveAuthority;
|
|
58
|
+
waitingRequests = 0;
|
|
59
|
+
poisoned = false;
|
|
60
|
+
constructor(host, getTurnNumber, getThreadId, getRemainingMs = () => 1_000) {
|
|
61
|
+
this.host = host;
|
|
62
|
+
this.getTurnNumber = getTurnNumber;
|
|
63
|
+
this.getThreadId = getThreadId;
|
|
64
|
+
this.getRemainingMs = getRemainingMs;
|
|
65
|
+
}
|
|
66
|
+
started(params) {
|
|
67
|
+
if (this.poisoned)
|
|
68
|
+
return;
|
|
69
|
+
if (!isRecord(params) || !isRecord(params.item) || params.item.type !== 'mcpToolCall')
|
|
70
|
+
return;
|
|
71
|
+
const record = this.parseLifecycleRecord(params);
|
|
72
|
+
if (!record)
|
|
73
|
+
return this.failLifecycle('malformed MCP lifecycle start');
|
|
74
|
+
if (!this.validateLifecycleTurn(record))
|
|
75
|
+
return this.failLifecycle('MCP lifecycle start belongs to a stale thread or turn');
|
|
76
|
+
if (this.terminal.has(record.item.id)) {
|
|
77
|
+
return this.failLifecycle(`MCP lifecycle start after completion ${record.item.id}`);
|
|
78
|
+
}
|
|
79
|
+
const prior = this.live.get(record.item.id);
|
|
80
|
+
if (prior && canonicalizeJson(prior) !== canonicalizeJson(record)) {
|
|
81
|
+
return this.failLifecycle(`conflicting MCP lifecycle start ${record.item.id}`);
|
|
82
|
+
}
|
|
83
|
+
this.live.set(record.item.id, record);
|
|
84
|
+
}
|
|
85
|
+
completed(params) {
|
|
86
|
+
if (this.poisoned)
|
|
87
|
+
return false;
|
|
88
|
+
if (!isRecord(params) || !isRecord(params.item) || params.item.type !== 'mcpToolCall')
|
|
89
|
+
return true;
|
|
90
|
+
const record = this.parseLifecycleRecord(params);
|
|
91
|
+
if (!record)
|
|
92
|
+
return this.failLifecycle('malformed MCP lifecycle completion');
|
|
93
|
+
if (!this.validateLifecycleTurn(record))
|
|
94
|
+
return this.failLifecycle('MCP lifecycle completion belongs to a stale thread or turn');
|
|
95
|
+
const fingerprint = canonicalizeJson(record);
|
|
96
|
+
const priorTerminal = this.terminal.get(record.item.id);
|
|
97
|
+
if (priorTerminal !== undefined) {
|
|
98
|
+
if (priorTerminal.fingerprint !== fingerprint) {
|
|
99
|
+
return this.failLifecycle(`conflicting MCP lifecycle completion ${record.item.id}`);
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
const started = this.live.get(record.item.id);
|
|
104
|
+
if (!started)
|
|
105
|
+
return this.failLifecycle(`MCP lifecycle completion before start ${record.item.id}`);
|
|
106
|
+
if (started.threadId !== record.threadId || started.turnId !== record.turnId
|
|
107
|
+
|| started.item.server !== record.item.server || started.item.tool !== record.item.tool
|
|
108
|
+
|| canonicalizeJson(started.item.arguments ?? {}) !== canonicalizeJson(record.item.arguments ?? {})) {
|
|
109
|
+
return this.failLifecycle(`conflicting MCP lifecycle completion ${record.item.id}`);
|
|
110
|
+
}
|
|
111
|
+
this.live.delete(record.item.id);
|
|
112
|
+
this.terminal.set(record.item.id, {
|
|
113
|
+
fingerprint, threadId: record.threadId, turnId: record.turnId,
|
|
114
|
+
});
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
async decide(requestId, rawParams) {
|
|
118
|
+
if (!this.active) {
|
|
119
|
+
this.host.failProtocol('MCP approval request received outside an active turn');
|
|
120
|
+
return { action: 'decline', events: [this.protocolEvent('outside_active_turn')] };
|
|
121
|
+
}
|
|
122
|
+
if (this.poisoned) {
|
|
123
|
+
return { action: 'decline', events: [this.protocolEvent('protocol_poisoned')] };
|
|
124
|
+
}
|
|
125
|
+
if (!this.authoritativeTurn) {
|
|
126
|
+
if (this.waitingRequests >= 64) {
|
|
127
|
+
this.poison('MCP approval pre-response quarantine overflow');
|
|
128
|
+
return { action: 'decline', events: [this.protocolEvent('quarantine_overflow')] };
|
|
129
|
+
}
|
|
130
|
+
this.waitingRequests += 1;
|
|
131
|
+
let ready;
|
|
132
|
+
try {
|
|
133
|
+
ready = await this.waitForAuthority();
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
this.waitingRequests -= 1;
|
|
137
|
+
}
|
|
138
|
+
if (!ready || !this.active || !this.authoritativeTurn || this.poisoned) {
|
|
139
|
+
this.poison('MCP approval pre-response quarantine expired');
|
|
140
|
+
return { action: 'decline', events: [this.protocolEvent('quarantine_expired')] };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const params = isRecord(rawParams) ? rawParams : {};
|
|
144
|
+
const key = `${typeof requestId}:${String(requestId)}`;
|
|
145
|
+
const meta = isRecord(params._meta) ? params._meta : {};
|
|
146
|
+
const threadId = this.getThreadId();
|
|
147
|
+
let fingerprint;
|
|
148
|
+
try {
|
|
149
|
+
fingerprint = canonicalizeJson({
|
|
150
|
+
mode: params.mode,
|
|
151
|
+
threadId: params.threadId,
|
|
152
|
+
turnId: params.turnId,
|
|
153
|
+
serverName: params.serverName,
|
|
154
|
+
kind: meta.codex_approval_kind,
|
|
155
|
+
tool_params: meta.tool_params,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
this.poison('malformed MCP approval authority fields');
|
|
160
|
+
return { action: 'decline', events: [this.protocolEvent('malformed_request')] };
|
|
161
|
+
}
|
|
162
|
+
const existing = this.decisions.get(key);
|
|
163
|
+
if (existing) {
|
|
164
|
+
if (existing.fingerprint === fingerprint)
|
|
165
|
+
return { action: existing.action, events: [] };
|
|
166
|
+
this.poison('conflicting duplicate MCP approval request');
|
|
167
|
+
return { action: 'decline', events: [this.protocolEvent('conflicting_duplicate')] };
|
|
168
|
+
}
|
|
169
|
+
if (params.mode !== 'form' || meta.codex_approval_kind !== 'mcp_tool_call'
|
|
170
|
+
|| typeof params.serverName !== 'string' || !isRecord(meta.tool_params)
|
|
171
|
+
|| (threadId && params.threadId !== threadId)
|
|
172
|
+
|| (this.authoritativeTurn && (params.threadId !== this.authoritativeTurn.threadId
|
|
173
|
+
|| params.turnId !== this.authoritativeTurn.turnId))) {
|
|
174
|
+
this.poison('malformed or stale MCP approval request');
|
|
175
|
+
const response = { action: 'decline', events: [this.protocolEvent('malformed_request')], fingerprint };
|
|
176
|
+
this.decisions.set(key, response);
|
|
177
|
+
return response;
|
|
178
|
+
}
|
|
179
|
+
const argsFingerprint = canonicalizeJson(meta.tool_params);
|
|
180
|
+
const candidates = [...this.live.values()].filter((record) => record.item.server === params.serverName
|
|
181
|
+
&& canonicalizeJson(record.item.arguments ?? {}) === argsFingerprint);
|
|
182
|
+
if (candidates.length !== 1) {
|
|
183
|
+
this.poison('MCP approval request did not correlate to exactly one lifecycle item');
|
|
184
|
+
const response = { action: 'decline', events: [this.protocolEvent('ambiguous_lifecycle')], fingerprint };
|
|
185
|
+
this.decisions.set(key, response);
|
|
186
|
+
return response;
|
|
187
|
+
}
|
|
188
|
+
const item = candidates[0].item;
|
|
189
|
+
const decision = this.host.decide(item.server, item.tool, meta.tool_params);
|
|
190
|
+
if (decision.outcome !== 'matched')
|
|
191
|
+
this.poisoned = true;
|
|
192
|
+
const approval = buildScriptedMcpApprovalEvent({
|
|
193
|
+
provider: 'codex', serverName: item.server, toolName: item.tool,
|
|
194
|
+
toolUseId: item.id, turnNumber: this.getTurnNumber(), decision,
|
|
195
|
+
});
|
|
196
|
+
const events = [approval];
|
|
197
|
+
if (decision.decision === 'deny') {
|
|
198
|
+
const outcome = decision.outcome === 'matched' ? 'user_denied' : 'protocol_error';
|
|
199
|
+
events.push(buildScriptedMcpDeniedCallEvent({
|
|
200
|
+
provider: 'codex', serverName: item.server, toolName: item.tool,
|
|
201
|
+
toolUseId: item.id, turnNumber: this.getTurnNumber(), args: meta.tool_params, outcome,
|
|
202
|
+
}));
|
|
203
|
+
}
|
|
204
|
+
const response = { action: decision.decision === 'approve' ? 'accept' : 'decline', events, fingerprint };
|
|
205
|
+
this.decisions.set(key, response);
|
|
206
|
+
return response;
|
|
207
|
+
}
|
|
208
|
+
beginTurn() {
|
|
209
|
+
this.active = true;
|
|
210
|
+
this.poisoned = false;
|
|
211
|
+
this.authoritativeTurn = undefined;
|
|
212
|
+
this.waitingRequests = 0;
|
|
213
|
+
this.authorityPromise = new Promise((resolve) => { this.resolveAuthority = resolve; });
|
|
214
|
+
this.live.clear();
|
|
215
|
+
this.terminal.clear();
|
|
216
|
+
this.decisions.clear();
|
|
217
|
+
}
|
|
218
|
+
setAuthoritativeTurn(threadId, turnId) {
|
|
219
|
+
if (!this.active || this.poisoned)
|
|
220
|
+
return false;
|
|
221
|
+
this.authoritativeTurn = { threadId, turnId };
|
|
222
|
+
const staleLifecycle = [...this.live.values(), ...this.terminal.values()]
|
|
223
|
+
.some((record) => record.threadId !== threadId || record.turnId !== turnId);
|
|
224
|
+
if (staleLifecycle) {
|
|
225
|
+
this.authoritativeTurn = undefined;
|
|
226
|
+
this.poison('MCP lifecycle item belongs to a stale thread or turn');
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
this.resolveAuthority?.(true);
|
|
230
|
+
this.resolveAuthority = undefined;
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
endTurn() {
|
|
234
|
+
this.active = false;
|
|
235
|
+
this.resolveAuthority?.(false);
|
|
236
|
+
this.resolveAuthority = undefined;
|
|
237
|
+
this.authoritativeTurn = undefined;
|
|
238
|
+
this.live.clear();
|
|
239
|
+
this.terminal.clear();
|
|
240
|
+
this.decisions.clear();
|
|
241
|
+
}
|
|
242
|
+
protocolEvent(reason) {
|
|
243
|
+
return {
|
|
244
|
+
action: 'mcp_approval', provider: 'codex', providerToolName: 'mcp.protocol_error',
|
|
245
|
+
turnNumber: this.getTurnNumber(), status: 'error',
|
|
246
|
+
arguments: { decision: 'deny', outcome: 'protocol_error', reason, decisionSource: 'scripted_user' },
|
|
247
|
+
summary: 'MCP approval protocol error', confidence: 'high',
|
|
248
|
+
rawSnippet: JSON.stringify({ outcome: 'protocol_error', reason }),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
waitForAuthority() {
|
|
252
|
+
const remainingMs = this.getRemainingMs();
|
|
253
|
+
const timeoutMs = Number.isFinite(remainingMs)
|
|
254
|
+
? Math.max(0, Math.min(1_000, remainingMs))
|
|
255
|
+
: 1_000;
|
|
256
|
+
if (timeoutMs === 0)
|
|
257
|
+
return Promise.resolve(false);
|
|
258
|
+
return new Promise((resolve) => {
|
|
259
|
+
const timer = setTimeout(() => resolve(false), timeoutMs);
|
|
260
|
+
this.authorityPromise.then((ready) => {
|
|
261
|
+
clearTimeout(timer);
|
|
262
|
+
resolve(ready);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
parseLifecycleRecord(params) {
|
|
267
|
+
if (typeof params.threadId !== 'string' || typeof params.turnId !== 'string' || !isRecord(params.item)) {
|
|
268
|
+
return undefined;
|
|
269
|
+
}
|
|
270
|
+
const item = params.item;
|
|
271
|
+
if (item.type !== 'mcpToolCall' || typeof item.id !== 'string' || !item.id
|
|
272
|
+
|| typeof item.server !== 'string' || !item.server || typeof item.tool !== 'string' || !item.tool
|
|
273
|
+
|| !isRecord(item.arguments ?? {}))
|
|
274
|
+
return undefined;
|
|
275
|
+
try {
|
|
276
|
+
canonicalizeJson(item.arguments ?? {});
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
return undefined;
|
|
280
|
+
}
|
|
281
|
+
return { threadId: params.threadId, turnId: params.turnId, item: item };
|
|
282
|
+
}
|
|
283
|
+
validateLifecycleTurn(record) {
|
|
284
|
+
return this.authoritativeTurn === undefined
|
|
285
|
+
|| (record.threadId === this.authoritativeTurn.threadId && record.turnId === this.authoritativeTurn.turnId);
|
|
286
|
+
}
|
|
287
|
+
failLifecycle(message) {
|
|
288
|
+
this.poison(message);
|
|
289
|
+
throw new Error(message);
|
|
290
|
+
}
|
|
291
|
+
poison(message) {
|
|
292
|
+
if (this.poisoned)
|
|
293
|
+
return;
|
|
294
|
+
this.poisoned = true;
|
|
295
|
+
this.host.failProtocol(message);
|
|
296
|
+
this.resolveAuthority?.(false);
|
|
297
|
+
this.resolveAuthority = undefined;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
function isPlainObject(value) {
|
|
2
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
3
|
+
return false;
|
|
4
|
+
const prototype = Object.getPrototypeOf(value);
|
|
5
|
+
return prototype === Object.prototype || prototype === null;
|
|
6
|
+
}
|
|
7
|
+
/** Deterministic serialization for JSON authority inputs. Rejects non-JSON shapes. */
|
|
8
|
+
export function canonicalizeJson(value, ancestors = new Set()) {
|
|
9
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') {
|
|
10
|
+
return JSON.stringify(value);
|
|
11
|
+
}
|
|
12
|
+
if (typeof value === 'number') {
|
|
13
|
+
if (!Number.isFinite(value))
|
|
14
|
+
throw new TypeError('MCP authority values must contain only finite JSON numbers');
|
|
15
|
+
return JSON.stringify(value);
|
|
16
|
+
}
|
|
17
|
+
if (Array.isArray(value)) {
|
|
18
|
+
if (ancestors.has(value))
|
|
19
|
+
throw new TypeError('MCP authority values must be acyclic JSON');
|
|
20
|
+
if (Object.keys(value).some((key) => !/^(0|[1-9]\d*)$/.test(key) || Number(key) >= value.length)) {
|
|
21
|
+
throw new TypeError('MCP authority values must not contain non-index array properties');
|
|
22
|
+
}
|
|
23
|
+
ancestors.add(value);
|
|
24
|
+
const serialized = Array.from({ length: value.length }, (_, index) => {
|
|
25
|
+
if (!Object.prototype.hasOwnProperty.call(value, index)) {
|
|
26
|
+
throw new TypeError('MCP authority values must not contain sparse arrays');
|
|
27
|
+
}
|
|
28
|
+
return canonicalizeJson(value[index], ancestors);
|
|
29
|
+
});
|
|
30
|
+
ancestors.delete(value);
|
|
31
|
+
return `[${serialized.join(',')}]`;
|
|
32
|
+
}
|
|
33
|
+
if (!isPlainObject(value))
|
|
34
|
+
throw new TypeError('MCP authority values must contain only plain JSON values');
|
|
35
|
+
if (ancestors.has(value))
|
|
36
|
+
throw new TypeError('MCP authority values must be acyclic JSON');
|
|
37
|
+
ancestors.add(value);
|
|
38
|
+
const entries = Reflect.ownKeys(value).map((key) => {
|
|
39
|
+
if (typeof key !== 'string')
|
|
40
|
+
throw new TypeError('MCP authority values must not contain symbol keys');
|
|
41
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
42
|
+
if (!descriptor.enumerable || !('value' in descriptor)) {
|
|
43
|
+
throw new TypeError('MCP authority values must contain only enumerable data properties');
|
|
44
|
+
}
|
|
45
|
+
return [key, descriptor.value];
|
|
46
|
+
});
|
|
47
|
+
const serialized = entries.sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
|
|
48
|
+
.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalizeJson(entry, ancestors)}`);
|
|
49
|
+
ancestors.delete(value);
|
|
50
|
+
return `{${serialized.join(',')}}`;
|
|
51
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare const MCP_ANNOTATION_PROTOCOL_FLOOR = "2025-03-26";
|
|
2
|
+
export declare const MCP_LATEST_ANNOTATION_PROTOCOL = "2025-11-25";
|
|
3
|
+
export interface GeneratedMcpInitializeResult {
|
|
4
|
+
protocolVersion: string;
|
|
5
|
+
capabilities: {
|
|
6
|
+
tools: Record<string, never>;
|
|
7
|
+
};
|
|
8
|
+
serverInfo: {
|
|
9
|
+
name: string;
|
|
10
|
+
version: '1.0.0';
|
|
11
|
+
};
|
|
12
|
+
instructions?: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function negotiateGeneratedMcpProtocol(requestedVersion: unknown): string;
|
|
15
|
+
export declare function buildGeneratedMcpInitializeResult(opts: {
|
|
16
|
+
requestedVersion: unknown;
|
|
17
|
+
serverName: string;
|
|
18
|
+
instructions?: string;
|
|
19
|
+
}): GeneratedMcpInitializeResult;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export const MCP_ANNOTATION_PROTOCOL_FLOOR = '2025-03-26';
|
|
2
|
+
export const MCP_LATEST_ANNOTATION_PROTOCOL = '2025-11-25';
|
|
3
|
+
const SUPPORTED_ANNOTATION_PROTOCOLS = new Set([
|
|
4
|
+
MCP_ANNOTATION_PROTOCOL_FLOOR,
|
|
5
|
+
'2025-06-18',
|
|
6
|
+
MCP_LATEST_ANNOTATION_PROTOCOL,
|
|
7
|
+
]);
|
|
8
|
+
export function negotiateGeneratedMcpProtocol(requestedVersion) {
|
|
9
|
+
if (typeof requestedVersion !== 'string') {
|
|
10
|
+
throw new Error('MCP initialize requires a string protocolVersion');
|
|
11
|
+
}
|
|
12
|
+
if (requestedVersion < MCP_ANNOTATION_PROTOCOL_FLOOR) {
|
|
13
|
+
throw new Error(`MCP protocol ${requestedVersion} predates Tool Annotations`);
|
|
14
|
+
}
|
|
15
|
+
return SUPPORTED_ANNOTATION_PROTOCOLS.has(requestedVersion)
|
|
16
|
+
? requestedVersion
|
|
17
|
+
: MCP_LATEST_ANNOTATION_PROTOCOL;
|
|
18
|
+
}
|
|
19
|
+
export function buildGeneratedMcpInitializeResult(opts) {
|
|
20
|
+
return {
|
|
21
|
+
protocolVersion: negotiateGeneratedMcpProtocol(opts.requestedVersion),
|
|
22
|
+
capabilities: { tools: {} },
|
|
23
|
+
serverInfo: { name: opts.serverName, version: '1.0.0' },
|
|
24
|
+
...(opts.instructions !== undefined ? { instructions: opts.instructions } : {}),
|
|
25
|
+
};
|
|
26
|
+
}
|
package/dist/core/mcp-mock.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import { MockMcpServerConfig, MockMcpServerDescriptor } from './mcp-mock.types.js';
|
|
2
|
-
export type { MockMcpTool, MockMcpServerConfig, MockMcpServerDescriptor } from './mcp-mock.types.js';
|
|
2
|
+
export type { MockMcpTool, MockMcpToolAnnotations, MockMcpServerConfig, MockMcpServerDescriptor, } from './mcp-mock.types.js';
|
|
3
3
|
export declare function mockMcpServer(config: MockMcpServerConfig): MockMcpServerDescriptor;
|
package/dist/core/mcp-mock.js
CHANGED
|
@@ -5,6 +5,15 @@ export function mockMcpServer(config) {
|
|
|
5
5
|
if (!Array.isArray(config.tools) || config.tools.length === 0) {
|
|
6
6
|
throw new Error('mockMcpServer: must have at least one tool');
|
|
7
7
|
}
|
|
8
|
+
if (config.instructions !== undefined) {
|
|
9
|
+
if (typeof config.instructions !== 'string' || config.instructions.length === 0) {
|
|
10
|
+
throw new Error('mockMcpServer: instructions must be a non-empty string');
|
|
11
|
+
}
|
|
12
|
+
if (Buffer.byteLength(config.instructions, 'utf8') > 32 * 1024) {
|
|
13
|
+
throw new Error('mockMcpServer: instructions must be at most 32 KiB of UTF-8');
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const readOnlyHints = new Map();
|
|
8
17
|
for (let i = 0; i < config.tools.length; i++) {
|
|
9
18
|
const tool = config.tools[i];
|
|
10
19
|
if (!tool.name || typeof tool.name !== 'string') {
|
|
@@ -18,6 +27,21 @@ export function mockMcpServer(config) {
|
|
|
18
27
|
throw new Error(`mockMcpServer: tools[${i}].when is not a valid regex: ${e.message}`);
|
|
19
28
|
}
|
|
20
29
|
}
|
|
30
|
+
if (tool.annotations !== undefined) {
|
|
31
|
+
if (tool.annotations === null || typeof tool.annotations !== 'object' || Array.isArray(tool.annotations)) {
|
|
32
|
+
throw new Error(`mockMcpServer: tools[${i}].annotations must be an object`);
|
|
33
|
+
}
|
|
34
|
+
const hint = tool.annotations.readOnlyHint;
|
|
35
|
+
if (hint !== undefined && typeof hint !== 'boolean') {
|
|
36
|
+
throw new Error(`mockMcpServer: tools[${i}].annotations.readOnlyHint must be a boolean`);
|
|
37
|
+
}
|
|
38
|
+
const previous = readOnlyHints.get(tool.name);
|
|
39
|
+
if (hint !== undefined && previous !== undefined && previous !== hint) {
|
|
40
|
+
throw new Error(`mockMcpServer: conflicting readOnlyHint values for tool "${tool.name}"`);
|
|
41
|
+
}
|
|
42
|
+
if (hint !== undefined)
|
|
43
|
+
readOnlyHints.set(tool.name, hint);
|
|
44
|
+
}
|
|
21
45
|
}
|
|
22
46
|
return { __type: 'mock_mcp_server', config };
|
|
23
47
|
}
|
|
@@ -1,12 +1,18 @@
|
|
|
1
|
+
export interface MockMcpToolAnnotations {
|
|
2
|
+
readOnlyHint?: boolean;
|
|
3
|
+
}
|
|
1
4
|
export interface MockMcpTool {
|
|
2
5
|
name: string;
|
|
3
6
|
description?: string;
|
|
4
7
|
inputSchema?: Record<string, unknown>;
|
|
8
|
+
annotations?: MockMcpToolAnnotations;
|
|
5
9
|
when?: string;
|
|
6
10
|
response: unknown;
|
|
7
11
|
}
|
|
8
12
|
export interface MockMcpServerConfig {
|
|
9
13
|
name: string;
|
|
14
|
+
/** Trusted eval-author guidance exposed by MCP initialize. Never an authorization control. */
|
|
15
|
+
instructions?: string;
|
|
10
16
|
tools: MockMcpTool[];
|
|
11
17
|
}
|
|
12
18
|
export interface MockMcpServerDescriptor {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare function assertScriptedMcpSchemaProfile(schema: Record<string, unknown>, identity: {
|
|
2
|
+
serverName: string;
|
|
3
|
+
toolName: string;
|
|
4
|
+
}): void;
|
|
5
|
+
/**
|
|
6
|
+
* Anthropic's tool API rejects composition at the input-schema root. Convert
|
|
7
|
+
* the common object-allOf form to its equivalent merged object shape while
|
|
8
|
+
* retaining nested schemas (including nested composition) unchanged.
|
|
9
|
+
*/
|
|
10
|
+
export declare function compileScriptedMcpSchemaForProvider(schema: Record<string, unknown>): Record<string, unknown>;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
2
|
+
const DIALECT = 'https://json-schema.org/draft/2020-12/schema';
|
|
3
|
+
const REFERENCE_KEYWORDS = new Set(['$ref', '$defs', '$recursiveRef', '$dynamicRef']);
|
|
4
|
+
function isRecord(value) {
|
|
5
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
6
|
+
return false;
|
|
7
|
+
const prototype = Object.getPrototypeOf(value);
|
|
8
|
+
return prototype === Object.prototype || prototype === null;
|
|
9
|
+
}
|
|
10
|
+
function walkSchema(value, path) {
|
|
11
|
+
if (Array.isArray(value)) {
|
|
12
|
+
value.forEach((entry, index) => walkSchema(entry, `${path}/${index}`));
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
if (!isRecord(value))
|
|
16
|
+
return;
|
|
17
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
18
|
+
const childPath = `${path}/${key}`;
|
|
19
|
+
if (REFERENCE_KEYWORDS.has(key)) {
|
|
20
|
+
throw new Error(`unsupported keyword ${key} at ${childPath}`);
|
|
21
|
+
}
|
|
22
|
+
if ((key === 'exclusiveMinimum' || key === 'exclusiveMaximum') && typeof entry === 'boolean') {
|
|
23
|
+
throw new Error(`legacy boolean ${key} at ${childPath}`);
|
|
24
|
+
}
|
|
25
|
+
if (key === 'nullable')
|
|
26
|
+
throw new Error(`OpenAPI-only keyword nullable at ${childPath}`);
|
|
27
|
+
walkSchema(entry, childPath);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function assertScriptedMcpSchemaProfile(schema, identity) {
|
|
31
|
+
const prefix = `Scripted MCP schema for ${identity.serverName}.${identity.toolName}`;
|
|
32
|
+
if (schema.type !== 'object') {
|
|
33
|
+
throw new Error(`${prefix} must have object root type at /type`);
|
|
34
|
+
}
|
|
35
|
+
if (schema.$schema !== undefined && schema.$schema !== DIALECT) {
|
|
36
|
+
throw new Error(`${prefix} uses unsupported dialect at /$schema`);
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
walkSchema(schema, '');
|
|
40
|
+
const ajv = new Ajv2020({ strict: true, allErrors: true });
|
|
41
|
+
ajv.compile(schema);
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
45
|
+
throw new Error(`${prefix} is not portable: ${message.slice(0, 500)}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Anthropic's tool API rejects composition at the input-schema root. Convert
|
|
50
|
+
* the common object-allOf form to its equivalent merged object shape while
|
|
51
|
+
* retaining nested schemas (including nested composition) unchanged.
|
|
52
|
+
*/
|
|
53
|
+
export function compileScriptedMcpSchemaForProvider(schema) {
|
|
54
|
+
if (!Array.isArray(schema.allOf))
|
|
55
|
+
return schema;
|
|
56
|
+
const branches = schema.allOf;
|
|
57
|
+
if (!branches.every(isMergeableObjectBranch))
|
|
58
|
+
return schema;
|
|
59
|
+
const root = { ...schema };
|
|
60
|
+
delete root.allOf;
|
|
61
|
+
const properties = isRecord(root.properties) ? { ...root.properties } : {};
|
|
62
|
+
const required = new Set(Array.isArray(root.required) ? root.required.filter((entry) => typeof entry === 'string') : []);
|
|
63
|
+
for (const branch of branches) {
|
|
64
|
+
if (isRecord(branch.properties)) {
|
|
65
|
+
for (const [name, propertySchema] of Object.entries(branch.properties)) {
|
|
66
|
+
const existing = properties[name];
|
|
67
|
+
properties[name] = existing === undefined
|
|
68
|
+
? propertySchema
|
|
69
|
+
: { allOf: [existing, propertySchema] };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (Array.isArray(branch.required)) {
|
|
73
|
+
for (const name of branch.required)
|
|
74
|
+
if (typeof name === 'string')
|
|
75
|
+
required.add(name);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (Object.keys(properties).length > 0)
|
|
79
|
+
root.properties = properties;
|
|
80
|
+
if (required.size > 0)
|
|
81
|
+
root.required = [...required];
|
|
82
|
+
return root;
|
|
83
|
+
}
|
|
84
|
+
function isMergeableObjectBranch(value) {
|
|
85
|
+
if (!isRecord(value))
|
|
86
|
+
return false;
|
|
87
|
+
return Object.keys(value).every((key) => key === 'type' || key === 'properties' || key === 'required')
|
|
88
|
+
&& (value.type === undefined || value.type === 'object')
|
|
89
|
+
&& (value.properties === undefined || isRecord(value.properties))
|
|
90
|
+
&& (value.required === undefined || Array.isArray(value.required));
|
|
91
|
+
}
|
package/dist/mcp-mock-server.js
CHANGED
|
@@ -1,21 +1,32 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as readline from 'readline';
|
|
3
|
+
import { buildGeneratedMcpInitializeResult } from './core/generated-mcp-protocol.js';
|
|
3
4
|
function loadFixture(fixturePath) {
|
|
4
5
|
const content = fs.readFileSync(fixturePath, 'utf-8');
|
|
5
6
|
return JSON.parse(content);
|
|
6
7
|
}
|
|
7
8
|
function buildToolSchemas(fixture) {
|
|
8
|
-
const seen = new Set();
|
|
9
9
|
const schemas = [];
|
|
10
|
+
const schemasByName = new Map();
|
|
10
11
|
for (const tool of fixture.tools) {
|
|
11
|
-
|
|
12
|
+
let schema = schemasByName.get(tool.name);
|
|
13
|
+
if (!schema) {
|
|
14
|
+
schema = {
|
|
15
|
+
name: tool.name,
|
|
16
|
+
description: tool.description || `Mock tool: ${tool.name}`,
|
|
17
|
+
inputSchema: tool.inputSchema || { type: 'object' },
|
|
18
|
+
};
|
|
19
|
+
schemasByName.set(tool.name, schema);
|
|
20
|
+
schemas.push(schema);
|
|
21
|
+
}
|
|
22
|
+
const hint = tool.annotations?.readOnlyHint;
|
|
23
|
+
if (hint === undefined)
|
|
12
24
|
continue;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
});
|
|
25
|
+
const previous = schema.annotations?.readOnlyHint;
|
|
26
|
+
if (previous !== undefined && previous !== hint) {
|
|
27
|
+
throw new Error(`Conflicting readOnlyHint values for tool "${tool.name}"`);
|
|
28
|
+
}
|
|
29
|
+
schema.annotations = { readOnlyHint: hint };
|
|
19
30
|
}
|
|
20
31
|
return schemas;
|
|
21
32
|
}
|
|
@@ -80,11 +91,16 @@ function main() {
|
|
|
80
91
|
return;
|
|
81
92
|
switch (msg.method) {
|
|
82
93
|
case 'initialize':
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
94
|
+
try {
|
|
95
|
+
sendResponse(msg.id, buildGeneratedMcpInitializeResult({
|
|
96
|
+
requestedVersion: msg.params?.protocolVersion,
|
|
97
|
+
serverName: fixture.name,
|
|
98
|
+
instructions: fixture.instructions,
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
sendError(msg.id, -32602, error.message);
|
|
103
|
+
}
|
|
88
104
|
break;
|
|
89
105
|
case 'tools/list':
|
|
90
106
|
sendResponse(msg.id, { tools: toolSchemas });
|
|
@@ -109,8 +109,10 @@ async function resolveMockServerScript(workspacePath) {
|
|
|
109
109
|
throw new Error(`Mock MCP server source not found: ${sourceScript}`);
|
|
110
110
|
}
|
|
111
111
|
const ts = await import('typescript');
|
|
112
|
-
const
|
|
113
|
-
const
|
|
112
|
+
const protocolSource = path.resolve(import.meta.dirname, '../core/generated-mcp-protocol.ts');
|
|
113
|
+
const protocol = (await fs.readFile(protocolSource, 'utf-8')).replaceAll('export ', '');
|
|
114
|
+
const source = (await fs.readFile(sourceScript, 'utf-8')).replace(/import \{ buildGeneratedMcpInitializeResult \} from '.\/core\/generated-mcp-protocol\.js';\n/, '');
|
|
115
|
+
const transpiled = ts.transpileModule(`${protocol}\n${source}`, {
|
|
114
116
|
compilerOptions: {
|
|
115
117
|
target: ts.ScriptTarget.ES2022,
|
|
116
118
|
module: ts.ModuleKind.CommonJS,
|
|
@@ -6,6 +6,9 @@ import { createSandboxRoot } from './sandbox-lifecycle.js';
|
|
|
6
6
|
export const SAFE_HOST_VARS = [
|
|
7
7
|
'PATH', 'SHELL', 'LANG', 'LC_ALL', 'LC_CTYPE', 'TERM', 'USER', 'LOGNAME',
|
|
8
8
|
];
|
|
9
|
+
function copiesEntireCodexHome(relPath) {
|
|
10
|
+
return path.posix.normalize(relPath.replace(/\\/g, '/')).replace(/\/+$/, '') === '.codex';
|
|
11
|
+
}
|
|
9
12
|
export async function createSandbox(spec) {
|
|
10
13
|
const rootDir = await createSandboxRoot();
|
|
11
14
|
const workspacePath = path.join(rootDir, 'workspace');
|
|
@@ -51,6 +54,9 @@ export async function createSandbox(spec) {
|
|
|
51
54
|
}
|
|
52
55
|
// Copy specified paths from real host HOME into sandbox HOME
|
|
53
56
|
if (spec.copyFromHome) {
|
|
57
|
+
if (spec.copyFromHome.some(copiesEntireCodexHome)) {
|
|
58
|
+
console.warn('pathgrade: copyFromHome requests a copy of the entire ~/.codex directory, including config, MCP definitions, plugins, caches, sessions, and other potentially large or sensitive state. Pathgrade stages ~/.codex/auth.json automatically when cached Codex auth is needed; keep the full copy only when reproducing the host Codex environment intentionally.');
|
|
59
|
+
}
|
|
54
60
|
const realHome = os.homedir();
|
|
55
61
|
for (const relPath of spec.copyFromHome) {
|
|
56
62
|
const srcPath = path.join(realHome, relPath);
|