@coldiq/mcp 0.3.12 → 0.3.13

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 (115) hide show
  1. package/dist/tools/enrich-company.d.ts +3 -9
  2. package/dist/tools/enrich-company.d.ts.map +1 -1
  3. package/dist/tools/enrich-company.js +3 -16
  4. package/dist/tools/enrich-company.js.map +1 -1
  5. package/dist/tools/enrich-person.d.ts +3 -9
  6. package/dist/tools/enrich-person.d.ts.map +1 -1
  7. package/dist/tools/enrich-person.js +3 -12
  8. package/dist/tools/enrich-person.js.map +1 -1
  9. package/dist/tools/fetch-page-content.d.ts +3 -9
  10. package/dist/tools/fetch-page-content.d.ts.map +1 -1
  11. package/dist/tools/fetch-page-content.js +3 -12
  12. package/dist/tools/fetch-page-content.js.map +1 -1
  13. package/dist/tools/find-email.d.ts +3 -9
  14. package/dist/tools/find-email.d.ts.map +1 -1
  15. package/dist/tools/find-email.js +3 -40
  16. package/dist/tools/find-email.js.map +1 -1
  17. package/dist/tools/find-emails.d.ts +3 -4
  18. package/dist/tools/find-emails.d.ts.map +1 -1
  19. package/dist/tools/find-emails.js +8 -423
  20. package/dist/tools/find-emails.js.map +1 -1
  21. package/dist/tools/find-influencers.d.ts +3 -9
  22. package/dist/tools/find-influencers.d.ts.map +1 -1
  23. package/dist/tools/find-influencers.js +3 -12
  24. package/dist/tools/find-influencers.js.map +1 -1
  25. package/dist/tools/find-people.d.ts +3 -8
  26. package/dist/tools/find-people.d.ts.map +1 -1
  27. package/dist/tools/find-people.js +3 -98
  28. package/dist/tools/find-people.js.map +1 -1
  29. package/dist/tools/find-phone.d.ts +3 -9
  30. package/dist/tools/find-phone.d.ts.map +1 -1
  31. package/dist/tools/find-phone.js +4 -41
  32. package/dist/tools/find-phone.js.map +1 -1
  33. package/dist/tools/find-signals.d.ts +3 -9
  34. package/dist/tools/find-signals.d.ts.map +1 -1
  35. package/dist/tools/find-signals.js +3 -83
  36. package/dist/tools/find-signals.js.map +1 -1
  37. package/dist/tools/get-place-reviews.d.ts +3 -9
  38. package/dist/tools/get-place-reviews.d.ts.map +1 -1
  39. package/dist/tools/get-place-reviews.js +3 -12
  40. package/dist/tools/get-place-reviews.js.map +1 -1
  41. package/dist/tools/search-ads.d.ts +3 -9
  42. package/dist/tools/search-ads.d.ts.map +1 -1
  43. package/dist/tools/search-ads.js +3 -12
  44. package/dist/tools/search-ads.js.map +1 -1
  45. package/dist/tools/search-companies.d.ts +3 -9
  46. package/dist/tools/search-companies.d.ts.map +1 -1
  47. package/dist/tools/search-companies.js +3 -24
  48. package/dist/tools/search-companies.js.map +1 -1
  49. package/dist/tools/search-jobs.d.ts +3 -9
  50. package/dist/tools/search-jobs.d.ts.map +1 -1
  51. package/dist/tools/search-jobs.js +3 -27
  52. package/dist/tools/search-jobs.js.map +1 -1
  53. package/dist/tools/search-places.d.ts +3 -15
  54. package/dist/tools/search-places.d.ts.map +1 -1
  55. package/dist/tools/search-places.js +5 -76
  56. package/dist/tools/search-places.js.map +1 -1
  57. package/dist/tools/search-reddit.d.ts +3 -9
  58. package/dist/tools/search-reddit.d.ts.map +1 -1
  59. package/dist/tools/search-reddit.js +3 -12
  60. package/dist/tools/search-reddit.js.map +1 -1
  61. package/dist/tools/search-seo.d.ts +3 -9
  62. package/dist/tools/search-seo.d.ts.map +1 -1
  63. package/dist/tools/search-seo.js +3 -12
  64. package/dist/tools/search-seo.js.map +1 -1
  65. package/dist/tools/search-web.d.ts +3 -9
  66. package/dist/tools/search-web.d.ts.map +1 -1
  67. package/dist/tools/search-web.js +3 -12
  68. package/dist/tools/search-web.js.map +1 -1
  69. package/dist/tools/verify-email.d.ts +3 -10
  70. package/dist/tools/verify-email.d.ts.map +1 -1
  71. package/dist/tools/verify-email.js +3 -68
  72. package/dist/tools/verify-email.js.map +1 -1
  73. package/dist/verb-client.d.ts +35 -0
  74. package/dist/verb-client.d.ts.map +1 -0
  75. package/dist/verb-client.js +57 -0
  76. package/dist/verb-client.js.map +1 -0
  77. package/package.json +1 -1
  78. package/src/tools/enrich-company.ts +3 -16
  79. package/src/tools/enrich-person.ts +3 -12
  80. package/src/tools/fetch-page-content.ts +3 -12
  81. package/src/tools/find-email.ts +3 -42
  82. package/src/tools/find-emails.ts +8 -506
  83. package/src/tools/find-influencers.ts +3 -12
  84. package/src/tools/find-people.ts +3 -107
  85. package/src/tools/find-phone.ts +4 -42
  86. package/src/tools/find-signals.ts +3 -88
  87. package/src/tools/get-place-reviews.ts +3 -12
  88. package/src/tools/search-ads.ts +3 -12
  89. package/src/tools/search-companies.ts +3 -23
  90. package/src/tools/search-jobs.ts +3 -30
  91. package/src/tools/search-places.ts +5 -72
  92. package/src/tools/search-reddit.ts +3 -12
  93. package/src/tools/search-seo.ts +3 -12
  94. package/src/tools/search-web.ts +3 -12
  95. package/src/tools/verify-email.ts +3 -76
  96. package/src/verb-client.ts +93 -0
  97. package/tests/verb-client.test.ts +152 -0
  98. package/tests/tools/enrich-company.test.ts +0 -92
  99. package/tests/tools/enrich-person.test.ts +0 -140
  100. package/tests/tools/fetch-page-content.test.ts +0 -108
  101. package/tests/tools/find-email.test.ts +0 -137
  102. package/tests/tools/find-emails.test.ts +0 -818
  103. package/tests/tools/find-influencers.test.ts +0 -91
  104. package/tests/tools/find-people.test.ts +0 -621
  105. package/tests/tools/find-phone.test.ts +0 -100
  106. package/tests/tools/find-signals.test.ts +0 -175
  107. package/tests/tools/get-place-reviews.test.ts +0 -73
  108. package/tests/tools/search-ads.test.ts +0 -182
  109. package/tests/tools/search-companies.test.ts +0 -192
  110. package/tests/tools/search-jobs.test.ts +0 -210
  111. package/tests/tools/search-places.test.ts +0 -210
  112. package/tests/tools/search-reddit.test.ts +0 -194
  113. package/tests/tools/search-seo.test.ts +0 -183
  114. package/tests/tools/search-web.test.ts +0 -79
  115. package/tests/tools/verify-email.test.ts +0 -142
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod'
2
- import { executeWithFallback, isExecutionError } from '../executor.js'
3
- import { resolvePreferredProviders, getProvidersForCapability } from '../utils/provider-resolver.js'
2
+ import { callVerb } from '../verb-client.js'
3
+ import { getProvidersForCapability } from '../utils/provider-resolver.js'
4
4
 
