@coldiq/mcp 0.1.12 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coldiq/mcp",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
package/src/registry.ts CHANGED
@@ -109,6 +109,60 @@ export function isoCountryToName(code: string): string {
109
109
  }
110
110
  }
111
111
 
112
+ // Reverse lookup: English country name → ISO 3166-1 alpha-2. Built lazily on first use.
113
+ // Agents routinely pass "Brazil" / "United States" instead of "BR" / "US" — without this
114
+ // coercion, downstream providers that require ISO codes (LeadsFactory, BlitzAPI) silently
115
+ // skip the filter.
116
+ let _nameToCode: Map<string, string> | undefined
117
+ function buildNameToCode(): Map<string, string> {
118
+ const map = new Map<string, string>()
119
+ // Iterate all ISO-2 codes A-Z × A-Z; keep those Intl.DisplayNames resolves.
120
+ for (let a = 65; a <= 90; a++) {
121
+ for (let b = 65; b <= 90; b++) {
122
+ const code = String.fromCharCode(a, b)
123
+ try {
124
+ const name = _displayNames.of(code)
125
+ if (name && name !== code) map.set(name.toLowerCase(), code)
126
+ } catch { /* skip */ }
127
+ }
128
+ }
129
+ // Common aliases not covered by Intl.DisplayNames defaults.
130
+ map.set('usa', 'US')
131
+ map.set('uk', 'GB')
132
+ map.set('south korea', 'KR')
133
+ map.set('north korea', 'KP')
134
+ map.set('russia', 'RU')
135
+ map.set('iran', 'IR')
136
+ map.set('syria', 'SY')
137
+ map.set('uae', 'AE')
138
+ map.set('emirates', 'AE')
139
+ return map
140
+ }
141
+
142
+ export function nameToIsoCountry(input: string): string | undefined {
143
+ if (typeof input !== 'string' || input.length === 0) return undefined
144
+ if (!_nameToCode) _nameToCode = buildNameToCode()
145
+ // Check aliases first — "UK" matches the ISO-2 regex but is not a valid code (GB is).
146
+ const aliased = _nameToCode.get(input.trim().toLowerCase())
147
+ if (aliased) return aliased
148
+ if (_ALPHA2_RE.test(input)) {
149
+ const upper = input.toUpperCase()
150
+ // Only accept if Intl actually resolves it — rejects "ZZ" etc.
151
+ try {
152
+ if (_displayNames.of(upper)) return upper
153
+ } catch { /* fall through */ }
154
+ }
155
+ return undefined
156
+ }
157
+
158
+ // Coerce a list of strings (mix of ISO-2 codes and country names) into ISO-2 codes.
159
+ // Unrecognized inputs are dropped (rather than passed through) so providers don't
160
+ // receive garbage like { code: "Brazil", name: "Brazil" }.
161
+ export function coerceCountriesToIso(values: readonly string[] | undefined): string[] {
162
+ if (!values?.length) return []
163
+ return values.map((v) => nameToIsoCountry(v)).filter((c): c is string => typeof c === 'string')
164
+ }
165
+
112
166
  // ---------------------------------------------------------------------------
113
167
  // Strict post-filter helpers
114
168
  // ---------------------------------------------------------------------------
