@coldiq/mcp 0.1.11 → 0.1.13

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 (35) hide show
  1. package/package.json +5 -1
  2. package/src/client.ts +31 -1
  3. package/src/executor.ts +36 -14
  4. package/src/registry.ts +111 -29
  5. package/src/tools/enrich-company.ts +4 -4
  6. package/src/tools/enrich-person.ts +3 -3
  7. package/src/tools/fetch-page-content.ts +3 -3
  8. package/src/tools/find-email.ts +4 -4
  9. package/src/tools/find-emails.ts +69 -5
  10. package/src/tools/find-influencers.ts +3 -3
  11. package/src/tools/find-people.ts +5 -5
  12. package/src/tools/find-phone.ts +4 -4
  13. package/src/tools/find-signals.ts +5 -5
  14. package/src/tools/search-ads.ts +3 -3
  15. package/src/tools/search-companies.ts +3 -3
  16. package/src/tools/search-jobs.ts +4 -4
  17. package/src/tools/search-places.ts +3 -3
  18. package/src/tools/search-reddit.ts +3 -3
  19. package/src/tools/search-seo.ts +3 -3
  20. package/src/tools/search-web.ts +3 -3
  21. package/src/tools/verify-email.ts +3 -3
  22. package/tests/client.test.ts +32 -0
  23. package/tests/executor.test.ts +81 -0
  24. package/tests/live/companyenrich-async-probe.ts +31 -0
  25. package/tests/live/companyenrich-fix-validation.ts +125 -0
  26. package/tests/live/companyenrich-hybrid-probe.ts +54 -0
  27. package/tests/live/companyenrich-query-probe.ts +49 -0
  28. package/tests/live/companyenrich-ranges-probe.ts +49 -0
  29. package/tests/live/companyenrich-upstream-probe.ts +72 -0
  30. package/tests/live/wework-brazil-trace.ts +200 -0
  31. package/tests/registry-find-people.test.ts +111 -0
  32. package/tests/registry-polling.test.ts +61 -0
  33. package/tests/registry.test.ts +35 -18
  34. package/tests/tools/find-emails.test.ts +108 -0
  35. package/tests/tools/response-format.test.ts +45 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coldiq/mcp",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -16,8 +16,12 @@
16
16
  "version": "git add package.json package-lock.json",
17
17
  "postversion": "git commit -m \"chore(mcp): release $npm_package_version\" && git tag mcp-v$npm_package_version && git push && git push --tags"
18
18
  },
19
+ "engines": {
20
+ "node": ">=18.18"
21
+ },
19
22
  "dependencies": {
20
23
  "@modelcontextprotocol/sdk": "^1.12.1",
24
+ "undici": "^6.25.0",
21
25
  "zod": "^3.24.2"
22
26
  },
