scrapeloop-mcp 0.7.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/README.md CHANGED
@@ -71,7 +71,7 @@ cleaners, strategy tags, vendors, scopes, and the ordered 7 setup steps) and
71
71
  | Enrich + strategies | `list_presets`, `create_preset`, `estimate_enrich`, `run_enrich`, `test_recipe`, `get_ai_settings`, `update_ai_settings`, `list_strategies`, `create_strategy`, `assign_strategy` |
72
72
  | Campaigns + Instantly | `list_instantly_campaigns`, `list_instantly_lead_lists`, `import_instantly_campaigns`, `import_instantly_lead_list`, `list_segments`, `create_segment`, `preview_segment`, `list_campaigns`, `create_campaign`, `update_campaign`, `activate_campaign`, `pause_campaign`, `sync_campaign`, `campaign_stats`, `get_sender_config`, `update_sender_config` |
73
73
  | B2B saved collections | `get_lists`, `create_list`, `add_list_members` |
74
- | Campaign-ready Tables | `get_tables`, `create_table`, `get_table_columns`, `create_table_column`, `set_table_cells`, `list_table_rows` |
74
+ | Campaign-ready Tables | `get_tables`, `create_table`, `get_table_columns`, `create_table_column`, `create_table_preset_column`, `set_table_cells`, `list_table_rows` |
75
75
 
76
76
  `import_leads`, `preview_import`, and `import_leads_csv` accept raw identities
77
77
  without placeholder emails. Each row needs an email, LinkedIn URL, external ID,
@@ -90,7 +90,16 @@ typed value, with explicit `null` reserved for clearing. `set_table_cells`
90
90
  accepts at most 500 cells and 256 KiB of JSON, validates the complete batch,
91
91
  then persists it as one atomic upsert.
92
92
 
93
- `create_table`, `create_table_column`, and `create_table_view` are
93
+ `create_table_preset_column` attaches a shipped system preset or an active
94
+ workspace preset as a real enrichment column. It requires a stable
95
+ `operation_id`, defaults `auto_run` to false, and accepts only overrides declared
96
+ by the preset. When auto-run would backfill existing rows, the first call returns
97
+ the exact row and cost estimate without writing. Repeat with `confirm_auto_run`
98
+ and that exact `confirmed_estimated_cost_usd` only after user confirmation. The
99
+ Table master switch and workspace budget caps still gate the queued work.
100
+
101
+ `create_table`, `create_table_column`, `create_table_preset_column`, and
102
+ `create_table_view` are
94
103
  idempotent. The MCP generates one `operation_id` for the logical create and
95
104
  reuses it across transient HTTP retries. The API returns the first resource for
96
105
  the same key and normalized payload, including concurrent calls, and returns
@@ -121,28 +130,40 @@ successful only after all four conditions are true:
121
130
 
122
131
  1. The exact `mcp-v<version>` tag matches `package.json`.
123
132
  2. Tests pass and the packed tarball contains only the declared package files.
124
- 3. npm accepts the exact tarball with provenance, or already has that same
125
- version with the same integrity.
133
+ 3. npm accepts the exact tarball through Trusted Publishing, or already has
134
+ that same version with the same integrity.
126
135
  4. `npx scrapeloop-mcp@<version>` reports the expected version and tool count.
127
136
 
137
+ The installed-package check removes GitHub Actions' temporary npm authentication
138
+ settings and replaces them with an empty, temporary public-registry config
139
+ before it runs `npx`. That makes the final check use the public registry exactly
140
+ like a customer install, without reusing publishing access.
141
+
128
142
  The Actions summary reports **Build and test** separately from **Publish and
129
143
  registry**. A green package build does not mean the version is published. A
130
- missing or rejected npm credential, a conflicting duplicate version, or a
131
- registry verification timeout fails the workflow.
144
+ missing OIDC identity, a rejected trusted-publisher match, a conflicting
145
+ duplicate version, or a registry verification timeout fails the workflow.
146
+
147
+ ### Trusted npm publishing
148
+
149
+ The npm package trusts only the `publish-mcp.yml` GitHub Actions workflow in
150
+ `Hinkam-Lanello/scrapeloop`. The workflow uses GitHub OIDC to request a
151
+ short-lived npm publishing identity. It must run on a GitHub-hosted runner with
152
+ `id-token: write`, Node.js 24, and npm 11.5.1 or newer. Do not add `NPM_TOKEN` or
153
+ another long-lived publish token.
132
154
 
