imprint-mcp 0.4.3 → 0.4.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imprint-mcp",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
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": {
@@ -257,6 +257,7 @@ async function runClaudeCliAttempt(opts: CompileViaClaudeCliOptions): Promise<Co
257
257
  ? ['--shared-modules-json', JSON.stringify(opts.sharedModules)]
258
258
  : []),
259
259
  ],
260
+ alwaysLoad: true,
260
261
  },
261
262
  },
262
263
  };
@@ -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
- const ladder = resolveLadder('auto', tool.preferredOrder);
176
- const { result, usedBackend, attempts } = await runWithLadder(
177
- ladder,
178
- tool,
179
- args,
180
- assetRoot,
181
- stealthCache,
182
- { cdpPool, winnerCache, skipBootstrapSplice: Boolean(tool.preferredOrder?.length) },
183
- );
184
- // Reset the idle timer for this site's pooled Chrome.
185
- if (result.ok && usedBackend === 'cdp-replay' && cdpPool.has(tool.site)) {
186
- const prev = cdpIdleTimers.get(tool.site);
187
- if (prev) clearTimeout(prev);
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
- usedBackend,
217
- attempts,
218
- });
219
- if (cache) {
220
- tool.preferredOrder = cache.preferredOrder;
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
- ` learned backend order for ${tool.workflow.toolName}: ${cache.preferredOrder.join(' ')}`,
252
+ ` warning: could not persist backend order for ${tool.workflow.toolName}: ${err instanceof Error ? err.message : String(err)}`,
223
253
  );
224
254
  }
225
- } catch (err) {
226
- log(
227
- ` warning: could not persist backend order for ${tool.workflow.toolName}: ${err instanceof Error ? err.message : String(err)}`,
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}` }] };