5
5
  export const findPhoneName = 'find_phone'
6
6
 
@@ -16,45 +16,7 @@ export const findPhoneSchema = {
16
16
  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_phone').join(', ')}. Provider names are matched fuzzily, so minor typos are tolerated.`),
17
17
  }
18
18
 
19
- const inputSchema = z
20
- .object({
21
- linkedin_url: z.string().url().optional(),
22
- first_name: z.string().optional(),
23
- last_name: z.string().optional(),
24
- company_domain: z.string().optional(),
25
- company_name: z.string().optional(),
26
- })
27
- .superRefine((val, ctx) => {
28
- const hasLinkedIn = typeof val.linkedin_url === 'string' && val.linkedin_url.length > 0
29
- const hasName = typeof val.first_name === 'string' && val.first_name.length > 0 &&
30
- typeof val.last_name === 'string' && val.last_name.length > 0
31
- const hasCompany = (typeof val.company_domain === 'string' && val.company_domain.length > 0) ||
32
- (typeof val.company_name === 'string' && val.company_name.length > 0)
33
- if (!hasLinkedIn && !(hasName && hasCompany)) {
34
- ctx.addIssue({
35
- code: 'custom',
36
- message: 'Provide either linkedin_url or both first_name+last_name and company_domain/company_name',
37
- })
38
- }
39
- })
40
-
41
19
  export async function findPhoneHandler(input: Record<string, unknown>) {
42
- const { use_providers: rawUseProviders, ...restInput } = input
43
- const resolved = resolvePreferredProviders('find_phone', restInput, rawUseProviders)
44
- if (!resolved.ok) {
45
- return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error) }], isError: true }
46
- }
47
- const validation = inputSchema.safeParse(restInput)
48
- if (!validation.success) {
49
- const msg = validation.error.issues.map((i) => i.message).join('; ')
50
- return {
51
- content: [{ type: 'text' as const, text: JSON.stringify({ error: msg }) }],
52
- isError: true,
53
- }
54
- }
55
- const result = await executeWithFallback('find_phone', restInput, { providers: resolved.providers, matchedFrom: resolved.matchedFrom })
56
- if (isExecutionError(result)) {
57
- return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], isError: true }
58
- }
59
- return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }
20
+ // MCP schema predates the verb: `company_domain` the verb's `domain`.
21
+ return callVerb('/phone/find', input, { remap: { company_domain: 'domain' } })
60
22
  }
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod'
2
- import { executeWithFallback, isExecutionError } from '../executor.js'
3
- import { resolvePreferredProviders, getProvidersForCapability } from '../utils/provider-resolver.js'
2
+ import { callVerb } from '../verb-client.js'
3
+ import { getProvidersForCapability } from '../utils/provider-resolver.js'
4
4
 