133
- ### Human-owned npm credential
155
+ The source repository is private, so the workflow must not force npm
156
+ provenance. npm supports Trusted Publishing from a private GitHub repository,
157
+ but provenance is available only when the source repository is public.
134
158
 
135
- An npm account owner must create a publish-capable access token for
136
- `scrapeloop-mcp` and store it as the GitHub Actions repository secret
137
- `NPM_TOKEN`. Agents must not create, reveal, or enter this credential. To recover
138
- an already tagged but unpublished version, run the workflow manually with its
139
- exact existing tag. The workflow archives and publishes that tag's source rather
140
- than the current default branch.
159
+ To recover an already tagged but unpublished version, run the workflow manually
160
+ with its exact existing tag. The workflow archives and publishes that tag's
161
+ source rather than the current default branch.
141
162
 
142
163
  Local preflight, which never publishes:
143
164
 
144
165
  ```bash
145
166
  npm ci --no-audit --no-fund
146
167
  npm test
147
- npm run release:preflight -- --tag mcp-v0.7.0
168
+ npm run release:preflight -- --tag mcp-v0.7.1
148
169
  ```
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "scrapeloop-mcp",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Scrapeloop MCP server — set up and run Scrapeloop end-to-end (integrations, scraping, lead database, verification, cleaners, enrichment, strategies, Instantly campaigns) from any MCP client (Claude Desktop, Cursor, ChatGPT, …).",
5
5
  "type": "module",
6
6
  "scripts": {
7
- "test": "node --check src/index.js && node test/mcp_table_tools.mjs && node test/mcp_community_intent.mjs && node test/mcp_api_key_preflight.mjs && node test/release_readiness.mjs",
7
+ "test": "node --check src/index.js && node test/mcp_table_tools.mjs && node test/mcp_community_intent.mjs && node test/mcp_api_key_preflight.mjs && node test/mutation_retry_contract.mjs && node test/release_readiness.mjs",
8
8
  "release:preflight": "node scripts/release-preflight.mjs",
9
9
  "release:verify": "node scripts/release-installed-verify.mjs"
10
10
  },
package/src/index.js CHANGED
@@ -18,10 +18,12 @@
18
18
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
19
19
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
20
20
  import { randomUUID } from 'node:crypto';
21
+ import { AsyncLocalStorage } from 'node:async_hooks';
21
22
  import {
22
23
  CallToolRequestSchema,
23
24
  ListToolsRequestSchema,
24
25
  } from '@modelcontextprotocol/sdk/types.js';
26
+ import { MUTATION_POLICIES, RETRY_CONTRACT_COPY } from './mutation-policies.js';
25
27
 
26
28
  const API_KEY = process.env.SCRAPELOOP_API_KEY;
27
29
  const BASE = (process.env.SCRAPELOOP_API_URL || 'https://api.scrapeloop.com').replace(/\/$/, '');
@@ -32,25 +34,39 @@ const MAX_TABLE_CELL_REQUEST_BYTES = 256 * 1024;
32
34
  const TRACE_HEADER = 'X-Scrapeloop-Trace-Id';
33
35
  const SAFE_TRACE_ID = /^[A-Za-z0-9_-]{8,80}$/;
34
36
  const STARTUP_WARNINGS = new Set();
37
+ const RETRY_BASE_MS = Number(process.env.SCRAPELOOP_RETRY_BASE_MS) || 500;
38
+ const toolCallContext = new AsyncLocalStorage();
35
39
 
36
40
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
37
41
 
38
- // One API call with a per-request timeout + retry/backoff on 429/5xx + transient
39
- // network errors. Never throws — always resolves to {ok, status, data|error} so a
40
- // single failed upstream call can't kill the server. The API key is sent in the
41
- // Authorization header only and is never logged.
42
- const uncertainMutationFailure = (status, error, reconciliation) => {
43
- const base = error && typeof error === 'object' ? error : { detail: String(error) };
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
+ );
44
50
  return {
45
51
  ok: false,
46
- status,
52
+ status: upstreamStatus || 0,
47
53
  error: {
48
- ...base,
54
+ type: 'uncertain_result',
49
55
  uncertain_result: true,
50
- retry_guidance:
51
- reconciliation.retry_guidance ||
52
- 'Do not repeat this mutation yet. The server may have committed it before the response was lost.',
53
- reconciliation,
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.',
54
70
  },
55
71
  };
