@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
package/src/executor.ts CHANGED
@@ -8,12 +8,27 @@ export interface ExecutionResult {
8
8
  provider: string
9
9
  latencyMs: number
10
10
  matchedFrom?: Record<string, string>
11
+ /** Net ColdIQ credits charged for this call (parsed from `X-ColdIQ-Credits-Charged`). */
12
+ credits_charged?: number
13
+ /** ColdIQ credit balance remaining after this call (parsed from `X-ColdIQ-Credits-Remaining`). */
14
+ credits_remaining?: number
11
15
  }
12
16
  }
13
17
 
14
18
  export interface ExecutionError {
15
19
  error: string
16
- providers_tried: Array<{ provider: string; status: number; error: string }>
20
+ providers_tried: Array<{
21
+ provider: string
22
+ status: number
23
+ error: string
24
+ /**
25
+ * Structured upstream provider response body, when available. Lets callers
26
+ * see validation detail (e.g. Prospeo `filter_error`) that would otherwise
27
+ * be lost when the short `error` string is built. Capped at ~2KB to protect
28
+ * against unbounded provider payloads.
29
+ */
30
+ upstream_error?: unknown
31
+ }>
17
32
  /** Set when at least one provider returned 402 — points the user at the top-up page. */
18
33
  billingUrl?: string
19
34
  /** True when every attempted provider failed with 402 (the user is fully blocked on credits). */
@@ -42,6 +57,32 @@ function debug(msg: string): void {
42
57
  }
43
58
  }
44
59
 
60
+ // ---------------------------------------------------------------------------
61
+ // Upstream error cap
62
+ // ---------------------------------------------------------------------------
63
+
64
+ const MAX_UPSTREAM_ERROR_BYTES = 2048
65
+
66
+ /**
67
+ * Defensive cap on upstream_error payloads. The API already caps `details` at
68
+ * 2KB, but pre-migration providers may surface their full `data` object here —
69
+ * keep this guard so a misbehaving upstream can't blow up the MCP response.
70
+ * Falls back to a truncated string repr when the JSON serialization is over
71
+ * the cap (rather than emitting malformed JSON).
72
+ */
73
+ function capUpstreamError(u: unknown): unknown {
74
+ if (u === undefined) return undefined
75
+ let serialized: string
76
+ try {
77
+ serialized = JSON.stringify(u)
78
+ } catch {
79
+ return undefined
80
+ }
81
+ if (serialized === undefined) return undefined
82
+ if (serialized.length <= MAX_UPSTREAM_ERROR_BYTES) return u
83
+ return serialized.slice(0, MAX_UPSTREAM_ERROR_BYTES)
84
+ }
85
+
45
86
  // ---------------------------------------------------------------------------
46
87
  // Async polling
47
88
  // ---------------------------------------------------------------------------
