@coldiq/mcp 0.1.18 → 0.2.4

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 (52) hide show
  1. package/dist/client.d.ts +2 -0
  2. package/dist/client.d.ts.map +1 -1
  3. package/dist/client.js +7 -1
  4. package/dist/client.js.map +1 -1
  5. package/dist/executor.d.ts +11 -0
  6. package/dist/executor.d.ts.map +1 -1
  7. package/dist/executor.js +72 -11
  8. package/dist/executor.js.map +1 -1
  9. package/dist/index.js +2 -0
  10. package/dist/index.js.map +1 -1
  11. package/dist/registry.d.ts +1 -0
  12. package/dist/registry.d.ts.map +1 -1
  13. package/dist/registry.js +57 -8
  14. package/dist/registry.js.map +1 -1
  15. package/dist/tools/find-emails.d.ts +2 -7
  16. package/dist/tools/find-emails.d.ts.map +1 -1
  17. package/dist/tools/find-emails.js +193 -67
  18. package/dist/tools/find-emails.js.map +1 -1
  19. package/dist/tools/find-people.d.ts +3 -2
  20. package/dist/tools/find-people.d.ts.map +1 -1
  21. package/dist/tools/find-people.js +65 -7
  22. package/dist/tools/find-people.js.map +1 -1
  23. package/dist/tools/get-credit-balance.d.ts +17 -0
  24. package/dist/tools/get-credit-balance.d.ts.map +1 -0
  25. package/dist/tools/get-credit-balance.js +20 -0
  26. package/dist/tools/get-credit-balance.js.map +1 -0
  27. package/dist/utils/compact-people.d.ts +24 -0
  28. package/dist/utils/compact-people.d.ts.map +1 -0
  29. package/dist/utils/compact-people.js +306 -0
  30. package/dist/utils/compact-people.js.map +1 -0
  31. package/dist/utils/provider-resolver.d.ts.map +1 -1
  32. package/dist/utils/provider-resolver.js +15 -1
  33. package/dist/utils/provider-resolver.js.map +1 -1
  34. package/package.json +1 -1
  35. package/src/client.ts +9 -1
  36. package/src/executor.ts +89 -17
  37. package/src/index.ts +8 -0
  38. package/src/registry.ts +67 -8
  39. package/src/tools/find-emails.ts +251 -80
  40. package/src/tools/find-people.ts +70 -7
  41. package/src/tools/get-credit-balance.ts +24 -0
  42. package/src/utils/compact-people.ts +318 -0
  43. package/src/utils/provider-resolver.ts +15 -1
  44. package/tests/executor.test.ts +165 -0
  45. package/tests/live/fullenrich-upstream-probe.ts +55 -0
  46. package/tests/live/pdl-upstream-probe.ts +83 -0
  47. package/tests/registry-find-people.test.ts +198 -7
  48. package/tests/registry-search-companies.test.ts +46 -7
  49. package/tests/tools/find-emails.test.ts +267 -1
  50. package/tests/tools/find-people.test.ts +269 -5
  51. package/tests/tools/get-credit-balance.test.ts +56 -0
  52. package/tests/utils/compact-people.test.ts +462 -0
@@ -108,9 +108,59 @@ describe('find_people handler', () => {
108
108
  limit: 5,
109
109
  })
110
110
 
