@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,125 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { initClient } from '../../src/client.js'
3
+ import { searchRedditHandler } from '../../src/tools/search-reddit.js'
4
+
5
+ describe('search_reddit 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('query search — reddit provider creates async job and polls to completion', async () => {
17
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
18
+ const u = url.toString()
19
+ if (u.includes('/reddit/scrape') && !u.includes('/scrape/')) {
20
+ return new Response(JSON.stringify({ jobId: 'r-1' }), { status: 200 })
21
+ }
22
+ if (u.includes('/reddit/scrape/r-1')) {
23
+ return new Response(JSON.stringify({
24
+ jobId: 'r-1',
25
+ status: 'done',
26
+ items: [{ title: 'Best CRM for startups', subreddit: 'r/sales', score: 342 }],
27
+ }), { status: 200 })
28
+ }
29
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
30
+ }) as typeof fetch
31
+
32
+ const { getProviders } = await import('../../src/registry.js')
33
+ const providers = getProviders('search_reddit')
34
+ const reddit = providers.find((p) => p.id === 'reddit')!
35
+ const orig = { t: reddit.async!.timeoutMs, i: reddit.async!.pollIntervalMs }
36
+ reddit.async!.timeoutMs = 500
37
+ reddit.async!.pollIntervalMs = 20
38
+
39
+ try {
40
+ const result = await searchRedditHandler({ start_urls: ['https://www.reddit.com/r/sales/'], query: 'best CRM for startups', limit: 5 })
41
+
42
+ expect(result.isError).toBeFalsy()
43
+ const parsed = JSON.parse(result.content[0].text)
44
+ expect(parsed._meta.provider).toBe('reddit')
45
+ expect(parsed.data.items).toHaveLength(1)
46
+ } finally {
47
+ reddit.async!.timeoutMs = orig.t
48
+ reddit.async!.pollIntervalMs = orig.i
49
+ }
50
+ })
51
+
52
+ it('start_urls scrape — query is omitted from request body', async () => {
53
+ let capturedBody: Record<string, unknown> | null = null
54
+
55
+ globalThis.fetch = vi.fn(async (url: string | URL | Request, opts?: RequestInit) => {
56
+ const u = url.toString()
57
+ if (u.includes('/reddit/scrape') && !u.includes('/scrape/')) {
58
+ capturedBody = JSON.parse((opts?.body as string) ?? '{}')
59
+ return new Response(JSON.stringify({ jobId: 'r-2' }), { status: 200 })
60
+ }
61
+ if (u.includes('/reddit/scrape/r-2')) {
62
+ return new Response(JSON.stringify({
63
+ jobId: 'r-2',
64
+ status: 'done',
65
+ items: [{ title: 'Reddit post', subreddit: 'r/entrepreneur' }],
66
+ }), { status: 200 })
67
+ }
68
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
69
+ }) as typeof fetch
70
+
71
+ const { getProviders } = await import('../../src/registry.js')
72
+ const providers = getProviders('search_reddit')
73
+ const reddit = providers.find((p) => p.id === 'reddit')!
74
+ const orig = { t: reddit.async!.timeoutMs, i: reddit.async!.pollIntervalMs }
75
+ reddit.async!.timeoutMs = 500
76
+ reddit.async!.pollIntervalMs = 20
77
+
78
+ try {
79
+ await searchRedditHandler({
80
+ start_urls: ['https://www.reddit.com/r/entrepreneur/'],
81
+ limit: 5,
82
+ })
83
+
84
+ expect(capturedBody).not.toBeNull()
85
+ // searchQueries is undefined when no query field provided
86
+ expect(capturedBody!.searchQueries).toBeUndefined()
87
+ expect(capturedBody!.startUrls).toEqual([{ url: 'https://www.reddit.com/r/entrepreneur/' }])
88
+ } finally {
89
+ reddit.async!.timeoutMs = orig.t
90
+ reddit.async!.pollIntervalMs = orig.i
91
+ }
92
+ })
93
+
94
+ it('async timeout — returns isError', async () => {
95
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
96
+ const u = url.toString()
97
+ if (u.includes('/reddit/scrape') && !u.includes('/scrape/')) {
98
+ return new Response(JSON.stringify({ jobId: 'r-3' }), { status: 200 })
99
+ }
100
+ // Always running — never completes
101
+ if (u.includes('/reddit/scrape/r-3')) {
102
+ return new Response(JSON.stringify({ jobId: 'r-3', status: 'running' }), { status: 200 })
103
+ }
104
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
105
+ }) as typeof fetch
106
+
107
+ const { getProviders } = await import('../../src/registry.js')
108
+ const providers = getProviders('search_reddit')
109
+ const reddit = providers.find((p) => p.id === 'reddit')!
110
+ const orig = { t: reddit.async!.timeoutMs, i: reddit.async!.pollIntervalMs }
111
+ reddit.async!.timeoutMs = 100
112
+ reddit.async!.pollIntervalMs = 20
113
+
114
+ try {
115
+ const result = await searchRedditHandler({ start_urls: ['https://www.reddit.com/r/sales/'], query: 'cold email', limit: 5 })
116
+
117
+ expect(result.isError).toBe(true)
118
+ const parsed = JSON.parse(result.content[0].text)
119
+ expect(parsed.error).toBeTruthy()
120
+ } finally {
121
+ reddit.async!.timeoutMs = orig.t
122
+ reddit.async!.pollIntervalMs = orig.i
123
+ }
124
+ })
125
+ })
@@ -0,0 +1,183 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { initClient } from '../../src/client.js'
3
+ import { searchSeoHandler } from '../../src/tools/search-seo.js'
4
+
5
+ describe('search_seo 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('category=keywords — kw_search_volume fires first', async () => {
17
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
18
+ const u = url.toString()
19
+ if (u.includes('/dataforseo/keywords/google-ads/search-volume')) {
20
+ return new Response(JSON.stringify({ tasks: [{ result: [{ keyword: 'cold email', search_volume: 5400 }] }] }), { status: 200 })
21
+ }
22
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
23
+ }) as typeof fetch
24
+
25
+ const result = await searchSeoHandler({ category: 'keywords', keywords: ['cold email'] })
26
+
27
+ expect(result.isError).toBeFalsy()
28
+ const parsed = JSON.parse(result.content[0].text)
29
+ expect(parsed._meta.provider).toBe('kw_search_volume')
30
+ })
31
+
32
+ it('category=serp (google engine default) — serp_google fires', async () => {
33
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
34
+ const u = url.toString()
35
+ if (u.includes('/dataforseo/serp/google/organic')) {
36
+ return new Response(JSON.stringify({ tasks: [{ result: [{ items: [{ title: 'ColdIQ', url: 'https://coldiq.com' }] }] }] }), { status: 200 })
37
+ }
38
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
39
+ }) as typeof fetch
40
+
41
+ const result = await searchSeoHandler({ category: 'serp', keyword: 'cold email tool' })
42
+
43
+ expect(result.isError).toBeFalsy()
44
+ const parsed = JSON.parse(result.content[0].text)
45
+ expect(parsed._meta.provider).toBe('serp_google')
46
+ })
47
+
48
+ it('category=serp engine=bing — serp_bing fires, serp_google skipped', async () => {
49
+ let googleCalled = false
50
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
51
+ const u = url.toString()
52
+ if (u.includes('/dataforseo/serp/google/organic')) { googleCalled = true }
53
+ if (u.includes('/dataforseo/serp/bing/organic')) {
54
+ return new Response(JSON.stringify({ tasks: [{ result: [{ items: [{ title: 'Bing result' }] }] }] }), { status: 200 })
55
+ }
56
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
57
+ }) as typeof fetch
58
+
59
+ const result = await searchSeoHandler({ category: 'serp', keyword: 'cold email', engine: 'bing' })
60
+
61
+ expect(result.isError).toBeFalsy()
62
+ const parsed = JSON.parse(result.content[0].text)
63
+ expect(parsed._meta.provider).toBe('serp_bing')
64
+ expect(googleCalled).toBe(false)
65
+ })
66
+
67
+ it('category=serp engine=youtube — serp_youtube fires', async () => {
68
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
69
+ const u = url.toString()
70
+ if (u.includes('/dataforseo/serp/youtube/organic')) {
71
+ return new Response(JSON.stringify({ tasks: [{ result: [{ items: [{ title: 'YouTube result' }] }] }] }), { status: 200 })
72
+ }
73
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
74
+ }) as typeof fetch
75
+
76
+ const result = await searchSeoHandler({ category: 'serp', keyword: 'cold email', engine: 'youtube' })
77
+
78
+ expect(result.isError).toBeFalsy()
79
+ const parsed = JSON.parse(result.content[0].text)
80
+ expect(parsed._meta.provider).toBe('serp_youtube')
81
+ })
82
+
83
+ it('category=backlinks — bl_summary fires first', async () => {
84
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
85
+ const u = url.toString()
86
+ if (u.includes('/dataforseo/backlinks/summary')) {
87
+ return new Response(JSON.stringify({ tasks: [{ result: [{ total_count: 1234, rank: 85 }] }] }), { status: 200 })
88
+ }
89
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
90
+ }) as typeof fetch
91
+
92
+ const result = await searchSeoHandler({ category: 'backlinks', target: 'coldiq.com' })
93
+
94
+ expect(result.isError).toBeFalsy()
95
+ const parsed = JSON.parse(result.content[0].text)
96
+ expect(parsed._meta.provider).toBe('bl_summary')
97
+ })
98
+
99
+ it('category=domain action=technologies — domain_tech fires', async () => {
100
+ let whoisCalled = false
101
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
102
+ const u = url.toString()
103
+ if (u.includes('/dataforseo/domain-analytics/whois')) { whoisCalled = true }
104
+ if (u.includes('/dataforseo/domain-analytics/technologies')) {
105
+ return new Response(JSON.stringify({ tasks: [{ result: [{ technologies: { cms: ['WordPress'] } }] }] }), { status: 200 })
106
+ }
107
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
108
+ }) as typeof fetch
109
+
110
+ const result = await searchSeoHandler({ category: 'domain', target: 'coldiq.com', action: 'technologies' })
111
+
112
+ expect(result.isError).toBeFalsy()
113
+ const parsed = JSON.parse(result.content[0].text)
114
+ expect(parsed._meta.provider).toBe('domain_tech')
115
+ expect(whoisCalled).toBe(false)
116
+ })
117
+
118
+ it('category=domain action=whois — domain_whois fires, domain_tech skipped', async () => {
119
+ let techCalled = false
120
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
121
+ const u = url.toString()
122
+ if (u.includes('/dataforseo/domain-analytics/technologies')) { techCalled = true }
123
+ if (u.includes('/dataforseo/domain-analytics/whois')) {
124
+ return new Response(JSON.stringify({ tasks: [{ result: [{ items: [{ domain: 'coldiq.com', expiration_datetime: '2026-01-01' }] }] }] }), { status: 200 })
125
+ }
126
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
127
+ }) as typeof fetch
128
+
129
+ const result = await searchSeoHandler({ category: 'domain', target: 'coldiq.com', action: 'whois' })
130
+
131
+ expect(result.isError).toBeFalsy()
132
+ const parsed = JSON.parse(result.content[0].text)
133
+ expect(parsed._meta.provider).toBe('domain_whois')
134
+ expect(techCalled).toBe(false)
135
+ })
136
+
137
+ it('category=labs with target and no lab_action — labs_rank_overview fires (default)', async () => {
138
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
139
+ const u = url.toString()
140
+ if (u.includes('/dataforseo/labs/domain-rank-overview')) {
141
+ return new Response(JSON.stringify({ tasks: [{ result: [{ metrics: { organic: { pos_1: 3 } }, rank: 90 }] }] }), { status: 200 })
142
+ }
143
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
144
+ }) as typeof fetch
145
+
146
+ const result = await searchSeoHandler({ category: 'labs', target: 'coldiq.com' })
147
+
148
+ expect(result.isError).toBeFalsy()
149
+ const parsed = JSON.parse(result.content[0].text)
150
+ expect(parsed._meta.provider).toBe('labs_rank_overview')
151
+ })
152
+
153
+ it('category=page page_action=content — page_content fires, page_lighthouse skipped', async () => {
154
+ let lighthouseCalled = false
155
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
156
+ const u = url.toString()
157
+ if (u.includes('/dataforseo/on-page/lighthouse')) { lighthouseCalled = true }
158
+ if (u.includes('/dataforseo/on-page/content-parsing')) {
159
+ return new Response(JSON.stringify({ tasks: [{ result: [{ content: 'ColdIQ homepage text', plain_text: 'ColdIQ' }] }] }), { status: 200 })
160
+ }
161
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
162
+ }) as typeof fetch
163
+
164
+ const result = await searchSeoHandler({ category: 'page', url: 'https://coldiq.com', page_action: 'content' })
165
+
166
+ expect(result.isError).toBeFalsy()
167
+ const parsed = JSON.parse(result.content[0].text)
168
+ expect(parsed._meta.provider).toBe('page_content')
169
+ expect(lighthouseCalled).toBe(false)
170
+ })
171
+
172
+ it('all providers fail — returns isError', async () => {
173
+ globalThis.fetch = vi.fn(async () => {
174
+ return new Response(JSON.stringify({ error: 'down' }), { status: 503 })
175
+ }) as typeof fetch
176
+
177
+ const result = await searchSeoHandler({ category: 'serp', keyword: 'cold email' })
178
+
179
+ expect(result.isError).toBe(true)
180
+ const parsed = JSON.parse(result.content[0].text)
181
+ expect(parsed.error).toBeTruthy()
182
+ })
183
+ })
@@ -0,0 +1,79 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { initClient } from '../../src/client.js'
3
+ import { searchWebHandler } from '../../src/tools/search-web.js'
4
+
5
+ describe('search_web 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 Serper results for general search', async () => {
17
+ globalThis.fetch = vi.fn(async () =>
18
+ new Response(JSON.stringify({ organic: [{ title: 'ColdIQ', link: 'https://coldiq.com' }] }), { status: 200 })
19
+ ) as typeof fetch
20
+
21
+ const result = await searchWebHandler({ query: 'ColdIQ', search_type: 'general' })
22
+
23
+ const parsed = JSON.parse(result.content[0].text)
24
+ expect(parsed._meta.provider).toBe('serper')
25
+ expect(parsed.data.organic).toHaveLength(1)
26
+ })
27
+
28
+ it('prefers Exa for neural search type', async () => {
29
+ let capturedUrl = ''
30
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
31
+ capturedUrl = url.toString()
32
+ return new Response(JSON.stringify({ results: [{ title: 'ColdIQ', url: 'https://coldiq.com' }] }), { status: 200 })
33
+ }) as typeof fetch
34
+
35
+ const result = await searchWebHandler({ query: 'B2B sales intelligence', search_type: 'neural' })
36
+
37
+ expect(capturedUrl).toContain('/exa/search')
38
+ const parsed = JSON.parse(result.content[0].text)
39
+ expect(parsed._meta.provider).toBe('exa')
40
+ })
41
+
42
+ it('falls back to LimaData when Serper fails', async () => {
43
+ let callCount = 0
44
+ globalThis.fetch = vi.fn(async () => {
45
+ callCount++
46
+ if (callCount === 1) {
47
+ return new Response(JSON.stringify({ error: 'Rate limited' }), { status: 429 })
48
+ }
49
+ return new Response(JSON.stringify({ organic: [{ title: 'ColdIQ', link: 'https://coldiq.com' }] }), { status: 200 })
50
+ }) as typeof fetch
51
+
52
+ const result = await searchWebHandler({ query: 'ColdIQ', search_type: 'general' })
53
+
54
+ const parsed = JSON.parse(result.content[0].text)
55
+ expect(parsed._meta.provider).toBe('limadata')
56
+ })
57
+
58
+ it('falls back to Exa when Serper and LimaData both fail', async () => {
59
+ let callCount = 0
60
+ let capturedUrls: string[] = []
61
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
62
+ capturedUrls.push(url.toString())
63
+ callCount++
64
+ if (callCount === 1) {
65
+ return new Response(JSON.stringify({ error: 'Rate limited' }), { status: 429 })
66
+ }
67
+ if (callCount === 2) {
68
+ return new Response(JSON.stringify({ error: 'Service unavailable' }), { status: 500 })
69
+ }
70
+ return new Response(JSON.stringify({ results: [{ title: 'ColdIQ', url: 'https://coldiq.com' }] }), { status: 200 })
71
+ }) as typeof fetch
72
+
73
+ const result = await searchWebHandler({ query: 'ColdIQ', search_type: 'general' })
74
+
75
+ expect(capturedUrls[2]).toContain('/exa/search')
76
+ const parsed = JSON.parse(result.content[0].text)
77
+ expect(parsed._meta.provider).toBe('exa')
78
+ })
79
+ })
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { initClient } from '../../src/client.js'
3
+ import { verifyEmailHandler } from '../../src/tools/verify-email.js'
4
+
5
+ describe('verify_email 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 verification result from FindyMail', async () => {
17
+ globalThis.fetch = vi.fn(async () =>
18
+ new Response(JSON.stringify({ status: 'valid', email: 'michel@coldiq.com' }), { status: 200 })
19
+ ) as typeof fetch
20
+
21
+ const result = await verifyEmailHandler({ email: 'michel@coldiq.com' })
22
+
23
+ const parsed = JSON.parse(result.content[0].text)
24
+ expect(parsed._meta.provider).toBe('findymail')
25
+ expect(parsed.data.status).toBe('valid')
26
+ })
27
+
28
+ it('falls back to IcyPeas on FindyMail failure', async () => {
29
+ let callCount = 0
30
+ globalThis.fetch = vi.fn(async () => {
31
+ callCount++
32
+ if (callCount === 1) {
33
+ return new Response(JSON.stringify({ error: 'Provider error' }), { status: 502 })
34
+ }
35
+ return new Response(JSON.stringify({ result: 'valid' }), { status: 200 })
36
+ }) as typeof fetch
37
+
38
+ const result = await verifyEmailHandler({ email: 'michel@coldiq.com' })
39
+
40
+ const parsed = JSON.parse(result.content[0].text)
41
+ expect(parsed._meta.provider).toBe('icypeas')
42
+ })
43
+
44
+ it('falls through to Instantly when FindyMail and IcyPeas both fail, polls until terminal status', async () => {
45
+ let callCount = 0
46
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
47
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.href : url.url
48
+ callCount++
49
+ // Findymail and IcyPeas fail
50
+ if (urlStr.includes('findymail') || urlStr.includes('icypeas')) {
51
+ return new Response(JSON.stringify({ error: 'Provider error' }), { status: 502 })
52
+ }
53
+ // Instantly POST returns pending first
54
+ if (urlStr.includes('email-verification') && !urlStr.includes('%40')) {
55
+ return new Response(JSON.stringify({ email: 'michel@coldiq.com', status: 'pending' }), { status: 200 })
56
+ }
57
+ // Instantly GET poll returns terminal status
58
+ return new Response(JSON.stringify({ email: 'michel@coldiq.com', status: 'valid' }), { status: 200 })
59
+ }) as typeof fetch
60
+
61
+ const result = await verifyEmailHandler({ email: 'michel@coldiq.com' })
62
+
63
+ const parsed = JSON.parse(result.content[0].text)
64
+ expect(result.isError).toBeFalsy()
65
+ expect(parsed._meta.provider).toBe('instantly')
66
+ expect(parsed.data.status).toBe('valid')
67
+ })
68
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "outDir": "dist",
10
+ "rootDir": "src",
11
+ "declaration": true,
12
+ "declarationMap": true,
13
+ "sourceMap": true
14
+ },
15
+ "include": ["src"],
16
+ "exclude": ["node_modules", "dist", "tests"]
17
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from 'vitest/config'
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ['tests/**/*.test.ts'],
6
+ },
7
+ })