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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. 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.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) => api('GET', `/integrations/${enc(a.integration_id)}/credentials`),
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 coverage. Always show this before submit_scrape.',
232
- inputSchema: obj({ kind: S, integration_id: S, config: O }, ['kind', 'config']),
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', { kind: a.kind, integration_id: a.integration_id, config: a.config || {} }),
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
- { kind: S, integration_id: S, config: O, credential_id: S, confirm_rescrape: B },
243
- ['kind', 'integration_id', 'config'],
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: Instantly + segments + campaigns ──────────────────────────
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.3.0' },
789
+ { name: 'scrapeloop-mcp', version: '0.5.0' },
564
790
  { capabilities: { tools: {} } }
565
791
  );
566
792