imprint-mcp 0.4.3 → 0.4.4
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/package.json +1 -1
- package/src/imprint/mcp-server.ts +88 -62
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "imprint-mcp",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"description": "Teach an AI agent how to use any website. Once. Records a real browser session + narration; generates a deterministic MCP tool plus a DOM-replay playbook fallback.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -89,6 +89,24 @@ export function buildJsonSchema(parameters: WorkflowParameter[]): Tool['inputSch
|
|
|
89
89
|
|
|
90
90
|
const log = createLog('mcp');
|
|
91
91
|
|
|
92
|
+
export async function runSerializedBySite<T>(
|
|
93
|
+
queues: Map<string, Promise<void>>,
|
|
94
|
+
site: string,
|
|
95
|
+
task: () => Promise<T>,
|
|
96
|
+
): Promise<T> {
|
|
97
|
+
const previous = queues.get(site) ?? Promise.resolve();
|
|
98
|
+
const run = previous.catch(() => undefined).then(task);
|
|
99
|
+
const tail = run.then(
|
|
100
|
+
() => undefined,
|
|
101
|
+
() => undefined,
|
|
102
|
+
);
|
|
103
|
+
queues.set(site, tail);
|
|
104
|
+
tail.finally(() => {
|
|
105
|
+
if (queues.get(site) === tail) queues.delete(site);
|
|
106
|
+
});
|
|
107
|
+
return await run;
|
|
108
|
+
}
|
|
109
|
+
|
|
92
110
|
/** Build the MCP Server with all discovered tools registered. */
|
|
93
111
|
function buildServer(
|
|
94
112
|
name: string,
|
|
@@ -127,6 +145,12 @@ function buildServer(
|
|
|
127
145
|
// cdp-replay and re-pay the ~33s relaunch.
|
|
128
146
|
const winnerCache = new Map<string, ConcreteBackend>();
|
|
129
147
|
|
|
148
|
+
// Browser-backed rungs share per-site state (CDP page/session, stealth token,
|
|
149
|
+
// winner memo, and backend cache). Parallel MCP calls can race that state and
|
|
150
|
+
// make Google Flights return fast empty result sets. Keep same-site execution
|
|
151
|
+
// sequential while allowing unrelated sites to proceed independently.
|
|
152
|
+
const siteExecutionQueues = new Map<string, Promise<void>>();
|
|
153
|
+
|
|
130
154
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
131
155
|
tools: tools.map((t) => ({
|
|
132
156
|
name: t.workflow.toolName,
|
|
@@ -162,74 +186,76 @@ function buildServer(
|
|
|
162
186
|
string | number | boolean
|
|
163
187
|
>;
|
|
164
188
|
|
|
165
|
-
// Audit-only pacing: when the audit harness sets IMPRINT_AUDIT_PACING_MS,
|
|
166
|
-
// sleep before each tool call so the auditor's per-parameter differential
|
|
167
|
-
// probing of bot-defended idempotent reads stays steady enough not to trip
|
|
168
|
-
// the per-IP anti-bot defense. Unset in production → no delay.
|
|
169
|
-
const pacingMs = Number(process.env.IMPRINT_AUDIT_PACING_MS);
|
|
170
|
-
if (Number.isFinite(pacingMs) && pacingMs > 0) {
|
|
171
|
-
await new Promise((r) => setTimeout(r, pacingMs));
|
|
172
|
-
}
|
|
173
|
-
|
|
174
189
|
try {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
const timer = setTimeout(() => {
|
|
189
|
-
const cf = cdpPool.get(tool.site);
|
|
190
|
-
if (cf) {
|
|
191
|
-
log(`closing idle CDP session for ${tool.site}`);
|
|
192
|
-
cf.close().catch(() => {});
|
|
193
|
-
cdpPool.delete(tool.site);
|
|
194
|
-
cdpIdleTimers.delete(tool.site);
|
|
195
|
-
// Drop this site's winner memo too: a memoized cdp-replay would now
|
|
196
|
-
// point at a closed Chrome and re-pay the cold relaunch.
|
|
197
|
-
for (const key of winnerCache.keys()) {
|
|
198
|
-
if (key.startsWith(`${tool.site}:`)) winnerCache.delete(key);
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
}, CDP_IDLE_TIMEOUT_MS);
|
|
202
|
-
timer.unref();
|
|
203
|
-
cdpIdleTimers.set(tool.site, timer);
|
|
204
|
-
}
|
|
205
|
-
if (!result.ok) {
|
|
206
|
-
const text = formatToolError(result);
|
|
207
|
-
return {
|
|
208
|
-
isError: true,
|
|
209
|
-
content: [{ type: 'text', text: `${text}\n(backend: ${usedBackend})` }],
|
|
210
|
-
};
|
|
211
|
-
}
|
|
212
|
-
try {
|
|
213
|
-
const cache = persistRuntimeBackendsCache({
|
|
190
|
+
return await runSerializedBySite(siteExecutionQueues, tool.site, async () => {
|
|
191
|
+
// Audit-only pacing: when the audit harness sets IMPRINT_AUDIT_PACING_MS,
|
|
192
|
+
// sleep before each actual workflow execution so same-site queued calls
|
|
193
|
+
// stay spaced out instead of all waiting concurrently before the queue.
|
|
194
|
+
// Unset in production -> no delay.
|
|
195
|
+
const pacingMs = Number(process.env.IMPRINT_AUDIT_PACING_MS);
|
|
196
|
+
if (Number.isFinite(pacingMs) && pacingMs > 0) {
|
|
197
|
+
await new Promise((r) => setTimeout(r, pacingMs));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const ladder = resolveLadder('auto', tool.preferredOrder);
|
|
201
|
+
const { result, usedBackend, attempts } = await runWithLadder(
|
|
202
|
+
ladder,
|
|
214
203
|
tool,
|
|
204
|
+
args,
|
|
215
205
|
assetRoot,
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
206
|
+
stealthCache,
|
|
207
|
+
{ cdpPool, winnerCache, skipBootstrapSplice: Boolean(tool.preferredOrder?.length) },
|
|
208
|
+
);
|
|
209
|
+
// Reset the idle timer for this site's pooled Chrome.
|
|
210
|
+
if (result.ok && usedBackend === 'cdp-replay' && cdpPool.has(tool.site)) {
|
|
211
|
+
const prev = cdpIdleTimers.get(tool.site);
|
|
212
|
+
if (prev) clearTimeout(prev);
|
|
213
|
+
const timer = setTimeout(() => {
|
|
214
|
+
const cf = cdpPool.get(tool.site);
|
|
215
|
+
if (cf) {
|
|
216
|
+
log(`closing idle CDP session for ${tool.site}`);
|
|
217
|
+
cf.close().catch(() => {});
|
|
218
|
+
cdpPool.delete(tool.site);
|
|
219
|
+
cdpIdleTimers.delete(tool.site);
|
|
220
|
+
// Drop this site's winner memo too: a memoized cdp-replay would now
|
|
221
|
+
// point at a closed Chrome and re-pay the cold relaunch.
|
|
222
|
+
for (const key of winnerCache.keys()) {
|
|
223
|
+
if (key.startsWith(`${tool.site}:`)) winnerCache.delete(key);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}, CDP_IDLE_TIMEOUT_MS);
|
|
227
|
+
timer.unref();
|
|
228
|
+
cdpIdleTimers.set(tool.site, timer);
|
|
229
|
+
}
|
|
230
|
+
if (!result.ok) {
|
|
231
|
+
const text = formatToolError(result);
|
|
232
|
+
return {
|
|
233
|
+
isError: true,
|
|
234
|
+
content: [{ type: 'text', text: `${text}\n(backend: ${usedBackend})` }],
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
const cache = persistRuntimeBackendsCache({
|
|
239
|
+
tool,
|
|
240
|
+
assetRoot,
|
|
241
|
+
usedBackend,
|
|
242
|
+
attempts,
|
|
243
|
+
});
|
|
244
|
+
if (cache) {
|
|
245
|
+
tool.preferredOrder = cache.preferredOrder;
|
|
246
|
+
log(
|
|
247
|
+
` learned backend order for ${tool.workflow.toolName}: ${cache.preferredOrder.join(' → ')}`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
} catch (err) {
|
|
221
251
|
log(
|
|
222
|
-
`
|
|
252
|
+
` warning: could not persist backend order for ${tool.workflow.toolName}: ${err instanceof Error ? err.message : String(err)}`,
|
|
223
253
|
);
|
|
224
254
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
}
|
|
230
|
-
const text =
|
|
231
|
-
typeof result.data === 'string' ? result.data : JSON.stringify(result.data, null, 2);
|
|
232
|
-
return { content: [{ type: 'text', text: `${text}\n\n(backend: ${usedBackend})` }] };
|
|
255
|
+
const text =
|
|
256
|
+
typeof result.data === 'string' ? result.data : JSON.stringify(result.data, null, 2);
|
|
257
|
+
return { content: [{ type: 'text', text: `${text}\n\n(backend: ${usedBackend})` }] };
|
|
258
|
+
});
|
|
233
259
|
} catch (err) {
|
|
234
260
|
const msg = err instanceof Error ? err.message : String(err);
|
|
235
261
|
return { isError: true, content: [{ type: 'text', text: `[INTERNAL] ${msg}` }] };
|