23
27
  "devDependencies": {
package/src/client.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { Agent, setGlobalDispatcher } from 'undici'
2
+
1
3
  export type ApiResponse = {
2
4
  ok: boolean
3
5
  status: number
@@ -6,15 +8,29 @@ export type ApiResponse = {
6
8
 
7
9
  let _apiUrl: string
8
10
  let _apiKey: string
11
+ let _httpTimeoutMs: number
9
12
 
10
13
  export function initClient(apiUrl?: string, apiKey?: string): void {
11
14
  _apiUrl = apiUrl ?? process.env.COLDIQ_API_URL ?? 'https://api.coldiq.com'
12
15
  _apiKey = apiKey ?? process.env.COLDIQ_API_KEY ?? ''
16
+ _httpTimeoutMs = parseInt(process.env.COLDIQ_HTTP_TIMEOUT_MS ?? '30000', 10)
13
17
  if (!_apiKey) {
14
18
  throw new Error('COLDIQ_API_KEY is required — set it in .mcp.json env or as an environment variable')
15
19
  }
16
20
  // Strip trailing slash
17
21
  _apiUrl = _apiUrl.replace(/\/+$/, '')
22
+
23
+ // Tune the global undici dispatcher: keep-alive + connection pool.
24
+ // pipelining: 1 — Cloudflare front-door does not reliably support HTTP/1.1 pipelining.
25
+ // connections: 32 — covers worst-case find_emails fan-out (50 people × 2 providers).
26
+ setGlobalDispatcher(
27
+ new Agent({
28
+ keepAliveTimeout: 30_000,
29
+ keepAliveMaxTimeout: 60_000,
30
+ connections: 32,
31
+ pipelining: 1,
32
+ }),
33
+ )
18
34
  }
19
35
 
20
36
  export async function callApi(
@@ -34,7 +50,11 @@ export async function callApi(
34
50
  Authorization: `Bearer ${_apiKey}`,
35
51
  }
36
52
 
37
- const init: RequestInit = { method, headers }
53
+ const init: RequestInit = {
54
+ method,
55
+ headers,
56
+ signal: AbortSignal.timeout(_httpTimeoutMs),
57
+ }
38
58
 
39
59
  if (method === 'POST' && body !== undefined) {
40
60
  headers['Content-Type'] = 'application/json'
@@ -51,6 +71,16 @@ export async function callApi(
51
71
  }
52
72
  return { ok: res.ok, status: res.status, data }
53
73
  } catch (err) {
74
+ const isTimeout =
75
+ err instanceof Error &&
76
+ (err.name === 'AbortError' || err.name === 'TimeoutError')
77
+ if (isTimeout) {
78
+ return {
79
+ ok: false,
80
+ status: 0,
81
+ data: { error: 'Request timed out' },
82
+ }
83
+ }
54
84
  if (process.env.COLDIQ_DEBUG) {
55
85
  console.error(`[coldiq:debug] network error: ${err instanceof Error ? err.message : String(err)}`)
56
86
  }
package/src/executor.ts CHANGED
@@ -51,6 +51,8 @@ async function executeAsync(
51
51
  payload: { body?: unknown; queryParams?: Record<string, string> },
52
52
  ): Promise<{ ok: boolean; data: unknown }> {
53
53
  const asyncCfg = provider.async!
54
+ const firstPollMs = parseInt(process.env.COLDIQ_FIRST_POLL_MS ?? '750', 10)
55
+ const maxPollErrors = parseInt(process.env.COLDIQ_MAX_POLL_ERRORS ?? '3', 10)
54
56
 
55
57
  // Step 1: create the job
56
58
  debug(`${provider.id} async: creating job...`)
@@ -70,28 +72,48 @@ async function executeAsync(
70
72
  if (!jobId) return { ok: false, data: { error: 'Empty job ID from async response' } }
71
73
  debug(`${provider.id} async: job created (${jobId}), polling...`)
72
74
 
73
- // Step 3: poll until complete or timeout
75
+ // Step 3: poll until complete or timeout.
76
+ // First probe fires quickly — capped by both COLDIQ_FIRST_POLL_MS and the provider's
77
+ // configured first interval, so tests that set a short pollIntervalMs aren't blocked
78
+ // by the default 750ms.
74
79
  const deadline = Date.now() + asyncCfg.timeoutMs
75
80
  let pollCount = 0
81
+ let consecutivePollErrors = 0
82
+
83
+ const configuredFirstInterval = typeof asyncCfg.pollIntervalMs === 'function'
84
+ ? asyncCfg.pollIntervalMs(1)
85
+ : asyncCfg.pollIntervalMs
86
+ await sleep(Math.min(firstPollMs, configuredFirstInterval))
87
+
76
88
  while (Date.now() < deadline) {
89
+ const pollRes = await callApi('GET', asyncCfg.pollEndpoint(jobId))
90
+ pollCount++
91
+
92
+ if (!pollRes.ok) {
93
+ consecutivePollErrors++
94
+ if (consecutivePollErrors >= maxPollErrors) {
95
+ log(`${provider.id} async: poll failing (${consecutivePollErrors} consecutive errors), skipping`)
96
+ return { ok: false, data: { error: `Poll endpoint failed ${consecutivePollErrors} consecutive times` } }
97
+ }
98
+ } else {
99
+ consecutivePollErrors = 0
100
+ const status = (pollRes.data as Record<string, unknown>).status ?? 'unknown'
101
+ const progress = (pollRes.data as Record<string, unknown>).progress_percentage ?? '?'
102
+ debug(`${provider.id} async: poll #${pollCount} — status=${status}, progress=${progress}%`)
103
+
104
+ if (asyncCfg.isComplete(pollRes.data)) {
105
+ debug(`${provider.id} async: complete`)
106
+ return pollRes
107
+ }
108
+ }
109
+
110
+ if (Date.now() >= deadline) break
111
+
77
112
  const interval =
78
113
  typeof asyncCfg.pollIntervalMs === 'function'
79
114
  ? asyncCfg.pollIntervalMs(pollCount + 1)
80
115
  : asyncCfg.pollIntervalMs
81
116
  await sleep(interval)
82
- pollCount++
83
-
84
- const pollRes = await callApi('GET', asyncCfg.pollEndpoint(jobId))
85
- if (!pollRes.ok) continue // transient poll errors — retry
86
-
87
- const status = (pollRes.data as Record<string, unknown>).status ?? 'unknown'
88
- const progress = (pollRes.data as Record<string, unknown>).progress_percentage ?? '?'
89
- debug(`${provider.id} async: poll #${pollCount} — status=${status}, progress=${progress}%`)
90
-
91
- if (asyncCfg.isComplete(pollRes.data)) {
92
- debug(`${provider.id} async: complete`)
93
- return pollRes
94
- }
95
117
  }
96
118
 
97
119
  debug(`${provider.id} async: timed out after ${asyncCfg.timeoutMs / 1000}s`)
package/src/registry.ts CHANGED
@@ -109,6 +109,60 @@ export function isoCountryToName(code: string): string {
109
109
  }
110
110
  }
111
111
 
112
+ // Reverse lookup: English country name → ISO 3166-1 alpha-2. Built lazily on first use.
113
+ // Agents routinely pass "Brazil" / "United States" instead of "BR" / "US" — without this
114
+ // coercion, downstream providers that require ISO codes (LeadsFactory, BlitzAPI) silently
115
+ // skip the filter.
116
+ let _nameToCode: Map<string, string> | undefined
117
+ function buildNameToCode(): Map<string, string> {
118
+ const map = new Map<string, string>()
119
+ // Iterate all ISO-2 codes A-Z × A-Z; keep those Intl.DisplayNames resolves.
120
+ for (let a = 65; a <= 90; a++) {
121
+ for (let b = 65; b <= 90; b++) {
122
+ const code = String.fromCharCode(a, b)
123
+ try {
124
+ const name = _displayNames.of(code)
125
+ if (name && name !== code) map.set(name.toLowerCase(), code)
126
+ } catch { /* skip */ }
127
+ }
128
+ }
129
+ // Common aliases not covered by Intl.DisplayNames defaults.
130
+ map.set('usa', 'US')
131
+ map.set('uk', 'GB')
132
+ map.set('south korea', 'KR')
133
+ map.set('north korea', 'KP')
134
+ map.set('russia', 'RU')
135
+ map.set('iran', 'IR')
136
+ map.set('syria', 'SY')
137
+ map.set('uae', 'AE')
138
+ map.set('emirates', 'AE')
139
+ return map
140
+ }
141
+
142
+ export function nameToIsoCountry(input: string): string | undefined {
143
+ if (typeof input !== 'string' || input.length === 0) return undefined
144
+ if (!_nameToCode) _nameToCode = buildNameToCode()
145
+ // Check aliases first — "UK" matches the ISO-2 regex but is not a valid code (GB is).
146
+ const aliased = _nameToCode.get(input.trim().toLowerCase())
147
+ if (aliased) return aliased
148
+ if (_ALPHA2_RE.test(input)) {
149
+ const upper = input.toUpperCase()
150
+ // Only accept if Intl actually resolves it — rejects "ZZ" etc.
151
+ try {
152
+ if (_displayNames.of(upper)) return upper
153
+ } catch { /* fall through */ }
154
+ }
155
+ return undefined
156
+ }
157
+
158
+ // Coerce a list of strings (mix of ISO-2 codes and country names) into ISO-2 codes.
159
+ // Unrecognized inputs are dropped (rather than passed through) so providers don't
160
+ // receive garbage like { code: "Brazil", name: "Brazil" }.
161
+ export function coerceCountriesToIso(values: readonly string[] | undefined): string[] {
162
+ if (!values?.length) return []
163
+ return values.map((v) => nameToIsoCountry(v)).filter((c): c is string => typeof c === 'string')
164
+ }
165
+
112
166
  // ---------------------------------------------------------------------------
113
167
  // Strict post-filter helpers
114
168
  // ---------------------------------------------------------------------------
@@ -200,9 +254,12 @@ const searchCompaniesProviders: ProviderEntry[] = [
200
254
  method: 'POST',
201
255
  priority: 1,
202
256
  mapParams: (input) => {
203
- // CompanyEnrich industries require exact taxonomy matches free-text values like
204
- // "SaaS" produce empty results. Fold industries into keywords for flexible matching.
205
- const keywords = [
257
+ // CompanyEnrich's `keywords` body field is a tag filter that does not match
258
+ // brand names sending "wework" returns global defaults instead of WeWork.
259
+ // Route free-text input through `query` (full-text on company name + domain).
260
+ // Industries are folded into the same string because CompanyEnrich's `industries`
261
+ // field requires exact taxonomy matches and rejects free-text like "SaaS".
262
+ const queryTerms = [
206
263
  ...((input.keywords as string[] | undefined) ?? []),
207
264
  ...((input.industries as string[] | undefined) ?? []),
208
265
  ]
@@ -213,7 +270,7 @@ const searchCompaniesProviders: ProviderEntry[] = [
213
270
  return {
214
271
  body: {
215
272
  countries: input.countries,
216
- keywords: keywords.length > 0 ? keywords : undefined,
273
+ query: queryTerms.length > 0 ? queryTerms.join(' ') : undefined,
217
274
  technologies: isNonEmptyArray(input.technologies) ? input.technologies : undefined,
218
275
  employees:
219
276
  input.min_employees || input.max_employees
@@ -944,6 +1001,14 @@ const findPeopleProviders: ProviderEntry[] = [
944
1001
  ? [...groups.values()].map((titles) => ({ job_title: titles.join(', ') }))
945
1002
  : [{ job_title: 'Decision Maker' }]
946
1003
  const hasLinkedInUrls = isNonEmptyArray(input.company_linkedin_urls)
1004
+ // LF natively scopes contacts by person location via country_codes. Coerce
1005
+ // input.locations (which agents pass as a mix of ISO-2 codes "BR" and country
1006
+ // names "Brazil") to ISO-2 first — LF rejects/ignores anything else and silently
1007
+ // returns unfiltered global results.
1008
+ const isoCodes = coerceCountriesToIso(input.locations as string[] | undefined)
1009
+ const country_codes = isoCodes.length > 0
1010
+ ? isoCodes.map((code) => ({ code, name: isoCountryToName(code) }))
1011
+ : undefined
947
1012
  return {
948
1013
  body: {
949
1014
  ...(hasLinkedInUrls && { company_linkedin_urls: input.company_linkedin_urls }),
@@ -954,6 +1019,7 @@ const findPeopleProviders: ProviderEntry[] = [
954
1019
  search: {
955
1020
  max_persona_results: (input.limit as number) ?? 25,
956
1021
  personas,
1022
+ ...(country_codes && { country_codes }),
957
1023
  },
958
1024
  },
959
1025
  }
@@ -962,21 +1028,20 @@ const findPeopleProviders: ProviderEntry[] = [
962
1028
  const d = data as Record<string, unknown>
963
1029
  return (
964
1030
  isNonEmptyArray(d.companies_personas) ||
965
- (d.status === 'SUCCESSFUL' && typeof d.nb_jobs_complete === 'number' && (d.nb_jobs_complete as number) > 0)
1031
+ (d.status === 'SUCCESSFUL' && d.nb_jobs_complete != null && (d.nb_jobs_complete as number) > 0)
966
1032
  )
967
1033
  },
968
1034
  async: {
969
1035
  pollEndpoint: (id: string) => `/leadsfactory/contact-finder/searches/${id}`,
970
- // Continuously growing backoff: fast first probe (3s), then ramp up indefinitely
971
- // never plateaus, but the cumulative delay is bounded by timeoutMs.
972
- // Schedule: 3s, 7s, 15s, 25s, 35s, 45s, 55s, 65s, +10s per attempt thereafter.
1036
+ // Growing backoff: fast early probes, then steady growth bounded by timeoutMs.
1037
+ // Schedule: 2s, 5s, 12s, 20s, 28s, 36s, +8s per attempt thereafter.
973
1038
  pollIntervalMs: (attempt) =>
974
- attempt === 1 ? 3000 : attempt === 2 ? 7000 : 15000 + (attempt - 3) * 10000,
1039
+ attempt === 1 ? 2000 : attempt === 2 ? 5000 : 12000 + (attempt - 3) * 8000,
975
1040
  timeoutMs: 300_000, // 5 minutes
976
1041
  isComplete: (data) => {
977
1042
  const d = data as Record<string, unknown>
978
1043
  const status = d.status as string | undefined
979
- return status === 'SUCCESSFUL' || status === 'FAILED'
1044
+ return status === 'SUCCESSFUL' || status === 'FAILED' || status === 'INVALID_URL'
980
1045
  },
981
1046
  extractId: (response) => {
982
1047
  const d = response as Record<string, unknown>
@@ -1368,7 +1433,7 @@ const findEmailProviders: ProviderEntry[] = [
1368
1433
  return d.enrichment_id as string
1369
1434
  },
1370
1435
  pollEndpoint: (id) => `/fullenrich/contact/enrich/bulk/${id}`,
1371
- pollIntervalMs: 5000,
1436
+ pollIntervalMs: 2500,
1372
1437
  timeoutMs: 60_000,
1373
1438
  isComplete: (data) => {
1374
1439
  const d = data as Record<string, unknown>
@@ -1463,7 +1528,7 @@ const verifyEmailProviders: ProviderEntry[] = [
1463
1528
  throw new Error('Instantly verification response has no email field')
1464
1529
  },
1465
1530
  pollEndpoint: (email) => `/instantly/email-verification/${encodeURIComponent(email)}`,
1466
- pollIntervalMs: 3000,
1531
+ pollIntervalMs: 1500,
1467
1532
  timeoutMs: 30_000,
1468
1533
  isComplete: (data) => {
1469
1534
  const d = data as Record<string, unknown>
@@ -1879,7 +1944,7 @@ function _hasJobFilter(input: Record<string, unknown>, keys: string[]): boolean
1879
1944
  }
1880
1945
 
1881
1946
  const _jobsSharedAsync = {
1882
- pollIntervalMs: 5_000,
1947
+ pollIntervalMs: (attempt: number) => (attempt === 1 ? 1500 : attempt <= 3 ? 2500 : 4000),
1883
1948
  timeoutMs: 300_000,
1884
1949
  extractId: (data: unknown) => {
1885
1950
  const jobId = (data as { jobId?: number | string }).jobId
@@ -2013,7 +2078,7 @@ const searchJobsProviders: ProviderEntry[] = [
2013
2078
  // ---------------------------------------------------------------------------
2014
2079
 
2015
2080
  const _adsSharedAsync = {
2016
- pollIntervalMs: 5_000,
2081
+ pollIntervalMs: (attempt: number) => (attempt === 1 ? 1500 : attempt <= 3 ? 2500 : 4000),
2017
2082
  timeoutMs: 300_000,
2018
2083
  extractId: (data: unknown) => {
2019
2084
  const jobId = (data as { jobId?: number | string }).jobId
@@ -2181,7 +2246,7 @@ function _hasAny(input: Record<string, unknown>, keys: readonly string[]): boole
2181
2246
  }
2182
2247
 
2183
2248
  const _placesSharedAsync = {
2184
- pollIntervalMs: 5_000,
2249
+ pollIntervalMs: (attempt: number) => (attempt === 1 ? 1500 : attempt <= 3 ? 2500 : 4000),
2185
2250
  timeoutMs: 300_000,
2186
2251
  extractId: (data: unknown) => {
2187
2252
  const jobId = (data as { jobId?: number | string }).jobId
@@ -2351,7 +2416,7 @@ const findInfluencersProviders: ProviderEntry[] = [
2351
2416
  // ---------------------------------------------------------------------------
2352
2417
 
2353
2418
  const _redditSharedAsync = {
2354
- pollIntervalMs: 5_000,
2419
+ pollIntervalMs: (attempt: number) => (attempt === 1 ? 1500 : attempt <= 3 ? 2500 : 4000),
2355
2420
  timeoutMs: 300_000,
2356
2421
  extractId: (data: unknown) => {
2357
2422
  const jobId = (data as { jobId?: number | string }).jobId
@@ -3093,28 +3158,45 @@ export function getProviderApplicabilityGuard(
3093
3158
  return providers.find((p) => p.id === providerId)?.isApplicable
3094
3159
  }
3095
3160
 
3161
+ const _providersCache = new Map<Capability, ProviderEntry[]>()
3162
+
3096
3163
  /**
3097
3164
  * Get the ordered provider list for a capability.
3098
- * Returns a copy sorted by priority (lower = first).
3165
+ * Returns a frozen sorted copy cached after the first call (registry is static).
3099
3166
  */
3100
3167
  export function getProviders(capability: Capability): ProviderEntry[] {
3101
- const providers = registry[capability]
3102
- if (!providers) throw new Error(`Unknown capability: ${capability}`)
3103
- return [...providers].sort((a, b) => a.priority - b.priority)
3168
+ let cached = _providersCache.get(capability)
3169
+ if (!cached) {
3170
+ const providers = registry[capability]
3171
+ if (!providers) throw new Error(`Unknown capability: ${capability}`)
3172
+ cached = Object.freeze([...providers].sort((a, b) => a.priority - b.priority)) as ProviderEntry[]
3173
+ _providersCache.set(capability, cached)
3174
+ }
3175
+ return cached
3104
3176
  }
3105
3177
 
3178
+ const _searchWebProvidersCache = new Map<boolean, ProviderEntry[]>()
3179
+
3106
3180
  /**
3107
3181
  * Get providers for search_web, reordering for neural search preference.
3182
+ * Result is cached per preferNeural value — registry is static.
3108
3183
  */
3109
3184
  export function getSearchWebProviders(preferNeural: boolean): ProviderEntry[] {
3110
- const providers = getProviders('search_web')
3111
- if (preferNeural) {
3112
- // Put Exa first when neural is requested
3113
- return providers.sort((a, b) => {
3114
- if (a.id === 'exa') return -1
3115
- if (b.id === 'exa') return 1
3116
- return a.priority - b.priority
3117
- })
3185
+ let cached = _searchWebProvidersCache.get(preferNeural)
3186
+ if (!cached) {
3187
+ const base = getProviders('search_web')
3188
+ if (preferNeural) {
3189
+ cached = Object.freeze(
3190
+ [...base].sort((a, b) => {
3191
+ if (a.id === 'exa') return -1
3192
+ if (b.id === 'exa') return 1
3193
+ return a.priority - b.priority
3194
+ }),
3195
+ ) as ProviderEntry[]
3196
+ } else {
3197
+ cached = base
3198
+ }
3199
+ _searchWebProvidersCache.set(preferNeural, cached)
3118
3200
  }
3119
- return providers
3201
+ return cached
3120
3202
  }
@@ -18,15 +18,15 @@ export async function enrichCompanyHandler(input: Record<string, unknown>) {
18
18
  const { use_providers: rawUseProviders, ...restInput } = input
19
19
  if (!restInput.domain && !restInput.linkedin_url && !restInput.name) {
20
20
  const err = { error: 'At least one of domain, linkedin_url, or name is required', providers_tried: [] }
21
- return { content: [{ type: 'text' as const, text: JSON.stringify(err, null, 2) }], isError: true }
21
+ return { content: [{ type: 'text' as const, text: JSON.stringify(err) }], isError: true }
22
22
  }
23
23
  const resolved = resolvePreferredProviders('enrich_company', restInput, rawUseProviders)
24
24
  if (!resolved.ok) {
25
- return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error, null, 2) }], isError: true }
25
+ return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error) }], isError: true }
26
26
  }
27
27
  const result = await executeWithFallback('enrich_company', restInput, { providers: resolved.providers, matchedFrom: resolved.matchedFrom })
28
28
  if (isExecutionError(result)) {
29
- return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], isError: true }
29
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], isError: true }
30
30
  }
31
- return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] }
31
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }
32
32
  }
@@ -25,11 +25,11 @@ export async function enrichPersonHandler(input: Record<string, unknown>) {
25
25
  const { use_providers: rawUseProviders, ...restInput } = input
26
26
  const resolved = resolvePreferredProviders('enrich_person', restInput, rawUseProviders)
27
27
  if (!resolved.ok) {
28
- return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error, null, 2) }], isError: true }
28
+ return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error) }], isError: true }
29
29
  }
30
30
  const result = await executeWithFallback('enrich_person', restInput, { providers: resolved.providers, matchedFrom: resolved.matchedFrom })
31
31
  if (isExecutionError(result)) {
32
- return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], isError: true }
32
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], isError: true }
33
33
  }
34
- return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] }
34
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }
35
35
  }
