scrapeloop-mcp 0.4.0 → 0.6.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 +214 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scrapeloop-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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
|
@@ -231,28 +231,44 @@ const TOOLS = {
|
|
|
231
231
|
},
|
|
232
232
|
estimate_scrape: {
|
|
233
233
|
def: {
|
|
234
|
-
description: 'Preview a scrape job: estimated result count, cost, budget headroom, and
|
|
235
|
-
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
|
+
),
|
|
236
239
|
},
|
|
237
240
|
run: (a) =>
|
|
238
|
-
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
|
+
}),
|
|
239
247
|
},
|
|
240
248
|
submit_scrape: {
|
|
241
249
|
def: {
|
|
242
250
|
description:
|
|
243
|
-
'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.',
|
|
244
252
|
inputSchema: obj(
|
|
245
|
-
{
|
|
246
|
-
|
|
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'],
|
|
247
262
|
),
|
|
248
263
|
},
|
|
249
264
|
run: (a) =>
|
|
250
265
|
api('POST', '/jobs', {
|
|
251
266
|
kind: a.kind,
|
|
252
|
-
integration_id: a.integration_id,
|
|
267
|
+
integration_id: a.integration_id ?? null,
|
|
253
268
|
config: a.config || {},
|
|
254
269
|
...(a.credential_id ? { credential_id: a.credential_id } : {}),
|
|
255
270
|
...(a.confirm_rescrape ? { confirm_rescrape: true } : {}),
|
|
271
|
+
...(a.billing ? { billing: a.billing } : {}),
|
|
256
272
|
}),
|
|
257
273
|
},
|
|
258
274
|
list_jobs: {
|
|
@@ -429,7 +445,23 @@ const TOOLS = {
|
|
|
429
445
|
}),
|
|
430
446
|
},
|
|
431
447
|
|
|
432
|
-
// ── 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
|
+
},
|
|
433
465
|
list_instantly_campaigns: {
|
|
434
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']) },
|
|
435
467
|
run: (a) => api('GET', `/instantly/campaigns${qs({ credential_id: a.credential_id })}`),
|
|
@@ -499,6 +531,14 @@ const TOOLS = {
|
|
|
499
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']) },
|
|
500
532
|
run: (a) => api('PATCH', `/campaigns/${enc(a.campaign_id)}`, a.patch || {}),
|
|
501
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
|
+
},
|
|
502
542
|
activate_campaign: {
|
|
503
543
|
def: { description: 'Activate a campaign — STARTS the feed/send loop. Confirm with the user first.', inputSchema: obj({ campaign_id: S }, ['campaign_id']) },
|
|
504
544
|
run: (a) => api('POST', `/campaigns/${enc(a.campaign_id)}/activate`),
|
|
@@ -573,6 +613,69 @@ const TOOLS = {
|
|
|
573
613
|
...(a.default_verification_status ? { default_verification_status: a.default_verification_status } : {}),
|
|
574
614
|
}),
|
|
575
615
|
},
|
|
616
|
+
import_leads_csv: {
|
|
617
|
+
def: {
|
|
618
|
+
description:
|
|
619
|
+
"Bring your own leads from a CSV/TSV: paste the raw file text and it's parsed server-side — the delimiter is auto-detected, headers are fuzzy-mapped to fields (email, first_name, business_name, phone, website, city, country, rating, reviews_count, verification_status, external_id, strategy tags…), any UNMAPPED columns become custom_fields (so a 'first_line' or 'review_snippet' column flows to Instantly automatically), and verifier verdicts (valid/catch-all/invalid…) are normalized. No scraping, no lead credits, no re-verification. Set dry_run:true to PREVIEW (shows would_insert/would_update + how each column mapped, writes nothing) — do that first, show the user the column_mapping, then run again with dry_run:false. Target list_id or list_name (create-or-get). ≤1000 rows/call. Returns the import result plus column_mapping + unmapped_columns + rows_parsed.",
|
|
620
|
+
inputSchema: obj(
|
|
621
|
+
{
|
|
622
|
+
csv: S,
|
|
623
|
+
list_id: S,
|
|
624
|
+
list_name: S,
|
|
625
|
+
dedupe_by: { ...S, description: '"email" (default) or "external_id"' },
|
|
626
|
+
default_verification_status: S,
|
|
627
|
+
column_map: { ...O, description: 'optional explicit header→field overrides, e.g. {"Owner":"first_name"}' },
|
|
628
|
+
dry_run: B,
|
|
629
|
+
},
|
|
630
|
+
['csv'],
|
|
631
|
+
),
|
|
632
|
+
},
|
|
633
|
+
run: (a) =>
|
|
634
|
+
api('POST', '/leads/import/csv', {
|
|
635
|
+
csv: a.csv,
|
|
636
|
+
...(a.list_id ? { list_id: a.list_id } : {}),
|
|
637
|
+
...(a.list_name ? { list_name: a.list_name } : {}),
|
|
638
|
+
...(a.dedupe_by ? { dedupe_by: a.dedupe_by } : {}),
|
|
639
|
+
...(a.default_verification_status ? { default_verification_status: a.default_verification_status } : {}),
|
|
640
|
+
...(a.column_map ? { column_map: a.column_map } : {}),
|
|
641
|
+
...(a.dry_run ? { dry_run: true } : {}),
|
|
642
|
+
}),
|
|
643
|
+
},
|
|
644
|
+
preview_import: {
|
|
645
|
+
def: {
|
|
646
|
+
description:
|
|
647
|
+
'Dry-run for import_leads: report exactly what an import WOULD do — {would_insert, would_update, deduped, invalid, invalid_reasons, list_exists} — WITHOUT writing anything (no leads, no list, no tags created). Same inputs as import_leads. Call this first for a bring-your-own batch, show the user the plan (e.g. "adds 340 new, updates 12, skips 3 bad rows"), then confirm before import_leads.',
|
|
648
|
+
inputSchema: obj(
|
|
649
|
+
{
|
|
650
|
+
list_id: S,
|
|
651
|
+
list_name: S,
|
|
652
|
+
records: ARR(O),
|
|
653
|
+
dedupe_by: { ...S, description: '"email" (default) or "external_id"' },
|
|
654
|
+
default_verification_status: S,
|
|
655
|
+
},
|
|
656
|
+
['records'],
|
|
657
|
+
),
|
|
658
|
+
},
|
|
659
|
+
run: (a) =>
|
|
660
|
+
api('POST', '/leads/import/preview', {
|
|
661
|
+
...(a.list_id ? { list_id: a.list_id } : {}),
|
|
662
|
+
...(a.list_name ? { list_name: a.list_name } : {}),
|
|
663
|
+
records: a.records || [],
|
|
664
|
+
...(a.dedupe_by ? { dedupe_by: a.dedupe_by } : {}),
|
|
665
|
+
...(a.default_verification_status ? { default_verification_status: a.default_verification_status } : {}),
|
|
666
|
+
}),
|
|
667
|
+
},
|
|
668
|
+
get_list_rows: {
|
|
669
|
+
def: {
|
|
670
|
+
description:
|
|
671
|
+
"Read a list's leads with their fields (email, name, status, verification_status, custom_fields, strategy tags, position). The read-back for a list built by import_leads (custom_fields + verification_status live on the lead's attributes). Paginated (limit ≤ 500, offset).",
|
|
672
|
+
inputSchema: obj({ list_id: S, limit: N, offset: N }, ['list_id']),
|
|
673
|
+
},
|
|
674
|
+
run: (a) =>
|
|
675
|
+
a.list_id
|
|
676
|
+
? api('GET', `/lists/${enc(a.list_id)}/leads${qs({ limit: a.limit, offset: a.offset })}`)
|
|
677
|
+
: { ok: false, status: 400, error: { detail: 'list_id is required.' } },
|
|
678
|
+
},
|
|
576
679
|
delete_list: {
|
|
577
680
|
def: {
|
|
578
681
|
description:
|
|
@@ -595,6 +698,108 @@ const TOOLS = {
|
|
|
595
698
|
? api('DELETE', `/campaigns/${enc(a.campaign_id)}`)
|
|
596
699
|
: { ok: false, status: 400, error: { detail: 'campaign_id is required.' } },
|
|
597
700
|
},
|
|
701
|
+
|
|
702
|
+
// ── Replies + suppression ─────────────────────────────────────────────
|
|
703
|
+
list_replies: {
|
|
704
|
+
def: {
|
|
705
|
+
description:
|
|
706
|
+
'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).',
|
|
707
|
+
inputSchema: obj({ sentiment: S, handled: B, lead_id: S, campaign_id: S, status: S, limit: N, offset: N }),
|
|
708
|
+
},
|
|
709
|
+
run: (a) =>
|
|
710
|
+
api(
|
|
711
|
+
'GET',
|
|
712
|
+
`/replies${qs({
|
|
713
|
+
sentiment: a.sentiment,
|
|
714
|
+
handled: a.handled,
|
|
715
|
+
lead_id: a.lead_id,
|
|
716
|
+
campaign_id: a.campaign_id,
|
|
717
|
+
status: a.status,
|
|
718
|
+
limit: a.limit,
|
|
719
|
+
offset: a.offset,
|
|
720
|
+
})}`,
|
|
721
|
+
),
|
|
722
|
+
},
|
|
723
|
+
update_reply: {
|
|
724
|
+
def: {
|
|
725
|
+
description:
|
|
726
|
+
"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).",
|
|
727
|
+
inputSchema: obj(
|
|
728
|
+
{
|
|
729
|
+
reply_id: S,
|
|
730
|
+
handled: B,
|
|
731
|
+
sentiment: { ...S, enum: ['interested', 'not_interested', 'unsubscribed', 'neutral'] },
|
|
732
|
+
},
|
|
733
|
+
['reply_id'],
|
|
734
|
+
),
|
|
735
|
+
},
|
|
736
|
+
run: (a) =>
|
|
737
|
+
api('PATCH', `/replies/${enc(a.reply_id)}`, {
|
|
738
|
+
...(a.handled !== undefined ? { handled: a.handled } : {}),
|
|
739
|
+
...(a.sentiment ? { sentiment: a.sentiment } : {}),
|
|
740
|
+
}),
|
|
741
|
+
},
|
|
742
|
+
list_suppression_entries: {
|
|
743
|
+
def: {
|
|
744
|
+
description:
|
|
745
|
+
'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.',
|
|
746
|
+
inputSchema: obj({ q: S, kind: { ...S, enum: ['email', 'domain'] }, limit: N, offset: N }),
|
|
747
|
+
},
|
|
748
|
+
run: (a) => api('GET', `/suppression/entries${qs({ q: a.q, kind: a.kind, limit: a.limit, offset: a.offset })}`),
|
|
749
|
+
},
|
|
750
|
+
add_suppression_entry: {
|
|
751
|
+
def: {
|
|
752
|
+
description:
|
|
753
|
+
'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.',
|
|
754
|
+
inputSchema: obj({ kind: { ...S, enum: ['email', 'domain'] }, value: S, reason: S, expires_at: S, entries: ARR(O) }),
|
|
755
|
+
},
|
|
756
|
+
run: (a) =>
|
|
757
|
+
api('POST', '/suppression/entries', {
|
|
758
|
+
...(a.kind ? { kind: a.kind } : {}),
|
|
759
|
+
...(a.value ? { value: a.value } : {}),
|
|
760
|
+
...(a.reason ? { reason: a.reason } : {}),
|
|
761
|
+
...(a.expires_at ? { expires_at: a.expires_at } : {}),
|
|
762
|
+
...(a.entries ? { entries: a.entries } : {}),
|
|
763
|
+
}),
|
|
764
|
+
},
|
|
765
|
+
remove_suppression_entry: {
|
|
766
|
+
def: {
|
|
767
|
+
description: 'Remove a suppression entry by id (the address/domain becomes contactable again).',
|
|
768
|
+
inputSchema: obj({ entry_id: S }, ['entry_id']),
|
|
769
|
+
},
|
|
770
|
+
run: (a) =>
|
|
771
|
+
a.entry_id
|
|
772
|
+
? api('DELETE', `/suppression/entries/${enc(a.entry_id)}`)
|
|
773
|
+
: { ok: false, status: 400, error: { detail: 'entry_id is required. Use list_suppression_entries to find it.' } },
|
|
774
|
+
},
|
|
775
|
+
get_suppression_settings: {
|
|
776
|
+
def: {
|
|
777
|
+
description:
|
|
778
|
+
'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).',
|
|
779
|
+
inputSchema: obj({}),
|
|
780
|
+
},
|
|
781
|
+
run: () => api('GET', '/suppression/settings'),
|
|
782
|
+
},
|
|
783
|
+
update_suppression_settings: {
|
|
784
|
+
def: {
|
|
785
|
+
description:
|
|
786
|
+
'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.',
|
|
787
|
+
inputSchema: obj(
|
|
788
|
+
{
|
|
789
|
+
auto_suppress_mode: { ...S, enum: ['manual', 'all_negative', 'hostile_only'] },
|
|
790
|
+
auto_suppress_duration_days: N,
|
|
791
|
+
},
|
|
792
|
+
['auto_suppress_mode'],
|
|
793
|
+
),
|
|
794
|
+
},
|
|
795
|
+
run: (a) =>
|
|
796
|
+
api('PUT', '/suppression/settings', {
|
|
797
|
+
auto_suppress_mode: a.auto_suppress_mode,
|
|
798
|
+
...(a.auto_suppress_duration_days != null
|
|
799
|
+
? { auto_suppress_duration_days: a.auto_suppress_duration_days }
|
|
800
|
+
: {}),
|
|
801
|
+
}),
|
|
802
|
+
},
|
|
598
803
|
};
|
|
599
804
|
|
|
600
805
|
async function serve() {
|
|
@@ -609,7 +814,7 @@ async function serve() {
|
|
|
609
814
|
}
|
|
610
815
|
|
|
611
816
|
const server = new Server(
|
|
612
|
-
{ name: 'scrapeloop-mcp', version: '0.
|
|
817
|
+
{ name: 'scrapeloop-mcp', version: '0.6.0' },
|
|
613
818
|
{ capabilities: { tools: {} } }
|
|
614
819
|
);
|
|
615
820
|
|