scrapeloop-mcp 0.6.0 → 0.7.1

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/src/index.js CHANGED
@@ -17,23 +17,61 @@
17
17
  */
18
18
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
19
19
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
20
+ import { randomUUID } from 'node:crypto';
21
+ import { AsyncLocalStorage } from 'node:async_hooks';
20
22
  import {
21
23
  CallToolRequestSchema,
22
24
  ListToolsRequestSchema,
23
25
  } from '@modelcontextprotocol/sdk/types.js';
26
+ import { MUTATION_POLICIES, RETRY_CONTRACT_COPY } from './mutation-policies.js';
24
27
 
25
28
  const API_KEY = process.env.SCRAPELOOP_API_KEY;
26
29
  const BASE = (process.env.SCRAPELOOP_API_URL || 'https://api.scrapeloop.com').replace(/\/$/, '');
27
30
  const TIMEOUT_MS = Number(process.env.SCRAPELOOP_TIMEOUT_MS) || 30000;
28
31
  const MAX_RETRIES = 3;
32
+ const MAX_TABLE_CELL_WRITES = 500;
33
+ const MAX_TABLE_CELL_REQUEST_BYTES = 256 * 1024;
34
+ const TRACE_HEADER = 'X-Scrapeloop-Trace-Id';
35
+ const SAFE_TRACE_ID = /^[A-Za-z0-9_-]{8,80}$/;
36
+ const STARTUP_WARNINGS = new Set();
37
+ const RETRY_BASE_MS = Number(process.env.SCRAPELOOP_RETRY_BASE_MS) || 500;
38
+ const toolCallContext = new AsyncLocalStorage();
29
39
 
30
40
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
31
41
 
