@coldiq/mcp 0.1.11 → 0.1.12

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coldiq/mcp",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
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
@@ -967,11 +967,10 @@ const findPeopleProviders: ProviderEntry[] = [
967
967
  },
968
968
  async: {
969
969
  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.
970
+ // Growing backoff: fast early probes, then steady growth bounded by timeoutMs.
971
+ // Schedule: 2s, 5s, 12s, 20s, 28s, 36s, +8s per attempt thereafter.
973
972
  pollIntervalMs: (attempt) =>
974
- attempt === 1 ? 3000 : attempt === 2 ? 7000 : 15000 + (attempt - 3) * 10000,
973
+ attempt === 1 ? 2000 : attempt === 2 ? 5000 : 12000 + (attempt - 3) * 8000,
975
974
  timeoutMs: 300_000, // 5 minutes
976
975
  isComplete: (data) => {
977
976
  const d = data as Record<string, unknown>
@@ -1368,7 +1367,7 @@ const findEmailProviders: ProviderEntry[] = [
1368
1367
  return d.enrichment_id as string
1369
1368
  },
1370
1369
  pollEndpoint: (id) => `/fullenrich/contact/enrich/bulk/${id}`,
1371
- pollIntervalMs: 5000,
1370
+ pollIntervalMs: 2500,
1372
1371
  timeoutMs: 60_000,
1373
1372
  isComplete: (data) => {
1374
1373
  const d = data as Record<string, unknown>
@@ -1463,7 +1462,7 @@ const verifyEmailProviders: ProviderEntry[] = [
1463
1462
  throw new Error('Instantly verification response has no email field')
1464
1463
  },
1465
1464
  pollEndpoint: (email) => `/instantly/email-verification/${encodeURIComponent(email)}`,
1466
- pollIntervalMs: 3000,
1465
+ pollIntervalMs: 1500,
1467
1466
  timeoutMs: 30_000,
1468
1467
  isComplete: (data) => {
1469
1468
  const d = data as Record<string, unknown>
@@ -1879,7 +1878,7 @@ function _hasJobFilter(input: Record<string, unknown>, keys: string[]): boolean
1879
1878
  }
1880
1879
 
1881
1880
  const _jobsSharedAsync = {
1882
- pollIntervalMs: 5_000,
1881
+ pollIntervalMs: (attempt: number) => (attempt === 1 ? 1500 : attempt <= 3 ? 2500 : 4000),
1883
1882
  timeoutMs: 300_000,
1884
1883
  extractId: (data: unknown) => {
1885
1884
  const jobId = (data as { jobId?: number | string }).jobId
@@ -2013,7 +2012,7 @@ const searchJobsProviders: ProviderEntry[] = [
2013
2012
  // ---------------------------------------------------------------------------
2014
2013
 
2015
2014
  const _adsSharedAsync = {
2016
- pollIntervalMs: 5_000,
2015
+ pollIntervalMs: (attempt: number) => (attempt === 1 ? 1500 : attempt <= 3 ? 2500 : 4000),
2017
2016
  timeoutMs: 300_000,
2018
2017
  extractId: (data: unknown) => {
2019
2018
  const jobId = (data as { jobId?: number | string }).jobId
@@ -2181,7 +2180,7 @@ function _hasAny(input: Record<string, unknown>, keys: readonly string[]): boole
2181
2180
  }
2182
2181
 
2183
2182
  const _placesSharedAsync = {
2184
- pollIntervalMs: 5_000,
2183
+ pollIntervalMs: (attempt: number) => (attempt === 1 ? 1500 : attempt <= 3 ? 2500 : 4000),
2185
2184
  timeoutMs: 300_000,
2186
2185
  extractId: (data: unknown) => {
2187
2186
  const jobId = (data as { jobId?: number | string }).jobId
@@ -2351,7 +2350,7 @@ const findInfluencersProviders: ProviderEntry[] = [
2351
2350
  // ---------------------------------------------------------------------------
2352
2351
 
2353
2352
  const _redditSharedAsync = {
2354
- pollIntervalMs: 5_000,
2353
+ pollIntervalMs: (attempt: number) => (attempt === 1 ? 1500 : attempt <= 3 ? 2500 : 4000),
2355
2354
  timeoutMs: 300_000,
2356
2355
  extractId: (data: unknown) => {
2357
2356
  const jobId = (data as { jobId?: number | string }).jobId
@@ -3093,28 +3092,45 @@ export function getProviderApplicabilityGuard(
3093
3092
  return providers.find((p) => p.id === providerId)?.isApplicable
3094
3093
  }
3095
3094
 
3095
+ const _providersCache = new Map<Capability, ProviderEntry[]>()
3096
+
3096
3097
  /**
3097
3098
  * Get the ordered provider list for a capability.
3098
- * Returns a copy sorted by priority (lower = first).
3099
+ * Returns a frozen sorted copy cached after the first call (registry is static).
3099
3100
  */
3100
3101
  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)
3102
+ let cached = _providersCache.get(capability)
3103
+ if (!cached) {
3104
+ const providers = registry[capability]
3105
+ if (!providers) throw new Error(`Unknown capability: ${capability}`)
3106
+ cached = Object.freeze([...providers].sort((a, b) => a.priority - b.priority)) as ProviderEntry[]
3107
+ _providersCache.set(capability, cached)
3108
+ }
3109
+ return cached
3104
3110
  }
3105
3111
 
3112
+ const _searchWebProvidersCache = new Map<boolean, ProviderEntry[]>()
3113
+
3106
3114
  /**
3107
3115
  * Get providers for search_web, reordering for neural search preference.
3116
+ * Result is cached per preferNeural value — registry is static.
3108
3117
  */
3109
3118
  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
- })
3119
+ let cached = _searchWebProvidersCache.get(preferNeural)
3120
+ if (!cached) {
3121
+ const base = getProviders('search_web')
3122
+ if (preferNeural) {
3123
+ cached = Object.freeze(
3124
+ [...base].sort((a, b) => {
3125
+ if (a.id === 'exa') return -1
3126
+ if (b.id === 'exa') return 1
3127
+ return a.priority - b.priority
3128
+ }),
3129
+ ) as ProviderEntry[]
3130
+ } else {
3131
+ cached = base
3132
+ }
3133
+ _searchWebProvidersCache.set(preferNeural, cached)
3118
3134
  }
3119
- return providers
3135
+ return cached
3120
3136
  }
@@ -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
  }
@@ -76,7 +76,7 @@ async function fullEnrichStep(misses: PersonInput[], results: EmailResult[]): Pr
76
76
  // faster, so FullEnrich's marginal value decays past this point.
77
77
  const deadline = Date.now() + 45_000
78
78
  while (Date.now() < deadline) {
79
- await sleep(5000)
79
+ await sleep(2000)
80
80
  const pollRes = await callApi('GET', `/fullenrich/contact/enrich/bulk/${enrichmentId}`)
81
81
  if (!pollRes.ok) continue
82
82
 
@@ -162,7 +162,7 @@ export async function findEmailsHandler(input: Record<string, unknown>) {
162
162
 
163
163
  const resolved = resolvePreferredProviders('find_emails', restInput, rawUseProviders)
164
164
  if (!resolved.ok) {
165
- return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error, null, 2) }], isError: true }
165
+ return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error) }], isError: true }
166
166
  }
