scrapeloop-mcp 0.3.0
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 +87 -0
- package/package.json +25 -0
- package/src/index.js +608 -0
package/README.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# scrapeloop-mcp
|
|
2
|
+
|
|
3
|
+
A [Model Context Protocol](https://modelcontextprotocol.io) server for Scrapeloop.
|
|
4
|
+
**Set up and run Scrapeloop end-to-end from Claude Desktop, Cursor, ChatGPT, or any
|
|
5
|
+
MCP client** — connect integrations, scrape or pull from the lead database, verify
|
|
6
|
+
(incl. catch-all), clean, enrich, tag, and feed Instantly campaigns.
|
|
7
|
+
|
|
8
|
+
Pair it with the [`scrapeloop-setup` skill](../skill) for a guided, step-by-step
|
|
9
|
+
flow ("set up Scrapeloop").
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
1. Generate an API key in Scrapeloop: **Settings → API access → "MCP / full
|
|
14
|
+
automation"** (mints an `sl_live_…` key with the full driving scope set).
|
|
15
|
+
2. Add it to your MCP client config.
|
|
16
|
+
|
|
17
|
+
### Claude Desktop
|
|
18
|
+
|
|
19
|
+
`~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or
|
|
20
|
+
`%APPDATA%\Claude\claude_desktop_config.json` (Windows):
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"mcpServers": {
|
|
25
|
+
"scrapeloop": {
|
|
26
|
+
"command": "npx",
|
|
27
|
+
"args": ["-y", "scrapeloop-mcp"],
|
|
28
|
+
"env": {
|
|
29
|
+
"SCRAPELOOP_API_KEY": "sl_live_your_key_here"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
(For local/self-hosted, also set `SCRAPELOOP_API_URL`, e.g. `http://localhost:8000`.)
|
|
37
|
+
|
|
38
|
+
### ChatGPT / other MCP clients
|
|
39
|
+
|
|
40
|
+
Add the same command + `SCRAPELOOP_API_KEY` as a custom connector. For ChatGPT,
|
|
41
|
+
pair it with the generated instructions pack (skill `dist/chatgpt-instructions.md`).
|
|
42
|
+
|
|
43
|
+
## Scopes
|
|
44
|
+
|
|
45
|
+
The key's scopes gate the **write/spend** surface (reads are free + ungated; credit
|
|
46
|
+
gating lives in the API as 402s). The "MCP / full automation" preset requests the
|
|
47
|
+
full set so the setup flow works end-to-end:
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
leads:read leads:reveal verify lists lists:write integrations:read
|
|
51
|
+
integrations:write scrape enrich cleaners strategies campaigns:write instantly
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
A 403 with `"API key missing scope(s): …"` means the key was minted with a narrower
|
|
55
|
+
set — regenerate it with the full preset.
|
|
56
|
+
|
|
57
|
+
## Tools
|
|
58
|
+
|
|
59
|
+
Call **`get_capabilities`** first (the live manifest: scrapers + schemas, presets,
|
|
60
|
+
cleaners, strategy tags, vendors, scopes, and the ordered 7 setup steps) and
|
|
61
|
+
**`get_setup_status`** to see what's done. Then walk the steps. Tools cover:
|
|
62
|
+
|
|
63
|
+
| Area | Tools |
|
|
64
|
+
|------|-------|
|
|
65
|
+
| Discovery | `get_capabilities`, `get_setup_status`, `get_credits`, `get_meta` |
|
|
66
|
+
| Integrations (BYOK) | `list_integrations`, `create_integration`, `list_credentials`, `add_credential`, `test_credential`, `list_instantly_connections` |
|
|
67
|
+
| Scrape | `list_scrapers`, `estimate_scrape`, `submit_scrape`, `list_jobs`, `get_job`, `cancel_job` |
|
|
68
|
+
| Lead database | `search_leads`, `estimate_leads`, `reveal_lead`, `save_leads`, `bulk_verify_leads` |
|
|
69
|
+
| Verify | `verify_email`, `verify_catchall` |
|
|
70
|
+
| Clean | `list_cleaners`, `preview_cleaners`, `apply_cleaners` |
|
|
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
|
+
| 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
|
+
| Lists | `get_lists`, `create_list`, `add_list_members` |
|
|
74
|
+
|
|
75
|
+
Credit-spending (`reveal_lead`, `save_leads`, `submit_scrape`, `verify_*`,
|
|
76
|
+
`run_enrich`) and send-starting (`activate_campaign`) tools should be preceded by the
|
|
77
|
+
matching `estimate_*` call and an explicit user confirmation. Out-of-credit (402),
|
|
78
|
+
missing-scope (403), and confirmation-needed (409) responses are returned verbatim so
|
|
79
|
+
the assistant can explain or self-correct.
|
|
80
|
+
|
|
81
|
+
## Develop / smoke-test locally
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
SCRAPELOOP_API_URL=http://localhost:8000 \
|
|
85
|
+
SCRAPELOOP_API_KEY=sl_live_… \
|
|
86
|
+
node test/mcp_smoke.mjs # drives tools against a running API + drift-checks the manifest
|
|
87
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "scrapeloop-mcp",
|
|
3
|
+
"version": "0.3.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"scrapeloop-mcp": "src/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=18"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@modelcontextprotocol/sdk": "^1.0.0"
|
|
18
|
+
},
|
|
19
|
+
"keywords": ["mcp", "scrapeloop", "leads", "b2b", "email-verification", "cold-email", "model-context-protocol"],
|
|
20
|
+
"homepage": "https://scrapeloop.com",
|
|
21
|
+
"repository": { "type": "git", "url": "git+https://github.com/Hinkam-Lanello/scrapeloop.git", "directory": "packages/mcp" },
|
|
22
|
+
"bugs": { "url": "https://github.com/Hinkam-Lanello/scrapeloop/issues" },
|
|
23
|
+
"publishConfig": { "access": "public" },
|
|
24
|
+
"license": "UNLICENSED"
|
|
25
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* scrapeloop-mcp — Model Context Protocol server for Scrapeloop.
|
|
4
|
+
*
|
|
5
|
+
* A thin wrapper over the Scrapeloop REST API (/api/v1). Authenticates with a
|
|
6
|
+
* per-workspace API key (Settings → API access). Credit-gated actions surface
|
|
7
|
+
* 402/403 bodies verbatim so the model can self-correct or prompt a top-up.
|
|
8
|
+
*
|
|
9
|
+
* The full setup flow (connect → choose source → configure → verify → clean →
|
|
10
|
+
* enrich → campaigns) is driven by these tools. Call `get_capabilities` first to
|
|
11
|
+
* learn what this workspace actually has and `get_setup_status` to know where the
|
|
12
|
+
* user already is, then walk the manifest's `steps` in order.
|
|
13
|
+
*
|
|
14
|
+
* Env:
|
|
15
|
+
* SCRAPELOOP_API_KEY required — sl_live_… key
|
|
16
|
+
* SCRAPELOOP_API_URL optional — defaults to https://api.scrapeloop.com
|
|
17
|
+
*/
|
|
18
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
19
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
20
|
+
import {
|
|
21
|
+
CallToolRequestSchema,
|
|
22
|
+
ListToolsRequestSchema,
|
|
23
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
24
|
+
|
|
25
|
+
const API_KEY = process.env.SCRAPELOOP_API_KEY;
|
|
26
|
+
const BASE = (process.env.SCRAPELOOP_API_URL || 'https://api.scrapeloop.com').replace(/\/$/, '');
|
|
27
|
+
const TIMEOUT_MS = Number(process.env.SCRAPELOOP_TIMEOUT_MS) || 30000;
|
|
28
|
+
const MAX_RETRIES = 3;
|
|
29
|
+
|
|
30
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
31
|
+
|
|
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) {
|
|
37
|
+
if (!API_KEY) {
|
|
38
|
+
return {
|
|
39
|
+
ok: false,
|
|
40
|
+
status: 401,
|
|
41
|
+
error: {
|
|
42
|
+
detail:
|
|
43
|
+
'SCRAPELOOP_API_KEY is not set. Generate one in Scrapeloop → Settings → ' +
|
|
44
|
+
'API access ("MCP / full automation") and add it to this server\'s env.',
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
let lastErr;
|
|
49
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
50
|
+
let res;
|
|
51
|
+
try {
|
|
52
|
+
res = await fetch(`${BASE}/api/v1${path}`, {
|
|
53
|
+
method,
|
|
54
|
+
headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
|
|
55
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
56
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
57
|
+
});
|
|
58
|
+
} catch (e) {
|
|
59
|
+
// Network error / timeout — retry a few times, then surface a clean error.
|
|
60
|
+
lastErr = e;
|
|
61
|
+
if (attempt < MAX_RETRIES) {
|
|
62
|
+
await sleep(500 * 2 ** attempt);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const timedOut = e && (e.name === 'TimeoutError' || e.name === 'AbortError');
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
status: 0,
|
|
69
|
+
error: { detail: `Could not reach Scrapeloop (${timedOut ? `timeout after ${TIMEOUT_MS}ms` : String(e)}).` },
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
// Retry transient upstream failures (rate limit / server errors).
|
|
73
|
+
if ((res.status === 429 || res.status >= 500) && attempt < MAX_RETRIES) {
|
|
74
|
+
const retryAfter = Number(res.headers.get('retry-after')) * 1000;
|
|
75
|
+
await sleep(retryAfter > 0 ? retryAfter : 500 * 2 ** attempt);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const text = await res.text();
|
|
79
|
+
let data;
|
|
80
|
+
try {
|
|
81
|
+
data = text ? JSON.parse(text) : {};
|
|
82
|
+
} catch {
|
|
83
|
+
data = { raw: text };
|
|
84
|
+
}
|
|
85
|
+
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 };
|
|
88
|
+
}
|
|
89
|
+
return { ok: true, status: res.status, data };
|
|
90
|
+
}
|
|
91
|
+
return { ok: false, status: 0, error: { detail: `Request failed: ${String(lastErr)}` } };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const result = (payload) => ({
|
|
95
|
+
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
96
|
+
isError: payload && payload.ok === false,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const enc = encodeURIComponent;
|
|
100
|
+
// Build "?k=v&…" from defined args, omitting undefined/null.
|
|
101
|
+
const qs = (params) => {
|
|
102
|
+
const parts = Object.entries(params)
|
|
103
|
+
.filter(([, v]) => v !== undefined && v !== null)
|
|
104
|
+
.map(([k, v]) => `${enc(k)}=${enc(v)}`);
|
|
105
|
+
return parts.length ? `?${parts.join('&')}` : '';
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const obj = (properties, required) => ({
|
|
109
|
+
type: 'object',
|
|
110
|
+
properties,
|
|
111
|
+
...(required ? { required } : {}),
|
|
112
|
+
});
|
|
113
|
+
const S = { type: 'string' };
|
|
114
|
+
const N = { type: 'number' };
|
|
115
|
+
const B = { type: 'boolean' };
|
|
116
|
+
const O = { type: 'object' };
|
|
117
|
+
const ARR = (items) => ({ type: 'array', items });
|
|
118
|
+
|
|
119
|
+
// --- Tool registry: name → { def, run } -------------------------------------
|
|
120
|
+
const TOOLS = {
|
|
121
|
+
// ── Discovery / status ────────────────────────────────────────────────
|
|
122
|
+
get_capabilities: {
|
|
123
|
+
def: {
|
|
124
|
+
description:
|
|
125
|
+
'Fetch the live Scrapeloop capability manifest: scrapers (+ their config schemas), enrichment primitives & presets, cleaners, strategy tags, vendors, scopes, filter enums, and the ordered 7 setup steps with guidance. CALL THIS FIRST and whenever unsure what is available — it reflects the workspace as it is right now.',
|
|
126
|
+
inputSchema: obj({}),
|
|
127
|
+
},
|
|
128
|
+
run: () => api('GET', '/manifest'),
|
|
129
|
+
},
|
|
130
|
+
get_setup_status: {
|
|
131
|
+
def: {
|
|
132
|
+
description:
|
|
133
|
+
'Onboarding checklist booleans (integration connected, a lead revealed, a verify run, a list created) so you know what is already done and where to resume.',
|
|
134
|
+
inputSchema: obj({}),
|
|
135
|
+
},
|
|
136
|
+
run: () => api('GET', '/setup/status'),
|
|
137
|
+
},
|
|
138
|
+
get_credits: {
|
|
139
|
+
def: {
|
|
140
|
+
description: 'Get the workspace lead-credit and verify-credit balances.',
|
|
141
|
+
inputSchema: obj({}),
|
|
142
|
+
},
|
|
143
|
+
run: () => api('GET', '/credits'),
|
|
144
|
+
},
|
|
145
|
+
get_meta: {
|
|
146
|
+
def: {
|
|
147
|
+
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.',
|
|
149
|
+
inputSchema: obj({ filter: { ...S, description: 'filter name or "_all"' } }, ['filter']),
|
|
150
|
+
},
|
|
151
|
+
run: (a) => api('GET', `/meta/${enc(a.filter)}`),
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
// ── Step 1: integrations + credentials (BYOK) ─────────────────────────
|
|
155
|
+
list_integrations: {
|
|
156
|
+
def: { description: 'List the workspace integrations (connected vendors).', inputSchema: obj({}) },
|
|
157
|
+
run: () => api('GET', '/integrations'),
|
|
158
|
+
},
|
|
159
|
+
create_integration: {
|
|
160
|
+
def: {
|
|
161
|
+
description:
|
|
162
|
+
'Register a vendor integration before adding its key. plugin_kind is scraper|enricher|sender|crm|notification (e.g. outscraper→scraper, instantly→sender, reoon→enricher).',
|
|
163
|
+
inputSchema: obj(
|
|
164
|
+
{
|
|
165
|
+
vendor: S,
|
|
166
|
+
plugin_kind: { ...S, enum: ['scraper', 'enricher', 'sender', 'crm', 'notification'] },
|
|
167
|
+
selection_strategy: { ...S, enum: ['cheapest_first', 'round_robin', 'sticky', 'manual'] },
|
|
168
|
+
monthly_cap_usd: N,
|
|
169
|
+
},
|
|
170
|
+
['vendor', 'plugin_kind'],
|
|
171
|
+
),
|
|
172
|
+
},
|
|
173
|
+
run: (a) =>
|
|
174
|
+
api('POST', '/integrations', {
|
|
175
|
+
vendor: a.vendor,
|
|
176
|
+
plugin_kind: a.plugin_kind,
|
|
177
|
+
...(a.selection_strategy ? { selection_strategy: a.selection_strategy } : {}),
|
|
178
|
+
...(a.monthly_cap_usd != null ? { monthly_cap_usd: a.monthly_cap_usd } : {}),
|
|
179
|
+
}),
|
|
180
|
+
},
|
|
181
|
+
list_credentials: {
|
|
182
|
+
def: {
|
|
183
|
+
description: "List an integration's stored credentials (masked — keys are never returned).",
|
|
184
|
+
inputSchema: obj({ integration_id: S }, ['integration_id']),
|
|
185
|
+
},
|
|
186
|
+
run: (a) => api('GET', `/integrations/${enc(a.integration_id)}/credentials`),
|
|
187
|
+
},
|
|
188
|
+
add_credential: {
|
|
189
|
+
def: {
|
|
190
|
+
description:
|
|
191
|
+
'Store a BYOK vendor API key (Outscraper/Apify/Prospeo/Instantly/Reoon/BounceBan). The key is validated against the vendor on save and encrypted at rest.',
|
|
192
|
+
inputSchema: obj(
|
|
193
|
+
{ integration_id: S, label: S, raw_key: S, notes: S },
|
|
194
|
+
['integration_id', 'label', 'raw_key'],
|
|
195
|
+
),
|
|
196
|
+
},
|
|
197
|
+
run: (a) =>
|
|
198
|
+
api('POST', `/integrations/${enc(a.integration_id)}/credentials`, {
|
|
199
|
+
label: a.label,
|
|
200
|
+
raw_key: a.raw_key,
|
|
201
|
+
...(a.notes ? { notes: a.notes } : {}),
|
|
202
|
+
}),
|
|
203
|
+
},
|
|
204
|
+
test_credential: {
|
|
205
|
+
def: {
|
|
206
|
+
description: 'Live-check a stored credential against the vendor (free). Returns ok + balance where available.',
|
|
207
|
+
inputSchema: obj({ credential_id: S }, ['credential_id']),
|
|
208
|
+
},
|
|
209
|
+
run: (a) => api('POST', `/credentials/${enc(a.credential_id)}/test`),
|
|
210
|
+
},
|
|
211
|
+
list_instantly_connections: {
|
|
212
|
+
def: { description: 'List the connected Instantly sender keys in the workspace.', inputSchema: obj({}) },
|
|
213
|
+
run: () => api('GET', '/instantly/connections'),
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
// ── Step 2/3: scrapers + scrape jobs ──────────────────────────────────
|
|
217
|
+
list_scrapers: {
|
|
218
|
+
def: {
|
|
219
|
+
description:
|
|
220
|
+
'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.',
|
|
221
|
+
inputSchema: obj({}),
|
|
222
|
+
},
|
|
223
|
+
run: () => api('GET', '/scrapers'),
|
|
224
|
+
},
|
|
225
|
+
list_primitives: {
|
|
226
|
+
def: { description: 'List enrichment primitives (the building blocks recipes compose from).', inputSchema: obj({}) },
|
|
227
|
+
run: () => api('GET', '/primitives'),
|
|
228
|
+
},
|
|
229
|
+
estimate_scrape: {
|
|
230
|
+
def: {
|
|
231
|
+
description: 'Preview a scrape job: estimated result count, cost, budget headroom, and coverage. Always show this before submit_scrape.',
|
|
232
|
+
inputSchema: obj({ kind: S, integration_id: S, config: O }, ['kind', 'config']),
|
|
233
|
+
},
|
|
234
|
+
run: (a) =>
|
|
235
|
+
api('POST', '/jobs/estimate', { kind: a.kind, integration_id: a.integration_id, config: a.config || {} }),
|
|
236
|
+
},
|
|
237
|
+
submit_scrape: {
|
|
238
|
+
def: {
|
|
239
|
+
description:
|
|
240
|
+
'Submit a scrape job. SPENDS vendor credits — confirm with the user first. Surfaces 402 (budget/free-tier) and 409 (rescrape confirmation needed) verbatim; pass confirm_rescrape:true to proceed past a coverage conflict.',
|
|
241
|
+
inputSchema: obj(
|
|
242
|
+
{ kind: S, integration_id: S, config: O, credential_id: S, confirm_rescrape: B },
|
|
243
|
+
['kind', 'integration_id', 'config'],
|
|
244
|
+
),
|
|
245
|
+
},
|
|
246
|
+
run: (a) =>
|
|
247
|
+
api('POST', '/jobs', {
|
|
248
|
+
kind: a.kind,
|
|
249
|
+
integration_id: a.integration_id,
|
|
250
|
+
config: a.config || {},
|
|
251
|
+
...(a.credential_id ? { credential_id: a.credential_id } : {}),
|
|
252
|
+
...(a.confirm_rescrape ? { confirm_rescrape: true } : {}),
|
|
253
|
+
}),
|
|
254
|
+
},
|
|
255
|
+
list_jobs: {
|
|
256
|
+
def: { description: 'List recent scrape/enrich jobs in the workspace.', inputSchema: obj({}) },
|
|
257
|
+
run: () => api('GET', '/jobs'),
|
|
258
|
+
},
|
|
259
|
+
get_job: {
|
|
260
|
+
def: { description: 'Get one job with its execution steps (poll this for scrape progress).', inputSchema: obj({ job_id: S }, ['job_id']) },
|
|
261
|
+
run: (a) => api('GET', `/jobs/${enc(a.job_id)}`),
|
|
262
|
+
},
|
|
263
|
+
cancel_job: {
|
|
264
|
+
def: { description: 'Cancel a queued/running job (also best-effort cancels the vendor task).', inputSchema: obj({ job_id: S }, ['job_id']) },
|
|
265
|
+
run: (a) => api('POST', `/jobs/${enc(a.job_id)}/cancel`),
|
|
266
|
+
},
|
|
267
|
+
|
|
268
|
+
// ── Lead database ─────────────────────────────────────────────────────
|
|
269
|
+
search_leads: {
|
|
270
|
+
def: {
|
|
271
|
+
description:
|
|
272
|
+
'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.',
|
|
273
|
+
inputSchema: obj({ filters: O, limit: { ...N, description: '1-100 (default 25)' }, offset: N }),
|
|
274
|
+
},
|
|
275
|
+
run: (a) => api('POST', '/leads/search', { filters: a.filters || {}, limit: a.limit || 25, offset: a.offset || 0 }),
|
|
276
|
+
},
|
|
277
|
+
estimate_leads: {
|
|
278
|
+
def: { description: 'Count how many leads match a filters object (free).', inputSchema: obj({ filters: O }) },
|
|
279
|
+
run: (a) => api('POST', '/leads/estimate', { filters: a.filters || {} }),
|
|
280
|
+
},
|
|
281
|
+
reveal_lead: {
|
|
282
|
+
def: {
|
|
283
|
+
description: "Reveal a lead's email by global_person_id. SPENDS 1 lead credit (idempotent — already-revealed leads are free).",
|
|
284
|
+
inputSchema: obj({ global_person_id: S }, ['global_person_id']),
|
|
285
|
+
},
|
|
286
|
+
run: (a) => api('POST', '/leads/reveal', { global_person_id: a.global_person_id }),
|
|
287
|
+
},
|
|
288
|
+
save_leads: {
|
|
289
|
+
def: {
|
|
290
|
+
description: 'Save leads into the workspace by global_person_id. SPENDS 1 lead credit per newly-acquired lead.',
|
|
291
|
+
inputSchema: obj({ global_person_ids: ARR(S) }, ['global_person_ids']),
|
|
292
|
+
},
|
|
293
|
+
run: (a) => api('POST', '/leads/save', { global_person_ids: a.global_person_ids }),
|
|
294
|
+
},
|
|
295
|
+
bulk_verify_leads: {
|
|
296
|
+
def: {
|
|
297
|
+
description: 'Enqueue a background verification pass over saved/DB leads (by lead_ids or global_person_ids).',
|
|
298
|
+
inputSchema: obj({ lead_ids: ARR(S), global_person_ids: ARR(S), max_per_hour: N }),
|
|
299
|
+
},
|
|
300
|
+
run: (a) =>
|
|
301
|
+
api('POST', '/leads/verify', {
|
|
302
|
+
lead_ids: a.lead_ids || [],
|
|
303
|
+
global_person_ids: a.global_person_ids || [],
|
|
304
|
+
...(a.max_per_hour ? { max_per_hour: a.max_per_hour } : {}),
|
|
305
|
+
}),
|
|
306
|
+
},
|
|
307
|
+
|
|
308
|
+
// ── Step 4: verification ──────────────────────────────────────────────
|
|
309
|
+
verify_email: {
|
|
310
|
+
def: { description: 'Verify an email address (1 verify credit).', inputSchema: obj({ email: S }, ['email']) },
|
|
311
|
+
run: (a) => api('POST', '/verify', { email: a.email }),
|
|
312
|
+
},
|
|
313
|
+
verify_catchall: {
|
|
314
|
+
def: {
|
|
315
|
+
description:
|
|
316
|
+
'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.',
|
|
317
|
+
inputSchema: obj({ email: S }, ['email']),
|
|
318
|
+
},
|
|
319
|
+
run: (a) => api('POST', '/verify/catchall', { email: a.email }),
|
|
320
|
+
},
|
|
321
|
+
|
|
322
|
+
// ── Step 5: cleaners ──────────────────────────────────────────────────
|
|
323
|
+
list_cleaners: {
|
|
324
|
+
def: { description: 'List the available cleaners (normalize names, drop .edu/.gov/.mil, drop free/disposable, dedup).', inputSchema: obj({}) },
|
|
325
|
+
run: () => api('GET', '/cleaners'),
|
|
326
|
+
},
|
|
327
|
+
preview_cleaners: {
|
|
328
|
+
def: {
|
|
329
|
+
description: 'Dry-run cleaners over the workspace (or one list): counts how many leads each cleaner would change. No writes.',
|
|
330
|
+
inputSchema: obj({ list_id: S, cleaners: ARR(S) }, ['cleaners']),
|
|
331
|
+
},
|
|
332
|
+
run: (a) => api('POST', '/cleaners/preview', { ...(a.list_id ? { list_id: a.list_id } : {}), cleaners: a.cleaners }),
|
|
333
|
+
},
|
|
334
|
+
apply_cleaners: {
|
|
335
|
+
def: {
|
|
336
|
+
description:
|
|
337
|
+
'Apply cleaners (confirm=true required). Non-destructive: email drops null the email + tag the row; dedup soft-removes the newer duplicate. Preview first.',
|
|
338
|
+
inputSchema: obj({ list_id: S, cleaners: ARR(S), confirm: B }, ['cleaners']),
|
|
339
|
+
},
|
|
340
|
+
run: (a) =>
|
|
341
|
+
api('POST', '/cleaners/apply', {
|
|
342
|
+
...(a.list_id ? { list_id: a.list_id } : {}),
|
|
343
|
+
cleaners: a.cleaners,
|
|
344
|
+
confirm: a.confirm === true,
|
|
345
|
+
}),
|
|
346
|
+
},
|
|
347
|
+
|
|
348
|
+
// ── Step 6: enrichment + strategies ───────────────────────────────────
|
|
349
|
+
list_presets: {
|
|
350
|
+
def: { description: 'List enrichment recipes (system + workspace presets).', inputSchema: obj({}) },
|
|
351
|
+
run: () => api('GET', '/presets'),
|
|
352
|
+
},
|
|
353
|
+
create_preset: {
|
|
354
|
+
def: {
|
|
355
|
+
description: 'Create a custom enrichment recipe. recipe = {steps:[{kind, config}]} where kind is a primitive name.',
|
|
356
|
+
inputSchema: obj({ name: S, description: S, recipe: O }, ['name', 'recipe']),
|
|
357
|
+
},
|
|
358
|
+
run: (a) => api('POST', '/presets', { name: a.name, description: a.description, recipe: a.recipe }),
|
|
359
|
+
},
|
|
360
|
+
estimate_enrich: {
|
|
361
|
+
def: {
|
|
362
|
+
description: 'Estimate the cost of running an enrichment over leads (by preset_slug/preset_id + lead_ids or filter).',
|
|
363
|
+
inputSchema: obj({ preset_slug: S, preset_id: S, lead_ids: ARR(S), filter_query: O }),
|
|
364
|
+
},
|
|
365
|
+
run: (a) =>
|
|
366
|
+
api('POST', '/enrich/estimate', {
|
|
367
|
+
...(a.preset_slug ? { preset_slug: a.preset_slug } : {}),
|
|
368
|
+
...(a.preset_id ? { preset_id: a.preset_id } : {}),
|
|
369
|
+
lead_ids: a.lead_ids || [],
|
|
370
|
+
...(a.filter_query ? { filter_query: a.filter_query } : {}),
|
|
371
|
+
}),
|
|
372
|
+
},
|
|
373
|
+
run_enrich: {
|
|
374
|
+
def: {
|
|
375
|
+
description: 'Enqueue a bulk enrichment run. SPENDS vendor credits — estimate + confirm first.',
|
|
376
|
+
inputSchema: obj({ preset_slug: S, preset_id: S, lead_ids: ARR(S), filter_query: O }),
|
|
377
|
+
},
|
|
378
|
+
run: (a) =>
|
|
379
|
+
api('POST', '/enrich/run', {
|
|
380
|
+
...(a.preset_slug ? { preset_slug: a.preset_slug } : {}),
|
|
381
|
+
...(a.preset_id ? { preset_id: a.preset_id } : {}),
|
|
382
|
+
lead_ids: a.lead_ids || [],
|
|
383
|
+
...(a.filter_query ? { filter_query: a.filter_query } : {}),
|
|
384
|
+
}),
|
|
385
|
+
},
|
|
386
|
+
test_recipe: {
|
|
387
|
+
def: { description: 'Test an enrichment recipe on a single lead (synchronous).', inputSchema: obj({ recipe: O, lead_id: S }, ['recipe', 'lead_id']) },
|
|
388
|
+
run: (a) => api('POST', '/enrich/test', { recipe: a.recipe, lead_id: a.lead_id }),
|
|
389
|
+
},
|
|
390
|
+
get_ai_settings: {
|
|
391
|
+
def: { description: 'Get the workspace AI defaults + available models/pricing.', inputSchema: obj({}) },
|
|
392
|
+
run: () => api('GET', '/ai-settings'),
|
|
393
|
+
},
|
|
394
|
+
update_ai_settings: {
|
|
395
|
+
def: {
|
|
396
|
+
description: 'Update the workspace AI settings (default_ai_model, enabled_models, cache_settings).',
|
|
397
|
+
inputSchema: obj({ default_ai_model: S, enabled_models: ARR(S), cache_settings: O }),
|
|
398
|
+
},
|
|
399
|
+
run: (a) =>
|
|
400
|
+
api('PUT', '/ai-settings', {
|
|
401
|
+
...(a.default_ai_model ? { default_ai_model: a.default_ai_model } : {}),
|
|
402
|
+
...(a.enabled_models ? { enabled_models: a.enabled_models } : {}),
|
|
403
|
+
...(a.cache_settings ? { cache_settings: a.cache_settings } : {}),
|
|
404
|
+
}),
|
|
405
|
+
},
|
|
406
|
+
list_strategies: {
|
|
407
|
+
def: { description: 'List the workspace strategy tags (routing tags campaigns filter on).', inputSchema: obj({}) },
|
|
408
|
+
run: () => api('GET', '/strategies'),
|
|
409
|
+
},
|
|
410
|
+
create_strategy: {
|
|
411
|
+
def: {
|
|
412
|
+
description: 'Create a workspace strategy tag (e.g. needs_website, review_first_line).',
|
|
413
|
+
inputSchema: obj({ name: S, color: S, description: S }, ['name']),
|
|
414
|
+
},
|
|
415
|
+
run: (a) => api('POST', '/strategies', { name: a.name, color: a.color, description: a.description }),
|
|
416
|
+
},
|
|
417
|
+
assign_strategy: {
|
|
418
|
+
def: {
|
|
419
|
+
description: "Tag leads with a strategy (appended to each lead's tags). Target explicit lead_ids OR every lead in a list.",
|
|
420
|
+
inputSchema: obj({ strategy_id: S, lead_ids: ARR(S), list_id: S }, ['strategy_id']),
|
|
421
|
+
},
|
|
422
|
+
run: (a) =>
|
|
423
|
+
api('POST', `/strategies/${enc(a.strategy_id)}/assign`, {
|
|
424
|
+
lead_ids: a.lead_ids || [],
|
|
425
|
+
...(a.list_id ? { list_id: a.list_id } : {}),
|
|
426
|
+
}),
|
|
427
|
+
},
|
|
428
|
+
|
|
429
|
+
// ── Step 7: Instantly + segments + campaigns ──────────────────────────
|
|
430
|
+
list_instantly_campaigns: {
|
|
431
|
+
def: { description: "List an Instantly key's campaigns (to pick external_list_id for a Scrapeloop campaign).", inputSchema: obj({ credential_id: S }, ['credential_id']) },
|
|
432
|
+
run: (a) => api('GET', `/instantly/campaigns${qs({ credential_id: a.credential_id })}`),
|
|
433
|
+
},
|
|
434
|
+
list_instantly_lead_lists: {
|
|
435
|
+
def: { description: "List an Instantly key's CRM lead-lists.", inputSchema: obj({ credential_id: S }, ['credential_id']) },
|
|
436
|
+
run: (a) => api('GET', `/instantly/lead-lists${qs({ credential_id: a.credential_id })}`),
|
|
437
|
+
},
|
|
438
|
+
import_instantly_campaigns: {
|
|
439
|
+
def: { description: 'Import existing Instantly campaigns into Scrapeloop as paused, configurable campaigns.', inputSchema: obj({ credential_id: S, campaign_ids: ARR(S) }, ['credential_id', 'campaign_ids']) },
|
|
440
|
+
run: (a) => api('POST', '/instantly/import-campaigns', { credential_id: a.credential_id, campaign_ids: a.campaign_ids }),
|
|
441
|
+
},
|
|
442
|
+
import_instantly_lead_list: {
|
|
443
|
+
def: { description: 'Import an Instantly CRM lead-list into a new Scrapeloop list (dedup-aware).', inputSchema: obj({ credential_id: S, lead_list_id: S, name: S }, ['credential_id', 'lead_list_id']) },
|
|
444
|
+
run: (a) => api('POST', '/instantly/import-lead-list', { credential_id: a.credential_id, lead_list_id: a.lead_list_id, ...(a.name ? { name: a.name } : {}) }),
|
|
445
|
+
},
|
|
446
|
+
list_segments: {
|
|
447
|
+
def: { description: 'List lead segments (saved filter definitions campaigns can target).', inputSchema: obj({}) },
|
|
448
|
+
run: () => api('GET', '/segments'),
|
|
449
|
+
},
|
|
450
|
+
create_segment: {
|
|
451
|
+
def: { description: 'Create a segment from a filter_expression.', inputSchema: obj({ name: S, description: S, filter_expression: O }, ['name']) },
|
|
452
|
+
run: (a) => api('POST', '/segments', { name: a.name, description: a.description, filter_expression: a.filter_expression || {} }),
|
|
453
|
+
},
|
|
454
|
+
preview_segment: {
|
|
455
|
+
def: { description: 'Preview how many leads a filter_expression matches (count + sample rows).', inputSchema: obj({ filter_expression: O, limit: N }) },
|
|
456
|
+
run: (a) => api('POST', '/segments/preview', { filter_expression: a.filter_expression || {}, limit: a.limit || 10 }),
|
|
457
|
+
},
|
|
458
|
+
list_campaigns: {
|
|
459
|
+
def: { description: 'List campaigns with per-campaign stats and the bound list name.', inputSchema: obj({}) },
|
|
460
|
+
run: () => api('GET', '/campaigns'),
|
|
461
|
+
},
|
|
462
|
+
create_campaign: {
|
|
463
|
+
def: {
|
|
464
|
+
description:
|
|
465
|
+
'Create a campaign binding a Scrapeloop list (source_list_id) OR segment to an Instantly campaign (external_list_id from list_instantly_campaigns). feed_mode: off|auto_add|drip. Set target_active_count, cooldown_days (default 180), resting_period_days (default 90). Created paused — confirm before activate_campaign.',
|
|
466
|
+
inputSchema: obj(
|
|
467
|
+
{
|
|
468
|
+
name: S,
|
|
469
|
+
source_list_id: S,
|
|
470
|
+
segment_id: S,
|
|
471
|
+
sender_credential_id: S,
|
|
472
|
+
external_list_id: S,
|
|
473
|
+
instantly_campaign_name: S,
|
|
474
|
+
feed_mode: { ...S, enum: ['off', 'auto_add', 'drip'] },
|
|
475
|
+
drip_strategy: { ...S, enum: ['target_active', 'fixed_daily'] },
|
|
476
|
+
drip_daily_count: N,
|
|
477
|
+
target_active_count: N,
|
|
478
|
+
cooldown_days: N,
|
|
479
|
+
resting_period_days: N,
|
|
480
|
+
},
|
|
481
|
+
['name', 'sender_credential_id', 'external_list_id'],
|
|
482
|
+
),
|
|
483
|
+
},
|
|
484
|
+
run: (a) => {
|
|
485
|
+
const body = { name: a.name, sender_credential_id: a.sender_credential_id, external_list_id: a.external_list_id };
|
|
486
|
+
for (const k of [
|
|
487
|
+
'source_list_id', 'segment_id', 'instantly_campaign_name', 'feed_mode', 'drip_strategy',
|
|
488
|
+
'drip_daily_count', 'target_active_count', 'cooldown_days', 'resting_period_days',
|
|
489
|
+
]) {
|
|
490
|
+
if (a[k] !== undefined && a[k] !== null) body[k] = a[k];
|
|
491
|
+
}
|
|
492
|
+
return api('POST', '/campaigns', body);
|
|
493
|
+
},
|
|
494
|
+
},
|
|
495
|
+
update_campaign: {
|
|
496
|
+
def: { description: 'Patch a campaign (feed_mode, target_active_count, cooldown_days, external_list_id, …).', inputSchema: obj({ campaign_id: S, patch: O }, ['campaign_id', 'patch']) },
|
|
497
|
+
run: (a) => api('PATCH', `/campaigns/${enc(a.campaign_id)}`, a.patch || {}),
|
|
498
|
+
},
|
|
499
|
+
activate_campaign: {
|
|
500
|
+
def: { description: 'Activate a campaign — STARTS the feed/send loop. Confirm with the user first.', inputSchema: obj({ campaign_id: S }, ['campaign_id']) },
|
|
501
|
+
run: (a) => api('POST', `/campaigns/${enc(a.campaign_id)}/activate`),
|
|
502
|
+
},
|
|
503
|
+
pause_campaign: {
|
|
504
|
+
def: { description: 'Pause a campaign (stops feeding/sending).', inputSchema: obj({ campaign_id: S }, ['campaign_id']) },
|
|
505
|
+
run: (a) => api('POST', `/campaigns/${enc(a.campaign_id)}/pause`),
|
|
506
|
+
},
|
|
507
|
+
sync_campaign: {
|
|
508
|
+
def: { description: 'Run one feed/offload tick for an active campaign immediately (idempotent).', inputSchema: obj({ campaign_id: S }, ['campaign_id']) },
|
|
509
|
+
run: (a) => api('POST', `/campaigns/${enc(a.campaign_id)}/sync-now`),
|
|
510
|
+
},
|
|
511
|
+
campaign_stats: {
|
|
512
|
+
def: { description: "A campaign's ledger stats + best-effort live Instantly analytics.", inputSchema: obj({ campaign_id: S }, ['campaign_id']) },
|
|
513
|
+
run: (a) => api('GET', `/campaigns/${enc(a.campaign_id)}/stats`),
|
|
514
|
+
},
|
|
515
|
+
get_sender_config: {
|
|
516
|
+
def: { description: "Get an Instantly credential's sender config (daily cap, send accounts, …).", inputSchema: obj({ credential_id: S }, ['credential_id']) },
|
|
517
|
+
run: (a) => api('GET', `/sender_configs/${enc(a.credential_id)}`),
|
|
518
|
+
},
|
|
519
|
+
update_sender_config: {
|
|
520
|
+
def: {
|
|
521
|
+
description: 'Set the sender config for an Instantly credential: daily_send_cap, send_account_emails, bounce_list_id, auto_confirm_after_days.',
|
|
522
|
+
inputSchema: obj(
|
|
523
|
+
{ credential_id: S, daily_send_cap: N, send_account_emails: ARR(S), bounce_list_id: S, auto_confirm_after_days: N },
|
|
524
|
+
['credential_id'],
|
|
525
|
+
),
|
|
526
|
+
},
|
|
527
|
+
run: (a) =>
|
|
528
|
+
api('PUT', `/sender_configs/${enc(a.credential_id)}`, {
|
|
529
|
+
...(a.daily_send_cap != null ? { daily_send_cap: a.daily_send_cap } : {}),
|
|
530
|
+
...(a.send_account_emails ? { send_account_emails: a.send_account_emails } : {}),
|
|
531
|
+
...(a.bounce_list_id ? { bounce_list_id: a.bounce_list_id } : {}),
|
|
532
|
+
...(a.auto_confirm_after_days != null ? { auto_confirm_after_days: a.auto_confirm_after_days } : {}),
|
|
533
|
+
}),
|
|
534
|
+
},
|
|
535
|
+
|
|
536
|
+
// ── Lists ─────────────────────────────────────────────────────────────
|
|
537
|
+
get_lists: {
|
|
538
|
+
def: { description: 'List the saved lead lists in the workspace.', inputSchema: obj({}) },
|
|
539
|
+
run: () => api('GET', '/lists'),
|
|
540
|
+
},
|
|
541
|
+
create_list: {
|
|
542
|
+
def: { description: 'Create a saved lead list.', inputSchema: obj({ name: S, description: S, color: S }, ['name']) },
|
|
543
|
+
run: (a) => api('POST', '/lists', { name: a.name, description: a.description, color: a.color }),
|
|
544
|
+
},
|
|
545
|
+
add_list_members: {
|
|
546
|
+
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']) },
|
|
547
|
+
run: (a) => api('POST', `/lists/${enc(a.list_id)}/members`, { global_person_ids: a.global_person_ids }),
|
|
548
|
+
},
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
async function serve() {
|
|
552
|
+
// Start cleanly even without a key — never crash or hang. The first tools/call
|
|
553
|
+
// returns a clear, structured auth error (api() handles the missing key), and we
|
|
554
|
+
// log exactly one warning to stderr here. The key itself is never logged.
|
|
555
|
+
if (!API_KEY) {
|
|
556
|
+
console.error(
|
|
557
|
+
'scrapeloop-mcp: SCRAPELOOP_API_KEY is not set — tools will return an auth error until it is. ' +
|
|
558
|
+
'Generate a key in Scrapeloop → Settings → API access.'
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const server = new Server(
|
|
563
|
+
{ name: 'scrapeloop-mcp', version: '0.3.0' },
|
|
564
|
+
{ capabilities: { tools: {} } }
|
|
565
|
+
);
|
|
566
|
+
|
|
567
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
568
|
+
tools: Object.entries(TOOLS).map(([name, t]) => ({ name, ...t.def })),
|
|
569
|
+
}));
|
|
570
|
+
|
|
571
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
572
|
+
const tool = TOOLS[req.params.name];
|
|
573
|
+
if (!tool) return result({ ok: false, error: `unknown tool: ${req.params.name}` });
|
|
574
|
+
try {
|
|
575
|
+
return result(await tool.run(req.params.arguments || {}));
|
|
576
|
+
} catch (e) {
|
|
577
|
+
// Belt-and-braces: a tool's run() should already return structured errors.
|
|
578
|
+
return result({ ok: false, status: 0, error: { detail: String(e) } });
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
const transport = new StdioServerTransport();
|
|
583
|
+
await server.connect(transport);
|
|
584
|
+
console.error('scrapeloop-mcp running on stdio →', BASE);
|
|
585
|
+
|
|
586
|
+
// Non-blocking key validation: warn once if the key is missing/invalid, so the
|
|
587
|
+
// user sees the problem at startup instead of only on first use. Never blocks
|
|
588
|
+
// serving and never logs the key.
|
|
589
|
+
if (API_KEY) {
|
|
590
|
+
api('GET', '/me').then((r) => {
|
|
591
|
+
if (!r.ok) {
|
|
592
|
+
const why = r.status === 401 ? 'invalid or revoked' : `unreachable (status ${r.status})`;
|
|
593
|
+
console.error(`scrapeloop-mcp: API key check failed — ${why}. Tools may return auth errors.`);
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Exported for the manifest/tools drift test. Tests set SCRAPELOOP_MCP_NO_SERVE
|
|
600
|
+
// before importing so the stdio server (and the API-key requirement) stay dormant;
|
|
601
|
+
// the CLI leaves it unset and serves. (An entrypoint-URL check is unreliable when
|
|
602
|
+
// the install path contains spaces — import.meta.url percent-encodes them but
|
|
603
|
+
// process.argv[1] does not — so an explicit opt-out flag is used instead.)
|
|
604
|
+
export { TOOLS, serve };
|
|
605
|
+
|
|
606
|
+
if (!process.env.SCRAPELOOP_MCP_NO_SERVE) {
|
|
607
|
+
await serve();
|
|
608
|
+
}
|