@@ -33,11 +33,11 @@ export async function fetchPageContentHandler(input: Record<string, unknown>) {
33
33
  const { use_providers: rawUseProviders, ...restInput } = input
34
34
  const resolved = resolvePreferredProviders('fetch_page_content', restInput, rawUseProviders)
35
35
  if (!resolved.ok) {
36
- return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error, null, 2) }], isError: true }
36
+ return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error) }], isError: true }
37
37
  }
38
38
  const result = await executeWithFallback('fetch_page_content', restInput, { providers: resolved.providers, matchedFrom: resolved.matchedFrom })
39
39
  if (isExecutionError(result)) {
40
- return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], isError: true }
40
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], isError: true }
41
41
  }
42
- return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] }
42
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }
43
43
  }
@@ -45,19 +45,19 @@ export async function findEmailHandler(input: Record<string, unknown>) {
45
45
  const { use_providers: rawUseProviders, ...restInput } = input
46
46
  const resolved = resolvePreferredProviders('find_email', restInput, rawUseProviders)
47
47
  if (!resolved.ok) {
48
- return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error, null, 2) }], isError: true }
48
+ return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error) }], isError: true }
49
49
  }
50
50
  const validation = inputSchema.safeParse(restInput)
51
51
  if (!validation.success) {
52
52
  const msg = validation.error.issues.map((i) => i.message).join('; ')
53
53
  return {
54
- content: [{ type: 'text' as const, text: JSON.stringify({ error: msg }, null, 2) }],
54
+ content: [{ type: 'text' as const, text: JSON.stringify({ error: msg }) }],
55
55
  isError: true,
56
56
  }
57
57
  }