167
167
 
168
168
  const allowedProviders = resolved.providers
@@ -218,14 +218,14 @@ export async function findEmailsHandler(input: Record<string, unknown>) {
218
218
  if (!isConstrained || allowedProviders.includes('findymail') || allowedProviders.includes('icypeas')) {
219
219
  steps.push(findymailIcypeasStep(misses, results, isConstrained ? allowedProviders : undefined))
220
220
  }
221
- if (steps.length > 0) await Promise.all(steps)
221
+ if (steps.length > 0) await Promise.allSettled(steps)
222
222
  }
223
223
 
224
224
  const found = results.filter((r) => r.email !== null).length
225
225
 
226
226
  if (isConstrained && found === 0) {
227
227
  const err = buildAllFailedError(allowedProviders, 'find_emails', FIND_EMAILS_PROVIDERS)
228
- return { content: [{ type: 'text' as const, text: JSON.stringify(err, null, 2) }], isError: true }
228
+ return { content: [{ type: 'text' as const, text: JSON.stringify(err) }], isError: true }
229
229
  }
230
230
 
231
231
  const meta = resolved.matchedFrom && Object.keys(resolved.matchedFrom).length > 0
@@ -236,7 +236,7 @@ export async function findEmailsHandler(input: Record<string, unknown>) {
236
236
  content: [
237
237
  {
238
238
  type: 'text' as const,
239
- text: JSON.stringify({ data: { results, found, total: people.length }, ...(meta ? { _meta: meta } : {}) }, null, 2),
239
+ text: JSON.stringify({ data: { results, found, total: people.length }, ...(meta ? { _meta: meta } : {}) }),
240
240
  },
241
241
  ],
242
242
  }
