@coldiq/mcp 0.1.13 → 0.1.15

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.13",
3
+ "version": "0.1.15",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
package/src/executor.ts CHANGED
@@ -128,6 +128,17 @@ function sleep(ms: number): Promise<void> {
128
128
  // Execute a single provider
129
129
  // ---------------------------------------------------------------------------
130
130
 
131
+ // Per-provider sync wall-clock cap. Most providers return "no result" in <5s when
132
+ // they have no match; a slow provider returning empty late only delays the next
133
+ // fallback. Capping keeps a 9-provider waterfall walk bounded — at most ~90s end-
134
+ // to-end when every provider misses, vs. multi-minute stalls observed in production
135
+ // before this cap. Override via COLDIQ_SYNC_PROVIDER_TIMEOUT_MS for debugging/tests.
136
+ // Read each call so tests can adjust without module reloads. Does NOT apply to
137
+ // async providers — their own pollIntervalMs/timeoutMs govern.
138
+ function syncProviderTimeoutMs(): number {
139
+ return parseInt(process.env.COLDIQ_SYNC_PROVIDER_TIMEOUT_MS ?? '10000', 10)
140
+ }
141
+
131
142
  async function executeSingle(
132
143
  provider: ProviderEntry,
133
144
  input: Record<string, unknown>,
@@ -151,7 +162,20 @@ async function executeSingle(
151
162
  const asyncResult = await executeAsync(provider, payload)
152
163
  result = { ...asyncResult, status: asyncResult.ok ? 200 : 500 }
153
164
  } else {
154
- result = await callApi(provider.method, provider.endpoint, payload.body, payload.queryParams)
165
+ const cap = syncProviderTimeoutMs()
166
+ const call = callApi(provider.method, provider.endpoint, payload.body, payload.queryParams)
167
+ const timeout = new Promise<{ ok: boolean; status: number; data: unknown }>((resolve) =>
168
+ setTimeout(
169
+ () =>
170
+ resolve({
171
+ ok: false,
172
+ status: 0,
173
+ data: { error: `Provider exceeded ${cap}ms sync cap` },
174
+ }),
175
+ cap,
176
+ ),
177
+ )
178
+ result = await Promise.race([call, timeout])
155
179
  }
156
180
 
157
181
  return { ...result, latencyMs: Date.now() - start }
package/src/registry.ts CHANGED
@@ -1045,7 +1045,9 @@ const findPeopleProviders: ProviderEntry[] = [
1045
1045
  },
1046
1046
  extractId: (response) => {
1047
1047
  const d = response as Record<string, unknown>
1048
- return (d._id as string) ?? (d.search_id as string)
1048
+ // LeadsFactory's upstream returns the ObjectId as `id`; the short `search_id`
1049
+ // cannot be used as the GET param (upstream rejects it as "not a valid ObjectId").
1050
+ return (d.id as string) ?? (d.search_id as string)
1049
1051
  },
1050
1052
  },
1051
1053
  },
@@ -1068,6 +1070,38 @@ const findPeopleProviders: ProviderEntry[] = [
1068
1070
  const d = data as Record<string, unknown>
1069
1071
  return isNonEmptyArray(d.people) || isNonEmptyArray(d.contacts)
1070
1072
  },
1073
+ // Apollo's `q_organization_domains` is a soft hint — when titles match strongly,
1074
+ // Apollo returns people from other companies whose title fits (e.g. a CEO seller
1075
+ // on the Hotmart platform, or a random Brazilian CEO when filtering by WeWork).
1076
+ // Strict-filter post-hoc by the person's actual employer domain when the caller
1077
+ // supplied company identifiers.
1078
+ postFilter: (data, input) => {
1079
+ const wantDomains = ((input.company_domains as string[] | undefined) ?? [])
1080
+ .map((d) => d.toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0])
1081
+ .filter(Boolean)
1082
+ const wantLinkedIns = ((input.company_linkedin_urls as string[] | undefined) ?? [])
1083
+ .map((u) => u.toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/+$/, ''))
1084
+ .filter(Boolean)
1085
+ if (wantDomains.length === 0 && wantLinkedIns.length === 0) return data
1086
+ const d = { ...(data as Record<string, unknown>) }
1087
+ const people = (d.people as Array<Record<string, unknown>> | undefined) ?? []
1088
+ d.people = people.filter((p) => {
1089
+ const org = p.organization as Record<string, unknown> | undefined
1090
+ const orgWebsite = (org?.website_url as string | undefined)?.toLowerCase()
1091
+ .replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0]
1092
+ const orgLinkedIn = (org?.linkedin_url as string | undefined)?.toLowerCase()
1093
+ .replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/+$/, '')
1094
+ // Keep only if the person's employer matches one of the requested companies.
1095
+ // Lenient when the field is missing — providers occasionally omit org details
1096
+ // but the person is still a match (e.g. found via name+title lookup).
1097
+ if (orgWebsite && wantDomains.length > 0 && wantDomains.includes(orgWebsite)) return true
1098
+ if (orgLinkedIn && wantLinkedIns.length > 0 && wantLinkedIns.some((u) => orgLinkedIn === u)) return true
1099
+ // If we have org info but neither matches, drop. If we have no org info at all, drop too —
1100
+ // safer to lose an unverified record than to include a false positive.
1101
+ return false
1102
+ })
1103
+ return d
1104
+ },
1071
1105
  },
