@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,25 @@
1
+ import { z } from 'zod'
2
+ import { executeWithFallback, isExecutionError } from '../executor.js'
3
+
4
+ export const enrichCompanyName = 'enrich_company'
5
+
6
+ export const enrichCompanyDescription =
7
+ 'Get detailed information about a company. Provide at least one of: domain, linkedin_url, or name. Returns company size, industry, funding, technologies, social profiles, and more. Use this to research a specific company before outreach or to fill in missing company data.'
8
+
9
+ export const enrichCompanySchema = {
10
+ domain: z.string().optional().describe('Company domain (e.g. "stripe.com")'),
11
+ linkedin_url: z.string().url().optional().describe('Company LinkedIn URL (e.g. "https://www.linkedin.com/company/stripe")'),
12
+ name: z.string().optional().describe('Company name (e.g. "Stripe")'),
13
+ }
14
+
15
+ export async function enrichCompanyHandler(input: Record<string, unknown>) {
16
+ if (!input.domain && !input.linkedin_url && !input.name) {
17
+ const err = { error: 'At least one of domain, linkedin_url, or name is required', providers_tried: [] }
18
+ return { content: [{ type: 'text' as const, text: JSON.stringify(err, null, 2) }], isError: true }
19
+ }
20
+ const result = await executeWithFallback('enrich_company', input)
21
+ if (isExecutionError(result)) {
22
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], isError: true }
23
+ }
24
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] }
25
+ }
@@ -0,0 +1,27 @@
1
+ import { z } from 'zod'
2
+ import { executeWithFallback, isExecutionError } from '../executor.js'
3
+
4
+ export const enrichPersonName = 'enrich_person'
5
+
6
+ export const enrichPersonDescription =
7
+ "Look up a person's LinkedIn profile and professional details. " +
8
+ 'Accepts either an email address (reverse lookup) or name + company (profile enrichment). ' +
9
+ 'Returns LinkedIn URL, headline, location, current role, and profile data.'
10
+
11
+ export const enrichPersonSchema = {
12
+ email: z.string().email().optional().describe('Email address for reverse lookup — identify the person behind the email'),
13
+ first_name: z.string().optional().describe("Person's first name (use with last_name + company_name or domain)"),
14
+ last_name: z.string().optional().describe("Person's last name"),
15
+ company_name: z.string().optional().describe("Current company name (e.g. \"ColdIQ\")"),
16
+ domain: z.string().optional().describe('Company domain — alternative to company_name (e.g. "coldiq.com")'),
17
+ linkedin_url: z.string().url().optional().describe('LinkedIn profile URL for direct profile lookup (e.g. "https://www.linkedin.com/in/michel-lieben")'),
18
+ phone: z.string().optional().describe('Phone number for reverse phone lookup'),
19
+ }
20
+
21
+ export async function enrichPersonHandler(input: Record<string, unknown>) {
22
+ const result = await executeWithFallback('enrich_person', input)
23
+ if (isExecutionError(result)) {
24
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], isError: true }
25
+ }
26
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] }
27
+ }
@@ -0,0 +1,36 @@
1
+ import { z } from 'zod'
2
+ import { executeWithFallback, isExecutionError } from '../executor.js'
3
+
4
+ export const fetchPageContentName = 'fetch_page_content'
5
+
6
+ export const fetchPageContentDescription =
7
+ 'Fetch the full text content of one or more web pages given their URLs. ' +
8
+ 'Returns extracted page text suitable for RAG, summarisation, or fact extraction. ' +
9
+ 'Use search_web first to discover URLs, then use this tool to fetch their full text. ' +
10
+ 'Costs 1 credit per URL submitted.'
11
+
12
+ export const fetchPageContentSchema = {
13
+ urls: z
14
+ .array(z.string().url())
15
+ .min(1)
16
+ .max(10)
17
+ .describe('URLs to fetch content from (1–10 URLs per call).'),
18
+ include_text: z
19
+ .boolean()
20
+ .optional()
21
+ .default(true)
22
+ .describe('Extract and return full page text (default: true).'),
23
+ include_summary: z
24
+ .boolean()
25
+ .optional()
26
+ .default(false)
27
+ .describe('Generate a summary for each page in addition to the text (default: false).'),
28
+ }
29
+
30
+ export async function fetchPageContentHandler(input: Record<string, unknown>) {
31
+ const result = await executeWithFallback('fetch_page_content', input)
32
+ if (isExecutionError(result)) {
33
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], isError: true }
34
+ }
35
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] }
36
+ }
@@ -0,0 +1,23 @@
1
+ import { z } from 'zod'
2
+ import { executeWaterfall, isExecutionError } from '../executor.js'
3
+
4
+ export const findEmailName = 'find_email'
5
+
6
+ export const findEmailDescription =
7
+ "Find a person's professional email given their name and company. Tries multiple providers in sequence until an email is found (waterfall). Returns the email and which provider found it."
8
+
9
+ export const findEmailSchema = {
10
+ first_name: z.string().optional().describe('First name of the person'),
11
+ last_name: z.string().optional().describe('Last name of the person'),
12
+ domain: z.string().optional().describe('Company domain (e.g. "stripe.com")'),
13
+ company_name: z.string().optional().describe('Company name — alternative to domain'),
14
+ linkedin_url: z.string().optional().describe('LinkedIn profile URL — alternative to name+domain'),
15
+ }
16
+
17
+ export async function findEmailHandler(input: Record<string, unknown>) {
18
+ const result = await executeWaterfall('find_email', input)
19
+ if (isExecutionError(result)) {
20
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], isError: true }
21
+ }
22
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] }
23
+ }
@@ -0,0 +1,190 @@
1
+ import { z } from 'zod'
2
+ import { callApi } from '../client.js'
3
+
4
+ export const findEmailsName = 'find_emails'
5
+
6
+ export const findEmailsDescription =
7
+ 'Find professional emails for multiple people in one batch. ' +
8
+ 'Uses bulk enrichment first, then a secondary provider for misses, then ' +
9
+ 'two additional providers in parallel for any remaining misses. ' +
10
+ 'Much faster than calling find_email one-by-one. Max 50 people per call. ' +
11
+ 'Each person needs a unique id to match results back. ' +
12
+ 'Always pass first_name, last_name, and domain — they are the primary enrichment signal. ' +
13
+ 'Also pass linkedin_url when available, but never rely on it alone as some providers cannot resolve vanity URLs.'
14
+
15
+ export const findEmailsSchema = {
16
+ people: z
17
+ .array(
18
+ z.object({
19
+ id: z.string().describe("Unique identifier to match results back (e.g. person's name or a slug)"),
20
+ first_name: z.string().optional().describe('First name'),
21
+ last_name: z.string().optional().describe('Last name'),
22
+ domain: z.string().optional().describe('Company domain (e.g. "stripe.com")'),
23
+ linkedin_url: z.string().optional().describe('LinkedIn profile URL — pass when available but always include name+domain too'),
24
+ }),
25
+ )
26
+ .min(1)
27
+ .max(50)
28
+ .describe('People to find emails for (max 50)'),
29
+ }
30
+
31
+ interface PersonInput {
32
+ id: string
33
+ first_name?: string
34
+ last_name?: string
35
+ domain?: string
36
+ linkedin_url?: string
37
+ }
38
+
39
+ interface EmailResult {
40
+ id: string
41
+ email: string | null
42
+ provider: string | null
43
+ }
44
+
45
+ function sleep(ms: number): Promise<void> {
46
+ return new Promise((resolve) => setTimeout(resolve, ms))
47
+ }
48
+
49
+ function missesOf(people: PersonInput[], results: EmailResult[]): PersonInput[] {
50
+ return people.filter((p) => !results.find((r) => r.id === p.id)?.email)
51
+ }
52
+
53
+ export async function findEmailsHandler(input: Record<string, unknown>) {
54
+ const people = input.people as PersonInput[]
55
+ const results: EmailResult[] = people.map((p) => ({ id: p.id, email: null, provider: null }))
56
+
57
+ // Step 1: Prospeo bulk — 1 call for all people
58
+ const bulkBody = {
59
+ data: people.map((p) =>
60
+ p.linkedin_url
61
+ ? { identifier: p.id, linkedin_url: p.linkedin_url }
62
+ : { identifier: p.id, first_name: p.first_name, last_name: p.last_name, company_name: p.domain },
63
+ ),
64
+ }
65
+
66
+ const bulkRes = await callApi('POST', '/prospeo/bulk-enrich-person', bulkBody)
67
+
68
+ if (bulkRes.ok) {
69
+ const data = bulkRes.data as {
70
+ results?: Array<{
71
+ identifier: string
72
+ person?: { email?: { email?: string } }
73
+ }>
74
+ }
75
+ for (const item of data.results ?? []) {
76
+ const email = item.person?.email?.email
77
+ if (typeof email === 'string' && email.includes('@')) {
78
+ const hit = results.find((r) => r.id === item.identifier)
79
+ if (hit) {
80
+ hit.email = email
81
+ hit.provider = 'prospeo'
82
+ }
83
+ }
84
+ }
85
+ }
86
+
87
+ // Step 2: FullEnrich batch for Prospeo misses (async, better European coverage)
88
+ const afterProspeo = missesOf(people, results)
89
+
90
+ if (afterProspeo.length > 0) {
91
+ const feBody = {
92
+ name: 'mcp-enrich-batch',
93
+ data: afterProspeo.map((p) => ({
94
+ first_name: p.first_name,
95
+ last_name: p.last_name,
96
+ domain: p.domain,
97
+ ...(p.linkedin_url ? { linkedin_url: p.linkedin_url } : {}),
98
+ enrich_fields: ['contact.emails'],
99
+ })),
100
+ }
101
+
102
+ const feCreateRes = await callApi('POST', '/fullenrich/contact/enrich/bulk', feBody)
103
+
104
+ if (feCreateRes.ok) {
105
+ const enrichmentId = (feCreateRes.data as Record<string, unknown>).enrichment_id as string | undefined
106
+
107
+ if (enrichmentId) {
108
+ const deadline = Date.now() + 90_000
109
+ while (Date.now() < deadline) {
110
+ await sleep(5000)
111
+ const pollRes = await callApi('GET', `/fullenrich/contact/enrich/bulk/${enrichmentId}`)
112
+ if (!pollRes.ok) continue
113
+
114
+ const pd = pollRes.data as Record<string, unknown>
115
+ const status = pd.status as string | undefined
116
+ if (status === 'DONE' || status === 'FAILED') {
117
+ if (status === 'DONE') {
118
+ const feItems = pd.data as Array<Record<string, unknown>> | undefined
119
+ if (Array.isArray(feItems)) {
120
+ feItems.forEach((item, idx) => {
121
+ const person = afterProspeo[idx]
122
+ const hit = results.find((r) => r.id === person?.id)
123
+ if (!hit || hit.email) return
124
+ const emails = item.emails as string[] | undefined
125
+ if (Array.isArray(emails) && emails.length > 0 && typeof emails[0] === 'string' && emails[0].includes('@')) {
126
+ hit.email = emails[0]
127
+ hit.provider = 'fullenrich'
128
+ }
129
+ })
130
+ }
131
+ }
132
+ break
133
+ }
134
+ }
135
+ }
136
+ }
137
+ }
138
+
139
+ // Step 3: parallel fallback for remaining misses — FindyMail then IcyPeas per person
140
+ const afterFullEnrich = missesOf(people, results)
141
+
142
+ await Promise.all(
143
+ afterFullEnrich.map(async (person) => {
144
+ const hit = results.find((r) => r.id === person.id)!
145
+ const fullName = [person.first_name, person.last_name].filter(Boolean).join(' ')
146
+
147
+ const fmRes = await callApi('POST', '/findymail/search/name', {
148
+ name: fullName,
149
+ domain: person.domain,
150
+ })
151
+ if (fmRes.ok) {
152
+ const d = fmRes.data as Record<string, unknown>
153
+ if (typeof d.email === 'string' && d.email.includes('@')) {
154
+ hit.email = d.email
155
+ hit.provider = 'findymail'
156
+ return
157
+ }
158
+ }
159
+
160
+ const icyRes = await callApi('POST', '/icypeas/email-search', {
161
+ firstname: person.first_name,
162
+ lastname: person.last_name,
163
+ domainOrCompany: person.domain,
164
+ })
165
+ if (icyRes.ok) {
166
+ const d = icyRes.data as Record<string, unknown>
167
+ const email =
168
+ typeof d.email === 'string' && d.email.includes('@')
169
+ ? d.email
170
+ : Array.isArray(d.emails) && typeof d.emails[0] === 'string' && d.emails[0].includes('@')
171
+ ? (d.emails[0] as string)
172
+ : null
173
+ if (email) {
174
+ hit.email = email
175
+ hit.provider = 'icypeas'
176
+ }
177
+ }
178
+ }),
179
+ )
180
+
181
+ const found = results.filter((r) => r.email !== null).length
182
+ return {
183
+ content: [
184
+ {
185
+ type: 'text' as const,
186
+ text: JSON.stringify({ data: { results, found, total: people.length } }, null, 2),
187
+ },
188
+ ],
189
+ }
190
+ }
@@ -0,0 +1,34 @@
1
+ import { z } from 'zod'
2
+ import { executeWithFallback, isExecutionError } from '../executor.js'
3
+
4
+ export const findInfluencersName = 'find_influencers'
5
+
6
+ export const findInfluencersDescription =
7
+ 'Discover and find influencers/creators on Instagram, YouTube, TikTok, Twitch, Twitter, and OnlyFans via 2 providers (Influencers Club Similar, Influencers Club Discovery). Routes by input: handle set → lookalike search (influencers_similar) runs first; no handle → keyword/filter discovery. Filters: location, gender, type (creator/business), AI natural language search, sort. Cost: 1 credit per result returned.'
8
+
9
+ export const findInfluencersSchema = {
10
+ platform: z.enum(['instagram', 'youtube', 'tiktok', 'twitch', 'twitter', 'onlyfans'])
11
+ .describe('Target platform.'),
12
+ limit: z.number().int().min(1).max(100).default(25)
13
+ .describe('Max creators to return (1–100). 1 credit per result.'),
14
+ page: z.number().int().min(1).default(1).optional()
15
+ .describe('Page number for pagination.'),
16
+ sort_by: z.enum(['relevancy', 'number_of_followers', 'engagement_rate']).optional(),
17
+ sort_order: z.enum(['asc', 'desc']).optional(),
18
+ ai_search: z.string().optional()
19
+ .describe('Natural language query e.g. "fitness influencers in NYC who post about nutrition".'),
20
+ location: z.array(z.string()).optional()
21
+ .describe('Country or city names e.g. ["United States", "New York"].'),
22
+ gender: z.enum(['male', 'female']).optional(),
23
+ type: z.enum(['creator', 'business']).optional(),
24
+ handle: z.string().optional()
25
+ .describe('Find creators similar to this handle. Routes to lookalike search. No @ prefix.'),
26
+ }
27
+
28
+ export async function findInfluencersHandler(input: Record<string, unknown>) {
29
+ const result = await executeWithFallback('find_influencers', input)
30
+ if (isExecutionError(result)) {
31
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], isError: true }
32
+ }
33
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] }
34
+ }
@@ -0,0 +1,69 @@
1
+ import { z } from 'zod'
2
+ import { executeWithFallback, isExecutionError } from '../executor.js'
3
+ import { callApi } from '../client.js'
4
+ import { getProviders } from '../registry.js'
5
+
6
+ export const findPeopleName = 'find_people'
7
+
8
+ export const findPeopleDescription =
9
+ 'Find people at specific companies by job title. ' +
10
+ 'Use this — never search_web — to find contacts, executives, or LinkedIn profiles at known companies. ' +
11
+ 'Do not fall back to search_web when results look uncertain or LinkedIn URLs seem wrong — trust what this tool returns. ' +
12
+ 'ALWAYS pass all companies in a single call — never loop and call once per company. ' +
13
+ 'Set limit = (desired results per company) × (number of companies). ' +
14
+ 'Prefer company_linkedin_urls when available — they make the search faster. Only pass company_domains when you have no LinkedIn URLs. ' +
15
+ 'Pass job_titles EXACTLY as stated — never expand or generate variants (e.g. do NOT turn "CEO" into ["CEO", "Chief Executive Officer", "Founder"]). LeadsFactory handles fuzzy title matching internally; adding variants wastes personas and degrades results. ' +
16
+ 'Do NOT pass seniorities unless explicitly asked — job titles alone produce better results. ' +
17
+ 'Returns names, titles, LinkedIn URLs. Default limit: 25.'
18
+
19
+ export const findPeopleSchema = {
20
+ company_linkedin_urls: z.array(z.string()).optional().describe('Company LinkedIn URLs (preferred over domains when available, e.g. ["https://linkedin.com/company/microsoft"])'),
21
+ company_domains: z.array(z.string()).optional().describe('Company domains to search (e.g. ["microsoft.com", "google.com"]). Use only when LinkedIn URLs are not available.'),
22
+ job_titles: z.array(z.string()).optional().describe('Target job titles (e.g. ["CEO", "VP of Sales"])'),
23
+ seniorities: z.array(z.string()).optional().describe('Seniority levels (e.g. ["c_suite", "vp", "director"])'),
24
+ locations: z.array(z.string()).optional().describe('Person or company locations'),
25
+ keywords: z.array(z.string()).optional().describe('Free-text keyword search terms (e.g. ["growth", "AI"])'),
26
+ limit: z.number().min(1).max(500).default(25).describe('Max results across all companies combined (default: 25, max: 500). For multiple companies multiply: e.g. 10 companies × 5 each = 50.'),
27
+ }
28
+
29
+ export async function findPeopleHandler(input: Record<string, unknown>) {
30
+ const result = await executeWithFallback('find_people', input)
31
+ if (isExecutionError(result)) {
32
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], isError: true }
33
+ }
34
+
35
+ // Gap-fill: if LeadsFactory missed some domains, try Apollo for those.
36
+ // Only runs when domains were the input — if we sent LinkedIn URLs, no_results_domains
37
+ // is unreliable (LF may resolve arbitrary domains from URLs, e.g. linkedin.com itself).
38
+ const inputHadDomains = Array.isArray(input.company_domains) && (input.company_domains as unknown[]).length > 0
39
+ const inputHadLinkedInUrls = Array.isArray(input.company_linkedin_urls) && (input.company_linkedin_urls as unknown[]).length > 0
40
+ if (result._meta.provider === 'leadsfactory' && inputHadDomains && !inputHadLinkedInUrls) {
41
+ const data = result.data as Record<string, unknown>
42
+ const missedDomains = (data.no_results_domains as string[] | undefined) ?? []
43
+
44
+ if (missedDomains.length > 0) {
45
+ const apollo = getProviders('find_people').find((p) => p.id === 'apollo')
46
+ if (apollo) {
47
+ const gapInput = { ...input, company_domains: missedDomains, company_linkedin_urls: undefined }
48
+ const payload = apollo.mapParams(gapInput)
49
+ const apolloRes = await callApi(apollo.method, apollo.endpoint, payload.body, payload.queryParams)
50
+
51
+ if (apolloRes.ok && apollo.hasResult(apolloRes.data)) {
52
+ const merged = {
53
+ ...data,
54
+ gap_fill: {
55
+ provider: 'apollo',
56
+ domains: missedDomains,
57
+ ...(apolloRes.data as Record<string, unknown>),
58
+ },
59
+ }
60
+ return {
61
+ content: [{ type: 'text' as const, text: JSON.stringify({ ...result, data: merged }, null, 2) }],
62
+ }
63
+ }
64
+ }
65
+ }
66
+ }
67
+
68
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] }
69
+ }
@@ -0,0 +1,53 @@
1
+ import { z } from 'zod'
2
+ import { executeWithFallback, isExecutionError } from '../executor.js'
3
+
4
+ export const findPhoneName = 'find_phone'
5
+
6
+ export const findPhoneDescription =
7
+ 'Find a direct phone number for a person. Accepts a LinkedIn URL, or first+last name with company domain/name. Phone lookups are expensive (10+ credits) — use only when specifically needed.'
8
+
9
+ export const findPhoneSchema = {
10
+ linkedin_url: z.string().url().optional().describe('LinkedIn profile URL'),
11
+ first_name: z.string().optional().describe('First name (required when no LinkedIn URL)'),
12
+ last_name: z.string().optional().describe('Last name (required when no LinkedIn URL)'),
13
+ company_domain: z.string().optional().describe('Company domain, e.g. coldiq.com'),
14
+ company_name: z.string().optional().describe('Company name (alternative to company_domain)'),
15
+ }
16
+
17
+ const inputSchema = z
18
+ .object({
19
+ linkedin_url: z.string().url().optional(),
20
+ first_name: z.string().optional(),
21
+ last_name: z.string().optional(),
22
+ company_domain: z.string().optional(),
23
+ company_name: z.string().optional(),
24
+ })
25
+ .superRefine((val, ctx) => {
26
+ const hasLinkedIn = typeof val.linkedin_url === 'string' && val.linkedin_url.length > 0
27
+ const hasName = typeof val.first_name === 'string' && val.first_name.length > 0 &&
28
+ typeof val.last_name === 'string' && val.last_name.length > 0
29
+ const hasCompany = (typeof val.company_domain === 'string' && val.company_domain.length > 0) ||
30
+ (typeof val.company_name === 'string' && val.company_name.length > 0)
31
+ if (!hasLinkedIn && !(hasName && hasCompany)) {
32
+ ctx.addIssue({
33
+ code: 'custom',
34
+ message: 'Provide either linkedin_url or both first_name+last_name and company_domain/company_name',
35
+ })
36
+ }
37
+ })
38
+
39
+ export async function findPhoneHandler(input: Record<string, unknown>) {
40
+ const validation = inputSchema.safeParse(input)
41
+ if (!validation.success) {
42
+ const msg = validation.error.issues.map((i) => i.message).join('; ')
43
+ return {
44
+ content: [{ type: 'text' as const, text: JSON.stringify({ error: msg }, null, 2) }],
45
+ isError: true,
46
+ }
47
+ }
48
+ const result = await executeWithFallback('find_phone', input)
49
+ if (isExecutionError(result)) {
50
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], isError: true }
51
+ }
52
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] }
53
+ }
@@ -0,0 +1,93 @@
1
+ import { z } from 'zod'
2
+ import { executeWithFallback, isExecutionError } from '../executor.js'
3
+
4
+ export const findSignalsName = 'find_signals'
5
+
6
+ export const findSignalsDescription =
7
+ 'Retrieve sales intelligence signals — funding rounds, acquisitions, hiring activity, job changes, buying intent, news, and startup posts. ' +
8
+ 'Each call targets one signal type. Two modes: ' +
9
+ 'Company-targeted (funding | acquisition | hiring | job_change | intent): accepts companies/domains/industries/countries/since filters. ' +
10
+ 'intent REQUIRES at least one of companies or domains. ' +
11
+ 'Feed-style (news | startup_post): country and since only — does NOT filter by company. Passing companies/domains for these types is rejected. ' +
12
+ 'hiring returns aggregated company-level hiring activity (e.g. "X is rapidly expanding") — for individual job postings use search_jobs instead.'
13
+
14
+ export const findSignalsSchema = {
15
+ signal_type: z
16
+ .enum(['funding', 'acquisition', 'hiring', 'job_change', 'news', 'intent', 'startup_post'])
17
+ .describe(
18
+ 'Signal type to retrieve. ' +
19
+ 'Company-targeted: "funding" (fundraising rounds), "acquisition" (M&A), "hiring" (company hiring surge — not individual postings), ' +
20
+ '"job_change" (people who recently changed roles), "intent" (companies showing buying intent). ' +
21
+ 'Feed-style (country/date filter only — company filter not supported): "news" (company news events), "startup_post" (Product Hunt, Hacker News, etc.)'
22
+ ),
23
+ companies: z
24
+ .array(z.string())
25
+ .optional()
26
+ .describe('Company names to filter signals for (e.g. ["ColdIQ", "HubSpot"]). Only used by company-targeted signal types: funding, acquisition, hiring, job_change, intent. Rejected for news and startup_post.'),
27
+ domains: z
28
+ .array(z.string())
29
+ .optional()
30
+ .describe('Company domains to filter signals for (e.g. ["coldiq.com"]). Only used by company-targeted types. Required for intent when companies is absent.'),
31
+ since: z
32
+ .string()
33
+ .optional()
34
+ .describe('Return signals after this date. ISO date format, e.g. "2026-01-01".'),
35
+ industries: z
36
+ .array(z.string())
37
+ .optional()
38
+ .describe('Industry names to filter by (e.g. ["Software", "SaaS"]). Only used by company-targeted signal types.'),
39
+ countries: z
40
+ .array(z.string())
41
+ .optional()
42
+ .describe('ISO country codes or names to filter by (e.g. ["US", "GB"]). Works for all signal types.'),
43
+ limit: z
44
+ .number()
45
+ .int()
46
+ .min(1)
47
+ .max(100)
48
+ .default(25)
49
+ .describe('Maximum number of signals to return (1–100).'),
50
+ }
51
+
52
+ export async function findSignalsHandler(input: Record<string, unknown>) {
53
+ const hasCompanies = Array.isArray(input.companies) && (input.companies as unknown[]).length > 0
54
+ const hasDomains = Array.isArray(input.domains) && (input.domains as unknown[]).length > 0
55
+
56
+ if (input.signal_type === 'intent' && !hasCompanies && !hasDomains) {
57
+ return {
58
+ content: [{
59
+ type: 'text' as const,
60
+ text: JSON.stringify({ error: 'intent signal_type requires at least one of: companies or domains' }, null, 2),
61
+ }],
62
+ isError: true,
63
+ }
64
+ }
65
+
66
+ if ((input.signal_type === 'news' || input.signal_type === 'startup_post') && (hasCompanies || hasDomains)) {
67
+ return {
68
+ content: [{
69
+ type: 'text' as const,
70
+ text: JSON.stringify({
71
+ error: 'news and startup_post are feed-style signal types that only filter by country and since date — they do not support company filtering. Remove companies/domains, or use signal_type=intent for company-targeted intent signals.',
72
+ }, null, 2),
73
+ }],
74
+ isError: true,
75
+ }
76
+ }
77
+
78
+ const result = await executeWithFallback('find_signals', input)
79
+ if (isExecutionError(result)) {
80
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], isError: true }
81
+ }
82
+
83
+ // buying_intents upstream has no limit param — truncate to requested limit
84
+ if (input.signal_type === 'intent') {
85
+ const limit = typeof input.limit === 'number' ? input.limit : 25
86
+ const typed = result as { data?: { data?: unknown[] } }
87
+ if (Array.isArray(typed.data?.data)) {
88
+ typed.data!.data = typed.data!.data.slice(0, limit)
89
+ }
90
+ }
91
+
92
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] }
93
+ }
@@ -0,0 +1,44 @@
1
+ import { z } from 'zod'
2
+ import { executeWithFallback, isExecutionError } from '../executor.js'
3
+
4
+ export const searchAdsName = 'search_ads'
5
+
6
+ export const searchAdsDescription =
7
+ 'Search live ad creatives across 5 ad libraries (Google Ads Transparency, LinkedIn Ad Library, Meta Ads Library, Twitter/X Ads, Reddit Ads) — a high-signal GTM input for competitive intelligence, ICP refinement, and pitch personalization. Routes by input: domains/advertiser_ids → Google only; search_urls → LinkedIn only; bare query → Google → Meta → Twitter → Reddit waterfall. Use platform="google"|"linkedin"|"meta"|"twitter"|"reddit" to pin to one platform. All providers are async (~10–60s). Cost: ~5 credits per call (Twitter charges 1 credit per ad returned; Meta does not refund on failure).'
8
+
9
+ export const searchAdsSchema = {
10
+ query: z.string().optional().describe('Advertiser/company name or keyword. Routes to Google→Meta→Twitter→Reddit when no platform-specific input is set.'),
11
+
12
+ domains: z.array(z.string()).optional().describe('Company domains (e.g. ["salesforce.com"]). Routes to Google Ads only.'),
13
+ advertiser_ids: z.array(z.string()).optional().describe('Google Ads Transparency advertiser IDs (e.g. ["AR16735076323512287233"]). Routes to Google Ads only.'),
14
+
15
+ search_urls: z.array(z.string()).optional().describe('Pre-built LinkedIn Ad Library URLs from linkedin.com/ad-library. Routes to LinkedIn only. Build at linkedin.com/ad-library/search using accountOwner/countries/dateOption filters.'),
16
+
17
+ country: z.string().optional().describe('ISO country code (2-letter for Meta/Twitter; longer accepted by Google as region). Ignored by Reddit.'),
18
+ max_results: z.number().int().min(1).max(200).default(25).describe('Max ads per provider call (1–200). Per-provider caps: Google 1000, LinkedIn 200, Meta 200, Twitter 100. Twitter charges 1 credit per ad returned.'),
19
+
20
+ ad_type: z.enum(['ALL', 'POLITICAL_AND_ISSUE_ADS']).optional().describe('Meta only. Defaults to ALL.'),
21
+
22
+ start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Twitter only. Date range start, YYYY-MM-DD.'),
23
+ end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Twitter only. Date range end, YYYY-MM-DD.'),
24
+
25
+ industry: z.enum([
26
+ 'TECH_B2C', 'TECH_B2B', 'EDUCATION', 'ENTERTAINMENT', 'HEALTH_AND_BEAUTY',
27
+ 'GAMING', 'EMPLOYMENT', 'RETAIL_AND_ECOMMERCE', 'AUTO', 'FINANCIAL_SERVICES',
28
+ 'TRAVEL', 'REAL_ESTATE', 'GAMBLING_AND_FANTASY_SPORTS', 'POLITICS_AND_GOVERNMENT',
29
+ 'CONSUMER_PACKAGED_GOODS', 'OTHER',
30
+ ]).optional().describe('Reddit only. Filter by industry category.'),
31
+ budget_category: z.enum(['LOW', 'MEDIUM', 'HIGH']).optional().describe('Reddit only.'),
32
+ post_type: z.enum(['IMAGE', 'VIDEO', 'TEXT']).optional().describe('Reddit only.'),
33
+ objective_type: z.enum(['AWARENESS', 'CONVERSIONS', 'APP_INSTALLS', 'TRAFFIC', 'VIDEO_VIEWABLE_IMPRESSIONS']).optional().describe('Reddit only.'),
34
+
35
+ platform: z.enum(['google', 'linkedin', 'meta', 'twitter', 'reddit']).optional().describe('Pin to one platform; skips all others. Useful for cross-platform comparison via separate calls.'),
36
+ }
37
+
38
+ export async function searchAdsHandler(input: Record<string, unknown>) {
39
+ const result = await executeWithFallback('search_ads', input)
40
+ if (isExecutionError(result)) {
41
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], isError: true }
42
+ }
43
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] }
44
+ }
@@ -0,0 +1,41 @@
1
+ import { z } from 'zod'
2
+ import { executeWithFallback, isExecutionError } from '../executor.js'
3
+
4
+ export const searchCompaniesName = 'search_companies'
5
+
6
+ export const searchCompaniesDescription =
7
+ 'Search B2B companies by industry, size, geography, founding year, tech stack, funding, revenue, exclusion lists, and hiring signals. Routing is automatic: strict firmographic filters (revenue, employee bands, exclusion lists) get high-precision providers first; tech-stack and funding-stage filters route to specialized providers; loose keyword/geo searches fall back to broad providers; a LinkedIn Sales Navigator search URL enables URL-based discovery as a final fallback. Default limit: 25.'
8
+
9
+ export const searchCompaniesSchema = {
10
+ keywords: z.array(z.string()).optional().describe('Industry keywords or topics (e.g. ["SaaS", "fintech"])'),
11
+ countries: z.array(z.string()).optional().describe('2-letter ISO country codes (e.g. ["FR", "US"])'),
12
+ locations: z.array(z.string()).optional().describe('City/region filters as free-text strings (e.g. ["Paris, France", "San Francisco, California"]). Narrows results beyond country-level.'),
13
+ industries: z.array(z.string()).optional().describe('Industry names (e.g. ["Technology", "Healthcare"])'),
14
+ technologies: z.array(z.string()).optional().describe('Tech stack filters — routes to tech-aware providers. Pass technology names or slugs e.g. ["Salesforce", "HubSpot"].'),
15
+ min_employees: z.number().optional().describe('Minimum employee count'),
16
+ max_employees: z.number().optional().describe('Maximum employee count'),
17
+ min_founded_year: z.number().optional().describe('Earliest founding year'),
18
+ max_founded_year: z.number().optional().describe('Latest founding year'),
19
+ funding_stages: z.array(z.string()).optional().describe('Funding stage filters e.g. ["seed", "series_a", "series_b"]. Exact stage IDs vary by provider.'),
20
+ min_funding_amount: z.number().optional().describe('Minimum total funding raised (USD)'),
21
+ max_funding_amount: z.number().optional().describe('Maximum total funding raised (USD)'),
22
+ min_funding_year: z.number().optional().describe('Earliest year of last funding round'),
23
+ max_funding_year: z.number().optional().describe('Latest year of last funding round'),
24
+ min_revenue: z.number().optional().describe('Minimum estimated annual revenue (USD)'),
25
+ max_revenue: z.number().optional().describe('Maximum estimated annual revenue (USD)'),
26
+ exclude_domains: z.array(z.string()).optional().describe('Company domains to exclude from results'),
27
+ exclude_industries: z.array(z.string()).optional().describe('Industry names to exclude from results'),
28
+ exclude_countries: z.array(z.string()).optional().describe('2-letter ISO country codes to exclude from results'),
29
+ is_hiring: z.boolean().optional().describe('Filter to companies currently posting jobs'),
30
+ min_workforce_growth_pct: z.number().optional().describe('Minimum workforce growth % over the past 12 months (e.g. 10 for 10%)'),
31
+ linkedin_search_url: z.string().optional().describe('LinkedIn Sales Navigator company search URL — when provided, enables URL-based prospect discovery as a final fallback. Most users should leave this unset.'),
32
+ limit: z.number().min(1).max(100).default(25).describe('Max results to return (default: 25, max: 100)'),
33
+ }
34
+
35
+ export async function searchCompaniesHandler(input: Record<string, unknown>) {
36
+ const result = await executeWithFallback('search_companies', input)
37
+ if (isExecutionError(result)) {
38
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], isError: true }
39
+ }
40
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] }
41
+ }