56
72
  };
@@ -69,34 +85,65 @@ async function api(method, path, body, options = {}) {
69
85
  }
70
86
  const fetcher = options.fetcher || fetch;
71
87
  const sleepFn = options.sleepFn || sleep;
72
- const maxRetries = options.maxRetries ?? MAX_RETRIES;
73
88
  const shouldRetryResponse =
74
89
  options.shouldRetryResponse || ((status) => status === 429 || status >= 500);
75
- const retryDelay = options.retryDelay || ((attempt) => 500 * 2 ** attempt);
90
+ const retryDelay = options.retryDelay || ((attempt) => RETRY_BASE_MS * 2 ** attempt);
76
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
+ };
77
126
  let lastErr;
78
127
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
79
128
  let res;
80
129
  try {
81
130
  res = await fetcher(`${BASE}/api/v1${path}`, {
82
- method,
83
- headers: {
84
- Authorization: `Bearer ${API_KEY}`,
85
- 'Content-Type': 'application/json',
86
- ...(traceId ? { [TRACE_HEADER]: traceId } : {}),
87
- },
131
+ method: verb,
132
+ headers,
88
133
  body: body === undefined ? undefined : JSON.stringify(body),
89
134
  signal: AbortSignal.timeout(TIMEOUT_MS),
90
135
  });
91
136
  } catch (e) {
92
- // Network error / timeout — retry a few times, then surface a clean error.
93
137
  lastErr = e;
94
138
  if (attempt < maxRetries) {
95
139
  await sleepFn(retryDelay(attempt));
96
140
  continue;
97
141
  }
142
+ if (policy?.effect === 'mutation') {
143
+ return uncertainResult({ context, method: verb, path, reconciliation: options.reconciliation });
144
+ }
98
145
  const timedOut = e && (e.name === 'TimeoutError' || e.name === 'AbortError');
99
- const failure = {
146
+ return {
100
147
  ok: false,
101
148
  status: 0,
102
149
  error: { detail: `Could not reach Scrapeloop (${timedOut ? `timeout after ${TIMEOUT_MS}ms` : String(e)}).` },
@@ -104,12 +151,10 @@ async function api(method, path, body, options = {}) {
104
151
  ? { trace_id: traceId, network_error: timedOut ? 'timeout' : 'transport' }
105
152
  : {}),
106
153
  };
107
- return options.reconciliation
108
- ? uncertainMutationFailure(0, failure.error, options.reconciliation)
109
- : failure;
110
154
  }
111
155
  // Retry transient upstream failures (rate limit / server errors).
112
- if (shouldRetryResponse(res.status) && attempt < maxRetries) {
156
+ const transient = shouldRetryResponse(res.status);
157
+ if (transient && attempt < maxRetries) {
113
158
  const retryAfter = Number(res.headers.get('retry-after')) * 1000;
114
159
  const delay =
115
160
  options.respectRetryAfter !== false && retryAfter > 0
@@ -126,9 +171,18 @@ async function api(method, path, body, options = {}) {
126
171
  data = { raw: text };
127
172
  }
128
173
  if (!res.ok) {
129
- // Surface the API's structured error verbatim (401 auth, 402 credits, 403 scope, …).
130
- if (res.status >= 500 && options.reconciliation) {
131
- return uncertainMutationFailure(res.status, data, options.reconciliation);
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
+ });
132
186
  }
133
187
  return {
134
188
  ok: false,
@@ -144,6 +198,9 @@ async function api(method, path, body, options = {}) {
144
198
  ...(options.captureTrace ? { trace_id: responseTraceId(res, traceId) } : {}),
145
199
  };
146
200
  }
201
+ if (policy?.effect === 'mutation') {
202
+ return uncertainResult({ context, method: verb, path, reconciliation: options.reconciliation });
203
+ }
147
204
  return {
148
205
  ok: false,
149
206
  status: 0,
@@ -385,7 +442,7 @@ const TOOLS = {
385
442
  list_scrapers: {
386
443
  def: {
387
444
  description:
388
- '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.',
389
446
  inputSchema: obj({}),
390
447
  },
391
448
  run: () => api('GET', '/scrapers'),
@@ -413,14 +470,16 @@ const TOOLS = {
413
470
  submit_scrape: {
414
471
  def: {
415
472
  description:
416
- '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.',
417
474
  inputSchema: obj(
418
475
  {
419
476
  kind: S,
420
477
  integration_id: S,
421
478
  config: O,
479
+ table_id: S,
422
480
  credential_id: S,
423
481
  confirm_rescrape: B,
482
+ hard_max_cost_usd: { ...N, exclusiveMinimum: 0 },
424
483
  billing: { ...S, enum: ['auto', 'byok', 'managed'] },
425
484
  },
426
485
  ['kind', 'config'],
@@ -431,8 +490,10 @@ const TOOLS = {
431
490
  kind: a.kind,
432
491
  integration_id: a.integration_id ?? null,
433
492
  config: a.config || {},
493
+ ...(a.table_id ? { target_list_id: a.table_id } : {}),
434
494
  ...(a.credential_id ? { credential_id: a.credential_id } : {}),
435
495
  ...(a.confirm_rescrape ? { confirm_rescrape: true } : {}),
496
+ ...(a.hard_max_cost_usd !== undefined ? { hard_max_cost_usd: a.hard_max_cost_usd } : {}),
436
497
  ...(a.billing ? { billing: a.billing } : {}),
437
498
  }),
438
499
  },
@@ -445,7 +506,7 @@ const TOOLS = {
445
506
  run: (a) => api('GET', `/jobs/${enc(a.job_id)}`),
446
507
  },
447
508
  cancel_job: {
448
- 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']) },
449
510
  run: (a) => api('POST', `/jobs/${enc(a.job_id)}/cancel`),
450
511
  },
451
512
 
@@ -617,7 +678,7 @@ const TOOLS = {
617
678
  verify_catchall: {
618
679
  def: {
619
680
  description:
620
- '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.',
621
682
  inputSchema: obj({ email: S }, ['email']),
622
683
  },
623
684
  run: (a) => api('POST', '/verify/catchall', { email: a.email }),
@@ -1207,6 +1268,76 @@ const TOOLS = {
1207
1268
  });
1208
1269
  },
1209
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
+ },
1210
1341
  set_table_cells: {
1211
1342
  def: {
1212
1343
  description:
@@ -1311,6 +1442,55 @@ const TOOLS = {
1311
1442
  });
1312
1443
  },
1313
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
+ },
1314
1494
  capture_community_intent: {
1315
1495
  def: {
1316
1496
  description:
@@ -1720,6 +1900,60 @@ const TOOLS = {
1720
1900
  error: { detail: 'campaign_id and rule_id are required.' },
1721
1901
  },
1722
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
+ },
1723
1957
  preview_send_to_table: {
1724
1958
  def: {
1725
1959
  description:
@@ -2366,6 +2600,49 @@ const TOOLS = {
2366
2600
  },
2367
2601
  };
2368
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
+
2369
2646
  async function serve() {
2370
2647
  // Start cleanly even without a key — never crash or hang. The first tools/call
2371
2648
  // returns a clear, structured auth error (api() handles the missing key), and we
@@ -2379,7 +2656,7 @@ async function serve() {
2379
2656
  }
2380
2657
 
2381
2658
  const server = new Server(
2382
- { name: 'scrapeloop-mcp', version: '0.7.0' },
2659
+ { name: 'scrapeloop-mcp', version: '0.7.1' },
2383
2660
  { capabilities: { tools: {} } }
2384
2661
  );
2385
2662
 
@@ -2423,6 +2700,7 @@ async function serve() {
2423
2700
  // the install path contains spaces — import.meta.url percent-encodes them but
2424
2701
  // process.argv[1] does not — so an explicit opt-out flag is used instead.)
2425
2702
  export {
2703
+ MUTATION_POLICIES,
2426
2704
  TOOLS,
2427
2705
  classifyApiKeyPreflight,
2428
2706
  formatApiKeyPreflightWarning,
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Retry contracts for every MCP operation that does not use HTTP GET.
3
+ *
4
+ * A POST may still be read-only, such as an estimate with a JSON filter body.
5
+ * Every true mutation declares whether it is provably idempotent, protected by
6
+ * a durable caller key, reconciliation-only, or forbidden from automatic retry.
7
+ */
8
+
9
+ const read = (method = 'POST') => ({ method, effect: 'read', retry: 'bounded' });
10
+ const idempotent = (method, inspectWith, matchFields) => ({
11
+ method,
12
+ effect: 'mutation',
13
+ retry: 'idempotent',
14
+ inspect_with: inspectWith,
15
+ match_fields: matchFields,
16
+ });
17
+ const keyed = (method, inspectWith, matchFields) => ({
18
+ method,
19
+ effect: 'mutation',
20
+ retry: 'idempotency_key',
21
+ inspect_with: inspectWith,
22
+ match_fields: matchFields,
23
+ });
24
+ const reconcile = (method, inspectWith, matchFields) => ({
25
+ method,
26
+ effect: 'mutation',
27
+ retry: 'reconcile',
28
+ inspect_with: inspectWith,
29
+ match_fields: matchFields,
30
+ });
31
+ const never = (method, inspectWith, matchFields) => ({
32
+ method,
33
+ effect: 'mutation',
34
+ retry: 'never',
35
+ inspect_with: inspectWith,
36
+ match_fields: matchFields,
37
+ });
38
+
39
+ export const MUTATION_POLICIES = Object.freeze({
40
+ create_integration: keyed('POST', ['list_integrations'], ['vendor', 'plugin_kind']),
41
+ add_credential: reconcile('POST', ['list_credentials'], ['integration_id', 'label']),
42
+ test_credential: idempotent('POST', ['list_credentials'], ['credential_id']),
43
+ estimate_scrape: read(),
44
+ submit_scrape: keyed('POST', ['list_jobs'], ['kind', 'integration_id', 'credential_id', 'table_id']),
45
+ cancel_job: never('POST', ['get_job'], ['job_id', 'status']),
46
+ search_leads: read(),
47
+ estimate_leads: read(),
48
+ expand_job_titles: read(),
49
+ reveal_lead: idempotent('POST', ['search_leads', 'get_credits'], ['global_person_id']),
50
+ save_leads: reconcile('POST', ['search_leads', 'get_credits'], ['global_person_ids']),
51
+ estimate_lead_import: read(),
52
+ import_leads_to_table: reconcile('POST', ['get_lists', 'get_list_rows'], ['list_id', 'new_list_name']),
53
+ save_search: idempotent('POST', ['list_saved_searches'], ['name']),
54
+ bulk_verify_leads: reconcile('POST', ['list_jobs'], ['lead_ids', 'global_person_ids']),
55
+ search_local_leads: read(),
56
+ estimate_local_leads: read(),
57
+ reveal_local_lead: idempotent('POST', ['search_local_leads', 'get_credits'], ['place_id']),
58
+ fill_coverage_gap: reconcile('POST', ['list_jobs', 'search_local_leads'], ['filters']),
59
+ verify_email: never('POST', ['get_credits'], ['email']),
60
+ verify_catchall: keyed('POST', ['get_credits'], ['email']),
61
+ preview_cleaners: read(),
62
+ apply_cleaners: reconcile('POST', ['get_lists'], ['list_id', 'cleaners']),
63
+ create_preset: reconcile('POST', ['list_presets'], ['name']),
64
+ estimate_enrich: read(),
65
+ run_enrich: never('POST', ['list_jobs'], ['preset_slug', 'preset_id', 'lead_ids', 'list_id']),
66
+ test_recipe: never('POST', ['get_credits'], ['lead_id']),
67
+ update_ai_settings: idempotent('PUT', ['get_ai_settings'], ['default_ai_model', 'enabled_models']),
68
+ update_ai_context: idempotent('PUT', ['get_ai_context'], ['company_description', 'icp']),
69
+ generate_ai_context: never('POST', ['get_ai_context', 'get_credits'], ['domain']),
70
+ create_strategy: reconcile('POST', ['list_strategies'], ['name']),
71
+ assign_strategy: reconcile('POST', ['list_strategies'], ['strategy_id', 'lead_ids', 'list_id']),
72
+ import_instantly_campaigns: reconcile('POST', ['list_campaigns'], ['credential_id', 'campaign_ids']),
73
+ import_instantly_lead_list: reconcile('POST', ['get_lists'], ['credential_id', 'lead_list_id']),
74
+ create_segment: reconcile('POST', ['list_segments'], ['name']),
75
+ preview_segment: read(),
76
+ preview_add_leads_to_campaign: read(),
77
+ add_leads_to_campaign: never('POST', ['list_campaigns', 'campaign_stats'], ['campaign_id', 'list_id']),
78
+ create_campaign: keyed('POST', ['list_campaigns'], ['name', 'sender_credential_id', 'source_list_id']),
79
+ update_campaign: idempotent('PATCH', ['list_campaigns'], ['campaign_id']),
80
+ duplicate_campaign: keyed('POST', ['list_campaigns'], ['campaign_id']),
81
+ activate_campaign: keyed('POST', ['list_campaigns', 'campaign_stats'], ['campaign_id', 'status']),
82
+ pause_campaign: reconcile('POST', ['list_campaigns'], ['campaign_id', 'status']),
83
+ sync_campaign: idempotent('POST', ['campaign_stats'], ['campaign_id']),
84
+ update_sender_config: idempotent('PUT', ['get_sender_config'], ['credential_id']),
85
+ create_list: keyed('POST', ['get_lists'], ['name']),
86
+ add_list_members: idempotent('POST', ['get_list_rows'], ['list_id', 'global_person_ids']),
87
+ create_workbook: reconcile('POST', ['list_workbooks'], ['name', 'list_ids']),
88
+ add_table_to_workbook: reconcile(
89
+ 'POST',
90
+ ['list_workbooks'],
91
+ ['workbook_id', 'list_id', 'new_table_name'],
92
+ ),
93
+ remove_table_from_workbook: reconcile(
94
+ 'DELETE',
95
+ ['list_workbooks'],
96
+ ['workbook_id', 'list_id'],
97
+ ),
98
+ reorder_workbook_table: idempotent(
99
+ 'PATCH',
100
+ ['list_workbooks'],
101
+ ['workbook_id', 'list_id', 'position'],
102
+ ),
103
+ duplicate_workbook: reconcile(
104
+ 'POST',
105
+ ['list_workbooks'],
106
+ ['workbook_id', 'name', 'folder_id'],
107
+ ),
108
+ create_folder: reconcile('POST', ['list_workbooks'], ['name']),
109
+ move_to_folder: idempotent(
110
+ 'PATCH',
111
+ ['list_workbooks'],
112
+ ['folder_id', 'workbook_id', 'list_id'],
113
+ ),
114
+ delete_folder: never('DELETE', ['list_workbooks'], ['folder_id']),
115
+ create_table: idempotent('POST', ['get_tables'], ['name', 'operation_id']),
116
+ create_table_column: idempotent(
117
+ 'POST',
118
+ ['get_table_columns'],
119
+ ['table_id', 'label', 'operation_id'],
120
+ ),
121
+ create_table_preset_column: idempotent(
122
+ 'POST',
123
+ ['get_table_columns'],
124
+ ['table_id', 'preset_slug', 'preset_id', 'label', 'operation_id'],
125
+ ),
126
+ set_table_cells: idempotent('POST', ['list_table_rows'], ['table_id']),
127
+ import_leads: idempotent(
128
+ 'POST',
129
+ ['get_tables', 'get_list_rows'],
130
+ ['list_id', 'list_name', 'dedupe_by', 'operation_id'],
131
+ ),
132
+ import_leads_csv: idempotent(
133
+ 'POST',
134
+ ['get_tables', 'get_list_rows'],
135
+ ['list_id', 'list_name', 'dedupe_by', 'operation_id'],
136
+ ),
137
+ preview_import: read(),
138
+ preview_apify_import: read(),
139
+ import_apify_dataset: idempotent(
140
+ 'POST',
141
+ ['get_tables', 'get_list_rows'],
142
+ ['list_id', 'list_name', 'dataset_id', 'preview_hash'],
143
+ ),
144
+ capture_community_intent: idempotent(
145
+ 'POST',
146
+ ['get_tables', 'get_list_rows'],
147
+ ['source_url', 'public_profile_url', 'observed_at', 'operation_id'],
148
+ ),
149
+ save_flow_layout: idempotent('PUT', ['get_flow_graph'], ['scope_key']),
150
+ create_table_flow_rule: reconcile('POST', ['list_table_flow_rules'], ['list_id', 'name']),
151
+ toggle_table_flow_rule: idempotent('PATCH', ['list_table_flow_rules'], ['list_id', 'rule_id', 'enabled']),
152
+ create_campaign_flow_rule: reconcile(
153
+ 'POST',
154
+ ['list_campaign_flow_rules'],
155
+ ['campaign_id', 'name'],
156
+ ),
157
+ toggle_campaign_flow_rule: idempotent(
158
+ 'PATCH',
159
+ ['list_campaign_flow_rules'],
160
+ ['campaign_id', 'rule_id', 'enabled'],
161
+ ),
162
+ test_flow_rule: never(
163
+ 'POST',
164
+ ['get_flow_rule_stats', 'list_table_flow_rules', 'list_campaign_flow_rules'],
165
+ ['list_id', 'campaign_id', 'rule_id', 'lead_id', 'execute'],
166
+ ),
167
+ preview_send_to_table: read(),
168
+ send_to_table: idempotent('POST', ['get_list_rows'], ['list_id', 'destination_list_id', 'new_table_name']),
169
+ preview_table_write_back: read(),
170
+ apply_table_write_back: idempotent('POST', ['get_list_rows'], ['list_id', 'column_id', 'field']),
171
+ export_table_csv: reconcile('POST', ['get_table_export'], ['list_id', 'filename']),
172
+ create_table_view: idempotent(
173
+ 'POST',
174
+ ['list_table_views'],
175
+ ['list_id', 'name', 'operation_id'],
176
+ ),
177
+ update_table_view: idempotent('PATCH', ['list_table_views'], ['list_id', 'view_id']),
178
+ delete_table_view: reconcile('DELETE', ['list_table_views'], ['list_id', 'view_id']),
179
+ estimate_table_column: read(),
180
+ run_table_column: never('POST', ['get_list_history'], ['list_id', 'column_id']),
181
+ run_scope_summary: read(),
182
+ estimate_table_run_all: read(),
183
+ run_table_all: never('POST', ['get_list_history'], ['list_id', 'column_ids']),
184
+ remove_table_rows: reconcile('DELETE', ['list_table_rows'], ['list_id', 'lead_ids']),
185
+ create_list_webhook: keyed('POST', ['get_list_webhook'], ['list_id']),
186
+ update_list_webhook: idempotent('PATCH', ['get_list_webhook'], ['list_id']),
187
+ revoke_list_webhook: never('DELETE', ['get_list_webhook'], ['list_id']),
188
+ export_list_to_webhook: never('POST', ['get_list_history'], ['list_id']),
189
+ delete_list: never('DELETE', ['get_lists'], ['list_id']),
190
+ delete_campaign: never('DELETE', ['list_campaigns'], ['campaign_id']),
191
+ update_reply: idempotent('PATCH', ['list_replies'], ['reply_id']),
192
+ add_suppression_entry: reconcile('POST', ['list_suppression_entries'], ['kind', 'value']),
193
+ remove_suppression_entry: reconcile('DELETE', ['list_suppression_entries'], ['entry_id']),
194
+ update_suppression_settings: idempotent('PUT', ['get_suppression_settings'], ['auto_suppress_mode']),
195
+ roadmap_update_status: idempotent('PATCH', ['roadmap_list'], ['slug', 'status']),
196
+ roadmap_create_item: reconcile('POST', ['roadmap_list'], ['slug']),
197
+ roadmap_update_plan: idempotent('PATCH', ['roadmap_list'], ['slug']),
198
+ });
199
+
200
+ export const RETRY_CONTRACT_COPY = Object.freeze({
201
+ bounded: 'Read-only operation. Retries transport errors, 429, and 5xx with bounded backoff.',
202
+ idempotent: 'Proven idempotent write. Retries transient failures with bounded backoff.',
203
+ idempotency_key: 'Durable Idempotency-Key required. Transient retries replay one stored result.',
204
+ reconcile: 'One request only. An ambiguous result returns reconciliation tools and stable match fields.',
205
+ never: 'Never retried automatically. An ambiguous result must be reconciled before any new attempt.',
206
+ });