111
+ const parsed = JSON.parse(result.content[0].text)
112
+ expect(parsed._meta.provider).toBe('leadsfactory')
113
+ // Compact-by-default: raw `companies_personas` is normalized into `people[]`.
114
+ expect(parsed.data.people).toHaveLength(1)
115
+ expect(parsed.data.people[0]).toMatchObject({
116
+ full_name: 'Michel Lieben',
117
+ first_name: 'Michel',
118
+ last_name: 'Lieben',
119
+ title: 'CEO',
120
+ })
121
+ expect(parsed.data.companies_personas).toBeUndefined()
122
+ } finally {
123
+ lf.async!.timeoutMs = originalTimeout
124
+ lf.async!.pollIntervalMs = originalInterval
125
+ }
126
+ })
127
+
128
+ it('fields=verbose returns the raw LeadsFactory passthrough (companies_personas) unchanged', async () => {
129
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
130
+ const urlStr = url.toString()
131
+ if (urlStr.includes('/leadsfactory/contact-finder/searches') && !urlStr.includes('abc123')) {
132
+ return new Response(JSON.stringify({ id: 'abc123', nb_jobs_total: 1, progress_percentage: 0 }), { status: 201 })
133
+ }
134
+ if (urlStr.includes('/leadsfactory/contact-finder/searches/abc123')) {
135
+ return new Response(JSON.stringify({
136
+ id: 'abc123', status: 'SUCCESSFUL', nb_jobs_total: 1, nb_jobs_complete: 1,
137
+ companies_personas: [
138
+ { company: 'ColdIQ', personas: [[{ contact: { first_name: 'Michel', last_name: 'Lieben', title: 'CEO' } }]] },
139
+ ],
140
+ }), { status: 200 })
141
+ }
142
+ return new Response(JSON.stringify({ error: 'Not found' }), { status: 404 })
143
+ }) as typeof fetch
144
+
145
+ const { getProviders } = await import('../../src/registry.js')
146
+ const lf = getProviders('find_people').find((p) => p.id === 'leadsfactory')!
147
+ const originalTimeout = lf.async!.timeoutMs
148
+ const originalInterval = lf.async!.pollIntervalMs
149
+
150
+ try {
151
+ lf.async!.timeoutMs = 500
152
+ lf.async!.pollIntervalMs = 20
153
+ const result = await findPeopleHandler({
154
+ company_domains: ['coldiq.com'],
155
+ job_titles: ['CEO'],
156
+ limit: 5,
157
+ fields: 'verbose',
158
+ })
159
+
111
160
  const parsed = JSON.parse(result.content[0].text)
112
161
  expect(parsed._meta.provider).toBe('leadsfactory')
113
162
  expect(parsed.data.companies_personas).toHaveLength(1)
163
+ expect(parsed.data.people).toBeUndefined()
114
164
  } finally {
115
165
  lf.async!.timeoutMs = originalTimeout
116
166
  lf.async!.pollIntervalMs = originalInterval
@@ -169,10 +219,13 @@ describe('find_people handler', () => {
169
219
 
170
220
  const parsed = JSON.parse(result.content[0].text)
171
221
  expect(parsed._meta.provider).toBe('leadsfactory')
172
- expect(parsed.data.companies_personas).toHaveLength(1)
173
- expect(parsed.data.gap_fill.provider).toBe('apollo')
174
- expect(parsed.data.gap_fill.domains).toEqual(['folk.app'])
175
- expect(parsed.data.gap_fill.people).toHaveLength(1)
222
+ // Compact-by-default: LF + Apollo gap-fill records are merged into one people[]
223
+ // and the gap-fill source is surfaced via gap_fill_provider.
224
+ expect(parsed.data.people).toHaveLength(2)
225
+ expect(parsed.data.people.map((p: { full_name: string }) => p.full_name)).toEqual(['Michel Lieben', 'Thibaud Elziere'])
226
+ expect(parsed.data.gap_fill_provider).toBe('apollo')
227
+ expect(parsed.data.companies_personas).toBeUndefined()
228
+ expect(parsed.data.gap_fill).toBeUndefined()
176
229
  } finally {
177
230
  lf.async!.timeoutMs = originalTimeout
178
231
  lf.async!.pollIntervalMs = originalInterval
@@ -224,7 +277,7 @@ describe('find_people handler', () => {
224
277
 
225
278
  const parsed = JSON.parse(result.content[0].text)
226
279
  expect(parsed._meta.provider).toBe('leadsfactory')
227
- expect(parsed.data.gap_fill).toBeUndefined()
280
+ expect(parsed.data.gap_fill_provider).toBeUndefined()
228
281
  expect(apolloCalled).toBe(false)
229
282
  } finally {
230
283
  lf.async!.timeoutMs = originalTimeout
@@ -353,5 +406,216 @@ describe('find_people handler', () => {
353
406
  const parsed = JSON.parse(result.content[0].text)
354
407
  expect(parsed._meta.provider).toBe('apollo')
355
408
  expect(parsed.data.people).toHaveLength(1)
409
+ expect(parsed.data.people[0]).toMatchObject({
410
+ full_name: 'Michel Lieben',
411
+ company_domain: 'coldiq.com',
412
+ })
413
+ })
414
+
415
+ it('reveal=true follows obfuscated Apollo search with bulk-match in chunks of 10', async () => {
416
+ const bulkMatchCalls: Array<{ url: string; body: Record<string, unknown> }> = []
417
+ const obfuscated = (id: string, n: number) => ({
418
+ id,
419
+ first_name: `Person${n}`,
420
+ last_name: `Ar***t${n}`,
421
+ organization: { website_url: 'coldiq.com' },
422
+ })
423
+ const revealed = (id: string, n: number) => ({
424
+ id,
425
+ first_name: `Person${n}`,
426
+ last_name: `Argent${n}`,
427
+ email: `person${n}@coldiq.com`,
428
+ linkedin_url: `https://linkedin.com/in/person${n}`,
429
+ organization: { website_url: 'coldiq.com' },
430
+ })
431
+
432
+ globalThis.fetch = vi.fn(async (url: string | URL | Request, opts?: RequestInit) => {
433
+ const urlStr = url.toString()
434
+
435
+ if (urlStr.includes('/apollo/people/search')) {
436
+ return new Response(
437
+ JSON.stringify({ people: Array.from({ length: 12 }, (_, i) => obfuscated(`id_${i}`, i)) }),
438
+ { status: 200 },
439
+ )
440
+ }
441
+
442
+ if (urlStr.includes('/apollo/people/bulk-match')) {
443
+ const body = JSON.parse((opts?.body as string) ?? '{}') as { details: Array<{ id: string }> }
444
+ bulkMatchCalls.push({ url: urlStr, body })
445
+ return new Response(
446
+ JSON.stringify({
447
+ matches: body.details.map((d, idx) => revealed(d.id, parseInt(d.id.replace('id_', ''), 10) || idx)),
448
+ }),
449
+ { status: 200 },
450
+ )
451
+ }
452
+
453
+ // Skip LeadsFactory entirely by pinning apollo via use_providers below.
454
+ return new Response(JSON.stringify({ error: 'Not found' }), { status: 404 })
455
+ }) as typeof fetch
456
+
457
+ const result = await findPeopleHandler({
458
+ company_domains: ['coldiq.com'],
459
+ job_titles: ['CEO'],
460
+ limit: 12,
461
+ reveal: true,
462
+ use_providers: ['apollo'],
463
+ })
464
+
465
+ const parsed = JSON.parse(result.content[0].text)
466
+ expect(parsed._meta.provider).toBe('apollo')
467
+ expect(parsed.data.revealed).toBe(true)
468
+ expect(parsed.data.people).toHaveLength(12)
469
+ // Chunked at 10 per bulk-match call: 12 IDs → 2 calls (10 + 2)
470
+ expect(bulkMatchCalls).toHaveLength(2)
471
+ expect(bulkMatchCalls[0].body.details).toHaveLength(10)
472
+ expect(bulkMatchCalls[1].body.details).toHaveLength(2)
473
+ // Revealed payload (compacted) carries email + full last name
474
+ expect(parsed.data.people[0].email).toBe('person0@coldiq.com')
475
+ expect(parsed.data.people[0].last_name).toBe('Argent0')
476
+ // reveal_personal_emails flag forwarded
477
+ expect(bulkMatchCalls[0].body.reveal_personal_emails).toBe(true)
478
+ })
479
+
480
+ it('reveal=true does NOT set revealed:true when bulk-match returns only some IDs (partial reveal)', async () => {
481
+ // Apollo's bulk-match can return matches for a subset of submitted IDs (e.g. some
482
+ // people aren't in their database). Setting `revealed: true` in that case would
483
+ // mislead callers — entries still obfuscated would look revealed at the top level.
484
+ globalThis.fetch = vi.fn(async (url: string | URL | Request, opts?: RequestInit) => {
485
+ const urlStr = url.toString()
486
+
487
+ if (urlStr.includes('/apollo/people/search')) {
488
+ return new Response(
489
+ JSON.stringify({
490
+ people: [
491
+ { id: 'id_0', first_name: 'A', last_name: 'A***A', organization: { website_url: 'coldiq.com' } },
492
+ { id: 'id_1', first_name: 'B', last_name: 'B***B', organization: { website_url: 'coldiq.com' } },
493
+ { id: 'id_2', first_name: 'C', last_name: 'C***C', organization: { website_url: 'coldiq.com' } },
494
+ ],
495
+ }),
496
+ { status: 200 },
497
+ )
498
+ }
499
+
500
+ if (urlStr.includes('/apollo/people/bulk-match')) {
501
+ const body = JSON.parse((opts?.body as string) ?? '{}') as { details: Array<{ id: string }> }
502
+ // Only return a match for id_0 — id_1 and id_2 stay obfuscated.
503
+ const onlyFirst = body.details
504
+ .filter((d) => d.id === 'id_0')
505
+ .map((d) => ({ id: d.id, first_name: 'A', last_name: 'AlphaReal', email: 'a@coldiq.com', linkedin_url: 'https://linkedin.com/in/a', organization: { website_url: 'coldiq.com' } }))
506
+ return new Response(JSON.stringify({ matches: onlyFirst }), { status: 200 })
507
+ }
508
+
509
+ return new Response(JSON.stringify({ error: 'Not found' }), { status: 404 })
510
+ }) as typeof fetch
511
+
512
+ const result = await findPeopleHandler({
513
+ company_domains: ['coldiq.com'],
514
+ job_titles: ['CEO'],
515
+ limit: 3,
516
+ reveal: true,
517
+ use_providers: ['apollo'],
518
+ })
519
+
520
+ const parsed = JSON.parse(result.content[0].text)
521
+ expect(parsed._meta.provider).toBe('apollo')
522
+ // Partial reveal: flag must be omitted (compactPayload only includes revealed when === true).
523
+ expect(parsed.data.revealed).toBeUndefined()
524
+ expect(parsed.data.people).toHaveLength(3)
525
+ // id_0 came back revealed; id_1/id_2 stayed obfuscated.
526
+ expect(parsed.data.people[0].last_name).toBe('AlphaReal')
527
+ expect(parsed.data.people[0].email).toBe('a@coldiq.com')
528
+ expect(parsed.data.people[1].last_name).toBe('B***B')
529
+ expect(parsed.data.people[2].last_name).toBe('C***C')
530
+ })
531
+
532
+ it('reveal=false (default) skips bulk-match and returns obfuscated Apollo data unchanged', async () => {
533
+ let bulkMatchCalled = false
534
+
535
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
536
+ const urlStr = url.toString()
537
+
538
+ if (urlStr.includes('/apollo/people/search')) {
539
+ return new Response(
540
+ JSON.stringify({
541
+ people: [{ id: 'id_0', first_name: 'Michel', last_name: 'Li***n', organization: { website_url: 'coldiq.com' } }],
542
+ }),
543
+ { status: 200 },
544
+ )
545
+ }
546
+
547
+ if (urlStr.includes('/apollo/people/bulk-match')) {
548
+ bulkMatchCalled = true
549
+ return new Response(JSON.stringify({ matches: [] }), { status: 200 })
550
+ }
551
+
552
+ return new Response(JSON.stringify({ error: 'Not found' }), { status: 404 })
553
+ }) as typeof fetch
554
+
555
+ const result = await findPeopleHandler({
556
+ company_domains: ['coldiq.com'],
557
+ job_titles: ['CEO'],
558
+ limit: 5,
559
+ use_providers: ['apollo'],
560
+ })
561
+
562
+ const parsed = JSON.parse(result.content[0].text)
563
+ expect(parsed._meta.provider).toBe('apollo')
564
+ expect(parsed.data.revealed).toBeUndefined()
565
+ // Obfuscated last name from /apollo/people/search is preserved through compaction.
566
+ expect(parsed.data.people[0].last_name).toBe('Li***n')
567
+ expect(bulkMatchCalled).toBe(false)
568
+ })
569
+
570
+ it('reveal=true is a no-op when the matched provider is not Apollo', async () => {
571
+ let bulkMatchCalled = false
572
+
573
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
574
+ const urlStr = url.toString()
575
+
576
+ if (urlStr.includes('/leadsfactory/contact-finder/searches') && !urlStr.includes('abc123')) {
577
+ return new Response(JSON.stringify({ id: 'abc123', nb_jobs_total: 1, progress_percentage: 0 }), { status: 201 })
578
+ }
579
+
580
+ if (urlStr.includes('/leadsfactory/contact-finder/searches/abc123')) {
581
+ return new Response(JSON.stringify({
582
+ id: 'abc123', status: 'SUCCESSFUL', nb_jobs_total: 1, nb_jobs_complete: 1,
583
+ companies_personas: [
584
+ { company: 'ColdIQ', personas: [[{ contact: { first_name: 'Michel', last_name: 'Lieben' } }]] },
585
+ ],
586
+ }), { status: 200 })
587
+ }
588
+
589
+ if (urlStr.includes('/apollo/people/bulk-match')) {
590
+ bulkMatchCalled = true
591
+ return new Response(JSON.stringify({ matches: [] }), { status: 200 })
592
+ }
593
+
594
+ return new Response(JSON.stringify({ error: 'Not found' }), { status: 404 })
595
+ }) as typeof fetch
596
+
597
+ const { getProviders } = await import('../../src/registry.js')
598
+ const lf = getProviders('find_people').find((p) => p.id === 'leadsfactory')!
599
+ const originalTimeout = lf.async!.timeoutMs
600
+ const originalInterval = lf.async!.pollIntervalMs
601
+
602
+ try {
603
+ lf.async!.timeoutMs = 500
604
+ lf.async!.pollIntervalMs = 20
605
+
606
+ const result = await findPeopleHandler({
607
+ company_domains: ['coldiq.com'],
608
+ job_titles: ['CEO'],
609
+ limit: 5,
610
+ reveal: true,
611
+ })
612
+
613
+ const parsed = JSON.parse(result.content[0].text)
614
+ expect(parsed._meta.provider).toBe('leadsfactory')
615
+ expect(bulkMatchCalled).toBe(false)
616
+ } finally {
617
+ lf.async!.timeoutMs = originalTimeout
618
+ lf.async!.pollIntervalMs = originalInterval
619
+ }
356
620
  })
357
621
  })