@@ -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
  }
@@ -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
  }
@@ -42,19 +42,19 @@ export async function findPhoneHandler(input: Record<string, unknown>) {
42
42
  const { use_providers: rawUseProviders, ...restInput } = input
43
43
  const resolved = resolvePreferredProviders('find_phone', restInput, rawUseProviders)
44
44
  if (!resolved.ok) {
45
- return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error, null, 2) }], isError: true }
45
+ return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error) }], isError: true }
46
46
  }
47
47
  const validation = inputSchema.safeParse(restInput)
48
48
  if (!validation.success) {
49
49
  const msg = validation.error.issues.map((i) => i.message).join('; ')
50
50
  return {
51
- content: [{ type: 'text' as const, text: JSON.stringify({ error: msg }, null, 2) }],
51
+ content: [{ type: 'text' as const, text: JSON.stringify({ error: msg }) }],
52
52
  isError: true,
53
53
  }
54
54
  }
55
55
  const result = await executeWithFallback('find_phone', restInput, { providers: resolved.providers, matchedFrom: resolved.matchedFrom })
56
56
  if (isExecutionError(result)) {
57
- return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], isError: true }
57
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], isError: true }
58
58
  }
59
- return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] }
59
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }
60
60
  }
@@ -60,7 +60,7 @@ export async function findSignalsHandler(input: Record<string, unknown>) {
60
60
  return {
61
61
  content: [{
62
62
  type: 'text' as const,
63
- text: JSON.stringify({ error: 'intent signal_type requires at least one of: companies or domains' }, null, 2),
63
+ text: JSON.stringify({ error: 'intent signal_type requires at least one of: companies or domains' }),
64
64
  }],
65
65
  isError: true,
66
66
  }
@@ -72,7 +72,7 @@ export async function findSignalsHandler(input: Record<string, unknown>) {
72
72
  type: 'text' as const,
73
73
  text: JSON.stringify({
74
74
  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.',
75
- }, null, 2),
75
+ }),
76
76
  }],
77
77
  isError: true,
78
78
  }
@@ -80,12 +80,12 @@ export async function findSignalsHandler(input: Record<string, unknown>) {
80
80
 
81
81
  const resolved = resolvePreferredProviders('find_signals', restInput, rawUseProviders)
82
82
  if (!resolved.ok) {
83
- return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error, null, 2) }], isError: true }
83
+ return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error) }], isError: true }
84
84
  }
85
85
 
86
86
  const result = await executeWithFallback('find_signals', restInput, { providers: resolved.providers, matchedFrom: resolved.matchedFrom })
87
87
  if (isExecutionError(result)) {
88
- return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], isError: true }
88
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], isError: true }
89
89
  }
90
90
 
91
91
  // buying_intents upstream has no limit param — truncate to requested limit
@@ -97,5 +97,5 @@ export async function findSignalsHandler(input: Record<string, unknown>) {
97
97
  }
98
98
  }
99
99
 
100
- return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] }
100
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }
101
101
  }
@@ -41,11 +41,11 @@ export async function searchAdsHandler(input: Record<string, unknown>) {
41
41
  const { use_providers: rawUseProviders, ...restInput } = input
42
42
  const resolved = resolvePreferredProviders('search_ads', restInput, rawUseProviders)
43
43
  if (!resolved.ok) {
44
- return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error, null, 2) }], isError: true }
44
+ return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error) }], isError: true }
45
45
  }
