@coldiq/mcp 0.1.10 → 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 +5 -1
- package/src/client.ts +31 -1
- package/src/executor.ts +36 -14
- package/src/registry.ts +39 -23
- package/src/tools/enrich-company.ts +4 -4
- package/src/tools/enrich-person.ts +3 -3
- package/src/tools/fetch-page-content.ts +3 -3
- package/src/tools/find-email.ts +4 -4
- package/src/tools/find-emails.ts +5 -5
- package/src/tools/find-influencers.ts +3 -3
- package/src/tools/find-people.ts +4 -4
- package/src/tools/find-phone.ts +4 -4
- package/src/tools/find-signals.ts +5 -5
- package/src/tools/search-ads.ts +3 -3
- package/src/tools/search-companies.ts +3 -3
- package/src/tools/search-jobs.ts +4 -4
- package/src/tools/search-places.ts +3 -3
- package/src/tools/search-reddit.ts +3 -3
- package/src/tools/search-seo.ts +3 -3
- package/src/tools/search-web.ts +3 -3
- package/src/tools/verify-email.ts +3 -3
- package/tests/client.test.ts +32 -0
- package/tests/executor.test.ts +81 -0
- package/tests/registry-polling.test.ts +61 -0
- package/tests/registry.test.ts +9 -9
- package/tests/tools/find-emails.test.ts +55 -0
- 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.
|
|
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 = {
|
|
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
|
-
//
|
|
971
|
-
//
|
|
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 ?
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
3102
|
-
if (!
|
|
3103
|
-
|
|
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
|
-
|
|
3111
|
-
if (
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
42
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }
|
|
43
43
|
}
|
package/src/tools/find-email.ts
CHANGED
|
@@ -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
|
|
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 }
|
|
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
|
|
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
|
|
62
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }
|
|
63
63
|
}
|
package/src/tools/find-emails.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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.
|
|
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
|
|
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 } : {}) }
|
|
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
|
|
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
|
|
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
|
|
40
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }
|
|
41
41
|
}
|
package/src/tools/find-people.ts
CHANGED
|
@@ -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
|
|
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
|
|
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 }
|
|
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
|
|
75
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }
|
|
76
76
|
}
|
package/src/tools/find-phone.ts
CHANGED
|
@@ -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
|
|
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 }
|
|
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
|
|
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
|
|
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' }
|
|
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
|
-
}
|
|
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
|
|
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
|
|
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
|
|
100
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }
|
|
101
101
|
}
|
package/src/tools/search-ads.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
47
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }
|
|
48
48
|
}
|
package/src/tools/search-jobs.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
40
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }
|
|
41
41
|
}
|
package/src/tools/search-seo.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
65
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }
|
|
66
66
|
}
|
package/src/tools/search-web.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
25
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }
|
|
26
26
|
}
|
package/tests/client.test.ts
CHANGED
|
@@ -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
|
})
|
package/tests/executor.test.ts
CHANGED
|
@@ -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
|
+
})
|
package/tests/registry.test.ts
CHANGED
|
@@ -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('
|
|
39
|
+
it('is memoized — consecutive 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(
|
|
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(
|
|
522
|
-
expect(fn(2)).toBe(
|
|
523
|
-
expect(fn(3)).toBe(
|
|
524
|
-
expect(fn(4)).toBe(
|
|
525
|
-
expect(fn(8)).toBe(
|
|
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
|
+
})
|