@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,108 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { initClient } from '../../src/client.js'
3
+ import { fetchPageContentHandler } from '../../src/tools/fetch-page-content.js'
4
+
5
+ describe('fetch_page_content 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('single URL — exa-contents returns results', async () => {
17
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
18
+ const u = url.toString()
19
+ if (u.includes('/exa/contents')) {
20
+ return new Response(JSON.stringify({
21
+ results: [{ url: 'https://coldiq.com', text: 'ColdIQ page text' }],
22
+ }), { status: 200 })
23
+ }
24
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
25
+ }) as typeof fetch
26
+
27
+ const result = await fetchPageContentHandler({ urls: ['https://coldiq.com'] })
28
+
29
+ expect(result.isError).toBeFalsy()
30
+ const parsed = JSON.parse(result.content[0].text)
31
+ expect(parsed._meta.provider).toBe('exa-contents')
32
+ expect(parsed.data.results).toHaveLength(1)
33
+ expect(parsed.data.results[0].text).toBe('ColdIQ page text')
34
+ })
35
+
36
+ it('batch URLs — all returned in one call', async () => {
37
+ globalThis.fetch = vi.fn(async (url: string | URL | Request, opts?: RequestInit) => {
38
+ const u = url.toString()
39
+ if (u.includes('/exa/contents')) {
40
+ const body = JSON.parse((opts?.body as string) ?? '{}')
41
+ return new Response(JSON.stringify({
42
+ results: body.urls.map((pageUrl: string) => ({ url: pageUrl, text: `text for ${pageUrl}` })),
43
+ }), { status: 200 })
44
+ }
45
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
46
+ }) as typeof fetch
47
+
48
+ const urls = ['https://coldiq.com', 'https://hubspot.com', 'https://salesforce.com']
49
+ const result = await fetchPageContentHandler({ urls })
50
+
51
+ expect(result.isError).toBeFalsy()
52
+ const parsed = JSON.parse(result.content[0].text)
53
+ expect(parsed._meta.provider).toBe('exa-contents')
54
+ expect(parsed.data.results).toHaveLength(3)
55
+ })
56
+
57
+ it('include_summary=true — summary flag forwarded to exa', async () => {
58
+ let capturedBody: Record<string, unknown> | null = null
59
+
60
+ globalThis.fetch = vi.fn(async (url: string | URL | Request, opts?: RequestInit) => {
61
+ const u = url.toString()
62
+ if (u.includes('/exa/contents')) {
63
+ capturedBody = JSON.parse((opts?.body as string) ?? '{}')
64
+ return new Response(JSON.stringify({
65
+ results: [{ url: 'https://coldiq.com', text: 'text', summary: 'summary text' }],
66
+ }), { status: 200 })
67
+ }
68
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
69
+ }) as typeof fetch
70
+
71
+ await fetchPageContentHandler({ urls: ['https://coldiq.com'], include_summary: true })
72
+
73
+ expect(capturedBody).not.toBeNull()
74
+ expect(capturedBody!.summary).toEqual({})
75
+ })
76
+
77
+ it('include_summary=false (default) — no summary field in request', async () => {
78
+ let capturedBody: Record<string, unknown> | null = null
79
+
80
+ globalThis.fetch = vi.fn(async (url: string | URL | Request, opts?: RequestInit) => {
81
+ const u = url.toString()
82
+ if (u.includes('/exa/contents')) {
83
+ capturedBody = JSON.parse((opts?.body as string) ?? '{}')
84
+ return new Response(JSON.stringify({
85
+ results: [{ url: 'https://coldiq.com', text: 'text' }],
86
+ }), { status: 200 })
87
+ }
88
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
89
+ }) as typeof fetch
90
+
91
+ await fetchPageContentHandler({ urls: ['https://coldiq.com'] })
92
+
93
+ expect(capturedBody).not.toBeNull()
94
+ expect(capturedBody!.summary).toBeUndefined()
95
+ })
96
+
97
+ it('provider fails — returns isError', async () => {
98
+ globalThis.fetch = vi.fn(async () => {
99
+ return new Response(JSON.stringify({ error: 'upstream error' }), { status: 502 })
100
+ }) as typeof fetch
101
+
102
+ const result = await fetchPageContentHandler({ urls: ['https://coldiq.com'] })
103
+
104
+ expect(result.isError).toBe(true)
105
+ const parsed = JSON.parse(result.content[0].text)
106
+ expect(parsed.error).toBeTruthy()
107
+ })
108
+ })
@@ -0,0 +1,91 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { initClient } from '../../src/client.js'
3
+ import { findInfluencersHandler } from '../../src/tools/find-influencers.js'
4
+
5
+ describe('find_influencers 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('handle set — influencers_similar fires first (lookalike route)', async () => {
17
+ let discoveryCalled = false
18
+
19
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
20
+ const u = url.toString()
21
+ if (u.includes('/influencers-club/discovery') && !u.includes('/creators/similar')) {
22
+ discoveryCalled = true
23
+ }
24
+ if (u.includes('/influencers-club/discovery/creators/similar')) {
25
+ return new Response(JSON.stringify({
26
+ creators: [{ handle: 'garyvee', platform: 'instagram', followers: 8_500_000 }],
27
+ }), { status: 200 })
28
+ }
29
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
30
+ }) as typeof fetch
31
+
32
+ const result = await findInfluencersHandler({ platform: 'instagram', handle: 'hubspot', limit: 5 })
33
+
34
+ expect(result.isError).toBeFalsy()
35
+ const parsed = JSON.parse(result.content[0].text)
36
+ expect(parsed._meta.provider).toBe('influencers_similar')
37
+ expect(parsed.data.creators).toHaveLength(1)
38
+ expect(discoveryCalled).toBe(false)
39
+ })
40
+
41
+ it('no handle — influencers_similar skipped, influencers_discovery fires', async () => {
42
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
43
+ const u = url.toString()
44
+ if (u.includes('/influencers-club/discovery') && !u.includes('/creators/similar')) {
45
+ return new Response(JSON.stringify({
46
+ accounts: [{ handle: 'salesforce', platform: 'linkedin', followers: 1_200_000 }],
47
+ }), { status: 200 })
48
+ }
49
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
50
+ }) as typeof fetch
51
+
52
+ const result = await findInfluencersHandler({ platform: 'linkedin', ai_search: 'B2B SaaS founders', limit: 5 })
53
+
54
+ expect(result.isError).toBeFalsy()
55
+ const parsed = JSON.parse(result.content[0].text)
56
+ expect(parsed._meta.provider).toBe('influencers_discovery')
57
+ expect(parsed.data.accounts).toHaveLength(1)
58
+ })
59
+
60
+ it('handle set but similar returns empty — falls back to influencers_discovery', async () => {
61
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
62
+ const u = url.toString()
63
+ if (u.includes('/influencers-club/discovery/creators/similar')) {
64
+ // hasResult fails: no creators/accounts array
65
+ return new Response(JSON.stringify({ creators: [] }), { status: 200 })
66
+ }
67
+ if (u.includes('/influencers-club/discovery') && !u.includes('/creators/similar')) {
68
+ return new Response(JSON.stringify({ accounts: [{ handle: 'techinfluencer' }] }), { status: 200 })
69
+ }
70
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
71
+ }) as typeof fetch
72
+
73
+ const result = await findInfluencersHandler({ platform: 'instagram', handle: 'unknown_handle', limit: 5 })
74
+
75
+ expect(result.isError).toBeFalsy()
76
+ const parsed = JSON.parse(result.content[0].text)
77
+ expect(parsed._meta.provider).toBe('influencers_discovery')
78
+ })
79
+
80
+ it('all providers fail — returns isError', async () => {
81
+ globalThis.fetch = vi.fn(async () => {
82
+ return new Response(JSON.stringify({ error: 'down' }), { status: 503 })
83
+ }) as typeof fetch
84
+
85
+ const result = await findInfluencersHandler({ platform: 'instagram', limit: 5 })
86
+
87
+ expect(result.isError).toBe(true)
88
+ const parsed = JSON.parse(result.content[0].text)
89
+ expect(parsed.error).toBeTruthy()
90
+ })
91
+ })
@@ -0,0 +1,344 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { initClient } from '../../src/client.js'
3
+ import { findPeopleHandler } from '../../src/tools/find-people.js'
4
+
5
+ describe('find_people 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('falls back to Apollo when LeadsFactory async times out', async () => {
17
+ let callCount = 0
18
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
19
+ callCount++
20
+ const urlStr = url.toString()
21
+
22
+ // LeadsFactory create search — returns an ID
23
+ if (urlStr.includes('/leadsfactory/contact-finder/searches') && callCount === 1) {
24
+ return new Response(JSON.stringify({ _id: 'abc123', search_id: 'abc', nb_jobs_total: 1, progress_percentage: 0 }), { status: 201 })
25
+ }
26
+
27
+ // LeadsFactory poll — always RUNNING (will timeout)
28
+ if (urlStr.includes('/leadsfactory/contact-finder/searches/abc123')) {
29
+ return new Response(JSON.stringify({ id: 'abc123', search_id: 'abc', status: 'RUNNING', nb_jobs_total: 1, progress_percentage: 50 }), { status: 200 })
30
+ }
31
+
32
+ // Apollo — succeeds
33
+ if (urlStr.includes('/apollo/people/search')) {
34
+ return new Response(JSON.stringify({ people: [{ name: 'Michel Lieben', title: 'CEO' }] }), { status: 200 })
35
+ }
36
+
37
+ return new Response(JSON.stringify({ error: 'Not found' }), { status: 404 })
38
+ }) as typeof fetch
39
+
40
+ // Override timeout to 100ms so test runs fast
41
+ const { getProviders } = await import('../../src/registry.js')
42
+ const providers = getProviders('find_people')
43
+ const lf = providers.find((p) => p.id === 'leadsfactory')!
44
+ const originalTimeout = lf.async!.timeoutMs
45
+ const originalInterval = lf.async!.pollIntervalMs
46
+ lf.async!.timeoutMs = 100
47
+ lf.async!.pollIntervalMs = 20
48
+
49
+ try {
50
+ const result = await findPeopleHandler({
51
+ company_domains: ['coldiq.com'],
52
+ job_titles: ['CEO'],
53
+ limit: 5,
54
+ })
55
+
56
+ const parsed = JSON.parse(result.content[0].text)
57
+ // Should fall back to Apollo since LeadsFactory times out
58
+ expect(parsed._meta.provider).toBe('apollo')
59
+ } finally {
60
+ lf.async!.timeoutMs = originalTimeout
61
+ lf.async!.pollIntervalMs = originalInterval
62
+ }
63
+ })
64
+
65
+ it('returns LeadsFactory results when poll completes with companies_personas', async () => {
66
+ let callCount = 0
67
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
68
+ callCount++
69
+ const urlStr = url.toString()
70
+
71
+ // LeadsFactory create search
72
+ if (urlStr.includes('/leadsfactory/contact-finder/searches') && !urlStr.includes('abc123')) {
73
+ return new Response(JSON.stringify({ _id: 'abc123', search_id: 'abc', nb_jobs_total: 1, progress_percentage: 0 }), { status: 201 })
74
+ }
75
+
76
+ // LeadsFactory poll — first RUNNING, then SUCCESSFUL with contacts
77
+ if (urlStr.includes('/leadsfactory/contact-finder/searches/abc123')) {
78
+ if (callCount <= 3) {
79
+ return new Response(JSON.stringify({ id: 'abc123', search_id: 'abc', status: 'RUNNING', nb_jobs_total: 1, progress_percentage: 50 }), { status: 200 })
80
+ }
81
+ return new Response(JSON.stringify({
82
+ id: 'abc123',
83
+ search_id: 'abc',
84
+ status: 'SUCCESSFUL',
85
+ nb_jobs_total: 1,
86
+ nb_jobs_complete: 1,
87
+ companies_personas: [{ company: 'ColdIQ', contacts: [{ first_name: 'Michel', last_name: 'Lieben', title: 'CEO' }] }],
88
+ }), { status: 200 })
89
+ }
90
+
91
+ return new Response(JSON.stringify({ error: 'Not found' }), { status: 404 })
92
+ }) as typeof fetch
93
+
94
+ const { getProviders } = await import('../../src/registry.js')
95
+ const providers = getProviders('find_people')
96
+ const lf = providers.find((p) => p.id === 'leadsfactory')!
97
+ const originalTimeout = lf.async!.timeoutMs
98
+ const originalInterval = lf.async!.pollIntervalMs
99
+ lf.async!.timeoutMs = 500
100
+ lf.async!.pollIntervalMs = 20
101
+
102
+ try {
103
+ const result = await findPeopleHandler({
104
+ company_domains: ['coldiq.com'],
105
+ job_titles: ['CEO'],
106
+ limit: 5,
107
+ })
108
+
109
+ const parsed = JSON.parse(result.content[0].text)
110
+ expect(parsed._meta.provider).toBe('leadsfactory')
111
+ expect(parsed.data.companies_personas).toHaveLength(1)
112
+ } finally {
113
+ lf.async!.timeoutMs = originalTimeout
114
+ lf.async!.pollIntervalMs = originalInterval
115
+ }
116
+ })
117
+
118
+ it('gap-fills with Apollo for domains LeadsFactory missed (no_results_domains)', async () => {
119
+ globalThis.fetch = vi.fn(async (url: string | URL | Request, opts?: RequestInit) => {
120
+ const urlStr = url.toString()
121
+
122
+ // LeadsFactory create
123
+ if (urlStr.includes('/leadsfactory/contact-finder/searches') && !urlStr.includes('abc123')) {
124
+ return new Response(JSON.stringify({ _id: 'abc123', search_id: 'abc', nb_jobs_total: 2, progress_percentage: 0 }), { status: 201 })
125
+ }
126
+
127
+ // LeadsFactory poll — SUCCESSFUL with one miss
128
+ if (urlStr.includes('/leadsfactory/contact-finder/searches/abc123')) {
129
+ return new Response(JSON.stringify({
130
+ id: 'abc123', search_id: 'abc', status: 'SUCCESSFUL',
131
+ nb_jobs_total: 2, nb_jobs_complete: 2,
132
+ companies_personas: [{ company: 'ColdIQ', contacts: [{ first_name: 'Michel', last_name: 'Lieben' }] }],
133
+ no_results_domains: ['folk.app'],
134
+ }), { status: 200 })
135
+ }
136
+
137
+ // Apollo gap-fill for folk.app
138
+ if (urlStr.includes('/apollo/people/search')) {
139
+ const body = JSON.parse((opts?.body as string) ?? '{}')
140
+ if (body.q_organization_domains?.includes('folk.app')) {
141
+ return new Response(JSON.stringify({ people: [{ name: 'Thibaud Elziere', title: 'CEO' }] }), { status: 200 })
142
+ }
143
+ }
144
+
145
+ return new Response(JSON.stringify({ error: 'Not found' }), { status: 404 })
146
+ }) as typeof fetch
147
+
148
+ const { getProviders } = await import('../../src/registry.js')
149
+ const providers = getProviders('find_people')
150
+ const lf = providers.find((p) => p.id === 'leadsfactory')!
151
+ const originalTimeout = lf.async!.timeoutMs
152
+ const originalInterval = lf.async!.pollIntervalMs
153
+ lf.async!.timeoutMs = 500
154
+ lf.async!.pollIntervalMs = 20
155
+
156
+ try {
157
+ const result = await findPeopleHandler({
158
+ company_domains: ['coldiq.com', 'folk.app'],
159
+ job_titles: ['CEO'],
160
+ limit: 2,
161
+ })
162
+
163
+ const parsed = JSON.parse(result.content[0].text)
164
+ expect(parsed._meta.provider).toBe('leadsfactory')
165
+ expect(parsed.data.companies_personas).toHaveLength(1)
166
+ expect(parsed.data.gap_fill.provider).toBe('apollo')
167
+ expect(parsed.data.gap_fill.domains).toEqual(['folk.app'])
168
+ expect(parsed.data.gap_fill.people).toHaveLength(1)
169
+ } finally {
170
+ lf.async!.timeoutMs = originalTimeout
171
+ lf.async!.pollIntervalMs = originalInterval
172
+ }
173
+ })
174
+
175
+ it('skips gap-fill when input used LinkedIn URLs (no trusted domains to pass Apollo)', async () => {
176
+ let apolloCalled = false
177
+
178
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
179
+ const urlStr = url.toString()
180
+
181
+ if (urlStr.includes('/leadsfactory/contact-finder/searches') && !urlStr.includes('abc123')) {
182
+ return new Response(JSON.stringify({ _id: 'abc123', nb_jobs_total: 2, progress_percentage: 0 }), { status: 201 })
183
+ }
184
+
185
+ if (urlStr.includes('/leadsfactory/contact-finder/searches/abc123')) {
186
+ return new Response(JSON.stringify({
187
+ id: 'abc123', status: 'SUCCESSFUL', nb_jobs_total: 2, nb_jobs_complete: 2,
188
+ companies_personas: [{ company: 'ColdIQ', contacts: [{ first_name: 'Michel' }] }],
189
+ no_results_domains: ['folk.app'],
190
+ }), { status: 200 })
191
+ }
192
+
193
+ if (urlStr.includes('/apollo/people/search')) {
194
+ apolloCalled = true
195
+ return new Response(JSON.stringify({ people: [{ name: 'Thibaud Elziere' }] }), { status: 200 })
196
+ }
197
+
198
+ return new Response(JSON.stringify({ error: 'Not found' }), { status: 404 })
199
+ }) as typeof fetch
200
+
201
+ const { getProviders } = await import('../../src/registry.js')
202
+ const providers = getProviders('find_people')
203
+ const lf = providers.find((p) => p.id === 'leadsfactory')!
204
+ const originalTimeout = lf.async!.timeoutMs
205
+ const originalInterval = lf.async!.pollIntervalMs
206
+ lf.async!.timeoutMs = 500
207
+ lf.async!.pollIntervalMs = 20
208
+
209
+ try {
210
+ const result = await findPeopleHandler({
211
+ company_linkedin_urls: ['https://linkedin.com/company/coldiq', 'https://linkedin.com/company/folk'],
212
+ job_titles: ['CEO'],
213
+ limit: 2,
214
+ })
215
+
216
+ const parsed = JSON.parse(result.content[0].text)
217
+ expect(parsed._meta.provider).toBe('leadsfactory')
218
+ expect(parsed.data.gap_fill).toBeUndefined()
219
+ expect(apolloCalled).toBe(false)
220
+ } finally {
221
+ lf.async!.timeoutMs = originalTimeout
222
+ lf.async!.pollIntervalMs = originalInterval
223
+ }
224
+ })
225
+
226
+ it('does not pass company_domains to LeadsFactory when LinkedIn URLs are provided', async () => {
227
+ let capturedBody: Record<string, unknown> | null = null
228
+
229
+ globalThis.fetch = vi.fn(async (url: string | URL | Request, opts?: RequestInit) => {
230
+ const urlStr = url.toString()
231
+
232
+ if (urlStr.includes('/leadsfactory/contact-finder/searches') && !urlStr.includes('abc123')) {
233
+ capturedBody = JSON.parse((opts?.body as string) ?? '{}')
234
+ return new Response(JSON.stringify({ _id: 'abc123', nb_jobs_total: 2, progress_percentage: 0 }), { status: 201 })
235
+ }
236
+
237
+ if (urlStr.includes('/leadsfactory/contact-finder/searches/abc123')) {
238
+ return new Response(JSON.stringify({
239
+ id: 'abc123', status: 'SUCCESSFUL', nb_jobs_total: 2, nb_jobs_complete: 2,
240
+ companies_personas: [{ company: 'ColdIQ', contacts: [{ first_name: 'Michel' }] }],
241
+ }), { status: 200 })
242
+ }
243
+
244
+ return new Response(JSON.stringify({ error: 'Not found' }), { status: 404 })
245
+ }) as typeof fetch
246
+
247
+ const { getProviders } = await import('../../src/registry.js')
248
+ const providers = getProviders('find_people')
249
+ const lf = providers.find((p) => p.id === 'leadsfactory')!
250
+ const originalTimeout = lf.async!.timeoutMs
251
+ const originalInterval = lf.async!.pollIntervalMs
252
+ lf.async!.timeoutMs = 500
253
+ lf.async!.pollIntervalMs = 20
254
+
255
+ try {
256
+ await findPeopleHandler({
257
+ company_linkedin_urls: ['https://linkedin.com/company/coldiq', 'https://linkedin.com/company/folk'],
258
+ company_domains: ['coldiq.com', 'folk.app'],
259
+ job_titles: ['CEO'],
260
+ limit: 2,
261
+ })
262
+
263
+ expect(capturedBody).not.toBeNull()
264
+ expect(capturedBody!.company_linkedin_urls).toBeDefined()
265
+ expect(capturedBody!.company_domains).toBeUndefined()
266
+ } finally {
267
+ lf.async!.timeoutMs = originalTimeout
268
+ lf.async!.pollIntervalMs = originalInterval
269
+ }
270
+ })
271
+
272
+ it('sends each job title as a separate persona to LeadsFactory', async () => {
273
+ let capturedBody: Record<string, unknown> | null = null
274
+
275
+ globalThis.fetch = vi.fn(async (url: string | URL | Request, opts?: RequestInit) => {
276
+ const urlStr = url.toString()
277
+
278
+ if (urlStr.includes('/leadsfactory/contact-finder/searches') && !urlStr.includes('abc123')) {
279
+ capturedBody = JSON.parse((opts?.body as string) ?? '{}')
280
+ return new Response(JSON.stringify({ _id: 'abc123', nb_jobs_total: 1, progress_percentage: 0 }), { status: 201 })
281
+ }
282
+
283
+ if (urlStr.includes('/leadsfactory/contact-finder/searches/abc123')) {
284
+ return new Response(JSON.stringify({
285
+ id: 'abc123', status: 'SUCCESSFUL', nb_jobs_total: 1, nb_jobs_complete: 1,
286
+ companies_personas: [{ company: 'ColdIQ', contacts: [{ first_name: 'Michel' }] }],
287
+ }), { status: 200 })
288
+ }
289
+
290
+ return new Response(JSON.stringify({ error: 'Not found' }), { status: 404 })
291
+ }) as typeof fetch
292
+
293
+ const { getProviders } = await import('../../src/registry.js')
294
+ const providers = getProviders('find_people')
295
+ const lf = providers.find((p) => p.id === 'leadsfactory')!
296
+ const originalTimeout = lf.async!.timeoutMs
297
+ const originalInterval = lf.async!.pollIntervalMs
298
+ lf.async!.timeoutMs = 500
299
+ lf.async!.pollIntervalMs = 20
300
+
301
+ try {
302
+ await findPeopleHandler({
303
+ company_domains: ['coldiq.com'],
304
+ job_titles: ['CEO', 'VP of Sales'],
305
+ limit: 10,
306
+ })
307
+
308
+ expect(capturedBody).not.toBeNull()
309
+ expect(capturedBody!.search).toMatchObject({
310
+ max_persona_results: 10,
311
+ personas: [{ job_title: 'CEO' }, { job_title: 'VP of Sales' }],
312
+ })
313
+ } finally {
314
+ lf.async!.timeoutMs = originalTimeout
315
+ lf.async!.pollIntervalMs = originalInterval
316
+ }
317
+ })
318
+
319
+ it('falls back to Apollo when LeadsFactory fails', async () => {
320
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
321
+ const urlStr = url.toString()
322
+
323
+ if (urlStr.includes('/leadsfactory/')) {
324
+ return new Response(JSON.stringify({ error: 'Provider error' }), { status: 502 })
325
+ }
326
+
327
+ if (urlStr.includes('/apollo/people/search')) {
328
+ return new Response(JSON.stringify({ people: [{ name: 'Michel Lieben' }] }), { status: 200 })
329
+ }
330
+
331
+ return new Response(JSON.stringify({ error: 'Not found' }), { status: 404 })
332
+ }) as typeof fetch
333
+
334
+ const result = await findPeopleHandler({
335
+ company_domains: ['coldiq.com'],
336
+ job_titles: ['CEO'],
337
+ limit: 5,
338
+ })
339
+
340
+ const parsed = JSON.parse(result.content[0].text)
341
+ expect(parsed._meta.provider).toBe('apollo')
342
+ expect(parsed.data.people).toHaveLength(1)
343
+ })
344
+ })
@@ -0,0 +1,100 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { initClient } from '../../src/client.js'
3
+ import { findPhoneHandler } from '../../src/tools/find-phone.js'
4
+
5
+ describe('find_phone 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 phone from FindyMail', async () => {
17
+ globalThis.fetch = vi.fn(async () =>
18
+ new Response(JSON.stringify({ phone: '+33612345678' }), { status: 200 })
19
+ ) as typeof fetch
20
+
21
+ const result = await findPhoneHandler({ linkedin_url: 'https://linkedin.com/in/michel-lieben' })
22
+
23
+ const parsed = JSON.parse(result.content[0].text)
24
+ expect(parsed._meta.provider).toBe('findymail')
25
+ expect(parsed.data.phone).toBe('+33612345678')
26
+ })
27
+
28
+ it('falls through to LimaData when Findymail misses', async () => {
29
+ let callCount = 0
30
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
31
+ callCount++
32
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.href : url.url
33
+ if (urlStr.includes('findymail')) return new Response(JSON.stringify({}), { status: 200 })
34
+ if (urlStr.includes('coldiq/find/phone')) return new Response(JSON.stringify({ phone: '+33698765432' }), { status: 200 })
35
+ return new Response(JSON.stringify({}), { status: 200 })
36
+ }) as typeof fetch
37
+
38
+ const result = await findPhoneHandler({ linkedin_url: 'https://linkedin.com/in/michel-lieben' })
39
+
40
+ const parsed = JSON.parse(result.content[0].text)
41
+ expect(result.isError).toBeFalsy()
42
+ expect(parsed._meta.provider).toBe('limadata')
43
+ expect(parsed.data.phone).toBe('+33698765432')
44
+ })
45
+
46
+ it('uses AI Ark when Findymail and LimaData both miss', async () => {
47
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
48
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.href : url.url
49
+ if (urlStr.includes('findymail')) return new Response(JSON.stringify({}), { status: 200 })
50
+ if (urlStr.includes('coldiq/find/phone')) return new Response(JSON.stringify({}), { status: 200 })
51
+ if (urlStr.includes('mobile-phone-finder')) return new Response(JSON.stringify({ data: [['+33611223344']] }), { status: 200 })
52
+ return new Response(JSON.stringify({}), { status: 200 })
53
+ }) as typeof fetch
54
+
55
+ const result = await findPhoneHandler({ linkedin_url: 'https://linkedin.com/in/michel-lieben' })
56
+
57
+ const parsed = JSON.parse(result.content[0].text)
58
+ expect(result.isError).toBeFalsy()
59
+ expect(parsed._meta.provider).toBe('ai-ark')
60
+ expect(parsed.data.data).toEqual([['+33611223344']])
61
+ })
62
+
63
+ it('uses LimaData and AI Ark when linkedin_url absent (name+domain path)', async () => {
64
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
65
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.href : url.url
66
+ if (urlStr.includes('coldiq/find/phone')) return new Response(JSON.stringify({}), { status: 200 })
67
+ if (urlStr.includes('mobile-phone-finder')) return new Response(JSON.stringify({ data: [['+33611223344']] }), { status: 200 })
68
+ return new Response(JSON.stringify({}), { status: 200 })
69
+ }) as typeof fetch
70
+
71
+ const result = await findPhoneHandler({
72
+ first_name: 'Michel',
73
+ last_name: 'Lieben',
74
+ company_domain: 'coldiq.com',
75
+ })
76
+
77
+ const parsed = JSON.parse(result.content[0].text)
78
+ expect(result.isError).toBeFalsy()
79
+ expect(parsed._meta.provider).toBe('ai-ark')
80
+ })
81
+
82
+ it('returns error with clear message when no valid input combo provided', async () => {
83
+ const result = await findPhoneHandler({ company_name: 'ColdIQ' })
84
+
85
+ expect(result.isError).toBe(true)
86
+ const parsed = JSON.parse(result.content[0].text)
87
+ expect(parsed.error).toContain('linkedin_url')
88
+ expect(parsed.error).toContain('first_name')
89
+ })
90
+
91
+ it('returns error when no provider finds a phone', async () => {
92
+ globalThis.fetch = vi.fn(async () =>
93
+ new Response(JSON.stringify({ error: 'Not found' }), { status: 404 })
94
+ ) as typeof fetch
95
+
96
+ const result = await findPhoneHandler({ linkedin_url: 'https://linkedin.com/in/unknown' })
97
+
98
+ expect(result.isError).toBe(true)
99
+ })
100
+ })