5
5
  export const findSignalsName = 'find_signals'
6
6
 
@@ -61,90 +61,5 @@ export const findSignalsSchema = {
61
61
  }
62
62
 
63
63
  export async function findSignalsHandler(input: Record<string, unknown>) {
64
- const { use_providers: rawUseProviders, ...restInput } = input
65
- const hasCompanies = Array.isArray(restInput.companies) && (restInput.companies as unknown[]).length > 0
66
- const hasDomains = Array.isArray(restInput.domains) && (restInput.domains as unknown[]).length > 0
67
-
68
- const hasTopics = Array.isArray(restInput.topics) && (restInput.topics as unknown[]).length > 0
69
-
70
- if (restInput.signal_type === 'intent' && !hasCompanies && !hasDomains && !hasTopics) {
71
- return {
72
- content: [{
73
- type: 'text' as const,
74
- text: JSON.stringify({ error: 'intent signal_type requires at least one of: topics (to discover companies by intent topic), or companies/domains (to verify intent on known companies)' }),
75
- }],
76
- isError: true,
77
- }
78
- }
79
-
80
- if ((restInput.signal_type === 'news' || restInput.signal_type === 'startup_post') && (hasCompanies || hasDomains)) {
81
- return {
82
- content: [{
83
- type: 'text' as const,
84
- text: JSON.stringify({
85
- error: 'news and startup_post are feed-style signal types that only filter by country and since date — they do not support company filtering. Remove companies/domains, or use signal_type=intent for company-targeted intent signals.',
86
- }),
87
- }],
88
- isError: true,
89
- }
90
- }
91
-
92
- const resolved = resolvePreferredProviders('find_signals', restInput, rawUseProviders)
93
- if (!resolved.ok) {
94
- return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error) }], isError: true }
95
- }
96
-
97
- const result = await executeWithFallback('find_signals', restInput, { providers: resolved.providers, matchedFrom: resolved.matchedFrom })
98
- if (isExecutionError(result)) {
99
- return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], isError: true }
100
- }
101
-
102
- // buying_intents upstream has no limit param — truncate to requested limit
103
- if (restInput.signal_type === 'intent') {
104
- const limit = typeof restInput.limit === 'number' ? restInput.limit : 25
105
- const typed = result as { data?: { data?: unknown[] } }
106
- if (Array.isArray(typed.data?.data)) {
107
- typed.data!.data = typed.data!.data.slice(0, limit)
108
- }
109
- }
110
-
111
- // Signalbase /hiring-signals does not accept an industry filter upstream, so the
112
- // `industries` param would otherwise be silently dropped. Filter client-side:
113
- // each hiring row carries an `industries` string (e.g. "Law Practice and Legal
114
- // Services") which we substring-match against the user-supplied list.
115
- //
116
- // Non-destructive fallback: Signalbase has no industry facet and tags rows with
117
- // coarse labels (e.g. "Financial Services"), so a user term like "Fintech" can
118
- // match nothing even when relevant rows exist. Rather than return a misleading
119
- // empty set (which reads as "no companies are hiring"), when the filter would
120
- // drop every row we keep the unfiltered rows and attach a note explaining that
121
- // the industry filter matched nothing.
122
- if (restInput.signal_type === 'hiring' && Array.isArray(restInput.industries) && restInput.industries.length > 0) {
123
- const wanted = (restInput.industries as unknown[])
124
- .map((s) => (typeof s === 'string' ? s.toLowerCase() : ''))
125
- .filter((s) => s.length > 0)
126
- if (wanted.length > 0) {
127
- const typed = result as { data?: { data?: unknown[]; _industry_filter?: string } }
128
- const rows = typed.data?.data
129
- if (Array.isArray(rows) && rows.length > 0) {
130
- const filtered = rows.filter((row) => {
131
- if (!row || typeof row !== 'object') return false
132
- const industriesField = (row as Record<string, unknown>).industries
133
- if (typeof industriesField !== 'string' || industriesField.length === 0) return false
134
- const haystack = industriesField.toLowerCase()
135
- return wanted.some((needle) => haystack.includes(needle))
136
- })
137
- if (filtered.length > 0) {
138
- typed.data!.data = filtered
139
- } else {
140
- typed.data!._industry_filter =
141
- `No hiring rows matched industries [${(restInput.industries as string[]).join(', ')}]. ` +
142
- 'Signalbase tags hiring rows with coarse industry labels (e.g. "Financial Services"), so a narrow term may match nothing — results are returned UNFILTERED. ' +
143
- 'Narrow with countries or a broader/more exact industry label (e.g. "Financial Services" instead of "Fintech").'
144
- }
145
- }
146
- }
147
- }
148
-
149
- return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }
64
+ return callVerb('/signals/find', input)
150
65
  }
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod'
2
- import { executeWithFallback, isExecutionError } from '../executor.js'
3
- import { resolvePreferredProviders, getProvidersForCapability } from '../utils/provider-resolver.js'
2
+ import { callVerb } from '../verb-client.js'
3
+ import { getProvidersForCapability } from '../utils/provider-resolver.js'
4
4
 