58
58
  const result = await executeWithFallback('find_email', restInput, { providers: resolved.providers, matchedFrom: resolved.matchedFrom })
59
59
  if (isExecutionError(result)) {
60
- return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], isError: true }
60
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], isError: true }
61
61
  }
62
- return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] }
62
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }
63
63
  }
@@ -1,7 +1,36 @@
1
1
  import { z } from 'zod'
2
2
  import { callApi } from '../client.js'
3
+ import { executeWithFallback, isExecutionError } from '../executor.js'
3
4
  import { resolvePreferredProviders, buildAllFailedError, FIND_EMAILS_PROVIDERS } from '../utils/provider-resolver.js'
4
5
 
6
+ // Single-find_email registry providers that the bulk pipeline does NOT already cover.
7
+ // Used as a fallback waterfall for residual misses after Prospeo + FullEnrich + Findymail + Icypeas.
8
+ const SINGLE_ONLY_FIND_EMAIL_PROVIDERS = ['limadata-work-email', 'blitzapi', 'limadata-work-email-linkedin', 'linkupapi'] as const
9
+
10
+ // Pulls a string email from any of the provider-specific response shapes used in the
11
+ // find_email registry (registry.ts:1228-1413). Keep in sync with the providers covered there.
12
+ function extractEmail(data: unknown): string | null {
13
+ if (!data || typeof data !== 'object') return null
14
+ const d = data as Record<string, unknown>
15
+ if (typeof d.email === 'string' && d.email.includes('@')) return d.email
16
+ if (Array.isArray(d.emails) && typeof d.emails[0] === 'string' && (d.emails[0] as string).includes('@')) return d.emails[0] as string
17
+ const person = d.person as Record<string, unknown> | undefined
18
+ const personEmail = person?.email as Record<string, unknown> | undefined
19
+ if (typeof personEmail?.email === 'string' && (personEmail.email as string).includes('@')) return personEmail.email as string
20
+ const nested = d.data
21
+ if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
22
+ const inner = (nested as Record<string, unknown>).email
23
+ if (typeof inner === 'string' && inner.includes('@')) return inner
24
+ }
25
+ if (Array.isArray(nested) && nested[0] && typeof nested[0] === 'object') {
26
+ const first = nested[0] as Record<string, unknown>
27
+ if (Array.isArray(first.emails) && typeof first.emails[0] === 'string' && (first.emails[0] as string).includes('@')) {
28
+ return first.emails[0] as string
29
+ }
30
+ }
31
+ return null
32
+ }
33
+
5
34
  export const findEmailsName = 'find_emails'
