imprint-mcp 0.6.0 → 0.6.2
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 +2 -0
- package/package.json +4 -2
- package/prompts/audit-agent.md +4 -2
- package/prompts/auth-compile-agent.md +149 -158
- package/prompts/build-planning.md +14 -9
- package/prompts/compile-agent.md +46 -23
- package/prompts/live-verifier-agent.md +101 -0
- package/prompts/playbook-compilation.md +8 -1
- package/prompts/tool-candidate-detection.md +9 -29
- package/src/cli.ts +83 -1
- package/src/imprint/audit.ts +219 -20
- package/src/imprint/auth-compile-agent.ts +215 -155
- package/src/imprint/auth-compile-tools.ts +300 -118
- package/src/imprint/auth-verifier.ts +167 -180
- package/src/imprint/backend-cache.ts +18 -11
- package/src/imprint/backend-ladder.ts +225 -177
- package/src/imprint/build-plan.ts +3 -8
- package/src/imprint/cdp-browser-fetch.ts +930 -83
- package/src/imprint/claude-cli-compile.ts +46 -20
- package/src/imprint/codex-cli-compile.ts +436 -139
- package/src/imprint/compile-agent-types.ts +15 -10
- package/src/imprint/compile-agent.ts +88 -28
- package/src/imprint/compile-tools.ts +228 -39
- package/src/imprint/compile-verification.ts +187 -0
- package/src/imprint/compile.ts +71 -2
- package/src/imprint/credential-store.ts +57 -44
- package/src/imprint/emit.ts +3 -1
- package/src/imprint/freeform-redact.ts +27 -6
- package/src/imprint/live-verifier.ts +1665 -0
- package/src/imprint/llm.ts +22 -3
- package/src/imprint/login.ts +107 -25
- package/src/imprint/mcp-compile-server.ts +447 -114
- package/src/imprint/mcp-live-verifier-server.ts +359 -0
- package/src/imprint/mcp-server.ts +116 -35
- package/src/imprint/probe-backends.ts +88 -7
- package/src/imprint/recording-request.ts +36 -0
- package/src/imprint/request-capture.ts +40 -17
- package/src/imprint/runtime.ts +490 -337
- package/src/imprint/teach-state.ts +36 -2
- package/src/imprint/teach.ts +302 -146
- package/src/imprint/tool-candidates.ts +8 -10
- package/src/imprint/tool-loader.ts +12 -4
- package/src/imprint/types.ts +137 -68
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join as pathJoin } from 'node:path';
|
|
3
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
4
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
|
+
import {
|
|
6
|
+
CallToolRequestSchema,
|
|
7
|
+
type CallToolResult,
|
|
8
|
+
ListToolsRequestSchema,
|
|
9
|
+
type Tool,
|
|
10
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
11
|
+
import {
|
|
12
|
+
type LiveIntegrationEvidence,
|
|
13
|
+
runCapturedIntegrationCase,
|
|
14
|
+
} from './compile-verification.ts';
|
|
15
|
+
import {
|
|
16
|
+
LIVE_VERIFICATION_EVIDENCE_FILE,
|
|
17
|
+
LiveVerificationReportSchema,
|
|
18
|
+
appendLiveVerifierLog,
|
|
19
|
+
assertReportCoversWorkflowParameters,
|
|
20
|
+
effectiveParamsForEvidence,
|
|
21
|
+
hasSuiteReceiptForSession,
|
|
22
|
+
persistLiveVerificationEvidence,
|
|
23
|
+
prepareLiveVerificationBackend,
|
|
24
|
+
readPersistedLiveVerificationEvidence,
|
|
25
|
+
runLiveIntegrationSuite,
|
|
26
|
+
} from './live-verifier.ts';
|
|
27
|
+
import { loadCredentialStore } from './runtime.ts';
|
|
28
|
+
import { buildZodValidator } from './tool-loader.ts';
|
|
29
|
+
import { WorkflowSchema } from './types.ts';
|
|
30
|
+
|
|
31
|
+
export function joinBackendPreparation<T>(
|
|
32
|
+
state: { current?: { promise: Promise<T>; forceReprobe: boolean } },
|
|
33
|
+
forceReprobe: boolean,
|
|
34
|
+
start: () => Promise<T>,
|
|
35
|
+
): { promise: Promise<T>; joined: boolean } {
|
|
36
|
+
if (state.current && (!forceReprobe || state.current.forceReprobe)) {
|
|
37
|
+
return { promise: state.current.promise, joined: true };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A forced reprobe must not be satisfied by an ordinary preparation that
|
|
41
|
+
// was already in flight. Queue it behind that preparation so both calls do
|
|
42
|
+
// not race while updating the same backend cache. Further callers can join
|
|
43
|
+
// the queued forced preparation.
|
|
44
|
+
const preceding = state.current?.promise;
|
|
45
|
+
const started = preceding ? preceding.then(start, start) : start();
|
|
46
|
+
const promise = started.finally(() => {
|
|
47
|
+
if (state.current?.promise === promise) state.current = undefined;
|
|
48
|
+
});
|
|
49
|
+
state.current = { promise, forceReprobe };
|
|
50
|
+
return { promise, joined: false };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const RUN_TOOL: Tool = {
|
|
54
|
+
name: 'run_live_integration_test',
|
|
55
|
+
description:
|
|
56
|
+
'Run a targeted live call for a specific unresolved semantic question. Repeated parameters are allowed when the reason explains why.',
|
|
57
|
+
inputSchema: {
|
|
58
|
+
type: 'object',
|
|
59
|
+
properties: {
|
|
60
|
+
params: { type: 'object', additionalProperties: true },
|
|
61
|
+
reason: { type: 'string' },
|
|
62
|
+
},
|
|
63
|
+
required: ['params', 'reason'],
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const PREPARE_BACKEND_TOOL: Tool = {
|
|
68
|
+
name: 'prepare_live_backend',
|
|
69
|
+
description:
|
|
70
|
+
'Reuse the preferred backend and probe only when no valid preference exists. Set forceReprobe only after a transport, network, or browser-infrastructure failure from that backend; never reprobe for empty, incorrect, or otherwise semantic output failures.',
|
|
71
|
+
inputSchema: {
|
|
72
|
+
type: 'object',
|
|
73
|
+
properties: {
|
|
74
|
+
params: { type: 'object', additionalProperties: true },
|
|
75
|
+
reason: { type: 'string' },
|
|
76
|
+
forceReprobe: { type: 'boolean' },
|
|
77
|
+
},
|
|
78
|
+
required: ['params', 'reason'],
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const RUN_SUITE_TOOL: Tool = {
|
|
83
|
+
name: 'run_live_integration_suite',
|
|
84
|
+
description:
|
|
85
|
+
'Run the compiler-proposed final integration suite. A justified rerun is allowed after failure, timeout, or backend reprobe.',
|
|
86
|
+
inputSchema: {
|
|
87
|
+
type: 'object',
|
|
88
|
+
properties: { reason: { type: 'string' } },
|
|
89
|
+
additionalProperties: false,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const SUBMIT_TOOL: Tool = {
|
|
94
|
+
name: 'submit_verification_report',
|
|
95
|
+
description: 'Submit the final structured semantic verification report exactly once.',
|
|
96
|
+
inputSchema: {
|
|
97
|
+
type: 'object',
|
|
98
|
+
properties: {
|
|
99
|
+
status: {
|
|
100
|
+
type: 'string',
|
|
101
|
+
enum: ['approved', 'approved_with_gaps', 'changes_required', 'inconclusive'],
|
|
102
|
+
},
|
|
103
|
+
summary: { type: 'string' },
|
|
104
|
+
baseline: {
|
|
105
|
+
type: 'object',
|
|
106
|
+
properties: {
|
|
107
|
+
verdict: {
|
|
108
|
+
type: 'string',
|
|
109
|
+
enum: ['semantically_correct', 'tool_broken', 'bad_parameters', 'infrastructure'],
|
|
110
|
+
},
|
|
111
|
+
reason: { type: 'string' },
|
|
112
|
+
},
|
|
113
|
+
required: ['verdict', 'reason'],
|
|
114
|
+
},
|
|
115
|
+
parameters: {
|
|
116
|
+
type: 'array',
|
|
117
|
+
items: {
|
|
118
|
+
type: 'object',
|
|
119
|
+
properties: {
|
|
120
|
+
name: { type: 'string' },
|
|
121
|
+
verdict: {
|
|
122
|
+
type: 'string',
|
|
123
|
+
enum: ['works', 'no_op', 'broken', 'untestable'],
|
|
124
|
+
},
|
|
125
|
+
reason: { type: 'string' },
|
|
126
|
+
},
|
|
127
|
+
required: ['name', 'verdict', 'reason'],
|
|
128
|
+
additionalProperties: false,
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
issues: {
|
|
132
|
+
type: 'array',
|
|
133
|
+
items: {
|
|
134
|
+
type: 'object',
|
|
135
|
+
properties: {
|
|
136
|
+
summary: { type: 'string' },
|
|
137
|
+
expected: { type: 'string' },
|
|
138
|
+
observed: { type: 'string' },
|
|
139
|
+
suggestedFix: { type: 'string' },
|
|
140
|
+
},
|
|
141
|
+
required: ['summary', 'expected', 'observed', 'suggestedFix'],
|
|
142
|
+
additionalProperties: false,
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
gaps: { type: 'array', items: { type: 'string' } },
|
|
146
|
+
},
|
|
147
|
+
required: ['status', 'summary', 'baseline', 'parameters', 'issues', 'gaps'],
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export async function runLiveVerifierMcpServer(opts: {
|
|
152
|
+
workflowPath: string;
|
|
153
|
+
reportPath: string;
|
|
154
|
+
sessionLabel: string;
|
|
155
|
+
logPath?: string;
|
|
156
|
+
attempt?: number;
|
|
157
|
+
}): Promise<void> {
|
|
158
|
+
const workflow = WorkflowSchema.parse(JSON.parse(readFileSync(opts.workflowPath, 'utf8')));
|
|
159
|
+
const validator = buildZodValidator(workflow.parameters);
|
|
160
|
+
const credentials = (await loadCredentialStore(workflow.site)) ?? undefined;
|
|
161
|
+
let submitted = false;
|
|
162
|
+
const backendPreparation: {
|
|
163
|
+
current?: {
|
|
164
|
+
promise: Promise<Awaited<ReturnType<typeof prepareLiveVerificationBackend>>>;
|
|
165
|
+
forceReprobe: boolean;
|
|
166
|
+
};
|
|
167
|
+
} = {};
|
|
168
|
+
const evidencePath = pathJoin(dirname(opts.reportPath), LIVE_VERIFICATION_EVIDENCE_FILE);
|
|
169
|
+
let targetedCallSequence = readPersistedLiveVerificationEvidence(evidencePath).reduce(
|
|
170
|
+
(highest, item) => {
|
|
171
|
+
const match = /^targeted-call-(\d+)$/.exec(item.label);
|
|
172
|
+
return match ? Math.max(highest, Number(match[1])) : highest;
|
|
173
|
+
},
|
|
174
|
+
0,
|
|
175
|
+
);
|
|
176
|
+
const hasSuiteReceipt = (): boolean => hasSuiteReceiptForSession(evidencePath, opts.sessionLabel);
|
|
177
|
+
|
|
178
|
+
const server = new Server(
|
|
179
|
+
{ name: 'imprint-live-verifier', version: '1.0.0' },
|
|
180
|
+
{ capabilities: { tools: {} } },
|
|
181
|
+
);
|
|
182
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
183
|
+
tools: [PREPARE_BACKEND_TOOL, RUN_SUITE_TOOL, RUN_TOOL, SUBMIT_TOOL],
|
|
184
|
+
}));
|
|
185
|
+
server.setRequestHandler(CallToolRequestSchema, async (request): Promise<CallToolResult> => {
|
|
186
|
+
if (request.params.name === 'prepare_live_backend') {
|
|
187
|
+
const input = request.params.arguments as {
|
|
188
|
+
params?: unknown;
|
|
189
|
+
reason?: unknown;
|
|
190
|
+
forceReprobe?: unknown;
|
|
191
|
+
};
|
|
192
|
+
if (typeof input.reason !== 'string' || !input.reason.trim()) {
|
|
193
|
+
return errorResult('reason must explain the backend preparation or reprobe');
|
|
194
|
+
}
|
|
195
|
+
const parsed = validator.safeParse(input.params ?? {});
|
|
196
|
+
if (!parsed.success) return errorResult(`invalid tool params: ${parsed.error.message}`);
|
|
197
|
+
try {
|
|
198
|
+
const forceReprobe = input.forceReprobe === true;
|
|
199
|
+
const preparation = joinBackendPreparation(backendPreparation, forceReprobe, () =>
|
|
200
|
+
prepareLiveVerificationBackend({
|
|
201
|
+
workflowPath: opts.workflowPath,
|
|
202
|
+
params: parsed.data as Record<string, string | number | boolean>,
|
|
203
|
+
reason: input.reason as string,
|
|
204
|
+
forceReprobe,
|
|
205
|
+
logPath: opts.logPath,
|
|
206
|
+
attempt: opts.attempt,
|
|
207
|
+
}),
|
|
208
|
+
);
|
|
209
|
+
if (preparation.joined) {
|
|
210
|
+
appendLiveVerifierLog(opts.logPath, {
|
|
211
|
+
type: 'backend.prepare.joined',
|
|
212
|
+
attempt: opts.attempt,
|
|
213
|
+
reason: input.reason,
|
|
214
|
+
forceReprobe: input.forceReprobe === true,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
const result = await preparation.promise;
|
|
218
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
219
|
+
} catch (error) {
|
|
220
|
+
return errorResult(error instanceof Error ? error.message : String(error));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (request.params.name === 'run_live_integration_suite') {
|
|
224
|
+
const input = request.params.arguments as { reason?: unknown };
|
|
225
|
+
try {
|
|
226
|
+
const suite = await runLiveIntegrationSuite({
|
|
227
|
+
toolDir: dirname(opts.workflowPath),
|
|
228
|
+
logPath: opts.logPath,
|
|
229
|
+
attempt: opts.attempt,
|
|
230
|
+
reason: typeof input.reason === 'string' ? input.reason : undefined,
|
|
231
|
+
sessionLabel: opts.sessionLabel,
|
|
232
|
+
});
|
|
233
|
+
return { content: [{ type: 'text', text: JSON.stringify(suite) }] };
|
|
234
|
+
} catch (error) {
|
|
235
|
+
return errorResult(error instanceof Error ? error.message : String(error));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (request.params.name === 'run_live_integration_test') {
|
|
239
|
+
if (!hasSuiteReceipt())
|
|
240
|
+
return errorResult('run the final integration suite before a targeted call');
|
|
241
|
+
const input = request.params.arguments as { params?: unknown; reason?: unknown };
|
|
242
|
+
if (typeof input.reason !== 'string' || !input.reason.trim()) {
|
|
243
|
+
return errorResult('reason must explain the unresolved semantic question');
|
|
244
|
+
}
|
|
245
|
+
const parsed = validator.safeParse(input.params ?? {});
|
|
246
|
+
if (!parsed.success) return errorResult(`invalid tool params: ${parsed.error.message}`);
|
|
247
|
+
const label = `targeted-call-${++targetedCallSequence}`;
|
|
248
|
+
const startedAt = Date.now();
|
|
249
|
+
appendLiveVerifierLog(opts.logPath, {
|
|
250
|
+
type: 'targeted-call.started',
|
|
251
|
+
attempt: opts.attempt,
|
|
252
|
+
label,
|
|
253
|
+
reason: input.reason,
|
|
254
|
+
params: parsed.data,
|
|
255
|
+
});
|
|
256
|
+
let run: Awaited<ReturnType<typeof runCapturedIntegrationCase>>;
|
|
257
|
+
try {
|
|
258
|
+
run = await runCapturedIntegrationCase({
|
|
259
|
+
caseName: label,
|
|
260
|
+
workflowPath: opts.workflowPath,
|
|
261
|
+
params: parsed.data as Record<string, string | number | boolean>,
|
|
262
|
+
credentials,
|
|
263
|
+
preferredOnlyBackend: true,
|
|
264
|
+
});
|
|
265
|
+
} catch (error) {
|
|
266
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
267
|
+
appendLiveVerifierLog(opts.logPath, {
|
|
268
|
+
type: 'targeted-call.failed',
|
|
269
|
+
attempt: opts.attempt,
|
|
270
|
+
label,
|
|
271
|
+
error: message,
|
|
272
|
+
});
|
|
273
|
+
return errorResult(message);
|
|
274
|
+
}
|
|
275
|
+
const supplemental: LiveIntegrationEvidence = {
|
|
276
|
+
schemaVersion: 1 as const,
|
|
277
|
+
kind: 'call',
|
|
278
|
+
label,
|
|
279
|
+
caseName: label,
|
|
280
|
+
toolName: workflow.toolName,
|
|
281
|
+
requestedParams: parsed.data as Record<string, string | number | boolean>,
|
|
282
|
+
effectiveParams: effectiveParamsForEvidence(
|
|
283
|
+
workflow.parameters,
|
|
284
|
+
parsed.data as Record<string, string | number | boolean>,
|
|
285
|
+
),
|
|
286
|
+
result: run.result,
|
|
287
|
+
usedBackend: run.usedBackend,
|
|
288
|
+
attempts: run.attempts,
|
|
289
|
+
durationMs: Date.now() - startedAt,
|
|
290
|
+
};
|
|
291
|
+
persistLiveVerificationEvidence(evidencePath, [supplemental]);
|
|
292
|
+
appendLiveVerifierLog(opts.logPath, {
|
|
293
|
+
type: 'targeted-call.completed',
|
|
294
|
+
attempt: opts.attempt,
|
|
295
|
+
record: supplemental,
|
|
296
|
+
});
|
|
297
|
+
return {
|
|
298
|
+
content: [
|
|
299
|
+
{
|
|
300
|
+
type: 'text',
|
|
301
|
+
text: JSON.stringify({
|
|
302
|
+
label: supplemental.label,
|
|
303
|
+
toolName: workflow.toolName,
|
|
304
|
+
requestedParams: parsed.data,
|
|
305
|
+
result: run.result,
|
|
306
|
+
usedBackend: run.usedBackend,
|
|
307
|
+
attempts: run.attempts,
|
|
308
|
+
}),
|
|
309
|
+
},
|
|
310
|
+
],
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
if (request.params.name === 'submit_verification_report') {
|
|
314
|
+
if (submitted) return errorResult('verification report was already submitted');
|
|
315
|
+
const parsed = LiveVerificationReportSchema.safeParse(request.params.arguments);
|
|
316
|
+
if (!parsed.success)
|
|
317
|
+
return errorResult(`invalid verification report: ${parsed.error.message}`);
|
|
318
|
+
if (!hasSuiteReceipt()) {
|
|
319
|
+
return errorResult('run the final integration suite before submitting');
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
assertReportCoversWorkflowParameters(parsed.data, workflow.parameters);
|
|
323
|
+
} catch (error) {
|
|
324
|
+
return errorResult(error instanceof Error ? error.message : String(error));
|
|
325
|
+
}
|
|
326
|
+
submitted = true;
|
|
327
|
+
appendLiveVerifierLog(opts.logPath, {
|
|
328
|
+
type: 'report.submitted',
|
|
329
|
+
attempt: opts.attempt,
|
|
330
|
+
report: parsed.data,
|
|
331
|
+
});
|
|
332
|
+
writeFileSync(opts.reportPath, `${JSON.stringify(parsed.data, null, 2)}\n`, {
|
|
333
|
+
encoding: 'utf8',
|
|
334
|
+
mode: 0o600,
|
|
335
|
+
});
|
|
336
|
+
return {
|
|
337
|
+
content: [{ type: 'text', text: 'Report accepted. End the verification session now.' }],
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
return errorResult(`unknown tool: ${request.params.name}`);
|
|
341
|
+
});
|
|
342
|
+
const transport = new StdioServerTransport();
|
|
343
|
+
await server.connect(transport);
|
|
344
|
+
|
|
345
|
+
// Server.connect() resolves after initialization; it does not own the
|
|
346
|
+
// process lifetime. Keep stdio alive until the verifier disconnects, just
|
|
347
|
+
// like the compile MCP server does, or Codex can lose the server during its
|
|
348
|
+
// initialize handshake.
|
|
349
|
+
await new Promise<void>((resolve) => {
|
|
350
|
+
const close = (): void => resolve();
|
|
351
|
+
transport.onclose = close;
|
|
352
|
+
process.once('SIGINT', close);
|
|
353
|
+
process.once('SIGTERM', close);
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function errorResult(message: string): CallToolResult {
|
|
358
|
+
return { isError: true, content: [{ type: 'text', text: message }] };
|
|
359
|
+
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* See docs/getting-started.md for Claude Desktop / mcp-inspector wire-up.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { randomUUID } from 'node:crypto';
|
|
7
8
|
import { existsSync } from 'node:fs';
|
|
8
9
|
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
|
|
9
10
|
import { resolve as pathResolve } from 'node:path';
|
|
@@ -30,7 +31,7 @@ import {
|
|
|
30
31
|
buildZodValidator,
|
|
31
32
|
discoverTools,
|
|
32
33
|
} from './tool-loader.ts';
|
|
33
|
-
import type { ConcreteBackend, ToolResult, WorkflowParameter } from './types.ts';
|
|
34
|
+
import type { ConcreteBackend, ToolResult, Workflow, WorkflowParameter } from './types.ts';
|
|
34
35
|
import { VERSION } from './version.ts';
|
|
35
36
|
|
|
36
37
|
interface RunMcpServerOptions {
|
|
@@ -60,19 +61,32 @@ interface ResolvedTool extends DiscoveredTool {
|
|
|
60
61
|
/** Tool description shown to MCP clients. Includes the operator's
|
|
61
62
|
* recorded narration when present — surprisingly load-bearing for the
|
|
62
63
|
* LLM picking the right tool. */
|
|
63
|
-
function buildToolDescription(w: ResolvedTool['workflow']): string {
|
|
64
|
+
export function buildToolDescription(w: ResolvedTool['workflow']): string {
|
|
64
65
|
const base = w.intent.description;
|
|
65
66
|
const said = w.intent.userSaid?.trim();
|
|
66
|
-
|
|
67
|
+
const context = said ? `${base}\n\nRecording context: "${said}"` : base;
|
|
68
|
+
if (!w.limitations?.length) return context;
|
|
69
|
+
const limitations = w.limitations
|
|
70
|
+
.map((entry) => {
|
|
71
|
+
const omitted = entry.omittedParameters?.length
|
|
72
|
+
? ` Omitted inputs: ${entry.omittedParameters.join(', ')}.`
|
|
73
|
+
: '';
|
|
74
|
+
return `- ${entry.feature}: ${entry.reason}${omitted}`;
|
|
75
|
+
})
|
|
76
|
+
.join('\n');
|
|
77
|
+
return `${context}\n\nKnown limitations:\n${limitations}`;
|
|
67
78
|
}
|
|
68
79
|
|
|
69
80
|
/** MCP advertises tool input as JSON Schema; build it directly from
|
|
70
81
|
* workflow parameters rather than going through Zod. */
|
|
71
82
|
export function buildJsonSchema(
|
|
72
83
|
parameters: WorkflowParameter[],
|
|
73
|
-
opts?: {
|
|
84
|
+
opts?: { authConfig?: Workflow['authConfig'] },
|
|
74
85
|
): Tool['inputSchema'] {
|
|
75
|
-
const properties: Record<
|
|
86
|
+
const properties: Record<
|
|
87
|
+
string,
|
|
88
|
+
{ type: string; description: string; enum?: Array<string | number | boolean> }
|
|
89
|
+
> = {};
|
|
76
90
|
const required: string[] = [];
|
|
77
91
|
for (const p of parameters) {
|
|
78
92
|
// Producer-sourced params: tell the orchestrating LLM where to mint/derive
|
|
@@ -81,18 +95,24 @@ export function buildJsonSchema(
|
|
|
81
95
|
const description = p.sourcedFrom
|
|
82
96
|
? `${p.description} Obtain this value from the \`${p.sourcedFrom.tool}\` tool's \`${p.sourcedFrom.field}\` output — call \`${p.sourcedFrom.tool}\` first and reuse the value across calls (no need to re-fetch each time).`
|
|
83
97
|
: p.description;
|
|
84
|
-
|
|
85
|
-
|
|
98
|
+
const property: { type: string; description: string; enum?: Array<string | number | boolean> } =
|
|
99
|
+
{
|
|
100
|
+
type: p.type,
|
|
101
|
+
description,
|
|
102
|
+
};
|
|
103
|
+
if (p.choices?.length) property.enum = p.choices;
|
|
104
|
+
properties[p.name] = property;
|
|
105
|
+
if (p.default === undefined && !opts?.authConfig) required.push(p.name);
|
|
86
106
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
107
|
+
if (opts?.authConfig) {
|
|
108
|
+
properties.action = {
|
|
109
|
+
type: 'string',
|
|
110
|
+
description: `Auth action to run. Defaults to ${JSON.stringify(opts.authConfig.entry)}.`,
|
|
111
|
+
enum: Object.keys(opts.authConfig.actions),
|
|
112
|
+
};
|
|
113
|
+
properties.continuation = {
|
|
114
|
+
type: 'string',
|
|
115
|
+
description: 'One-use opaque token returned by the previous auth action.',
|
|
96
116
|
};
|
|
97
117
|
}
|
|
98
118
|
return {
|
|
@@ -104,6 +124,33 @@ export function buildJsonSchema(
|
|
|
104
124
|
|
|
105
125
|
const log = createLog('mcp');
|
|
106
126
|
|
|
127
|
+
interface StoredAuthContinuation {
|
|
128
|
+
toolName: string;
|
|
129
|
+
nextAction?: string;
|
|
130
|
+
state: Record<string, unknown>;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export class AuthContinuationStore {
|
|
134
|
+
private readonly values = new Map<string, StoredAuthContinuation>();
|
|
135
|
+
|
|
136
|
+
issue(value: StoredAuthContinuation): string {
|
|
137
|
+
const token = randomUUID();
|
|
138
|
+
this.values.set(token, value);
|
|
139
|
+
return token;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
consume(token: string, toolName: string, action: string): Record<string, unknown> | undefined {
|
|
143
|
+
const value = this.values.get(token);
|
|
144
|
+
this.values.delete(token);
|
|
145
|
+
if (!value || value.toolName !== toolName || value.nextAction !== action) return undefined;
|
|
146
|
+
return value.state;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
clear(): void {
|
|
150
|
+
this.values.clear();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
107
154
|
export async function runSerializedBySite<T>(
|
|
108
155
|
queues: Map<string, Promise<void>>,
|
|
109
156
|
site: string,
|
|
@@ -141,12 +188,20 @@ function buildServer(
|
|
|
141
188
|
{
|
|
142
189
|
capabilities: { tools: {} },
|
|
143
190
|
instructions:
|
|
144
|
-
'Imprint runs deterministic workflows captured from real browser sessions.
|
|
191
|
+
'Imprint runs deterministic workflows captured from real browser sessions. Data tools can escalate through API, browser, and playbook backends. Authenticate tools execute their compiled action program without playbooks. Error codes: AUTH_EXPIRED (401); ACTION_REQUIRED (run nextAction and pass continuation back unchanged); STATE_MISSING; FORBIDDEN; RATE_LIMITED; BAD_RESPONSE; NETWORK; UNKNOWN.',
|
|
145
192
|
},
|
|
146
193
|
);
|
|
147
194
|
|
|
148
195
|
const validators = new Map(
|
|
149
|
-
tools.map(
|
|
196
|
+
tools.map(
|
|
197
|
+
(t) =>
|
|
198
|
+
[
|
|
199
|
+
t.workflow.toolName,
|
|
200
|
+
buildZodValidator(t.workflow.parameters, {
|
|
201
|
+
allOptional: t.workflow.toolKind === 'authenticate',
|
|
202
|
+
}),
|
|
203
|
+
] as const,
|
|
204
|
+
),
|
|
150
205
|
);
|
|
151
206
|
|
|
152
207
|
// Per-site stealth-fetch cache so the ~12s bootstrap runs once per site.
|
|
@@ -156,6 +211,7 @@ function buildServer(
|
|
|
156
211
|
// the first successful call so subsequent calls reuse it (~2-5s vs ~33s).
|
|
157
212
|
const cdpPool = new Map<string, CdpBrowserFetch>();
|
|
158
213
|
const cdpIdleTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
214
|
+
const authContinuations = new AuthContinuationStore();
|
|
159
215
|
const CDP_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
160
216
|
|
|
161
217
|
// Per-tool memo of the winning backend for THIS server session. After the
|
|
@@ -208,16 +264,31 @@ function buildServer(
|
|
|
208
264
|
string | number | boolean
|
|
209
265
|
>;
|
|
210
266
|
|
|
211
|
-
// Auth 2FA bridge (stateless): the validator strips unknown keys, so read the
|
|
212
|
-
// echoed `twoFactorContext` from the raw arguments and seed it as initialState
|
|
213
|
-
// so a token captured on the initiate call resolves on this submit_otp call.
|
|
214
267
|
const rawArgs = (req.params.arguments ?? {}) as Record<string, unknown>;
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
268
|
+
if (tool.workflow.toolKind === 'authenticate' && typeof rawArgs.action === 'string') {
|
|
269
|
+
args.action = rawArgs.action;
|
|
270
|
+
}
|
|
271
|
+
let initialState: Record<string, unknown> | undefined;
|
|
272
|
+
if (tool.workflow.toolKind === 'authenticate' && rawArgs.continuation !== undefined) {
|
|
273
|
+
const action = String(args.action ?? tool.workflow.authConfig?.entry ?? '');
|
|
274
|
+
if (typeof rawArgs.continuation !== 'string') {
|
|
275
|
+
return {
|
|
276
|
+
isError: true,
|
|
277
|
+
content: [{ type: 'text', text: 'Invalid continuation token.' }],
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
initialState = authContinuations.consume(
|
|
281
|
+
rawArgs.continuation,
|
|
282
|
+
tool.workflow.toolName,
|
|
283
|
+
action,
|
|
284
|
+
);
|
|
285
|
+
if (!initialState) {
|
|
286
|
+
return {
|
|
287
|
+
isError: true,
|
|
288
|
+
content: [{ type: 'text', text: 'Invalid or expired continuation token.' }],
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
}
|
|
221
292
|
|
|
222
293
|
try {
|
|
223
294
|
return await runSerializedBySite(siteExecutionQueues, tool.site, async () => {
|
|
@@ -293,7 +364,16 @@ function buildServer(
|
|
|
293
364
|
cdpIdleTimers.set(tool.site, timer);
|
|
294
365
|
}
|
|
295
366
|
if (!result.ok) {
|
|
296
|
-
const
|
|
367
|
+
const continuationToken =
|
|
368
|
+
tool.workflow.toolKind === 'authenticate' && result.continuation
|
|
369
|
+
? authContinuations.issue({
|
|
370
|
+
toolName: tool.workflow.toolName,
|
|
371
|
+
nextAction:
|
|
372
|
+
result.nextAction ?? String(args.action ?? tool.workflow.authConfig?.entry),
|
|
373
|
+
state: result.continuation,
|
|
374
|
+
})
|
|
375
|
+
: undefined;
|
|
376
|
+
const text = formatToolError(result, continuationToken);
|
|
297
377
|
return {
|
|
298
378
|
isError: true,
|
|
299
379
|
content: [{ type: 'text', text: `${text}\n(backend: ${usedBackend})` }],
|
|
@@ -331,12 +411,16 @@ function buildServer(
|
|
|
331
411
|
for (const timer of cdpIdleTimers.values()) clearTimeout(timer);
|
|
332
412
|
cdpIdleTimers.clear();
|
|
333
413
|
winnerCache.clear();
|
|
414
|
+
authContinuations.clear();
|
|
334
415
|
}
|
|
335
416
|
|
|
336
417
|
return { server, closeCdpPool };
|
|
337
418
|
}
|
|
338
419
|
|
|
339
|
-
function formatToolError(
|
|
420
|
+
function formatToolError(
|
|
421
|
+
result: Extract<ToolResult, { ok: false }>,
|
|
422
|
+
continuationToken?: string,
|
|
423
|
+
): string {
|
|
340
424
|
const lines = [`[${result.error}] ${result.message}`];
|
|
341
425
|
if (result.error === 'STATE_MISSING' && result.missing?.length) {
|
|
342
426
|
for (const item of result.missing) {
|
|
@@ -345,11 +429,8 @@ function formatToolError(result: Extract<ToolResult, { ok: false }>): string {
|
|
|
345
429
|
);
|
|
346
430
|
}
|
|
347
431
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
if (result.error === 'AWAITING_2FA' && result.twoFactorContext) {
|
|
351
|
-
lines.push(` twoFactorContext: ${JSON.stringify(result.twoFactorContext)}`);
|
|
352
|
-
}
|
|
432
|
+
if (result.nextAction) lines.push(` nextAction: ${result.nextAction}`);
|
|
433
|
+
if (continuationToken) lines.push(` continuation: ${JSON.stringify(continuationToken)}`);
|
|
353
434
|
if (result.remediation) lines.push(` → ${result.remediation}`);
|
|
354
435
|
return lines.join('\n');
|
|
355
436
|
}
|
|
@@ -370,7 +451,7 @@ export async function runMcpServer(opts: RunMcpServerOptions): Promise<void> {
|
|
|
370
451
|
return {
|
|
371
452
|
...t,
|
|
372
453
|
inputSchema: buildJsonSchema(t.workflow.parameters, {
|
|
373
|
-
|
|
454
|
+
authConfig: t.workflow.toolKind === 'authenticate' ? t.workflow.authConfig : undefined,
|
|
374
455
|
}),
|
|
375
456
|
playbookPath: existsSync(playbookPath) ? playbookPath : undefined,
|
|
376
457
|
preferredOrder: cacheStatus.status === 'ok' ? cacheStatus.cache.preferredOrder : undefined,
|