@coldiq/mcp 0.1.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 (176) hide show
  1. package/dist/client.d.ts +8 -0
  2. package/dist/client.d.ts.map +1 -0
  3. package/dist/client.js +47 -0
  4. package/dist/client.js.map +1 -0
  5. package/dist/executor.d.ts +21 -0
  6. package/dist/executor.d.ts.map +1 -0
  7. package/dist/executor.js +130 -0
  8. package/dist/executor.js.map +1 -0
  9. package/dist/index.d.ts +3 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +49 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/registry.d.ts +49 -0
  14. package/dist/registry.d.ts.map +1 -0
  15. package/dist/registry.js +3104 -0
  16. package/dist/registry.js.map +1 -0
  17. package/dist/tools/enrich-company.d.ts +22 -0
  18. package/dist/tools/enrich-company.d.ts.map +1 -0
  19. package/dist/tools/enrich-company.js +21 -0
  20. package/dist/tools/enrich-company.js.map +1 -0
  21. package/dist/tools/enrich-email.d.ts +24 -0
  22. package/dist/tools/enrich-email.d.ts.map +1 -0
  23. package/dist/tools/enrich-email.js +19 -0
  24. package/dist/tools/enrich-email.js.map +1 -0
  25. package/dist/tools/enrich-emails.d.ts +31 -0
  26. package/dist/tools/enrich-emails.d.ts.map +1 -0
  27. package/dist/tools/enrich-emails.js +146 -0
  28. package/dist/tools/enrich-emails.js.map +1 -0
  29. package/dist/tools/enrich-person.d.ts +26 -0
  30. package/dist/tools/enrich-person.d.ts.map +1 -0
  31. package/dist/tools/enrich-person.js +23 -0
  32. package/dist/tools/enrich-person.js.map +1 -0
  33. package/dist/tools/fetch-page-content.d.ts +22 -0
  34. package/dist/tools/fetch-page-content.d.ts.map +1 -0
  35. package/dist/tools/fetch-page-content.js +32 -0
  36. package/dist/tools/fetch-page-content.js.map +1 -0
  37. package/dist/tools/find-email.d.ts +24 -0
  38. package/dist/tools/find-email.d.ts.map +1 -0
  39. package/dist/tools/find-email.js +19 -0
  40. package/dist/tools/find-email.js.map +1 -0
  41. package/dist/tools/find-emails.d.ts +31 -0
  42. package/dist/tools/find-emails.d.ts.map +1 -0
  43. package/dist/tools/find-emails.js +146 -0
  44. package/dist/tools/find-emails.js.map +1 -0
  45. package/dist/tools/find-influencers.d.ts +29 -0
  46. package/dist/tools/find-influencers.d.ts.map +1 -0
  47. package/dist/tools/find-influencers.js +30 -0
  48. package/dist/tools/find-influencers.js.map +1 -0
  49. package/dist/tools/find-people.d.ts +26 -0
  50. package/dist/tools/find-people.d.ts.map +1 -0
  51. package/dist/tools/find-people.js +61 -0
  52. package/dist/tools/find-people.js.map +1 -0
  53. package/dist/tools/find-phone.d.ts +24 -0
  54. package/dist/tools/find-phone.d.ts.map +1 -0
  55. package/dist/tools/find-phone.js +48 -0
  56. package/dist/tools/find-phone.js.map +1 -0
  57. package/dist/tools/find-signals.d.ts +26 -0
  58. package/dist/tools/find-signals.d.ts.map +1 -0
  59. package/dist/tools/find-signals.js +82 -0
  60. package/dist/tools/find-signals.js.map +1 -0
  61. package/dist/tools/search-ads.d.ts +33 -0
  62. package/dist/tools/search-ads.d.ts.map +1 -0
  63. package/dist/tools/search-ads.js +33 -0
  64. package/dist/tools/search-ads.js.map +1 -0
  65. package/dist/tools/search-companies.d.ts +42 -0
  66. package/dist/tools/search-companies.d.ts.map +1 -0
  67. package/dist/tools/search-companies.js +37 -0
  68. package/dist/tools/search-companies.js.map +1 -0
  69. package/dist/tools/search-jobs.d.ts +51 -0
  70. package/dist/tools/search-jobs.d.ts.map +1 -0
  71. package/dist/tools/search-jobs.js +64 -0
  72. package/dist/tools/search-jobs.js.map +1 -0
  73. package/dist/tools/search-places.d.ts +47 -0
  74. package/dist/tools/search-places.d.ts.map +1 -0
  75. package/dist/tools/search-places.js +42 -0
  76. package/dist/tools/search-places.js.map +1 -0
  77. package/dist/tools/search-reddit.d.ts +27 -0
  78. package/dist/tools/search-reddit.d.ts.map +1 -0
  79. package/dist/tools/search-reddit.js +30 -0
  80. package/dist/tools/search-reddit.js.map +1 -0
  81. package/dist/tools/search-seo.d.ts +37 -0
  82. package/dist/tools/search-seo.d.ts.map +1 -0
  83. package/dist/tools/search-seo.js +49 -0
  84. package/dist/tools/search-seo.js.map +1 -0
  85. package/dist/tools/search-web.d.ts +23 -0
  86. package/dist/tools/search-web.d.ts.map +1 -0
  87. package/dist/tools/search-web.js +20 -0
  88. package/dist/tools/search-web.js.map +1 -0
  89. package/dist/tools/verify-email.d.ts +20 -0
  90. package/dist/tools/verify-email.d.ts.map +1 -0
  91. package/dist/tools/verify-email.js +15 -0
  92. package/dist/tools/verify-email.js.map +1 -0
  93. package/package.json +28 -0
  94. package/src/client.ts +60 -0
  95. package/src/executor.ts +182 -0
  96. package/src/index.ts +155 -0
  97. package/src/registry.ts +3159 -0
  98. package/src/tools/enrich-company.ts +25 -0
  99. package/src/tools/enrich-person.ts +27 -0
  100. package/src/tools/fetch-page-content.ts +36 -0
  101. package/src/tools/find-email.ts +23 -0
  102. package/src/tools/find-emails.ts +190 -0
  103. package/src/tools/find-influencers.ts +34 -0
  104. package/src/tools/find-people.ts +69 -0
  105. package/src/tools/find-phone.ts +53 -0
  106. package/src/tools/find-signals.ts +93 -0
  107. package/src/tools/search-ads.ts +44 -0
  108. package/src/tools/search-companies.ts +41 -0
  109. package/src/tools/search-jobs.ts +73 -0
  110. package/src/tools/search-places.ts +52 -0
  111. package/src/tools/search-reddit.ts +34 -0
  112. package/src/tools/search-seo.ts +59 -0
  113. package/src/tools/search-web.ts +24 -0
  114. package/src/tools/verify-email.ts +19 -0
  115. package/test-ads-live.ts +77 -0
  116. package/test-company-live.ts +91 -0
  117. package/test-email-live.ts +171 -0
  118. package/test-influencers-live.ts +66 -0
  119. package/test-jobs-live.ts +69 -0
  120. package/test-linkupapi-live.ts +137 -0
  121. package/test-phone-live.ts +41 -0
  122. package/test-places-live.ts +89 -0
  123. package/test-reddit-live.ts +66 -0
  124. package/test-search-live.ts +79 -0
  125. package/test-seo-live.ts +68 -0
  126. package/test-web-live.ts +67 -0
  127. package/tests/client.test.ts +90 -0
  128. package/tests/executor.test.ts +83 -0
  129. package/tests/gtm/01-icp-to-emails.test.ts +43 -0
  130. package/tests/gtm/02-icp-bulk-emails.test.ts +38 -0
  131. package/tests/gtm/03-icp-to-phones.test.ts +39 -0
  132. package/tests/gtm/04-funding-signal-outreach.test.ts +42 -0
  133. package/tests/gtm/05-hiring-signal-decisionmakers.test.ts +41 -0
  134. package/tests/gtm/06-intent-signal-outreach.test.ts +44 -0
  135. package/tests/gtm/07-places-to-content.test.ts +50 -0
  136. package/tests/gtm/08-domain-to-account.test.ts +44 -0
  137. package/tests/gtm/09-linkedin-to-everything.test.ts +41 -0
  138. package/tests/gtm/10-jobs-vs-signals-routing.test.ts +38 -0
  139. package/tests/gtm/11-find-vs-enrich-routing.test.ts +39 -0
  140. package/tests/gtm/12-bogus-domain-graceful.test.ts +42 -0
  141. package/tests/gtm/13-private-linkedin-graceful.test.ts +44 -0
  142. package/tests/gtm/14-empty-handoff.test.ts +43 -0
  143. package/tests/gtm/15-seo-reddit-research.test.ts +38 -0
  144. package/tests/gtm/README.md +59 -0
  145. package/tests/gtm/harness.ts +217 -0
  146. package/tests/gtm/tools-bridge.ts +232 -0
  147. package/tests/gtm-scenarios.md +32 -0
  148. package/tests/live/smoke-report.ts +255 -0
  149. package/tests/live/smoke.test.ts +134 -0
  150. package/tests/registry-enrich-person.test.ts +447 -0
  151. package/tests/registry-fetch-page-content.test.ts +90 -0
  152. package/tests/registry-find-people.test.ts +467 -0
  153. package/tests/registry-find-signals.test.ts +470 -0
  154. package/tests/registry-linkupapi.test.ts +331 -0
  155. package/tests/registry-search-companies.test.ts +188 -0
  156. package/tests/registry-search-jobs.test.ts +116 -0
  157. package/tests/registry.test.ts +2210 -0
  158. package/tests/tools/enrich-company.test.ts +92 -0
  159. package/tests/tools/enrich-email.test.ts +94 -0
  160. package/tests/tools/enrich-emails.test.ts +271 -0
  161. package/tests/tools/enrich-person.test.ts +140 -0
  162. package/tests/tools/fetch-page-content.test.ts +108 -0
  163. package/tests/tools/find-influencers.test.ts +91 -0
  164. package/tests/tools/find-people.test.ts +344 -0
  165. package/tests/tools/find-phone.test.ts +100 -0
  166. package/tests/tools/find-signals.test.ts +110 -0
  167. package/tests/tools/search-ads.test.ts +182 -0
  168. package/tests/tools/search-companies.test.ts +58 -0
  169. package/tests/tools/search-jobs.test.ts +210 -0
  170. package/tests/tools/search-places.test.ts +114 -0
  171. package/tests/tools/search-reddit.test.ts +125 -0
  172. package/tests/tools/search-seo.test.ts +183 -0
  173. package/tests/tools/search-web.test.ts +79 -0
  174. package/tests/tools/verify-email.test.ts +68 -0
  175. package/tsconfig.json +17 -0
  176. package/vitest.config.ts +7 -0