5
5
  export const getPlaceReviewsName = 'get_place_reviews'
6
6
 
@@ -37,14 +37,5 @@ export const getPlaceReviewsSchema = {
37
37
  }
38
38
 
39
39
  export async function getPlaceReviewsHandler(input: Record<string, unknown>) {
40
- const { use_providers: rawUseProviders, ...restInput } = input
41
- const resolved = resolvePreferredProviders('get_place_reviews', restInput, rawUseProviders)
42
- if (!resolved.ok) {
43
- return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error) }], isError: true }
44
- }
45
- const result = await executeWithFallback('get_place_reviews', restInput, { providers: resolved.providers, matchedFrom: resolved.matchedFrom })
46
- if (isExecutionError(result)) {
47
- return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], isError: true }
48
- }
49
- return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }
40
+ return callVerb('/places/reviews', input)
50
41
  }
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod'
2
- import { executeWithFallback, isExecutionError } from '../executor.js'
3
- import { resolvePreferredProviders, getProvidersForCapability } from '../utils/provider-resolver.js'
2
+ import { callVerb } from '../verb-client.js'
3
+ import { getProvidersForCapability } from '../utils/provider-resolver.js'
4
4
 
5
5
  export const searchAdsName = 'search_ads'
6
6
 
@@ -38,14 +38,5 @@ export const searchAdsSchema = {
38
38
  }
39
39
 
40
40
  export async function searchAdsHandler(input: Record<string, unknown>) {
41
- const { use_providers: rawUseProviders, ...restInput } = input
42
- const resolved = resolvePreferredProviders('search_ads', restInput, rawUseProviders)
43
- if (!resolved.ok) {
44
- return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error) }], isError: true }
45
- }
46
- const result = await executeWithFallback('search_ads', restInput, { providers: resolved.providers, matchedFrom: resolved.matchedFrom })
47
- if (isExecutionError(result)) {
48
- return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], isError: true }
49
- }
50
- return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }
41
+ return callVerb('/ads/search', input)
51
42
  }
@@ -1,7 +1,6 @@
1
1
  import { z } from 'zod'
2
- import { executeWithFallback, isExecutionError } from '../executor.js'
3
- import { resolvePreferredProviders, getProvidersForCapability } from '../utils/provider-resolver.js'
4
- import { compactCompaniesPayload } from '../utils/compact-companies.js'
2
+ import { callVerb } from '../verb-client.js'
3
+ import { getProvidersForCapability } from '../utils/provider-resolver.js'
5
4
 
6
5
  export const searchCompaniesName = 'search_companies'
7
6
 
@@ -38,24 +37,5 @@ export const searchCompaniesSchema = {
38
37
  }
39
38
 
40
39
  export async function searchCompaniesHandler(input: Record<string, unknown>) {
41
- const { use_providers: rawUseProviders, fields: rawFields, ...restInput } = input
42
- const fields: 'compact' | 'verbose' = rawFields === 'verbose' ? 'verbose' : 'compact'
43
- const resolved = resolvePreferredProviders('search_companies', restInput, rawUseProviders)
44
- if (!resolved.ok) {
45
- return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error) }], isError: true }
46
- }
47
- const result = await executeWithFallback('search_companies', restInput, { providers: resolved.providers, matchedFrom: resolved.matchedFrom })
48
- if (isExecutionError(result)) {
49
- return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], isError: true }
50
- }
51
- // Surface the upstream match count (e.g. Apollo's pagination.total_entries) as the
52
- // total-addressable-market signal, so clients see "matched N, returned M".
53
- const totalEntries = (result.data as { pagination?: { total_entries?: unknown } })?.pagination?.total_entries
54
- if (typeof totalEntries === 'number') {
55
- ;(result._meta as Record<string, unknown>).total_entries = totalEntries
56
- }
57
- const payload = fields === 'compact'
58
- ? { ...result, data: compactCompaniesPayload(result.data) }
59
- : result
60
- return { content: [{ type: 'text' as const, text: JSON.stringify(payload) }] }
40
+ return callVerb('/companies/search', input)
61
41
  }
