@coldiq/mcp 0.1.6 → 0.1.8
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/README.md +3 -1
- package/dist/executor.d.ts +4 -0
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +24 -4
- package/dist/executor.js.map +1 -1
- package/dist/registry.d.ts +6 -2
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +4 -1
- package/dist/registry.js.map +1 -1
- package/dist/tools/find-email.d.ts +1 -1
- package/dist/tools/find-email.d.ts.map +1 -1
- package/dist/tools/find-email.js +4 -1
- package/dist/tools/find-email.js.map +1 -1
- package/dist/tools/find-emails.d.ts.map +1 -1
- package/dist/tools/find-emails.js +89 -72
- package/dist/tools/find-emails.js.map +1 -1
- package/package.json +1 -1
- package/src/executor.ts +30 -4
- package/src/registry.ts +11 -3
- package/src/tools/find-email.ts +4 -1
- package/src/tools/find-emails.ts +103 -88
- package/tests/executor.test.ts +169 -0
- package/tests/registry.test.ts +19 -2
- package/tests/tools/find-emails.test.ts +43 -0
package/tests/executor.test.ts
CHANGED
|
@@ -308,3 +308,172 @@ describe('executor postFilter integration', () => {
|
|
|
308
308
|
}
|
|
309
309
|
})
|
|
310
310
|
})
|
|
311
|
+
|
|
312
|
+
// 402 / Insufficient credits propagation
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
describe('executor — 402 propagation', () => {
|
|
316
|
+
const originalFetch = globalThis.fetch
|
|
317
|
+
|
|
318
|
+
beforeEach(() => {
|
|
319
|
+
initClient('http://test-api.local', 'test-key-123')
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
afterEach(() => {
|
|
323
|
+
globalThis.fetch = originalFetch
|
|
324
|
+
vi.restoreAllMocks()
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('surfaces billingUrl + insufficientCredits when every provider returns 402', async () => {
|
|
328
|
+
stubProviders([
|
|
329
|
+
makeProvider({ id: 'p1', hasResult: () => false }),
|
|
330
|
+
makeProvider({ id: 'p2', hasResult: () => false }),
|
|
331
|
+
])
|
|
332
|
+
|
|
333
|
+
const body = {
|
|
334
|
+
error: "You don't have enough credits to run this request — it costs 5 credits and your balance is 0. Top up your account at https://coldiq.com/marketplace/billing to continue.",
|
|
335
|
+
billingUrl: 'https://coldiq.com/marketplace/billing',
|
|
336
|
+
needed: 5,
|
|
337
|
+
balance: 0,
|
|
338
|
+
}
|
|
339
|
+
globalThis.fetch = vi.fn(async () =>
|
|
340
|
+
new Response(JSON.stringify(body), { status: 402 })
|
|
341
|
+
) as typeof fetch
|
|
342
|
+
|
|
343
|
+
const result = await executeWithFallback('search_companies', { keywords: ['SaaS'] })
|
|
344
|
+
|
|
345
|
+
expect('error' in result).toBe(true)
|
|
346
|
+
if ('error' in result) {
|
|
347
|
+
expect(result.insufficientCredits).toBe(true)
|
|
348
|
+
expect(result.billingUrl).toBe('https://coldiq.com/marketplace/billing')
|
|
349
|
+
expect(result.error).toContain('https://coldiq.com/marketplace/billing')
|
|
350
|
+
expect(result.error).toMatch(/insufficient credits/i)
|
|
351
|
+
expect(result.providers_tried).toHaveLength(2)
|
|
352
|
+
expect(result.providers_tried[0].status).toBe(402)
|
|
353
|
+
}
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
it('does NOT set insufficientCredits when only some providers return 402', async () => {
|
|
357
|
+
stubProviders([
|
|
358
|
+
makeProvider({ id: 'p1', hasResult: () => false }),
|
|
359
|
+
makeProvider({ id: 'p2', hasResult: () => false }),
|
|
360
|
+
])
|
|
361
|
+
|
|
362
|
+
let call = 0
|
|
363
|
+
globalThis.fetch = vi.fn(async () => {
|
|
364
|
+
call++
|
|
365
|
+
if (call === 1) {
|
|
366
|
+
return new Response(
|
|
367
|
+
JSON.stringify({ error: 'no credits', billingUrl: 'https://coldiq.com/marketplace/billing' }),
|
|
368
|
+
{ status: 402 }
|
|
369
|
+
)
|
|
370
|
+
}
|
|
371
|
+
return new Response(JSON.stringify({ error: 'upstream timeout' }), { status: 502 })
|
|
372
|
+
}) as typeof fetch
|
|
373
|
+
|
|
374
|
+
const result = await executeWithFallback('search_companies', { keywords: ['SaaS'] })
|
|
375
|
+
|
|
376
|
+
expect('error' in result).toBe(true)
|
|
377
|
+
if ('error' in result) {
|
|
378
|
+
expect(result.insufficientCredits).toBeUndefined()
|
|
379
|
+
expect(result.billingUrl).toBe('https://coldiq.com/marketplace/billing')
|
|
380
|
+
expect(result.error).not.toMatch(/insufficient credits/i)
|
|
381
|
+
}
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
it('preserves the full 402 message in providers_tried (not truncated mid-URL)', async () => {
|
|
385
|
+
stubProviders([makeProvider({ id: 'p1', hasResult: () => false })])
|
|
386
|
+
|
|
387
|
+
const longMessage =
|
|
388
|
+
"You don't have enough credits to run this request — it costs 5 credits and your balance is 0. Top up your account at https://coldiq.com/marketplace/billing to continue."
|
|
389
|
+
globalThis.fetch = vi.fn(async () =>
|
|
390
|
+
new Response(
|
|
391
|
+
JSON.stringify({ error: longMessage, billingUrl: 'https://coldiq.com/marketplace/billing' }),
|
|
392
|
+
{ status: 402 }
|
|
393
|
+
)
|
|
394
|
+
) as typeof fetch
|
|
395
|
+
|
|
396
|
+
const result = await executeWithFallback('search_companies', { keywords: ['SaaS'] })
|
|
397
|
+
|
|
398
|
+
if ('error' in result) {
|
|
399
|
+
expect(result.providers_tried[0].error).toContain('https://coldiq.com/marketplace/billing')
|
|
400
|
+
}
|
|
401
|
+
})
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
// ---------------------------------------------------------------------------
|
|
405
|
+
// Async polling — function-form pollIntervalMs (backoff schedule)
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
|
|
408
|
+
describe('executor async polling intervals', () => {
|
|
409
|
+
const originalFetch = globalThis.fetch
|
|
410
|
+
const originalSetTimeout = globalThis.setTimeout
|
|
411
|
+
|
|
412
|
+
beforeEach(() => {
|
|
413
|
+
initClient('http://test-api.local', 'test-key-123')
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
afterEach(() => {
|
|
417
|
+
globalThis.fetch = originalFetch
|
|
418
|
+
globalThis.setTimeout = originalSetTimeout
|
|
419
|
+
vi.restoreAllMocks()
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
it('function-form pollIntervalMs is invoked per attempt with the 1-indexed attempt number', async () => {
|
|
423
|
+
const intervalCalls: number[] = []
|
|
424
|
+
const requestedDelays: number[] = []
|
|
425
|
+
|
|
426
|
+
// Capture every sleep delay (executeAsync uses setTimeout via the local sleep helper)
|
|
427
|
+
// and resolve immediately so we don't actually wait.
|
|
428
|
+
globalThis.setTimeout = ((cb: () => void, ms?: number) => {
|
|
429
|
+
if (typeof ms === 'number' && ms > 0) requestedDelays.push(ms)
|
|
430
|
+
cb()
|
|
431
|
+
return 0 as unknown as ReturnType<typeof setTimeout>
|
|
432
|
+
}) as typeof setTimeout
|
|
433
|
+
|
|
434
|
+
// Provider that completes on poll #4 — drives 4 sleep calls before returning.
|
|
435
|
+
let pollCount = 0
|
|
436
|
+
stubProviders([
|
|
437
|
+
makeProvider({
|
|
438
|
+
id: 'backoff-provider',
|
|
439
|
+
hasResult: (data) => (data as Record<string, unknown>).done === true,
|
|
440
|
+
async: {
|
|
441
|
+
extractId: () => 'job-xyz',
|
|
442
|
+
pollEndpoint: (id) => `/poll/${id}`,
|
|
443
|
+
pollIntervalMs: (attempt) => {
|
|
444
|
+
intervalCalls.push(attempt)
|
|
445
|
+
// Use small numbers so total wait stays bounded if setTimeout were real
|
|
446
|
+
return attempt * 100
|
|
447
|
+
},
|
|
448
|
+
timeoutMs: 60_000,
|
|
449
|
+
isComplete: (data) => (data as Record<string, unknown>).done === true,
|
|
450
|
+
},
|
|
451
|
+
}),
|
|
452
|
+
])
|
|
453
|
+
|
|
454
|
+
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
|
455
|
+
const u = url.toString()
|
|
456
|
+
if (u.includes('/poll/')) {
|
|
457
|
+
pollCount++
|
|
458
|
+
return new Response(
|
|
459
|
+
JSON.stringify(pollCount >= 4 ? { done: true } : { done: false }),
|
|
460
|
+
{ status: 200 },
|
|
461
|
+
)
|
|
462
|
+
}
|
|
463
|
+
// create-job response
|
|
464
|
+
return new Response(JSON.stringify({ id: 'job-xyz' }), { status: 200 })
|
|
465
|
+
}) as typeof fetch
|
|
466
|
+
|
|
467
|
+
const result = await executeWithFallback('enrich_company', { domain: 'coldiq.com' })
|
|
468
|
+
|
|
469
|
+
expect('data' in result).toBe(true)
|
|
470
|
+
// Function called once per loop iteration with 1-indexed attempt numbers
|
|
471
|
+
expect(intervalCalls).toEqual([1, 2, 3, 4])
|
|
472
|
+
// Each requested delay matches `attempt * 100`
|
|
473
|
+
expect(requestedDelays).toEqual([100, 200, 300, 400])
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
// Note: the LeadsFactory backoff *schedule* itself is asserted against the live
|
|
479
|
+
// provider entry in tests/registry.test.ts to avoid drift between test and source.
|
package/tests/registry.test.ts
CHANGED
|
@@ -509,12 +509,29 @@ describe('registry', () => {
|
|
|
509
509
|
expect((result.body as Record<string, unknown>).company_linkedin_urls).toBeUndefined()
|
|
510
510
|
})
|
|
511
511
|
|
|
512
|
-
it('LeadsFactory has async config', () => {
|
|
512
|
+
it('LeadsFactory has async config with continuous backoff schedule', () => {
|
|
513
513
|
const providers = getProviders('find_people')
|
|
514
514
|
const lf = providers.find((p) => p.id === 'leadsfactory')!
|
|
515
515
|
expect(lf.async).toBeDefined()
|
|
516
|
-
expect(lf.async!.pollIntervalMs).toBe(15000)
|
|
517
516
|
expect(lf.async!.timeoutMs).toBe(300_000)
|
|
517
|
+
// Function-form pollIntervalMs: fast first probe, then continuously growing backoff.
|
|
518
|
+
const sched = lf.async!.pollIntervalMs
|
|
519
|
+
expect(typeof sched).toBe('function')
|
|
520
|
+
const fn = sched as (attempt: number) => number
|
|
521
|
+
expect(fn(1)).toBe(3000)
|
|
522
|
+
expect(fn(2)).toBe(7000)
|
|
523
|
+
expect(fn(3)).toBe(15000)
|
|
524
|
+
expect(fn(4)).toBe(25000)
|
|
525
|
+
expect(fn(8)).toBe(65000)
|
|
526
|
+
// At least 7 polls must fit inside the 5-minute timeout — guards against
|
|
527
|
+
// future tuning that would make the ramp so steep we only get 1–2 polls.
|
|
528
|
+
let cumulative = 0
|
|
529
|
+
let attempts = 0
|
|
530
|
+
while (cumulative < lf.async!.timeoutMs) {
|
|
531
|
+
attempts++
|
|
532
|
+
cumulative += fn(attempts)
|
|
533
|
+
}
|
|
534
|
+
expect(attempts).toBeGreaterThanOrEqual(7)
|
|
518
535
|
})
|
|
519
536
|
|
|
520
537
|
it('Apollo maps correctly', () => {
|
|
@@ -244,6 +244,49 @@ describe('find_emails handler (bulk)', () => {
|
|
|
244
244
|
expect(parsed.data.results[0]).toEqual({ id: 'p1', email: 'alice@example.com', provider: 'fullenrich' })
|
|
245
245
|
})
|
|
246
246
|
|
|
247
|
+
it('parallel branches: faster FindyMail wins, slower FullEnrich does not overwrite', async () => {
|
|
248
|
+
// Step 2 (FullEnrich) and Step 3 (FindyMail/IcyPeas) run concurrently. If FindyMail
|
|
249
|
+
// returns an email first, the FullEnrich poll later must NOT overwrite it.
|
|
250
|
+
vi.useFakeTimers()
|
|
251
|
+
|
|
252
|
+
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
|
253
|
+
const u = url.toString()
|
|
254
|
+
if (u.includes('/prospeo/bulk-enrich-person')) {
|
|
255
|
+
return new Response(
|
|
256
|
+
JSON.stringify({ error: false, results: [{ identifier: 'p1', error: true, person: null }], total_cost: 0 }),
|
|
257
|
+
{ status: 200 },
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
// FindyMail wins immediately
|
|
261
|
+
if (u.includes('/findymail/search/name')) {
|
|
262
|
+
return new Response(JSON.stringify({ email: 'fm@example.com' }), { status: 200 })
|
|
263
|
+
}
|
|
264
|
+
// FullEnrich create + later poll succeed but with a different email
|
|
265
|
+
if (u.includes('/fullenrich/contact/enrich/bulk/abc-fe-456')) {
|
|
266
|
+
return new Response(
|
|
267
|
+
JSON.stringify({ status: 'DONE', data: [{ custom_id: 'p1', emails: ['fe@example.com'] }] }),
|
|
268
|
+
{ status: 200 },
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
if (u.includes('/fullenrich/contact/enrich/bulk')) {
|
|
272
|
+
return new Response(JSON.stringify({ enrichment_id: 'abc-fe-456' }), { status: 200 })
|
|
273
|
+
}
|
|
274
|
+
return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
|
|
275
|
+
}) as typeof fetch
|
|
276
|
+
|
|
277
|
+
const handlerPromise = findEmailsHandler({
|
|
278
|
+
people: [{ id: 'p1', first_name: 'Alice', last_name: 'Smith', domain: 'example.com' }],
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
await vi.runAllTimersAsync()
|
|
282
|
+
const result = await handlerPromise
|
|
283
|
+
vi.useRealTimers()
|
|
284
|
+
|
|
285
|
+
const parsed = JSON.parse(result.content[0].text)
|
|
286
|
+
// FindyMail won the race — its email + provider must persist
|
|
287
|
+
expect(parsed.data.results[0]).toEqual({ id: 'p1', email: 'fm@example.com', provider: 'findymail' })
|
|
288
|
+
})
|
|
289
|
+
|
|
247
290
|
it('gracefully handles prospeo bulk failure and still tries fallbacks', async () => {
|
|
248
291
|
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
|
249
292
|
const u = url.toString()
|