6
35
 
7
36
  export const findEmailsDescription =
@@ -76,7 +105,7 @@ async function fullEnrichStep(misses: PersonInput[], results: EmailResult[]): Pr
76
105
  // faster, so FullEnrich's marginal value decays past this point.
77
106
  const deadline = Date.now() + 45_000
78
107
  while (Date.now() < deadline) {
79
- await sleep(5000)
108
+ await sleep(2000)
80
109
  const pollRes = await callApi('GET', `/fullenrich/contact/enrich/bulk/${enrichmentId}`)
81
110
  if (!pollRes.ok) continue
82
111
 
@@ -162,7 +191,7 @@ export async function findEmailsHandler(input: Record<string, unknown>) {
162
191
 
163
192
  const resolved = resolvePreferredProviders('find_emails', restInput, rawUseProviders)
164
193
  if (!resolved.ok) {
165
- return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error, null, 2) }], isError: true }
194
+ return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error) }], isError: true }
166
195
  }
167
196
 
168
197
  const allowedProviders = resolved.providers
@@ -218,14 +247,49 @@ export async function findEmailsHandler(input: Record<string, unknown>) {
218
247
  if (!isConstrained || allowedProviders.includes('findymail') || allowedProviders.includes('icypeas')) {
219
248
  steps.push(findymailIcypeasStep(misses, results, isConstrained ? allowedProviders : undefined))
220
249
  }
221
- if (steps.length > 0) await Promise.all(steps)
250
+ if (steps.length > 0) await Promise.allSettled(steps)
251
+ }
252
+
253
+ // Step 4 — single-find_email fallback for stragglers.
254
+ // Bulk covers Prospeo + FullEnrich + FindyMail + IcyPeas. The single-find_email registry
255
+ // (registry.ts:1228-1413) additionally covers LimaData, BlitzAPI, LimaData-LinkedIn, LinkupAPI.
256
+ // Without this step, agents would observe bulk return 0 and fall back to N+1 single calls.
257
+ const stragglers = missesOf(people, results)
258
+ if (stragglers.length > 0) {
259
+ const fallbackProviders = isConstrained
260
+ ? allowedProviders.filter((p) => (SINGLE_ONLY_FIND_EMAIL_PROVIDERS as readonly string[]).includes(p))
261
+ : [...SINGLE_ONLY_FIND_EMAIL_PROVIDERS]
262
+ if (fallbackProviders.length > 0) {
263
+ await Promise.all(
264
+ stragglers.map(async (person) => {
265
+ const hit = results.find((r) => r.id === person.id)
266
+ if (!hit || hit.email) return
267
+ const singleInput: Record<string, unknown> = {
268
+ first_name: person.first_name,
269
+ last_name: person.last_name,
270
+ domain: person.domain,
271
+ linkedin_url: person.linkedin_url,
272
+ }
273
+ // Per-person 30s ceiling so one slow provider doesn't block the rest.
274
+ const exec = executeWithFallback('find_email', singleInput, { providers: fallbackProviders })
275
+ const timeout = new Promise<null>((resolve) => setTimeout(() => resolve(null), 30_000))
276
+ const raced = await Promise.race([exec, timeout])
277
+ if (!raced || isExecutionError(raced)) return
278
+ const email = extractEmail(raced.data)
279
+ if (email && !hit.email) {
280
+ hit.email = email
281
+ hit.provider = raced._meta.provider
282
+ }
283
+ }),
284
+ )
285
+ }
222
286
  }