@@ -1,16 +1,6 @@
1
1
  import { z } from 'zod'
2
- import { executeWithFallback, isExecutionError } from '../executor.js'
3
- import { resolvePreferredProviders, getProvidersForCapability } from '../utils/provider-resolver.js'
4
-
5
- const _CAREER_SITE_ONLY = ['ats_slugs', 'exclude_ats_slugs', 'company_domains', 'exclude_company_domains']
6
- const _LINKEDIN_ONLY = ['seniority_levels', 'industries', 'organization_slugs', 'exclude_organization_slugs', 'min_employees', 'max_employees', 'easy_apply_only', 'exclude_easy_apply']
7
-
8
- function _isFilterSet(input: Record<string, unknown>, keys: string[]): boolean {
9
- return keys.some((k) => {
10
- const v = input[k]
11
- return !!v && (!Array.isArray(v) || v.length > 0)
12
- })
13
- }
2
+ import { callVerb } from '../verb-client.js'
3
+ import { getProvidersForCapability } from '../utils/provider-resolver.js'
14
4
 
15
5
  export const searchJobsName = 'search_jobs'
16
6
 
@@ -59,22 +49,5 @@ export const searchJobsSchema = {
59
49
  }
60
50
 
61
51
  export async function searchJobsHandler(input: Record<string, unknown>) {
62
- const { use_providers: rawUseProviders, ...restInput } = input
63
- if (_isFilterSet(restInput, _CAREER_SITE_ONLY) && _isFilterSet(restInput, _LINKEDIN_ONLY)) {
64
- const err = {
65
- error:
66
- 'Contradictory filters: ATS/domain filters (ats_slugs, company_domains) are Career Site only, ' +
67
- 'while seniority/industry/employee filters are LinkedIn only. Remove one set.',
68
- }
69
- return { content: [{ type: 'text' as const, text: JSON.stringify(err) }], isError: true }
70
- }
71
- const resolved = resolvePreferredProviders('search_jobs', restInput, rawUseProviders)
72
- if (!resolved.ok) {
73
- return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error) }], isError: true }
74
- }
75
- const result = await executeWithFallback('search_jobs', restInput, { providers: resolved.providers, matchedFrom: resolved.matchedFrom })
76
- if (isExecutionError(result)) {
77
- return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], isError: true }
78
- }
79
- return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }
52
+ return callVerb('/jobs/search', input)
80
53
  }
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod'
2
- import { executeWithFallback, isExecutionError } from '../executor.js'
3
- import { resolvePreferredProviders, getProvidersForCapability } from '../utils/provider-resolver.js'
2
+ import { callVerb } from '../verb-client.js'
3
+ import { getProvidersForCapability } from '../utils/provider-resolver.js'
4
4
 
5
5
  export const searchPlacesName = 'search_places'
6
6
 
