scrapeloop-mcp 0.3.0 → 0.5.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/package.json +1 -1
- package/src/index.js +236 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scrapeloop-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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
|
"bin": {
|
package/src/index.js
CHANGED
|
@@ -183,7 +183,10 @@ const TOOLS = {
|
|
|
183
183
|
description: "List an integration's stored credentials (masked — keys are never returned).",
|
|
184
184
|
inputSchema: obj({ integration_id: S }, ['integration_id']),
|
|
185
185
|
},
|
|
186
|
-
run: (a) =>
|
|
186
|
+
run: (a) =>
|
|
187
|
+
a.integration_id
|
|
188
|
+
? api('GET', `/integrations/${enc(a.integration_id)}/credentials`)
|
|
189
|
+
: { ok: false, status: 400, error: { detail: 'integration_id is required. Use list_integrations to find it.' } },
|
|
187
190
|
},
|
|
188
191
|
add_credential: {
|
|
189
192
|
def: {
|
|
@@ -228,28 +231,44 @@ const TOOLS = {
|
|
|
228
231
|
},
|
|
229
232
|
estimate_scrape: {
|
|
230
233
|
def: {
|
|
231
|
-
description: 'Preview a scrape job: estimated result count, cost, budget headroom, and
|
|
232
|
-
inputSchema: obj(
|
|
234
|
+
description: 'Preview a scrape job: estimated result count, cost, budget headroom, coverage, and (for managed billing) the lead-credit cost. Always show this before submit_scrape.',
|
|
235
|
+
inputSchema: obj(
|
|
236
|
+
{ kind: S, integration_id: S, config: O, billing: { ...S, enum: ['auto', 'byok', 'managed'] } },
|
|
237
|
+
['kind', 'config'],
|
|
238
|
+
),
|
|
233
239
|
},
|
|
234
240
|
run: (a) =>
|
|
235
|
-
api('POST', '/jobs/estimate', {
|
|
241
|
+
api('POST', '/jobs/estimate', {
|
|
242
|
+
kind: a.kind,
|
|
243
|
+
integration_id: a.integration_id,
|
|
244
|
+
config: a.config || {},
|
|
245
|
+
...(a.billing ? { billing: a.billing } : {}),
|
|
246
|
+
}),
|
|
236
247
|
},
|
|
237
248
|
submit_scrape: {
|
|
238
249
|
def: {
|
|
239
250
|
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.',
|
|
251
|
+
'Submit a scrape job. SPENDS vendor credits (BYOK) or lead credits (managed: 1 credit per NEW lead, deduped free) — confirm with the user first. billing: "auto" (default) uses the workspace key when one exists, else Scrapeloop\'s managed key; "byok"/"managed" force the mode. integration_id is required for BYOK, optional for managed. Surfaces 402 (budget/free-tier/credits) and 409 (rescrape confirmation needed) verbatim; pass confirm_rescrape:true to proceed past a coverage conflict.',
|
|
241
252
|
inputSchema: obj(
|
|
242
|
-
{
|
|
243
|
-
|
|
253
|
+
{
|
|
254
|
+
kind: S,
|
|
255
|
+
integration_id: S,
|
|
256
|
+
config: O,
|
|
257
|
+
credential_id: S,
|
|
258
|
+
confirm_rescrape: B,
|
|
259
|
+
billing: { ...S, enum: ['auto', 'byok', 'managed'] },
|
|
260
|
+
},
|
|
261
|
+
['kind', 'config'],
|
|
244
262
|
),
|
|
245
263
|
},
|
|
246
264
|
run: (a) =>
|
|
247
265
|
api('POST', '/jobs', {
|
|
248
266
|
kind: a.kind,
|
|
249
|
-
integration_id: a.integration_id,
|
|
267
|
+
integration_id: a.integration_id ?? null,
|
|
250
268
|
config: a.config || {},
|
|
251
269
|
...(a.credential_id ? { credential_id: a.credential_id } : {}),
|
|
252
270
|
...(a.confirm_rescrape ? { confirm_rescrape: true } : {}),
|
|
271
|
+
...(a.billing ? { billing: a.billing } : {}),
|
|
253
272
|
}),
|
|
254
273
|
},
|
|
255
274
|
list_jobs: {
|
|
@@ -426,7 +445,23 @@ const TOOLS = {
|
|
|
426
445
|
}),
|
|
427
446
|
},
|
|
428
447
|
|
|
429
|
-
// ── Step 7:
|
|
448
|
+
// ── Step 7: senders + segments + campaigns ────────────────────────────
|
|
449
|
+
list_senders: {
|
|
450
|
+
def: {
|
|
451
|
+
description:
|
|
452
|
+
'List the sender vendors Scrapeloop supports (instantly, …) with each one\'s capabilities (webhooks, lead lists, async moves, bulk upload). Catalog read — use it to know what a connected sender can do before configuring feed/offload.',
|
|
453
|
+
inputSchema: obj({}),
|
|
454
|
+
},
|
|
455
|
+
run: () => api('GET', '/senders'),
|
|
456
|
+
},
|
|
457
|
+
list_sender_campaigns: {
|
|
458
|
+
def: {
|
|
459
|
+
description:
|
|
460
|
+
"List a sender credential's campaigns at the vendor (vendor-generic: works for any connected sender key, same shape as list_instantly_campaigns — use it to pick external_list_id).",
|
|
461
|
+
inputSchema: obj({ credential_id: S }, ['credential_id']),
|
|
462
|
+
},
|
|
463
|
+
run: (a) => api('GET', `/senders/${enc(a.credential_id)}/campaigns`),
|
|
464
|
+
},
|
|
430
465
|
list_instantly_campaigns: {
|
|
431
466
|
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
467
|
run: (a) => api('GET', `/instantly/campaigns${qs({ credential_id: a.credential_id })}`),
|
|
@@ -496,6 +531,14 @@ const TOOLS = {
|
|
|
496
531
|
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
532
|
run: (a) => api('PATCH', `/campaigns/${enc(a.campaign_id)}`, a.patch || {}),
|
|
498
533
|
},
|
|
534
|
+
duplicate_campaign: {
|
|
535
|
+
def: {
|
|
536
|
+
description:
|
|
537
|
+
'Duplicate a campaign: clones ALL configuration (list/segment binding, sender credential, Instantly binding, feed/drip/offload settings, field mapping) into a new draft named "Copy of …". Created SAFE regardless of the source — feed_mode is forced to off and require_approval to true, so the copy never feeds leads to Instantly until explicitly enabled. Leads/stats/ledger are NOT copied.',
|
|
538
|
+
inputSchema: obj({ campaign_id: S }, ['campaign_id']),
|
|
539
|
+
},
|
|
540
|
+
run: (a) => api('POST', `/campaigns/${enc(a.campaign_id)}/duplicate`),
|
|
541
|
+
},
|
|
499
542
|
activate_campaign: {
|
|
500
543
|
def: { description: 'Activate a campaign — STARTS the feed/send loop. Confirm with the user first.', inputSchema: obj({ campaign_id: S }, ['campaign_id']) },
|
|
501
544
|
run: (a) => api('POST', `/campaigns/${enc(a.campaign_id)}/activate`),
|
|
@@ -546,6 +589,189 @@ const TOOLS = {
|
|
|
546
589
|
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
590
|
run: (a) => api('POST', `/lists/${enc(a.list_id)}/members`, { global_person_ids: a.global_person_ids }),
|
|
548
591
|
},
|
|
592
|
+
import_leads: {
|
|
593
|
+
def: {
|
|
594
|
+
description:
|
|
595
|
+
'Bring your own leads: import externally-sourced lead records into a campaign-bindable Scrapeloop list. For leads you already have (a CSV/export with emails — optionally pre-verified, with a generated first line) — no scraping, no lead credits, and NO re-verification (your verification_status is preserved). Idempotent: dedupe within the workspace by email (default) or external_id — re-running the same batch UPDATES existing leads (merges custom_fields, unions strategy tags), never duplicates. custom_fields flow through to Instantly as custom variables (usable as {{first_line}} etc.). Target an existing list_id OR a list_name (create-or-get). Batch up to 1000 records. Each record: {email (required), first_name, last_name, business_name, phone, website, address, city, state_region, country, niche, rating, reviews_count, verification_status ("verified"|"catchall_verified"|"unverified"|"risky"|"invalid"), strategy_tags:[], custom_fields:{}, external_id}. Returns {list_id, inserted, updated, deduped, invalid, invalid_reasons}. Then bind list_id with create_campaign(source_list_id=...).',
|
|
596
|
+
inputSchema: obj(
|
|
597
|
+
{
|
|
598
|
+
list_id: S,
|
|
599
|
+
list_name: S,
|
|
600
|
+
records: ARR(O),
|
|
601
|
+
dedupe_by: { ...S, description: '"email" (default) or "external_id"' },
|
|
602
|
+
default_verification_status: S,
|
|
603
|
+
},
|
|
604
|
+
['records'],
|
|
605
|
+
),
|
|
606
|
+
},
|
|
607
|
+
run: (a) =>
|
|
608
|
+
api('POST', '/leads/import', {
|
|
609
|
+
...(a.list_id ? { list_id: a.list_id } : {}),
|
|
610
|
+
...(a.list_name ? { list_name: a.list_name } : {}),
|
|
611
|
+
records: a.records || [],
|
|
612
|
+
...(a.dedupe_by ? { dedupe_by: a.dedupe_by } : {}),
|
|
613
|
+
...(a.default_verification_status ? { default_verification_status: a.default_verification_status } : {}),
|
|
614
|
+
}),
|
|
615
|
+
},
|
|
616
|
+
preview_import: {
|
|
617
|
+
def: {
|
|
618
|
+
description:
|
|
619
|
+
'Dry-run for import_leads: report exactly what an import WOULD do — {would_insert, would_update, deduped, invalid, invalid_reasons, list_exists} — WITHOUT writing anything (no leads, no list, no tags created). Same inputs as import_leads. Call this first for a bring-your-own batch, show the user the plan (e.g. "adds 340 new, updates 12, skips 3 bad rows"), then confirm before import_leads.',
|
|
620
|
+
inputSchema: obj(
|
|
621
|
+
{
|
|
622
|
+
list_id: S,
|
|
623
|
+
list_name: S,
|
|
624
|
+
records: ARR(O),
|
|
625
|
+
dedupe_by: { ...S, description: '"email" (default) or "external_id"' },
|
|
626
|
+
default_verification_status: S,
|
|
627
|
+
},
|
|
628
|
+
['records'],
|
|
629
|
+
),
|
|
630
|
+
},
|
|
631
|
+
run: (a) =>
|
|
632
|
+
api('POST', '/leads/import/preview', {
|
|
633
|
+
...(a.list_id ? { list_id: a.list_id } : {}),
|
|
634
|
+
...(a.list_name ? { list_name: a.list_name } : {}),
|
|
635
|
+
records: a.records || [],
|
|
636
|
+
...(a.dedupe_by ? { dedupe_by: a.dedupe_by } : {}),
|
|
637
|
+
...(a.default_verification_status ? { default_verification_status: a.default_verification_status } : {}),
|
|
638
|
+
}),
|
|
639
|
+
},
|
|
640
|
+
get_list_rows: {
|
|
641
|
+
def: {
|
|
642
|
+
description:
|
|
643
|
+
"Read a list's leads with their fields (email, name, status, verification_status, custom_fields, strategy tags, position). The read-back for a list built by import_leads (custom_fields + verification_status live on the lead's attributes). Paginated (limit ≤ 500, offset).",
|
|
644
|
+
inputSchema: obj({ list_id: S, limit: N, offset: N }, ['list_id']),
|
|
645
|
+
},
|
|
646
|
+
run: (a) =>
|
|
647
|
+
a.list_id
|
|
648
|
+
? api('GET', `/lists/${enc(a.list_id)}/leads${qs({ limit: a.limit, offset: a.offset })}`)
|
|
649
|
+
: { ok: false, status: 400, error: { detail: 'list_id is required.' } },
|
|
650
|
+
},
|
|
651
|
+
delete_list: {
|
|
652
|
+
def: {
|
|
653
|
+
description:
|
|
654
|
+
'Delete a list (the list + its membership; the underlying leads stay in the workspace). Works for any Scrapeloop list. Irreversible — confirm with the user first.',
|
|
655
|
+
inputSchema: obj({ list_id: S }, ['list_id']),
|
|
656
|
+
},
|
|
657
|
+
run: (a) =>
|
|
658
|
+
a.list_id
|
|
659
|
+
? api('DELETE', `/lists/${enc(a.list_id)}`)
|
|
660
|
+
: { ok: false, status: 400, error: { detail: 'list_id is required.' } },
|
|
661
|
+
},
|
|
662
|
+
delete_campaign: {
|
|
663
|
+
def: {
|
|
664
|
+
description:
|
|
665
|
+
'Delete a campaign (the Scrapeloop campaign + its ledger; the Instantly campaign itself is not deleted). Irreversible — if you only want to stop sending, pause_campaign instead. Confirm with the user first.',
|
|
666
|
+
inputSchema: obj({ campaign_id: S }, ['campaign_id']),
|
|
667
|
+
},
|
|
668
|
+
run: (a) =>
|
|
669
|
+
a.campaign_id
|
|
670
|
+
? api('DELETE', `/campaigns/${enc(a.campaign_id)}`)
|
|
671
|
+
: { ok: false, status: 400, error: { detail: 'campaign_id is required.' } },
|
|
672
|
+
},
|
|
673
|
+
|
|
674
|
+
// ── Replies + suppression ─────────────────────────────────────────────
|
|
675
|
+
list_replies: {
|
|
676
|
+
def: {
|
|
677
|
+
description:
|
|
678
|
+
'List inbound campaign replies, newest first. Filters: sentiment (interested|not_interested|unsubscribed), handled (true = already actioned, false = needs attention), lead_id, campaign_id, status (pending|auto_classified|human_required|human_reviewed — human_required is the review queue).',
|
|
679
|
+
inputSchema: obj({ sentiment: S, handled: B, lead_id: S, campaign_id: S, status: S, limit: N, offset: N }),
|
|
680
|
+
},
|
|
681
|
+
run: (a) =>
|
|
682
|
+
api(
|
|
683
|
+
'GET',
|
|
684
|
+
`/replies${qs({
|
|
685
|
+
sentiment: a.sentiment,
|
|
686
|
+
handled: a.handled,
|
|
687
|
+
lead_id: a.lead_id,
|
|
688
|
+
campaign_id: a.campaign_id,
|
|
689
|
+
status: a.status,
|
|
690
|
+
limit: a.limit,
|
|
691
|
+
offset: a.offset,
|
|
692
|
+
})}`,
|
|
693
|
+
),
|
|
694
|
+
},
|
|
695
|
+
update_reply: {
|
|
696
|
+
def: {
|
|
697
|
+
description:
|
|
698
|
+
"Update a reply: mark it handled/unhandled and/or set its sentiment. Setting sentiment is a human classification — it propagates to the lead (reply_sentiment, replied_at, status transition, the unsubscribe guard for 'unsubscribed', and the workspace's auto-suppress feed).",
|
|
699
|
+
inputSchema: obj(
|
|
700
|
+
{
|
|
701
|
+
reply_id: S,
|
|
702
|
+
handled: B,
|
|
703
|
+
sentiment: { ...S, enum: ['interested', 'not_interested', 'unsubscribed', 'neutral'] },
|
|
704
|
+
},
|
|
705
|
+
['reply_id'],
|
|
706
|
+
),
|
|
707
|
+
},
|
|
708
|
+
run: (a) =>
|
|
709
|
+
api('PATCH', `/replies/${enc(a.reply_id)}`, {
|
|
710
|
+
...(a.handled !== undefined ? { handled: a.handled } : {}),
|
|
711
|
+
...(a.sentiment ? { sentiment: a.sentiment } : {}),
|
|
712
|
+
}),
|
|
713
|
+
},
|
|
714
|
+
list_suppression_entries: {
|
|
715
|
+
def: {
|
|
716
|
+
description:
|
|
717
|
+
'List the workspace do-not-contact suppression entries (emails + whole domains, optionally expiring). q searches values; kind filters email|domain. Suppressed addresses are never uploaded to campaigns.',
|
|
718
|
+
inputSchema: obj({ q: S, kind: { ...S, enum: ['email', 'domain'] }, limit: N, offset: N }),
|
|
719
|
+
},
|
|
720
|
+
run: (a) => api('GET', `/suppression/entries${qs({ q: a.q, kind: a.kind, limit: a.limit, offset: a.offset })}`),
|
|
721
|
+
},
|
|
722
|
+
add_suppression_entry: {
|
|
723
|
+
def: {
|
|
724
|
+
description:
|
|
725
|
+
'Add a do-not-contact suppression entry: kind "email" (one address) or "domain" (the whole company). Optional expires_at (ISO timestamp; omit for permanent). For a bulk/CSV import pass entries: [{kind, value, reason}] (max 500 per call) instead of kind+value.',
|
|
726
|
+
inputSchema: obj({ kind: { ...S, enum: ['email', 'domain'] }, value: S, reason: S, expires_at: S, entries: ARR(O) }),
|
|
727
|
+
},
|
|
728
|
+
run: (a) =>
|
|
729
|
+
api('POST', '/suppression/entries', {
|
|
730
|
+
...(a.kind ? { kind: a.kind } : {}),
|
|
731
|
+
...(a.value ? { value: a.value } : {}),
|
|
732
|
+
...(a.reason ? { reason: a.reason } : {}),
|
|
733
|
+
...(a.expires_at ? { expires_at: a.expires_at } : {}),
|
|
734
|
+
...(a.entries ? { entries: a.entries } : {}),
|
|
735
|
+
}),
|
|
736
|
+
},
|
|
737
|
+
remove_suppression_entry: {
|
|
738
|
+
def: {
|
|
739
|
+
description: 'Remove a suppression entry by id (the address/domain becomes contactable again).',
|
|
740
|
+
inputSchema: obj({ entry_id: S }, ['entry_id']),
|
|
741
|
+
},
|
|
742
|
+
run: (a) =>
|
|
743
|
+
a.entry_id
|
|
744
|
+
? api('DELETE', `/suppression/entries/${enc(a.entry_id)}`)
|
|
745
|
+
: { ok: false, status: 400, error: { detail: 'entry_id is required. Use list_suppression_entries to find it.' } },
|
|
746
|
+
},
|
|
747
|
+
get_suppression_settings: {
|
|
748
|
+
def: {
|
|
749
|
+
description:
|
|
750
|
+
'Get the workspace auto-suppress settings: mode (manual = never auto-suppress; all_negative = suppress on any negative reply; hostile_only = suppress on hostile/unsubscribe replies) + duration in days (0 = permanent).',
|
|
751
|
+
inputSchema: obj({}),
|
|
752
|
+
},
|
|
753
|
+
run: () => api('GET', '/suppression/settings'),
|
|
754
|
+
},
|
|
755
|
+
update_suppression_settings: {
|
|
756
|
+
def: {
|
|
757
|
+
description:
|
|
758
|
+
'Set the auto-suppress policy: auto_suppress_mode (manual | all_negative | hostile_only) and auto_suppress_duration_days (0 = permanent, max 3650). Explain the choice to the user before changing it.',
|
|
759
|
+
inputSchema: obj(
|
|
760
|
+
{
|
|
761
|
+
auto_suppress_mode: { ...S, enum: ['manual', 'all_negative', 'hostile_only'] },
|
|
762
|
+
auto_suppress_duration_days: N,
|
|
763
|
+
},
|
|
764
|
+
['auto_suppress_mode'],
|
|
765
|
+
),
|
|
766
|
+
},
|
|
767
|
+
run: (a) =>
|
|
768
|
+
api('PUT', '/suppression/settings', {
|
|
769
|
+
auto_suppress_mode: a.auto_suppress_mode,
|
|
770
|
+
...(a.auto_suppress_duration_days != null
|
|
771
|
+
? { auto_suppress_duration_days: a.auto_suppress_duration_days }
|
|
772
|
+
: {}),
|
|
773
|
+
}),
|
|
774
|
+
},
|
|
549
775
|
};
|
|
550
776
|
|
|
551
777
|
async function serve() {
|
|
@@ -560,7 +786,7 @@ async function serve() {
|
|
|
560
786
|
}
|
|
561
787
|
|
|
562
788
|
const server = new Server(
|
|
563
|
-
{ name: 'scrapeloop-mcp', version: '0.
|
|
789
|
+
{ name: 'scrapeloop-mcp', version: '0.5.0' },
|
|
564
790
|
{ capabilities: { tools: {} } }
|
|
565
791
|
);
|
|
566
792
|
|