@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,73 @@
1
+ import { z } from 'zod'
2
+ import { executeWithFallback, isExecutionError } from '../executor.js'
3
+
4
+ const _CAREER_SITE_ONLY = ['ats_slugs', 'exclude_ats_slugs', 'company_domains', 'exclude_company_domains']
5
+ const _LINKEDIN_ONLY = ['seniority_levels', 'industries', 'organization_slugs', 'exclude_organization_slugs', 'min_employees', 'max_employees', 'easy_apply_only', 'exclude_easy_apply']
6
+
7
+ function _isFilterSet(input: Record<string, unknown>, keys: string[]): boolean {
8
+ return keys.some((k) => {
9
+ const v = input[k]
10
+ return !!v && (!Array.isArray(v) || v.length > 0)
11
+ })
12
+ }
13
+
14
+ export const searchJobsName = 'search_jobs'
15
+
16
+ export const searchJobsDescription =
17
+ 'Search live job postings as a GTM buying signal — find companies hiring for specific roles, on specific ATS platforms, in specific regions. Filter routing is automatic: ATS slugs and company-domain filters route to broad career-site coverage (175k+ company sites, 54 ATS platforms); LinkedIn-only filters (seniority, industry, employee count, easy apply) route to LinkedIn. Shared filters (title, location, description, work arrangement, employment type, taxonomies) work everywhere. For aggregated hiring activity at the company level (e.g. "X is hiring fast"), use find_signals(signal_type="hiring") instead. Default limit: 25.'
18
+
19
+ export const searchJobsSchema = {
20
+ // --- shared filters ---
21
+ title_keywords: z.array(z.string()).optional().describe('Job title terms to match (supports prefix matching, e.g. "engineer:*"). OR-joined across terms.'),
22
+ exclude_title_keywords: z.array(z.string()).optional().describe('Title terms to exclude.'),
23
+ locations: z.array(z.string()).optional().describe('Locations in "City, State/Region, Country" format (English), e.g. ["Austin, Texas, United States"].'),
24
+ exclude_locations: z.array(z.string()).optional(),
25
+ description_keywords: z.array(z.string()).optional().describe('Free-text terms to search in job descriptions.'),
26
+ exclude_description_keywords: z.array(z.string()).optional(),
27
+ companies: z.array(z.string()).optional().describe('Company names to include.'),
28
+ exclude_companies: z.array(z.string()).optional(),
29
+ remote: z.boolean().optional().describe('Include remote jobs only.'),
30
+ exclude_agencies: z.boolean().optional().describe('Filter out staffing agencies and job boards.'),
31
+ posted_after: z.string().optional().describe('Only return jobs posted after this date, e.g. "2026-01-01" or ISO datetime.'),
32
+ time_range: z.enum(['1h', '24h', '7d', '6m']).optional().default('7d').describe('Recency window for the search.'),
33
+ include_description: z.boolean().optional().default(false).describe('Include full job description text in results (slower, more tokens).'),
34
+ employment_types: z.array(z.enum(['FULL_TIME', 'PART_TIME', 'CONTRACTOR', 'TEMPORARY', 'INTERN', 'VOLUNTEER', 'PER_DIEM', 'OTHER'])).optional(),
35
+ work_arrangements: z.array(z.enum(['On-site', 'Hybrid', 'Remote OK', 'Remote Solely'])).optional(),
36
+ experience_levels: z.array(z.enum(['0-2', '2-5', '5-10', '10+'])).optional().describe('AI-inferred years-of-experience bands.'),
37
+ has_salary: z.boolean().optional().describe('Only return postings that include salary information.'),
38
+ has_visa_sponsorship: z.boolean().optional(),
39
+ taxonomies: z.array(z.string()).optional().describe('AI-inferred industry taxonomies, e.g. ["Technology", "Healthcare"].'),
40
+ limit: z.number().int().min(10).max(500).default(25).describe('Max jobs to return (10–500, default 25).'),
41
+
42
+ // --- Career-site-only routing filters ---
43
+ ats_slugs: z.array(z.string()).optional().describe('Return only jobs from these ATS platforms, e.g. ["greenhouse", "workday", "ashby", "lever"]. Routes to career-site source; LinkedIn is skipped.'),
44
+ exclude_ats_slugs: z.array(z.string()).optional(),
45
+ company_domains: z.array(z.string()).optional().describe('Restrict to jobs posted by companies with these domains (exact match). Routes to career-site source.'),
46
+ exclude_company_domains: z.array(z.string()).optional(),
47
+
48
+ // --- LinkedIn-only routing filters ---
49
+ seniority_levels: z.array(z.enum(['Entry level', 'Associate', 'Mid-Senior level', 'Director', 'Executive', 'Internship', 'Not Applicable'])).optional().describe('LinkedIn seniority filter. Routes to LinkedIn source; career-site is skipped.'),
50
+ industries: z.array(z.string()).optional().describe('LinkedIn industry names (exact match). Routes to LinkedIn source.'),
51
+ organization_slugs: z.array(z.string()).optional().describe('LinkedIn company slugs to include. Routes to LinkedIn source.'),
52
+ exclude_organization_slugs: z.array(z.string()).optional(),
53
+ min_employees: z.number().int().min(1).optional().describe('Minimum company employee count (≥1). Routes to LinkedIn source.'),
54
+ max_employees: z.number().int().min(1).optional(),
55
+ easy_apply_only: z.boolean().optional().describe('Only LinkedIn Easy Apply jobs. Routes to LinkedIn source.'),
56
+ exclude_easy_apply: z.boolean().optional().describe('Exclude LinkedIn Easy Apply jobs. Routes to LinkedIn source.'),
57
+ }
58
+
59
+ export async function searchJobsHandler(input: Record<string, unknown>) {
60
+ if (_isFilterSet(input, _CAREER_SITE_ONLY) && _isFilterSet(input, _LINKEDIN_ONLY)) {
61
+ const err = {
62
+ error:
63
+ 'Contradictory filters: ATS/domain filters (ats_slugs, company_domains) are Career Site only, ' +
64
+ 'while seniority/industry/employee filters are LinkedIn only. Remove one set.',
65
+ }
66
+ return { content: [{ type: 'text' as const, text: JSON.stringify(err, null, 2) }], isError: true }
67
+ }
68
+ const result = await executeWithFallback('search_jobs', input)
69
+ if (isExecutionError(result)) {
70
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], isError: true }
71
+ }
72
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] }
73
+ }
@@ -0,0 +1,52 @@
1
+ import { z } from 'zod'
2
+ import { executeWithFallback, isExecutionError } from '../executor.js'
3
+
4
+ export const searchPlacesName = 'search_places'
5
+
6
+ export const searchPlacesDescription =
7
+ 'Search local businesses and places via 2 providers (Openmart Search, Google Maps Scraper) — useful for territory mapping, local-services prospecting, restaurant/retail/vertical research. Routes by input: structured filters or country in {US,CA,AU,PR,NZ} → Openmart (sync, ~1s) first, then Google Maps Scraper (async, ~30–120s) as fallback or for global coverage. Use provider="openmart"|"google_maps" to pin to one. Cost: 1 credit per place returned (both providers).'
8
+
9
+ export const searchPlacesSchema = {
10
+ query: z.string().optional().describe('Free-text query (e.g. "coffee shops in Brooklyn", "law firm New York"). Used by both providers.'),
11
+ country: z.string().optional().describe('ISO country code. Openmart accepts US/CA/AU/PR/NZ only — outside this set, routes to Google Maps. Auto-cased per provider.'),
12
+ city: z.string().optional().describe('City name. Used by both providers.'),
13
+ limit: z.number().int().min(1).max(200).default(25).describe('Max places per provider call (1–200). Openmart caps at 500 internally; Google Maps at 200. Default 25.'),
14
+ provider: z.enum(['openmart', 'google_maps']).optional().describe('Pin to one provider; skips the other. Useful for explicit cross-provider comparison via separate calls.'),
15
+
16
+ state: z.string().optional().describe('Openmart only. State name or 2-letter code.'),
17
+ zip_code: z.string().optional().describe('Openmart only.'),
18
+ lat: z.number().optional().describe('Openmart only. Latitude for radius search.'),
19
+ long: z.number().optional().describe('Openmart only. Longitude for radius search.'),
20
+ geo_radius: z.number().int().positive().optional().describe('Openmart only. Search radius in meters around lat/long.'),
21
+
22
+ tags: z.array(z.string()).max(100).optional().describe('Openmart only. Category tags (mutually exclusive with query upstream — query is ignored if tags non-empty).'),
23
+
24
+ min_overall_rating: z.number().min(0).max(5).optional().describe('Openmart only.'),
25
+ max_overall_rating: z.number().min(0).max(5).optional().describe('Openmart only.'),
26
+ min_total_reviews: z.number().int().min(0).optional().describe('Openmart only.'),
27
+ max_total_reviews: z.number().int().min(0).optional().describe('Openmart only.'),
28
+
29
+ ownership_type: z.enum(['INDEPENDENT', 'FAMILY', 'FRANCHISE', 'CHAIN']).optional().describe('Openmart only.'),
30
+ has_website: z.boolean().optional().describe('Openmart only.'),
31
+ has_valid_website: z.boolean().optional().describe('Openmart only.'),
32
+ has_contact_info: z.boolean().optional().describe('Openmart only.'),
33
+ min_price_tier: z.number().int().optional().describe('Openmart only. 1–4 (1=$, 4=$$$$).'),
34
+ max_price_tier: z.number().int().optional().describe('Openmart only.'),
35
+
36
+ include_keywords: z.array(z.string()).max(64).optional().describe('Openmart only. Free-text terms that must appear in result fields.'),
37
+ exclude_keywords: z.array(z.string()).max(64).optional().describe('Openmart only.'),
38
+ exclude_root_domains: z.array(z.string()).max(10000).optional().describe('Openmart only. Skip results whose website root domain is in this list.'),
39
+
40
+ start_urls: z.array(z.string().url()).max(10).optional().describe('Google Maps only. Pre-built google.com/maps URLs (place/search/reviews). Routes to Google Maps only. 1–10 URLs.'),
41
+ include_opening_hours: z.boolean().optional().describe('Google Maps only. Routes to Google Maps only when set.'),
42
+ include_additional_info: z.boolean().optional().describe('Google Maps only. Adds amenities/accessibility fields. Routes to Google Maps only when set.'),
43
+ language: z.string().optional().describe('Google Maps only. ISO 639-1 (e.g. "en", "fr"). Routes to Google Maps only when set.'),
44
+ }
45
+
46
+ export async function searchPlacesHandler(input: Record<string, unknown>) {
47
+ const result = await executeWithFallback('search_places', input)
48
+ if (isExecutionError(result)) {
49
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], isError: true }
50
+ }
51
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] }
52
+ }
@@ -0,0 +1,34 @@
1
+ import { z } from 'zod'
2
+ import { executeWithFallback, isExecutionError } from '../executor.js'
3
+
4
+ export const searchRedditName = 'search_reddit'
5
+
6
+ export const searchRedditDescription =
7
+ 'Scrape Reddit posts and comments via 1 provider (Reddit Scraper). Requires at least one subreddit or post URL. Optionally filter by keyword query within those URLs. Supports sorting, time filters, comment inclusion. Async (~30–120s). Cost: 1 credit per item returned.'
8
+
9
+ export const searchRedditSchema = {
10
+ start_urls: z.array(z.string().url()).min(1).max(25)
11
+ .describe('Reddit URLs to scrape (subreddit, post, or search URL). At least 1 required. Up to 25. Example: ["https://www.reddit.com/r/sales/"]'),
12
+ query: z.string().optional()
13
+ .describe('Optional keyword filter applied within the provided URLs e.g. "best CRM for startups".'),
14
+ type: z.enum(['posts', 'comments']).default('posts')
15
+ .describe('Scrape posts or comments.'),
16
+ sort: z.enum(['relevance', 'hot', 'top', 'new', 'comments']).optional()
17
+ .describe('Sort order for results.'),
18
+ time: z.enum(['hour', 'day', 'week', 'month', 'year', 'all']).optional()
19
+ .describe('Time filter.'),
20
+ limit: z.number().int().min(1).max(200).default(10)
21
+ .describe('Max items to return (1–200). 1 credit per item.'),
22
+ max_comments_per_post: z.number().int().min(1).max(1000).optional()
23
+ .describe('Max comments per post when type=comments.'),
24
+ include_comments: z.boolean().optional()
25
+ .describe('Include comments alongside posts.'),
26
+ }
27
+
28
+ export async function searchRedditHandler(input: Record<string, unknown>) {
29
+ const result = await executeWithFallback('search_reddit', 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,59 @@
1
+ import { z } from 'zod'
2
+ import { executeWithFallback, isExecutionError } from '../executor.js'
3
+
4
+ export const searchSeoName = 'search_seo'
5
+
6
+ export const searchSeoDescription =
7
+ 'Search SEO data — 6 categories. Pick category + required fields:\n' +
8
+ '• keywords: pass keywords[] — search volume or trends\n' +
9
+ '• serp: pass keyword (single string) — Google/Bing/YouTube organic results\n' +
10
+ '• backlinks: pass target (domain or URL) — summary, links, referring domains\n' +
11
+ '• domain: pass target + action ("technologies" | "whois")\n' +
12
+ '• labs: pass target for rank-overview/competitors/ranked-keywords; OR pass keywords[] for keyword-ideas\n' +
13
+ '• page: pass url + page_action ("lighthouse" | "content")\n' +
14
+ 'Optional shared: location, language, limit. All sync. Cost varies by endpoint.'
15
+
16
+ export const searchSeoSchema = {
17
+ category: z.enum(['keywords', 'serp', 'backlinks', 'domain', 'labs', 'page'])
18
+ .describe('Which SEO category to use. Required.'),
19
+
20
+ target: z.string().optional()
21
+ .describe('Domain or URL. Required for: backlinks, domain, labs.'),
22
+ keyword: z.string().optional()
23
+ .describe('Single keyword. Required for: serp.'),
24
+ keywords: z.array(z.string()).optional()
25
+ .describe('Keyword list. Required for: keywords/search-volume, labs/keyword-ideas.'),
26
+ location: z.string().optional()
27
+ .describe('Location name e.g. "United States", "London,England,United Kingdom".'),
28
+ language: z.string().optional()
29
+ .describe('Language code e.g. "en".'),
30
+ limit: z.number().int().min(1).max(700).default(10),
31
+
32
+ date_from: z.string().optional().describe('YYYY-MM-DD. keywords/trends.'),
33
+ date_to: z.string().optional().describe('YYYY-MM-DD. keywords/trends.'),
34
+ time_range: z.string().optional().describe('keywords/trends: "past_7_days", "past_30_days", etc.'),
35
+
36
+ engine: z.enum(['google', 'bing', 'youtube']).default('google'),
37
+ device: z.enum(['desktop', 'mobile']).optional(),
38
+
39
+ action: z.enum(['technologies', 'whois']).optional()
40
+ .describe('domain: "technologies" (tech stack) or "whois". Default: technologies.'),
41
+
42
+ lab_action: z.enum(['ranked-keywords', 'competitors', 'keyword-ideas', 'rank-overview']).optional()
43
+ .describe('labs action. Default: rank-overview when target set; keyword-ideas when keywords[] set.'),
44
+
45
+ url: z.string().url().optional()
46
+ .describe('Full URL to analyze. Required for page category.'),
47
+ page_action: z.enum(['lighthouse', 'content']).default('lighthouse')
48
+ .describe('page: "lighthouse" (Core Web Vitals audit) or "content" (parse page text).'),
49
+ enable_javascript: z.boolean().optional(),
50
+ full_data: z.boolean().optional().describe('Lighthouse only: include full audit data.'),
51
+ }
52
+
53
+ export async function searchSeoHandler(input: Record<string, unknown>) {
54
+ const result = await executeWithFallback('search_seo', input)
55
+ if (isExecutionError(result)) {
56
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], isError: true }
57
+ }
58
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] }
59
+ }
@@ -0,0 +1,24 @@
1
+ import { z } from 'zod'
2
+ import { executeWithFallback, isExecutionError } from '../executor.js'
3
+
4
+ export const searchWebName = 'search_web'
5
+
6
+ export const searchWebDescription =
7
+ 'Search the web for market research, company news, or general lookups. Supports Google search (default) and neural/semantic search via Exa. ' +
8
+ 'NEVER use this to find, verify, or look up people, contacts, LinkedIn profiles, or executives — even as a fallback when find_people returns uncertain results. ' +
9
+ 'find_people is the only tool for people lookups; do not supplement or replace it with web search.'
10
+
11
+ export const searchWebSchema = {
12
+ query: z.string().describe('Search query'),
13
+ num_results: z.number().min(1).max(100).default(10).describe('Number of results (default: 10, max: 100)'),
14
+ country: z.string().optional().describe('Country code for geo-targeting (e.g. "us", "fr")'),
15
+ search_type: z.enum(['general', 'neural']).default('general').describe('"general" for Google search (default), "neural" for semantic search via Exa'),
16
+ }
17
+
18
+ export async function searchWebHandler(input: Record<string, unknown>) {
19
+ const result = await executeWithFallback('search_web', input)
20
+ if (isExecutionError(result)) {
21
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], isError: true }
22
+ }
23
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] }
24
+ }
@@ -0,0 +1,19 @@
1
+ import { z } from 'zod'
2
+ import { executeWithFallback, isExecutionError } from '../executor.js'
3
+
4
+ export const verifyEmailName = 'verify_email'
5
+
6
+ export const verifyEmailDescription =
7
+ 'Check whether an email address is valid and deliverable. Returns verification status (valid, invalid, catch-all, unknown). Use before cold outreach to protect sender reputation.'
8
+
9
+ export const verifyEmailSchema = {
10
+ email: z.string().email().describe('Email address to verify'),
11
+ }
12
+
13
+ export async function verifyEmailHandler(input: Record<string, unknown>) {
14
+ const result = await executeWithFallback('verify_email', input)
15
+ if (isExecutionError(result)) {
16
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], isError: true }
17
+ }
18
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] }
19
+ }
@@ -0,0 +1,77 @@
1
+ import { initClient } from './src/client.js'
2
+ import { searchAdsHandler } from './src/tools/search-ads.js'
3
+
4
+ const API_URL = 'https://api.coldiq.com'
5
+ const API_KEY = 'ciq_test_3797d4ddc5542ceb'
6
+
7
+ initClient(API_URL, API_KEY)
8
+
9
+ async function run(label: string, input: Record<string, unknown>) {
10
+ console.log(`\n${'─'.repeat(60)}`)
11
+ console.log(`TEST: ${label}`)
12
+ console.log(`INPUT: ${JSON.stringify(input)}`)
13
+ const result = await searchAdsHandler(input)
14
+ const parsed = JSON.parse(result.content[0].text)
15
+ if (result.isError) {
16
+ console.log(`RESULT: ERROR — ${parsed.error}`)
17
+ if (parsed.providers_tried?.length) {
18
+ console.log(`PROVIDERS TRIED: ${parsed.providers_tried.map((p: { id: string }) => p.id).join(' → ')}`)
19
+ }
20
+ return
21
+ }
22
+ const meta = parsed._meta
23
+ const ads = (parsed.data as { ads?: Array<Record<string, unknown>> }).ads ?? []
24
+ console.log(`RESULT: provider=${meta.provider} latency=${meta.latencyMs}ms ads=${ads.length}`)
25
+ for (const a of ads.slice(0, 3)) {
26
+ console.log(` • ${a.advertiserName ?? a.advertiser ?? a.page_name ?? '-'} — ${a.title ?? a.headline ?? '-'}`)
27
+ }
28
+ }
29
+
30
+ async function main() {
31
+ // Test 1: Domain → Google only
32
+ await run('Domain lookup (Google expected)', {
33
+ domains: ['salesforce.com'],
34
+ max_results: 10,
35
+ })
36
+
37
+ // Test 2: LinkedIn URL → LinkedIn only
38
+ await run('LinkedIn URL (LinkedIn expected)', {
39
+ search_urls: ['https://www.linkedin.com/ad-library/search?accountOwner=salesforce'],
40
+ max_results: 10,
41
+ })
42
+
43
+ // Test 3: Pin platform=meta with keyword
44
+ await run('Meta pinned (keyword)', {
45
+ query: 'HubSpot',
46
+ platform: 'meta',
47
+ max_results: 10,
48
+ })
49
+
50
+ // Test 4: Pin platform=twitter with keyword
51
+ await run('Twitter pinned (keyword)', {
52
+ query: 'ColdIQ',
53
+ platform: 'twitter',
54
+ max_results: 10,
55
+ })
56
+
57
+ // Test 5: Pin platform=reddit with industry filter
58
+ await run('Reddit pinned (TECH_B2B)', {
59
+ platform: 'reddit',
60
+ industry: 'TECH_B2B',
61
+ query: 'sales automation',
62
+ })
63
+
64
+ // Test 6: Bare query — runs Google → Meta → Twitter → Reddit waterfall
65
+ await run('Bare query waterfall (Google→Meta→Twitter→Reddit)', {
66
+ query: 'Stripe',
67
+ max_results: 10,
68
+ })
69
+
70
+ // Test 7: platform=linkedin without search_urls — no provider applicable
71
+ await run('LinkedIn pin without URLs (expect failure)', {
72
+ platform: 'linkedin',
73
+ query: 'Salesforce',
74
+ })
75
+ }
76
+
77
+ main().catch(console.error)
@@ -0,0 +1,91 @@
1
+ import { initClient } from './src/client.js'
2
+ import { enrichCompanyHandler } from './src/tools/enrich-company.js'
3
+
4
+ const API_URL = 'https://api.coldiq.com'
5
+ const API_KEY = 'ciq_test_3797d4ddc5542ceb'
6
+
7
+ initClient(API_URL, API_KEY)
8
+
9
+ async function run(label: string, input: Record<string, unknown>) {
10
+ console.log(`\n${'─'.repeat(60)}`)
11
+ console.log(`TEST: ${label}`)
12
+ console.log(`INPUT: ${JSON.stringify(input)}`)
13
+ const result = await enrichCompanyHandler(input)
14
+ const parsed = JSON.parse(result.content[0].text)
15
+ if (result.isError) {
16
+ console.log(`RESULT: ERROR — ${parsed.error}`)
17
+ if (parsed.providers_tried?.length) {
18
+ console.log(`PROVIDERS TRIED: ${parsed.providers_tried.map((p: { id: string }) => p.id).join(' → ')}`)
19
+ }
20
+ } else {
21
+ const meta = parsed._meta
22
+ const data = parsed.data as Record<string, unknown>
23
+ console.log(`RESULT: provider=${meta.provider} latency=${meta.latencyMs}ms`)
24
+ const name = data?.name ?? data?.display_name ?? (data as { organization?: { name?: string } })?.organization?.name ?? '-'
25
+ const domain = data?.domain ?? data?.website ?? '-'
26
+ const industry = data?.industry ?? data?.industries ?? '-'
27
+ const employees = data?.employee_count ?? data?.employees ?? data?.size ?? data?.estimated_num_employees ?? '-'
28
+ const countryRaw = data?.country ?? data?.hq_country ?? (data?.location as Record<string, unknown>)?.country ?? '-'
29
+ const country = typeof countryRaw === 'object' && countryRaw !== null
30
+ ? ((countryRaw as Record<string, unknown>).name ?? JSON.stringify(countryRaw))
31
+ : countryRaw
32
+ console.log(` name=${name} | domain=${domain} | industry=${industry} | employees=${employees} | country=${country}`)
33
+ }
34
+ }
35
+
36
+ async function main() {
37
+ // Test 1: Well-known company by domain — should hit CompanyEnrich first
38
+ await run('Well-known domain (stripe.com)', {
39
+ domain: 'stripe.com',
40
+ })
41
+
42
+ // Test 2: Small company by domain — exercises later fallbacks
43
+ await run('Small company domain (coldiq.com)', {
44
+ domain: 'coldiq.com',
45
+ })
46
+
47
+ // Test 3: LinkedIn URL only — skips CompanyEnrich GET and Apollo
48
+ await run('LinkedIn URL only (stripe)', {
49
+ linkedin_url: 'https://www.linkedin.com/company/stripe',
50
+ })
51
+
52
+ // Test 4: Company name only — only PDL / Findymail / Prospeo / CompanyEnrich POST eligible
53
+ await run('Name only (Stripe)', {
54
+ name: 'Stripe',
55
+ })
56
+
57
+ // Test 5: Domain + LinkedIn URL together — best-case identifier set
58
+ await run('Domain + LinkedIn URL (Stripe)', {
59
+ domain: 'stripe.com',
60
+ linkedin_url: 'https://www.linkedin.com/company/stripe',
61
+ })
62
+
63
+ // Test 6: All three identifiers
64
+ await run('All three identifiers (Stripe)', {
65
+ domain: 'stripe.com',
66
+ linkedin_url: 'https://www.linkedin.com/company/stripe',
67
+ name: 'Stripe',
68
+ })
69
+
70
+ // Test 7: Bogus domain — should exhaust the applicable waterfall
71
+ await run('Bogus domain (should fail all providers)', {
72
+ domain: 'zzznotreal-fake-domain-12345.example',
73
+ })
74
+
75
+ // Test 8: BuiltWith fallback — small domain that top providers may not index,
76
+ // but BuiltWith scrapes any live site. Check _meta.provider in output.
77
+ await run('BuiltWith fallback candidate (plotly.com)', {
78
+ domain: 'plotly.com',
79
+ })
80
+
81
+ // Test 9: Openmart fallback — retail/SMB brand Openmart specializes in.
82
+ // Check _meta.provider to see if Openmart is reached or an earlier provider wins.
83
+ await run('Openmart fallback candidate (allbirds.com)', {
84
+ domain: 'allbirds.com',
85
+ })
86
+
87
+ // Test 10: No identifiers — should return guard error before any provider is tried
88
+ await run('No identifiers (expect guard error)', {})
89
+ }
90
+
91
+ main().catch(console.error)
@@ -0,0 +1,171 @@
1
+ import { initClient } from './src/client.js'
2
+ import { enrichEmailHandler } from './src/tools/enrich-email.js'
3
+ import { enrichEmailsHandler } from './src/tools/enrich-emails.js'
4
+ import { verifyEmailHandler } from './src/tools/verify-email.js'
5
+
6
+ const API_URL = 'https://api.coldiq.com'
7
+ const API_KEY = 'ciq_test_3797d4ddc5542ceb'
8
+
9
+ initClient(API_URL, API_KEY)
10
+
11
+ type Handler = (input: Record<string, unknown>) => Promise<{
12
+ content: Array<{ type: string; text: string }>
13
+ isError?: boolean
14
+ }>
15
+
16
+ function pickEmail(data: unknown): string | null {
17
+ if (!data || typeof data !== 'object') return null
18
+ const d = data as Record<string, unknown>
19
+ if (typeof d.email === 'string' && d.email.includes('@')) return d.email
20
+ if (Array.isArray(d.emails) && typeof d.emails[0] === 'string' && d.emails[0].includes('@')) return d.emails[0] as string
21
+ const person = d.person as Record<string, unknown> | undefined
22
+ const emailObj = person?.email as Record<string, unknown> | undefined
23
+ if (typeof emailObj?.email === 'string' && emailObj.email.includes('@')) return emailObj.email as string
24
+ const inner = d.data as unknown
25
+ if (Array.isArray(inner) && inner[0]) {
26
+ const emails = (inner[0] as Record<string, unknown>).emails
27
+ if (Array.isArray(emails) && typeof emails[0] === 'string') return emails[0] as string
28
+ }
29
+ return null
30
+ }
31
+
32
+ async function run(label: string, handler: Handler, input: Record<string, unknown>) {
33
+ console.log(`\n${'─'.repeat(60)}`)
34
+ console.log(`TEST: ${label}`)
35
+ console.log(`INPUT: ${JSON.stringify(input)}`)
36
+ const result = await handler(input)
37
+ const parsed = JSON.parse(result.content[0].text)
38
+
39
+ if (result.isError) {
40
+ console.log(`RESULT: ERROR — ${parsed.error}`)
41
+ if (Array.isArray(parsed.providers_tried)) {
42
+ console.log(`PROVIDERS TRIED (${parsed.providers_tried.length}):`)
43
+ for (const p of parsed.providers_tried) {
44
+ console.log(` • ${p.id}: status=${p.status ?? '?'} error=${p.error ?? '-'}`)
45
+ }
46
+ }
47
+ return
48
+ }
49
+
50
+ const meta = parsed._meta
51
+ if (meta) {
52
+ console.log(`RESULT: provider=${meta.provider} latency=${meta.latencyMs}ms`)
53
+ const email = pickEmail(parsed.data)
54
+ if (email) {
55
+ console.log(`EMAIL: ${email}`)
56
+ } else {
57
+ const d = parsed.data as Record<string, unknown>
58
+ const status = d.status ?? d.result ?? d.valid ?? d.state
59
+ if (status !== undefined) console.log(`STATUS: ${JSON.stringify(status)}`)
60
+ }
61
+ } else {
62
+ // enrich_emails batch — no _meta
63
+ const { results, found, total } = parsed.data
64
+ console.log(`RESULT: found=${found}/${total}`)
65
+ for (const r of results) {
66
+ console.log(` • ${r.id}: email=${r.email ?? 'null'} provider=${r.provider ?? 'null'}`)
67
+ }
68
+ }
69
+ }
70
+
71
+ async function main() {
72
+ // ── enrich_email: 8-provider waterfall ────────────────────────────────────
73
+
74
+ // Test 1: Happy path — name + domain
75
+ // Expected: findymail (priority 1) wins. blitzapi + limadata-work-email-linkedin skipped (no linkedin_url).
76
+ await run('enrich_email — happy path (name + domain)', enrichEmailHandler, {
77
+ first_name: 'Michel',
78
+ last_name: 'Lieben',
79
+ domain: 'coldiq.com',
80
+ })
81
+
82
+ // Test 2: Name + company_name, no domain
83
+ // Expected: limadata-work-email skipped (requires domain). icypeas (priority 2) likely wins via company_name.
84
+ await run('enrich_email — name + company_name, no domain (Satya Nadella / Microsoft)', enrichEmailHandler, {
85
+ first_name: 'Satya',
86
+ last_name: 'Nadella',
87
+ company_name: 'Microsoft',
88
+ })
89
+
90
+ // Test 3: LinkedIn URL + name, no domain
91
+ // Expected: limadata-work-email skipped (no domain). prospeo (priority 4) likely wins via linkedin_url.
92
+ // blitzapi + limadata-work-email-linkedin are now eligible (have linkedin_url).
93
+ await run('enrich_email — linkedin_url + name, no domain', enrichEmailHandler, {
94
+ first_name: 'Michel',
95
+ last_name: 'Lieben',
96
+ linkedin_url: 'https://www.linkedin.com/in/michel-lieben',
97
+ })
98
+
99
+ // Test 4: Full input — all 8 providers eligible
100
+ // Expected: findymail wins. Debug trace shows 0 skips.
101
+ await run('enrich_email — full input (all 8 providers eligible)', enrichEmailHandler, {
102
+ first_name: 'Michel',
103
+ last_name: 'Lieben',
104
+ domain: 'coldiq.com',
105
+ company_name: 'ColdIQ',
106
+ linkedin_url: 'https://www.linkedin.com/in/michel-lieben',
107
+ })
108
+
109
+ // Test 5: Forced fallthrough — fake name at real domain
110
+ // Expected: providers 1-3 miss (no such person), waterfall marches to prospeo/fullenrich/linkupapi.
111
+ // Most important scenario for confirming priority order. fullenrich may add 5-10s (async poll).
112
+ await run('enrich_email — forced fallthrough (fake name at coldiq.com)', enrichEmailHandler, {
113
+ first_name: 'Zerphus',
114
+ last_name: 'Mclaren',
115
+ domain: 'coldiq.com',
116
+ })
117
+
118
+ // Test 6: Total failure — fabricated person + nonexistent domain
119
+ // Expected: isError=true, providers_tried.length === 6 (skips blitzapi + limadata-work-email-linkedin).
120
+ await run('enrich_email — total failure (fake person + nonexistent domain)', enrichEmailHandler, {
121
+ first_name: 'Zerphus',
122
+ last_name: 'Mclaren',
123
+ domain: 'this-domain-does-not-exist-zxcv.io',
124
+ })
125
+
126
+ // ── enrich_emails: 3-stage batch waterfall ────────────────────────────────
127
+
128
+ // Test 7: Mixed batch — all 3 stages
129
+ // michel: Prospeo stage 1 via linkedin_url (most likely)
130
+ // satya: FullEnrich stage 2 or FindyMail stage 3 (no linkedin_url)
131
+ // fake-person: provider=null expected
132
+ await run('enrich_emails — mixed batch (Prospeo → FullEnrich → FindyMail/IcyPeas)', enrichEmailsHandler, {
133
+ people: [
134
+ {
135
+ id: 'michel',
136
+ first_name: 'Michel',
137
+ last_name: 'Lieben',
138
+ domain: 'coldiq.com',
139
+ linkedin_url: 'https://www.linkedin.com/in/michel-lieben',
140
+ },
141
+ {
142
+ id: 'satya',
143
+ first_name: 'Satya',
144
+ last_name: 'Nadella',
145
+ domain: 'microsoft.com',
146
+ },
147
+ {
148
+ id: 'fake-person',
149
+ first_name: 'Zerphus',
150
+ last_name: 'Mclaren',
151
+ domain: 'coldiq.com',
152
+ },
153
+ ],
154
+ })
155
+
156
+ // ── verify_email: findymail → icypeas waterfall ───────────────────────────
157
+
158
+ // Test 8: Known-valid email
159
+ // Expected: findymail wins; response has a status/result/valid field.
160
+ await run('verify_email — valid email (michel@coldiq.com)', verifyEmailHandler, {
161
+ email: 'michel@coldiq.com',
162
+ })
163
+
164
+ // Test 9: Nonexistent email
165
+ // Expected: findymail returns an invalid status (still counts as hasResult → wins immediately).
166
+ await run('verify_email — invalid email (noreply-zzz-9999@coldiq.com)', verifyEmailHandler, {
167
+ email: 'noreply-zzz-9999@coldiq.com',
168
+ })
169
+ }
170
+
171
+ main().catch(console.error)