@@ -45,75 +45,8 @@ export const searchPlacesSchema = {
45
45
  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('search_places').join(', ')}. Provider names are matched fuzzily, so minor typos are tolerated.`),
46
46
  }
47
47
 
48
- function asNumber(v: unknown): number | undefined {
49
- return typeof v === 'number' && Number.isFinite(v) ? v : undefined
50
- }
51
-
52
- // Google Maps Scraper does not honor min/max rating or review-count filters
53
- // upstream, so the params would otherwise be silently dropped. Filter client-side
54
- // against the `totalScore` (rating) and `reviewsCount` fields on each place.
55
- // Applied to all routes for consistency; on Openmart this is a no-op since the
56
- // filters were already enforced upstream and Openmart returns `{ data: [...] }`
57
- // rather than `{ places: [...] }`.
58
- // Pure: returns a new object instead of mutating its argument.
59
- export function applyPlaceFilters(
60
- data: unknown,
61
- filters: { minRating?: number; maxRating?: number; minReviews?: number; maxReviews?: number },
62
- ): unknown {
63
- const { minRating, maxRating, minReviews, maxReviews } = filters
64
- if (minRating === undefined && maxRating === undefined && minReviews === undefined && maxReviews === undefined) return data
65
- if (!data || typeof data !== 'object') return data
66
- const d = data as Record<string, unknown>
67
- const places = d.places
68
- if (!Array.isArray(places)) return data
69
- const filtered = places.filter((place) => {
70
- if (!place || typeof place !== 'object') return false
71
- const p = place as Record<string, unknown>
72
- const rating = asNumber(p.totalScore)
73
- const reviews = asNumber(p.reviewsCount)
74
- if (minRating !== undefined && (rating === undefined || rating < minRating)) return false
75
- if (maxRating !== undefined && (rating === undefined || rating > maxRating)) return false
76
- if (minReviews !== undefined && (reviews === undefined || reviews < minReviews)) return false
77
- if (maxReviews !== undefined && (reviews === undefined || reviews > maxReviews)) return false
78
- return true
79
- })
80
- return { ...d, places: filtered }
81
- }
82
-
83
48
  export async function searchPlacesHandler(input: Record<string, unknown>) {
84
- const { use_providers: rawUseProviders, ...restInput } = input
85
- const resolved = resolvePreferredProviders('search_places', restInput, rawUseProviders)
86
- if (!resolved.ok) {
87
- return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error) }], isError: true }
88
- }
89
- const result = await executeWithFallback('search_places', restInput, { providers: resolved.providers, matchedFrom: resolved.matchedFrom })
90
- if (isExecutionError(result)) {
91
- return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], isError: true }
92
- }
93
- const filters = {
94
- minRating: asNumber(restInput.min_overall_rating),
95
- maxRating: asNumber(restInput.max_overall_rating),
96
- minReviews: asNumber(restInput.min_total_reviews),
97
- maxReviews: asNumber(restInput.max_total_reviews),
98
- }
99
- const scraped = placesCount(result.data)
100
- result.data = applyPlaceFilters(result.data, filters)
101
- const matched = placesCount(result.data)
102
- // Google Maps bills per place scraped upstream (what ColdIQ pays the provider),
103
- // but rating/review filters are applied here client-side. When the filter trims
104
- // the set, make the gap explicit so the credit charge isn't surprising.
105
- if (scraped !== undefined && matched !== undefined && matched < scraped) {
106
- ;(result._meta as Record<string, unknown>).filtered = {
107
- scraped,
108
- matched,
109
- note: 'Rating/review filters are applied client-side. You are billed per place scraped upstream (scraped), not per matched place.',
110
- }
111
- }
112
- return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }
113
- }
114
-
115
- function placesCount(data: unknown): number | undefined {
116
- if (!data || typeof data !== 'object') return undefined
117
- const places = (data as Record<string, unknown>).places
118
- return Array.isArray(places) ? places.length : undefined
49
+ // The MCP `provider` (openmart|google_maps) is a place-source pin; the verb has
50
+ // no such input field, so route it through the envelope's provider list.
51
+ return callVerb('/places/search', input, { providerFromField: 'provider' })
119
52
  }
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod'
2
- import { executeWithFallback, isExecutionError } from '../executor.js'
3
- import { resolvePreferredProviders, getProvidersForCapability } from '../utils/provider-resolver.js'
2
+ import { callVerb } from '../verb-client.js'
3
+ import { getProvidersForCapability } from '../utils/provider-resolver.js'
4
4
 
5
5
  export const searchRedditName = 'search_reddit'
6
6
 
@@ -34,14 +34,5 @@ export const searchRedditSchema = {
34
34
  }
35
35
 
36
36
  export async function searchRedditHandler(input: Record<string, unknown>) {
37
- const { use_providers: rawUseProviders, ...restInput } = input
38
- const resolved = resolvePreferredProviders('search_reddit', restInput, rawUseProviders)
39
- if (!resolved.ok) {
40
- return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error) }], isError: true }
41
- }
42
- const result = await executeWithFallback('search_reddit', restInput, { providers: resolved.providers, matchedFrom: resolved.matchedFrom })
43
- if (isExecutionError(result)) {
44
- return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], isError: true }
45
- }
46
- return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }
37
+ return callVerb('/reddit/search', input)
47
38
  }
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod'
2
- import { executeWithFallback, isExecutionError } from '../executor.js'
3
- import { resolvePreferredProviders, getProvidersForCapability } from '../utils/provider-resolver.js'
2
+ import { callVerb } from '../verb-client.js'
3
+ import { getProvidersForCapability } from '../utils/provider-resolver.js'
4
4
 
5
5
  export const searchSeoName = 'search_seo'
6
6
 
@@ -53,14 +53,5 @@ export const searchSeoSchema = {
53
53
  }
54
54
 
55
55
  export async function searchSeoHandler(input: Record<string, unknown>) {
56
- const { use_providers: rawUseProviders, ...restInput } = input
57
- const resolved = resolvePreferredProviders('search_seo', restInput, rawUseProviders)
58
- if (!resolved.ok) {
59
- return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error) }], isError: true }
60
- }
61
- const result = await executeWithFallback('search_seo', restInput, { providers: resolved.providers, matchedFrom: resolved.matchedFrom })
62
- if (isExecutionError(result)) {
63
- return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], isError: true }
64
- }
65
- return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }
56
+ return callVerb('/seo/search', input)
66
57
  }
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod'
2
- import { executeWithFallback, isExecutionError } from '../executor.js'
3
- import { resolvePreferredProviders, getProvidersForCapability } from '../utils/provider-resolver.js'
2
+ import { callVerb } from '../verb-client.js'
3
+ import { getProvidersForCapability } from '../utils/provider-resolver.js'
4
4
 
5
5
  export const searchWebName = 'search_web'
6
6
 
@@ -19,14 +19,5 @@ export const searchWebSchema = {
19
19
  }
20
20
 
21
21
  export async function searchWebHandler(input: Record<string, unknown>) {
22
- const { use_providers: rawUseProviders, ...restInput } = input
23
- const resolved = resolvePreferredProviders('search_web', restInput, rawUseProviders)
24
- if (!resolved.ok) {
25
- return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error) }], isError: true }
26
- }
27
- const result = await executeWithFallback('search_web', restInput, { providers: resolved.providers, matchedFrom: resolved.matchedFrom })
28
- if (isExecutionError(result)) {
29
- return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], isError: true }
30
- }
31
- return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }
22
+ return callVerb('/web/search', input)
32
23
  }
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod'
2
- import { executeWithFallback, isExecutionError } from '../executor.js'
3
- import { resolvePreferredProviders, getProvidersForCapability } from '../utils/provider-resolver.js'
2
+ import { callVerb } from '../verb-client.js'
3
+ import { getProvidersForCapability } from '../utils/provider-resolver.js'
4
4
 
5
5
  export const verifyEmailName = 'verify_email'
6
6
 
@@ -12,79 +12,6 @@ export const verifyEmailSchema = {
12
12
  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('verify_email').join(', ')}. Provider names are matched fuzzily, so minor typos are tolerated.`),