223
287
 
224
288
  const found = results.filter((r) => r.email !== null).length
225
289
 
226
290
  if (isConstrained && found === 0) {
227
291
  const err = buildAllFailedError(allowedProviders, 'find_emails', FIND_EMAILS_PROVIDERS)
228
- return { content: [{ type: 'text' as const, text: JSON.stringify(err, null, 2) }], isError: true }
292
+ return { content: [{ type: 'text' as const, text: JSON.stringify(err) }], isError: true }
229
293
  }
230
294
 
231
295
  const meta = resolved.matchedFrom && Object.keys(resolved.matchedFrom).length > 0
@@ -236,7 +300,7 @@ export async function findEmailsHandler(input: Record<string, unknown>) {
236
300
  content: [
237
301
  {
238
302
  type: 'text' as const,
239
- text: JSON.stringify({ data: { results, found, total: people.length }, ...(meta ? { _meta: meta } : {}) }, null, 2),
303
+ text: JSON.stringify({ data: { results, found, total: people.length }, ...(meta ? { _meta: meta } : {}) }),
240
304
  },
241
305
  ],
242
306
  }
@@ -31,11 +31,11 @@ export async function findInfluencersHandler(input: Record<string, unknown>) {
31
31
  const { use_providers: rawUseProviders, ...restInput } = input
32
32
  const resolved = resolvePreferredProviders('find_influencers', restInput, rawUseProviders)
33
33
  if (!resolved.ok) {
34
- return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error, null, 2) }], isError: true }
34
+ return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error) }], isError: true }
35
35
  }