46
46
  const result = await executeWithFallback('search_ads', restInput, { providers: resolved.providers, matchedFrom: resolved.matchedFrom })
47
47
  if (isExecutionError(result)) {
48
- return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], isError: true }
48
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], isError: true }
49
49
  }
50
- return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] }
50
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }
51
51
  }
@@ -38,11 +38,11 @@ export async function searchCompaniesHandler(input: Record<string, unknown>) {
38
38
  const { use_providers: rawUseProviders, ...restInput } = input
39
39
  const resolved = resolvePreferredProviders('search_companies', restInput, rawUseProviders)
40
40
  if (!resolved.ok) {
41
- return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error, null, 2) }], isError: true }
41
+ return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error) }], isError: true }
42
42
  }
43
43
  const result = await executeWithFallback('search_companies', restInput, { providers: resolved.providers, matchedFrom: resolved.matchedFrom })
44
44
  if (isExecutionError(result)) {
45
- return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], isError: true }
45
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], isError: true }
46
46
  }
47
- return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] }
47
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }
48
48
  }
@@ -66,15 +66,15 @@ export async function searchJobsHandler(input: Record<string, unknown>) {
66
66
  'Contradictory filters: ATS/domain filters (ats_slugs, company_domains) are Career Site only, ' +
67
67
  'while seniority/industry/employee filters are LinkedIn only. Remove one set.',
68
68
  }
69
- return { content: [{ type: 'text' as const, text: JSON.stringify(err, null, 2) }], isError: true }
69
+ return { content: [{ type: 'text' as const, text: JSON.stringify(err) }], isError: true }
70
70
  }
71
71
  const resolved = resolvePreferredProviders('search_jobs', restInput, rawUseProviders)
72
72
  if (!resolved.ok) {
73
- return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error, null, 2) }], isError: true }
73
+ return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error) }], isError: true }
74
74
  }
75
75
  const result = await executeWithFallback('search_jobs', restInput, { providers: resolved.providers, matchedFrom: resolved.matchedFrom })
76
76
  if (isExecutionError(result)) {
77
- return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], isError: true }
77
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], isError: true }
78
78
  }
79
- return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] }
79
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }
80
80
  }
@@ -49,11 +49,11 @@ export async function searchPlacesHandler(input: Record<string, unknown>) {
49
49
  const { use_providers: rawUseProviders, ...restInput } = input
50
50
  const resolved = resolvePreferredProviders('search_places', restInput, rawUseProviders)
51
51
  if (!resolved.ok) {
52
- return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error, null, 2) }], isError: true }
52
+ return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error) }], isError: true }
53
53
  }
54
54
  const result = await executeWithFallback('search_places', restInput, { providers: resolved.providers, matchedFrom: resolved.matchedFrom })
55
55
  if (isExecutionError(result)) {
56
- return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], isError: true }
56
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], isError: true }
57
57
  }
58
- return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] }
58
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }
59
59
  }
@@ -31,11 +31,11 @@ export async function searchRedditHandler(input: Record<string, unknown>) {
31
31
  const { use_providers: rawUseProviders, ...restInput } = input
32
32
  const resolved = resolvePreferredProviders('search_reddit', 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('search_reddit', 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
  }
@@ -56,11 +56,11 @@ export async function searchSeoHandler(input: Record<string, unknown>) {
56
56
  const { use_providers: rawUseProviders, ...restInput } = input
57
57
  const resolved = resolvePreferredProviders('search_seo', restInput, rawUseProviders)
58
58
  if (!resolved.ok) {
59
- return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error, null, 2) }], isError: true }
59
+ return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error) }], isError: true }
60
60
  }
61
61
  const result = await executeWithFallback('search_seo', restInput, { providers: resolved.providers, matchedFrom: resolved.matchedFrom })
62
62
  if (isExecutionError(result)) {
63
- return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], isError: true }
63
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], isError: true }
64
64
  }
65
- return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] }
65
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }
66
66
  }
