@coldiq/mcp 0.1.19 → 0.2.5

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.
Files changed (50) hide show
  1. package/dist/client.d.ts +2 -0
  2. package/dist/client.d.ts.map +1 -1
  3. package/dist/client.js +7 -1
  4. package/dist/client.js.map +1 -1
  5. package/dist/executor.d.ts +11 -0
  6. package/dist/executor.d.ts.map +1 -1
  7. package/dist/executor.js +72 -11
  8. package/dist/executor.js.map +1 -1
  9. package/dist/index.js +2 -0
  10. package/dist/index.js.map +1 -1
  11. package/dist/registry.d.ts +1 -0
  12. package/dist/registry.d.ts.map +1 -1
  13. package/dist/registry.js +35 -9
  14. package/dist/registry.js.map +1 -1
  15. package/dist/tools/find-emails.d.ts +2 -7
  16. package/dist/tools/find-emails.d.ts.map +1 -1
  17. package/dist/tools/find-emails.js +193 -67
  18. package/dist/tools/find-emails.js.map +1 -1
  19. package/dist/tools/find-people.d.ts +3 -2
  20. package/dist/tools/find-people.d.ts.map +1 -1
  21. package/dist/tools/find-people.js +65 -7
  22. package/dist/tools/find-people.js.map +1 -1
  23. package/dist/tools/get-credit-balance.d.ts +17 -0
  24. package/dist/tools/get-credit-balance.d.ts.map +1 -0
  25. package/dist/tools/get-credit-balance.js +20 -0
  26. package/dist/tools/get-credit-balance.js.map +1 -0
  27. package/dist/utils/compact-people.d.ts +24 -0
  28. package/dist/utils/compact-people.d.ts.map +1 -0
  29. package/dist/utils/compact-people.js +311 -0
  30. package/dist/utils/compact-people.js.map +1 -0
  31. package/dist/utils/provider-resolver.d.ts.map +1 -1
  32. package/dist/utils/provider-resolver.js +12 -1
  33. package/dist/utils/provider-resolver.js.map +1 -1
  34. package/package.json +1 -1
  35. package/src/client.ts +9 -1
  36. package/src/executor.ts +89 -17
  37. package/src/index.ts +8 -0
  38. package/src/registry.ts +41 -9
  39. package/src/tools/find-emails.ts +251 -80
  40. package/src/tools/find-people.ts +70 -7
  41. package/src/tools/get-credit-balance.ts +24 -0
  42. package/src/utils/compact-people.ts +323 -0
  43. package/src/utils/provider-resolver.ts +12 -1
  44. package/tests/executor.test.ts +165 -0
  45. package/tests/registry-find-people.test.ts +39 -7
  46. package/tests/registry-search-companies.test.ts +46 -7
  47. package/tests/tools/find-emails.test.ts +267 -1
  48. package/tests/tools/find-people.test.ts +269 -5
  49. package/tests/tools/get-credit-balance.test.ts +56 -0
  50. package/tests/utils/compact-people.test.ts +487 -0
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod'
2
- import { callApi } from '../client.js'
2
+ import { callApi, type ApiResponse } from '../client.js'
3
3
  import { executeWithFallback, isExecutionError } from '../executor.js'
4
4
  import { resolvePreferredProviders, buildAllFailedError, FIND_EMAILS_PROVIDERS } from '../utils/provider-resolver.js'
5
5
 
@@ -7,6 +7,13 @@ import { resolvePreferredProviders, buildAllFailedError, FIND_EMAILS_PROVIDERS }
7
7
  // Used as a fallback waterfall for residual misses after Prospeo + FullEnrich + Findymail + Icypeas.
8
8
  const SINGLE_ONLY_FIND_EMAIL_PROVIDERS = ['limadata-work-email', 'blitzapi', 'limadata-work-email-linkedin', 'linkupapi'] as const
9
9
 
10
+ // Chunk inputs of >10 people into independent waterfalls. Empirically, Prospeo bulk on 25-50
11
+ // names regularly exceeds the 30s client timeout; 10 keeps each Prospeo call well under it.
12
+ export const FIND_EMAILS_CHUNK_SIZE = 10
13
+ // Concurrency cap keeps peak in-flight calls (per-chunk fan-out × concurrent chunks) under
14
+ // the undici Agent's 32-connection budget (client.ts).
15
+ const CHUNK_CONCURRENCY = 3
16
+
10
17
  // Pulls a string email from any of the provider-specific response shapes used in the