1072
1106
  {
1073
1107
  id: 'pdl',
@@ -738,3 +738,54 @@ describe('executeWithFallback with options.providers', () => {
738
738
 
739
739
  // Note: the LeadsFactory backoff *schedule* itself is asserted against the live
740
740
  // provider entry in tests/registry.test.ts to avoid drift between test and source.
741
+
742
+ // ---------------------------------------------------------------------------
743
+ // Per-provider sync timeout cap — keeps a missing-everywhere waterfall bounded
744
+ // ---------------------------------------------------------------------------
745
+
746
+ describe('per-provider sync timeout cap', () => {
747
+ const originalFetch = globalThis.fetch
748
+ const originalEnv = process.env.COLDIQ_SYNC_PROVIDER_TIMEOUT_MS
749
+
750
+ beforeEach(() => {
751
+ initClient('http://test-api.local', 'test-key-123')
752
+ })
753
+
754
+ afterEach(() => {
755
+ globalThis.fetch = originalFetch
756
+ vi.restoreAllMocks()
757
+ if (originalEnv === undefined) delete process.env.COLDIQ_SYNC_PROVIDER_TIMEOUT_MS
758
+ else process.env.COLDIQ_SYNC_PROVIDER_TIMEOUT_MS = originalEnv
759
+ })
760
+
761
+ it('caps a hanging sync provider and falls through to the next', async () => {
762
+ process.env.COLDIQ_SYNC_PROVIDER_TIMEOUT_MS = '120'
763
+ stubProviders([
764
+ makeProvider({ id: 'slow', priority: 1, hasResult: () => true }),
765
+ makeProvider({ id: 'fast', priority: 2, hasResult: (d) => (d as { ok?: boolean }).ok === true }),
766
+ ])
767
+
768
+ let slowCalled = false
769
+ let fastCalled = false
770
+ globalThis.fetch = vi.fn(async () => {
771
+ if (!slowCalled) {
772
+ slowCalled = true
773
+ await new Promise((r) => setTimeout(r, 5000))
774
+ return new Response(JSON.stringify({ ok: true }), { status: 200 })
775
+ }
776
+ fastCalled = true
777
+ return new Response(JSON.stringify({ ok: true }), { status: 200 })
778
+ }) as typeof fetch
779
+
780
+ const start = Date.now()
781
+ const result = await executeWithFallback('enrich_company', { domain: 'coldiq.com' })
782
+ const elapsed = Date.now() - start
783
+
784
+ expect(slowCalled).toBe(true)
785
+ expect(fastCalled).toBe(true)
786
+ expect('data' in result).toBe(true)
787
+ if ('data' in result) expect(result._meta.provider).toBe('fast')
788
+ // Slow attempt should give up around 120ms, total well under the 5s hang.
789
+ expect(elapsed).toBeLessThan(2000)
790
+ })
791
+ })
@@ -0,0 +1,97 @@
1
+ // Direct upstream probe — bypasses the marketplace proxy. Goal: figure out why
2
+ // GET /api/v1/find-people/searches/{id} returns errors from our proxy. Probe with
3
+ // both id formats (mongo _id and short search_id) and both path forms (singular vs plural).
4
+ //
5
+ // Run: LEADSFACTORY_API_KEY=… npx tsx mcp/tests/live/leadsfactory-upstream-probe.ts
6
+
7
+ const KEY = process.env.LEADSFACTORY_API_KEY
8
+ if (!KEY) { console.error('LEADSFACTORY_API_KEY required'); process.exit(1) }
9
+ const BASE = 'https://apiv2.leadsfactory.io'
10
+
11
+ async function call(method: 'GET' | 'POST', path: string, body?: unknown) {
12
+ const t0 = Date.now()
13
+ try {
14
+ const res = await fetch(`${BASE}${path}`, {
15
+ method,
16
+ headers: {
17
+ 'X-API-Key': KEY!,
18
+ ...(body ? { 'Content-Type': 'application/json' } : {}),
19
+ },
20
+ body: body ? JSON.stringify(body) : undefined,
21
+ signal: AbortSignal.timeout(30_000),
22
+ })
23
+ let data: any
24
+ try { data = await res.json() } catch { data = await res.text().catch(() => '<no body>') }
25
+ return { ok: res.ok, status: res.status, ms: Date.now() - t0, data }
26
+ } catch (err) {
27
+ return { ok: false, status: 0, ms: Date.now() - t0, data: { error: String(err) } }
28
+ }
29
+ }
30
+
31
+ function summary(r: any, max = 300) {
32
+ const s = typeof r.data === 'string' ? r.data : JSON.stringify(r.data)
33
+ return `status=${r.status} ms=${r.ms} body=${s.slice(0, max)}`
34
+ }
35
+
36
+ async function main() {
37
+ console.log('=== LeadsFactory upstream probe ===')
38
+ console.log(`BASE=${BASE}\n`)
39
+
40
+ // 1. Create a small search to get fresh IDs to test against
41
+ console.log('▸ Step 1: POST to create a small search (1 contact at ColdIQ)')
42
+ const post = await call('POST', '/api/v1/find-people/search', {
43
+ company_domains: ['coldiq.com'],
44
+ search: {
45
+ max_persona_results: 1,
46
+ personas: [{ job_title: 'CEO' }],
47
+ },
48
+ })
49
+ console.log(` ${summary(post, 800)}`)
50
+
51
+ if (!post.ok) {
52
+ console.log(' POST failed; aborting GET probes.')
53
+ return
54
+ }
55
+
56
+ const mongoId = post.data?._id
57
+ const searchId = post.data?.search_id
58
+ console.log(`\n mongoId=${mongoId}`)
59
+ console.log(` searchId=${searchId}\n`)
60
+
61
+ // 2. Try the GET in every shape we can think of
62
+ console.log('▸ Step 2: GET probes (try every path/id combo)')
63
+
64
+ for (const id of [mongoId, searchId].filter(Boolean)) {
65
+ const idType = id === mongoId ? 'mongoId' : 'searchId'
66
+ for (const path of [
67
+ `/api/v1/find-people/searches/${id}`, // current marketplace shape (plural)
68
+ `/api/v1/find-people/search/${id}`, // singular — matches POST path style
69
+ `/api/v1/find-people/searches/${id}/`, // trailing slash
70
+ `/api/v1/find-people/${id}`, // no segment
71
+ `/api/v1/find-people/search?id=${id}`, // query param
72
+ `/api/v1/find-people/search?search_id=${id}`,
73
+ ]) {
74
+ const r = await call('GET', path)
75
+ console.log(` [${idType}] GET ${path}`)
76
+ console.log(` ${summary(r)}`)
77
+ }
78
+ }
79
+
80
+ // 3. Try the GET via different auth shapes (in case X-API-Key vs Bearer matters for GET)
81
+ console.log('\n▸ Step 3: alternate auth headers on the canonical path (plural + mongoId)')
82
+ const probePath = `/api/v1/find-people/searches/${mongoId}`
83
+ for (const [label, headers] of [
84
+ ['X-API-Key', { 'X-API-Key': KEY! }],
85
+ ['Authorization Bearer', { Authorization: `Bearer ${KEY}` }],
86
+ ['Authorization raw', { Authorization: KEY! }],
87
+ ['Api-Key', { 'Api-Key': KEY! }],
88
+ ] as const) {
89
+ const t0 = Date.now()
90
+ const res = await fetch(`${BASE}${probePath}`, { headers, signal: AbortSignal.timeout(20_000) })
91
+ let body: any
92
+ try { body = await res.json() } catch { body = await res.text() }
93
+ console.log(` [${label}] status=${res.status} ms=${Date.now() - t0} body=${JSON.stringify(body).slice(0, 200)}`)
94
+ }
95
+ }
96
+
97
+ main().catch((e) => { console.error('FATAL', e); process.exit(1) })
@@ -362,6 +362,72 @@ describe('apollo (find_people)', () => {
362
362
  const body = result.body as Record<string, unknown>
363
363
  expect(body.q_keywords).toBeUndefined()
364
364
  })
365
+
366
+ it('postFilter drops people whose employer does not match company_domains', () => {
367
+ const filtered = p().postFilter!(
368
+ {
369
+ people: [
370
+ { name: 'Real WeWork CEO', organization: { website_url: 'https://wework.com/about' } },
371
+ { name: 'Random Brazilian CEO', organization: { website_url: 'shiftcompany.com' } },
372
+ { name: 'Hotmart Seller', organization: { website_url: 'someseller.com.br' } },
373
+ ],
374
+ },
375
+ { company_domains: ['wework.com'], job_titles: ['CEO'] },
376
+ ) as { people: Array<{ name: string }> }
377
+ expect(filtered.people.map((p) => p.name)).toEqual(['Real WeWork CEO'])
378
+ })
379
+
380
+ it('postFilter handles `www.` and protocol prefixes when matching', () => {
381
+ const filtered = p().postFilter!(
382
+ {
383
+ people: [
384
+ { name: 'Match A', organization: { website_url: 'https://www.coldiq.com/' } },
385
+ { name: 'Match B', organization: { website_url: 'coldiq.com' } },
386
+ { name: 'No match', organization: { website_url: 'otherco.com' } },
387
+ ],
388
+ },
389
+ { company_domains: ['coldiq.com'] },
390
+ ) as { people: Array<{ name: string }> }
391
+ expect(filtered.people.map((p) => p.name)).toEqual(['Match A', 'Match B'])
392
+ })
393
+
394
+ it('postFilter matches by LinkedIn URL when company_linkedin_urls is provided', () => {
395
+ const filtered = p().postFilter!(
396
+ {
397
+ people: [
398
+ { name: 'WW employee', organization: { linkedin_url: 'https://linkedin.com/company/wework' } },
399
+ { name: 'WW employee normalized', organization: { linkedin_url: 'linkedin.com/company/wework' } },
400
+ { name: 'Other', organization: { linkedin_url: 'linkedin.com/company/other' } },
401
+ ],
402
+ },
403
+ { company_linkedin_urls: ['https://linkedin.com/company/wework'] },
404
+ ) as { people: Array<{ name: string }> }
405
+ expect(filtered.people.map((p) => p.name)).toEqual(['WW employee', 'WW employee normalized'])
406
+ })
407
+
408
+ it('postFilter passes data through unchanged when no company filter is requested', () => {
409
+ const input = {
410
+ people: [
411
+ { name: 'A', organization: { website_url: 'a.com' } },
412
+ { name: 'B', organization: { website_url: 'b.com' } },
413
+ ],
414
+ }
415
+ const out = p().postFilter!(input, { job_titles: ['CEO'] }) as typeof input
416
+ expect(out.people.map((p) => p.name)).toEqual(['A', 'B'])
417
+ })
418
+
419
+ it('postFilter drops records that are missing organization info entirely (safer to lose than to risk false positives)', () => {
420
+ const filtered = p().postFilter!(
421
+ {
422
+ people: [
423
+ { name: 'Has org', organization: { website_url: 'wework.com' } },
424
+ { name: 'No org' }, // missing organization
425
+ ],
426
+ },
427
+ { company_domains: ['wework.com'] },
428
+ ) as { people: Array<{ name: string }> }
429
+ expect(filtered.people.map((p) => p.name)).toEqual(['Has org'])
430
+ })
365
431
  })
366
432
 
367
433
  // ---------------------------------------------------------------------------
@@ -21,7 +21,7 @@ describe('find_people handler', () => {
21
21
 
22
22
  // LeadsFactory create search — returns an ID
23
23
  if (urlStr.includes('/leadsfactory/contact-finder/searches') && callCount === 1) {
24
- return new Response(JSON.stringify({ _id: 'abc123', search_id: 'abc', nb_jobs_total: 1, progress_percentage: 0 }), { status: 201 })
24
+ return new Response(JSON.stringify({ id: 'abc123', search_id: 'abc', nb_jobs_total: 1, progress_percentage: 0 }), { status: 201 })
25
25
  }
26
26
 
27
27
  // LeadsFactory poll — always RUNNING (will timeout)
@@ -31,7 +31,7 @@ describe('find_people handler', () => {
31
31
 
32
32
  // Apollo — succeeds
33
33
  if (urlStr.includes('/apollo/people/search')) {
34
- return new Response(JSON.stringify({ people: [{ name: 'Michel Lieben', title: 'CEO' }] }), { status: 200 })
34
+ return new Response(JSON.stringify({ people: [{ name: 'Michel Lieben', title: 'CEO', organization: { website_url: 'coldiq.com' } }] }), { status: 200 })
35
35
  }
36
36
 
37
37
  return new Response(JSON.stringify({ error: 'Not found' }), { status: 404 })
@@ -70,7 +70,7 @@ describe('find_people handler', () => {
70
70
 
71
71
  // LeadsFactory create search
72
72
  if (urlStr.includes('/leadsfactory/contact-finder/searches') && !urlStr.includes('abc123')) {
73
- return new Response(JSON.stringify({ _id: 'abc123', search_id: 'abc', nb_jobs_total: 1, progress_percentage: 0 }), { status: 201 })
73
+ return new Response(JSON.stringify({ id: 'abc123', search_id: 'abc', nb_jobs_total: 1, progress_percentage: 0 }), { status: 201 })
74
74
  }
75
75
 
76
76
  // LeadsFactory poll — first RUNNING, then SUCCESSFUL with contacts
@@ -121,7 +121,7 @@ describe('find_people handler', () => {
121
121
 
122
122
  // LeadsFactory create
123
123
  if (urlStr.includes('/leadsfactory/contact-finder/searches') && !urlStr.includes('abc123')) {
124
- return new Response(JSON.stringify({ _id: 'abc123', search_id: 'abc', nb_jobs_total: 2, progress_percentage: 0 }), { status: 201 })
124
+ return new Response(JSON.stringify({ id: 'abc123', search_id: 'abc', nb_jobs_total: 2, progress_percentage: 0 }), { status: 201 })
125
125
  }
126
126
 
127
127
  // LeadsFactory poll — SUCCESSFUL with one miss
@@ -138,7 +138,7 @@ describe('find_people handler', () => {
138
138
  if (urlStr.includes('/apollo/people/search')) {
139
139
  const body = JSON.parse((opts?.body as string) ?? '{}')
140
140
  if (body.q_organization_domains?.includes('folk.app')) {
141
- return new Response(JSON.stringify({ people: [{ name: 'Thibaud Elziere', title: 'CEO' }] }), { status: 200 })
141
+ return new Response(JSON.stringify({ people: [{ name: 'Thibaud Elziere', title: 'CEO', organization: { website_url: 'folk.app' } }] }), { status: 200 })
142
142
  }
143
143
  }
144
144
 
@@ -179,7 +179,7 @@ describe('find_people handler', () => {
179
179
  const urlStr = url.toString()
180
180
 
181
181
  if (urlStr.includes('/leadsfactory/contact-finder/searches') && !urlStr.includes('abc123')) {
182
- return new Response(JSON.stringify({ _id: 'abc123', nb_jobs_total: 2, progress_percentage: 0 }), { status: 201 })
182
+ return new Response(JSON.stringify({ id: 'abc123', nb_jobs_total: 2, progress_percentage: 0 }), { status: 201 })
183
183
  }
184
184
 
185
185
  if (urlStr.includes('/leadsfactory/contact-finder/searches/abc123')) {
@@ -231,7 +231,7 @@ describe('find_people handler', () => {
231
231
 
232
232
  if (urlStr.includes('/leadsfactory/contact-finder/searches') && !urlStr.includes('abc123')) {
233
233
  capturedBody = JSON.parse((opts?.body as string) ?? '{}')
234
- return new Response(JSON.stringify({ _id: 'abc123', nb_jobs_total: 2, progress_percentage: 0 }), { status: 201 })
234
+ return new Response(JSON.stringify({ id: 'abc123', nb_jobs_total: 2, progress_percentage: 0 }), { status: 201 })
235
235
  }
236
236
 
237
237
  if (urlStr.includes('/leadsfactory/contact-finder/searches/abc123')) {
@@ -277,7 +277,7 @@ describe('find_people handler', () => {
277
277
 
278
278
  if (urlStr.includes('/leadsfactory/contact-finder/searches') && !urlStr.includes('abc123')) {
279
279
  capturedBody = JSON.parse((opts?.body as string) ?? '{}')
280
- return new Response(JSON.stringify({ _id: 'abc123', nb_jobs_total: 1, progress_percentage: 0 }), { status: 201 })
280
+ return new Response(JSON.stringify({ id: 'abc123', nb_jobs_total: 1, progress_percentage: 0 }), { status: 201 })
281
281
  }
282
282
 
283
283
  if (urlStr.includes('/leadsfactory/contact-finder/searches/abc123')) {
@@ -325,7 +325,7 @@ describe('find_people handler', () => {
325
325
  }
326
326
 
327
327
  if (urlStr.includes('/apollo/people/search')) {
328
- return new Response(JSON.stringify({ people: [{ name: 'Michel Lieben' }] }), { status: 200 })
328
+ return new Response(JSON.stringify({ people: [{ name: 'Michel Lieben', organization: { website_url: 'coldiq.com' } }] }), { status: 200 })
329
329
  }
330
330
 
331
331
  return new Response(JSON.stringify({ error: 'Not found' }), { status: 404 })