@coldiq/mcp 0.1.14 → 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 +1 -1
- package/src/executor.ts +25 -1
- package/src/registry.ts +32 -0
- package/tests/executor.test.ts +51 -0
- package/tests/registry-find-people.test.ts +66 -0
- package/tests/tools/find-people.test.ts +3 -3
package/package.json
CHANGED
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
|
-
|
|
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
|
@@ -1070,6 +1070,38 @@ const findPeopleProviders: ProviderEntry[] = [
|
|
|
1070
1070
|
const d = data as Record<string, unknown>
|
|
1071
1071
|
return isNonEmptyArray(d.people) || isNonEmptyArray(d.contacts)
|
|
1072
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
|
+
},
|
|
1073
1105
|
},
|
|
1074
1106
|
{
|
|
1075
1107
|
id: 'pdl',
|
package/tests/executor.test.ts
CHANGED
|
@@ -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
|
+
})
|
|
@@ -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
|
// ---------------------------------------------------------------------------
|
|
@@ -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 })
|
|
@@ -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
|
|
|
@@ -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 })
|