@@ -0,0 +1,56 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { initClient } from '../../src/client.js'
3
+ import { getCreditBalanceHandler } from '../../src/tools/get-credit-balance.js'
4
+
5
+ describe('get_credit_balance handler', () => {
6
+ const originalFetch = globalThis.fetch
7
+
8
+ beforeEach(() => {
9
+ initClient('http://test-api.local', 'test-key')
10
+ })
11
+
12
+ afterEach(() => {
13
+ globalThis.fetch = originalFetch
14
+ })
15
+
16
+ it('returns the balance payload on success', async () => {
17
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
18
+ const u = url.toString()
19
+ if (u.endsWith('/v1/me/credits')) {
20
+ return new Response(JSON.stringify({ balance: 250, usedThisMonth: 47 }), { status: 200 })
21
+ }
22
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
23
+ }) as typeof fetch
24
+
25
+ const result = await getCreditBalanceHandler({})
26
+
27
+ expect(result.isError).toBeFalsy()
28
+ const parsed = JSON.parse(result.content[0].text)
29
+ expect(parsed.data).toEqual({ balance: 250, usedThisMonth: 47 })
30
+ })
31
+
32
+ it('sends the API key as a bearer token', async () => {
33
+ let capturedAuth: string | null = null
34
+ globalThis.fetch = vi.fn(async (_url: string | URL | Request, opts?: RequestInit) => {
35
+ const headers = (opts?.headers ?? {}) as Record<string, string>
36
+ capturedAuth = headers.Authorization ?? null
37
+ return new Response(JSON.stringify({ balance: 0, usedThisMonth: 0 }), { status: 200 })
38
+ }) as typeof fetch
39
+
40
+ await getCreditBalanceHandler({})
41
+ expect(capturedAuth).toBe('Bearer test-key')
42
+ })
43
+
44
+ it('returns isError when the API responds with non-2xx', async () => {
45
+ globalThis.fetch = vi.fn(async () => {
46
+ return new Response(JSON.stringify({ error: 'Invalid API key' }), { status: 401 })
47
+ }) as typeof fetch
48
+
49
+ const result = await getCreditBalanceHandler({})
50
+
51
+ expect(result.isError).toBe(true)
52
+ const parsed = JSON.parse(result.content[0].text)
53
+ expect(parsed.status).toBe(401)
54
+ expect(parsed.error).toBe('Failed to fetch credit balance')
55
+ })
56
+ })