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