@@ -22,11 +22,11 @@ export async function searchWebHandler(input: Record<string, unknown>) {
22
22
  const { use_providers: rawUseProviders, ...restInput } = input
23
23
  const resolved = resolvePreferredProviders('search_web', 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('search_web', 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
  }
@@ -16,11 +16,11 @@ export async function verifyEmailHandler(input: Record<string, unknown>) {
16
16
  const { use_providers: rawUseProviders, ...restInput } = input
17
17
  const resolved = resolvePreferredProviders('verify_email', restInput, rawUseProviders)
18
18
  if (!resolved.ok) {
19
- return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error, null, 2) }], isError: true }
19
+ return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error) }], isError: true }
20
20
  }
21
21
  const result = await executeWithFallback('verify_email', restInput, { providers: resolved.providers, matchedFrom: resolved.matchedFrom })
22
22
  if (isExecutionError(result)) {
23
- return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], isError: true }
23
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], isError: true }
24
24
  }
25
- return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] }
25
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }
26
26
  }
@@ -87,4 +87,36 @@ describe('client', () => {
87
87
  expect(result.ok).toBe(false)
88
88
  expect(result.status).toBe(404)
89
89
  })
90
+
91
+ it('maps AbortError (timeout) to { error: "Request timed out" }', async () => {
92
+ globalThis.fetch = vi.fn(async (_url: unknown, init?: RequestInit) => {
93
+ const signal = init?.signal as AbortSignal | undefined
94
+ if (signal?.aborted) {
95
+ const err = new Error('aborted')
96
+ err.name = 'AbortError'
97
+ throw err
98
+ }
99
+ await new Promise<void>((_, reject) => {
100
+ signal?.addEventListener('abort', () => {
101
+ const err = new Error('aborted')
102
+ err.name = 'AbortError'
103
+ reject(err)
104
+ })
105
+ })
106
+ return new Response(JSON.stringify({}), { status: 200 })
107
+ }) as typeof fetch
108
+
109
+ const origTimeout = process.env.COLDIQ_HTTP_TIMEOUT_MS
110
+ process.env.COLDIQ_HTTP_TIMEOUT_MS = '1'
111
+ initClient('http://test-api.local', 'test-key-123')
112
+ try {
113
+ const res = await callApi('GET', '/test')
114
+ expect(res.ok).toBe(false)
115
+ expect(res.status).toBe(0)
116
+ expect((res.data as Record<string, unknown>).error).toBe('Request timed out')
117
+ } finally {
118
+ if (origTimeout === undefined) delete process.env.COLDIQ_HTTP_TIMEOUT_MS
119
+ else process.env.COLDIQ_HTTP_TIMEOUT_MS = origTimeout
120
+ }
121
+ })
90
122
  })
@@ -541,6 +541,87 @@ describe('executor async polling intervals', () => {
541
541
  expect(requestedDelays).toEqual([100, 200, 300, 400])
542
542
  })
543
543
 