36
36
  const result = await executeWithFallback('find_influencers', restInput, { providers: resolved.providers, matchedFrom: resolved.matchedFrom })
37
37
  if (isExecutionError(result)) {
38
- return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], isError: true }
38
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], isError: true }
39
39
  }
40
- return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] }
40
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }
41
41
  }
@@ -22,7 +22,7 @@ export const findPeopleSchema = {
22
22
  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.'),
23
23
  job_titles: z.array(z.string()).optional().describe('Target job titles (e.g. ["CEO", "VP of Sales"])'),
24
24
  seniorities: z.array(z.string()).optional().describe('Seniority levels (e.g. ["c_suite", "vp", "director"])'),
25
- locations: z.array(z.string()).optional().describe('Person or company locations'),
25
+ locations: z.array(z.string()).optional().describe('Person locations as either ISO-2 country codes ("BR") or English country names ("Brazil") — both are accepted and normalized internally. LeadsFactory uses these to scope contacts to the country; other providers fall back to free-text matching.'),
26
26
  keywords: z.array(z.string()).optional().describe('Free-text keyword search terms (e.g. ["growth", "AI"])'),
27
27
  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.'),
28
28
  use_providers: z.array(z.string()).optional().describe(`Optional ordered list of providers to use. Leave empty to let ColdIQ automatically pick the best tool for your inputs — recommended for most use cases. Available providers: ${getProvidersForCapability('find_people').join(', ')}. Provider names are matched fuzzily, so minor typos are tolerated.`),
@@ -32,11 +32,11 @@ export async function findPeopleHandler(input: Record<string, unknown>) {
32
32
  const { use_providers: rawUseProviders, ...restInput } = input
33
33
  const resolved = resolvePreferredProviders('find_people', restInput, rawUseProviders)
34
34
  if (!resolved.ok) {
35
- return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error, null, 2) }], isError: true }
35
+ return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error) }], isError: true }
36
36
  }
37
37
  const result = await executeWithFallback('find_people', restInput, { providers: resolved.providers, matchedFrom: resolved.matchedFrom })
38
38
  if (isExecutionError(result)) {
39
- return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], isError: true }
39
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], isError: true }
40
40
  }
41
41
 
42
42
  // Gap-fill: if LeadsFactory missed some domains, try Apollo for those.
@@ -65,12 +65,12 @@ export async function findPeopleHandler(input: Record<string, unknown>) {
65
65
  },
66
66
  }
67
67
  return {
68
- content: [{ type: 'text' as const, text: JSON.stringify({ ...result, data: merged }, null, 2) }],
68
+ content: [{ type: 'text' as const, text: JSON.stringify({ ...result, data: merged }) }],
69
69
  }
70
70
  }
71
71
  }
72
72
  }
73
73
  }
74
74
 
75
- return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] }
75
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }
76
76
  }