@@ -49,7 +90,7 @@ function debug(msg: string): void {
49
90
  async function executeAsync(
50
91
  provider: ProviderEntry,
51
92
  payload: { body?: unknown; queryParams?: Record<string, string> },
52
- ): Promise<{ ok: boolean; data: unknown }> {
93
+ ): Promise<{ ok: boolean; data: unknown; headers: Record<string, string> }> {
53
94
  const asyncCfg = provider.async!
54
95
  const firstPollMs = parseInt(process.env.COLDIQ_FIRST_POLL_MS ?? '750', 10)
55
96
  const maxPollErrors = parseInt(process.env.COLDIQ_MAX_POLL_ERRORS ?? '3', 10)
@@ -61,15 +102,18 @@ async function executeAsync(
61
102
  debug(`${provider.id} async: create failed (${createRes.status})`)
62
103
  return createRes
63
104
  }
105
+ // Credit headers are emitted on the create response (where the reservation
106
+ // happens). Poll responses don't bill, so they won't carry them.
107
+ const createHeaders = createRes.headers
64
108
 
65
109
  // Step 2: extract job ID
66
110
  let jobId: string
67
111
  try {
68
112
  jobId = asyncCfg.extractId(createRes.data)
69
113
  } catch {
70
- return { ok: false, data: { error: 'Could not extract job ID from async response' } }
114
+ return { ok: false, data: { error: 'Could not extract job ID from async response' }, headers: createHeaders }
71
115
  }
72
- if (!jobId) return { ok: false, data: { error: 'Empty job ID from async response' } }
116
+ if (!jobId) return { ok: false, data: { error: 'Empty job ID from async response' }, headers: createHeaders }
73
117
  debug(`${provider.id} async: job created (${jobId}), polling...`)
74
118
 
75
119
  // Step 3: poll until complete or timeout.
@@ -93,7 +137,7 @@ async function executeAsync(
93
137
  consecutivePollErrors++
94
138
  if (consecutivePollErrors >= maxPollErrors) {
95
139
  log(`${provider.id} async: poll failing (${consecutivePollErrors} consecutive errors), skipping`)
96
- return { ok: false, data: { error: `Poll endpoint failed ${consecutivePollErrors} consecutive times` } }
140
+ return { ok: false, data: { error: `Poll endpoint failed ${consecutivePollErrors} consecutive times` }, headers: createHeaders }
97
141
  }
98
142
  } else {
99
143
  consecutivePollErrors = 0
@@ -103,7 +147,7 @@ async function executeAsync(
103
147
 
104
148
  if (asyncCfg.isComplete(pollRes.data)) {
105
149
  debug(`${provider.id} async: complete`)
106
- return pollRes
150
+ return { ...pollRes, headers: { ...createHeaders, ...pollRes.headers } }
107
151
  }
108
152
  }
109
153
 
@@ -117,13 +161,19 @@ async function executeAsync(
117
161
  }
118
162
 
119
163
  debug(`${provider.id} async: timed out after ${asyncCfg.timeoutMs / 1000}s`)
120
- return { ok: false, data: { error: `Async operation timed out after ${asyncCfg.timeoutMs / 1000}s` } }
164
+ return { ok: false, data: { error: `Async operation timed out after ${asyncCfg.timeoutMs / 1000}s` }, headers: createHeaders }
121
165
  }
122
166
 
123
167
  function sleep(ms: number): Promise<void> {
124
168
  return new Promise((resolve) => setTimeout(resolve, ms))
125
169
  }
126
170
 
171
+ function parseCreditHeader(raw: string | undefined): number | undefined {
172
+ if (raw === undefined) return undefined
173
+ const n = Number(raw)
174
+ return Number.isFinite(n) ? n : undefined
175
+ }
176
+
127
177
  // ---------------------------------------------------------------------------
128
178
  // Execute a single provider
129
179
  // ---------------------------------------------------------------------------
@@ -142,7 +192,7 @@ function syncProviderTimeoutMs(): number {
142
192
  async function executeSingle(
143
193
  provider: ProviderEntry,
144
194
  input: Record<string, unknown>,
145
- ): Promise<{ ok: boolean; status: number; data: unknown; latencyMs: number }> {
195
+ ): Promise<{ ok: boolean; status: number; data: unknown; latencyMs: number; headers: Record<string, string> }> {
146
196
  const start = Date.now()
147
197
 
148
198
  let payload: ReturnType<ProviderEntry['mapParams']>
@@ -151,12 +201,12 @@ async function executeSingle(
151
201
  } catch (err) {
152
202
  const msg = err instanceof Error ? err.message : String(err)
153
203
  debug(`${provider.id}: mapParams threw — ${msg}`)
154
- return { ok: false, status: 0, data: { error: `mapParams_threw: ${msg}` }, latencyMs: Date.now() - start }
204
+ return { ok: false, status: 0, data: { error: `mapParams_threw: ${msg}` }, latencyMs: Date.now() - start, headers: {} }
155
205
  }
156
206
 
157
207
  debug(`${provider.id}: payload_keys = ${Object.keys(payload?.body ?? {}).join(',')}`)
158
208
 
159
- let result: { ok: boolean; status: number; data: unknown }
209
+ let result: { ok: boolean; status: number; data: unknown; headers: Record<string, string> }
160
210
 
161
211
  if (provider.async) {
162
212
  const asyncResult = await executeAsync(provider, payload)
@@ -164,13 +214,14 @@ async function executeSingle(
164
214
  } else {
165
215
  const cap = syncProviderTimeoutMs()
166
216
  const call = callApi(provider.method, provider.endpoint, payload.body, payload.queryParams)
167
- const timeout = new Promise<{ ok: boolean; status: number; data: unknown }>((resolve) =>
217
+ const timeout = new Promise<{ ok: boolean; status: number; data: unknown; headers: Record<string, string> }>((resolve) =>
168
218
  setTimeout(
169
219
  () =>
170
220
  resolve({
171
221
  ok: false,
172
222
  status: 0,
173
223
  data: { error: `Provider exceeded ${cap}ms sync cap` },
224
+ headers: {},
174
225
  }),
175
226
  cap,
176
227
  ),
@@ -215,7 +266,7 @@ export async function executeWithFallback(
215
266
  allProviders = ordered
216
267
  }
217
268
 
218
- const errors: Array<{ id: string; status: number; error: string }> = []
269
+ const errors: Array<{ id: string; status: number; error: string; upstream_error?: unknown }> = []
219
270
  let billingUrl: string | undefined
220
271
  let zeroBalance = false
221
272
 
@@ -259,18 +310,29 @@ export async function executeWithFallback(
259
310
  if (options?.matchedFrom && Object.keys(options.matchedFrom).length > 0) {
260
311
  meta.matchedFrom = options.matchedFrom
261
312
  }
313
+ const charged = parseCreditHeader(result.headers['x-coldiq-credits-charged'])
314
+ const remaining = parseCreditHeader(result.headers['x-coldiq-credits-remaining'])
315
+ if (charged !== undefined) meta.credits_charged = charged
316
+ if (remaining !== undefined) meta.credits_remaining = remaining
262
317
  return { data: payload, _meta: meta }
263
318
  }
264
319
 
265
320
  // Record the failure
266
- const rawErr = result.data && typeof result.data === 'object' && 'error' in result.data
267
- ? (result.data as Record<string, unknown>).error
321
+ const dataObj = result.data && typeof result.data === 'object'
322
+ ? (result.data as Record<string, unknown>)
268
323
  : undefined
324
+ const rawErr = dataObj && 'error' in dataObj ? dataObj.error : undefined
269
325
  const errMsg = rawErr !== undefined
270
326
  ? (typeof rawErr === 'string' ? rawErr : JSON.stringify(rawErr))
271
327
  : `Status ${result.status}, no results`
328
+ // Prefer the API's sanitized `details` passthrough; fall back to the full
329
+ // body so providers that haven't migrated yet still surface SOMETHING
330
+ // structured to the caller. `'details' in dataObj` (rather than `?? dataObj`)
331
+ // so an explicitly-emitted `details: null/false/0` is preserved verbatim
332
+ // instead of silently falling through to the full body.
333
+ const upstreamErr = dataObj && 'details' in dataObj ? dataObj.details : dataObj
272
334
  log(`${capability} → ${provider.id} ✗ ${errMsg}`)
273
- errors.push({ id: provider.id, status: result.status, error: errMsg })
335
+ errors.push({ id: provider.id, status: result.status, error: errMsg, upstream_error: upstreamErr })
274
336
 
275
337
  // Capture the billing URL the API surfaces in 402 responses so the
276
338
  // top-level error can include a clickable link for the user.
@@ -297,9 +359,19 @@ export async function executeWithFallback(
297
359
  // When the user pinned specific providers, expose their real IDs in the error
298
360
  // (they already know what they asked for). Otherwise anonymize to avoid leaking
299
361
  // internal provider names on auto-route failures.
362
+ const buildEntry = (provider: string, e: { status: number; error: string; upstream_error?: unknown }) => {
363
+ const entry: ExecutionError['providers_tried'][number] = {
364
+ provider,
365
+ status: e.status,
366
+ error: e.error.slice(0, 200),
367
+ }
368
+ const capped = capUpstreamError(e.upstream_error)
369
+ if (capped !== undefined) entry.upstream_error = capped
370
+ return entry
371
+ }
300
372
  const sanitized = isConstrained
301
- ? errors.map((e) => ({ provider: e.id, status: e.status, error: e.error.slice(0, 200) }))
302
- : errors.map((e, i) => ({ provider: `provider_${i + 1}`, status: e.status, error: e.error.slice(0, 200) }))
373
+ ? errors.map((e) => buildEntry(e.id, e))
374
+ : errors.map((e, i) => buildEntry(`provider_${i + 1}`, e))
303
375
 
304
376
  const allOutOfCredits = zeroBalance
305
377
  || (errors.length > 0 && errors.every((e) => e.status === 402))
package/src/index.ts CHANGED
@@ -123,6 +123,13 @@ import {
123
123
  fetchPageContentHandler,
124
124
  } from './tools/fetch-page-content.js'
125
125
 
126
+ import {
127
+ getCreditBalanceName,
128
+ getCreditBalanceDescription,
129
+ getCreditBalanceSchema,
130
+ getCreditBalanceHandler,
131
+ } from './tools/get-credit-balance.js'
132
+
126
133
  // Initialize HTTP client (reads env vars)
127
134
  initClient()
128
135
 
@@ -149,6 +156,7 @@ server.tool(searchRedditName, searchRedditDescription, searchRedditSchema, searc
149
156
  server.tool(searchSeoName, searchSeoDescription, searchSeoSchema, searchSeoHandler)
150
157
  server.tool(findSignalsName, findSignalsDescription, findSignalsSchema, findSignalsHandler)
151
158
  server.tool(fetchPageContentName, fetchPageContentDescription, fetchPageContentSchema, fetchPageContentHandler)
159
+ server.tool(getCreditBalanceName, getCreditBalanceDescription, getCreditBalanceSchema, getCreditBalanceHandler)
152
160
 
153
161
  // Connect via stdio transport
154
162
  const transport = new StdioServerTransport()
package/src/registry.ts CHANGED
@@ -76,6 +76,28 @@ function hasAnyKey(obj: unknown, keys: string[]): boolean {
76
76
  })
77
77
  }
78
78
 
79
+ // Prospeo only accepts headcount as a list of fixed buckets, not arbitrary
80
+ // min/max ranges. Map the user's numeric range to the overlapping buckets.
81
+ const PROSPEO_HEADCOUNT_BUCKETS: ReadonlyArray<{ label: string; low: number; high: number }> = [
82
+ { label: '1-10', low: 1, high: 10 },
83
+ { label: '11-20', low: 11, high: 20 },
84
+ { label: '21-50', low: 21, high: 50 },
85
+ { label: '51-100', low: 51, high: 100 },
86
+ { label: '101-200', low: 101, high: 200 },
87
+ { label: '201-500', low: 201, high: 500 },
88
+ { label: '501-1000', low: 501, high: 1000 },
89
+ { label: '1001-2000', low: 1001, high: 2000 },
90
+ { label: '2001-5000', low: 2001, high: 5000 },
91
+ { label: '5001-10000', low: 5001, high: 10000 },
92
+ { label: '10000+', low: 10001, high: Number.POSITIVE_INFINITY },
93
+ ]
94
+
95
+ export function prospeoHeadcountBuckets(min: number | undefined, max: number | undefined): string[] {
96
+ const lo = typeof min === 'number' ? min : 0
97
+ const hi = typeof max === 'number' ? max : Number.POSITIVE_INFINITY
98
+ return PROSPEO_HEADCOUNT_BUCKETS.filter((b) => b.low <= hi && b.high >= lo).map((b) => b.label)
99
+ }
100
+
79
101
  function toLinkupFundingStage(stage: string): string | undefined {
80
102
  const map: Record<string, string> = {
81
103
  seed: 'Seed',
@@ -883,13 +905,13 @@ const searchCompaniesProviders: ProviderEntry[] = [
883
905
  company_industry: { include: input.industries },
884
906
  }),
885
907
  ...(isNonEmptyArray(input.countries) && {
886
- company_country: { include: (input.countries as string[]).map(isoCountryToName) },
908
+ company_location_search: { include: (input.countries as string[]).map(isoCountryToName) },
887
909
  }),
888
910
  ...((input.min_employees !== undefined || input.max_employees !== undefined) && {
889
- company_headcount_custom: {
890
- min: input.min_employees,
891
- max: input.max_employees,
892
- },
911
+ company_headcount_range: prospeoHeadcountBuckets(
912
+ input.min_employees as number | undefined,
913
+ input.max_employees as number | undefined,
914
+ ),
893
915
  }),
894
916
  },
895
917
  },
@@ -985,6 +1007,12 @@ const findPeopleProviders: ProviderEntry[] = [
985
1007
  endpoint: '/leadsfactory/contact-finder/searches',
986
1008
  method: 'POST',
987
1009
  priority: 1,
1010
+ // LF is a company-scoped contact finder — upstream 500s with
1011
+ // "At least one of company_linkedin_urls or company_domains must be provided"
1012
+ // when neither is supplied. Skip for pure prospecting (titles/seniorities/keywords
1013
+ // only); apollo at priority 2 handles that path.
1014
+ isApplicable: (input) =>
1015
+ isNonEmptyArray(input.company_domains) || isNonEmptyArray(input.company_linkedin_urls),
988
1016
  mapParams: (input) => {
989
1017
  const rawTitles = (input.job_titles as string[] | undefined) ?? []
990
1018
  // Step 1: deduplicate near-identical "of" variants (e.g. "VP Sales" ≡ "VP of Sales")
@@ -1075,6 +1103,10 @@ const findPeopleProviders: ProviderEntry[] = [
1075
1103
  endpoint: '/apollo/people/search',
1076
1104
  method: 'POST',
1077
1105
  priority: 2,
1106
+ // Apollo caps per_page at 100 and rejects larger values with a 400. The
1107
+ // find_people schema permits limit up to 500 (a waterfall-wide ceiling),
1108
+ // so clamp here to keep Apollo from breaking the request when the caller
1109
+ // pins use_providers=['apollo'] with a large limit.
1078
1110
  mapParams: (input) => ({
1079
1111
  body: {
1080
1112
  person_titles: input.job_titles,
@@ -1082,7 +1114,7 @@ const findPeopleProviders: ProviderEntry[] = [
1082
1114
  person_seniorities: input.seniorities,
1083
1115
  person_locations: input.locations,
1084
1116
  q_keywords: (input.keywords as string[] | undefined)?.join(' ') || undefined,
1085
- per_page: (input.limit as number) ?? 25,
1117
+ per_page: Math.min((input.limit as number) ?? 25, 100),
1086
1118
  },
1087
1119
  }),
1088
1120
  hasResult: (data) => {
@@ -1257,7 +1289,7 @@ const findPeopleProviders: ProviderEntry[] = [
1257
1289
  body: {
1258
1290
  filters: {
1259
1291
  ...(isNonEmptyArray(input.job_titles) && {
1260
- job_title: { include: input.job_titles },
1292
+ person_job_title: { include: input.job_titles },
1261
1293
  }),
1262
1294
  ...(isNonEmptyArray(input.seniorities) && {
1263
1295
  person_seniority: { include: input.seniorities },
@@ -1266,14 +1298,14 @@ const findPeopleProviders: ProviderEntry[] = [
1266
1298
  company: { websites: { include: input.company_domains } },
1267
1299
  }),
1268
1300
  ...(isNonEmptyArray(input.locations) && {
1269
- person_location: { include: input.locations },
1301
+ person_location_search: { include: input.locations },
1270
1302
  }),
1271
1303
  },
1272
1304
  },
1273
1305
  }),
1274
1306
  hasResult: (data) => {
1275
1307
  const d = data as Record<string, unknown>
1276
- return isNonEmptyArray(d.data)
1308
+ return isNonEmptyArray(d.results)
1277
1309
  },
1278
1310
  },
1279
1311
  {