13
13
  }
14
14
 
15
- type NormalizedStatus = 'valid' | 'invalid' | 'catch_all' | 'risky' | 'disposable' | 'unknown'
16
-
17
- const STATUS_ALIASES: Record<string, NormalizedStatus> = {
18
- valid: 'valid',
19
- deliverable: 'valid',
20
- ok: 'valid',
21
- invalid: 'invalid',
22
- undeliverable: 'invalid',
23
- bounce: 'invalid',
24
- bounced: 'invalid',
25
- catch_all: 'catch_all',
26
- catchall: 'catch_all',
27
- 'catch-all': 'catch_all',
28
- accept_all: 'catch_all',
29
- acceptall: 'catch_all',
30
- risky: 'risky',
31
- disposable: 'disposable',
32
- unknown: 'unknown',
33
- }
34
-
35
- function normalizeStatus(raw: unknown): NormalizedStatus {
36
- if (typeof raw !== 'string' || raw.length === 0) return 'unknown'
37
- return STATUS_ALIASES[raw.toLowerCase()] ?? 'unknown'
38
- }
39
-
40
- // Provider responses are heterogeneous. We surface a single `status` field on the
41
- // payload while leaving the raw upstream fields untouched so callers that already
42
- // depend on them keep working.
43
- export function addNormalizedStatus(data: unknown, providerId: string): unknown {
44
- if (!data || typeof data !== 'object' || Array.isArray(data)) return data
45
- const d = { ...(data as Record<string, unknown>) }
46
-
47
- // If the response already carries a status string, normalize it and stop.
48
- if (typeof d.status === 'string') {
49
- d.status = normalizeStatus(d.status)
50
- return d
51
- }
52
-
53
- if (providerId === 'findymail') {
54
- // Findymail's /api/verify returns { email, verified: boolean, provider } — no
55
- // catch_all/unknown distinction. Map the boolean to valid/invalid only.
56
- d.status = d.verified === true ? 'valid' : 'invalid'
57
- return d
58
- }
59
-
60
- if (providerId === 'icypeas') {
61
- const item = d.item as Record<string, unknown> | undefined
62
- d.status = normalizeStatus(item?.status)
63
- return d
64
- }
65
-
66
- if (providerId === 'linkupapi-validate') {
67
- // LinkupAPI shapes vary; accept any of the common signal fields.
68
- const truthy = d.verified === true || d.valid === true || d.result === 'valid'
69
- d.status = truthy ? 'valid' : normalizeStatus(d.result ?? d.valid ?? d.verified)
70
- return d
71
- }
72
-
73
- // Unknown provider — leave the original payload alone and flag the gap.
74
- d.status = 'unknown'
75
- return d
76
- }
77
-
78
15
  export async function verifyEmailHandler(input: Record<string, unknown>) {
79
- const { use_providers: rawUseProviders, ...restInput } = input
80
- const resolved = resolvePreferredProviders('verify_email', restInput, rawUseProviders)
81
- if (!resolved.ok) {
82
- return { content: [{ type: 'text' as const, text: JSON.stringify(resolved.error) }], isError: true }
83
- }
84
- const result = await executeWithFallback('verify_email', restInput, { providers: resolved.providers, matchedFrom: resolved.matchedFrom })
85
- if (isExecutionError(result)) {
86
- return { content: [{ type: 'text' as const, text: JSON.stringify(result) }], isError: true }
87
- }
88
- result.data = addNormalizedStatus(result.data, result._meta.provider)
89
- return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }
16
+ return callVerb('/email/verify', input)
90
17
  }
