@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
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Direct upstream probe — bypasses the marketplace proxy.
|
|
2
|
+
// Goal: figure out whether CompanyEnrich's /companies/search wants filters wrapped
|
|
3
|
+
// under { filters: {...} } (current behavior) or at the top level, and whether ANY
|
|
4
|
+
// filter shape narrows results away from their default top-5.
|
|
5
|
+
//
|
|
6
|
+
// Run: COMPANYENRICH_API_KEY=… npx tsx mcp/tests/live/companyenrich-upstream-probe.ts
|
|
7
|
+
|
|
8
|
+
const KEY = process.env.COMPANYENRICH_API_KEY
|
|
9
|
+
if (!KEY) {
|
|
10
|
+
console.error('COMPANYENRICH_API_KEY required')
|
|
11
|
+
process.exit(1)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const BASE = 'https://api.companyenrich.com'
|
|
15
|
+
|
|
16
|
+
async function call(body: unknown, label: string) {
|
|
17
|
+
const t0 = Date.now()
|
|
18
|
+
let res: Response
|
|
19
|
+
try {
|
|
20
|
+
res = await fetch(`${BASE}/companies/search`, {
|
|
21
|
+
method: 'POST',
|
|
22
|
+
headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
|
|
23
|
+
body: JSON.stringify(body),
|
|
24
|
+
signal: AbortSignal.timeout(30_000),
|
|
25
|
+
})
|
|
26
|
+
} catch (err) {
|
|
27
|
+
console.log(`\n[${label}] body=${JSON.stringify(body)}`)
|
|
28
|
+
console.log(` fetch-failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
let data: any
|
|
32
|
+
try { data = await res.json() } catch { data = '<non-json>' }
|
|
33
|
+
const items = (data?.items ?? []).slice(0, 5).map((c: any) => `${c.name} (${c.domain})`)
|
|
34
|
+
console.log(`\n[${label}] body=${JSON.stringify(body)}`)
|
|
35
|
+
console.log(` status=${res.status} ms=${Date.now() - t0} count=${data?.items?.length ?? 0}`)
|
|
36
|
+
console.log(` top5=${JSON.stringify(items)}`)
|
|
37
|
+
if (!res.ok) console.log(` raw=${JSON.stringify(data).slice(0, 400)}`)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function main() {
|
|
41
|
+
console.log('=== CompanyEnrich /companies/search direct upstream probe ===')
|
|
42
|
+
console.log(`BASE=${BASE}`)
|
|
43
|
+
|
|
44
|
+
// A. What our marketplace currently sends
|
|
45
|
+
await call({ pageSize: 5, filters: { query: 'wework' } }, 'A. filters.query="wework" (our current shape)')
|
|
46
|
+
|
|
47
|
+
// B. Try query at top level
|
|
48
|
+
await call({ query: 'wework', pageSize: 5 }, 'B. top-level query="wework"')
|
|
49
|
+
|
|
50
|
+
// C. Try an exact identifier wrapped
|
|
51
|
+
await call({ pageSize: 5, filters: { domains: ['wework.com'] } }, 'C. filters.domains=["wework.com"]')
|
|
52
|
+
|
|
53
|
+
// D. Try an exact identifier top-level
|
|
54
|
+
await call({ domains: ['wework.com'], pageSize: 5 }, 'D. top-level domains=["wework.com"]')
|
|
55
|
+
|
|
56
|
+
// E. Try names wrapped
|
|
57
|
+
await call({ pageSize: 5, filters: { names: ['WeWork'] } }, 'E. filters.names=["WeWork"]')
|
|
58
|
+
|
|
59
|
+
// F. Country filter only (baseline narrowing)
|
|
60
|
+
await call({ pageSize: 5, filters: { countries: ['BR'] } }, 'F. filters.countries=["BR"] (baseline)')
|
|
61
|
+
|
|
62
|
+
// G. Country top-level
|
|
63
|
+
await call({ countries: ['BR'], pageSize: 5 }, 'G. top-level countries=["BR"]')
|
|
64
|
+
|
|
65
|
+
// H. Empty body — what does no filter return?
|
|
66
|
+
await call({ pageSize: 5 }, 'H. {pageSize:5} — empty body')
|
|
67
|
+
|
|
68
|
+
// I. semanticQuery (alternative full-text path)
|
|
69
|
+
await call({ pageSize: 5, filters: { semanticQuery: 'WeWork coworking real estate' } }, 'I. filters.semanticQuery="WeWork coworking real estate"')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
main().catch((err) => { console.error('FATAL:', err); process.exit(1) })
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Live prod trace for the "C-suite of WeWork Brazil" agent flow.
|
|
2
|
+
// Pinpoints which calls misbehave so we can fix find_people / find_emails / search_companies
|
|
3
|
+
// before re-running and measuring improvement.
|
|
4
|
+
//
|
|
5
|
+
// Run: COLDIQ_API_KEY=ciq_test_xxx tsx mcp/tests/live/wework-brazil-trace.ts
|
|
6
|
+
|
|
7
|
+
const API_URL = process.env.COLDIQ_API_URL ?? 'https://api.coldiq.com'
|
|
8
|
+
const API_KEY = process.env.COLDIQ_API_KEY
|
|
9
|
+
if (!API_KEY) {
|
|
10
|
+
console.error('COLDIQ_API_KEY required')
|
|
11
|
+
process.exit(1)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type Result = { ok: boolean; status: number; data: unknown; ms: number }
|
|
15
|
+
|
|
16
|
+
async function call(method: 'GET' | 'POST', path: string, body?: unknown): Promise<Result> {
|
|
17
|
+
const t0 = Date.now()
|
|
18
|
+
try {
|
|
19
|
+
const res = await fetch(`${API_URL}/v1${path}`, {
|
|
20
|
+
method,
|
|
21
|
+
headers: {
|
|
22
|
+
Authorization: `Bearer ${API_KEY}`,
|
|
23
|
+
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|
24
|
+
},
|
|
25
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
26
|
+
signal: AbortSignal.timeout(120_000),
|
|
27
|
+
})
|
|
28
|
+
let data: unknown
|
|
29
|
+
try { data = await res.json() } catch { data = '<non-json>' }
|
|
30
|
+
return { ok: res.ok, status: res.status, data, ms: Date.now() - t0 }
|
|
31
|
+
} catch (err) {
|
|
32
|
+
return {
|
|
33
|
+
ok: false,
|
|
34
|
+
status: 0,
|
|
35
|
+
data: { error: 'fetch-failed', message: err instanceof Error ? err.message : String(err) },
|
|
36
|
+
ms: Date.now() - t0,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function shortJson(v: unknown, max = 400): string {
|
|
42
|
+
const s = typeof v === 'string' ? v : JSON.stringify(v)
|
|
43
|
+
return s.length > max ? s.slice(0, max) + '…' : s
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function header(s: string) { console.log(`\n${'='.repeat(78)}\n${s}\n${'='.repeat(78)}`) }
|
|
47
|
+
function step(s: string) { console.log(`\n— ${s} —`) }
|
|
48
|
+
|
|
49
|
+
async function main() {
|
|
50
|
+
header('WeWork Brazil — live trace')
|
|
51
|
+
console.log(`API: ${API_URL}`)
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------
|
|
54
|
+
// 1. search_companies — does the corrected `query` field surface WeWork?
|
|
55
|
+
// Compares the buggy `keywords` shape (before-fix) with the fixed `query` shape (after-fix).
|
|
56
|
+
// ---------------------------------------------------------------
|
|
57
|
+
for (const kw of ['wework', 'wework brazil', 'WeWork']) {
|
|
58
|
+
step(`BEFORE-FIX companyenrich/companies/search keywords=[${kw}]`)
|
|
59
|
+
let r = await call('POST', '/companyenrich/companies/search', { keywords: [kw], pageSize: 5 })
|
|
60
|
+
console.log(` status=${r.status} ms=${r.ms} top=${(((r.data as any)?.items ?? [])[0]?.name) ?? '<none>'}`)
|
|
61
|
+
|
|
62
|
+
step(`AFTER-FIX companyenrich/companies/search query="${kw}"`)
|
|
63
|
+
r = await call('POST', '/companyenrich/companies/search', { query: kw, pageSize: 5 })
|
|
64
|
+
const top3 = (((r.data as any)?.items ?? []) as any[]).slice(0, 3).map((c: any) => c.name)
|
|
65
|
+
console.log(` status=${r.status} ms=${r.ms} top3=${JSON.stringify(top3)}`)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
step('fullenrich/company/search keywords=[wework]')
|
|
69
|
+
let r = await call('POST', '/fullenrich/company/search', {
|
|
70
|
+
keywords: [{ value: 'wework' }],
|
|
71
|
+
limit: 5,
|
|
72
|
+
})
|
|
73
|
+
console.log(` status=${r.status} ms=${r.ms} data=${shortJson(r.data, 400)}`)
|
|
74
|
+
|
|
75
|
+
step('signalbase/companies search=wework')
|
|
76
|
+
r = await call('GET', `/signalbase/companies?search=${encodeURIComponent('wework')}&limit=5`)
|
|
77
|
+
console.log(` status=${r.status} ms=${r.ms} data=${shortJson(r.data, 400)}`)
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------
|
|
80
|
+
// 2. enrich_company — confirm wework.com
|
|
81
|
+
// ---------------------------------------------------------------
|
|
82
|
+
step('enrich_company domain=wework.com')
|
|
83
|
+
r = await call('POST', '/companyenrich/companies/enrich', { domain: 'wework.com' })
|
|
84
|
+
console.log(` status=${r.status} ms=${r.ms}`)
|
|
85
|
+
console.log(` data=${shortJson(r.data, 400)}`)
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------
|
|
88
|
+
// 3. find_people — LeadsFactory WITHOUT country filter (current behavior)
|
|
89
|
+
// ---------------------------------------------------------------
|
|
90
|
+
step('LF contact-finder — wework.com, no country filter')
|
|
91
|
+
const lfNoCountry = await call('POST', '/leadsfactory/contact-finder/searches', {
|
|
92
|
+
company_domains: ['wework.com'],
|
|
93
|
+
search: {
|
|
94
|
+
max_persona_results: 10,
|
|
95
|
+
personas: [
|
|
96
|
+
{ job_title: 'CEO, Chief Executive Officer' },
|
|
97
|
+
{ job_title: 'Head of Sales, VP Sales' },
|
|
98
|
+
{ job_title: 'Country Manager, Managing Director' },
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
console.log(` status=${lfNoCountry.status} ms=${lfNoCountry.ms}`)
|
|
103
|
+
console.log(` data=${shortJson(lfNoCountry.data, 400)}`)
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------
|
|
106
|
+
// 4. find_people — LeadsFactory WITH country filter (proposed fix)
|
|
107
|
+
// ---------------------------------------------------------------
|
|
108
|
+
step('LF contact-finder — wework.com + country_codes BR')
|
|
109
|
+
const lfBr = await call('POST', '/leadsfactory/contact-finder/searches', {
|
|
110
|
+
company_domains: ['wework.com'],
|
|
111
|
+
search: {
|
|
112
|
+
max_persona_results: 10,
|
|
113
|
+
personas: [
|
|
114
|
+
{ job_title: 'CEO, Chief Executive Officer' },
|
|
115
|
+
{ job_title: 'Head of Sales, VP Sales' },
|
|
116
|
+
{ job_title: 'Country Manager, Managing Director' },
|
|
117
|
+
],
|
|
118
|
+
country_codes: [{ code: 'BR', name: 'Brazil' }],
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
console.log(` status=${lfBr.status} ms=${lfBr.ms}`)
|
|
122
|
+
console.log(` data=${shortJson(lfBr.data, 600)}`)
|
|
123
|
+
|
|
124
|
+
// Poll one of them if async
|
|
125
|
+
function extractId(d: unknown): string | undefined {
|
|
126
|
+
const o = d as Record<string, unknown>
|
|
127
|
+
return (o?._id as string) ?? (o?.search_id as string)
|
|
128
|
+
}
|
|
129
|
+
const lfId = extractId(lfBr.data)
|
|
130
|
+
if (lfId) {
|
|
131
|
+
step(`Polling LF search ${lfId} (up to 90s)…`)
|
|
132
|
+
const deadline = Date.now() + 90_000
|
|
133
|
+
let attempt = 0
|
|
134
|
+
while (Date.now() < deadline) {
|
|
135
|
+
attempt++
|
|
136
|
+
const wait = attempt === 1 ? 2000 : attempt === 2 ? 5000 : 12000
|
|
137
|
+
await new Promise((r) => setTimeout(r, wait))
|
|
138
|
+
const pr = await call('GET', `/leadsfactory/contact-finder/searches/${lfId}`)
|
|
139
|
+
const d = pr.data as Record<string, unknown>
|
|
140
|
+
console.log(` attempt=${attempt} status=${d?.status} complete=${d?.nb_jobs_complete}/${d?.nb_jobs_total} (${pr.ms}ms)`)
|
|
141
|
+
if (d?.status === 'SUCCESSFUL' || d?.status === 'FAILED' || d?.status === 'INVALID_URL') {
|
|
142
|
+
console.log(` final=${shortJson(d, 1200)}`)
|
|
143
|
+
break
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------
|
|
149
|
+
// 5. find_emails (bulk) vs find_email (single) on the same people
|
|
150
|
+
// Use realistic test people from a public source — Estefania Barbosa @ wework.com
|
|
151
|
+
// ---------------------------------------------------------------
|
|
152
|
+
const testPeople = [
|
|
153
|
+
{ id: 'estefania', first_name: 'Estefania', last_name: 'Barbosa', domain: 'wework.com' },
|
|
154
|
+
{ id: 'marcelo', first_name: 'Marcelo', last_name: 'Russo', domain: 'wework.com' },
|
|
155
|
+
{ id: 'marina', first_name: 'Marina', last_name: 'Soares', domain: 'wework.com' },
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
step('Prospeo bulk (the bulk-find_emails Step 1)')
|
|
159
|
+
const prospeoBulk = await call('POST', '/prospeo/bulk-enrich-person', {
|
|
160
|
+
data: testPeople.map((p) => ({
|
|
161
|
+
identifier: p.id, first_name: p.first_name, last_name: p.last_name, company_name: p.domain,
|
|
162
|
+
})),
|
|
163
|
+
})
|
|
164
|
+
console.log(` status=${prospeoBulk.status} ms=${prospeoBulk.ms}`)
|
|
165
|
+
console.log(` data=${shortJson(prospeoBulk.data, 800)}`)
|
|
166
|
+
|
|
167
|
+
step('Findymail single — each person, in parallel')
|
|
168
|
+
const findyResults = await Promise.all(
|
|
169
|
+
testPeople.map(async (p) => {
|
|
170
|
+
const fr = await call('POST', '/findymail/search/name', {
|
|
171
|
+
name: `${p.first_name} ${p.last_name}`,
|
|
172
|
+
domain: p.domain,
|
|
173
|
+
})
|
|
174
|
+
return { id: p.id, status: fr.status, ms: fr.ms, data: fr.data }
|
|
175
|
+
}),
|
|
176
|
+
)
|
|
177
|
+
for (const f of findyResults) {
|
|
178
|
+
console.log(` ${f.id}: status=${f.status} ms=${f.ms} → ${shortJson(f.data, 200)}`)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
step('Icypeas single — each person, in parallel')
|
|
182
|
+
const icyResults = await Promise.all(
|
|
183
|
+
testPeople.map(async (p) => {
|
|
184
|
+
const ir = await call('POST', '/icypeas/email-search', {
|
|
185
|
+
firstname: p.first_name, lastname: p.last_name, domainOrCompany: p.domain,
|
|
186
|
+
})
|
|
187
|
+
return { id: p.id, status: ir.status, ms: ir.ms, data: ir.data }
|
|
188
|
+
}),
|
|
189
|
+
)
|
|
190
|
+
for (const f of icyResults) {
|
|
191
|
+
console.log(` ${f.id}: status=${f.status} ms=${f.ms} → ${shortJson(f.data, 200)}`)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
header('Done')
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
main().catch((err) => {
|
|
198
|
+
console.error('FATAL:', err)
|
|
199
|
+
process.exit(1)
|
|
200
|
+
})
|
|
@@ -430,6 +430,77 @@ describe('leadsfactory (find_people)', () => {
|
|
|
430
430
|
})
|
|
431
431
|
})
|
|
432
432
|
|
|
433
|
+
it('locations are forwarded as ISO country_codes pairs', () => {
|
|
434
|
+
const result = p().mapParams({
|
|
435
|
+
company_domains: ['wework.com'],
|
|
436
|
+
job_titles: ['CEO'],
|
|
437
|
+
locations: ['BR', 'US'],
|
|
438
|
+
limit: 10,
|
|
439
|
+
})
|
|
440
|
+
const search = (result.body as Record<string, unknown>).search as Record<string, unknown>
|
|
441
|
+
expect(search.country_codes).toEqual([
|
|
442
|
+
{ code: 'BR', name: 'Brazil' },
|
|
443
|
+
{ code: 'US', name: 'United States' },
|
|
444
|
+
])
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
it('country names are coerced to ISO-2 (agents commonly pass "Brazil" instead of "BR")', () => {
|
|
448
|
+
const result = p().mapParams({
|
|
449
|
+
company_domains: ['wework.com'],
|
|
450
|
+
job_titles: ['CEO'],
|
|
451
|
+
locations: ['Brazil', 'United States'],
|
|
452
|
+
limit: 10,
|
|
453
|
+
})
|
|
454
|
+
const search = (result.body as Record<string, unknown>).search as Record<string, unknown>
|
|
455
|
+
expect(search.country_codes).toEqual([
|
|
456
|
+
{ code: 'BR', name: 'Brazil' },
|
|
457
|
+
{ code: 'US', name: 'United States' },
|
|
458
|
+
])
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
it('common aliases (USA, UK) and case-insensitive names are coerced', () => {
|
|
462
|
+
const result = p().mapParams({
|
|
463
|
+
company_domains: ['x.com'],
|
|
464
|
+
job_titles: ['CEO'],
|
|
465
|
+
locations: ['usa', 'UK', 'south korea'],
|
|
466
|
+
limit: 5,
|
|
467
|
+
})
|
|
468
|
+
const search = (result.body as Record<string, unknown>).search as Record<string, unknown>
|
|
469
|
+
expect(search.country_codes).toEqual([
|
|
470
|
+
{ code: 'US', name: 'United States' },
|
|
471
|
+
{ code: 'GB', name: 'United Kingdom' },
|
|
472
|
+
{ code: 'KR', name: 'South Korea' },
|
|
473
|
+
])
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
it('unrecognized location strings are dropped rather than forwarded as garbage', () => {
|
|
477
|
+
const result = p().mapParams({
|
|
478
|
+
company_domains: ['x.com'],
|
|
479
|
+
job_titles: ['CEO'],
|
|
480
|
+
locations: ['Atlantis', 'BR'],
|
|
481
|
+
limit: 5,
|
|
482
|
+
})
|
|
483
|
+
const search = (result.body as Record<string, unknown>).search as Record<string, unknown>
|
|
484
|
+
expect(search.country_codes).toEqual([{ code: 'BR', name: 'Brazil' }])
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
it('country_codes is omitted when locations is not provided', () => {
|
|
488
|
+
const result = p().mapParams({ company_domains: ['coldiq.com'], job_titles: ['CEO'], limit: 5 })
|
|
489
|
+
const search = (result.body as Record<string, unknown>).search as Record<string, unknown>
|
|
490
|
+
expect(search.country_codes).toBeUndefined()
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
it('country_codes is omitted when locations is provided but no value coerces (all unrecognized)', () => {
|
|
494
|
+
const result = p().mapParams({
|
|
495
|
+
company_domains: ['x.com'],
|
|
496
|
+
job_titles: ['CEO'],
|
|
497
|
+
locations: ['Atlantis', 'Narnia'],
|
|
498
|
+
limit: 5,
|
|
499
|
+
})
|
|
500
|
+
const search = (result.body as Record<string, unknown>).search as Record<string, unknown>
|
|
501
|
+
expect(search.country_codes).toBeUndefined()
|
|
502
|
+
})
|
|
503
|
+
|
|
433
504
|
it('seniorities and keywords are not forwarded', () => {
|
|
434
505
|
const result = p().mapParams({
|
|
435
506
|
company_domains: ['coldiq.com'],
|
|
@@ -442,6 +513,46 @@ describe('leadsfactory (find_people)', () => {
|
|
|
442
513
|
expect(body.seniorities).toBeUndefined()
|
|
443
514
|
expect(body.keywords).toBeUndefined()
|
|
444
515
|
})
|
|
516
|
+
|
|
517
|
+
it('hasResult: true when companies_personas is non-empty', () => {
|
|
518
|
+
expect(p().hasResult({ companies_personas: [{ company: 'ColdIQ' }] })).toBe(true)
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
it('hasResult: false when companies_personas is empty array', () => {
|
|
522
|
+
expect(p().hasResult({ companies_personas: [] })).toBe(false)
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
it('hasResult: true when SUCCESSFUL + nb_jobs_complete > 0 (no companies_personas in response)', () => {
|
|
526
|
+
expect(p().hasResult({ status: 'SUCCESSFUL', nb_jobs_complete: 3 })).toBe(true)
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
it('hasResult: false when SUCCESSFUL + nb_jobs_complete is null (no results)', () => {
|
|
530
|
+
expect(p().hasResult({ status: 'SUCCESSFUL', nb_jobs_complete: null })).toBe(false)
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
it('hasResult: false when SUCCESSFUL + nb_jobs_complete is 0', () => {
|
|
534
|
+
expect(p().hasResult({ status: 'SUCCESSFUL', nb_jobs_complete: 0 })).toBe(false)
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
it('isComplete: true for SUCCESSFUL', () => {
|
|
538
|
+
expect(p().async!.isComplete({ status: 'SUCCESSFUL' })).toBe(true)
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
it('isComplete: true for FAILED', () => {
|
|
542
|
+
expect(p().async!.isComplete({ status: 'FAILED' })).toBe(true)
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
it('isComplete: true for INVALID_URL (terminal error state)', () => {
|
|
546
|
+
expect(p().async!.isComplete({ status: 'INVALID_URL' })).toBe(true)
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
it('isComplete: false for RUNNING', () => {
|
|
550
|
+
expect(p().async!.isComplete({ status: 'RUNNING' })).toBe(false)
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
it('isComplete: false for PAUSED', () => {
|
|
554
|
+
expect(p().async!.isComplete({ status: 'PAUSED' })).toBe(false)
|
|
555
|
+
})
|
|
445
556
|
})
|
|
446
557
|
|
|
447
558
|
// ---------------------------------------------------------------------------
|
|
@@ -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', () => {
|
|
@@ -48,7 +48,7 @@ describe('registry', () => {
|
|
|
48
48
|
})
|
|
49
49
|
|
|
50
50
|
describe('search_companies mapParams', () => {
|
|
51
|
-
it('CompanyEnrich
|
|
51
|
+
it('CompanyEnrich routes keywords + industries into the `query` full-text field (not the tag-filter `keywords` field)', () => {
|
|
52
52
|
const providers = getProviders('search_companies')
|
|
53
53
|
const ce = providers.find((p) => p.id === 'companyenrich')!
|
|
54
54
|
const result = ce.mapParams({
|
|
@@ -59,13 +59,30 @@ describe('registry', () => {
|
|
|
59
59
|
max_employees: 200,
|
|
60
60
|
limit: 25,
|
|
61
61
|
})
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
62
|
+
const body = result.body as Record<string, unknown>
|
|
63
|
+
expect(body.query).toBe('SaaS Software')
|
|
64
|
+
expect(body.keywords).toBeUndefined()
|
|
65
|
+
expect(body.countries).toEqual(['US'])
|
|
66
|
+
expect(body.employees).toEqual([{ from: 10, to: 200 }])
|
|
67
|
+
expect(body.pageSize).toBe(25)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('CompanyEnrich brand-name keyword goes to `query` (the WeWork regression)', () => {
|
|
71
|
+
const providers = getProviders('search_companies')
|
|
72
|
+
const ce = providers.find((p) => p.id === 'companyenrich')!
|
|
73
|
+
const result = ce.mapParams({ keywords: ['wework'], limit: 5 })
|
|
74
|
+
const body = result.body as Record<string, unknown>
|
|
75
|
+
expect(body.query).toBe('wework')
|
|
76
|
+
expect(body.keywords).toBeUndefined()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('CompanyEnrich omits `query` when no keywords/industries provided', () => {
|
|
80
|
+
const providers = getProviders('search_companies')
|
|
81
|
+
const ce = providers.find((p) => p.id === 'companyenrich')!
|
|
82
|
+
const result = ce.mapParams({ countries: ['US'], limit: 5 })
|
|
83
|
+
const body = result.body as Record<string, unknown>
|
|
84
|
+
expect(body.query).toBeUndefined()
|
|
85
|
+
expect(body.keywords).toBeUndefined()
|
|
69
86
|
})
|
|
70
87
|
|
|
71
88
|
it('Apollo maps correctly, translating ISO country codes to English names', () => {
|
|
@@ -518,11 +535,11 @@ describe('registry', () => {
|
|
|
518
535
|
const sched = lf.async!.pollIntervalMs
|
|
519
536
|
expect(typeof sched).toBe('function')
|
|
520
537
|
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(
|
|
538
|
+
expect(fn(1)).toBe(2000)
|
|
539
|
+
expect(fn(2)).toBe(5000)
|
|
540
|
+
expect(fn(3)).toBe(12000)
|
|
541
|
+
expect(fn(4)).toBe(20000)
|
|
542
|
+
expect(fn(8)).toBe(52000)
|
|
526
543
|
// At least 7 polls must fit inside the 5-minute timeout — guards against
|
|
527
544
|
// future tuning that would make the ramp so steep we only get 1–2 polls.
|
|
528
545
|
let cumulative = 0
|
|
@@ -1255,7 +1272,7 @@ describe('registry', () => {
|
|
|
1255
1272
|
})
|
|
1256
1273
|
expect(result.body).toEqual({
|
|
1257
1274
|
countries: ['US'],
|
|
1258
|
-
|
|
1275
|
+
query: 'SaaS',
|
|
1259
1276
|
technologies: ['Salesforce', 'HubSpot'],
|
|
1260
1277
|
employees: undefined,
|
|
1261
1278
|
foundedYear: undefined,
|
|
@@ -400,3 +400,111 @@ 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
|
+
|
|
458
|
+
it('falls back to single find_email waterfall when bulk providers all miss (covers limadata/blitzapi/linkupapi gap)', async () => {
|
|
459
|
+
// Bulk path: Prospeo + FullEnrich + Findymail + Icypeas all miss.
|
|
460
|
+
// Single-only providers (limadata-work-email, blitzapi, limadata-work-email-linkedin, linkupapi)
|
|
461
|
+
// should be tried; blitzapi finds the email.
|
|
462
|
+
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
|
463
|
+
const u = url.toString()
|
|
464
|
+
if (u.includes('/prospeo/bulk-enrich-person')) {
|
|
465
|
+
return new Response(
|
|
466
|
+
JSON.stringify({ error: false, results: [{ identifier: 'p1', error: true, person: null }], total_cost: 0 }),
|
|
467
|
+
{ status: 200 },
|
|
468
|
+
)
|
|
469
|
+
}
|
|
470
|
+
if (u.includes('/fullenrich/contact/enrich/bulk')) {
|
|
471
|
+
// No enrichment_id returned → fullEnrichStep exits early without polling
|
|
472
|
+
return new Response(JSON.stringify({}), { status: 200 })
|
|
473
|
+
}
|
|
474
|
+
if (u.includes('/findymail/search/name')) {
|
|
475
|
+
return new Response(JSON.stringify({ email: null }), { status: 200 })
|
|
476
|
+
}
|
|
477
|
+
if (u.includes('/icypeas/email-search')) {
|
|
478
|
+
return new Response(JSON.stringify({ email: null }), { status: 200 })
|
|
479
|
+
}
|
|
480
|
+
// Single-only providers — limadata first (per priority), then blitzapi.
|
|
481
|
+
if (u.includes('/coldiq/find/work-email-linkedin')) {
|
|
482
|
+
return new Response(JSON.stringify({ email: null }), { status: 200 })
|
|
483
|
+
}
|
|
484
|
+
if (u.includes('/coldiq/find/work-email')) {
|
|
485
|
+
return new Response(JSON.stringify({ email: null }), { status: 200 })
|
|
486
|
+
}
|
|
487
|
+
if (u.includes('/blitzapi/enrichment/find-work-email')) {
|
|
488
|
+
return new Response(JSON.stringify({ email: 'alice@example.com' }), { status: 200 })
|
|
489
|
+
}
|
|
490
|
+
if (u.includes('/linkupapi/data/mail/finder')) {
|
|
491
|
+
return new Response(JSON.stringify({ email: null }), { status: 200 })
|
|
492
|
+
}
|
|
493
|
+
return new Response(JSON.stringify({ error: 'unexpected', url: u }), { status: 500 })
|
|
494
|
+
}) as typeof fetch
|
|
495
|
+
|
|
496
|
+
const result = await findEmailsHandler({
|
|
497
|
+
people: [{
|
|
498
|
+
id: 'p1',
|
|
499
|
+
first_name: 'Alice',
|
|
500
|
+
last_name: 'Smith',
|
|
501
|
+
domain: 'example.com',
|
|
502
|
+
linkedin_url: 'https://linkedin.com/in/alice',
|
|
503
|
+
}],
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
const parsed = JSON.parse(result.content[0].text)
|
|
507
|
+
expect(parsed.data.found).toBe(1)
|
|
508
|
+
expect(parsed.data.results[0]).toMatchObject({ id: 'p1', email: 'alice@example.com', provider: 'blitzapi' })
|
|
509
|
+
})
|
|
510
|
+
})
|