11
18
  // find_email registry (registry.ts:1228-1413). Keep in sync with the providers covered there.
12
19
  function extractEmail(data: unknown): string | null {
@@ -37,11 +44,14 @@ export const findEmailsDescription =
37
44
  'Find professional emails for multiple people in one batch. ' +
38
45
  'Uses Prospeo bulk first; for any misses, runs FullEnrich and FindyMail/IcyPeas in parallel — ' +
39
46
  'whichever provider returns an email first wins. ' +
40
- 'Much faster than calling find_email one-by-one. Max 50 people per call. ' +
47
+ 'Much faster than calling find_email one-by-one. Max 50 people per call ' +
48
+ `inputs are split into chunks of ${FIND_EMAILS_CHUNK_SIZE} internally for reliability, so larger batches work transparently. ` +
41
49
  'Each person needs a unique id to match results back. ' +
42
50
  'Always pass first_name, last_name, and domain — they are the primary enrichment signal. ' +
43
51
  'Also pass linkedin_url when available, but never rely on it alone as some providers cannot resolve vanity URLs. ' +
44
- 'ColdIQ automatically picks the best waterfall — pass use_providers only if you need specific tools.'
52
+ 'ColdIQ automatically picks the best waterfall — pass use_providers only if you need specific tools. ' +
53
+ 'Response includes `_meta.batch_status` (complete|partial|failed) and per-provider call stats so callers can ' +
54
+ 'tell a real "no coverage" result apart from a provider-level failure.'
45
55
 
46
56
  export const findEmailsSchema = {
47
57
  people: z
@@ -74,6 +84,49 @@ interface EmailResult {
74
84
  provider: string | null
75
85
  }
76
86
 
87
+ interface ProviderStats {
88
+ attempted: number
89
+ failed: number
90
+ }
91
+
92
+ interface BatchStats {
93
+ prospeo: ProviderStats
94
+ fullenrich: ProviderStats
95
+ findymail: ProviderStats
96
+ icypeas: ProviderStats
97
+ single_fallback: ProviderStats
98
+ }
99
+
100
+ function newBatchStats(): BatchStats {
101
+ return {
102
+ prospeo: { attempted: 0, failed: 0 },
103
+ fullenrich: { attempted: 0, failed: 0 },
104
+ findymail: { attempted: 0, failed: 0 },
105
+ icypeas: { attempted: 0, failed: 0 },
106
+ single_fallback: { attempted: 0, failed: 0 },
107
+ }
108
+ }
109
+
110
+ function mergeStats(into: BatchStats, from: BatchStats): void {
111
+ for (const key of Object.keys(into) as (keyof BatchStats)[]) {
112
+ into[key].attempted += from[key].attempted
113
+ into[key].failed += from[key].failed
114
+ }
115
+ }
116
+
117
+ function totalFailures(s: BatchStats): number {
118
+ let n = 0
119
+ for (const key of Object.keys(s) as (keyof BatchStats)[]) n += s[key].failed
120
+ return n
121
+ }
122
+
123
+ // A call counts as a "failure" when callApi never received a usable response: network error,
124
+ // abort/timeout (status === 0), or upstream 5xx. Upstream 4xx is a data-shape problem, not a
125
+ // provider outage — we don't surface it as a batch-level failure.
126
+ function isCallFailure(res: ApiResponse): boolean {
127
+ return !res.ok && (res.status === 0 || res.status >= 500)
128
+ }
129
+
77
130
  function sleep(ms: number): Promise<void> {
78
131
  return new Promise((resolve) => setTimeout(resolve, ms))
79
132
  }
@@ -82,7 +135,75 @@ function missesOf(people: PersonInput[], results: EmailResult[]): PersonInput[]
82
135
  return people.filter((p) => !results.find((r) => r.id === p.id)?.email)
83
136
  }
84
137
 
85
- async function fullEnrichStep(misses: PersonInput[], results: EmailResult[]): Promise<void> {
138
+ function chunkArray<T>(arr: T[], size: number): T[][] {
139
+ if (arr.length <= size) return [arr]
140
+ const out: T[][] = []
141
+ for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size))
142
+ return out
143
+ }
144
+
145
+ async function withConcurrency<T, R>(
146
+ items: T[],
147
+ concurrency: number,
148
+ fn: (item: T, idx: number) => Promise<R>,
149
+ ): Promise<R[]> {
150
+ const results: R[] = new Array(items.length)
151
+ let cursor = 0
152
+ const workerCount = Math.min(concurrency, items.length)
153
+ const workers = Array.from({ length: workerCount }, async () => {
154
+ while (true) {
155
+ const i = cursor++
156
+ if (i >= items.length) return
157
+ results[i] = await fn(items[i]!, i)
158
+ }
159
+ })
160
+ await Promise.all(workers)
161
+ return results
162
+ }
163
+
164
+ async function prospeoBulkStep(
165
+ chunk: PersonInput[],
166
+ results: EmailResult[],
167
+ stats: BatchStats,
168
+ ): Promise<void> {
169
+ const bulkBody = {
170
+ data: chunk.map((p) =>
171
+ p.linkedin_url
172
+ ? { identifier: p.id, linkedin_url: p.linkedin_url }
173
+ : { identifier: p.id, first_name: p.first_name, last_name: p.last_name, company_name: p.domain },
174
+ ),
175
+ }
176
+
177
+ stats.prospeo.attempted += 1
178
+ const bulkRes = await callApi('POST', '/prospeo/bulk-enrich-person', bulkBody)
179
+ if (isCallFailure(bulkRes)) stats.prospeo.failed += 1
180
+ if (!bulkRes.ok) return
181
+
182
+ const data = bulkRes.data as {
183
+ results?: Array<{
184
+ identifier: string
185
+ person?: { email?: { email?: string } }
186
+ }>
187
+ }
188
+ for (const item of data.results ?? []) {
189
+ const email = item.person?.email?.email
190
+ if (typeof email === 'string' && email.includes('@')) {
191
+ const hit = results.find((r) => r.id === item.identifier)
192
+ if (hit) {
193
+ hit.email = email
194
+ hit.provider = 'prospeo'
195
+ }
196
+ }
197
+ }
198
+ }
199
+
200
+ async function fullEnrichStep(
201
+ misses: PersonInput[],
202
+ results: EmailResult[],
203
+ stats: BatchStats,
204
+ ): Promise<void> {
205
+ stats.fullenrich.attempted += 1
206
+
86
207
  const feBody = {
87
208
  name: 'mcp-enrich-batch',
88
209
  data: misses.map((p) => ({
@@ -96,10 +217,16 @@ async function fullEnrichStep(misses: PersonInput[], results: EmailResult[]): Pr
96
217
  }
97
218
 
98
219
  const feCreateRes = await callApi('POST', '/fullenrich/contact/enrich/bulk', feBody)
99
- if (!feCreateRes.ok) return
220
+ if (!feCreateRes.ok) {
221
+ if (isCallFailure(feCreateRes)) stats.fullenrich.failed += 1
222
+ return
223
+ }
100
224
 
101
225
  const enrichmentId = (feCreateRes.data as Record<string, unknown>).enrichment_id as string | undefined
102
- if (!enrichmentId) return
226
+ if (!enrichmentId) {
227
+ // No id returned but POST 2xx — treat as a soft skip, not a network failure.
228
+ return
229
+ }
103
230
 
104
231
  // Tightened from 90s → 45s: Step 3 runs in parallel and typically fills misses
105
232
  // faster, so FullEnrich's marginal value decays past this point.
@@ -130,9 +257,17 @@ async function fullEnrichStep(misses: PersonInput[], results: EmailResult[]): Pr
130
257
  }
131
258
  return
132
259
  }
260
+ // Polling deadline reached without DONE/FAILED — count as a failure so the caller
261
+ // sees fullenrich didn't actually finish.
262
+ stats.fullenrich.failed += 1
133
263
  }
134
264
 
135
- async function findymailIcypeasStep(misses: PersonInput[], results: EmailResult[], allowedProviders?: string[]): Promise<void> {
265
+ async function findymailIcypeasStep(
266
+ misses: PersonInput[],
267
+ results: EmailResult[],
268
+ stats: BatchStats,
269
+ allowedProviders?: string[],
270
+ ): Promise<void> {
136
271
  const useFindymail = !allowedProviders || allowedProviders.includes('findymail')
137
272
  const useIcypeas = !allowedProviders || allowedProviders.includes('icypeas')
138
273
 
@@ -147,10 +282,12 @@ async function findymailIcypeasStep(misses: PersonInput[], results: EmailResult[
147
282
  if (useFindymail) {
148
283
  const fullName = [person.first_name, person.last_name].filter(Boolean).join(' ')
149
284
 
285
+ stats.findymail.attempted += 1
150
286
  const fmRes = await callApi('POST', '/findymail/search/name', {
151
287
  name: fullName,
152
288
  domain: person.domain,
153
289
  })
290
+ if (isCallFailure(fmRes)) stats.findymail.failed += 1
154
291
  if (!hit.email && fmRes.ok) {
155
292
  const d = fmRes.data as Record<string, unknown>
156
293
  if (typeof d.email === 'string' && d.email.includes('@')) {
@@ -163,11 +300,13 @@ async function findymailIcypeasStep(misses: PersonInput[], results: EmailResult[
163
300
 
164
301
  if (hit.email || !useIcypeas) return
165
302
 
303
+ stats.icypeas.attempted += 1
166
304
  const icyRes = await callApi('POST', '/icypeas/email-search', {
167
305
  firstname: person.first_name,
168
306
  lastname: person.last_name,
169
307
  domainOrCompany: person.domain,
170
308
  })
309
+ if (isCallFailure(icyRes)) stats.icypeas.failed += 1
171
310
  if (!hit.email && icyRes.ok) {
172
311
  const d = icyRes.data as Record<string, unknown>
173
312
  const email =
@@ -185,50 +324,62 @@ async function findymailIcypeasStep(misses: PersonInput[], results: EmailResult[
185
324
  )
186
325
  }
187
326
 
188
- export async function findEmailsHandler(input: Record<string, unknown>) {
189
- const { use_providers: rawUseProviders, ...restInput } = input
190
- const people = restInput.people as PersonInput[]
327
+ async function singleFallbackStep(
328
+ stragglers: PersonInput[],
329
+ results: EmailResult[],
330
+ stats: BatchStats,
331
+ fallbackProviders: string[],
332
+ ): Promise<void> {
333
+ if (fallbackProviders.length === 0 || stragglers.length === 0) return
191
334
 
192
- const resolved = resolvePreferredProviders('find_emails', restInput, rawUseProviders)
193
- if (!resolved.ok) {
194
- return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error) }], isError: true }
195
- }
196
-
197
- const allowedProviders = resolved.providers
198
- const isConstrained = allowedProviders.length > 0
335
+ await Promise.all(
336
+ stragglers.map(async (person) => {
337
+ const hit = results.find((r) => r.id === person.id)
338
+ if (!hit || hit.email) return
339
+ const singleInput: Record<string, unknown> = {
340
+ first_name: person.first_name,
341
+ last_name: person.last_name,
342
+ domain: person.domain,
343
+ linkedin_url: person.linkedin_url,
344
+ }
345
+ stats.single_fallback.attempted += 1
346
+ // Per-person 30s ceiling so one slow provider doesn't block the rest.
347
+ const exec = executeWithFallback('find_email', singleInput, { providers: fallbackProviders })
348
+ const timeout = new Promise<null>((resolve) => setTimeout(() => resolve(null), 30_000))
349
+ const raced = await Promise.race([exec, timeout])
350
+ if (!raced) {
351
+ // Race timed out — none of the fallback providers returned in time.
352
+ stats.single_fallback.failed += 1
353
+ return
354
+ }
355
+ if (isExecutionError(raced)) {
356
+ // Every fallback provider returned an error. Only count as `failed` when at least
357
+ // one of them looked like a real network/upstream failure — providers that responded
358
+ // 200 with no result are legitimate "no coverage", not a batch failure.
359
+ const hadRealFailure = raced.providers_tried.some((p) => p.status === 0 || p.status >= 500)
360
+ if (hadRealFailure) stats.single_fallback.failed += 1
361
+ return
362
+ }
363
+ const email = extractEmail(raced.data)
364
+ if (email && !hit.email) {
365
+ hit.email = email
366
+ hit.provider = raced._meta.provider
367
+ }
368
+ }),
369
+ )
370
+ }
199
371
 
200
- const results: EmailResult[] = people.map((p) => ({ id: p.id, email: null, provider: null }))
372
+ async function processChunk(
373
+ chunk: PersonInput[],
374
+ allowedProviders: string[],
375
+ isConstrained: boolean,
376
+ ): Promise<{ results: EmailResult[]; stats: BatchStats }> {
377
+ const results: EmailResult[] = chunk.map((p) => ({ id: p.id, email: null, provider: null }))
378
+ const stats = newBatchStats()
201
379
 
202
- // Step 1: Prospeo bulk — 1 call for all people
380
+ // Step 1: Prospeo bulk — 1 call for everyone in this chunk.
203
381
  if (!isConstrained || allowedProviders.includes('prospeo')) {
204
- const bulkBody = {
205
- data: people.map((p) =>
206
- p.linkedin_url
207
- ? { identifier: p.id, linkedin_url: p.linkedin_url }
208
- : { identifier: p.id, first_name: p.first_name, last_name: p.last_name, company_name: p.domain },
209
- ),
210
- }
211
-
212
- const bulkRes = await callApi('POST', '/prospeo/bulk-enrich-person', bulkBody)
213
-
214
- if (bulkRes.ok) {
215
- const data = bulkRes.data as {
216
- results?: Array<{
217
- identifier: string
218
- person?: { email?: { email?: string } }
219
- }>
220
- }
221
- for (const item of data.results ?? []) {
222
- const email = item.person?.email?.email
223
- if (typeof email === 'string' && email.includes('@')) {
224
- const hit = results.find((r) => r.id === item.identifier)
225
- if (hit) {
226
- hit.email = email
227
- hit.provider = 'prospeo'
228
- }
229
- }
230
- }
231
- }
382
+ await prospeoBulkStep(chunk, results, stats)
232
383
  }
233
384
 
234
385
  // Steps 2 & 3 run concurrently for misses.
@@ -237,15 +388,14 @@ export async function findEmailsHandler(input: Record<string, unknown>) {
237
388
  // Both branches write to the shared `results` array; every write site checks
238
389
  // `if (hit.email)` first so whichever provider arrives first wins and the
239
390
  // other does not overwrite.
240
- const misses = missesOf(people, results)
241
-
391
+ const misses = missesOf(chunk, results)
242
392
  if (misses.length > 0) {
243
393
  const steps: Promise<void>[] = []
244
394
  if (!isConstrained || allowedProviders.includes('fullenrich')) {
245
- steps.push(fullEnrichStep(misses, results))
395
+ steps.push(fullEnrichStep(misses, results, stats))
246
396
  }
247
397
  if (!isConstrained || allowedProviders.includes('findymail') || allowedProviders.includes('icypeas')) {
248
- steps.push(findymailIcypeasStep(misses, results, isConstrained ? allowedProviders : undefined))
398
+ steps.push(findymailIcypeasStep(misses, results, stats, isConstrained ? allowedProviders : undefined))
249
399
  }
250
400
  if (steps.length > 0) await Promise.allSettled(steps)
251
401
  }
@@ -254,54 +404,75 @@ export async function findEmailsHandler(input: Record<string, unknown>) {
254
404
  // Bulk covers Prospeo + FullEnrich + FindyMail + IcyPeas. The single-find_email registry
255
405
  // (registry.ts:1228-1413) additionally covers LimaData, BlitzAPI, LimaData-LinkedIn, LinkupAPI.
256
406
  // Without this step, agents would observe bulk return 0 and fall back to N+1 single calls.
257
- const stragglers = missesOf(people, results)
407
+ const stragglers = missesOf(chunk, results)
258
408
  if (stragglers.length > 0) {
259
409
  const fallbackProviders = isConstrained
260
410
  ? allowedProviders.filter((p) => (SINGLE_ONLY_FIND_EMAIL_PROVIDERS as readonly string[]).includes(p))
261
411
  : [...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
- }
412
+ await singleFallbackStep(stragglers, results, stats, fallbackProviders)
286
413
  }
287
414
 
415
+ return { results, stats }
416
+ }
417
+
418
+ function computeBatchStatus(found: number, total: number, stats: BatchStats): 'complete' | 'partial' | 'failed' {
419
+ const failures = totalFailures(stats)
420
+ if (failures === 0) return 'complete'
421
+ if (found === 0 && total > 0) return 'failed'
422
+ return 'partial'
423
+ }
424
+
425
+ export async function findEmailsHandler(input: Record<string, unknown>) {
426
+ const { use_providers: rawUseProviders, ...restInput } = input
427
+ const people = restInput.people as PersonInput[]
428
+
429
+ const resolved = resolvePreferredProviders('find_emails', restInput, rawUseProviders)
430
+ if (!resolved.ok) {
431
+ return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error) }], isError: true }
432
+ }
433
+
434
+ const allowedProviders = resolved.providers
435
+ const isConstrained = allowedProviders.length > 0
436
+
437
+ const chunks = chunkArray(people, FIND_EMAILS_CHUNK_SIZE)
438
+ const chunkOutputs = await withConcurrency(chunks, CHUNK_CONCURRENCY, (chunk) =>
439
+ processChunk(chunk, allowedProviders, isConstrained),
440
+ )
441
+
442
+ const results: EmailResult[] = chunkOutputs.flatMap((c) => c.results)
443
+ const aggregateStats = newBatchStats()
444
+ for (const c of chunkOutputs) mergeStats(aggregateStats, c.stats)
445
+
288
446
  const found = results.filter((r) => r.email !== null).length
447
+ const batchStatus = computeBatchStatus(found, people.length, aggregateStats)
289
448
 
449
+ // Constrained-mode behaviour preserved: if the caller pinned providers and got zero, push back.
290
450
  if (isConstrained && found === 0) {
291
451
  const err = buildAllFailedError(allowedProviders, 'find_emails', FIND_EMAILS_PROVIDERS)
292
452
  return { content: [{ type: 'text' as const, text: JSON.stringify(err) }], isError: true }
293
453
  }
294
454
 
295
- const meta = resolved.matchedFrom && Object.keys(resolved.matchedFrom).length > 0
296
- ? { matchedFrom: resolved.matchedFrom }
297
- : undefined
455
+ const meta: Record<string, unknown> = {
456
+ providers: aggregateStats,
457
+ batch_status: batchStatus,
458
+ chunk_size: FIND_EMAILS_CHUNK_SIZE,
459
+ chunk_count: chunks.length,
460
+ }
461
+ if (resolved.matchedFrom && Object.keys(resolved.matchedFrom).length > 0) {
462
+ meta.matchedFrom = resolved.matchedFrom
463
+ }
464
+
465
+ // Surface the silent-failure case the user reported: 200 OK with all-nulls is misleading
466
+ // when the nulls were caused by provider failures rather than legitimate no-coverage.
467
+ const isError = batchStatus === 'failed'
298
468
 
299
469
  return {
300
470
  content: [
301
471
  {
302
472
  type: 'text' as const,
303
- text: JSON.stringify({ data: { results, found, total: people.length }, ...(meta ? { _meta: meta } : {}) }),
473
+ text: JSON.stringify({ data: { results, found, total: people.length }, _meta: meta }),
304
474
  },
305
475
  ],
476
+ ...(isError ? { isError: true } : {}),
306
477
  }
307
478
  }
@@ -3,6 +3,7 @@ import { executeWithFallback, isExecutionError } from '../executor.js'
3
3
  import { callApi } from '../client.js'
4
4
  import { getProviders } from '../registry.js'
5
5
  import { resolvePreferredProviders, getProvidersForCapability } from '../utils/provider-resolver.js'
6
+ import { compactPayload } from '../utils/compact-people.js'
6
7
 
7
8
  export const findPeopleName = 'find_people'
8
9
 
@@ -15,7 +16,9 @@ export const findPeopleDescription =
15
16
  'Prefer company_linkedin_urls when available — they make the search faster. Only pass company_domains when you have no LinkedIn URLs. ' +
16
17
  'Pass job_titles EXACTLY as stated — never expand or generate variants (e.g. do NOT turn "CEO" into ["CEO", "Chief Executive Officer", "Founder"]). LeadsFactory handles fuzzy title matching internally; adding variants wastes personas and degrades results. ' +
17
18
  'Do NOT pass seniorities unless explicitly asked — job titles alone produce better results. ' +
18
- 'Returns names, titles, LinkedIn URLs. Default limit: 25.'
19
+ 'Returns names, titles, LinkedIn URLs, plus company name/domain/linkedin/headcount when available. Default limit: 25. ' +
20
+ 'Response is compact by default (~99% smaller than the raw provider payload). Set fields="verbose" only when you specifically need employment history, skills, languages, or company descriptions. ' +
21
+ 'When the matched provider is Apollo, last names are partially obfuscated and emails/LinkedIn URLs are omitted unless reveal=true is passed (adds 1 credit per revealed person).'
19
22
 
20
23
  export const findPeopleSchema = {
21
24
  company_linkedin_urls: z.array(z.string()).optional().describe('Company LinkedIn URLs (preferred over domains when available, e.g. ["https://linkedin.com/company/microsoft"])'),
@@ -24,12 +27,26 @@ export const findPeopleSchema = {
24
27
  seniorities: z.array(z.string()).optional().describe('Seniority levels (e.g. ["c_suite", "vp", "director"])'),
25
28
  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
29
  keywords: z.array(z.string()).optional().describe('Free-text keyword search terms (e.g. ["growth", "AI"])'),
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.'),
30
+ 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. Note: when the matched provider is Apollo, results are capped at 100 per call (Apollo upstream limit).'),
31
+ fields: z.enum(['compact', 'verbose']).default('compact').describe('Response shape. "compact" (default) returns a small, predictable per-person record: name, title, linkedin_url, company {name, domain, linkedin_url, headcount}, plus seniority/email/location when the upstream provider includes them. "verbose" returns the raw provider passthrough (~30KB+ per record from FullEnrich: employment history, every company office, descriptions, skills, languages) — only use when you specifically need those fields.'),
32
+ reveal: z.boolean().optional().describe('Apollo-only: when true, follow the obfuscated Apollo people-search results with /apollo/people/bulk-match to reveal full names, emails, and LinkedIn URLs. Costs 1 extra credit per revealed person on top of the search credit. Has no effect for other providers (their data is already unobfuscated). Default false to protect credits.'),
28
33
  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.`),
29
34
  }
30
35
 
36
+ function buildSuccess(
37
+ result: { data: unknown; _meta: { provider: string; latencyMs: number; matchedFrom?: Record<string, string> } },
38
+ fields: 'compact' | 'verbose',
39
+ ) {
40
+ const payload = fields === 'compact'
41
+ ? { ...result, data: compactPayload(result.data, result._meta.provider) }
42
+ : result
43
+ return { content: [{ type: 'text' as const, text: JSON.stringify(payload) }] }
44
+ }
45
+
31
46
  export async function findPeopleHandler(input: Record<string, unknown>) {
32
- const { use_providers: rawUseProviders, ...restInput } = input
47
+ const { use_providers: rawUseProviders, reveal: rawReveal, fields: rawFields, ...restInput } = input
48
+ const reveal = rawReveal === true
49
+ const fields: 'compact' | 'verbose' = rawFields === 'verbose' ? 'verbose' : 'compact'
33
50
  const resolved = resolvePreferredProviders('find_people', restInput, rawUseProviders)
34
51
  if (!resolved.ok) {
35
52
  return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error) }], isError: true }
@@ -39,6 +56,54 @@ export async function findPeopleHandler(input: Record<string, unknown>) {
39
56
  return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], isError: true }
40
57
  }
41
58
 
59
+ // Apollo's /people/search returns obfuscated data (last names like "Ar***t",
60
+ // no emails). When the caller opts in with reveal=true, follow up with
61
+ // /apollo/people/bulk-match (10 IDs per call, Apollo's bulk cap) to swap each
62
+ // person object for its fully-revealed counterpart. Costs 1 extra credit per
63
+ // revealed person on top of the original search credit.
64
+ if (reveal && result._meta.provider === 'apollo') {
65
+ const data = result.data as Record<string, unknown>
66
+ const people = (data.people as Array<Record<string, unknown>> | undefined) ?? []
67
+ const ids = people
68
+ .map((p) => p.id)
69
+ .filter((id): id is string => typeof id === 'string' && id.length > 0)
70
+
71
+ if (ids.length > 0) {
72
+ const matches: Array<Record<string, unknown>> = []
73
+ for (let i = 0; i < ids.length; i += 10) {
74
+ const chunk = ids.slice(i, i + 10)
75
+ const res = await callApi('POST', '/apollo/people/bulk-match', {
76
+ details: chunk.map((id) => ({ id })),
77
+ reveal_personal_emails: true,
78
+ })
79
+ if (res.ok && res.data && typeof res.data === 'object') {
80
+ const body = res.data as Record<string, unknown>
81
+ const chunkMatches = (body.matches as Array<Record<string, unknown>> | undefined) ?? []
82
+ matches.push(...chunkMatches)
83
+ }
84
+ }
85
+
86
+ if (matches.length > 0) {
87
+ const matchById = new Map<string, Record<string, unknown>>()
88
+ for (const m of matches) {
89
+ const id = m.id
90
+ if (typeof id === 'string') matchById.set(id, m)
91
+ }
92
+ const revealedPeople = people.map((p) => {
93
+ const id = p.id
94
+ if (typeof id === 'string' && matchById.has(id)) return matchById.get(id)!
95
+ return p
96
+ })
97
+ // Only claim `revealed: true` when bulk-match returned a result for every ID.
98
+ // Partial reveals (some entries remain obfuscated) would otherwise mislead
99
+ // callers into trusting `last_name` / `email` that aren't actually filled.
100
+ const fullyRevealed = matchById.size >= ids.length
101
+ const merged = { ...data, people: revealedPeople, revealed: fullyRevealed }
102
+ return buildSuccess({ ...result, data: merged }, fields)
103
+ }
104
+ }
105
+ }
106
+
42
107
  // Gap-fill: if LeadsFactory missed some domains, try Apollo for those.
43
108
  // Only runs when domains were the input — if we sent LinkedIn URLs, no_results_domains
44
109
  // is unreliable (LF may resolve arbitrary domains from URLs, e.g. linkedin.com itself).
@@ -64,13 +129,11 @@ export async function findPeopleHandler(input: Record<string, unknown>) {
64
129
  ...(apolloRes.data as Record<string, unknown>),
65
130
  },
66
131
  }
67
- return {
68
- content: [{ type: 'text' as const, text: JSON.stringify({ ...result, data: merged }) }],
69
- }
132
+ return buildSuccess({ ...result, data: merged }, fields)
70
133
  }
71
134
  }
72
135
  }
73
136
  }
74
137
 
75
- return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }
138
+ return buildSuccess(result, fields)
76
139
  }
@@ -0,0 +1,24 @@
1
+ import { callApi } from '../client.js'
2
+
3
+ export const getCreditBalanceName = 'get_credit_balance'
4
+
5
+ export const getCreditBalanceDescription =
6
+ 'Get the current ColdIQ credit balance and month-to-date usage for the authenticated account. Use before bulk or fan-out operations (e.g. find_emails over many people) to project cost and avoid mid-run insufficient-credit failures. Returns { balance, usedThisMonth }.'
7
+
8
+ export const getCreditBalanceSchema = {}
9
+
10
+ export async function getCreditBalanceHandler(_input: Record<string, unknown>) {
11
+ const res = await callApi('GET', '/me/credits')
12
+ if (!res.ok) {
13
+ return {
14
+ content: [
15
+ {
16
+ type: 'text' as const,
17
+ text: JSON.stringify({ error: 'Failed to fetch credit balance', status: res.status, data: res.data }),
18
+ },
19
+ ],
20
+ isError: true,
21
+ }
22
+ }
23
+ return { content: [{ type: 'text' as const, text: JSON.stringify({ data: res.data }) }] }
24
+ }