@@ -0,0 +1,93 @@
1
+ import { callApi } from './client.js'
2
+
3
+ /**
4
+ * Thin client for the deployed /v1 GTM verb endpoints.
5
+ *
6
+ * Each verb takes an envelope `{ input: {...filters}, provider?, fields?, reveal? }`
7
+ * (or `{ inputs: [...] }` for bulk) and returns `{ data, _meta }` (or `{ results }`
8
+ * for bulk, or `{ error, providers_tried }` on failure with a 4xx/5xx status).
9
+ *
10
+ * The MCP tool schemas expose `use_providers` (the ordered provider list) and the
11
+ * optional `fields` / `reveal` knobs alongside the verb's filter fields. This helper
12
+ * splits those out and maps everything else into the verb envelope, so each tool
13
+ * handler collapses to a single delegating call.
14
+ *
15
+ * A few MCP tool schemas predate the verb input schemas and need light mapping:
16
+ * - `remap`: rename input keys before building the envelope (e.g. find_phone's
17
+ * `company_domain` → the verb's `domain`).
18
+ * - `providerFromField`: a tool-specific provider-pin field (e.g. search_places'
19
+ * `provider: openmart|google_maps`) → the envelope's provider list.
20
+ * - `reattachIdField`: bulk verbs return `results` positionally with no echo of the
21
+ * per-item correlation id, so re-attach `input[bulkField][i][id]` onto `results[i]`
22
+ * so callers can still match results back.
23
+ */
24
+ export interface CallVerbOptions {
25
+ bulkField?: string
26
+ remap?: Record<string, string>
27
+ providerFromField?: string
28
+ reattachIdField?: string
29
+ }
30
+
31
+ export async function callVerb(
32
+ verbPath: string,
33
+ input: Record<string, unknown>,
34
+ opts?: CallVerbOptions,
35
+ ): Promise<{ content: [{ type: 'text'; text: string }]; isError?: boolean }> {
36
+ // 1. Rename any schema-drift field names before splitting the envelope.
37
+ let work: Record<string, unknown> = input
38
+ if (opts?.remap) {
39
+ work = {}
40
+ for (const [k, v] of Object.entries(input)) {
41
+ work[opts.remap[k] ?? k] = v
42
+ }
43
+ }
44
+
45
+ const { use_providers, fields, reveal, ...rest } = work
46
+
47
+ // 2. Resolve the provider pin: explicit use_providers wins; otherwise a
48
+ // tool-specific provider field (e.g. search_places `provider`).
49
+ let providerPin: string[] | undefined =
50
+ Array.isArray(use_providers) && use_providers.length ? (use_providers as string[]) : undefined
51
+ if (opts?.providerFromField && rest[opts.providerFromField] !== undefined) {
52
+ const v = rest[opts.providerFromField]
53
+ delete rest[opts.providerFromField]
54
+ if (!providerPin && typeof v === 'string' && v) providerPin = [v]
55
+ }
56
+
57
+ // 3. Build the envelope (single vs bulk).
58
+ let envelope: Record<string, unknown>
59
+ let list: Array<Record<string, unknown>> | undefined
60
+ if (opts?.bulkField) {
61
+ const { [opts.bulkField]: l, ...siblings } = rest
62
+ list = Array.isArray(l) ? (l as Array<Record<string, unknown>>) : []
63
+ envelope = { ...siblings, inputs: list }
64
+ } else {
65
+ envelope = { input: rest }
66
+ }
67
+ if (providerPin) envelope.provider = providerPin
68
+ if (fields !== undefined) envelope.fields = fields
69
+ if (reveal !== undefined) envelope.reveal = reveal
70
+
71
+ const res = await callApi('POST', verbPath, envelope)
72
+
73
+ // 4. Re-attach the per-item correlation id to bulk results (positional).
74
+ let data = res.data
75
+ if (
76
+ opts?.reattachIdField && opts?.bulkField && list &&
77
+ data && typeof data === 'object' && Array.isArray((data as { results?: unknown }).results)
78
+ ) {
79
+ const idField = opts.reattachIdField
80
+ const results = (data as { results: unknown[] }).results.map((r, i) => {
81
+ const id = list?.[i]?.[idField]
82
+ return id !== undefined && r && typeof r === 'object' && !Array.isArray(r)
83
+ ? { [idField]: id, ...(r as Record<string, unknown>) }
84
+ : r
85
+ })
86
+ data = { ...(data as Record<string, unknown>), results }
87
+ }
88
+
89
+ return {
90
+ content: [{ type: 'text', text: JSON.stringify(data) }],
91
+ isError: !res.ok,
92
+ }
93
+ }