544
+ it('first poll fires after firstPollMs (≤ configured interval) — not after the full interval', async () => {
545
+ const delays: number[] = []
546
+ globalThis.setTimeout = ((cb: () => void, ms?: number) => {
547
+ if (typeof ms === 'number' && ms > 0) delays.push(ms)
548
+ cb()
549
+ return 0 as unknown as ReturnType<typeof setTimeout>
550
+ }) as typeof setTimeout
551
+
552
+ let pollCount = 0
553
+ stubProviders([
554
+ makeProvider({
555
+ id: 'fast-complete',
556
+ hasResult: (data) => (data as Record<string, unknown>).done === true,
557
+ async: {
558
+ extractId: () => 'job-1',
559
+ pollEndpoint: (id) => `/poll/${id}`,
560
+ pollIntervalMs: 5000, // long interval
561
+ timeoutMs: 60_000,
562
+ isComplete: (data) => (data as Record<string, unknown>).done === true,
563
+ },
564
+ }),
565
+ ])
566
+
567
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
568
+ const u = url.toString()
569
+ if (u.includes('/poll/')) {
570
+ pollCount++
571
+ return new Response(JSON.stringify({ done: true }), { status: 200 })
572
+ }
573
+ return new Response(JSON.stringify({ id: 'job-1' }), { status: 200 })
574
+ }) as typeof fetch
575
+
576
+ await executeWithFallback('enrich_company', { domain: 'coldiq.com' })
577
+
578
+ // First poll fired after ≤ 750ms (COLDIQ_FIRST_POLL_MS default), which is less
579
+ // than the configured 5000ms interval.
580
+ expect(pollCount).toBe(1)
581
+ expect(delays.length).toBe(1)
582
+ expect(delays[0]).toBeLessThanOrEqual(750)
583
+ })
584
+
585
+ it('consecutive poll errors up to maxPollErrors abort the async job', async () => {
586
+ globalThis.setTimeout = ((cb: () => void) => { cb(); return 0 as unknown as ReturnType<typeof setTimeout> }) as typeof setTimeout
587
+
588
+ let createCalled = false
589
+ stubProviders([
590
+ makeProvider({
591
+ id: 'stuck-poll',
592
+ hasResult: () => false,
593
+ async: {
594
+ extractId: () => 'job-stuck',
595
+ pollEndpoint: (id) => `/poll/${id}`,
596
+ pollIntervalMs: 10,
597
+ timeoutMs: 60_000,
598
+ isComplete: () => false,
599
+ },
600
+ }),
601
+ ])
602
+
603
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
604
+ const u = url.toString()
605
+ if (u.includes('/poll/')) {
606
+ return new Response(JSON.stringify({ error: 'Internal error' }), { status: 500 })
607
+ }
608
+ createCalled = true
609
+ return new Response(JSON.stringify({ id: 'job-stuck' }), { status: 200 })
610
+ }) as typeof fetch
611
+
612
+ const origMax = process.env.COLDIQ_MAX_POLL_ERRORS
613
+ process.env.COLDIQ_MAX_POLL_ERRORS = '3'
614
+ try {
615
+ const result = await executeWithFallback('enrich_company', { domain: 'coldiq.com' })
616
+ expect(createCalled).toBe(true)
617
+ expect('error' in result).toBe(true)
618
+ // Should return in well under the 60s timeout (no setTimeout mocking needed beyond immediate)
619
+ } finally {
620
+ if (origMax === undefined) delete process.env.COLDIQ_MAX_POLL_ERRORS
621
+ else process.env.COLDIQ_MAX_POLL_ERRORS = origMax
622
+ }
623
+ })
624
+
544
625
  })
545
626
 