@@ -0,0 +1,92 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { initClient } from '../../src/client.js'
3
+ import { enrichCompanyHandler } from '../../src/tools/enrich-company.js'
4
+
5
+ describe('enrich_company 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 error when no identifier provided', async () => {
17
+ const result = await enrichCompanyHandler({})
18
+ expect(result.isError).toBe(true)
19
+ const parsed = JSON.parse(result.content[0].text)
20
+ expect(parsed.error).toMatch(/domain.*linkedin_url.*name/i)
21
+ })
22
+
23
+ it('returns enriched company from CompanyEnrich (GET)', async () => {
24
+ let capturedUrl = ''
25
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
26
+ capturedUrl = url.toString()
27
+ return new Response(JSON.stringify({ name: 'Stripe', domain: 'stripe.com', employees: 8000 }), { status: 200 })
28
+ }) as typeof fetch
29
+
30
+ const result = await enrichCompanyHandler({ domain: 'stripe.com' })
31
+
32
+ expect(capturedUrl).toContain('domain=stripe.com')
33
+ expect(capturedUrl).toContain('/companyenrich/companies/enrich')
34
+
35
+ const parsed = JSON.parse(result.content[0].text)
36
+ expect(parsed._meta.provider).toBe('companyenrich')
37
+ expect(parsed.data.name).toBe('Stripe')
38
+ })
39
+
40
+ it('falls back to Apollo on CompanyEnrich failure', async () => {
41
+ let callCount = 0
42
+ globalThis.fetch = vi.fn(async () => {
43
+ callCount++
44
+ if (callCount === 1) {
45
+ return new Response(JSON.stringify({ error: 'Provider error' }), { status: 502 })
46
+ }
47
+ return new Response(JSON.stringify({ organization: { name: 'Stripe' } }), { status: 200 })
48
+ }) as typeof fetch
49
+
50
+ const result = await enrichCompanyHandler({ domain: 'stripe.com' })
51
+
52
+ const parsed = JSON.parse(result.content[0].text)
53
+ expect(parsed._meta.provider).toBe('apollo')
54
+ })
55
+
56
+ it('skips CompanyEnrich GET and Apollo when only linkedin_url is provided', async () => {
57
+ const calledEndpoints: string[] = []
58
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
59
+ const endpoint = url.toString()
60
+ calledEndpoints.push(endpoint)
61
+ // PDL (3rd provider) succeeds
62
+ if (endpoint.includes('/pdl/company/enrich')) {
63
+ return new Response(JSON.stringify({ name: 'Stripe', website: 'stripe.com' }), { status: 200 })
64
+ }
65
+ return new Response(JSON.stringify({ error: 'not found' }), { status: 404 })
66
+ }) as typeof fetch
67
+
68
+ const result = await enrichCompanyHandler({ linkedin_url: 'https://www.linkedin.com/company/stripe' })
69
+
70
+ expect(calledEndpoints.every((u) => !u.includes('/companyenrich/companies/enrich?'))).toBe(true)
71
+ expect(calledEndpoints.every((u) => !u.includes('/apollo/'))).toBe(true)
72
+ const parsed = JSON.parse(result.content[0].text)
73
+ expect(parsed._meta.provider).toBe('pdl')
74
+ })
75
+
76
+ it('skips all domain-only providers when only name is provided', async () => {
77
+ const calledEndpoints: string[] = []
78
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
79
+ const endpoint = url.toString()
80
+ calledEndpoints.push(endpoint)
81
+ if (endpoint.includes('/pdl/company/enrich')) {
82
+ return new Response(JSON.stringify({ name: 'Stripe', website: 'stripe.com' }), { status: 200 })
83
+ }
84
+ return new Response(JSON.stringify({ error: 'not found' }), { status: 404 })
85
+ }) as typeof fetch
86
+
87
+ await enrichCompanyHandler({ name: 'Stripe' })
88
+
89
+ expect(calledEndpoints.every((u) => !u.includes('/companyenrich/companies/enrich?'))).toBe(true)
90
+ expect(calledEndpoints.every((u) => !u.includes('/apollo/'))).toBe(true)
91
+ })
92
+ })
@@ -0,0 +1,94 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { initClient } from '../../src/client.js'
3
+ import { findEmailHandler as enrichEmailHandler } from '../../src/tools/find-email.js'
4
+
5
+ describe('find_email handler (waterfall)', () => {
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 email from first provider that finds one', async () => {
17
+ globalThis.fetch = vi.fn(async () =>
18
+ new Response(JSON.stringify({ email: 'michel@coldiq.com' }), { status: 200 })
19
+ ) as typeof fetch
20
+
21
+ const result = await enrichEmailHandler({
22
+ first_name: 'Michel',
23
+ last_name: 'Lieben',
24
+ domain: 'coldiq.com',
25
+ })
26
+
27
+ const parsed = JSON.parse(result.content[0].text)
28
+ expect(parsed._meta.provider).toBe('findymail')
29
+ expect(parsed.data.email).toBe('michel@coldiq.com')
30
+ })
31
+
32
+ it('falls through to IcyPeas when FindyMail has no result', async () => {
33
+ let callCount = 0
34
+ globalThis.fetch = vi.fn(async () => {
35
+ callCount++
36
+ if (callCount === 1) {
37
+ // FindyMail — no email found
38
+ return new Response(JSON.stringify({ email: null }), { status: 200 })
39
+ }
40
+ // IcyPeas — found
41
+ return new Response(JSON.stringify({ email: 'michel@coldiq.com' }), { status: 200 })
42
+ }) as typeof fetch
43
+
44
+ const result = await enrichEmailHandler({
45
+ first_name: 'Michel',
46
+ last_name: 'Lieben',
47
+ domain: 'coldiq.com',
48
+ })
49
+
50
+ const parsed = JSON.parse(result.content[0].text)
51
+ expect(parsed._meta.provider).toBe('icypeas')
52
+ })
53
+
54
+ it('falls through to Prospeo when FindyMail and IcyPeas both fail', async () => {
55
+ let callCount = 0
56
+ globalThis.fetch = vi.fn(async () => {
57
+ callCount++
58
+ if (callCount <= 2) {
59
+ return new Response(JSON.stringify({ error: 'Not found' }), { status: 404 })
60
+ }
61
+ // Prospeo success response — email is nested under person.email.email
62
+ return new Response(
63
+ JSON.stringify({ error: false, person: { email: { email: 'michel@coldiq.com', status: 'VERIFIED' } } }),
64
+ { status: 200 },
65
+ )
66
+ }) as typeof fetch
67
+
68
+ const result = await enrichEmailHandler({
69
+ first_name: 'Michel',
70
+ last_name: 'Lieben',
71
+ domain: 'coldiq.com',
72
+ })
73
+
74
+ const parsed = JSON.parse(result.content[0].text)
75
+ expect(parsed._meta.provider).toBe('prospeo')
76
+ })
77
+
78
+ it('returns error when all providers fail', async () => {
79
+ globalThis.fetch = vi.fn(async () =>
80
+ new Response(JSON.stringify({ error: 'Not found' }), { status: 404 })
81
+ ) as typeof fetch
82
+
83
+ const result = await enrichEmailHandler({
84
+ first_name: 'Unknown',
85
+ last_name: 'Person',
86
+ domain: 'unknown.xyz',
87
+ })
88
+
89
+ expect(result.isError).toBe(true)
90
+ const parsed = JSON.parse(result.content[0].text)
91
+ // No linkedin_url → blitzapi and limadata-work-email-linkedin are skipped via isApplicable
92
+ expect(parsed.providers_tried.length).toBe(6) // findymail, icypeas, limadata-work-email, prospeo, fullenrich, linkupapi
93
+ })
94
+ })
@@ -0,0 +1,271 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { initClient } from '../../src/client.js'
3
+ import { findEmailsHandler as enrichEmailsHandler } from '../../src/tools/find-emails.js'
4
+
5
+ describe('find_emails handler (bulk)', () => {
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('resolves all people from prospeo bulk when all match', async () => {
17
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
18
+ const u = url.toString()
19
+ if (u.includes('/prospeo/bulk-enrich-person')) {
20
+ return new Response(
21
+ JSON.stringify({
22
+ error: false,
23
+ results: [
24
+ { identifier: 'p1', error: false, person: { email: { email: 'alice@example.com' } } },
25
+ { identifier: 'p2', error: false, person: { email: { email: 'bob@example.com' } } },
26
+ ],
27
+ total_cost: 2,
28
+ }),
29
+ { status: 200 },
30
+ )
31
+ }
32
+ return new Response(JSON.stringify({ error: 'unexpected call' }), { status: 500 })
33
+ }) as typeof fetch
34
+
35
+ const result = await enrichEmailsHandler({
36
+ people: [
37
+ { id: 'p1', first_name: 'Alice', last_name: 'Smith', domain: 'example.com' },
38
+ { id: 'p2', first_name: 'Bob', last_name: 'Jones', domain: 'example.com' },
39
+ ],
40
+ })
41
+
42
+ const parsed = JSON.parse(result.content[0].text)
43
+ expect(parsed.data.found).toBe(2)
44
+ expect(parsed.data.total).toBe(2)
45
+ expect(parsed.data.results).toEqual([
46
+ { id: 'p1', email: 'alice@example.com', provider: 'prospeo' },
47
+ { id: 'p2', email: 'bob@example.com', provider: 'prospeo' },
48
+ ])
49
+ })
50
+
51
+ it('falls back to FindyMail for prospeo misses', async () => {
52
+ let fmCalled = false
53
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
54
+ const u = url.toString()
55
+ if (u.includes('/prospeo/bulk-enrich-person')) {
56
+ return new Response(
57
+ JSON.stringify({
58
+ error: false,
59
+ results: [
60
+ { identifier: 'p1', error: true, person: null },
61
+ ],
62
+ total_cost: 0,
63
+ }),
64
+ { status: 200 },
65
+ )
66
+ }
67
+ if (u.includes('/findymail/search/name')) {
68
+ fmCalled = true
69
+ return new Response(JSON.stringify({ email: 'alice@example.com' }), { status: 200 })
70
+ }
71
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
72
+ }) as typeof fetch
73
+
74
+ const result = await enrichEmailsHandler({
75
+ people: [{ id: 'p1', first_name: 'Alice', last_name: 'Smith', domain: 'example.com' }],
76
+ })
77
+
78
+ expect(fmCalled).toBe(true)
79
+ const parsed = JSON.parse(result.content[0].text)
80
+ expect(parsed.data.results[0]).toEqual({ id: 'p1', email: 'alice@example.com', provider: 'findymail' })
81
+ })
82
+
83
+ it('falls back to IcyPeas when FindyMail also misses', async () => {
84
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
85
+ const u = url.toString()
86
+ if (u.includes('/prospeo/bulk-enrich-person')) {
87
+ return new Response(
88
+ JSON.stringify({ error: false, results: [{ identifier: 'p1', error: true, person: null }], total_cost: 0 }),
89
+ { status: 200 },
90
+ )
91
+ }
92
+ if (u.includes('/findymail/search/name')) {
93
+ return new Response(JSON.stringify({ email: null }), { status: 200 })
94
+ }
95
+ if (u.includes('/icypeas/email-search')) {
96
+ return new Response(JSON.stringify({ email: 'alice@example.com' }), { status: 200 })
97
+ }
98
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
99
+ }) as typeof fetch
100
+
101
+ const result = await enrichEmailsHandler({
102
+ people: [{ id: 'p1', first_name: 'Alice', last_name: 'Smith', domain: 'example.com' }],
103
+ })
104
+
105
+ const parsed = JSON.parse(result.content[0].text)
106
+ expect(parsed.data.results[0]).toEqual({ id: 'p1', email: 'alice@example.com', provider: 'icypeas' })
107
+ })
108
+
109
+ it('returns email:null when all providers miss for a person', async () => {
110
+ globalThis.fetch = vi.fn(async () =>
111
+ new Response(JSON.stringify({ error: true }), { status: 200 }),
112
+ ) as typeof fetch
113
+
114
+ const result = await enrichEmailsHandler({
115
+ people: [{ id: 'p1', first_name: 'Unknown', last_name: 'Person', domain: 'nowhere.xyz' }],
116
+ })
117
+
118
+ const parsed = JSON.parse(result.content[0].text)
119
+ expect(parsed.data.found).toBe(0)
120
+ expect(parsed.data.results[0]).toEqual({ id: 'p1', email: null, provider: null })
121
+ })
122
+
123
+ it('handles mixed results: some from prospeo, some from fallback, some missed', async () => {
124
+ // Track findymail call count to serve different results per person
125
+ let findymailCalls = 0
126
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
127
+ const u = url.toString()
128
+ if (u.includes('/prospeo/bulk-enrich-person')) {
129
+ return new Response(
130
+ JSON.stringify({
131
+ error: false,
132
+ results: [
133
+ { identifier: 'p1', error: false, person: { email: { email: 'alice@a.com' } } },
134
+ { identifier: 'p2', error: true, person: null },
135
+ { identifier: 'p3', error: true, person: null },
136
+ ],
137
+ total_cost: 1,
138
+ }),
139
+ { status: 200 },
140
+ )
141
+ }
142
+ if (u.includes('/findymail/search/name')) {
143
+ findymailCalls++
144
+ // First miss (p2 or p3): one finds, one doesn't
145
+ return new Response(
146
+ JSON.stringify({ email: findymailCalls === 1 ? 'bob@b.com' : null }),
147
+ { status: 200 },
148
+ )
149
+ }
150
+ if (u.includes('/icypeas/email-search')) {
151
+ return new Response(JSON.stringify({ email: null }), { status: 200 })
152
+ }
153
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
154
+ }) as typeof fetch
155
+
156
+ const result = await enrichEmailsHandler({
157
+ people: [
158
+ { id: 'p1', first_name: 'Alice', last_name: 'Smith', domain: 'a.com' },
159
+ { id: 'p2', first_name: 'Bob', last_name: 'Jones', domain: 'b.com' },
160
+ { id: 'p3', first_name: 'Carol', last_name: 'White', domain: 'c.com' },
161
+ ],
162
+ })
163
+
164
+ const parsed = JSON.parse(result.content[0].text)
165
+ expect(parsed.data.found).toBe(2)
166
+ expect(parsed.data.results.find((r: EmailResult) => r.id === 'p1')).toMatchObject({ email: 'alice@a.com', provider: 'prospeo' })
167
+ // One of p2/p3 found by findymail (order non-deterministic due to Promise.all)
168
+ const findymailHit = parsed.data.results.find((r: EmailResult) => r.provider === 'findymail')
169
+ expect(findymailHit).toBeDefined()
170
+ expect(findymailHit.email).toMatch(/@/)
171
+ const missed = parsed.data.results.find((r: EmailResult) => r.email === null)
172
+ expect(missed).toBeDefined()
173
+ })
174
+
175
+ it('uses linkedin_url in prospeo bulk when provided', async () => {
176
+ let capturedBulkBody: unknown
177
+ globalThis.fetch = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
178
+ const u = url.toString()
179
+ if (u.includes('/prospeo/bulk-enrich-person')) {
180
+ capturedBulkBody = JSON.parse(init?.body as string)
181
+ return new Response(
182
+ JSON.stringify({ error: false, results: [], total_cost: 0 }),
183
+ { status: 200 },
184
+ )
185
+ }
186
+ // Fallback calls (no domain/name so findymail/icypeas return nothing)
187
+ return new Response(JSON.stringify({ email: null }), { status: 200 })
188
+ }) as typeof fetch
189
+
190
+ await enrichEmailsHandler({
191
+ people: [{ id: 'p1', linkedin_url: 'https://linkedin.com/in/michel-lieben' }],
192
+ })
193
+
194
+ const body = capturedBulkBody as { data: Array<Record<string, unknown>> }
195
+ expect(body.data[0]).toMatchObject({ identifier: 'p1', linkedin_url: 'https://linkedin.com/in/michel-lieben' })
196
+ expect(body.data[0].first_name).toBeUndefined()
197
+ })
198
+
199
+ it('uses FullEnrich batch for Prospeo misses and matches by index', async () => {
200
+ vi.useFakeTimers()
201
+
202
+ let feCreateCalled = false
203
+ let fePollCalled = false
204
+
205
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
206
+ const u = url.toString()
207
+
208
+ if (u.includes('/prospeo/bulk-enrich-person')) {
209
+ return new Response(JSON.stringify({
210
+ error: false,
211
+ results: [{ identifier: 'p1', error: true, person: null }],
212
+ total_cost: 0,
213
+ }), { status: 200 })
214
+ }
215
+
216
+ if (u.includes('/fullenrich/contact/enrich/bulk/abc-fe-123')) {
217
+ fePollCalled = true
218
+ return new Response(JSON.stringify({
219
+ status: 'DONE',
220
+ data: [{ emails: ['alice@example.com'] }],
221
+ }), { status: 200 })
222
+ }
223
+
224
+ if (u.includes('/fullenrich/contact/enrich/bulk')) {
225
+ feCreateCalled = true
226
+ return new Response(JSON.stringify({ enrichment_id: 'abc-fe-123' }), { status: 200 })
227
+ }
228
+
229
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
230
+ }) as typeof fetch
231
+
232
+ const handlerPromise = enrichEmailsHandler({
233
+ people: [{ id: 'p1', first_name: 'Alice', last_name: 'Smith', domain: 'example.com' }],
234
+ })
235
+
236
+ await vi.runAllTimersAsync()
237
+ const result = await handlerPromise
238
+ vi.useRealTimers()
239
+
240
+ expect(feCreateCalled).toBe(true)
241
+ expect(fePollCalled).toBe(true)
242
+ const parsed = JSON.parse(result.content[0].text)
243
+ expect(parsed.data.results[0]).toEqual({ id: 'p1', email: 'alice@example.com', provider: 'fullenrich' })
244
+ })
245
+
246
+ it('gracefully handles prospeo bulk failure and still tries fallbacks', async () => {
247
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
248
+ const u = url.toString()
249
+ if (u.includes('/prospeo/bulk-enrich-person')) {
250
+ return new Response(JSON.stringify({ error: 'service unavailable' }), { status: 502 })
251
+ }
252
+ if (u.includes('/findymail/search/name')) {
253
+ return new Response(JSON.stringify({ email: 'alice@example.com' }), { status: 200 })
254
+ }
255
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
256
+ }) as typeof fetch
257
+
258
+ const result = await enrichEmailsHandler({
259
+ people: [{ id: 'p1', first_name: 'Alice', last_name: 'Smith', domain: 'example.com' }],
260
+ })
261
+
262
+ const parsed = JSON.parse(result.content[0].text)
263
+ expect(parsed.data.results[0]).toMatchObject({ email: 'alice@example.com', provider: 'findymail' })
264
+ })
265
+ })
266
+
267
+ interface EmailResult {
268
+ id: string
269
+ email: string | null
270
+ provider: string | null
271
+ }
@@ -0,0 +1,140 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { initClient } from '../../src/client.js'
3
+ import { enrichPersonHandler } from '../../src/tools/enrich-person.js'
4
+
5
+ describe('enrich_person 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('email input — linkupapi-email-reverse wins on first try', async () => {
17
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
18
+ const u = url.toString()
19
+ if (u.includes('/linkupapi/data/profil/enrich')) {
20
+ // profile-enrich not applicable for email-only, should not be called
21
+ throw new Error('profile-enrich should not fire for email-only input')
22
+ }
23
+ if (u.includes('/linkupapi/data/mail/reverse')) {
24
+ return new Response(JSON.stringify({ status: 'success', data: { linkedin_url: 'https://linkedin.com/in/michel-lieben', name: 'Michel Lieben' } }), { status: 200 })
25
+ }
26
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
27
+ }) as typeof fetch
28
+
29
+ const result = await enrichPersonHandler({ email: 'michel@coldiq.com' })
30
+
31
+ expect(result.isError).toBeFalsy()
32
+ const parsed = JSON.parse(result.content[0].text)
33
+ expect(parsed._meta.provider).toBe('linkupapi-email-reverse')
34
+ // linkupapi wraps: { status, data: { ... } }
35
+ expect(parsed.data.data.linkedin_url).toContain('linkedin.com')
36
+ })
37
+
38
+ it('email input — falls back to pdl-person-enrich when linkupapi-email-reverse returns empty', async () => {
39
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
40
+ const u = url.toString()
41
+ if (u.includes('/linkupapi/data/mail/reverse')) {
42
+ // hasResult fails: status not 'success'
43
+ return new Response(JSON.stringify({ status: 'not_found', data: null }), { status: 200 })
44
+ }
45
+ if (u.includes('/pdl/person/enrich')) {
46
+ return new Response(JSON.stringify({ status: 200, data: { full_name: 'Michel Lieben', linkedin_url: 'https://linkedin.com/in/michel-lieben' } }), { status: 200 })
47
+ }
48
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
49
+ }) as typeof fetch
50
+
51
+ const result = await enrichPersonHandler({ email: 'michel@coldiq.com' })
52
+
53
+ expect(result.isError).toBeFalsy()
54
+ const parsed = JSON.parse(result.content[0].text)
55
+ expect(parsed._meta.provider).toBe('pdl-person-enrich')
56
+ // PDL wraps: { status: 200, data: { ... } }
57
+ expect(parsed.data.data.full_name).toBe('Michel Lieben')
58
+ })
59
+
60
+ it('name+company input — linkupapi-profile-enrich wins (highest priority for this input type)', async () => {
61
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
62
+ const u = url.toString()
63
+ if (u.includes('/linkupapi/data/profil/enrich')) {
64
+ return new Response(JSON.stringify({ status: 'success', data: { linkedin_url: 'https://linkedin.com/in/michel-lieben', headline: 'CEO at ColdIQ' } }), { status: 200 })
65
+ }
66
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
67
+ }) as typeof fetch
68
+
69
+ const result = await enrichPersonHandler({ first_name: 'Michel', last_name: 'Lieben', company_name: 'ColdIQ' })
70
+
71
+ expect(result.isError).toBeFalsy()
72
+ const parsed = JSON.parse(result.content[0].text)
73
+ expect(parsed._meta.provider).toBe('linkupapi-profile-enrich')
74
+ // linkupapi wraps: { status, data: { ... } }
75
+ expect(parsed.data.data.headline).toBe('CEO at ColdIQ')
76
+ })
77
+
78
+ it('linkedin_url input — findymail-business-profile wins after non-applicable providers are skipped', async () => {
79
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
80
+ const u = url.toString()
81
+ // linkupapi-profile-enrich not applicable (no first_name)
82
+ // linkupapi-email-reverse not applicable (no email)
83
+ // pdl-person-enrich fires but returns no match
84
+ if (u.includes('/pdl/person/enrich')) {
85
+ return new Response(JSON.stringify({ status: 404, data: null }), { status: 200 })
86
+ }
87
+ // apollo-people-match fires but returns no person
88
+ if (u.includes('/apollo/people/match')) {
89
+ return new Response(JSON.stringify({ person: null }), { status: 200 })
90
+ }
91
+ // blitzapi-reverse-email not applicable (no email)
92
+ // findymail-business-profile: applicable (linkedin_url present)
93
+ if (u.includes('/findymail/search/business-profile')) {
94
+ return new Response(JSON.stringify({ name: 'Michel Lieben', email: 'michel@coldiq.com', linkedin_url: 'https://linkedin.com/in/michel-lieben' }), { status: 200 })
95
+ }
96
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
97
+ }) as typeof fetch
98
+
99
+ const result = await enrichPersonHandler({ linkedin_url: 'https://www.linkedin.com/in/michel-lieben' })
100
+
101
+ expect(result.isError).toBeFalsy()
102
+ const parsed = JSON.parse(result.content[0].text)
103
+ expect(parsed._meta.provider).toBe('findymail-business-profile')
104
+ expect(parsed.data.name).toBe('Michel Lieben')
105
+ })
106
+
107
+ it('phone input — ai-ark-reverse-lookup wins (gated on phone present)', async () => {
108
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
109
+ const u = url.toString()
110
+ if (u.includes('/pdl/person/enrich')) {
111
+ return new Response(JSON.stringify({ status: 404, data: null }), { status: 200 })
112
+ }
113
+ if (u.includes('/apollo/people/match')) {
114
+ return new Response(JSON.stringify({ person: null }), { status: 200 })
115
+ }
116
+ if (u.includes('/ai-ark/people/reverse-lookup')) {
117
+ return new Response(JSON.stringify({ name: 'Michel Lieben', email: 'michel@coldiq.com' }), { status: 200 })
118
+ }
119
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
120
+ }) as typeof fetch
121
+
122
+ const result = await enrichPersonHandler({ phone: '+32123456789' })
123
+
124
+ expect(result.isError).toBeFalsy()
125
+ const parsed = JSON.parse(result.content[0].text)
126
+ expect(parsed._meta.provider).toBe('ai-ark-reverse-lookup')
127
+ })
128
+
129
+ it('all providers fail — returns isError with error object', async () => {
130
+ globalThis.fetch = vi.fn(async () => {
131
+ return new Response(JSON.stringify({ error: 'Provider unavailable' }), { status: 503 })
132
+ }) as typeof fetch
133
+
134
+ const result = await enrichPersonHandler({ email: 'michel@coldiq.com' })
135
+
136
+ expect(result.isError).toBe(true)
137
+ const parsed = JSON.parse(result.content[0].text)
138
+ expect(parsed.error).toBeTruthy()
139
+ })
140
+ })