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 +35 -14
- package/package.json +2 -2
- package/src/index.js +312 -34
- package/src/mutation-policies.js +206 -0
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
|
-
`
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
54
|
+
type: 'uncertain_result',
|
|
49
55
|
uncertain_result: true,
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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.
|
|
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: '
|
|
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
|
|
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.
|
|
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
|
+
});
|