546
627
  // ---------------------------------------------------------------------------
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { getProviders } from '../src/registry.js'
3
+
4
+ function evalPollSchedule(pollIntervalMs: number | ((attempt: number) => number), maxMs: number) {
5
+ const result: number[] = []
6
+ let cumulative = 0
7
+ let attempt = 1
8
+ while (cumulative < maxMs) {
9
+ const interval = typeof pollIntervalMs === 'function' ? pollIntervalMs(attempt) : pollIntervalMs
10
+ cumulative += interval
11
+ result.push(interval)
12
+ attempt++
13
+ }
14
+ return result
15
+ }
16
+
17
+ describe('MCP polling interval schedules', () => {
18
+ it('Apify shared async configs (jobs/ads/places/reddit): fast early polls, plateau at 4s', () => {
19
+ const providers = getProviders('search_jobs')
20
+ const p = providers.find((p) => p.id === 'career_site_jobs' || p.id === 'linkedin_jobs')!
21
+ expect(p?.async).toBeDefined()
22
+ const fn = p.async!.pollIntervalMs as (attempt: number) => number
23
+ expect(typeof fn).toBe('function')
24
+ expect(fn(1)).toBe(1500)
25
+ expect(fn(2)).toBe(2500)
26
+ expect(fn(3)).toBe(2500)
27
+ expect(fn(4)).toBe(4000) // plateaus at 4s
28
+ expect(fn(10)).toBe(4000)
29
+ // Must get ≥ 10 polls inside the 5-minute timeout
30
+ expect(evalPollSchedule(fn, 300_000).length).toBeGreaterThanOrEqual(10)
31
+ })
32
+
33
+ it('FullEnrich find-email: 2.5s interval fits inside 60s timeout', () => {
34
+ const providers = getProviders('find_email')
35
+ const fe = providers.find((p) => p.id === 'fullenrich')!
36
+ expect(fe?.async).toBeDefined()
37
+ expect(fe.async!.pollIntervalMs).toBe(2500)
38
+ // timeoutMs = 60_000 → at least 24 polls
39
+ expect(60_000 / 2500).toBeGreaterThanOrEqual(24)
40
+ })
41
+
42
+ it('Instantly verify-email: 1.5s interval fits inside 30s timeout', () => {
43
+ const providers = getProviders('verify_email')
44
+ const inst = providers.find((p) => p.id === 'instantly')!
45
+ expect(inst?.async).toBeDefined()
46
+ expect(inst.async!.pollIntervalMs).toBe(1500)
47
+ expect(30_000 / 1500).toBeGreaterThanOrEqual(20)
48
+ })
49
+
50
+ it('LeadsFactory find-people: shorter front, ≥7 polls in 5 minutes', () => {
51
+ const providers = getProviders('find_people')
52
+ const lf = providers.find((p) => p.id === 'leadsfactory')!
53
+ expect(lf?.async).toBeDefined()
54
+ const fn = lf.async!.pollIntervalMs as (attempt: number) => number
55
+ expect(fn(1)).toBe(2000) // faster first probe vs old 3000
56
+ expect(fn(2)).toBe(5000)
57
+ expect(fn(3)).toBe(12000)
58
+ // Validates ≥7 polls still fit in 5 min (guard against overly aggressive growth)
59
+ expect(evalPollSchedule(fn, 300_000).length).toBeGreaterThanOrEqual(7)
60
+ })
61
+ })
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import { getProviders, getSearchWebProviders, isoCountryToName, listCapabilityProviderIds } from '../src/registry.js'
3
- import type { Capability } from '../src/registry.js'
3
+ import type { Capability, ProviderEntry } from '../src/registry.js'
4
4
 
5
5
  const ALL_CAPABILITIES: Capability[] = [
6
6
  'search_companies',
@@ -36,11 +36,11 @@ describe('registry', () => {
36
36
  }
37
37
  })
38
38
 