@@ -200,9 +254,12 @@ const searchCompaniesProviders: ProviderEntry[] = [
200
254
  method: 'POST',
201
255
  priority: 1,
202
256
  mapParams: (input) => {
203
- // CompanyEnrich industries require exact taxonomy matches free-text values like
204
- // "SaaS" produce empty results. Fold industries into keywords for flexible matching.
205
- const keywords = [
257
+ // CompanyEnrich's `keywords` body field is a tag filter that does not match
258
+ // brand names sending "wework" returns global defaults instead of WeWork.
259
+ // Route free-text input through `query` (full-text on company name + domain).
260
+ // Industries are folded into the same string because CompanyEnrich's `industries`
261
+ // field requires exact taxonomy matches and rejects free-text like "SaaS".
262
+ const queryTerms = [
206
263
  ...((input.keywords as string[] | undefined) ?? []),
207
264
  ...((input.industries as string[] | undefined) ?? []),
208
265
  ]
@@ -213,7 +270,7 @@ const searchCompaniesProviders: ProviderEntry[] = [
213
270
  return {
214
271
  body: {
215
272
  countries: input.countries,
216
- keywords: keywords.length > 0 ? keywords : undefined,
273
+ query: queryTerms.length > 0 ? queryTerms.join(' ') : undefined,
217
274
  technologies: isNonEmptyArray(input.technologies) ? input.technologies : undefined,
218
275
  employees:
219
276
  input.min_employees || input.max_employees
@@ -944,6 +1001,14 @@ const findPeopleProviders: ProviderEntry[] = [
944
1001
  ? [...groups.values()].map((titles) => ({ job_title: titles.join(', ') }))
945
1002
  : [{ job_title: 'Decision Maker' }]
946
1003
  const hasLinkedInUrls = isNonEmptyArray(input.company_linkedin_urls)
1004
+ // LF natively scopes contacts by person location via country_codes. Coerce
1005
+ // input.locations (which agents pass as a mix of ISO-2 codes "BR" and country
1006
+ // names "Brazil") to ISO-2 first — LF rejects/ignores anything else and silently
1007
+ // returns unfiltered global results.
1008
+ const isoCodes = coerceCountriesToIso(input.locations as string[] | undefined)
1009
+ const country_codes = isoCodes.length > 0
1010
+ ? isoCodes.map((code) => ({ code, name: isoCountryToName(code) }))
1011
+ : undefined
947
1012
  return {
948
1013
  body: {
949
1014
  ...(hasLinkedInUrls && { company_linkedin_urls: input.company_linkedin_urls }),
@@ -954,6 +1019,7 @@ const findPeopleProviders: ProviderEntry[] = [
954
1019
  search: {
955
1020
  max_persona_results: (input.limit as number) ?? 25,
956
1021
  personas,
1022
+ ...(country_codes && { country_codes }),
957
1023
  },
958
1024
  },
959
1025
  }
@@ -962,7 +1028,7 @@ const findPeopleProviders: ProviderEntry[] = [
962
1028
  const d = data as Record<string, unknown>
963
1029
  return (
964
1030
  isNonEmptyArray(d.companies_personas) ||
965
- (d.status === 'SUCCESSFUL' && typeof d.nb_jobs_complete === 'number' && (d.nb_jobs_complete as number) > 0)
1031
+ (d.status === 'SUCCESSFUL' && d.nb_jobs_complete != null && (d.nb_jobs_complete as number) > 0)
966
1032
  )
967
1033
  },
968
1034
  async: {
@@ -975,7 +1041,7 @@ const findPeopleProviders: ProviderEntry[] = [
975
1041
  isComplete: (data) => {
976
1042
  const d = data as Record<string, unknown>
977
1043
  const status = d.status as string | undefined
978
- return status === 'SUCCESSFUL' || status === 'FAILED'
1044
+ return status === 'SUCCESSFUL' || status === 'FAILED' || status === 'INVALID_URL'
979
1045
  },
980
1046
  extractId: (response) => {
981
1047
  const d = response as Record<string, unknown>
@@ -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 =
@@ -221,6 +250,41 @@ export async function findEmailsHandler(input: Record<string, unknown>) {
221
250
  if (steps.length > 0) await Promise.allSettled(steps)
222
251
  }
223
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
+ }
286
+ }
287
+
224
288
  const found = results.filter((r) => r.email !== null).length
225
289
 
226
290
  if (isConstrained && found === 0) {
@@ -22,7 +22,7 @@ export const findPeopleSchema = {
22
22
  company_domains: z.array(z.string()).optional().describe('Company domains to search (e.g. ["microsoft.com", "google.com"]). Use only when LinkedIn URLs are not available.'),
23
23
  job_titles: z.array(z.string()).optional().describe('Target job titles (e.g. ["CEO", "VP of Sales"])'),
24
24
  seniorities: z.array(z.string()).optional().describe('Seniority levels (e.g. ["c_suite", "vp", "director"])'),
25
- locations: z.array(z.string()).optional().describe('Person or company locations'),
25
+ locations: z.array(z.string()).optional().describe('Person locations as either ISO-2 country codes ("BR") or English country names ("Brazil") — both are accepted and normalized internally. LeadsFactory uses these to scope contacts to the country; other providers fall back to free-text matching.'),
26
26
  keywords: z.array(z.string()).optional().describe('Free-text keyword search terms (e.g. ["growth", "AI"])'),
27
27
  limit: z.number().min(1).max(500).default(25).describe('Max results across all companies combined (default: 25, max: 500). For multiple companies multiply: e.g. 10 companies × 5 each = 50.'),
28
28
  use_providers: z.array(z.string()).optional().describe(`Optional ordered list of providers to use. Leave empty to let ColdIQ automatically pick the best tool for your inputs — recommended for most use cases. Available providers: ${getProvidersForCapability('find_people').join(', ')}. Provider names are matched fuzzily, so minor typos are tolerated.`),
@@ -0,0 +1,31 @@
1
+ // Probe /companies/search/async body shape — does it want filters flat under `search`
2
+ // or still wrapped under `search.filters`?
3
+ const KEY = process.env.COMPANYENRICH_API_KEY!
4
+ const BASE = 'https://api.companyenrich.com'
5
+
6
+ async function call(label: string, body: unknown) {
7
+ const t0 = Date.now()
8
+ const res = await fetch(`${BASE}/companies/search/async`, {
9
+ method: 'POST',
10
+ headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
11
+ body: JSON.stringify(body),
12
+ signal: AbortSignal.timeout(20_000),
13
+ })
14
+ let data: any
15
+ try { data = await res.json() } catch { data = '<non-json>' }
16
+ console.log(`[${label}]\n body=${JSON.stringify(body)}\n status=${res.status} ms=${Date.now() - t0} data=${JSON.stringify(data).slice(0, 300)}\n`)
17
+ }
18
+
19
+ async function main() {
20
+ // Use count=1 to minimise credit burn.
21
+ await call('A: search.filters wrapped (current)', {
22
+ search: { filters: { query: 'wework' } }, count: 1,
23
+ })
24
+ await call('B: search flat (proposed)', {
25
+ search: { query: 'wework' }, count: 1,
26
+ })
27
+ // Some async APIs accept query at the very top too.
28
+ await call('C: query at top, no search envelope', { query: 'wework', count: 1 })
29
+ }
30
+
31
+ main()
@@ -0,0 +1,125 @@
1
+ // Validation suite — confirms the fix direction before committing.
2
+ //
3
+ // What we need to prove:
4
+ // 1. `{filters: {...}}` wrapping is broken across the FOUR search endpoints
5
+ // (search, preview, count, scroll). If only `/search` is affected, the fix is narrower.
6
+ // 2. Top-level shape works consistently — filter combinations (query + country,
7
+ // query + employees, etc.) all narrow the result set as expected.
8
+ // 3. The bulk-enrich endpoint is unaffected (the bug is search-specific).
9
+ // 4. Ranges that wrapFilters translates (employees, foundedYear) still need their
10
+ // translation logic — confirm whether upstream wants employeesFrom/employeesTo top-level
11
+ // or {employees: {from, to}} or [{from, to}].
12
+ //
13
+ // Run: COMPANYENRICH_API_KEY=… npx tsx mcp/tests/live/companyenrich-fix-validation.ts
14
+
15
+ const KEY = process.env.COMPANYENRICH_API_KEY
16
+ if (!KEY) { console.error('COMPANYENRICH_API_KEY required'); process.exit(1) }
17
+ const BASE = 'https://api.companyenrich.com'
18
+
19
+ async function callRaw(path: string, body: unknown) {
20
+ const t0 = Date.now()
21
+ try {
22
+ const res = await fetch(`${BASE}${path}`, {
23
+ method: 'POST',
24
+ headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
25
+ body: JSON.stringify(body),
26
+ signal: AbortSignal.timeout(30_000),
27
+ })
28
+ let data: any
29
+ try { data = await res.json() } catch { data = '<non-json>' }
30
+ return { ok: res.ok, status: res.status, ms: Date.now() - t0, data }
31
+ } catch (err) {
32
+ return { ok: false, status: 0, ms: Date.now() - t0, data: { error: String(err) } }
33
+ }
34
+ }
35
+
36
+ function summarize(r: any) {
37
+ const items = (r.data?.items ?? []).slice(0, 3).map((c: any) => `${c.name}/${c.country ?? '?'}`)
38
+ return `status=${r.status} ms=${r.ms} count=${r.data?.items?.length ?? r.data?.count ?? '?'} top=${JSON.stringify(items)}`
39
+ }
40
+
41
+ function header(s: string) { console.log(`\n${'═'.repeat(78)}\n${s}\n${'═'.repeat(78)}`) }
42
+ function sub(s: string) { console.log(`\n— ${s}`) }
43
+
44
+ async function main() {
45
+ // ============================================================================
46
+ header('PART 1 — Confirm the wrap-vs-top-level bug on EACH of 4 search endpoints')
47
+ // ============================================================================
48
+
49
+ const wrapped = { pageSize: 5, filters: { query: 'wework' } }
50
+ const flat = { pageSize: 5, query: 'wework' }
51
+
52
+ for (const path of ['/companies/search', '/companies/search/preview', '/companies/search/scroll']) {
53
+ sub(`${path} wrapped (current)`)
54
+ console.log(' ', summarize(await callRaw(path, wrapped)))
55
+ sub(`${path} flat (proposed)`)
56
+ console.log(' ', summarize(await callRaw(path, flat)))
57
+ }
58
+
59
+ // count endpoint returns a count not items
60
+ sub('/companies/search/count wrapped (current)')
61
+ let r = await callRaw('/companies/search/count', { filters: { query: 'wework' } })
62
+ console.log(` status=${r.status} ms=${r.ms} data=${JSON.stringify(r.data).slice(0, 200)}`)
63
+ sub('/companies/search/count flat (proposed)')
64
+ r = await callRaw('/companies/search/count', { query: 'wework' })
65
+ console.log(` status=${r.status} ms=${r.ms} data=${JSON.stringify(r.data).slice(0, 200)}`)
66
+
67
+ // ============================================================================
68
+ header('PART 2 — Confirm flat-shape combinations actually narrow results')
69
+ // ============================================================================
70
+
71
+ sub('flat: query="wework" + countries=["BR"]')
72
+ console.log(' ', summarize(await callRaw('/companies/search', { pageSize: 5, query: 'wework', countries: ['BR'] })))
73
+
74
+ sub('flat: query="wework" + countries=["US"]')
75
+ console.log(' ', summarize(await callRaw('/companies/search', { pageSize: 5, query: 'wework', countries: ['US'] })))
76
+
77
+ sub('flat: countries=["BR"] + keywords=["fintech"]')
78
+ console.log(' ', summarize(await callRaw('/companies/search', { pageSize: 5, countries: ['BR'], keywords: ['fintech'] })))
79
+
80
+ sub('flat: keywords=["SaaS"] (does keywords field do anything at top level?)')
81
+ console.log(' ', summarize(await callRaw('/companies/search', { pageSize: 5, keywords: ['SaaS'] })))
82
+
83
+ sub('flat: industries=["Software"] (does industries field do anything?)')
84
+ console.log(' ', summarize(await callRaw('/companies/search', { pageSize: 5, industries: ['Software'] })))
85
+
86
+ // ============================================================================
87
+ header('PART 3 — Confirm range shape (employees, foundedYear) works at top level')
88
+ // ============================================================================
89
+
90
+ sub('flat: employees=[{from:1000, to:5000}]')
91
+ console.log(' ', summarize(await callRaw('/companies/search', { pageSize: 5, employees: [{ from: 1000, to: 5000 }] })))
92
+
93
+ sub('flat: employeesFrom=1000 + employeesTo=5000 (the wrapFilters translation target)')
94
+ console.log(' ', summarize(await callRaw('/companies/search', { pageSize: 5, employeesFrom: 1000, employeesTo: 5000 })))
95
+
96
+ sub('flat: foundedYear={from:2015, to:2020}')
97
+ console.log(' ', summarize(await callRaw('/companies/search', { pageSize: 5, foundedYear: { from: 2015, to: 2020 } })))
98
+
99
+ sub('flat: foundedFrom=2015 + foundedTo=2020 (translated form)')
100
+ console.log(' ', summarize(await callRaw('/companies/search', { pageSize: 5, foundedFrom: 2015, foundedTo: 2020 })))
101
+
102
+ // ============================================================================
103
+ header('PART 4 — Confirm bulk-enrich endpoint is unaffected (no wrapFilters there)')
104
+ // ============================================================================
105
+
106
+ sub('POST /companies/enrich?domain=wework.com (single enrich, GET-like)')
107
+ // Single enrich uses query params, not wrapFilters — should be unaffected.
108
+ try {
109
+ const er = await fetch(`${BASE}/companies/enrich?domain=wework.com`, {
110
+ method: 'GET',
111
+ headers: { Authorization: `Bearer ${KEY}` },
112
+ signal: AbortSignal.timeout(20_000),
113
+ })
114
+ const ed = await er.json()
115
+ console.log(` status=${er.status} name=${(ed as any)?.name} domain=${(ed as any)?.domain}`)
116
+ } catch (err) {
117
+ console.log(` failed: ${err}`)
118
+ }
119
+
120
+ // ============================================================================
121
+ header('DONE')
122
+ // ============================================================================
123
+ }
124
+
125
+ main().catch((e) => { console.error('FATAL', e); process.exit(1) })
@@ -0,0 +1,54 @@
1
+ // Hybrid: scalars top-level, ranges wrapped? Or ranges with a special shape?
2
+ const KEY = process.env.COMPANYENRICH_API_KEY!
3
+ const BASE = 'https://api.companyenrich.com'
4
+
5
+ async function count(label: string, body: unknown) {
6
+ const t0 = Date.now()
7
+ const res = await fetch(`${BASE}/companies/search/count`, {
8
+ method: 'POST',
9
+ headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
10
+ body: JSON.stringify(body),
11
+ signal: AbortSignal.timeout(20_000),
12
+ })
13
+ const data: any = await res.json().catch(() => ({}))
14
+ console.log(`[${label}]\n body=${JSON.stringify(body)}\n status=${res.status} ms=${Date.now() - t0} count=${data?.count} total=${data?.total}\n`)
15
+ }
16
+
17
+ async function main() {
18
+ // Hypothesis A: maybe ranges need `filters: {}` wrapping (hybrid model)
19
+ await count('A1: countries top-level + filters.employees', {
20
+ countries: ['US'], filters: { employees: { from: 1000, to: 5000 } },
21
+ })
22
+ await count('A2: countries top-level + filters.employeesFrom/To', {
23
+ countries: ['US'], filters: { employeesFrom: 1000, employeesTo: 5000 },
24
+ })
25
+
26
+ // Hypothesis B: everything inside filters (back to old shape)
27
+ await count('B: filters.countries + filters.employees array', {
28
+ filters: { countries: ['US'], employees: [{ from: 1000, to: 5000 }] },
29
+ })
30
+
31
+ // Hypothesis C: maybe a different field name (size, headcount_range, employee_range)
32
+ await count('C1: size {from,to}', { size: { from: 1000, to: 5000 } })
33
+ await count('C2: headcountRange {min,max}', { headcountRange: { min: 1000, max: 5000 } })
34
+ await count('C3: employeesRange', { employeesRange: { from: 1000, to: 5000 } })
35
+
36
+ // Hypothesis D: maybe array of buckets like ["1001-5000"] or letter codes
37
+ await count('D1: employees ["1001-5000"]', { employees: ['1001-5000'] })
38
+ await count('D2: employees ["F"]', { employees: ['F'] }) // LimaData uses letter buckets, just in case
39
+
40
+ // Hypothesis E: snake_case field name
41
+ await count('E: employees_from/employees_to', { employees_from: 1000, employees_to: 5000 })
42
+
43
+ // Hypothesis F: revenue & funding to triangulate — do ANY ranges work?
44
+ await count('F1: revenueFrom/revenueTo', { revenueFrom: 1000000, revenueTo: 10000000 })
45
+ await count('F2: filters.revenueFrom/revenueTo', { filters: { revenueFrom: 1000000, revenueTo: 10000000 } })
46
+
47
+ // Hypothesis G: lookup an actual reference-data endpoint for valid bucket IDs (CompanyEnrich does have one)
48
+ // Skipping — adds latency; do if all of the above fail.
49
+
50
+ // Sanity check
51
+ await count('sanity: countries=DE alone', { countries: ['DE'] })
52
+ }
53
+
54
+ main()
@@ -0,0 +1,49 @@
1
+ // Probe CompanyEnrich /companies/search to find the correct field name for brand-name lookup.
2
+ // We hit our marketplace endpoint with different request shapes and observe what comes back.
3
+ const API_URL = process.env.COLDIQ_API_URL ?? 'https://api.coldiq.com'
4
+ const API_KEY = process.env.COLDIQ_API_KEY!
5
+
6
+ async function call(body: unknown) {
7
+ const t0 = Date.now()
8
+ const res = await fetch(`${API_URL}/v1/companyenrich/companies/search`, {
9
+ method: 'POST',
10
+ headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
11
+ body: JSON.stringify(body),
12
+ signal: AbortSignal.timeout(30_000),
13
+ })
14
+ let data: any
15
+ try { data = await res.json() } catch { data = '<non-json>' }
16
+ return { ok: res.ok, status: res.status, ms: Date.now() - t0, data }
17
+ }
18
+
19
+ function summarize(r: any) {
20
+ const items = (r.data?.items ?? []).slice(0, 5).map((c: any) => `${c.name} (${c.domain})`)
21
+ return `status=${r.status} ms=${r.ms} items=${items.length} top5=${JSON.stringify(items)}`
22
+ }
23
+
24
+ async function main() {
25
+ console.log('=== Probe: how to find WeWork in CompanyEnrich ===\n')
26
+
27
+ console.log('1) query="wework"')
28
+ console.log(' ', summarize(await call({ query: 'wework', pageSize: 5 })))
29
+
30
+ console.log('\n2) names=["WeWork"]')
31
+ console.log(' ', summarize(await call({ names: ['WeWork'], pageSize: 5 })))
32
+
33
+ console.log('\n3) domains=["wework.com"]')
34
+ console.log(' ', summarize(await call({ domains: ['wework.com'], pageSize: 5 })))
35
+
36
+ console.log('\n4) query="wework" + countries=["US"]')
37
+ console.log(' ', summarize(await call({ query: 'wework', countries: ['US'], pageSize: 5 })))
38
+
39
+ console.log('\n5) semanticQuery="WeWork coworking"')
40
+ console.log(' ', summarize(await call({ semanticQuery: 'WeWork coworking', pageSize: 5 })))
41
+
42
+ console.log('\n6) names=["wework"] (lowercase)')
43
+ console.log(' ', summarize(await call({ names: ['wework'], pageSize: 5 })))
44
+
45
+ console.log('\n7) only countries=["BR"] (baseline — what does no name filter return?)')
46
+ console.log(' ', summarize(await call({ countries: ['BR'], pageSize: 5 })))
47
+ }
48
+
49
+ main().catch(console.error)
@@ -0,0 +1,49 @@
1
+ // Probe the range-filter shape. We use /companies/search/count because it gives a
2
+ // definitive "did the filter narrow?" signal — full-DB count is ~20.4M, so anything
3
+ // less means the filter applied.
4
+ const KEY = process.env.COMPANYENRICH_API_KEY!
5
+ const BASE = 'https://api.companyenrich.com'
6
+
7
+ async function count(label: string, body: unknown) {
8
+ const t0 = Date.now()
9
+ const res = await fetch(`${BASE}/companies/search/count`, {
10
+ method: 'POST',
11
+ headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
12
+ body: JSON.stringify(body),
13
+ signal: AbortSignal.timeout(20_000),
14
+ })
15
+ const data: any = await res.json().catch(() => ({}))
16
+ const ms = Date.now() - t0
17
+ console.log(`[${label}]`)
18
+ console.log(` body=${JSON.stringify(body)}`)
19
+ console.log(` status=${res.status} ms=${ms} count=${data?.count} total=${data?.total}\n`)
20
+ }
21
+
22
+ async function main() {
23
+ // Baselines
24
+ await count('baseline empty', {})
25
+ await count('baseline countries=US', { countries: ['US'] })
26
+
27
+ // Range candidates — try every shape
28
+ await count('flat employeesFrom/employeesTo', { employeesFrom: 1000, employeesTo: 5000 })
29
+ await count('flat employees array [{from,to}]', { employees: [{ from: 1000, to: 5000 }] })
30
+ await count('flat employees object {from,to}', { employees: { from: 1000, to: 5000 } })
31
+ await count('flat employees scalar {min,max}', { employees: { min: 1000, max: 5000 } })
32
+ await count('flat employeesMin/employeesMax', { employeesMin: 1000, employeesMax: 5000 })
33
+ await count('flat headcount', { headcount: { from: 1000, to: 5000 } })
34
+ await count('flat employee_count', { employee_count: { from: 1000, to: 5000 } })
35
+
36
+ // Ranges combined with a primary filter (in case they need company narrowing first)
37
+ await count('countries=US + employeesFrom/To', { countries: ['US'], employeesFrom: 1000, employeesTo: 5000 })
38
+ await count('countries=US + employees array', { countries: ['US'], employees: [{ from: 1000, to: 5000 }] })
39
+
40
+ // Founded year shapes
41
+ await count('foundedFrom/foundedTo', { foundedFrom: 2015, foundedTo: 2020 })
42
+ await count('foundedYear {from,to}', { foundedYear: { from: 2015, to: 2020 } })
43
+ await count('foundedYearFrom/foundedYearTo', { foundedYearFrom: 2015, foundedYearTo: 2020 })
44
+
45
+ // Sanity — does a known-working filter still narrow alongside ranges?
46
+ await count('countries=BR (sanity)', { countries: ['BR'] })
47
+ }
48
+
49
+ main()
@@ -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
  // ---------------------------------------------------------------------------
@@ -48,7 +48,7 @@ describe('registry', () => {
48
48
  })
49
49
 
50
50
  describe('search_companies mapParams', () => {
51
- it('CompanyEnrich maps correctly and folds industries into keywords', () => {
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
- expect(result.body).toEqual({
63
- keywords: ['SaaS', 'Software'],
64
- countries: ['US'],
65
- employees: [{ from: 10, to: 200 }],
66
- foundedYear: undefined,
67
- pageSize: 25,
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', () => {
@@ -1255,7 +1272,7 @@ describe('registry', () => {
1255
1272
  })
1256
1273
  expect(result.body).toEqual({
1257
1274
  countries: ['US'],
1258
- keywords: ['SaaS'],
1275
+ query: 'SaaS',
1259
1276
  technologies: ['Salesforce', 'HubSpot'],
1260
1277
  employees: undefined,
1261
1278
  foundedYear: undefined,
@@ -454,4 +454,57 @@ describe('find_emails handler — resilience', () => {
454
454
  expect(parsed.data.found).toBe(1)
455
455
  expect(parsed.data.results[0]).toMatchObject({ id: 'p1', email: 'alice@example.com', provider: 'findymail' })
456
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
+ })
457
510
  })