@coldiq/mcp 0.1.9 → 0.1.10
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/dist/executor.d.ts +8 -1
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +26 -12
- package/dist/executor.js.map +1 -1
- package/dist/registry.d.ts +9 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +16 -0
- package/dist/registry.js.map +1 -1
- package/dist/tools/enrich-company.d.ts +2 -1
- package/dist/tools/enrich-company.d.ts.map +1 -1
- package/dist/tools/enrich-company.js +10 -3
- package/dist/tools/enrich-company.js.map +1 -1
- package/dist/tools/enrich-person.d.ts +1 -0
- package/dist/tools/enrich-person.d.ts.map +1 -1
- package/dist/tools/enrich-person.js +10 -2
- package/dist/tools/enrich-person.js.map +1 -1
- package/dist/tools/fetch-page-content.d.ts +1 -0
- package/dist/tools/fetch-page-content.d.ts.map +1 -1
- package/dist/tools/fetch-page-content.js +8 -1
- package/dist/tools/fetch-page-content.js.map +1 -1
- package/dist/tools/find-email.d.ts +1 -0
- package/dist/tools/find-email.d.ts.map +1 -1
- package/dist/tools/find-email.js +9 -2
- package/dist/tools/find-email.js.map +1 -1
- package/dist/tools/find-emails.d.ts +8 -0
- package/dist/tools/find-emails.d.ts.map +1 -1
- package/dist/tools/find-emails.js +64 -33
- package/dist/tools/find-emails.js.map +1 -1
- package/dist/tools/find-influencers.d.ts +1 -0
- package/dist/tools/find-influencers.d.ts.map +1 -1
- package/dist/tools/find-influencers.js +8 -1
- package/dist/tools/find-influencers.js.map +1 -1
- package/dist/tools/find-people.d.ts +1 -0
- package/dist/tools/find-people.d.ts.map +1 -1
- package/dist/tools/find-people.js +12 -5
- package/dist/tools/find-people.js.map +1 -1
- package/dist/tools/find-phone.d.ts +1 -0
- package/dist/tools/find-phone.d.ts.map +1 -1
- package/dist/tools/find-phone.js +9 -2
- package/dist/tools/find-phone.js.map +1 -1
- package/dist/tools/find-signals.d.ts +1 -0
- package/dist/tools/find-signals.d.ts.map +1 -1
- package/dist/tools/find-signals.js +14 -7
- package/dist/tools/find-signals.js.map +1 -1
- package/dist/tools/search-ads.d.ts +1 -0
- package/dist/tools/search-ads.d.ts.map +1 -1
- package/dist/tools/search-ads.js +8 -1
- package/dist/tools/search-ads.js.map +1 -1
- package/dist/tools/search-companies.d.ts +2 -1
- package/dist/tools/search-companies.d.ts.map +1 -1
- package/dist/tools/search-companies.js +9 -2
- package/dist/tools/search-companies.js.map +1 -1
- package/dist/tools/search-jobs.d.ts +1 -0
- package/dist/tools/search-jobs.d.ts.map +1 -1
- package/dist/tools/search-jobs.js +9 -2
- package/dist/tools/search-jobs.js.map +1 -1
- package/dist/tools/search-places.d.ts +1 -0
- package/dist/tools/search-places.d.ts.map +1 -1
- package/dist/tools/search-places.js +8 -1
- package/dist/tools/search-places.js.map +1 -1
- package/dist/tools/search-reddit.d.ts +1 -0
- package/dist/tools/search-reddit.d.ts.map +1 -1
- package/dist/tools/search-reddit.js +8 -1
- package/dist/tools/search-reddit.js.map +1 -1
- package/dist/tools/search-seo.d.ts +1 -0
- package/dist/tools/search-seo.d.ts.map +1 -1
- package/dist/tools/search-seo.js +8 -1
- package/dist/tools/search-seo.js.map +1 -1
- package/dist/tools/search-web.d.ts +1 -0
- package/dist/tools/search-web.d.ts.map +1 -1
- package/dist/tools/search-web.js +10 -2
- package/dist/tools/search-web.js.map +1 -1
- package/dist/tools/verify-email.d.ts +2 -1
- package/dist/tools/verify-email.d.ts.map +1 -1
- package/dist/tools/verify-email.js +9 -2
- package/dist/tools/verify-email.js.map +1 -1
- package/dist/utils/fuzzy.d.ts +13 -0
- package/dist/utils/fuzzy.d.ts.map +1 -0
- package/dist/utils/fuzzy.js +46 -0
- package/dist/utils/fuzzy.js.map +1 -0
- package/dist/utils/provider-resolver.d.ts +34 -0
- package/dist/utils/provider-resolver.d.ts.map +1 -0
- package/dist/utils/provider-resolver.js +235 -0
- package/dist/utils/provider-resolver.js.map +1 -0
- package/package.json +1 -1
- package/src/executor.ts +36 -8
- package/src/registry.ts +20 -0
- package/src/tools/enrich-company.ts +10 -3
- package/src/tools/enrich-person.ts +10 -2
- package/src/tools/fetch-page-content.ts +8 -1
- package/src/tools/find-email.ts +9 -2
- package/src/tools/find-emails.ts +78 -41
- package/src/tools/find-influencers.ts +8 -1
- package/src/tools/find-people.ts +12 -5
- package/src/tools/find-phone.ts +9 -2
- package/src/tools/find-signals.ts +15 -7
- package/src/tools/search-ads.ts +8 -1
- package/src/tools/search-companies.ts +9 -2
- package/src/tools/search-jobs.ts +9 -2
- package/src/tools/search-places.ts +8 -1
- package/src/tools/search-reddit.ts +8 -1
- package/src/tools/search-seo.ts +8 -1
- package/src/tools/search-web.ts +10 -2
- package/src/tools/verify-email.ts +9 -2
- package/src/utils/fuzzy.ts +57 -0
- package/src/utils/provider-resolver.ts +306 -0
- package/tests/executor.test.ts +112 -0
- package/tests/registry.test.ts +22 -1
- package/tests/tools/find-email.test.ts +43 -0
- package/tests/tools/find-emails.test.ts +87 -0
- package/tests/tools/search-companies.test.ts +40 -0
- package/tests/utils/fuzzy.test.ts +63 -0
- package/tests/utils/provider-resolver.test.ts +145 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export function normalize(s: string): string {
|
|
2
|
+
return s.toLowerCase().replace(/[^a-z0-9]/g, '')
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function levenshtein(a: string, b: string): number {
|
|
6
|
+
const m = a.length
|
|
7
|
+
const n = b.length
|
|
8
|
+
const dp: number[][] = Array.from({ length: m + 1 }, (_, i) =>
|
|
9
|
+
Array.from({ length: n + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
|
|
10
|
+
)
|
|
11
|
+
for (let i = 1; i <= m; i++) {
|
|
12
|
+
for (let j = 1; j <= n; j++) {
|
|
13
|
+
dp[i][j] = a[i - 1] === b[j - 1]
|
|
14
|
+
? dp[i - 1][j - 1]
|
|
15
|
+
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return dp[m][n]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type MatchVia = 'exact' | 'normalized' | 'fuzzy'
|
|
22
|
+
|
|
23
|
+
export interface FuzzyMatchResult {
|
|
24
|
+
matched: string
|
|
25
|
+
via: MatchVia
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Match a user-supplied string against a list of candidate IDs.
|
|
30
|
+
* Precedence: exact → normalized (case + punctuation stripped) → Levenshtein ≤ threshold.
|
|
31
|
+
* Threshold = min(2, floor(len/3)) where len = normalized candidate length.
|
|
32
|
+
*/
|
|
33
|
+
export function fuzzyMatch(input: string, candidates: string[]): FuzzyMatchResult | null {
|
|
34
|
+
// Exact
|
|
35
|
+
if (candidates.includes(input)) return { matched: input, via: 'exact' }
|
|
36
|
+
|
|
37
|
+
const normInput = normalize(input)
|
|
38
|
+
|
|
39
|
+
// Normalized exact
|
|
40
|
+
for (const c of candidates) {
|
|
41
|
+
if (normalize(c) === normInput) return { matched: c, via: 'normalized' }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Levenshtein
|
|
45
|
+
let best: { matched: string; dist: number } | null = null
|
|
46
|
+
for (const c of candidates) {
|
|
47
|
+
const normC = normalize(c)
|
|
48
|
+
const threshold = Math.min(2, Math.floor(normC.length / 3))
|
|
49
|
+
if (threshold === 0) continue
|
|
50
|
+
const dist = levenshtein(normInput, normC)
|
|
51
|
+
if (dist <= threshold && (best === null || dist < best.dist)) {
|
|
52
|
+
best = { matched: c, dist }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return best ? { matched: best.matched, via: 'fuzzy' } : null
|
|
57
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { listCapabilityProviderIds, getProviderApplicabilityGuard } from '../registry.js'
|
|
2
|
+
import type { Capability } from '../registry.js'
|
|
3
|
+
import { fuzzyMatch } from './fuzzy.js'
|
|
4
|
+
|
|
5
|
+
// find_emails uses a custom waterfall — its providers are not in the registry.
|
|
6
|
+
// Exported so find-emails.ts stays in sync without a second hardcoded list.
|
|
7
|
+
export const FIND_EMAILS_PROVIDERS = ['prospeo', 'fullenrich', 'findymail', 'icypeas']
|
|
8
|
+
|
|
9
|
+
export function getProvidersForCapability(capability: Capability | 'find_emails'): string[] {
|
|
10
|
+
if (capability === 'find_emails') return FIND_EMAILS_PROVIDERS
|
|
11
|
+
return listCapabilityProviderIds(capability as Capability)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// User-facing error payloads
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
export interface ToolErrorPayload {
|
|
19
|
+
error: string
|
|
20
|
+
available_providers: string[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const TRUST_LINE =
|
|
24
|
+
'Or just run the tool without specifying providers — ColdIQ will automatically pick the best tool for your needs.'
|
|
25
|
+
|
|
26
|
+
export function buildUnrecognizedError(
|
|
27
|
+
input: string,
|
|
28
|
+
capability: Capability | 'find_emails',
|
|
29
|
+
available: string[],
|
|
30
|
+
): ToolErrorPayload {
|
|
31
|
+
return {
|
|
32
|
+
error: `'${input}' is not a recognized provider for this tool. Available providers: ${available.join(', ')}. ${TRUST_LINE}`,
|
|
33
|
+
available_providers: available,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function buildGatedError(
|
|
38
|
+
provider: string,
|
|
39
|
+
gate: GateDesc,
|
|
40
|
+
capability: Capability | 'find_emails',
|
|
41
|
+
available: string[],
|
|
42
|
+
input: Record<string, unknown>,
|
|
43
|
+
): ToolErrorPayload {
|
|
44
|
+
const others = available.filter((p) => {
|
|
45
|
+
if (p === provider) return false
|
|
46
|
+
const guard = getProviderApplicabilityGuard(capability as Capability, p)
|
|
47
|
+
try {
|
|
48
|
+
return !guard || guard(input)
|
|
49
|
+
} catch {
|
|
50
|
+
return false
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
const othersText = others.length > 0
|
|
54
|
+
? `Other providers that work with your inputs: ${others.join(', ')}. `
|
|
55
|
+
: ''
|
|
56
|
+
const clause = gate.kind === 'incompatible_with'
|
|
57
|
+
? `is not compatible with ${gate.fields}`
|
|
58
|
+
: `requires ${gate.fields}, which wasn't provided`
|
|
59
|
+
return {
|
|
60
|
+
error: `'${provider}' ${clause}. ${othersText}${TRUST_LINE}`,
|
|
61
|
+
available_providers: others,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function buildAllFailedError(
|
|
66
|
+
tried: string[],
|
|
67
|
+
capability: Capability | 'find_emails',
|
|
68
|
+
available: string[],
|
|
69
|
+
): ToolErrorPayload {
|
|
70
|
+
const remaining = available.filter((p) => !tried.includes(p))
|
|
71
|
+
const remainingText = remaining.length > 0
|
|
72
|
+
? `You can retry with one of these instead: ${remaining.join(', ')}. `
|
|
73
|
+
: ''
|
|
74
|
+
return {
|
|
75
|
+
error: `Couldn't fulfill your request with the providers you specified (${tried.join(', ')}). ${remainingText}${TRUST_LINE}`,
|
|
76
|
+
available_providers: remaining,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Resolver
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
export interface ResolveOk {
|
|
85
|
+
ok: true
|
|
86
|
+
providers: string[]
|
|
87
|
+
matchedFrom?: Record<string, string>
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface ResolveError {
|
|
91
|
+
ok: false
|
|
92
|
+
error: ToolErrorPayload
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export type ResolveResult = ResolveOk | ResolveError
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Resolve `use_providers` field against the capability's known provider IDs.
|
|
99
|
+
* - Empty/absent → auto-route (ok: true, providers: [])
|
|
100
|
+
* - Unknown name → unrecognized error
|
|
101
|
+
* - isApplicable fails → gated error
|
|
102
|
+
* - Otherwise → resolved IDs in user order
|
|
103
|
+
*/
|
|
104
|
+
export function resolvePreferredProviders(
|
|
105
|
+
capability: Capability | 'find_emails',
|
|
106
|
+
input: Record<string, unknown>,
|
|
107
|
+
useProviders: unknown,
|
|
108
|
+
): ResolveResult {
|
|
109
|
+
if (!Array.isArray(useProviders) || useProviders.length === 0) {
|
|
110
|
+
return { ok: true, providers: [] }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const available = getProvidersForCapability(capability)
|
|
114
|
+
const resolved: string[] = []
|
|
115
|
+
const matchedFrom: Record<string, string> = {}
|
|
116
|
+
|
|
117
|
+
for (const raw of useProviders) {
|
|
118
|
+
const userInput = typeof raw === 'string' ? raw.trim() : String(raw)
|
|
119
|
+
const match = fuzzyMatch(userInput, available)
|
|
120
|
+
if (!match) {
|
|
121
|
+
return { ok: false, error: buildUnrecognizedError(userInput, capability, available) }
|
|
122
|
+
}
|
|
123
|
+
if (match.via !== 'exact') {
|
|
124
|
+
matchedFrom[userInput] = match.matched
|
|
125
|
+
}
|
|
126
|
+
if (!resolved.includes(match.matched)) {
|
|
127
|
+
resolved.push(match.matched)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Check isApplicable for each resolved provider (registry-based capabilities only)
|
|
132
|
+
if (capability !== 'find_emails') {
|
|
133
|
+
for (const id of resolved) {
|
|
134
|
+
const guard = getProviderApplicabilityGuard(capability as Capability, id)
|
|
135
|
+
if (guard) {
|
|
136
|
+
let applicable: boolean
|
|
137
|
+
try {
|
|
138
|
+
applicable = guard(input)
|
|
139
|
+
} catch {
|
|
140
|
+
applicable = false
|
|
141
|
+
}
|
|
142
|
+
if (!applicable) {
|
|
143
|
+
const gate = getGateDescription(capability as Capability, id)
|
|
144
|
+
return {
|
|
145
|
+
ok: false,
|
|
146
|
+
error: buildGatedError(id, gate, capability, available, input),
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
ok: true,
|
|
155
|
+
providers: resolved,
|
|
156
|
+
...(Object.keys(matchedFrom).length > 0 ? { matchedFrom } : {}),
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// Gate descriptions — used to produce accurate user-facing messages.
|
|
162
|
+
//
|
|
163
|
+
// Two kinds:
|
|
164
|
+
// requires → provider needs a field that the input does NOT have
|
|
165
|
+
// incompatible_with → provider is excluded BECAUSE a field IS present
|
|
166
|
+
//
|
|
167
|
+
// The distinction matters: "requires X" tells the user to ADD something;
|
|
168
|
+
// "not compatible with X" tells the user to REMOVE something.
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
interface GateDesc {
|
|
172
|
+
kind: 'requires' | 'incompatible_with'
|
|
173
|
+
fields: string
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const GENERIC_REQUIRES: GateDesc = { kind: 'requires', fields: 'specific input fields' }
|
|
177
|
+
|
|
178
|
+
const GATED_DESCRIPTIONS: Partial<Record<Capability, Partial<Record<string, GateDesc>>>> = {
|
|
179
|
+
// -------------------------------------------------------------------------
|
|
180
|
+
search_companies: {
|
|
181
|
+
theirstack: { kind: 'requires', fields: 'technologies, industries, or funding stage filters' },
|
|
182
|
+
signalbase: { kind: 'incompatible_with', fields: 'technology, funding, revenue, or hiring signal filters' },
|
|
183
|
+
blitzapi: { kind: 'incompatible_with', fields: 'advanced filters (tech stack, funding, revenue, exclusion lists, or workforce growth)' },
|
|
184
|
+
apollo: { kind: 'incompatible_with', fields: 'year, tech stack, funding, revenue, exclusion, or workforce growth filters' },
|
|
185
|
+
predictleads: { kind: 'requires', fields: 'both a location filter and an employee size filter' },
|
|
186
|
+
sumble: { kind: 'requires', fields: 'keywords, industries, or technologies' },
|
|
187
|
+
'limadata-prospect-filter': { kind: 'requires', fields: 'specific employee count range filters (min_employees / max_employees)' },
|
|
188
|
+
'limadata-prospect-url': { kind: 'requires', fields: 'linkedin_search_url' },
|
|
189
|
+
'linkupapi-fundraising': { kind: 'requires', fields: 'funding filters and keywords or industries' },
|
|
190
|
+
'linkupapi-hiring': { kind: 'requires', fields: 'is_hiring: true' },
|
|
191
|
+
'prospeo-search-company': { kind: 'requires', fields: 'keywords, industries, or countries' },
|
|
192
|
+
'ai-ark-companies': { kind: 'requires', fields: 'keywords, industries, or countries' },
|
|
193
|
+
},
|
|
194
|
+
// -------------------------------------------------------------------------
|
|
195
|
+
find_people: {
|
|
196
|
+
'linkupapi-search-profiles': { kind: 'incompatible_with', fields: 'company_linkedin_urls or company_domains (this provider only works without company filters)' },
|
|
197
|
+
'sumble-people-find': { kind: 'requires', fields: 'company_domains or company_linkedin_urls, and job_titles or seniorities' },
|
|
198
|
+
'prospeo-search-person': { kind: 'requires', fields: 'job_titles or company_domains' },
|
|
199
|
+
'ai-ark-people': { kind: 'requires', fields: 'job_titles, seniorities, or keywords' },
|
|
200
|
+
'findymail-search-employees': { kind: 'requires', fields: 'company_domains and job_titles' },
|
|
201
|
+
},
|
|
202
|
+
// -------------------------------------------------------------------------
|
|
203
|
+
find_email: {
|
|
204
|
+
findymail: { kind: 'requires', fields: 'first_name and (domain or company_name)' },
|
|
205
|
+
'limadata-work-email': { kind: 'requires', fields: 'first_name, last_name, and domain' },
|
|
206
|
+
blitzapi: { kind: 'requires', fields: 'linkedin_url' },
|
|
207
|
+
'limadata-work-email-linkedin': { kind: 'requires', fields: 'linkedin_url' },
|
|
208
|
+
},
|
|
209
|
+
// -------------------------------------------------------------------------
|
|
210
|
+
verify_email: {},
|
|
211
|
+
// -------------------------------------------------------------------------
|
|
212
|
+
find_phone: {
|
|
213
|
+
findymail: { kind: 'requires', fields: 'linkedin_url' },
|
|
214
|
+
limadata: { kind: 'requires', fields: 'linkedin_url or (first_name, last_name, and company domain)' },
|
|
215
|
+
'ai-ark': { kind: 'requires', fields: 'linkedin_url or (first_name, last_name, and company domain)' },
|
|
216
|
+
},
|
|
217
|
+
// -------------------------------------------------------------------------
|
|
218
|
+
enrich_company: {
|
|
219
|
+
companyenrich: { kind: 'requires', fields: 'domain' },
|
|
220
|
+
apollo: { kind: 'requires', fields: 'domain' },
|
|
221
|
+
limadata: { kind: 'requires', fields: 'domain or linkedin_url' },
|
|
222
|
+
wiza: { kind: 'requires', fields: 'domain or name' },
|
|
223
|
+
companyenrich_props: { kind: 'requires', fields: 'name or linkedin_url' },
|
|
224
|
+
blitzapi: { kind: 'requires', fields: 'linkedin_url' },
|
|
225
|
+
icypeas: { kind: 'requires', fields: 'linkedin_url' },
|
|
226
|
+
builtwith: { kind: 'requires', fields: 'domain' },
|
|
227
|
+
openmart: { kind: 'requires', fields: 'domain' },
|
|
228
|
+
'linkupapi-by-domain': { kind: 'requires', fields: 'domain' },
|
|
229
|
+
'linkupapi-by-url': { kind: 'requires', fields: 'linkedin_url' },
|
|
230
|
+
},
|
|
231
|
+
// -------------------------------------------------------------------------
|
|
232
|
+
enrich_person: {
|
|
233
|
+
'linkupapi-profile-enrich': { kind: 'requires', fields: 'first_name, last_name, and (company_name or domain)' },
|
|
234
|
+
'linkupapi-email-reverse': { kind: 'requires', fields: 'email' },
|
|
235
|
+
'blitzapi-reverse-email': { kind: 'requires', fields: 'email' },
|
|
236
|
+
'findymail-business-profile': { kind: 'requires', fields: 'linkedin_url (must be a linkedin.com URL)' },
|
|
237
|
+
'findymail-reverse-email': { kind: 'requires', fields: 'email' },
|
|
238
|
+
'icypeas-scrape-profile': { kind: 'requires', fields: 'linkedin_url (must be a linkedin.com URL)' },
|
|
239
|
+
'icypeas-url-search-profile': { kind: 'requires', fields: 'first_name, last_name, and (company_name or domain)' },
|
|
240
|
+
'ai-ark-reverse-lookup': { kind: 'requires', fields: 'email or phone' },
|
|
241
|
+
'icypeas-reverse-email-lookup': { kind: 'requires', fields: 'email' },
|
|
242
|
+
},
|
|
243
|
+
// -------------------------------------------------------------------------
|
|
244
|
+
search_web: {},
|
|
245
|
+
// -------------------------------------------------------------------------
|
|
246
|
+
search_jobs: {
|
|
247
|
+
career_site_jobs: { kind: 'incompatible_with', fields: 'LinkedIn-only filters (seniority_levels, industries, organization_slugs, min/max_employees, easy_apply_only, exclude_easy_apply)' },
|
|
248
|
+
linkedin_jobs_api: { kind: 'incompatible_with', fields: 'Career Site-only filters (ats_slugs, company_domains)' },
|
|
249
|
+
'theirstack-jobs': { kind: 'requires', fields: 'specific job filter conditions' },
|
|
250
|
+
},
|
|
251
|
+
// -------------------------------------------------------------------------
|
|
252
|
+
search_ads: {
|
|
253
|
+
google_ads: { kind: 'incompatible_with', fields: 'search_urls (LinkedIn-specific input); use query, domains, or advertiser_ids instead' },
|
|
254
|
+
linkedin_ad_library: { kind: 'requires', fields: 'search_urls (pre-built LinkedIn Ad Library URLs)' },
|
|
255
|
+
meta_ads: { kind: 'incompatible_with', fields: 'search_urls (LinkedIn-specific input)' },
|
|
256
|
+
twitter_ads: { kind: 'incompatible_with', fields: 'search_urls (LinkedIn-specific input)' },
|
|
257
|
+
reddit_ads: { kind: 'incompatible_with', fields: 'search_urls (LinkedIn-specific input)' },
|
|
258
|
+
},
|
|
259
|
+
// -------------------------------------------------------------------------
|
|
260
|
+
search_places: {
|
|
261
|
+
openmart: { kind: 'requires', fields: 'country in [US, CA, AU, PR, NZ] or structured Openmart filters' },
|
|
262
|
+
google_maps: { kind: 'requires', fields: 'a query or start_urls' },
|
|
263
|
+
},
|
|
264
|
+
// -------------------------------------------------------------------------
|
|
265
|
+
find_influencers: {
|
|
266
|
+
influencers_similar: { kind: 'requires', fields: 'handle (a creator handle for lookalike search)' },
|
|
267
|
+
},
|
|
268
|
+
// -------------------------------------------------------------------------
|
|
269
|
+
search_reddit: {},
|
|
270
|
+
// -------------------------------------------------------------------------
|
|
271
|
+
search_seo: {
|
|
272
|
+
kw_search_volume: { kind: 'requires', fields: 'category: "keywords"' },
|
|
273
|
+
kw_trends: { kind: 'requires', fields: 'category: "keywords"' },
|
|
274
|
+
serp_google: { kind: 'requires', fields: 'category: "serp" and engine: "google"' },
|
|
275
|
+
serp_bing: { kind: 'requires', fields: 'category: "serp" and engine: "bing"' },
|
|
276
|
+
serp_youtube: { kind: 'requires', fields: 'category: "serp" and engine: "youtube"' },
|
|
277
|
+
bl_summary: { kind: 'requires', fields: 'category: "backlinks"' },
|
|
278
|
+
bl_backlinks: { kind: 'requires', fields: 'category: "backlinks"' },
|
|
279
|
+
bl_referring: { kind: 'requires', fields: 'category: "backlinks"' },
|
|
280
|
+
domain_tech: { kind: 'requires', fields: 'category: "domain" and action: "technologies"' },
|
|
281
|
+
domain_whois: { kind: 'requires', fields: 'category: "domain" and action: "whois"' },
|
|
282
|
+
labs_rank_overview: { kind: 'requires', fields: 'category: "labs" and target' },
|
|
283
|
+
labs_ranked_kw: { kind: 'requires', fields: 'category: "labs" and target' },
|
|
284
|
+
labs_competitors: { kind: 'requires', fields: 'category: "labs" and target' },
|
|
285
|
+
labs_kw_ideas: { kind: 'requires', fields: 'category: "labs" and keywords[]' },
|
|
286
|
+
page_lighthouse: { kind: 'requires', fields: 'category: "page", url, and page_action: "lighthouse"' },
|
|
287
|
+
page_content: { kind: 'requires', fields: 'category: "page", url, and page_action: "content"' },
|
|
288
|
+
},
|
|
289
|
+
// -------------------------------------------------------------------------
|
|
290
|
+
find_signals: {
|
|
291
|
+
'signalbase-funding': { kind: 'requires', fields: 'signal_type: "funding"' },
|
|
292
|
+
'signalbase-acquisition': { kind: 'requires', fields: 'signal_type: "acquisition"' },
|
|
293
|
+
'signalbase-hiring': { kind: 'requires', fields: 'signal_type: "hiring"' },
|
|
294
|
+
'signalbase-job-change': { kind: 'requires', fields: 'signal_type: "job_change"' },
|
|
295
|
+
'theirstack-buying-intents': { kind: 'requires', fields: 'signal_type: "intent" and at least one of companies or domains' },
|
|
296
|
+
'predictleads-financing': { kind: 'requires', fields: 'signal_type: "funding"' },
|
|
297
|
+
'predictleads-news': { kind: 'requires', fields: 'signal_type: "news"' },
|
|
298
|
+
'predictleads-startup-posts': { kind: 'requires', fields: 'signal_type: "startup_post"' },
|
|
299
|
+
},
|
|
300
|
+
// -------------------------------------------------------------------------
|
|
301
|
+
fetch_page_content: {},
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function getGateDescription(capability: Capability, providerId: string): GateDesc {
|
|
305
|
+
return GATED_DESCRIPTIONS[capability]?.[providerId] ?? GENERIC_REQUIRES
|
|
306
|
+
}
|
package/tests/executor.test.ts
CHANGED
|
@@ -543,5 +543,117 @@ describe('executor async polling intervals', () => {
|
|
|
543
543
|
|
|
544
544
|
})
|
|
545
545
|
|
|
546
|
+
// ---------------------------------------------------------------------------
|
|
547
|
+
// options.providers — pinned provider routing
|
|
548
|
+
// ---------------------------------------------------------------------------
|
|
549
|
+
|
|
550
|
+
describe('executeWithFallback with options.providers', () => {
|
|
551
|
+
const originalFetch = globalThis.fetch
|
|
552
|
+
|
|
553
|
+
beforeEach(() => {
|
|
554
|
+
initClient('http://test-api.local', 'test-key-123')
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
afterEach(() => {
|
|
558
|
+
globalThis.fetch = originalFetch
|
|
559
|
+
vi.restoreAllMocks()
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
it('only calls the pinned provider, skips others', async () => {
|
|
563
|
+
const callOrder: string[] = []
|
|
564
|
+
stubProviders([
|
|
565
|
+
makeProvider({ id: 'first', priority: 1, hasResult: () => false }),
|
|
566
|
+
makeProvider({ id: 'second', priority: 2, hasResult: () => true }),
|
|
567
|
+
makeProvider({ id: 'third', priority: 3, hasResult: () => false }),
|
|
568
|
+
])
|
|
569
|
+
|
|
570
|
+
globalThis.fetch = vi.fn(async (url: unknown) => {
|
|
571
|
+
const path = (url as string).replace('http://test-api.local', '')
|
|
572
|
+
callOrder.push(path)
|
|
573
|
+
return new Response(JSON.stringify({ ok: true }), { status: 200 })
|
|
574
|
+
}) as typeof fetch
|
|
575
|
+
|
|
576
|
+
const result = await executeWithFallback('enrich_company', { domain: 'coldiq.com' }, { providers: ['second'] })
|
|
577
|
+
|
|
578
|
+
expect(callOrder).toHaveLength(1)
|
|
579
|
+
expect('data' in result).toBe(true)
|
|
580
|
+
if ('data' in result) expect(result._meta.provider).toBe('second')
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
it('respects user-specified order when multiple providers given', async () => {
|
|
584
|
+
let callCount = 0
|
|
585
|
+
stubProviders([
|
|
586
|
+
makeProvider({ id: 'alpha', priority: 1, hasResult: () => false }),
|
|
587
|
+
makeProvider({ id: 'beta', priority: 2, hasResult: (d) => callCount >= 2 && (d as Record<string, unknown>).ok === true }),
|
|
588
|
+
])
|
|
589
|
+
|
|
590
|
+
globalThis.fetch = vi.fn(async () => {
|
|
591
|
+
callCount++
|
|
592
|
+
return new Response(JSON.stringify({ ok: true }), { status: 200 })
|
|
593
|
+
}) as typeof fetch
|
|
594
|
+
|
|
595
|
+
const result = await executeWithFallback('enrich_company', { domain: 'coldiq.com' }, { providers: ['alpha', 'beta'] })
|
|
596
|
+
|
|
597
|
+
expect(callCount).toBe(2)
|
|
598
|
+
expect('data' in result).toBe(true)
|
|
599
|
+
if ('data' in result) expect(result._meta.provider).toBe('beta')
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
it('on total failure with pinned providers, returns non-anonymized provider IDs', async () => {
|
|
603
|
+
stubProviders([
|
|
604
|
+
makeProvider({ id: 'specific-provider', priority: 1, hasResult: () => false }),
|
|
605
|
+
])
|
|
606
|
+
|
|
607
|
+
globalThis.fetch = vi.fn(async () =>
|
|
608
|
+
new Response(JSON.stringify({ error: 'down' }), { status: 500 })
|
|
609
|
+
) as typeof fetch
|
|
610
|
+
|
|
611
|
+
const result = await executeWithFallback('enrich_company', { domain: 'coldiq.com' }, { providers: ['specific-provider'] })
|
|
612
|
+
|
|
613
|
+
expect('error' in result).toBe(true)
|
|
614
|
+
if ('error' in result) {
|
|
615
|
+
expect(result.providers_tried[0].provider).toBe('specific-provider')
|
|
616
|
+
}
|
|
617
|
+
})
|
|
618
|
+
|
|
619
|
+
it('on total failure without pinned providers, still anonymizes provider IDs', async () => {
|
|
620
|
+
stubProviders([
|
|
621
|
+
makeProvider({ id: 'secret-provider', priority: 1, hasResult: () => false }),
|
|
622
|
+
])
|
|
623
|
+
|
|
624
|
+
globalThis.fetch = vi.fn(async () =>
|
|
625
|
+
new Response(JSON.stringify({ error: 'down' }), { status: 500 })
|
|
626
|
+
) as typeof fetch
|
|
627
|
+
|
|
628
|
+
const result = await executeWithFallback('enrich_company', { domain: 'coldiq.com' })
|
|
629
|
+
|
|
630
|
+
expect('error' in result).toBe(true)
|
|
631
|
+
if ('error' in result) {
|
|
632
|
+
expect(result.providers_tried[0].provider).toBe('provider_1')
|
|
633
|
+
}
|
|
634
|
+
})
|
|
635
|
+
|
|
636
|
+
it('surfaces matchedFrom in success _meta when provided', async () => {
|
|
637
|
+
stubProviders([
|
|
638
|
+
makeProvider({ id: 'prospeo', priority: 1, hasResult: () => true }),
|
|
639
|
+
])
|
|
640
|
+
|
|
641
|
+
globalThis.fetch = vi.fn(async () =>
|
|
642
|
+
new Response(JSON.stringify({ ok: true }), { status: 200 })
|
|
643
|
+
) as typeof fetch
|
|
644
|
+
|
|
645
|
+
const result = await executeWithFallback(
|
|
646
|
+
'enrich_company',
|
|
647
|
+
{ domain: 'coldiq.com' },
|
|
648
|
+
{ providers: ['prospeo'], matchedFrom: { prospec: 'prospeo' } },
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
expect('data' in result).toBe(true)
|
|
652
|
+
if ('data' in result) {
|
|
653
|
+
expect(result._meta.matchedFrom).toEqual({ prospec: 'prospeo' })
|
|
654
|
+
}
|
|
655
|
+
})
|
|
656
|
+
})
|
|
657
|
+
|
|
546
658
|
// Note: the LeadsFactory backoff *schedule* itself is asserted against the live
|
|
547
659
|
// provider entry in tests/registry.test.ts to avoid drift between test and source.
|
package/tests/registry.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { getProviders, getSearchWebProviders, isoCountryToName } from '../src/registry.js'
|
|
2
|
+
import { getProviders, getSearchWebProviders, isoCountryToName, listCapabilityProviderIds } from '../src/registry.js'
|
|
3
3
|
import type { Capability } from '../src/registry.js'
|
|
4
4
|
|
|
5
5
|
const ALL_CAPABILITIES: Capability[] = [
|
|
@@ -2149,3 +2149,24 @@ describe('registry', () => {
|
|
|
2149
2149
|
})
|
|
2150
2150
|
})
|
|
2151
2151
|
})
|
|
2152
|
+
|
|
2153
|
+
describe('listCapabilityProviderIds', () => {
|
|
2154
|
+
it('returns at least one provider ID for every capability', () => {
|
|
2155
|
+
const caps: Capability[] = [
|
|
2156
|
+
'search_companies', 'find_people', 'find_email', 'verify_email', 'find_phone',
|
|
2157
|
+
'enrich_company', 'enrich_person', 'search_web', 'search_jobs', 'search_ads',
|
|
2158
|
+
'search_places', 'find_influencers', 'search_reddit', 'search_seo',
|
|
2159
|
+
'find_signals', 'fetch_page_content',
|
|
2160
|
+
]
|
|
2161
|
+
for (const cap of caps) {
|
|
2162
|
+
const ids = listCapabilityProviderIds(cap)
|
|
2163
|
+
expect(ids.length, `${cap} should have at least 1 provider`).toBeGreaterThan(0)
|
|
2164
|
+
}
|
|
2165
|
+
})
|
|
2166
|
+
|
|
2167
|
+
it('returns string IDs that match getProviders output order', () => {
|
|
2168
|
+
const ids = listCapabilityProviderIds('find_email')
|
|
2169
|
+
const providers = getProviders('find_email')
|
|
2170
|
+
expect(ids).toEqual(providers.map((p) => p.id))
|
|
2171
|
+
})
|
|
2172
|
+
})
|
|
@@ -91,4 +91,47 @@ describe('find_email handler (waterfall)', () => {
|
|
|
91
91
|
// No linkedin_url → blitzapi and limadata-work-email-linkedin are skipped via isApplicable
|
|
92
92
|
expect(parsed.providers_tried.length).toBe(6) // findymail, icypeas, limadata-work-email, prospeo, fullenrich, linkupapi
|
|
93
93
|
})
|
|
94
|
+
|
|
95
|
+
describe('use_providers', () => {
|
|
96
|
+
it('Scenario A — pinned provider succeeds', async () => {
|
|
97
|
+
globalThis.fetch = vi.fn(async () =>
|
|
98
|
+
new Response(JSON.stringify({ email: 'michel@coldiq.com' }), { status: 200 })
|
|
99
|
+
) as typeof fetch
|
|
100
|
+
|
|
101
|
+
const result = await findEmailHandler({
|
|
102
|
+
first_name: 'Michel',
|
|
103
|
+
domain: 'coldiq.com',
|
|
104
|
+
use_providers: ['findymail'],
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
expect(result.isError).toBeUndefined()
|
|
108
|
+
const parsed = JSON.parse(result.content[0].text)
|
|
109
|
+
expect(parsed._meta.provider).toBe('findymail')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('Scenario C — gated provider without required input returns helpful error', async () => {
|
|
113
|
+
const result = await findEmailHandler({
|
|
114
|
+
first_name: 'Michel',
|
|
115
|
+
domain: 'coldiq.com',
|
|
116
|
+
use_providers: ['blitzapi'],
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
expect(result.isError).toBe(true)
|
|
120
|
+
const parsed = JSON.parse(result.content[0].text)
|
|
121
|
+
expect(parsed.error).toContain("'blitzapi' requires")
|
|
122
|
+
expect(parsed.error).toContain('ColdIQ will automatically pick the best tool')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('Scenario E — unrecognized provider returns isError', async () => {
|
|
126
|
+
const result = await findEmailHandler({
|
|
127
|
+
first_name: 'Michel',
|
|
128
|
+
domain: 'coldiq.com',
|
|
129
|
+
use_providers: ['apollo'],
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
expect(result.isError).toBe(true)
|
|
133
|
+
const parsed = JSON.parse(result.content[0].text)
|
|
134
|
+
expect(parsed.error).toContain("'apollo' is not a recognized provider")
|
|
135
|
+
})
|
|
136
|
+
})
|
|
94
137
|
})
|
|
@@ -313,3 +313,90 @@ interface EmailResult {
|
|
|
313
313
|
email: string | null
|
|
314
314
|
provider: string | null
|
|
315
315
|
}
|
|
316
|
+
|
|
317
|
+
describe('find_emails handler — use_providers', () => {
|
|
318
|
+
const originalFetch = globalThis.fetch
|
|
319
|
+
|
|
320
|
+
beforeEach(() => {
|
|
321
|
+
initClient('http://test-api.local', 'test-key')
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
afterEach(() => {
|
|
325
|
+
globalThis.fetch = originalFetch
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
it('Scenario A — pinned to prospeo only, skips fallback providers', async () => {
|
|
329
|
+
let findymailCalled = false
|
|
330
|
+
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
|
331
|
+
const u = url.toString()
|
|
332
|
+
if (u.includes('/findymail')) findymailCalled = true
|
|
333
|
+
if (u.includes('/prospeo/bulk-enrich-person')) {
|
|
334
|
+
return new Response(JSON.stringify({
|
|
335
|
+
error: false,
|
|
336
|
+
results: [{ identifier: 'p1', person: { email: { email: 'alice@example.com' } } }],
|
|
337
|
+
}), { status: 200 })
|
|
338
|
+
}
|
|
339
|
+
return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
|
|
340
|
+
}) as typeof fetch
|
|
341
|
+
|
|
342
|
+
const result = await findEmailsHandler({
|
|
343
|
+
people: [{ id: 'p1', first_name: 'Alice', domain: 'example.com' }],
|
|
344
|
+
use_providers: ['prospeo'],
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
expect(result.isError).toBeUndefined()
|
|
348
|
+
expect(findymailCalled).toBe(false)
|
|
349
|
+
const parsed = JSON.parse(result.content[0].text)
|
|
350
|
+
expect(parsed.data.results[0]).toMatchObject({ provider: 'prospeo' })
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
it('Scenario H — all pinned providers find nothing → returns isError with pushback message', async () => {
|
|
354
|
+
globalThis.fetch = vi.fn(async () =>
|
|
355
|
+
new Response(JSON.stringify({ error: false, results: [] }), { status: 200 })
|
|
356
|
+
) as typeof fetch
|
|
357
|
+
|
|
358
|
+
const result = await findEmailsHandler({
|
|
359
|
+
people: [{ id: 'p1', first_name: 'Unknown', domain: 'nowhere.xyz' }],
|
|
360
|
+
use_providers: ['findymail'],
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
expect(result.isError).toBe(true)
|
|
364
|
+
const parsed = JSON.parse(result.content[0].text)
|
|
365
|
+
expect(parsed.error).toContain('Couldn\'t fulfill')
|
|
366
|
+
expect(parsed.error).toContain('ColdIQ will automatically pick the best tool')
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it('Scenario E — unrecognized provider name returns isError', async () => {
|
|
370
|
+
const result = await findEmailsHandler({
|
|
371
|
+
people: [{ id: 'p1', first_name: 'Alice', domain: 'example.com' }],
|
|
372
|
+
use_providers: ['wiza'],
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
expect(result.isError).toBe(true)
|
|
376
|
+
const parsed = JSON.parse(result.content[0].text)
|
|
377
|
+
expect(parsed.error).toContain("'wiza' is not a recognized provider")
|
|
378
|
+
expect(parsed.error).toContain('ColdIQ will automatically pick the best tool')
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
it('Scenario G — ordered providers: first succeeds, second skipped', async () => {
|
|
382
|
+
let icypeasCalled = false
|
|
383
|
+
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
|
384
|
+
const u = url.toString()
|
|
385
|
+
if (u.includes('/icypeas')) icypeasCalled = true
|
|
386
|
+
if (u.includes('/findymail/search/name')) {
|
|
387
|
+
return new Response(JSON.stringify({ email: 'alice@example.com' }), { status: 200 })
|
|
388
|
+
}
|
|
389
|
+
return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
|
|
390
|
+
}) as typeof fetch
|
|
391
|
+
|
|
392
|
+
const result = await findEmailsHandler({
|
|
393
|
+
people: [{ id: 'p1', first_name: 'Alice', domain: 'example.com' }],
|
|
394
|
+
use_providers: ['findymail', 'icypeas'],
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
expect(result.isError).toBeUndefined()
|
|
398
|
+
const parsed = JSON.parse(result.content[0].text)
|
|
399
|
+
expect(parsed.data.results[0]).toMatchObject({ provider: 'findymail', email: 'alice@example.com' })
|
|
400
|
+
expect(icypeasCalled).toBe(false)
|
|
401
|
+
})
|
|
402
|
+
})
|