32
- // One API call with a per-request timeout + retry/backoff on 429/5xx + transient
33
- // network errors. Never throws — always resolves to {ok, status, data|error} so a
34
- // single failed upstream call can't kill the server. The API key is sent in the
35
- // Authorization header only and is never logged.
36
- async function api(method, path, body) {
42
+ const uncertainResult = ({ context, method, path, upstreamStatus, reconciliation }) => {
43
+ const policy = context?.policy || {};
44
+ const args = context?.args || {};
45
+ const match = Object.fromEntries(
46
+ (policy.match_fields || [])
47
+ .filter((field) => args[field] !== undefined)
48
+ .map((field) => [field, args[field]]),
49
+ );
50
+ return {
51
+ ok: false,
52
+ status: upstreamStatus || 0,
53
+ error: {
54
+ type: 'uncertain_result',
55
+ uncertain_result: true,
56
+ detail: `The ${method} ${path} result is unknown. The request may have committed before the response was lost.`,
57
+ tool: context?.toolName,
58
+ operation: `${method} ${path}`,
59
+ ...(args.idempotency_key ? { idempotency_key: args.idempotency_key } : {}),
60
+ inspect_with: policy.inspect_with || [],
61
+ match_fields: match,
62
+ ...(reconciliation ? { reconciliation } : {}),
63
+ ...(reconciliation?.retry_guidance
64
+ ? { retry_guidance: reconciliation.retry_guidance }
65
+ : {}),
66
+ guidance:
67
+ reconciliation?.guidance ||
68
+ reconciliation?.retry_guidance ||
69
+ 'Inspect the listed read tools and stable fields before making any new mutation attempt.',
70
+ },
71
+ };
72
+ };
73
+
74
+ async function api(method, path, body, options = {}) {
37
75
  if (!API_KEY) {
38
76
  return {
39
77
  ok: false,
@@ -45,34 +83,84 @@ async function api(method, path, body) {
45
83
  },
46
84
  };
47
85
  }
86
+ const fetcher = options.fetcher || fetch;
87
+ const sleepFn = options.sleepFn || sleep;
88
+ const shouldRetryResponse =
89
+ options.shouldRetryResponse || ((status) => status === 429 || status >= 500);
90
+ const retryDelay = options.retryDelay || ((attempt) => RETRY_BASE_MS * 2 ** attempt);
91
+ const traceId = safeTraceId(options.traceId);
92
+ const verb = method.toUpperCase();
93
+ const context = toolCallContext.getStore();
94
+ const policy = context?.policy;
95
+ if (verb !== 'GET' && (!policy || policy.method !== verb)) {
96
+ return {
97
+ ok: false,
98
+ status: 500,
99
+ error: {
100
+ detail: `MCP mutation policy missing or mismatched for ${context?.toolName || 'unknown tool'} (${verb} ${path}).`,
101
+ },
102
+ };
103
+ }
104
+ if (policy?.retry === 'idempotency_key' && !context?.args?.idempotency_key) {
105
+ return {
106
+ ok: false,
107
+ status: 400,
108
+ error: {
109
+ detail: `${context.toolName} requires idempotency_key. Generate one stable UUID and reuse it for every retry of this exact operation.`,
110
+ },
111
+ };
112
+ }
113
+ const canRetry = verb === 'GET'
114
+ || policy?.effect === 'read'
115
+ || policy?.retry === 'idempotent'
116
+ || policy?.retry === 'idempotency_key';
117
+ const maxRetries = canRetry ? (options.maxRetries ?? MAX_RETRIES) : 0;
118
+ const headers = {
119
+ Authorization: `Bearer ${API_KEY}`,
120
+ 'Content-Type': 'application/json',
121
+ ...(traceId ? { [TRACE_HEADER]: traceId } : {}),
122
+ ...(policy?.retry === 'idempotency_key'
123
+ ? { 'Idempotency-Key': context.args.idempotency_key }
124
+ : {}),
125
+ };
48
126
  let lastErr;
49
- for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
127
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
50
128
  let res;
51
129
  try {
52
- res = await fetch(`${BASE}/api/v1${path}`, {
53
- method,
54
- headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
130
+ res = await fetcher(`${BASE}/api/v1${path}`, {
131
+ method: verb,
132
+ headers,
55
133
  body: body === undefined ? undefined : JSON.stringify(body),
56
134
  signal: AbortSignal.timeout(TIMEOUT_MS),
57
135
  });
58
136
  } catch (e) {
59
- // Network error / timeout — retry a few times, then surface a clean error.
60
137
  lastErr = e;
61
- if (attempt < MAX_RETRIES) {
62
- await sleep(500 * 2 ** attempt);
138
+ if (attempt < maxRetries) {
139
+ await sleepFn(retryDelay(attempt));
63
140
  continue;
64
141
  }
142
+ if (policy?.effect === 'mutation') {
143
+ return uncertainResult({ context, method: verb, path, reconciliation: options.reconciliation });
144
+ }
65
145
  const timedOut = e && (e.name === 'TimeoutError' || e.name === 'AbortError');
66
146
  return {
67
147
  ok: false,
68
148
  status: 0,
69
149
  error: { detail: `Could not reach Scrapeloop (${timedOut ? `timeout after ${TIMEOUT_MS}ms` : String(e)}).` },
150
+ ...(options.captureTrace
151
+ ? { trace_id: traceId, network_error: timedOut ? 'timeout' : 'transport' }
152
+ : {}),
70
153
  };
71
154
  }
72
155
  // Retry transient upstream failures (rate limit / server errors).
73
- if ((res.status === 429 || res.status >= 500) && attempt < MAX_RETRIES) {
156
+ const transient = shouldRetryResponse(res.status);
157
+ if (transient && attempt < maxRetries) {
74
158
  const retryAfter = Number(res.headers.get('retry-after')) * 1000;
75
- await sleep(retryAfter > 0 ? retryAfter : 500 * 2 ** attempt);
159
+ const delay =
160
+ options.respectRetryAfter !== false && retryAfter > 0
161
+ ? retryAfter
162
+ : retryDelay(attempt);
163
+ await sleepFn(delay);
76
164
  continue;
77
165
  }
78
166
  const text = await res.text();
@@ -83,12 +171,136 @@ async function api(method, path, body) {
83
171
  data = { raw: text };
84
172
  }
85
173
  if (!res.ok) {
86
- // Surface the API's structured error verbatim (401 auth, 402 credits, 403 scope, …).
87
- return { ok: false, status: res.status, error: data };
174
+ if (
175
+ transient
176
+ && policy?.effect === 'mutation'
177
+ && !(res.status === 429 && canRetry)
178
+ ) {
179
+ return uncertainResult({
180
+ context,
181
+ method: verb,
182
+ path,
183
+ upstreamStatus: res.status,
184
+ reconciliation: options.reconciliation,
185
+ });
186
+ }
187
+ return {
188
+ ok: false,
189
+ status: res.status,
190
+ error: data,
191
+ ...(options.captureTrace ? { trace_id: responseTraceId(res, traceId) } : {}),
192
+ };
88
193
  }
89
- return { ok: true, status: res.status, data };
194
+ return {
195
+ ok: true,
196
+ status: res.status,
197
+ data,
198
+ ...(options.captureTrace ? { trace_id: responseTraceId(res, traceId) } : {}),
199
+ };
200
+ }
201
+ if (policy?.effect === 'mutation') {
202
+ return uncertainResult({ context, method: verb, path, reconciliation: options.reconciliation });
203
+ }
204
+ return {
205
+ ok: false,
206
+ status: 0,
207
+ error: { detail: `Request failed: ${String(lastErr)}` },
208
+ ...(options.captureTrace ? { trace_id: traceId } : {}),
209
+ };
210
+ }
211
+
212
+ const safeTraceId = (value) =>
213
+ typeof value === 'string' && SAFE_TRACE_ID.test(value) ? value : undefined;
214
+
215
+ const responseTraceId = (response, fallback) =>
216
+ safeTraceId(response.headers.get(TRACE_HEADER)) || fallback;
217
+
218
+ const createPreflightTraceId = () => `mcp_${randomUUID()}`;
219
+
220
+ const preflightRetryDelay = (random) =>
221
+ 150 + Math.floor(Math.max(0, Math.min(1, random())) * 150);
222
+
223
+ function classifyApiKeyPreflight(response) {
224
+ const traceId = safeTraceId(response.trace_id);
225
+ if (response.ok) {
226
+ return { state: 'authenticated', conclusive: true, status: response.status, traceId };
90
227
  }
91
- return { ok: false, status: 0, error: { detail: `Request failed: ${String(lastErr)}` } };
228
+ if (response.status === 401) {
229
+ return { state: 'invalid_key', conclusive: true, status: 401, traceId };
230
+ }
231
+ if (response.status === 403) {
232
+ return { state: 'forbidden', conclusive: true, status: 403, traceId };
233
+ }
234
+ if (response.status === 429) {
235
+ return { state: 'rate_limited', conclusive: false, status: 429, traceId };
236
+ }
237
+ if (response.status >= 500) {
238
+ return { state: 'server_error', conclusive: false, status: response.status, traceId };
239
+ }
240
+ if (response.status === 0) {
241
+ return {
242
+ state: 'network_unreachable',
243
+ conclusive: false,
244
+ status: 0,
245
+ traceId,
246
+ networkError: response.network_error === 'timeout' ? 'timeout' : 'transport',
247
+ };
248
+ }
249
+ return { state: 'inconclusive', conclusive: false, status: response.status, traceId };
250
+ }
251
+
252
+ async function runApiKeyPreflight(options = {}) {
253
+ const random = options.random || Math.random;
254
+ const traceId = safeTraceId(options.traceId) || createPreflightTraceId();
255
+ let response;
256
+ try {
257
+ response = await api('GET', '/me', undefined, {
258
+ captureTrace: true,
259
+ fetcher: options.fetcher,
260
+ maxRetries: 1,
261
+ respectRetryAfter: false,
262
+ retryDelay: () => preflightRetryDelay(random),
263
+ shouldRetryResponse: (status) => status >= 500,
264
+ sleepFn: options.sleepFn,
265
+ traceId,
266
+ });
267
+ } catch {
268
+ response = {
269
+ ok: false,
270
+ status: 0,
271
+ trace_id: traceId,
272
+ network_error: 'transport',
273
+ };
274
+ }
275
+ return classifyApiKeyPreflight(response);
276
+ }
277
+
278
+ function formatApiKeyPreflightWarning(response) {
279
+ const trace = response.traceId ? ` Trace ID: ${response.traceId}.` : '';
280
+ if (response.state === 'invalid_key') {
281
+ return `scrapeloop-mcp: API key preflight failed: the key is invalid or revoked. Tools may return authentication errors.${trace}`;
282
+ }
283
+ if (response.state === 'forbidden') {
284
+ return `scrapeloop-mcp: API key preflight failed: the identity check is forbidden. Tool calls remain independent.${trace}`;
285
+ }
286
+ if (response.state === 'rate_limited') {
287
+ return `scrapeloop-mcp: API key preflight inconclusive: the identity check was rate limited. Tool calls remain available.${trace}`;
288
+ }
289
+ if (response.state === 'server_error') {
290
+ return `scrapeloop-mcp: API key preflight inconclusive: the identity check returned server status ${response.status}. Tool calls remain available.${trace}`;
291
+ }
292
+ if (response.state === 'network_unreachable') {
293
+ const kind = response.networkError === 'timeout' ? 'timed out' : 'hit a network error';
294
+ return `scrapeloop-mcp: API key preflight inconclusive: the identity check ${kind}. This does not prove the API is unreachable. Tool calls remain available.${trace}`;
295
+ }
296
+ return `scrapeloop-mcp: API key preflight inconclusive (status ${response.status}). Tool calls remain available.${trace}`;
297
+ }
298
+
299
+ function warnStartupOnce(key, message, logger = console.error) {
300
+ if (STARTUP_WARNINGS.has(key)) return false;
301
+ STARTUP_WARNINGS.add(key);
302
+ logger(message);
303
+ return true;
92
304
  }
93
305
 
94
306
  const result = (payload) => ({
@@ -116,6 +328,16 @@ const B = { type: 'boolean' };
116
328
  const O = { type: 'object' };
117
329
  const ARR = (items) => ({ type: 'array', items });
118
330
 
331
+ // card run-scope-menu: build the run/estimate body from the scope args
332
+ // (view/selection + cell_filter + n_rows/start_row window).
333
+ const runScopeBody = (a) => ({
334
+ ...(a.view_id ? { view_id: a.view_id } : {}),
335
+ ...(a.selection ? { selection: a.selection } : {}),
336
+ ...(a.cell_filter ? { cell_filter: a.cell_filter } : {}),
337
+ ...(a.n_rows ? { row_window: { n: a.n_rows, start: a.start_row || 1 } } : {}),
338
+ ...(a.only_failed !== undefined ? { only_failed: !!a.only_failed } : {}),
339
+ });
340
+
119
341
  // --- Tool registry: name → { def, run } -------------------------------------
120
342
  const TOOLS = {
121
343
  // ── Discovery / status ────────────────────────────────────────────────
@@ -145,7 +367,7 @@ const TOOLS = {
145
367
  get_meta: {
146
368
  def: {
147
369
  description:
148
- 'List the canonical values for a lead-database filter (seniorities, functions, email_quality_labels, employee_ranges) so search inputs are valid. Use "_all" for everything.',
370
+ 'List the canonical values for a lead-database filter (seniorities, functions, email_quality_labels, industries) so search inputs are valid. Use "_all" for everything.',
149
371
  inputSchema: obj({ filter: { ...S, description: 'filter name or "_all"' } }, ['filter']),
150
372
  },
151
373
  run: (a) => api('GET', `/meta/${enc(a.filter)}`),
@@ -220,7 +442,7 @@ const TOOLS = {
220
442
  list_scrapers: {
221
443
  def: {
222
444
  description:
223
- 'List available scrapers and the JSON Schema for each config. Ask the user the schema\'s fields (query, locations, limit, …), then fill `config` for estimate_scrape / submit_scrape.',
445
+ 'List available scrapers, whether each requires a credential, and the JSON Schema for each config. Ask the user the schema\'s fields (query, state, locations, limit, …), then fill `config` for estimate_scrape / submit_scrape.',
224
446
  inputSchema: obj({}),
225
447
  },
226
448
  run: () => api('GET', '/scrapers'),
@@ -248,14 +470,16 @@ const TOOLS = {
248
470
  submit_scrape: {
249
471
  def: {
250
472
  description:
251
- 'Submit a scrape job. SPENDS vendor credits (BYOK) or lead credits (managed: 1 credit per NEW lead, deduped free) — confirm with the user first. billing: "auto" (default) uses the workspace key when one exists, else Scrapeloop\'s managed key; "byok"/"managed" force the mode. integration_id is required for BYOK, optional for managed. Surfaces 402 (budget/free-tier/credits) and 409 (rescrape confirmation needed) verbatim; pass confirm_rescrape:true to proceed past a coverage conflict.',
473
+ 'Submit a scrape job. Optionally pass table_id from get_tables to add results to an existing writable static Table; omit it to keep results in All leads only. Paid scrapers spend vendor credits (BYOK) or lead credits (managed: 1 credit per NEW lead, deduped free), so confirm paid work with the user first. For every paid scraper, pass a positive hard_max_cost_usd equal to or above the estimate after the user confirms that ceiling; paid work will not start without it. A scraper with requires_credential=false is free and needs no integration_id, credential_id, or hard maximum. billing: "auto" (default) uses the workspace key when one exists, else Scrapeloop\'s managed key; "byok"/"managed" force the mode. integration_id is required for normal BYOK, optional for managed, and omitted for credential-free sources. Surfaces 402 (budget/free-tier/credits) and 409 (rescrape confirmation needed) verbatim; pass confirm_rescrape:true to proceed past a coverage conflict.',
252
474
  inputSchema: obj(
253
475
  {
254
476
  kind: S,
255
477
  integration_id: S,
256
478
  config: O,
479
+ table_id: S,
257
480
  credential_id: S,
258
481
  confirm_rescrape: B,
482
+ hard_max_cost_usd: { ...N, exclusiveMinimum: 0 },
259
483
  billing: { ...S, enum: ['auto', 'byok', 'managed'] },
260
484
  },
261
485
  ['kind', 'config'],
@@ -266,8 +490,10 @@ const TOOLS = {
266
490
  kind: a.kind,
267
491
  integration_id: a.integration_id ?? null,
268
492
  config: a.config || {},
493
+ ...(a.table_id ? { target_list_id: a.table_id } : {}),
269
494
  ...(a.credential_id ? { credential_id: a.credential_id } : {}),
270
495
  ...(a.confirm_rescrape ? { confirm_rescrape: true } : {}),
496
+ ...(a.hard_max_cost_usd !== undefined ? { hard_max_cost_usd: a.hard_max_cost_usd } : {}),
271
497
  ...(a.billing ? { billing: a.billing } : {}),
272
498
  }),
273
499
  },
@@ -280,7 +506,7 @@ const TOOLS = {
280
506
  run: (a) => api('GET', `/jobs/${enc(a.job_id)}`),
281
507
  },
282
508
  cancel_job: {
283
- def: { description: 'Cancel a queued/running job (also best-effort cancels the vendor task).', inputSchema: obj({ job_id: S }, ['job_id']) },
509
+ def: { description: 'Request cancellation of a queued or running job. An active external vendor task remains nonterminal while the worker aborts it and settles final partial usage, then becomes cancelled.', inputSchema: obj({ job_id: S }, ['job_id']) },
284
510
  run: (a) => api('POST', `/jobs/${enc(a.job_id)}/cancel`),
285
511
  },
286
512
 
@@ -288,15 +514,23 @@ const TOOLS = {
288
514
  search_leads: {
289
515
  def: {
290
516
  description:
291
- 'Search the Scrapeloop B2B lead database. Emails are masked until revealed. Pass a filters object (titles, seniorities, functions, industries, countries, states, cities, company_keywords_include, employee_ranges, decision_maker, …). Free — costs no credits.',
517
+ 'Search the Scrapeloop B2B lead database (people-level). Emails are masked until revealed. Pass a filters object (job_titles_include, job_titles_mode: "contains" (default) | "exact" | "similar", seniorities, functions, countries, states, cities, company_keywords_include, company_names, domains_include, industries, decision_maker, email_quality_labels, ...). Industries are coarse inferred sector buckets from the company name, not verified firmographics. Company size and revenue filters (employee_ranges, revenue_ranges, and related fields) are deprecated no-ops; fold size hints into company_keywords_include. Get valid slugs from get_meta("industries"). Free, costs no credits.',
292
518
  inputSchema: obj({ filters: O, limit: { ...N, description: '1-100 (default 25)' }, offset: N }),
293
519
  },
294
520
  run: (a) => api('POST', '/leads/search', { filters: a.filters || {}, limit: a.limit || 25, offset: a.offset || 0 }),
295
521
  },
296
522
  estimate_leads: {
297
- def: { description: 'Count how many leads match a filters object (free).', inputSchema: obj({ filters: O }) },
523
+ def: { description: 'Count how many leads match a filters object (free). Supports job_titles_mode: "contains" (default) | "exact" | "similar".', inputSchema: obj({ filters: O }) },
298
524
  run: (a) => api('POST', '/leads/estimate', { filters: a.filters || {} }),
299
525
  },
526
+ expand_job_titles: {
527
+ def: {
528
+ description:
529
+ 'Preview what "is similar to" title matching will expand to (curated synonym map). Free.',
530
+ inputSchema: obj({ titles: ARR(S) }, ['titles']),
531
+ },
532
+ run: (a) => api('POST', '/leads/title-expansion', { titles: a.titles }),
533
+ },
300
534
  reveal_lead: {
301
535
  def: {
302
536
  description: "Reveal a lead's email by global_person_id. SPENDS 1 lead credit (idempotent — already-revealed leads are free).",
@@ -311,6 +545,74 @@ const TOOLS = {
311
545
  },
312
546
  run: (a) => api('POST', '/leads/save', { global_person_ids: a.global_person_ids }),
313
547
  },
548
+ estimate_lead_import: {
549
+ def: {
550
+ description:
551
+ 'Preview a Lead Database to table import: how many match, how many are new reveals, and the lead-credit cost. Free.',
552
+ inputSchema: obj(
553
+ {
554
+ filters: O,
555
+ total_limit: N,
556
+ per_company_limit: N,
557
+ },
558
+ ['filters'],
559
+ ),
560
+ },
561
+ run: (a) =>
562
+ api('POST', '/leads/import-estimate', {
563
+ filters: a.filters || {},
564
+ ...(a.total_limit ? { total_limit: a.total_limit } : {}),
565
+ ...(a.per_company_limit ? { per_company_limit: a.per_company_limit } : {}),
566
+ }),
567
+ },
568
+ import_leads_to_table: {
569
+ def: {
570
+ description:
571
+ 'Import Lead Database people into a table by filters. SPENDS lead credits for newly revealed people. Exactly one of list_id or new_list_name is required.',
572
+ inputSchema: obj(
573
+ {
574
+ filters: O,
575
+ total_limit: N,
576
+ per_company_limit: N,
577
+ list_id: S,
578
+ new_list_name: S,
579
+ },
580
+ ['filters'],
581
+ ),
582
+ },
583
+ run: (a) =>
584
+ api('POST', '/leads/import-to-table', {
585
+ filters: a.filters || {},
586
+ ...(a.total_limit ? { total_limit: a.total_limit } : {}),
587
+ ...(a.per_company_limit ? { per_company_limit: a.per_company_limit } : {}),
588
+ ...(a.list_id ? { list_id: a.list_id } : {}),
589
+ ...(a.new_list_name ? { new_list_name: a.new_list_name } : {}),
590
+ }),
591
+ },
592
+ list_saved_searches: {
593
+ def: {
594
+ description:
595
+ 'List saved + recent Lead Database searches (name, filters object, result count, last used). Reuse a search by passing its filters to search_leads / estimate_leads (free).',
596
+ inputSchema: obj({}),
597
+ },
598
+ run: () => api('GET', '/leads/saved-searches'),
599
+ },
600
+ save_search: {
601
+ def: {
602
+ description:
603
+ 'Save a Lead Database filters object as a named search for the workspace (free). Upserts by name.',
604
+ inputSchema: obj(
605
+ { name: S, filters: O, result_count_at_save: N },
606
+ ['name', 'filters'],
607
+ ),
608
+ },
609
+ run: (a) =>
610
+ api('POST', '/leads/saved-searches', {
611
+ name: a.name,
612
+ filters: a.filters || {},
613
+ result_count_at_save: a.result_count_at_save,
614
+ }),
615
+ },
314
616
  bulk_verify_leads: {
315
617
  def: {
316
618
  description: 'Enqueue a background verification pass over saved/DB leads (by lead_ids or global_person_ids).',
@@ -324,6 +626,50 @@ const TOOLS = {
324
626
  }),
325
627
  },
326
628
 
629
+ // ── Local Leads: browse first, spend only on reveal or coverage fill ──
630
+ search_local_leads: {
631
+ def: {
632
+ description:
633
+ 'Search existing local-business coverage for free. Results keep email and owner fields masked until reveal. Filter keys include search_query, gcids, niche_text, countries, states, cities, postal_codes, rating_min/max, reviews_min/max, has_website, has_phone, has_email, email_status, has_owner_name, platforms, tech_tools, domain_age_max_years, page_count_min/max, site_signals, has_gbp_description, and sort.',
634
+ inputSchema: obj(
635
+ { filters: O, limit: { ...N, description: '1-100 (default 25)' }, offset: N },
636
+ ),
637
+ },
638
+ run: (a) => api('POST', '/local-leads/search', {
639
+ filters: a.filters || {},
640
+ limit: a.limit || 25,
641
+ offset: a.offset || 0,
642
+ }),
643
+ },
644
+ estimate_local_leads: {
645
+ def: {
646
+ description: 'Count matching local businesses in existing coverage for free. Uses the same filters as search_local_leads.',
647
+ inputSchema: obj({ filters: O }),
648
+ },
649
+ run: (a) => api('POST', '/local-leads/estimate', { filters: a.filters || {} }),
650
+ },
651
+ reveal_local_lead: {
652
+ def: {
653
+ description: 'Reveal one local business by place_id. SPENDS 1 lead credit and is idempotent, so an already revealed record is free.',
654
+ inputSchema: obj({ place_id: S }, ['place_id']),
655
+ },
656
+ run: (a) => api('POST', '/local-leads/reveal', { place_id: a.place_id }),
657
+ },
658
+ fill_coverage_gap: {
659
+ def: {
660
+ description: 'Request a live local scrape when existing coverage is thin or stale. MAY SPEND vendor dollars or lead credits. Search and estimate first, show the expected cost, and confirm with the user before calling.',
661
+ inputSchema: obj({ filters: O }, ['filters']),
662
+ },
663
+ run: (a) => api('POST', '/local-leads/fill-gap', { filters: a.filters || {} }),
664
+ },
665
+ list_local_categories: {
666
+ def: {
667
+ description: 'Resolve a local-business category or synonym to ranked Google category IDs. Use this before search_local_leads when the user gives a plain-language niche.',
668
+ inputSchema: obj({ q: S, country: S }, ['q']),
669
+ },
670
+ run: (a) => api('GET', `/geo/categories${qs({ q: a.q, country: a.country })}`),
671
+ },
672
+
327
673
  // ── Step 4: verification ──────────────────────────────────────────────
328
674
  verify_email: {
329
675
  def: { description: 'Verify an email address (1 verify credit).', inputSchema: obj({ email: S }, ['email']) },
@@ -332,7 +678,7 @@ const TOOLS = {
332
678
  verify_catchall: {
333
679
  def: {
334
680
  description:
335
- 'Resolve a mailbox on a catch-all domain with the deep verifier (5 verify credits). High ROI: deliverable catch-all/unknown contacts get fewer cold emails because most senders skip them, so they reply more.',
681
+ 'Resolve a mailbox on a catch-all domain with Scrapeloop managed verification (5 verify credits). High ROI: deliverable catch-all/unknown contacts get fewer cold emails because most senders skip them, so they reply more.',
336
682
  inputSchema: obj({ email: S }, ['email']),
337
683
  },
338
684
  run: (a) => api('POST', '/verify/catchall', { email: a.email }),
@@ -422,6 +768,33 @@ const TOOLS = {
422
768
  ...(a.cache_settings ? { cache_settings: a.cache_settings } : {}),
423
769
  }),
424
770
  },
771
+ get_ai_context: {
772
+ def: {
773
+ description: 'Get the workspace AI context (company description, ICP, buyer personas) that seeds every AI feature.',
774
+ inputSchema: obj({}),
775
+ },
776
+ run: () => api('GET', '/ai-context'),
777
+ },
778
+ update_ai_context: {
779
+ def: {
780
+ description: 'Update the workspace AI context. The personas field replaces the whole array.',
781
+ inputSchema: obj({ company_description: S, icp: S, personas: ARR(S), source_domain: S }),
782
+ },
783
+ run: (a) =>
784
+ api('PUT', '/ai-context', {
785
+ ...(a.company_description !== undefined ? { company_description: a.company_description } : {}),
786
+ ...(a.icp !== undefined ? { icp: a.icp } : {}),
787
+ ...(a.personas !== undefined ? { personas: a.personas } : {}),
788
+ ...(a.source_domain !== undefined ? { source_domain: a.source_domain } : {}),
789
+ }),
790
+ },
791
+ generate_ai_context: {
792
+ def: {
793
+ description: 'Draft the AI context from a website domain (fetch + AI summarize). Returns a draft, call update_ai_context to save it. Free, no credits.',
794
+ inputSchema: obj({ domain: S }, ['domain']),
795
+ },
796
+ run: (a) => api('POST', '/ai-context/generate', { domain: a.domain }),
797
+ },
425
798
  list_strategies: {
426
799
  def: { description: 'List the workspace strategy tags (routing tags campaigns filter on).', inputSchema: obj({}) },
427
800
  run: () => api('GET', '/strategies'),
@@ -494,6 +867,30 @@ const TOOLS = {
494
867
  def: { description: 'List campaigns with per-campaign stats and the bound list name.', inputSchema: obj({}) },
495
868
  run: () => api('GET', '/campaigns'),
496
869
  },
870
+ preview_add_leads_to_campaign: {
871
+ def: {
872
+ description:
873
+ 'FREE preview for pushing a table view or selection into an existing campaign. ALWAYS show the valid, risky, invalid, suppressed, and already-added breakdown before add_leads_to_campaign. scope accepts view_id plus selection {mode: query, q, filter, exclude_ids} or {mode: explicit, ids}.',
874
+ inputSchema: obj({ campaign_id: S, list_id: S, scope: O }, ['campaign_id', 'list_id']),
875
+ },
876
+ run: (a) =>
877
+ api('POST', `/campaigns/${enc(a.campaign_id)}/add-leads/preview`, {
878
+ list_id: a.list_id,
879
+ ...(a.scope ? { scope: a.scope } : {}),
880
+ }),
881
+ },
882
+ add_leads_to_campaign: {
883
+ def: {
884
+ description:
885
+ 'Push scoped table rows into a live sender campaign, where they may start receiving email. Run preview_add_leads_to_campaign and confirm with the user first. Invalid emails are never added, and risky emails are added only when the campaign allows them.',
886
+ inputSchema: obj({ campaign_id: S, list_id: S, scope: O }, ['campaign_id', 'list_id']),
887
+ },
888
+ run: (a) =>
889
+ api('POST', `/campaigns/${enc(a.campaign_id)}/add-leads`, {
890
+ list_id: a.list_id,
891
+ ...(a.scope ? { scope: a.scope } : {}),
892
+ }),
893
+ },
497
894
  create_campaign: {
498
895
  def: {
499
896
  description:
@@ -576,131 +973,1419 @@ const TOOLS = {
576
973
  }),
577
974
  },
578
975
 
579
- // ── Lists ─────────────────────────────────────────────────────────────
976
+ // ── Legacy Lead Database saved lists ─────────────────────────────────
580
977
  get_lists: {
581
- def: { description: 'List the saved lead lists in the workspace.', inputSchema: obj({}) },
978
+ def: {
979
+ description:
980
+ 'List saved B2B Lead Database collections. These are NOT campaign-bindable Tables; use get_tables when you need a source_list_id, enrichment columns, or table cells.',
981
+ inputSchema: obj({}),
982
+ },
582
983
  run: () => api('GET', '/lists'),
583
984
  },
584
985
  create_list: {
585
- def: { description: 'Create a saved lead list.', inputSchema: obj({ name: S, description: S, color: S }, ['name']) },
986
+ def: {
987
+ description:
988
+ 'Create a saved B2B Lead Database collection for add_list_members. This is NOT a campaign-bindable Table; use create_table for imports, enrichment columns, or campaigns.',
989
+ inputSchema: obj({ name: S, description: S, color: S }, ['name']),
990
+ },
586
991
  run: (a) => api('POST', '/lists', { name: a.name, description: a.description, color: a.color }),
587
992
  },
588
993
  add_list_members: {
589
994
  def: { description: 'Add lead-database people to a list by global_person_id (acquires them, dedup-aware).', inputSchema: obj({ list_id: S, global_person_ids: ARR(S) }, ['list_id', 'global_person_ids']) },
590
995
  run: (a) => api('POST', `/lists/${enc(a.list_id)}/members`, { global_person_ids: a.global_person_ids }),
591
996
  },
592
- import_leads: {
997
+
998
+ // ── Workbench organization ───────────────────────────────────────────
999
+ list_workbooks: {
593
1000
  def: {
594
1001
  description:
595
- 'Bring your own leads: import externally-sourced lead records into a campaign-bindable Scrapeloop list. For leads you already have (a CSV/export with emails — optionally pre-verified, with a generated first line) — no scraping, no lead credits, and NO re-verification (your verification_status is preserved). Idempotent: dedupe within the workspace by email (default) or external_id — re-running the same batch UPDATES existing leads (merges custom_fields, unions strategy tags), never duplicates. custom_fields flow through to Instantly as custom variables (usable as {{first_line}} etc.). Target an existing list_id OR a list_name (create-or-get). Batch up to 1000 records. Each record: {email (required), first_name, last_name, business_name, phone, website, address, city, state_region, country, niche, rating, reviews_count, verification_status ("verified"|"catchall_verified"|"unverified"|"risky"|"invalid"), strategy_tags:[], custom_fields:{}, external_id}. Returns {list_id, inserted, updated, deduped, invalid, invalid_reasons}. Then bind list_id with create_campaign(source_list_id=...).',
596
- inputSchema: obj(
597
- {
598
- list_id: S,
599
- list_name: S,
600
- records: ARR(O),
601
- dedupe_by: { ...S, description: '"email" (default) or "external_id"' },
602
- default_verification_status: S,
603
- },
604
- ['records'],
605
- ),
1002
+ 'Read the complete Workbench tree: folders, workbooks in each folder, workbook table order, standalone Tables, and All leads. Use this before organizing or cloning a client workspace.',
1003
+ inputSchema: obj({}),
606
1004
  },
607
- run: (a) =>
608
- api('POST', '/leads/import', {
1005
+ run: () => api('GET', '/workbench/tree'),
1006
+ },
1007
+ create_workbook: {
1008
+ def: {
1009
+ description:
1010
+ 'Create a workbook to group related Tables as ordered tabs. Pass existing list_ids in the order they should appear, or add tabs later with add_table_to_workbook.',
1011
+ inputSchema: obj({ name: S, list_ids: ARR(S) }, ['name']),
1012
+ },
1013
+ run: (a) => api('POST', '/workbooks', { name: a.name, list_ids: a.list_ids || [] }),
1014
+ },
1015
+ add_table_to_workbook: {
1016
+ def: {
1017
+ description:
1018
+ 'Add one tab to a workbook. Pass list_id to attach an existing standalone Table, or new_table_name to create a new empty Table and attach it. Pass exactly one.',
1019
+ inputSchema: obj({ workbook_id: S, list_id: S, new_table_name: S }, ['workbook_id']),
1020
+ },
1021
+ run: (a) => {
1022
+ if (!!a.list_id === !!a.new_table_name) {
1023
+ return {
1024
+ ok: false,
1025
+ status: 400,
1026
+ error: { detail: 'Pass exactly one of list_id or new_table_name.' },
1027
+ };
1028
+ }
1029
+ return api('POST', `/workbooks/${enc(a.workbook_id)}/tables`, {
609
1030
  ...(a.list_id ? { list_id: a.list_id } : {}),
610
- ...(a.list_name ? { list_name: a.list_name } : {}),
611
- records: a.records || [],
612
- ...(a.dedupe_by ? { dedupe_by: a.dedupe_by } : {}),
613
- ...(a.default_verification_status ? { default_verification_status: a.default_verification_status } : {}),
614
- }),
1031
+ ...(a.new_table_name ? { new_table_name: a.new_table_name } : {}),
1032
+ });
1033
+ },
615
1034
  },
616
- import_leads_csv: {
1035
+ remove_table_from_workbook: {
617
1036
  def: {
618
1037
  description:
619
- "Bring your own leads from a CSV/TSV: paste the raw file text and it's parsed server-side — the delimiter is auto-detected, headers are fuzzy-mapped to fields (email, first_name, business_name, phone, website, city, country, rating, reviews_count, verification_status, external_id, strategy tags…), any UNMAPPED columns become custom_fields (so a 'first_line' or 'review_snippet' column flows to Instantly automatically), and verifier verdicts (valid/catch-all/invalid…) are normalized. No scraping, no lead credits, no re-verification. Set dry_run:true to PREVIEW (shows would_insert/would_update + how each column mapped, writes nothing) — do that first, show the user the column_mapping, then run again with dry_run:false. Target list_id or list_name (create-or-get). ≤1000 rows/call. Returns the import result plus column_mapping + unmapped_columns + rows_parsed.",
1038
+ 'Remove a Table tab from its workbook without deleting the Table or any rows. The Table becomes standalone in the Workbench root.',
1039
+ inputSchema: obj({ workbook_id: S, list_id: S }, ['workbook_id', 'list_id']),
1040
+ },
1041
+ run: (a) => api('DELETE', `/workbooks/${enc(a.workbook_id)}/tables/${enc(a.list_id)}`),
1042
+ },
1043
+ reorder_workbook_table: {
1044
+ def: {
1045
+ description:
1046
+ 'Change one workbook tab position. Read list_workbooks first, then choose a numeric position between neighboring tabs.',
1047
+ inputSchema: obj({ workbook_id: S, list_id: S, position: N }, ['workbook_id', 'list_id', 'position']),
1048
+ },
1049
+ run: (a) => api('PATCH', `/workbooks/${enc(a.workbook_id)}/tables/${enc(a.list_id)}`, { position: a.position }),
1050
+ },
1051
+ duplicate_workbook: {
1052
+ def: {
1053
+ description:
1054
+ 'Clone a template pipeline for a new client. Copies table structure, settings, and views without rows or cell history. Internal routing rules are rewired to the copied Tables; external, campaign, and webhook rules are disabled with review warnings.',
620
1055
  inputSchema: obj(
621
1056
  {
622
- csv: S,
623
- list_id: S,
624
- list_name: S,
625
- dedupe_by: { ...S, description: '"email" (default) or "external_id"' },
626
- default_verification_status: S,
627
- column_map: { ...O, description: 'optional explicit header→field overrides, e.g. {"Owner":"first_name"}' },
628
- dry_run: B,
1057
+ workbook_id: S,
1058
+ name: S,
1059
+ folder_id: S,
1060
+ include_rules: B,
629
1061
  },
630
- ['csv'],
1062
+ ['workbook_id', 'name'],
631
1063
  ),
632
1064
  },
633
- run: (a) =>
634
- api('POST', '/leads/import/csv', {
635
- csv: a.csv,
636
- ...(a.list_id ? { list_id: a.list_id } : {}),
637
- ...(a.list_name ? { list_name: a.list_name } : {}),
638
- ...(a.dedupe_by ? { dedupe_by: a.dedupe_by } : {}),
639
- ...(a.default_verification_status ? { default_verification_status: a.default_verification_status } : {}),
640
- ...(a.column_map ? { column_map: a.column_map } : {}),
641
- ...(a.dry_run ? { dry_run: true } : {}),
642
- }),
1065
+ run: (a) => api('POST', `/workbooks/${enc(a.workbook_id)}/duplicate`, {
1066
+ name: a.name,
1067
+ ...(a.folder_id !== undefined ? { folder_id: a.folder_id } : {}),
1068
+ ...(a.include_rules !== undefined ? { include_rules: a.include_rules } : {}),
1069
+ }),
643
1070
  },
644
- preview_import: {
1071
+ create_folder: {
645
1072
  def: {
646
1073
  description:
647
- 'Dry-run for import_leads: report exactly what an import WOULD do — {would_insert, would_update, deduped, invalid, invalid_reasons, list_exists} — WITHOUT writing anything (no leads, no list, no tags created). Same inputs as import_leads. Call this first for a bring-your-own batch, show the user the plan (e.g. "adds 340 new, updates 12, skips 3 bad rows"), then confirm before import_leads.',
1074
+ 'Create a Workbench folder for client, team, or pipeline organization. Then use move_to_folder for workbooks or standalone Tables.',
1075
+ inputSchema: obj({ name: S }, ['name']),
1076
+ },
1077
+ run: (a) => api('POST', '/folders', { name: a.name }),
1078
+ },
1079
+ move_to_folder: {
1080
+ def: {
1081
+ description:
1082
+ 'Move exactly one workbook or standalone Table into a folder. Pass folder_id null to move it back to the Workbench root. Tables inside a workbook move with the workbook, not individually.',
648
1083
  inputSchema: obj(
649
1084
  {
1085
+ folder_id: { anyOf: [S, { type: 'null' }] },
1086
+ workbook_id: S,
650
1087
  list_id: S,
651
- list_name: S,
652
- records: ARR(O),
653
- dedupe_by: { ...S, description: '"email" (default) or "external_id"' },
654
- default_verification_status: S,
655
1088
  },
656
- ['records'],
1089
+ ['folder_id'],
657
1090
  ),
658
1091
  },
659
- run: (a) =>
660
- api('POST', '/leads/import/preview', {
661
- ...(a.list_id ? { list_id: a.list_id } : {}),
662
- ...(a.list_name ? { list_name: a.list_name } : {}),
663
- records: a.records || [],
664
- ...(a.dedupe_by ? { dedupe_by: a.dedupe_by } : {}),
665
- ...(a.default_verification_status ? { default_verification_status: a.default_verification_status } : {}),
666
- }),
1092
+ run: (a) => {
1093
+ if (!!a.workbook_id === !!a.list_id) {
1094
+ return {
1095
+ ok: false,
1096
+ status: 400,
1097
+ error: { detail: 'Pass exactly one of workbook_id or list_id.' },
1098
+ };
1099
+ }
1100
+ return a.workbook_id
1101
+ ? api('PATCH', `/workbooks/${enc(a.workbook_id)}`, { folder_id: a.folder_id })
1102
+ : api('PATCH', `/lists-placement/${enc(a.list_id)}`, { folder_id: a.folder_id });
1103
+ },
667
1104
  },
668
- get_list_rows: {
1105
+ delete_folder: {
669
1106
  def: {
670
1107
  description:
671
- "Read a list's leads with their fields (email, name, status, verification_status, custom_fields, strategy tags, position). The read-back for a list built by import_leads (custom_fields + verification_status live on the lead's attributes). Paginated (limit ≤ 500, offset).",
672
- inputSchema: obj({ list_id: S, limit: N, offset: N }, ['list_id']),
1108
+ 'Delete an empty or organizational Workbench folder. Its workbooks and standalone Tables move to the root; their data is not deleted.',
1109
+ inputSchema: obj({ folder_id: S }, ['folder_id']),
673
1110
  },
674
- run: (a) =>
675
- a.list_id
676
- ? api('GET', `/lists/${enc(a.list_id)}/leads${qs({ limit: a.limit, offset: a.offset })}`)
677
- : { ok: false, status: 400, error: { detail: 'list_id is required.' } },
1111
+ run: (a) => api('DELETE', `/folders/${enc(a.folder_id)}`),
678
1112
  },
679
- delete_list: {
1113
+
1114
+ // ── Campaign-bindable Tables ─────────────────────────────────────────
1115
+ get_tables: {
680
1116
  def: {
681
1117
  description:
682
- 'Delete a list (the list + its membership; the underlying leads stay in the workspace). Works for any Scrapeloop list. Irreversible — confirm with the user first.',
683
- inputSchema: obj({ list_id: S }, ['list_id']),
1118
+ 'List campaign-bindable Scrapeloop Tables in this workspace. Returns Clay-style Tables used by imports, enrichment columns, table cells, and create_campaign(source_list_id). Distinct from legacy get_lists.',
1119
+ inputSchema: obj({ archived: B }),
684
1120
  },
685
- run: (a) =>
686
- a.list_id
687
- ? api('DELETE', `/lists/${enc(a.list_id)}`)
688
- : { ok: false, status: 400, error: { detail: 'list_id is required.' } },
1121
+ run: (a) => api('GET', `/tables${qs({ archived: a.archived })}`),
689
1122
  },
690
- delete_campaign: {
1123
+ create_table: {
691
1124
  def: {
692
1125
  description:
693
- 'Delete a campaign (the Scrapeloop campaign + its ledger; the Instantly campaign itself is not deleted). Irreversible — if you only want to stop sending, pause_campaign instead. Confirm with the user first.',
694
- inputSchema: obj({ campaign_id: S }, ['campaign_id']),
1126
+ 'Create a campaign-bindable Scrapeloop Table. Static is the default for imported/manual leads and accepts only name, description, and kind. filter_expression, sort_expression, visible_columns, and is_shared are smart-Table-only fields. The create is retry-safe: one operation_id is reused across HTTP retries. You may pass a prior operation_id to recover an ambiguous call; reusing it with different inputs returns 409.',
1127
+ inputSchema: obj(
1128
+ {
1129
+ name: S,
1130
+ operation_id: { ...S, format: 'uuid', description: 'Optional retry key from a prior ambiguous call.' },
1131
+ description: S,
1132
+ kind: { ...S, enum: ['static', 'smart'] },
1133
+ filter_expression: { ...O, description: 'Smart Tables only.' },
1134
+ sort_expression: { ...O, description: 'Smart Tables only.' },
1135
+ visible_columns: { ...ARR(S), description: 'Smart Tables only.' },
1136
+ is_shared: { ...B, description: 'Smart Tables only.' },
1137
+ },
1138
+ ['name'],
1139
+ ),
1140
+ },
1141
+ run: async (a) => {
1142
+ if (!a.name) return { ok: false, status: 400, error: { detail: 'name is required.' } };
1143
+ const kind = a.kind || 'static';
1144
+ const smartOnly = ['filter_expression', 'sort_expression', 'visible_columns', 'is_shared'];
1145
+ const suppliedSmartOnly = smartOnly.filter((key) => a[key] !== undefined);
1146
+ if (kind === 'static' && suppliedSmartOnly.length) {
1147
+ return {
1148
+ ok: false,
1149
+ status: 400,
1150
+ error: {
1151
+ detail: `static Tables do not accept smart-only fields: ${suppliedSmartOnly.join(', ')}`,
1152
+ },
1153
+ };
1154
+ }
1155
+ const requestBody = {
1156
+ name: a.name,
1157
+ operation_id: a.operation_id || randomUUID(),
1158
+ ...(a.description !== undefined ? { description: a.description } : {}),
1159
+ ...(a.kind !== undefined ? { kind: a.kind } : {}),
1160
+ ...(a.filter_expression !== undefined ? { filter_expression: a.filter_expression } : {}),
1161
+ ...(a.sort_expression !== undefined ? { sort_expression: a.sort_expression } : {}),
1162
+ ...(a.visible_columns !== undefined ? { visible_columns: a.visible_columns } : {}),
1163
+ ...(a.is_shared !== undefined ? { is_shared: a.is_shared } : {}),
1164
+ };
1165
+ return api('POST', '/tables', requestBody, {
1166
+ reconciliation: {
1167
+ operation_id: requestBody.operation_id,
1168
+ retry_with: {
1169
+ tool: 'create_table',
1170
+ arguments: { ...a, operation_id: requestBody.operation_id },
1171
+ },
1172
+ retry_guidance:
1173
+ `Retry create_table with the same operation_id ${requestBody.operation_id}. Never substitute a new key for this logical create.`,
1174
+ },
1175
+ });
695
1176
  },
696
- run: (a) =>
697
- a.campaign_id
698
- ? api('DELETE', `/campaigns/${enc(a.campaign_id)}`)
699
- : { ok: false, status: 400, error: { detail: 'campaign_id is required.' } },
700
1177
  },
701
-
702
- // ── Replies + suppression ─────────────────────────────────────────────
703
- list_replies: {
1178
+ get_table_columns: {
1179
+ def: {
1180
+ description:
1181
+ 'List a campaign-bindable Table\'s dynamic columns in display order. Use each returned id with set_table_cells and each key with list_table_rows filters/sorts.',
1182
+ inputSchema: obj({ table_id: S }, ['table_id']),
1183
+ },
1184
+ run: (a) =>
1185
+ a.table_id
1186
+ ? api('GET', `/tables/${enc(a.table_id)}/columns`)
1187
+ : { ok: false, status: 400, error: { detail: 'table_id is required.' } },
1188
+ },
1189
+ create_table_column: {
1190
+ def: {
1191
+ description:
1192
+ 'Create a FREE manual data column on an active campaign-bindable Table. Archived Tables must be unarchived first. Select and multi_select require 1-100 render-safe options shaped as {label,value,color?}; values are lowercase tokens and colors are gray, blue, teal, amber, or red. This tool cannot create enrichment/formula columns, auto-run work, or spend credits. The create is retry-safe: one operation_id is reused across HTTP retries. You may pass a prior operation_id to recover an ambiguous call; reusing it with different inputs returns 409.',
1193
+ inputSchema: obj(
1194
+ {
1195
+ table_id: S,
1196
+ label: S,
1197
+ operation_id: { ...S, format: 'uuid', description: 'Optional retry key from a prior ambiguous call.' },
1198
+ type: {
1199
+ ...S,
1200
+ enum: ['text', 'number', 'currency', 'select', 'multi_select', 'url', 'email', 'date', 'checkbox'],
1201
+ },
1202
+ width: { ...N, minimum: 60, maximum: 1200 },
1203
+ position_after: {
1204
+ anyOf: [S, { type: 'null' }],
1205
+ description: 'Omit to append, pass null for leftmost, or pass a column id.',
1206
+ },
1207
+ options: {
1208
+ type: 'array',
1209
+ minItems: 1,
1210
+ maxItems: 100,
1211
+ items: obj(
1212
+ {
1213
+ label: { ...S, minLength: 1, maxLength: 80 },
1214
+ value: {
1215
+ ...S,
1216
+ minLength: 1,
1217
+ maxLength: 64,
1218
+ pattern: '^[a-z0-9][a-z0-9_-]*$',
1219
+ },
1220
+ color: { ...S, enum: ['gray', 'blue', 'teal', 'amber', 'red'] },
1221
+ },
1222
+ ['label', 'value'],
1223
+ ),
1224
+ },
1225
+ },
1226
+ ['table_id', 'label'],
1227
+ ),
1228
+ },
1229
+ run: async (a) => {
1230
+ if (!a.table_id || !a.label) {
1231
+ return { ok: false, status: 400, error: { detail: 'table_id and label are required.' } };
1232
+ }
1233
+ const type = a.type || 'text';
1234
+ const isSelect = type === 'select' || type === 'multi_select';
1235
+ if (isSelect && (!Array.isArray(a.options) || !a.options.length)) {
1236
+ return {
1237
+ ok: false,
1238
+ status: 400,
1239
+ error: { detail: 'select and multi_select columns require options.' },
1240
+ };
1241
+ }
1242
+ if (!isSelect && a.options !== undefined) {
1243
+ return {
1244
+ ok: false,
1245
+ status: 400,
1246
+ error: { detail: 'options are allowed only for select and multi_select columns.' },
1247
+ };
1248
+ }
1249
+ const requestBody = {
1250
+ label: a.label,
1251
+ operation_id: a.operation_id || randomUUID(),
1252
+ ...(a.type !== undefined ? { type: a.type } : {}),
1253
+ ...(a.width !== undefined ? { width: a.width } : {}),
1254
+ ...(a.position_after !== undefined ? { position_after: a.position_after } : {}),
1255
+ ...(a.options !== undefined ? { options: a.options } : {}),
1256
+ };
1257
+ const columnsPath = `/tables/${enc(a.table_id)}/columns`;
1258
+ return api('POST', columnsPath, requestBody, {
1259
+ reconciliation: {
1260
+ operation_id: requestBody.operation_id,
1261
+ retry_with: {
1262
+ tool: 'create_table_column',
1263
+ arguments: { ...a, operation_id: requestBody.operation_id },
1264
+ },
1265
+ retry_guidance:
1266
+ `Retry create_table_column with the same operation_id ${requestBody.operation_id}. Never substitute a new key for this logical create.`,
1267
+ },
1268
+ });
1269
+ },
1270
+ },
1271
+ create_table_preset_column: {
1272
+ def: {
1273
+ description:
1274
+ 'Attach one shipped system preset or active workspace preset as a real enrichment column. operation_id is required and must be reused for every retry. auto_run defaults false and queues nothing. If auto_run would backfill existing rows, the API returns an exact 409 estimate first; confirm that estimate with confirm_auto_run and confirmed_estimated_cost_usd before retrying. The Table master auto-run switch and budget caps remain fail-closed. Only preset-documented overrides are accepted.',
1275
+ inputSchema: {
1276
+ ...obj(
1277
+ {
1278
+ table_id: S,
1279
+ preset_slug: S,
1280
+ preset_id: { ...S, format: 'uuid' },
1281
+ label: S,
1282
+ auto_run: B,
1283
+ confirm_auto_run: B,
1284
+ confirmed_estimated_cost_usd: { ...N, minimum: 0 },
1285
+ overrides: O,
1286
+ operation_id: {
1287
+ ...S,
1288
+ format: 'uuid',
1289
+ description: 'Required stable retry key for this logical preset-column create.',
1290
+ },
1291
+ },
1292
+ ['table_id', 'operation_id'],
1293
+ ),
1294
+ oneOf: [
1295
+ { required: ['preset_slug'], not: { required: ['preset_id'] } },
1296
+ { required: ['preset_id'], not: { required: ['preset_slug'] } },
1297
+ ],
1298
+ },
1299
+ },
1300
+ run: async (a) => {
1301
+ if (!a.table_id || !a.operation_id) {
1302
+ return {
1303
+ ok: false,
1304
+ status: 400,
1305
+ error: { detail: 'table_id and a stable operation_id are required.' },
1306
+ };
1307
+ }
1308
+ if (!!a.preset_slug === !!a.preset_id) {
1309
+ return {
1310
+ ok: false,
1311
+ status: 400,
1312
+ error: { detail: 'Pass exactly one of preset_slug or preset_id.' },
1313
+ };
1314
+ }
1315
+ const requestBody = {
1316
+ operation_id: a.operation_id,
1317
+ ...(a.preset_slug !== undefined ? { preset_slug: a.preset_slug } : {}),
1318
+ ...(a.preset_id !== undefined ? { preset_id: a.preset_id } : {}),
1319
+ ...(a.label !== undefined ? { label: a.label } : {}),
1320
+ ...(a.auto_run !== undefined ? { auto_run: a.auto_run } : {}),
1321
+ ...(a.confirm_auto_run !== undefined ? { confirm_auto_run: a.confirm_auto_run } : {}),
1322
+ ...(a.confirmed_estimated_cost_usd !== undefined
1323
+ ? { confirmed_estimated_cost_usd: a.confirmed_estimated_cost_usd }
1324
+ : {}),
1325
+ ...(a.overrides !== undefined ? { overrides: a.overrides } : {}),
1326
+ };
1327
+ const path = `/tables/${enc(a.table_id)}/preset-columns`;
1328
+ return api('POST', path, requestBody, {
1329
+ reconciliation: {
1330
+ operation_id: a.operation_id,
1331
+ retry_with: {
1332
+ tool: 'create_table_preset_column',
1333
+ arguments: { ...a, operation_id: a.operation_id },
1334
+ },
1335
+ retry_guidance:
1336
+ `Retry create_table_preset_column with the same operation_id ${a.operation_id}. Never substitute a new key for this logical create.`,
1337
+ },
1338
+ });
1339
+ },
1340
+ },
1341
+ set_table_cells: {
1342
+ def: {
1343
+ description:
1344
+ 'Write up to 500 FREE manual values into an active static Table in one atomic upsert (maximum 256 KiB JSON). Archived Tables must be unarchived first. Every cell must include value; explicit null clears it. Formats: text is at most 10,000 characters, number/currency is a finite number, checkbox is boolean, date is YYYY-MM-DD, email is valid and at most 320 characters, URL is absolute HTTP(S) and at most 2,048 characters, select is one configured token, and multi_select is a unique token array of at most 100 values. The whole request is validated before writing. Returns updated and skipped counts, and triggers configured row_updated rules once per changed lead. The write itself does not run enrichment or charge Scrapeloop credits.',
1345
+ inputSchema: obj(
1346
+ {
1347
+ table_id: S,
1348
+ cells: {
1349
+ type: 'array',
1350
+ minItems: 1,
1351
+ maxItems: MAX_TABLE_CELL_WRITES,
1352
+ items: obj(
1353
+ {
1354
+ lead_id: S,
1355
+ column_id: S,
1356
+ value: { description: 'Required. Pass null explicitly to clear the cell.' },
1357
+ },
1358
+ ['lead_id', 'column_id', 'value'],
1359
+ ),
1360
+ },
1361
+ },
1362
+ ['table_id', 'cells'],
1363
+ ),
1364
+ },
1365
+ run: (a) => {
1366
+ if (!a.table_id || !Array.isArray(a.cells) || !a.cells.length) {
1367
+ return { ok: false, status: 400, error: { detail: 'table_id and non-empty cells are required.' } };
1368
+ }
1369
+ if (a.cells.length > MAX_TABLE_CELL_WRITES) {
1370
+ return { ok: false, status: 400, error: { detail: 'cells accepts at most 500 items.' } };
1371
+ }
1372
+ if (a.cells.some((cell) => !Object.prototype.hasOwnProperty.call(cell, 'value'))) {
1373
+ return {
1374
+ ok: false,
1375
+ status: 400,
1376
+ error: { detail: 'every cell must include value; pass null explicitly to clear.' },
1377
+ };
1378
+ }
1379
+ const requestBytes = new TextEncoder().encode(
1380
+ JSON.stringify({ cells: a.cells }),
1381
+ ).length;
1382
+ if (requestBytes > MAX_TABLE_CELL_REQUEST_BYTES) {
1383
+ return {
1384
+ ok: false,
1385
+ status: 400,
1386
+ error: { detail: 'cells request exceeds 256 KiB; split it into smaller batches.' },
1387
+ };
1388
+ }
1389
+ return api('POST', `/tables/${enc(a.table_id)}/cells`, { cells: a.cells });
1390
+ },
1391
+ },
1392
+ import_leads: {
1393
+ def: {
1394
+ description:
1395
+ 'Bring your own leads: atomically import externally-sourced records into a campaign-bindable Scrapeloop Table without scraping or lead credits. A generic record needs at least one stable identity: email, linkedin_url, external_id, or an absolute source_url. Email may be null. Canonical sourcing rows may opt into sourcing_contract and must include controlled source, source_state, niche, and a valid phone or email; phone-only local businesses receive a stable source-scoped fallback identity. Source and niche tags are generated automatically. Matching is workspace-scoped and deterministic: normalized email first, then local-business phone, canonical LinkedIn, and external/source identity. The legacy dedupe_by="external_id" mode keeps external identity authoritative. A later record with a real email merges into the earlier LinkedIn/source-only lead instead of duplicating it. No placeholder emails are created. verification_status and custom_fields are preserved, and custom_fields flow to Instantly as custom variables. Target list_id or pass list_name to create-or-get a Table. Batch up to 1000 records. One operation_id is reused across HTTP retries, so an uncertain response can be recovered without duplicating leads. You may pass a prior operation_id; reusing it with different inputs returns 409. Each record: {source?, source_state?, email?, linkedin_url?, source_url?, external_id?, first_name, last_name, business_name, phone, website, address, city, state_region, country, niche, rating, reviews_count, gbp_link?, facebook?, instagram?, verification_status ("verified"|"catchall_verified"|"unverified"|"risky"|"invalid"), strategy_tags:[], custom_fields:{}}. Returns {operation_id, list_id, inserted, updated, deduped, invalid, invalid_reasons}.',
1396
+ inputSchema: obj(
1397
+ {
1398
+ list_id: S,
1399
+ list_name: S,
1400
+ operation_id: { ...S, format: 'uuid', description: 'Optional retry key from a prior ambiguous call.' },
1401
+ records: ARR(O),
1402
+ dedupe_by: { ...S, description: '"email" (default, with LinkedIn/source fallbacks) or legacy "external_id" priority' },
1403
+ lead_type: { ...S, enum: ['local_business', 'b2b_person'] },
1404
+ sourcing_contract: B,
1405
+ default_verification_status: S,
1406
+ },
1407
+ ['records'],
1408
+ ),
1409
+ },
1410
+ run: (a) => {
1411
+ const records = a.records || [];
1412
+ const requestBody = {
1413
+ ...(a.list_id ? { list_id: a.list_id } : {}),
1414
+ ...(a.list_name ? { list_name: a.list_name } : {}),
1415
+ operation_id: a.operation_id || randomUUID(),
1416
+ records,
1417
+ ...(a.dedupe_by ? { dedupe_by: a.dedupe_by } : {}),
1418
+ ...(a.lead_type ? { lead_type: a.lead_type } : {}),
1419
+ ...(a.sourcing_contract ? { sourcing_contract: true } : {}),
1420
+ ...(a.default_verification_status ? { default_verification_status: a.default_verification_status } : {}),
1421
+ };
1422
+ return api('POST', '/leads/import', requestBody, {
1423
+ reconciliation: {
1424
+ inspect_with: a.list_id
1425
+ ? { tool: 'get_list_rows', arguments: { list_id: a.list_id, limit: 500, offset: 0 } }
1426
+ : { tool: 'get_tables', arguments: { archived: false } },
1427
+ expected: {
1428
+ record_count: records.length,
1429
+ ...(a.list_id ? { list_id: a.list_id } : {}),
1430
+ ...(a.list_name ? { list_name: a.list_name } : {}),
1431
+ },
1432
+ operation_id: requestBody.operation_id,
1433
+ retry_with: {
1434
+ tool: 'import_leads',
1435
+ arguments: { ...a, operation_id: requestBody.operation_id },
1436
+ },
1437
+ retry_guidance:
1438
+ `Retry import_leads with the same operation_id ${requestBody.operation_id}. Never substitute a new key for this logical import.`,
1439
+ guidance:
1440
+ 'The original tool arguments remain the reconciliation source of truth; do not infer success from row count alone.',
1441
+ },
1442
+ });
1443
+ },
1444
+ },
1445
+ list_apify_actors: {
1446
+ def: {
1447
+ description:
1448
+ 'List the APPROVED Apify actors you may run/import from. Arbitrary Apify actors are NOT allowed: only vetted actors (website content, contact info with add-ons off, google search with add-ons off) can be used, and LinkedIn / Facebook-group / social-graph actors are always rejected. Returns [{actor_id, add_ons_allowed}]. Use this before preview_apify_import to pick a permitted actor_id.',
1449
+ inputSchema: obj({}),
1450
+ },
1451
+ run: () => api('GET', '/apify/actors'),
1452
+ },
1453
+ preview_apify_import: {
1454
+ def: {
1455
+ description:
1456
+ "Read-only preview of an approved Apify actor's already-produced dataset before importing it. mapping is {lead_field: dotted.source.path} where lead_field is one of name, email, phone, domain, organization_name, city, state, country, linkedin_url, title. Normalizes the whole dataset (rows with no email/phone/domain/linkedin/name are skipped), returns the first 25 rows plus record_count (the true total) and a preview_hash. Pass that preview_hash to import_apify_dataset — the import re-verifies it and refuses (409) if the dataset changed. Does NOT run the actor or spend: it reads an existing dataset_id. Returns {dataset_id, actor_id, record_count, preview, preview_truncated, preview_hash}.",
1457
+ inputSchema: obj(
1458
+ {
1459
+ dataset_id: { ...S, description: 'Apify dataset id from a completed actor run.' },
1460
+ actor_id: { ...S, description: 'Approved actor id (owner/name), e.g. apify/google-search-scraper.' },
1461
+ mapping: { ...O, description: '{lead_field: "dotted.source.path"} onto the lead spine.' },
1462
+ },
1463
+ ['dataset_id', 'actor_id', 'mapping'],
1464
+ ),
1465
+ },
1466
+ run: (a) => api('POST', '/apify/preview', { dataset_id: a.dataset_id, actor_id: a.actor_id, mapping: a.mapping }),
1467
+ },
1468
+ import_apify_dataset: {
1469
+ def: {
1470
+ description:
1471
+ 'Import a previewed Apify dataset (≤1000 rows) into a private Scrapeloop Table through the bring-your-own-leads funnel (dedupe + identity matching + Table attach reused; no lead credits). Call preview_apify_import first and pass its preview_hash — the import re-reads the dataset and returns 409 (preview_drift) if it changed since preview. Each row gets a stable external_id (apify:{dataset}:{row}) so re-importing updates rather than duplicates; company lands in business_name and the contact name/title ride through as custom fields. Target list_id, or omit for a new "Apify import" Table. Returns the import funnel result {inserted, updated, deduped, invalid, ...}.',
1472
+ inputSchema: obj(
1473
+ {
1474
+ dataset_id: S,
1475
+ actor_id: S,
1476
+ mapping: O,
1477
+ preview_hash: { ...S, description: 'The preview_hash returned by preview_apify_import.' },
1478
+ list_id: S,
1479
+ list_name: S,
1480
+ },
1481
+ ['dataset_id', 'actor_id', 'mapping', 'preview_hash'],
1482
+ ),
1483
+ },
1484
+ run: (a) =>
1485
+ api('POST', '/apify/import', {
1486
+ dataset_id: a.dataset_id,
1487
+ actor_id: a.actor_id,
1488
+ mapping: a.mapping,
1489
+ preview_hash: a.preview_hash,
1490
+ ...(a.list_id ? { list_id: a.list_id } : {}),
1491
+ ...(a.list_name ? { list_name: a.list_name } : {}),
1492
+ }),
1493
+ },
1494
+ capture_community_intent: {
1495
+ def: {
1496
+ description:
1497
+ 'Manually record one reviewed, high-intent public community signal or admin-approved opt-in. This is not a social scraper. Private content, group member lists, membership-only evidence, reactions, follows, automated messages, cookies, and platform-control bypasses are prohibited. Required evidence is minimized to policy basis, visibility, consent mode, source and public-profile URLs, observed timestamp, evidence class, company, and reviewer note. Pending signals default to outreach_ready=false and legal_review_required=true. A business email is accepted only after approval and only when it was published for business contact or explicitly opted in. Set dry_run=true first. The mutation is idempotent through operation_id and writes reviewer lineage before any Table import.',
1498
+ inputSchema: obj(
1499
+ {
1500
+ policy_basis: {
1501
+ ...S,
1502
+ enum: ['public_product_signal', 'admin_partnership', 'approved_form', 'webinar', 'poll', 'lead_magnet'],
1503
+ },
1504
+ visibility: { ...S, enum: ['public', 'admin_approved_opt_in', 'private', 'member_list'] },
1505
+ consent_mode: { ...S, enum: ['none', 'public_business_contact', 'explicit_opt_in'] },
1506
+ source_url: { ...S, format: 'uri' },
1507
+ public_profile_url: { ...S, format: 'uri' },
1508
+ observed_at: { ...S, format: 'date-time' },
1509
+ evidence_class: {
1510
+ ...S,
1511
+ enum: ['product_named', 'product_problem', 'admin_approved_opt_in', 'membership_only'],
1512
+ },
1513
+ company: S,
1514
+ reviewer_note: {
1515
+ ...S,
1516
+ description: 'Short reviewer rationale only. Do not paste the post or comment body.',
1517
+ },
1518
+ reviewer_decision: { ...S, enum: ['pending', 'approved', 'rejected'] },
1519
+ first_name: S,
1520
+ last_name: S,
1521
+ business_email: { ...S, format: 'email' },
1522
+ email_published_for_business: B,
1523
+ list_id: S,
1524
+ list_name: S,
1525
+ operation_id: { ...S, format: 'uuid', description: 'Optional retry key from a prior ambiguous call.' },
1526
+ dry_run: B,
1527
+ },
1528
+ [
1529
+ 'policy_basis',
1530
+ 'visibility',
1531
+ 'consent_mode',
1532
+ 'source_url',
1533
+ 'public_profile_url',
1534
+ 'observed_at',
1535
+ 'evidence_class',
1536
+ 'company',
1537
+ 'reviewer_note',
1538
+ ],
1539
+ ),
1540
+ },
1541
+ run: (a) => {
1542
+ const signal = {
1543
+ policy_basis: a.policy_basis,
1544
+ visibility: a.visibility,
1545
+ consent_mode: a.consent_mode,
1546
+ source_url: a.source_url,
1547
+ public_profile_url: a.public_profile_url,
1548
+ observed_at: a.observed_at,
1549
+ evidence_class: a.evidence_class,
1550
+ company: a.company,
1551
+ reviewer_note: a.reviewer_note,
1552
+ reviewer_decision: a.reviewer_decision || 'pending',
1553
+ ...(a.first_name ? { first_name: a.first_name } : {}),
1554
+ ...(a.last_name ? { last_name: a.last_name } : {}),
1555
+ ...(a.business_email ? { business_email: a.business_email } : {}),
1556
+ ...(a.email_published_for_business ? { email_published_for_business: true } : {}),
1557
+ };
1558
+ const requestBody = {
1559
+ signal,
1560
+ ...(a.list_id ? { list_id: a.list_id } : {}),
1561
+ ...(a.list_name ? { list_name: a.list_name } : {}),
1562
+ ...(!a.dry_run ? { operation_id: a.operation_id || randomUUID() } : {}),
1563
+ };
1564
+ if (a.dry_run) {
1565
+ return api('POST', '/leads/community-intent/preview', requestBody);
1566
+ }
1567
+ return api('POST', '/leads/community-intent', requestBody, {
1568
+ reconciliation: {
1569
+ inspect_with: a.list_id
1570
+ ? { tool: 'get_list_rows', arguments: { list_id: a.list_id, limit: 100, offset: 0 } }
1571
+ : { tool: 'get_tables', arguments: { archived: false } },
1572
+ expected: {
1573
+ company: a.company,
1574
+ public_profile_url: a.public_profile_url,
1575
+ reviewer_decision: signal.reviewer_decision,
1576
+ },
1577
+ operation_id: requestBody.operation_id,
1578
+ retry_with: {
1579
+ tool: 'capture_community_intent',
1580
+ arguments: { ...a, operation_id: requestBody.operation_id },
1581
+ },
1582
+ retry_guidance:
1583
+ `Retry capture_community_intent with the same operation_id ${requestBody.operation_id}. Never substitute a new key for this logical review.`,
1584
+ guidance:
1585
+ 'The reviewer decision and operation_id are the receipt source of truth. Never infer approval from a Table row alone.',
1586
+ },
1587
+ });
1588
+ },
1589
+ },
1590
+ import_leads_csv: {
1591
+ def: {
1592
+ description:
1593
+ "Bring your own leads from CSV/TSV into a campaign-bindable Table. Rows may identify a lead by email, LinkedIn URL, external ID, or absolute source URL, so raw identities do not need placeholder emails. The parser auto-detects the delimiter, maps common headers (including linkedin_url and source_url), preserves unmapped columns as custom_fields, and normalizes verifier verdicts. A CSV containing source, source_state, and niche automatically enables the sourcing contract: every loaded row must also have a valid phone or email, source IDs are namespaced, phone-only local businesses get a stable fallback identity, and source/niche tags are generated. Matching is workspace-scoped: email first, then local-business phone, LinkedIn, and external/source identity. Set dry_run:true first to preview writes and column mapping. Mutating CSV imports are atomic and reuse one operation_id across HTTP retries. You may pass a prior operation_id; reusing it with changed CSV inputs returns 409. No scraping, lead credits, or re-verification. Target list_id or pass list_name. Up to 1000 rows per call.",
1594
+ inputSchema: obj(
1595
+ {
1596
+ csv: S,
1597
+ list_id: S,
1598
+ list_name: S,
1599
+ operation_id: { ...S, format: 'uuid', description: 'Optional retry key from a prior ambiguous call.' },
1600
+ dedupe_by: { ...S, description: '"email" (default, with LinkedIn/source fallbacks) or legacy "external_id" priority' },
1601
+ lead_type: { ...S, enum: ['local_business', 'b2b_person'] },
1602
+ sourcing_contract: B,
1603
+ default_verification_status: S,
1604
+ column_map: { ...O, description: 'optional explicit header→field overrides, e.g. {"Owner":"first_name"}' },
1605
+ dry_run: B,
1606
+ },
1607
+ ['csv'],
1608
+ ),
1609
+ },
1610
+ run: (a) => {
1611
+ const requestBody = {
1612
+ csv: a.csv,
1613
+ ...(a.list_id ? { list_id: a.list_id } : {}),
1614
+ ...(a.list_name ? { list_name: a.list_name } : {}),
1615
+ ...(!a.dry_run ? { operation_id: a.operation_id || randomUUID() } : {}),
1616
+ ...(a.dedupe_by ? { dedupe_by: a.dedupe_by } : {}),
1617
+ ...(a.lead_type ? { lead_type: a.lead_type } : {}),
1618
+ ...(a.sourcing_contract ? { sourcing_contract: true } : {}),
1619
+ ...(a.default_verification_status ? { default_verification_status: a.default_verification_status } : {}),
1620
+ ...(a.column_map ? { column_map: a.column_map } : {}),
1621
+ ...(a.dry_run ? { dry_run: true } : {}),
1622
+ };
1623
+ if (a.dry_run) return api('POST', '/leads/import/csv', requestBody);
1624
+ return api('POST', '/leads/import/csv', requestBody, {
1625
+ reconciliation: {
1626
+ inspect_with: a.list_id
1627
+ ? { tool: 'get_list_rows', arguments: { list_id: a.list_id, limit: 500, offset: 0 } }
1628
+ : { tool: 'get_tables', arguments: { archived: false } },
1629
+ expected: {
1630
+ ...(a.list_id ? { list_id: a.list_id } : {}),
1631
+ ...(a.list_name ? { list_name: a.list_name } : {}),
1632
+ },
1633
+ operation_id: requestBody.operation_id,
1634
+ retry_with: {
1635
+ tool: 'import_leads_csv',
1636
+ arguments: { ...a, operation_id: requestBody.operation_id },
1637
+ },
1638
+ retry_guidance:
1639
+ `Retry import_leads_csv with the same operation_id ${requestBody.operation_id}. Never substitute a new key for this logical import.`,
1640
+ guidance:
1641
+ 'Do not infer success from row count alone because the Table may already contain unrelated rows.',
1642
+ },
1643
+ });
1644
+ },
1645
+ },
1646
+ preview_import: {
1647
+ def: {
1648
+ description:
1649
+ 'Dry-run for import_leads: report exactly what an import WOULD do — {would_insert, would_update, deduped, invalid, invalid_reasons, list_exists} — WITHOUT writing anything (no leads, no list, no tags created). Same inputs as import_leads. Call this first for a bring-your-own batch, show the user the plan (e.g. "adds 340 new, updates 12, skips 3 bad rows"), then confirm before import_leads.',
1650
+ inputSchema: obj(
1651
+ {
1652
+ list_id: S,
1653
+ list_name: S,
1654
+ records: ARR(O),
1655
+ dedupe_by: { ...S, description: '"email" (default, with LinkedIn/source fallbacks) or legacy "external_id" priority' },
1656
+ lead_type: { ...S, enum: ['local_business', 'b2b_person'] },
1657
+ sourcing_contract: B,
1658
+ default_verification_status: S,
1659
+ },
1660
+ ['records'],
1661
+ ),
1662
+ },
1663
+ run: (a) =>
1664
+ api('POST', '/leads/import/preview', {
1665
+ ...(a.list_id ? { list_id: a.list_id } : {}),
1666
+ ...(a.list_name ? { list_name: a.list_name } : {}),
1667
+ records: a.records || [],
1668
+ ...(a.dedupe_by ? { dedupe_by: a.dedupe_by } : {}),
1669
+ ...(a.lead_type ? { lead_type: a.lead_type } : {}),
1670
+ ...(a.sourcing_contract ? { sourcing_contract: true } : {}),
1671
+ ...(a.default_verification_status ? { default_verification_status: a.default_verification_status } : {}),
1672
+ }),
1673
+ },
1674
+ get_list_rows: {
1675
+ def: {
1676
+ description:
1677
+ "Read a Table's imported leads with canonical identity fields (email, phone, LinkedIn, external_id, source_url, place_id), name/company/location, lead type, source platform, verification status, custom fields, tags, and position. Email remains null for raw identities until enrichment finds one. Paginated (limit up to 500, offset).",
1678
+ inputSchema: obj({ list_id: S, limit: N, offset: N }, ['list_id']),
1679
+ },
1680
+ run: (a) =>
1681
+ a.list_id
1682
+ ? api('GET', `/lists/${enc(a.list_id)}/leads${qs({ limit: a.limit, offset: a.offset })}`)
1683
+ : { ok: false, status: 400, error: { detail: 'list_id is required.' } },
1684
+ },
1685
+ get_list_history: {
1686
+ def: {
1687
+ description:
1688
+ "A table's history: log='runs' (default, who ran which enrichment column, scope, rows, cost, and outcome) or log='changes' (column and settings edits with actor). Kept 90 days. Paginated (limit at most 200).",
1689
+ inputSchema: obj(
1690
+ {
1691
+ list_id: S,
1692
+ log: { ...S, description: '"runs" (default) or "changes"' },
1693
+ limit: N,
1694
+ before: S,
1695
+ },
1696
+ ['list_id'],
1697
+ ),
1698
+ },
1699
+ run: (a) =>
1700
+ a.list_id
1701
+ ? api(
1702
+ 'GET',
1703
+ `/lists/${enc(a.list_id)}/history/${a.log === 'changes' ? 'changes' : 'runs'}${qs({ limit: a.limit, before: a.before })}`,
1704
+ )
1705
+ : { ok: false, status: 400, error: { detail: 'list_id is required.' } },
1706
+ },
1707
+ get_flow_graph: {
1708
+ def: {
1709
+ description:
1710
+ 'Get the real-object Flows graph for the workspace, one workbook, or one Table ego graph. Includes rule identity and activity, campaign feed/offload relationships, and saved node positions. scope is workspace, workbook:<uuid>, or list:<uuid>.',
1711
+ inputSchema: obj({ scope: { ...S, description: 'workspace, workbook:<uuid>, or list:<uuid>' } }),
1712
+ },
1713
+ run: (a) => api('GET', `/workspaces/flow-graph${qs({ scope: a.scope })}`),
1714
+ },
1715
+ save_flow_layout: {
1716
+ def: {
1717
+ description:
1718
+ 'Save presentation-only node positions for a Flows scope. This never creates or changes rules or relationships. layout maps node ids to {x,y} coordinates and is limited to 500 nodes.',
1719
+ inputSchema: obj(
1720
+ {
1721
+ scope_key: { ...S, description: 'workspace, workbook:<uuid>, or list:<uuid>' },
1722
+ layout: { ...O, description: 'Map of node id to {x:number,y:number}' },
1723
+ },
1724
+ ['scope_key', 'layout'],
1725
+ ),
1726
+ },
1727
+ run: (a) => api('PUT', '/workspaces/flow-layout', { scope_key: a.scope_key, layout: a.layout }),
1728
+ },
1729
+ get_flows_catalog: {
1730
+ def: {
1731
+ description:
1732
+ 'Get the live flow trigger, action, operator, config-schema, availability, and safety-limit catalog. Call this before creating a rule so unavailable epic capabilities are not selected.',
1733
+ inputSchema: obj({}),
1734
+ },
1735
+ run: () => api('GET', '/flows/catalog'),
1736
+ },
1737
+ list_table_flow_rules: {
1738
+ def: {
1739
+ description:
1740
+ "List a Table's saved automation rules, including triggers, conditions, actions, enabled state, and fire counts.",
1741
+ inputSchema: obj({ list_id: S }, ['list_id']),
1742
+ },
1743
+ run: (a) =>
1744
+ a.list_id
1745
+ ? api('GET', `/lists/${enc(a.list_id)}/flow-rules`)
1746
+ : { ok: false, status: 400, error: { detail: 'list_id is required.' } },
1747
+ },
1748
+ create_table_flow_rule: {
1749
+ def: {
1750
+ description:
1751
+ 'Create a Table automation rule. Conditions use FilterExpr. Actions route one matching row to another Table, a campaign, or a public webhook.',
1752
+ inputSchema: obj(
1753
+ {
1754
+ list_id: S,
1755
+ name: S,
1756
+ trigger: {
1757
+ ...S,
1758
+ enum: [
1759
+ 'row_created',
1760
+ 'row_updated',
1761
+ 'column_completed',
1762
+ 'verification_completed',
1763
+ 'schedule',
1764
+ ],
1765
+ },
1766
+ trigger_column_id: S,
1767
+ condition: O,
1768
+ action: {
1769
+ ...S,
1770
+ enum: [
1771
+ 'send_to_table',
1772
+ 'send_to_campaign',
1773
+ 'send_to_webhook',
1774
+ 'set_field',
1775
+ 'run_columns',
1776
+ 'suppress',
1777
+ 'notify_email',
1778
+ 'remove_from_campaign',
1779
+ 'approval_gate',
1780
+ 'send_to_crm',
1781
+ ],
1782
+ },
1783
+ action_config: O,
1784
+ daily_fire_cap: N,
1785
+ delay_seconds: N,
1786
+ schedule_interval: { ...S, enum: ['hourly', 'daily', 'weekly', 'monthly'] },
1787
+ },
1788
+ ['list_id', 'name', 'trigger', 'condition', 'action', 'action_config'],
1789
+ ),
1790
+ },
1791
+ run: (a) =>
1792
+ a.list_id
1793
+ ? api('POST', `/lists/${enc(a.list_id)}/flow-rules`, {
1794
+ name: a.name,
1795
+ trigger: a.trigger,
1796
+ ...(a.trigger_column_id ? { trigger_column_id: a.trigger_column_id } : {}),
1797
+ condition: a.condition || {},
1798
+ action: a.action,
1799
+ action_config: a.action_config || {},
1800
+ ...(a.daily_fire_cap != null ? { daily_fire_cap: a.daily_fire_cap } : {}),
1801
+ ...(a.delay_seconds != null ? { delay_seconds: a.delay_seconds } : {}),
1802
+ ...(a.schedule_interval ? { schedule_interval: a.schedule_interval } : {}),
1803
+ })
1804
+ : { ok: false, status: 400, error: { detail: 'list_id is required.' } },
1805
+ },
1806
+ toggle_table_flow_rule: {
1807
+ def: {
1808
+ description: 'Enable or disable one saved Table automation rule.',
1809
+ inputSchema: obj({ list_id: S, rule_id: S, enabled: B }, ['list_id', 'rule_id', 'enabled']),
1810
+ },
1811
+ run: (a) =>
1812
+ a.list_id && a.rule_id
1813
+ ? api('PATCH', `/lists/${enc(a.list_id)}/flow-rules/${enc(a.rule_id)}`, {
1814
+ enabled: !!a.enabled,
1815
+ })
1816
+ : { ok: false, status: 400, error: { detail: 'list_id and rule_id are required.' } },
1817
+ },
1818
+ list_campaign_flow_rules: {
1819
+ def: {
1820
+ description:
1821
+ "List a campaign's saved automation rules. Campaign triggers remain unavailable until the catalog marks their event emitters live.",
1822
+ inputSchema: obj({ campaign_id: S }, ['campaign_id']),
1823
+ },
1824
+ run: (a) =>
1825
+ a.campaign_id
1826
+ ? api('GET', `/campaigns/${enc(a.campaign_id)}/flow-rules`)
1827
+ : { ok: false, status: 400, error: { detail: 'campaign_id is required.' } },
1828
+ },
1829
+ create_campaign_flow_rule: {
1830
+ def: {
1831
+ description:
1832
+ 'Create a campaign-scoped automation rule after get_flows_catalog reports its trigger and action available.',
1833
+ inputSchema: obj(
1834
+ {
1835
+ campaign_id: S,
1836
+ name: S,
1837
+ trigger: {
1838
+ ...S,
1839
+ enum: [
1840
+ 'campaign_replied',
1841
+ 'campaign_bounced',
1842
+ 'campaign_unsubscribed',
1843
+ 'campaign_sequence_done',
1844
+ ],
1845
+ },
1846
+ condition: O,
1847
+ action: {
1848
+ ...S,
1849
+ enum: [
1850
+ 'send_to_table',
1851
+ 'send_to_campaign',
1852
+ 'send_to_webhook',
1853
+ 'set_field',
1854
+ 'run_columns',
1855
+ 'suppress',
1856
+ 'notify_email',
1857
+ 'remove_from_campaign',
1858
+ 'approval_gate',
1859
+ 'send_to_crm',
1860
+ ],
1861
+ },
1862
+ action_config: O,
1863
+ daily_fire_cap: N,
1864
+ delay_seconds: N,
1865
+ },
1866
+ ['campaign_id', 'name', 'trigger', 'condition', 'action', 'action_config'],
1867
+ ),
1868
+ },
1869
+ run: (a) =>
1870
+ a.campaign_id
1871
+ ? api('POST', `/campaigns/${enc(a.campaign_id)}/flow-rules`, {
1872
+ name: a.name,
1873
+ trigger: a.trigger,
1874
+ condition: a.condition || {},
1875
+ action: a.action,
1876
+ action_config: a.action_config || {},
1877
+ ...(a.daily_fire_cap != null ? { daily_fire_cap: a.daily_fire_cap } : {}),
1878
+ ...(a.delay_seconds != null ? { delay_seconds: a.delay_seconds } : {}),
1879
+ })
1880
+ : { ok: false, status: 400, error: { detail: 'campaign_id is required.' } },
1881
+ },
1882
+ toggle_campaign_flow_rule: {
1883
+ def: {
1884
+ description: 'Enable or disable one saved campaign automation rule.',
1885
+ inputSchema: obj(
1886
+ { campaign_id: S, rule_id: S, enabled: B },
1887
+ ['campaign_id', 'rule_id', 'enabled'],
1888
+ ),
1889
+ },
1890
+ run: (a) =>
1891
+ a.campaign_id && a.rule_id
1892
+ ? api(
1893
+ 'PATCH',
1894
+ `/campaigns/${enc(a.campaign_id)}/flow-rules/${enc(a.rule_id)}`,
1895
+ { enabled: !!a.enabled },
1896
+ )
1897
+ : {
1898
+ ok: false,
1899
+ status: 400,
1900
+ error: { detail: 'campaign_id and rule_id are required.' },
1901
+ },
1902
+ },
1903
+ test_flow_rule: {
1904
+ def: {
1905
+ description:
1906
+ 'Test one saved flow rule against one lead. Dry-run is the safe default and reports whether the row matches plus the action that would run. Set execute=true only after confirmation because the action may spend credits or change data.',
1907
+ inputSchema: obj(
1908
+ {
1909
+ list_id: { ...S, description: 'Source Table id. Provide this or campaign_id, never both.' },
1910
+ campaign_id: { ...S, description: 'Source campaign id. Provide this or list_id, never both.' },
1911
+ rule_id: S,
1912
+ lead_id: { ...S, description: 'Optional lead id. The newest source row is used when omitted.' },
1913
+ execute: { ...B, description: 'False for dry-run. True executes the action once.' },
1914
+ },
1915
+ ['rule_id'],
1916
+ ),
1917
+ },
1918
+ run: (a) => {
1919
+ if (!!a.list_id === !!a.campaign_id) {
1920
+ return {
1921
+ ok: false,
1922
+ status: 400,
1923
+ error: { detail: 'Provide exactly one of list_id or campaign_id.' },
1924
+ };
1925
+ }
1926
+ const source = a.list_id
1927
+ ? `/lists/${enc(a.list_id)}`
1928
+ : `/campaigns/${enc(a.campaign_id)}`;
1929
+ return api('POST', `${source}/flow-rules/${enc(a.rule_id)}/test`, {
1930
+ ...(a.lead_id ? { lead_id: a.lead_id } : {}),
1931
+ execute: !!a.execute,
1932
+ });
1933
+ },
1934
+ },
1935
+ get_flow_rule_stats: {
1936
+ def: {
1937
+ description:
1938
+ 'Get the frozen 24-hour and 7-day fire, failure, last-run, and paused-state counters for every saved rule on one Table or campaign.',
1939
+ inputSchema: obj({
1940
+ list_id: { ...S, description: 'Source Table id. Provide this or campaign_id, never both.' },
1941
+ campaign_id: { ...S, description: 'Source campaign id. Provide this or list_id, never both.' },
1942
+ }),
1943
+ },
1944
+ run: (a) => {
1945
+ if (!!a.list_id === !!a.campaign_id) {
1946
+ return {
1947
+ ok: false,
1948
+ status: 400,
1949
+ error: { detail: 'Provide exactly one of list_id or campaign_id.' },
1950
+ };
1951
+ }
1952
+ return a.list_id
1953
+ ? api('GET', `/lists/${enc(a.list_id)}/flow-rules/stats`)
1954
+ : api('GET', `/campaigns/${enc(a.campaign_id)}/flow-rules/stats`);
1955
+ },
1956
+ },
1957
+ preview_send_to_table: {
1958
+ def: {
1959
+ description:
1960
+ 'FREE preview for sending a table view or selection into another table. ALWAYS show the new-row, already-present, and cell-copy counts before send_to_table. Finished mapped values arrive as plain data; enrichment provenance stays in the source table.',
1961
+ inputSchema: obj(
1962
+ {
1963
+ list_id: S,
1964
+ destination_list_id: S,
1965
+ new_table_name: S,
1966
+ scope: O,
1967
+ include_enrichment_values: B,
1968
+ column_mapping: ARR(O),
1969
+ },
1970
+ ['list_id'],
1971
+ ),
1972
+ },
1973
+ run: (a) =>
1974
+ api('POST', `/lists/${enc(a.list_id)}/send-to-list/preview`, {
1975
+ ...(a.destination_list_id ? { destination_list_id: a.destination_list_id } : {}),
1976
+ ...(a.new_table_name ? { new_table_name: a.new_table_name } : {}),
1977
+ ...(a.scope ? { scope: a.scope } : {}),
1978
+ include_enrichment_values: !!a.include_enrichment_values,
1979
+ column_mapping: a.column_mapping || [],
1980
+ }),
1981
+ },
1982
+ send_to_table: {
1983
+ def: {
1984
+ description:
1985
+ 'Send scoped rows into another table, optionally copying finished mapped cells as plain data. Never copies enrichment cost, status, cache, errors, or vendor provenance. Run preview_send_to_table and confirm with the user first.',
1986
+ inputSchema: obj(
1987
+ {
1988
+ list_id: S,
1989
+ destination_list_id: S,
1990
+ new_table_name: S,
1991
+ scope: O,
1992
+ include_enrichment_values: B,
1993
+ column_mapping: ARR(O),
1994
+ },
1995
+ ['list_id'],
1996
+ ),
1997
+ },
1998
+ run: (a) =>
1999
+ api('POST', `/lists/${enc(a.list_id)}/send-to-list`, {
2000
+ ...(a.destination_list_id ? { destination_list_id: a.destination_list_id } : {}),
2001
+ ...(a.new_table_name ? { new_table_name: a.new_table_name } : {}),
2002
+ ...(a.scope ? { scope: a.scope } : {}),
2003
+ include_enrichment_values: !!a.include_enrichment_values,
2004
+ column_mapping: a.column_mapping || [],
2005
+ }),
2006
+ },
2007
+ preview_table_write_back: {
2008
+ def: {
2009
+ description:
2010
+ "FREE dry-run of writing a column's settled values onto the leads' real field (one of: email, phone, domain, name, city, state, country, postal_code, address_line1). Returns fill-empty / conflicts / duplicates / invalid counts. ALWAYS run this and show the counts before apply_table_write_back. A verify column is refused (it already writes email_status).",
2011
+ inputSchema: obj(
2012
+ { list_id: S, column_id: S, field: S, lead_ids: ARR(S) },
2013
+ ['list_id', 'column_id', 'field'],
2014
+ ),
2015
+ },
2016
+ run: (a) =>
2017
+ api('POST', `/lists/${enc(a.list_id)}/columns/${enc(a.column_id)}/write-back/preview`, {
2018
+ field: a.field,
2019
+ ...(a.lead_ids ? { lead_ids: a.lead_ids } : {}),
2020
+ }),
2021
+ },
2022
+ apply_table_write_back: {
2023
+ def: {
2024
+ description:
2025
+ "Write a column's settled cell values onto the leads' real field. Fill-empty by default; set overwrite=true to replace a DIFFERENT existing value. FREE (writes lead fields, spends nothing). A changed email resets email_status to 'unverified' unless the cell proves that exact address was verified; a known-invalid email is never written even with overwrite. Run preview_table_write_back and confirm with the user first.",
2026
+ inputSchema: obj(
2027
+ { list_id: S, column_id: S, field: S, overwrite: B, lead_ids: ARR(S) },
2028
+ ['list_id', 'column_id', 'field'],
2029
+ ),
2030
+ },
2031
+ run: (a) =>
2032
+ api('POST', `/lists/${enc(a.list_id)}/columns/${enc(a.column_id)}/write-back`, {
2033
+ field: a.field,
2034
+ overwrite: !!a.overwrite,
2035
+ ...(a.lead_ids ? { lead_ids: a.lead_ids } : {}),
2036
+ }),
2037
+ },
2038
+ export_table_csv: {
2039
+ def: {
2040
+ description:
2041
+ "Export a table to CSV as a background job. scope is the same view/filter/selection object used by table runs; fields are ordered lead.<field>/col.<column_key> keys and default to what the view shows. Returns job_id; poll get_table_export for a download link valid for 7 days.",
2042
+ inputSchema: obj(
2043
+ { list_id: S, scope: O, fields: ARR(S), filename: S },
2044
+ ['list_id'],
2045
+ ),
2046
+ },
2047
+ run: (a) =>
2048
+ api('POST', `/lists/${enc(a.list_id)}/export`, {
2049
+ ...(a.scope ? { scope: a.scope } : {}),
2050
+ ...(a.fields ? { fields: a.fields } : {}),
2051
+ ...(a.filename ? { filename: a.filename } : {}),
2052
+ }),
2053
+ },
2054
+ get_table_export: {
2055
+ def: {
2056
+ description:
2057
+ 'Get one table export and its signed download URL, or omit job_id to list recent background exports.',
2058
+ inputSchema: obj({ list_id: S, job_id: S }, ['list_id']),
2059
+ },
2060
+ run: (a) =>
2061
+ api(
2062
+ 'GET',
2063
+ a.job_id
2064
+ ? `/lists/${enc(a.list_id)}/exports/${enc(a.job_id)}`
2065
+ : `/lists/${enc(a.list_id)}/exports`,
2066
+ ),
2067
+ },
2068
+ list_table_rows: {
2069
+ def: {
2070
+ description:
2071
+ "Read a table's rows the way the grid sees them — lead identity fields (name/email/phone/domain/city/state/country/status) plus every column's cell (status + value). Server-side: sort is a JSON-array string like [{\"key\":\"lead.name\",\"dir\":\"asc\"},{\"key\":\"col.company_size\",\"dir\":\"desc\"}] (keys are lead.<field> or col.<column_key>, ≤3 levels); q is a full-text search across identity fields + visible column cells; filter is a FilterExpr object (same grammar the Tables filter UI uses) — e.g. {\"and\":[{\"field\":\"lead.email_status\",\"op\":\"eq\",\"value\":\"valid\"},{\"field\":\"col.company_size\",\"op\":\"gte\",\"value\":50}]} — and adds total_filtered alongside total; pass the previous response's next_cursor back as cursor to page (null next_cursor = last page). Distinct from get_list_rows (the plain import read-back). limit ≤ 500.",
2072
+ inputSchema: obj(
2073
+ {
2074
+ list_id: S,
2075
+ cursor: S,
2076
+ limit: N,
2077
+ sort: S,
2078
+ q: S,
2079
+ filter: { ...O, description: 'FilterExpr JSON (object) — server-side row filter; adds total_filtered.' },
2080
+ view_id: { ...S, description: 'A saved custom view uuid or a system key (errored_rows/fully_enriched/data_only) from list_table_views. An ad-hoc filter/sort replaces the view\'s.' },
2081
+ },
2082
+ ['list_id'],
2083
+ ),
2084
+ },
2085
+ run: (a) =>
2086
+ a.list_id
2087
+ ? api(
2088
+ 'GET',
2089
+ `/lists/${enc(a.list_id)}/rows${qs({
2090
+ cursor: a.cursor,
2091
+ limit: a.limit,
2092
+ sort: a.sort,
2093
+ q: a.q,
2094
+ filter: a.filter ? JSON.stringify(a.filter) : undefined,
2095
+ view_id: a.view_id,
2096
+ })}`,
2097
+ )
2098
+ : { ok: false, status: 400, error: { detail: 'list_id is required.' } },
2099
+ },
2100
+ list_table_views: {
2101
+ def: {
2102
+ description:
2103
+ "List a table's views — three always-current SYSTEM views (errored_rows / fully_enriched / data_only) plus the user's saved custom views. Pass a returned id (or a system key) as view_id to list_table_rows to read that view (a saved filter + sorts + column overlay).",
2104
+ inputSchema: obj({ list_id: S }, ['list_id']),
2105
+ },
2106
+ run: (a) =>
2107
+ a.list_id
2108
+ ? api('GET', `/lists/${enc(a.list_id)}/views`)
2109
+ : { ok: false, status: 400, error: { detail: 'list_id is required.' } },
2110
+ },
2111
+ create_table_view: {
2112
+ def: {
2113
+ description:
2114
+ 'Save a custom view of an active table: a named filter (FilterExpr) + sorts + column overlay + row window (all optional beyond name). filter_expression uses the same grammar as list_table_rows; sorts is [{key,dir}]. The create is retry-safe: one operation_id is reused across HTTP retries. You may pass a prior operation_id to recover an ambiguous call; reusing it with different inputs returns 409.',
2115
+ inputSchema: obj(
2116
+ {
2117
+ list_id: S,
2118
+ name: S,
2119
+ operation_id: { ...S, format: 'uuid', description: 'Optional retry key from a prior ambiguous call.' },
2120
+ description: S,
2121
+ filter_expression: O,
2122
+ sorts: ARR(O),
2123
+ column_state: O,
2124
+ row_window: O,
2125
+ },
2126
+ ['list_id', 'name'],
2127
+ ),
2128
+ },
2129
+ run: async (a) => {
2130
+ if (!a.list_id || !a.name) {
2131
+ return { ok: false, status: 400, error: { detail: 'list_id and name are required.' } };
2132
+ }
2133
+ const viewsPath = `/lists/${enc(a.list_id)}/views`;
2134
+ const requestBody = {
2135
+ name: a.name,
2136
+ operation_id: a.operation_id || randomUUID(),
2137
+ ...(a.description !== undefined ? { description: a.description } : {}),
2138
+ ...(a.filter_expression !== undefined ? { filter_expression: a.filter_expression } : {}),
2139
+ ...(a.sorts !== undefined ? { sorts: a.sorts } : {}),
2140
+ ...(a.column_state !== undefined ? { column_state: a.column_state } : {}),
2141
+ ...(a.row_window !== undefined ? { row_window: a.row_window } : {}),
2142
+ };
2143
+ return api('POST', viewsPath, requestBody, {
2144
+ reconciliation: {
2145
+ operation_id: requestBody.operation_id,
2146
+ retry_with: {
2147
+ tool: 'create_table_view',
2148
+ arguments: { ...a, operation_id: requestBody.operation_id },
2149
+ },
2150
+ retry_guidance:
2151
+ `Retry create_table_view with the same operation_id ${requestBody.operation_id}. Never substitute a new key for this logical create.`,
2152
+ },
2153
+ });
2154
+ },
2155
+ },
2156
+ update_table_view: {
2157
+ def: {
2158
+ description:
2159
+ 'Update a saved table view — any field (name/description/filter_expression/sorts/column_state/row_window); a field set to null clears it. System views (errored_rows/fully_enriched/data_only) are read-only.',
2160
+ inputSchema: obj(
2161
+ { list_id: S, view_id: S, name: S, description: S, filter_expression: O, sorts: ARR(O), column_state: O, row_window: O },
2162
+ ['list_id', 'view_id'],
2163
+ ),
2164
+ },
2165
+ run: (a) => {
2166
+ if (!a.list_id || !a.view_id) return { ok: false, status: 400, error: { detail: 'list_id and view_id are required.' } };
2167
+ const body = {};
2168
+ for (const k of ['name', 'description', 'filter_expression', 'sorts', 'column_state', 'row_window'])
2169
+ if (a[k] !== undefined) body[k] = a[k];
2170
+ return api('PATCH', `/lists/${enc(a.list_id)}/views/${enc(a.view_id)}`, body);
2171
+ },
2172
+ },
2173
+ delete_table_view: {
2174
+ def: {
2175
+ description:
2176
+ 'Delete a saved table view — rows in the table are unaffected. Confirm with the user first. System views cannot be deleted.',
2177
+ inputSchema: obj({ list_id: S, view_id: S }, ['list_id', 'view_id']),
2178
+ },
2179
+ run: (a) =>
2180
+ a.list_id && a.view_id
2181
+ ? api('DELETE', `/lists/${enc(a.list_id)}/views/${enc(a.view_id)}`)
2182
+ : { ok: false, status: 400, error: { detail: 'list_id and view_id are required.' } },
2183
+ },
2184
+ estimate_table_column: {
2185
+ def: {
2186
+ description:
2187
+ "FREE cost preview for running one enrichment column, optionally scoped to a view or selection (view_id + selection{mode:'query', filter:<FilterExpr>, exclude_ids} or {mode:'explicit', ids}). cell_filter picks which cells: 'empty_or_stale' (default in the UI — skips results still fresh under the current column config), 'failed', or 'all'; n_rows/start_row window the run (1-based). Returns lead_count + estimated_cost_usd + scope_resolved. ALWAYS show this to the user before run_table_column — it spends nothing.",
2188
+ inputSchema: obj(
2189
+ { list_id: S, column_id: S, view_id: S, selection: O, cell_filter: S, n_rows: N, start_row: N, only_failed: B },
2190
+ ['list_id', 'column_id'],
2191
+ ),
2192
+ },
2193
+ run: (a) =>
2194
+ a.list_id && a.column_id
2195
+ ? api('POST', `/lists/${enc(a.list_id)}/columns/${enc(a.column_id)}/estimate`, runScopeBody(a))
2196
+ : { ok: false, status: 400, error: { detail: 'list_id and column_id are required.' } },
2197
+ },
2198
+ run_table_column: {
2199
+ def: {
2200
+ description:
2201
+ "SPENDS vendor credits — enrich one column across the scoped rows (view_id + selection + cell_filter + n_rows/start_row, same shape as estimate_table_column). cell_filter='empty_or_stale' re-runs only rows that are empty or out of date — fresh results are never re-billed. Run estimate_table_column first and confirm with the user.",
2202
+ inputSchema: obj(
2203
+ { list_id: S, column_id: S, view_id: S, selection: O, cell_filter: S, n_rows: N, start_row: N, only_failed: B },
2204
+ ['list_id', 'column_id'],
2205
+ ),
2206
+ },
2207
+ run: (a) =>
2208
+ a.list_id && a.column_id
2209
+ ? api('POST', `/lists/${enc(a.list_id)}/run-column`, { column_id: a.column_id, ...runScopeBody(a) })
2210
+ : { ok: false, status: 400, error: { detail: 'list_id and column_id are required.' } },
2211
+ },
2212
+ run_scope_summary: {
2213
+ def: {
2214
+ description:
2215
+ 'FREE counts + estimates for a column run scope — total, empty_or_stale, stale, failed rows + est_all/est_empty_or_stale/est_first_10 USD + budget. Use to decide what to run (which cell_filter) before estimate/run_table_column. Read-only.',
2216
+ inputSchema: obj({ list_id: S, column_id: S, view_id: S, selection: O }, ['list_id', 'column_id']),
2217
+ },
2218
+ run: (a) =>
2219
+ a.list_id && a.column_id
2220
+ ? api('POST', `/lists/${enc(a.list_id)}/columns/${enc(a.column_id)}/run-scope-summary`, {
2221
+ ...(a.view_id ? { view_id: a.view_id } : {}),
2222
+ ...(a.selection ? { selection: a.selection } : {}),
2223
+ })
2224
+ : { ok: false, status: 400, error: { detail: 'list_id and column_id are required.' } },
2225
+ },
2226
+ estimate_table_run_all: {
2227
+ def: {
2228
+ description:
2229
+ 'FREE per-column preview + total for running EVERY enrichment column in dependency order, optionally scoped (view_id + selection). Show before run_table_all — spends nothing.',
2230
+ inputSchema: obj(
2231
+ { list_id: S, column_ids: ARR(S), view_id: S, selection: O, only_empty: B },
2232
+ ['list_id'],
2233
+ ),
2234
+ },
2235
+ run: (a) =>
2236
+ a.list_id
2237
+ ? api('POST', `/lists/${enc(a.list_id)}/run-all/estimate`, {
2238
+ ...(a.column_ids ? { column_ids: a.column_ids } : {}),
2239
+ ...(a.view_id ? { view_id: a.view_id } : {}),
2240
+ ...(a.selection ? { selection: a.selection } : {}),
2241
+ only_empty: a.only_empty !== false,
2242
+ })
2243
+ : { ok: false, status: 400, error: { detail: 'list_id is required.' } },
2244
+ },
2245
+ run_table_all: {
2246
+ def: {
2247
+ description:
2248
+ 'SPENDS vendor credits — run every enrichment column in dependency order over the scoped rows (only empty cells run by default; cached results are free). Run estimate_table_run_all first and confirm with the user.',
2249
+ inputSchema: obj(
2250
+ { list_id: S, column_ids: ARR(S), view_id: S, selection: O, only_empty: B },
2251
+ ['list_id'],
2252
+ ),
2253
+ },
2254
+ run: (a) =>
2255
+ a.list_id
2256
+ ? api('POST', `/lists/${enc(a.list_id)}/run-all`, {
2257
+ ...(a.column_ids ? { column_ids: a.column_ids } : {}),
2258
+ ...(a.view_id ? { view_id: a.view_id } : {}),
2259
+ ...(a.selection ? { selection: a.selection } : {}),
2260
+ only_empty: a.only_empty !== false,
2261
+ })
2262
+ : { ok: false, status: 400, error: { detail: 'list_id is required.' } },
2263
+ },
2264
+ get_table_cell: {
2265
+ def: {
2266
+ description:
2267
+ "Read one table cell's FULL enrichment payload plus provenance (status/cost/provider/ran-at/error). Use when list_table_rows returns a cell with truncated: true because the rows endpoint trims payloads over 64KB. Get lead_id from list_table_rows and column_id from get_table_columns.",
2268
+ inputSchema: obj({ list_id: S, lead_id: S, column_id: S }, ['list_id', 'lead_id', 'column_id']),
2269
+ },
2270
+ run: (a) =>
2271
+ a.list_id && a.lead_id && a.column_id
2272
+ ? api('GET', `/lists/${enc(a.list_id)}/cells/${enc(a.lead_id)}/${enc(a.column_id)}`)
2273
+ : { ok: false, status: 400, error: { detail: 'list_id, lead_id and column_id are required.' } },
2274
+ },
2275
+ remove_table_rows: {
2276
+ def: {
2277
+ description:
2278
+ 'Remove rows from a Scrapeloop table (list) by lead id — the leads stay in the workspace pool; only the table membership and that table\'s enrichment cells are removed. Get ids from list_table_rows. To delete the whole table use delete_list.',
2279
+ inputSchema: obj({ list_id: S, lead_ids: ARR(S) }, ['list_id', 'lead_ids']),
2280
+ },
2281
+ run: (a) =>
2282
+ a.list_id && Array.isArray(a.lead_ids)
2283
+ ? api('DELETE', `/lists/${enc(a.list_id)}/rows`, { lead_ids: a.lead_ids })
2284
+ : { ok: false, status: 400, error: { detail: 'list_id and lead_ids are required.' } },
2285
+ },
2286
+ get_list_webhook: {
2287
+ def: {
2288
+ description:
2289
+ "Read a table's inbound webhook state (masked URL, rows received, 50k lifetime cap, field mapping). Returns configured:false if none exists.",
2290
+ inputSchema: obj({ list_id: S }, ['list_id']),
2291
+ },
2292
+ run: (a) => api('GET', `/lists/${enc(a.list_id)}/webhook`),
2293
+ },
2294
+ create_list_webhook: {
2295
+ def: {
2296
+ description:
2297
+ 'Create or rotate a static table inbound webhook. Returns the full URL and token ONCE, so show it to the user immediately. Calling again rotates the token and the old URL stops working. Smart and system tables are not supported.',
2298
+ inputSchema: obj(
2299
+ { list_id: S, field_map: O, default_verification_status: S },
2300
+ ['list_id'],
2301
+ ),
2302
+ },
2303
+ run: (a) =>
2304
+ api('POST', `/lists/${enc(a.list_id)}/webhook`, {
2305
+ ...(a.field_map ? { field_map: a.field_map } : {}),
2306
+ ...(a.default_verification_status
2307
+ ? { default_verification_status: a.default_verification_status }
2308
+ : {}),
2309
+ }),
2310
+ },
2311
+ update_list_webhook: {
2312
+ def: {
2313
+ description: "Update a table inbound webhook's field mapping or default verification status.",
2314
+ inputSchema: obj(
2315
+ { list_id: S, field_map: O, default_verification_status: S },
2316
+ ['list_id'],
2317
+ ),
2318
+ },
2319
+ run: (a) =>
2320
+ api('PATCH', `/lists/${enc(a.list_id)}/webhook`, {
2321
+ ...(a.field_map !== undefined ? { field_map: a.field_map } : {}),
2322
+ ...(a.default_verification_status !== undefined
2323
+ ? { default_verification_status: a.default_verification_status }
2324
+ : {}),
2325
+ }),
2326
+ },
2327
+ revoke_list_webhook: {
2328
+ def: {
2329
+ description:
2330
+ "Revoke a table's inbound webhook. The current URL stops working immediately and this cannot be undone. Confirm with the user first.",
2331
+ inputSchema: obj({ list_id: S }, ['list_id']),
2332
+ },
2333
+ run: (a) => api('DELETE', `/lists/${enc(a.list_id)}/webhook`),
2334
+ },
2335
+ export_list_to_webhook: {
2336
+ def: {
2337
+ description:
2338
+ "Push a table's rows to a webhook URL as JSON batches of 100 (Zapier/Make/n8n/custom). Optional bearer_token is stored encrypted and reusable via credential_id. Returns a job_id, poll it with get_job. Sends lead data to an external URL: confirm the destination with the user first.",
2339
+ inputSchema: obj(
2340
+ {
2341
+ list_id: S,
2342
+ url: S,
2343
+ bearer_token: S,
2344
+ credential_id: S,
2345
+ lead_ids: ARR(S),
2346
+ },
2347
+ ['list_id', 'url'],
2348
+ ),
2349
+ },
2350
+ run: (a) =>
2351
+ a.list_id && a.url
2352
+ ? api('POST', `/lists/${enc(a.list_id)}/export/webhook`, {
2353
+ url: a.url,
2354
+ bearer_token: a.bearer_token,
2355
+ credential_id: a.credential_id,
2356
+ lead_ids: a.lead_ids,
2357
+ })
2358
+ : {
2359
+ ok: false,
2360
+ status: 400,
2361
+ error: { detail: 'list_id and url are required.' },
2362
+ },
2363
+ },
2364
+ delete_list: {
2365
+ def: {
2366
+ description:
2367
+ 'Delete a list (the list + its membership; the underlying leads stay in the workspace). Works for any Scrapeloop list. Irreversible — confirm with the user first.',
2368
+ inputSchema: obj({ list_id: S }, ['list_id']),
2369
+ },
2370
+ run: (a) =>
2371
+ a.list_id
2372
+ ? api('DELETE', `/lists/${enc(a.list_id)}`)
2373
+ : { ok: false, status: 400, error: { detail: 'list_id is required.' } },
2374
+ },
2375
+ delete_campaign: {
2376
+ def: {
2377
+ description:
2378
+ 'Delete a campaign (the Scrapeloop campaign + its ledger; the Instantly campaign itself is not deleted). Irreversible — if you only want to stop sending, pause_campaign instead. Confirm with the user first.',
2379
+ inputSchema: obj({ campaign_id: S }, ['campaign_id']),
2380
+ },
2381
+ run: (a) =>
2382
+ a.campaign_id
2383
+ ? api('DELETE', `/campaigns/${enc(a.campaign_id)}`)
2384
+ : { ok: false, status: 400, error: { detail: 'campaign_id is required.' } },
2385
+ },
2386
+
2387
+ // ── Replies + suppression ─────────────────────────────────────────────
2388
+ list_replies: {
704
2389
  def: {
705
2390
  description:
706
2391
  'List inbound campaign replies, newest first. Filters: sentiment (interested|not_interested|unsubscribed), handled (true = already actioned, false = needs attention), lead_id, campaign_id, status (pending|auto_classified|human_required|human_reviewed — human_required is the review queue).',
@@ -800,21 +2485,178 @@ const TOOLS = {
800
2485
  : {}),
801
2486
  }),
802
2487
  },
2488
+
2489
+ // ── Roadmap (agency build kanban) ─────────────────────────────────────
2490
+ roadmap_list: {
2491
+ def: {
2492
+ description:
2493
+ 'List every card on the agency build roadmap (planned / in_progress / deployed). Each card is a feature/bug/improvement with slug, track (claude|codex|either), priority position, deps, and has_plan. Use roadmap_get_item for the full execution plan.',
2494
+ inputSchema: obj({}),
2495
+ },
2496
+ run: () => api('GET', '/roadmap'),
2497
+ },
2498
+ roadmap_next_planned: {
2499
+ def: {
2500
+ description:
2501
+ 'Fetch the next eligible planned roadmap card (highest priority whose dependencies are all deployed), INCLUDING its full execution plan (plan_md). This is what "launch the next planned thing on the roadmap" resolves to. Optionally filter by track.',
2502
+ inputSchema: obj({ track: { ...S, enum: ['claude', 'codex', 'either'] } }),
2503
+ },
2504
+ run: (a) => api('GET', `/roadmap/next${qs({ track: a.track })}`),
2505
+ },
2506
+ roadmap_get_item: {
2507
+ def: {
2508
+ description:
2509
+ 'Fetch one roadmap card by slug, including its full execution plan (plan_md, markdown). Follow that plan exactly when building the card.',
2510
+ inputSchema: obj({ slug: S }, ['slug']),
2511
+ },
2512
+ run: (a) => api('GET', `/roadmap/${enc(a.slug)}`),
2513
+ },
2514
+ roadmap_update_status: {
2515
+ def: {
2516
+ description:
2517
+ 'Move a roadmap card across the board: planned | in_progress | deployed. Set in_progress when you start building it; set deployed (with pr_url) once merged + live.',
2518
+ inputSchema: obj(
2519
+ {
2520
+ slug: S,
2521
+ status: { ...S, enum: ['planned', 'in_progress', 'deployed'] },
2522
+ pr_url: { ...S, description: 'PR link — set it when marking deployed' },
2523
+ },
2524
+ ['slug', 'status'],
2525
+ ),
2526
+ },
2527
+ run: (a) =>
2528
+ api('PATCH', `/roadmap/${enc(a.slug)}`, {
2529
+ status: a.status,
2530
+ ...(a.pr_url ? { pr_url: a.pr_url } : {}),
2531
+ }),
2532
+ },
2533
+ roadmap_create_item: {
2534
+ def: {
2535
+ description:
2536
+ 'Add a card to the build roadmap. slug is a stable lowercase-hyphen id; plan_md is the self-contained execution plan (markdown) an agent will follow; depends_on lists slugs that must be deployed first.',
2537
+ inputSchema: obj(
2538
+ {
2539
+ slug: S,
2540
+ title: S,
2541
+ kind: { ...S, enum: ['feature', 'bug', 'improvement'] },
2542
+ track: { ...S, enum: ['claude', 'codex', 'either'] },
2543
+ area: S,
2544
+ effort: { ...S, enum: ['S', 'M', 'L'] },
2545
+ summary: S,
2546
+ plan_md: S,
2547
+ depends_on: ARR(S),
2548
+ files_hint: ARR(S),
2549
+ position: N,
2550
+ },
2551
+ ['slug', 'title'],
2552
+ ),
2553
+ },
2554
+ run: (a) =>
2555
+ api('POST', '/roadmap', {
2556
+ slug: a.slug,
2557
+ title: a.title,
2558
+ ...(a.kind ? { kind: a.kind } : {}),
2559
+ ...(a.track ? { track: a.track } : {}),
2560
+ ...(a.area ? { area: a.area } : {}),
2561
+ ...(a.effort ? { effort: a.effort } : {}),
2562
+ ...(a.summary ? { summary: a.summary } : {}),
2563
+ ...(a.plan_md ? { plan_md: a.plan_md } : {}),
2564
+ ...(a.depends_on ? { depends_on: a.depends_on } : {}),
2565
+ ...(a.files_hint ? { files_hint: a.files_hint } : {}),
2566
+ ...(a.position != null ? { position: a.position } : {}),
2567
+ }),
2568
+ },
2569
+ roadmap_update_plan: {
2570
+ def: {
2571
+ description:
2572
+ "Update a roadmap card's fields — most importantly plan_md (the execution plan). Also: title, summary, kind, track, area, effort, depends_on, files_hint, position, pr_url.",
2573
+ inputSchema: obj(
2574
+ {
2575
+ slug: S,
2576
+ plan_md: S,
2577
+ title: S,
2578
+ summary: S,
2579
+ kind: { ...S, enum: ['feature', 'bug', 'improvement'] },
2580
+ track: { ...S, enum: ['claude', 'codex', 'either'] },
2581
+ area: S,
2582
+ effort: { ...S, enum: ['S', 'M', 'L'] },
2583
+ depends_on: ARR(S),
2584
+ files_hint: ARR(S),
2585
+ position: N,
2586
+ pr_url: S,
2587
+ },
2588
+ ['slug'],
2589
+ ),
2590
+ },
2591
+ run: (a) => {
2592
+ const { slug, ...rest } = a;
2593
+ const body = Object.fromEntries(
2594
+ Object.entries(rest).filter(([, v]) => v !== undefined && v !== null),
2595
+ );
2596
+ return Object.keys(body).length
2597
+ ? api('PATCH', `/roadmap/${enc(slug)}`, body)
2598
+ : { ok: false, status: 400, error: { detail: 'nothing to update — pass at least one field' } };
2599
+ },
2600
+ },
803
2601
  };
804
2602
 
2603
+ const extractHttpMethods = (run) => [
2604
+ ...run.toString().matchAll(/api\(\s*['"](GET|POST|PUT|PATCH|DELETE)['"]/g),
2605
+ ].map((match) => match[1]);
2606
+
2607
+ export const TOOL_HTTP_OPERATIONS = Object.freeze(
2608
+ Object.fromEntries(
2609
+ Object.entries(TOOLS).map(([name, tool]) => [
2610
+ name,
2611
+ Object.freeze([...new Set(extractHttpMethods(tool.run))]),
2612
+ ]),
2613
+ ),
2614
+ );
2615
+
2616
+ for (const [name, tool] of Object.entries(TOOLS)) {
2617
+ const originalRun = tool.run;
2618
+ const policy = MUTATION_POLICIES[name];
2619
+ const methods = TOOL_HTTP_OPERATIONS[name];
2620
+ const retryCopy = policy
2621
+ ? RETRY_CONTRACT_COPY[policy.retry]
2622
+ : methods.every((method) => method === 'GET')
2623
+ ? RETRY_CONTRACT_COPY.bounded
2624
+ : 'Policy missing. This operation is blocked until its retry contract is reviewed.';
2625
+ tool.def.description = `${tool.def.description} Retry contract: ${retryCopy}`;
2626
+ if (policy?.retry === 'idempotency_key') {
2627
+ const schema = tool.def.inputSchema;
2628
+ tool.def.inputSchema = {
2629
+ ...schema,
2630
+ properties: {
2631
+ ...schema.properties,
2632
+ idempotency_key: {
2633
+ type: 'string',
2634
+ description: 'Stable UUID for this exact mutation. Reuse it after a timeout or lost response.',
2635
+ },
2636
+ },
2637
+ required: [...new Set([...(schema.required || []), 'idempotency_key'])],
2638
+ };
2639
+ }
2640
+ tool.run = (args = {}) => toolCallContext.run(
2641
+ { toolName: name, policy, args },
2642
+ () => originalRun(args),
2643
+ );
2644
+ }
2645
+
805
2646
  async function serve() {
806
2647
  // Start cleanly even without a key — never crash or hang. The first tools/call
807
2648
  // returns a clear, structured auth error (api() handles the missing key), and we
808
2649
  // log exactly one warning to stderr here. The key itself is never logged.
809
2650
  if (!API_KEY) {
810
- console.error(
2651
+ warnStartupOnce(
2652
+ 'missing_api_key',
811
2653
  'scrapeloop-mcp: SCRAPELOOP_API_KEY is not set — tools will return an auth error until it is. ' +
812
2654
  'Generate a key in Scrapeloop → Settings → API access.'
813
2655
  );
814
2656
  }
815
2657
 
816
2658
  const server = new Server(
817
- { name: 'scrapeloop-mcp', version: '0.6.0' },
2659
+ { name: 'scrapeloop-mcp', version: '0.7.1' },
818
2660
  { capabilities: { tools: {} } }
819
2661
  );
820
2662
 
@@ -841,10 +2683,12 @@ async function serve() {
841
2683
  // user sees the problem at startup instead of only on first use. Never blocks
842
2684
  // serving and never logs the key.
843
2685
  if (API_KEY) {
844
- api('GET', '/me').then((r) => {
845
- if (!r.ok) {
846
- const why = r.status === 401 ? 'invalid or revoked' : `unreachable (status ${r.status})`;
847
- console.error(`scrapeloop-mcp: API key check failed — ${why}. Tools may return auth errors.`);
2686
+ runApiKeyPreflight().then((preflight) => {
2687
+ if (preflight.state !== 'authenticated') {
2688
+ warnStartupOnce(
2689
+ `api_key_preflight:${preflight.state}`,
2690
+ formatApiKeyPreflightWarning(preflight),
2691
+ );
848
2692
  }
849
2693
  });
850
2694
  }
@@ -855,7 +2699,15 @@ async function serve() {
855
2699
  // the CLI leaves it unset and serves. (An entrypoint-URL check is unreliable when
856
2700
  // the install path contains spaces — import.meta.url percent-encodes them but
857
2701
  // process.argv[1] does not — so an explicit opt-out flag is used instead.)
858
- export { TOOLS, serve };
2702
+ export {
2703
+ MUTATION_POLICIES,
2704
+ TOOLS,
2705
+ classifyApiKeyPreflight,
2706
+ formatApiKeyPreflightWarning,
2707
+ runApiKeyPreflight,
2708
+ serve,
2709
+ warnStartupOnce,
2710
+ };
859
2711
 
860
2712
  if (!process.env.SCRAPELOOP_MCP_NO_SERVE) {
861
2713
  await serve();