39
- it('returns a copy mutating does not affect the registry', () => {
39
+ it('is memoizedconsecutive calls return the same frozen reference', () => {
40
40
  const first = getProviders('search_companies')
41
- first.pop()
42
41
  const second = getProviders('search_companies')
43
- expect(second.length).toBeGreaterThan(first.length)
42
+ expect(first).toBe(second) // referential equality — cache is working
43
+ expect(() => (first as ProviderEntry[]).pop()).toThrow() // frozen — mutation throws
44
44
  })
45
45
 
46
46
  it('throws for unknown capability', () => {
@@ -518,11 +518,11 @@ describe('registry', () => {
518
518
  const sched = lf.async!.pollIntervalMs
519
519
  expect(typeof sched).toBe('function')
520
520
  const fn = sched as (attempt: number) => number
521
- expect(fn(1)).toBe(3000)
522
- expect(fn(2)).toBe(7000)
523
- expect(fn(3)).toBe(15000)
524
- expect(fn(4)).toBe(25000)
525
- expect(fn(8)).toBe(65000)
521
+ expect(fn(1)).toBe(2000)
522
+ expect(fn(2)).toBe(5000)
523
+ expect(fn(3)).toBe(12000)
524
+ expect(fn(4)).toBe(20000)
525
+ expect(fn(8)).toBe(52000)
526
526
  // At least 7 polls must fit inside the 5-minute timeout — guards against
527
527
  // future tuning that would make the ramp so steep we only get 1–2 polls.
528
528
  let cumulative = 0
@@ -400,3 +400,58 @@ describe('find_emails handler — use_providers', () => {
400
400
  expect(icypeasCalled).toBe(false)
401
401
  })
402
402
  })
403
+
404
+ describe('find_emails handler — resilience', () => {
405
+ const originalFetch = globalThis.fetch
406
+
407
+ beforeEach(() => {
408
+ initClient('http://test-api.local', 'test-key')
409
+ })
410
+
411
+ afterEach(() => {
412
+ globalThis.fetch = originalFetch
413
+ })
414
+
415
+ it('findymailIcypeasStep completing correctly even when fullEnrichStep throws', async () => {
416
+ // Prospeo returns no results.
417
+ // FullEnrich create call throws (simulates unexpected error).
418
+ // FindyMail finds the email — result should still be populated.
419
+
420
+ vi.useFakeTimers()
421
+
422
+ globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
423
+ const u = url.toString()
424
+ if (u.includes('/prospeo/bulk-enrich-person')) {
425
+ return new Response(JSON.stringify({
426
+ error: false,
427
+ results: [{ identifier: 'p1', error: true, person: null }],
428
+ total_cost: 0,
429
+ }), { status: 200 })
430
+ }
431
+ if (u.includes('/fullenrich/contact/enrich/bulk') && !u.includes('/fullenrich/contact/enrich/bulk/')) {
432
+ // Throw so fullEnrichStep rejects
433
+ throw new Error('FullEnrich network failure')
434
+ }
435
+ if (u.includes('/findymail/search/name')) {
436
+ return new Response(JSON.stringify({ email: 'alice@example.com' }), { status: 200 })
437
+ }
438
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
439
+ }) as typeof fetch
440
+
441
+ const resultPromise = findEmailsHandler({
442
+ people: [{ id: 'p1', first_name: 'Alice', last_name: 'Smith', domain: 'example.com' }],
443
+ })
444
+
445
+ // Advance timers past the FullEnrich poll sleep
446
+ await vi.runAllTimersAsync()
447
+ const result = await resultPromise
448
+
449
+ vi.useRealTimers()
450
+
451
+ // Despite fullEnrichStep throwing, the handler must succeed
452
+ expect(result.isError).toBeFalsy()
453
+ const parsed = JSON.parse(result.content[0].text)
454
+ expect(parsed.data.found).toBe(1)
455
+ expect(parsed.data.results[0]).toMatchObject({ id: 'p1', email: 'alice@example.com', provider: 'findymail' })
456
+ })
457
+ })
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
+ import { initClient } from '../../src/client.js'
3
+ import { searchCompaniesHandler } from '../../src/tools/search-companies.js'
4
+ import { findEmailHandler } from '../../src/tools/find-email.js'
5
+
6
+ describe('tool response format', () => {
7
+ const originalFetch = globalThis.fetch
8
+
9
+ beforeEach(() => {
10
+ initClient('http://test-api.local', 'test-key')
11
+ })
12
+
13
+ afterEach(() => {
14
+ globalThis.fetch = originalFetch
15
+ vi.restoreAllMocks()
16
+ })
17
+
18
+ it('success response is compact JSON (no indentation)', async () => {
19
+ globalThis.fetch = vi.fn(async () =>
20
+ new Response(JSON.stringify({ companies: [{ name: 'Acme' }] }), { status: 200 }),
21
+ ) as typeof fetch
22
+
23
+ const result = await searchCompaniesHandler({ keywords: ['SaaS'], limit: 5 })
24
+ const text = result.content[0].text
25
+
26
+ expect(result.isError).toBeFalsy()
27
+ // Compact JSON has no newlines
28
+ expect(text).not.toContain('\n')
29
+ // Must be valid JSON
30
+ expect(() => JSON.parse(text)).not.toThrow()
31
+ })
32
+
33
+ it('error response is compact JSON (no indentation)', async () => {
34
+ globalThis.fetch = vi.fn(async () =>
35
+ new Response(JSON.stringify({ error: 'Bad request' }), { status: 400 }),
36
+ ) as typeof fetch
37
+
38
+ const result = await findEmailHandler({ first_name: 'Michel', last_name: 'Lieben', domain: 'coldiq.com' })
39
+ const text = result.content[0].text
40
+
41
+ expect(result.isError).toBeTruthy()
42
+ expect(text).not.toContain('\n')
43
+ expect(() => JSON.parse(text)).not.toThrow()
44
+ })
45
+ })