@coldiq/mcp 0.2.8 → 0.3.1

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 (53) hide show
  1. package/dist/index.js +4 -0
  2. package/dist/index.js.map +1 -1
  3. package/dist/registry.d.ts +1 -1
  4. package/dist/registry.d.ts.map +1 -1
  5. package/dist/registry.js +102 -16
  6. package/dist/registry.js.map +1 -1
  7. package/dist/tools/extract-post-engagement.d.ts +21 -0
  8. package/dist/tools/extract-post-engagement.d.ts.map +1 -0
  9. package/dist/tools/extract-post-engagement.js +117 -0
  10. package/dist/tools/extract-post-engagement.js.map +1 -0
  11. package/dist/tools/find-influencers.d.ts +1 -1
  12. package/dist/tools/find-influencers.d.ts.map +1 -1
  13. package/dist/tools/find-influencers.js +2 -1
  14. package/dist/tools/find-influencers.js.map +1 -1
  15. package/dist/tools/find-signals.d.ts.map +1 -1
  16. package/dist/tools/find-signals.js +27 -10
  17. package/dist/tools/find-signals.js.map +1 -1
  18. package/dist/tools/get-place-reviews.d.ts +24 -0
  19. package/dist/tools/get-place-reviews.d.ts.map +1 -0
  20. package/dist/tools/get-place-reviews.js +46 -0
  21. package/dist/tools/get-place-reviews.js.map +1 -0
  22. package/dist/tools/search-ads.d.ts +1 -1
  23. package/dist/tools/search-ads.d.ts.map +1 -1
  24. package/dist/tools/search-ads.js +1 -1
  25. package/dist/tools/search-ads.js.map +1 -1
  26. package/dist/tools/search-companies.d.ts.map +1 -1
  27. package/dist/tools/search-companies.js +8 -1
  28. package/dist/tools/search-companies.js.map +1 -1
  29. package/dist/tools/search-places.d.ts +1 -1
  30. package/dist/tools/search-places.d.ts.map +1 -1
  31. package/dist/tools/search-places.js +23 -3
  32. package/dist/tools/search-places.js.map +1 -1
  33. package/dist/tools/search-reddit.js +1 -1
  34. package/dist/tools/search-reddit.js.map +1 -1
  35. package/package.json +1 -1
  36. package/src/index.ts +16 -0
  37. package/src/registry.ts +93 -5
  38. package/src/tools/extract-post-engagement.ts +135 -0
  39. package/src/tools/find-influencers.ts +2 -1
  40. package/src/tools/find-signals.ts +28 -11
  41. package/src/tools/get-place-reviews.ts +50 -0
  42. package/src/tools/search-ads.ts +1 -1
  43. package/src/tools/search-companies.ts +7 -1
  44. package/src/tools/search-places.ts +22 -3
  45. package/src/tools/search-reddit.ts +1 -1
  46. package/tests/registry-find-signals.test.ts +66 -0
  47. package/tests/registry-search-companies.test.ts +16 -0
  48. package/tests/registry.test.ts +3 -3
  49. package/tests/tools/extract-post-engagement.test.ts +76 -0
  50. package/tests/tools/find-signals.test.ts +5 -2
  51. package/tests/tools/get-place-reviews.test.ts +73 -0
  52. package/tests/tools/search-companies.test.ts +19 -1
  53. package/tests/tools/search-reddit.test.ts +69 -0
@@ -309,6 +309,72 @@ describe('signalbase-job-change', () => {
309
309
  })
310
310
  })
311
311
 
312
+ // ---------------------------------------------------------------------------
313
+ // theirstack-intent-discovery
314
+ // ---------------------------------------------------------------------------
315
+
316
+ describe('theirstack-intent-discovery', () => {
317
+ const p = () => get('theirstack-intent-discovery')
318
+
319
+ it('routes to the company search endpoint', () => {
320
+ expect(p().endpoint).toBe('/theirstack/companies/search')
321
+ expect(p().method).toBe('POST')
322
+ })
323
+
324
+ it('isApplicable: true for intent with topics and no companies/domains', () => {
325
+ expect(p().isApplicable!({ signal_type: 'intent', topics: ['sales-automation'] })).toBe(true)
326
+ })
327
+
328
+ it('isApplicable: false when companies present (verify mode handles that)', () => {
329
+ expect(p().isApplicable!({ signal_type: 'intent', topics: ['sales-automation'], companies: ['ColdIQ'] })).toBe(false)
330
+ })
331
+
332
+ it('isApplicable: false when domains present', () => {
333
+ expect(p().isApplicable!({ signal_type: 'intent', topics: ['sales-automation'], domains: ['coldiq.com'] })).toBe(false)
334
+ })
335
+
336
+ it('isApplicable: false when no topics', () => {
337
+ expect(p().isApplicable!({ signal_type: 'intent' })).toBe(false)
338
+ })
339
+
340
+ it('isApplicable: false for other signal types', () => {
341
+ expect(p().isApplicable!({ signal_type: 'funding', topics: ['x'] })).toBe(false)
342
+ })
343
+
344
+ it('mapParams forwards topics as company_keyword_slug_or', () => {
345
+ const result = p().mapParams({ signal_type: 'intent', topics: ['sales-automation', 'lead-generation'], limit: 20 })
346
+ const body = result.body as Record<string, unknown>
347
+ expect(body.company_keyword_slug_or).toEqual(['sales-automation', 'lead-generation'])
348
+ expect(body.limit).toBe(20)
349
+ expect(body.include_total_results).toBe(true)
350
+ })
351
+
352
+ it('mapParams forwards industries and countries when present', () => {
353
+ const result = p().mapParams({
354
+ signal_type: 'intent',
355
+ topics: ['sales-automation'],
356
+ industries: ['Software'],
357
+ countries: ['US', 'GB'],
358
+ })
359
+ const body = result.body as Record<string, unknown>
360
+ expect(body.industry_or).toEqual(['Software'])
361
+ expect(body.company_country_code_or).toEqual(['US', 'GB'])
362
+ })
363
+
364
+ it('mapParams caps limit at 100', () => {
365
+ const result = p().mapParams({ signal_type: 'intent', topics: ['x'], limit: 999 })
366
+ expect((result.body as Record<string, unknown>).limit).toBe(100)
367
+ })
368
+
369
+ it('hasResult: true when data non-empty', () => {
370
+ expect(p().hasResult({ data: [{ name: 'ColdIQ' }] })).toBe(true)
371
+ })
372
+
373
+ it('hasResult: false on empty data', () => {
374
+ expect(p().hasResult({ data: [] })).toBe(false)
375
+ })
376
+ })
377
+
312
378
  // ---------------------------------------------------------------------------
313
379
  // theirstack-buying-intents
314
380
  // ---------------------------------------------------------------------------
@@ -176,6 +176,22 @@ describe('ai-ark-companies', () => {
176
176
  // theirstack — buying-intent keyword discovery via company_keyword_slug_or
177
177
  // ---------------------------------------------------------------------------
178
178
 
179
+ describe('apollo search_companies', () => {
180
+ const p = () => get('apollo')
181
+
182
+ it('mapParams sends `limit` (not per_page) so the backend auto-paginates', () => {
183
+ const body = p().mapParams({ keywords: ['SaaS'], industries: ['fintech'], countries: ['US'], limit: 250 }).body as Record<string, unknown>
184
+ expect(body.limit).toBe(250)
185
+ expect(body.per_page).toBeUndefined()
186
+ expect(body.q_organization_keyword_tags).toEqual(['SaaS', 'fintech'])
187
+ })
188
+
189
+ it('mapParams defaults limit to 25 when absent', () => {
190
+ const body = p().mapParams({ keywords: ['SaaS'] }).body as Record<string, unknown>
191
+ expect(body.limit).toBe(25)
192
+ })
193
+ })
194
+
179
195
  describe('theirstack search_companies', () => {
180
196
  const p = () => get('theirstack')
181
197
 
@@ -99,7 +99,7 @@ describe('registry', () => {
99
99
  q_organization_keyword_tags: ['SaaS'],
100
100
  organization_locations: ['United States'],
101
101
  organization_num_employees_ranges: ['10,200'],
102
- per_page: 25,
102
+ limit: 25,
103
103
  })
104
104
  })
105
105
 
@@ -119,7 +119,7 @@ describe('registry', () => {
119
119
  q_organization_keyword_tags: ['healthcare', 'Hospital & Health Care', 'Medical Devices'],
120
120
  organization_locations: ['Paris, France', 'France'],
121
121
  organization_num_employees_ranges: ['50,100'],
122
- per_page: 25,
122
+ limit: 25,
123
123
  })
124
124
  })
125
125
 
@@ -131,7 +131,7 @@ describe('registry', () => {
131
131
  q_organization_keyword_tags: undefined,
132
132
  organization_locations: undefined,
133
133
  organization_num_employees_ranges: ['50,100'],
134
- per_page: 25,
134
+ limit: 25,
135
135
  })
136
136
  })
137
137
 
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { initClient } from '../../src/client.js'
3
+ import { extractPostEngagementHandler } from '../../src/tools/extract-post-engagement.js'
4
+
5
+ const POST_URL = 'https://www.linkedin.com/feed/update/urn:li:activity:7234567890123456789'
6
+
7
+ function res(body: unknown, status = 200, headers?: Record<string, string>) {
8
+ return new Response(JSON.stringify(body), { status, headers })
9
+ }
10
+
11
+ describe('extract_post_engagement handler', () => {
12
+ const originalFetch = globalThis.fetch
13
+
14
+ beforeEach(() => {
15
+ initClient('http://test-api.local', 'test-key')
16
+ vi.stubEnv('COLDIQ_ENGAGEMENT_POLL_MS', '1')
17
+ vi.stubEnv('COLDIQ_ENGAGEMENT_TIMEOUT_MS', '5000')
18
+ })
19
+
20
+ afterEach(() => {
21
+ globalThis.fetch = originalFetch
22
+ vi.unstubAllEnvs()
23
+ })
24
+
25
+ it('runs create → poll → fetch contacts and returns the people', async () => {
26
+ const people = [{ name: 'Alex Vacca', profile_url: 'https://www.linkedin.com/in/alexvacca' }]
27
+ globalThis.fetch = vi.fn(async (url: string | URL) => {
28
+ const u = url.toString()
29
+ if (u.includes('/jungler/workbooks/wb_1/contacts')) return res(people)
30
+ if (u.includes('/jungler/tasks/task_1/status')) return res({ task_id: 'task_1', status: 'success', workbook_id: 'wb_1' })
31
+ if (u.includes('/jungler/workbooks')) return res({ task_id: 'task_1', status: 'PENDING' }, 200, { 'x-coldiq-credits-charged': '10', 'x-coldiq-credits-remaining': '90' })
32
+ throw new Error(`unexpected url ${u}`)
33
+ }) as typeof fetch
34
+
35
+ const result = await extractPostEngagementHandler({ post_url: POST_URL, type: 'both' })
36
+ const parsed = JSON.parse(result.content[0].text)
37
+ expect(result.isError).toBeUndefined()
38
+ expect(parsed.data.people).toEqual(people)
39
+ expect(parsed.data.type).toBe('both')
40
+ expect(parsed._meta.credits_charged).toBe(10)
41
+ })
42
+
43
+ it('passes activity_filter=commenters when type=comments', async () => {
44
+ let contactsUrl = ''
45
+ globalThis.fetch = vi.fn(async (url: string | URL) => {
46
+ const u = url.toString()
47
+ if (u.includes('/jungler/workbooks/wb_1/contacts')) { contactsUrl = u; return res([]) }
48
+ if (u.includes('/jungler/tasks/task_1/status')) return res({ task_id: 'task_1', status: 'success', workbook_id: 'wb_1' })
49
+ if (u.includes('/jungler/workbooks')) return res({ task_id: 'task_1', status: 'PENDING' })
50
+ throw new Error(`unexpected url ${u}`)
51
+ }) as typeof fetch
52
+
53
+ await extractPostEngagementHandler({ post_url: POST_URL, type: 'comments' })
54
+ expect(contactsUrl).toContain('activity_filter=commenters')
55
+ })
56
+
57
+ it('returns an error when the task fails upstream', async () => {
58
+ globalThis.fetch = vi.fn(async (url: string | URL) => {
59
+ const u = url.toString()
60
+ if (u.includes('/jungler/tasks/task_1/status')) return res({ task_id: 'task_1', status: 'failure' })
61
+ if (u.includes('/jungler/workbooks')) return res({ task_id: 'task_1', status: 'PENDING' })
62
+ throw new Error(`unexpected url ${u}`)
63
+ }) as typeof fetch
64
+
65
+ const result = await extractPostEngagementHandler({ post_url: POST_URL, type: 'both' })
66
+ expect(result.isError).toBe(true)
67
+ expect(JSON.parse(result.content[0].text).error).toMatch(/extraction failed/i)
68
+ })
69
+
70
+ it('surfaces the create error when the job cannot start', async () => {
71
+ globalThis.fetch = vi.fn(async () => res({ error: 'Invalid LinkedIn post URL' }, 400)) as typeof fetch
72
+ const result = await extractPostEngagementHandler({ post_url: POST_URL, type: 'both' })
73
+ expect(result.isError).toBe(true)
74
+ expect(JSON.parse(result.content[0].text).error).toMatch(/Invalid LinkedIn post URL/)
75
+ })
76
+ })
@@ -139,14 +139,17 @@ describe('find_signals handler — hiring industries post-filter', () => {
139
139
  expect(body.data.data.map((r: { id: string }) => r.id)).toEqual(['a', 'c'])
140
140
  })
141
141
 
142
- it('returns empty array when no row matches any industry', async () => {
142
+ it('returns unfiltered rows with a note when no row matches any industry', async () => {
143
143
  vi.mocked(mockedExecute).mockResolvedValueOnce({
144
144
  data: { success: true, data: [...hiringRows] },
145
145
  _meta: { provider: 'signalbase-hiring', latencyMs: 1 },
146
146
  })
147
147
  const result = await findSignalsHandler({ signal_type: 'hiring', industries: ['Aerospace'], limit: 25 })
148
148
  const body = JSON.parse(result.content[0].text)
149
- expect(body.data.data).toEqual([])
149
+ // Non-destructive: keep all rows rather than return a misleading empty set,
150
+ // and flag that the industry filter matched nothing.
151
+ expect(body.data.data).toHaveLength(5)
152
+ expect(body.data._industry_filter).toMatch(/UNFILTERED/)
150
153
  })
151
154
 
152
155
  it('leaves rows untouched when industries is absent', async () => {
@@ -0,0 +1,73 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { initClient } from '../../src/client.js'
3
+ import { getPlaceReviewsHandler } from '../../src/tools/get-place-reviews.js'
4
+ import { getProviders } from '../../src/registry.js'
5
+
6
+ const PLACE_URL = 'https://www.google.com/maps/place/ColdIQ'
7
+
8
+ describe('get_place_reviews handler', () => {
9
+ const originalFetch = globalThis.fetch
10
+
11
+ beforeEach(() => {
12
+ initClient('http://test-api.local', 'test-key')
13
+ })
14
+
15
+ afterEach(() => {
16
+ globalThis.fetch = originalFetch
17
+ })
18
+
19
+ function withFastPolling<T>(fn: () => Promise<T>): Promise<T> {
20
+ const p = getProviders('get_place_reviews').find((x) => x.id === 'google_maps_reviews')!
21
+ const orig = { t: p.async!.timeoutMs, i: p.async!.pollIntervalMs }
22
+ p.async!.timeoutMs = 500
23
+ p.async!.pollIntervalMs = 20
24
+ return fn().finally(() => {
25
+ p.async!.timeoutMs = orig.t
26
+ p.async!.pollIntervalMs = orig.i
27
+ })
28
+ }
29
+
30
+ it('submits the reviews job and returns reviews when done', async () => {
31
+ const reviews = [{ text: 'Great service', stars: 5 }]
32
+ globalThis.fetch = vi.fn(async (url: string | URL) => {
33
+ const u = url.toString()
34
+ if (u.includes('/google-maps/reviews/rev-1')) {
35
+ return new Response(JSON.stringify({ jobId: 'rev-1', status: 'done', reviews }), { status: 200 })
36
+ }
37
+ if (u.includes('/google-maps/reviews')) {
38
+ return new Response(JSON.stringify({ jobId: 'rev-1', status: 'processing' }), { status: 200 })
39
+ }
40
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
41
+ }) as typeof fetch
42
+
43
+ const result = await withFastPolling(() =>
44
+ getPlaceReviewsHandler({ place_urls: [PLACE_URL], max_reviews: 10, sort: 'lowestRanking' }),
45
+ )
46
+ expect(result.isError).toBeFalsy()
47
+ const parsed = JSON.parse(result.content[0].text)
48
+ expect(parsed._meta.provider).toBe('google_maps_reviews')
49
+ expect(parsed.data.reviews).toEqual(reviews)
50
+ })
51
+
52
+ it('forwards place_urls, max_reviews and sort to the upstream body', async () => {
53
+ let submitBody: Record<string, unknown> = {}
54
+ globalThis.fetch = vi.fn(async (url: string | URL, init?: RequestInit) => {
55
+ const u = url.toString()
56
+ if (u.includes('/google-maps/reviews/rev-1')) {
57
+ return new Response(JSON.stringify({ jobId: 'rev-1', status: 'done', reviews: [] }), { status: 200 })
58
+ }
59
+ if (u.includes('/google-maps/reviews')) {
60
+ submitBody = JSON.parse(init!.body as string)
61
+ return new Response(JSON.stringify({ jobId: 'rev-1', status: 'processing' }), { status: 200 })
62
+ }
63
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
64
+ }) as typeof fetch
65
+
66
+ await withFastPolling(() =>
67
+ getPlaceReviewsHandler({ place_urls: [PLACE_URL], max_reviews: 25, sort: 'newest' }),
68
+ )
69
+ expect(submitBody.startUrls).toEqual([{ url: PLACE_URL }])
70
+ expect(submitBody.maxReviews).toBe(25)
71
+ expect(submitBody.reviewsSort).toBe('newest')
72
+ })
73
+ })
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
2
  import { initClient } from '../../src/client.js'
3
- import { searchCompaniesHandler } from '../../src/tools/search-companies.js'
3
+ import { searchCompaniesHandler, searchCompaniesSchema } from '../../src/tools/search-companies.js'
4
4
 
5
5
  describe('search_companies handler', () => {
6
6
  const originalFetch = globalThis.fetch
@@ -56,6 +56,24 @@ describe('search_companies handler', () => {
56
56
  expect(parsed.providers_tried.length).toBeGreaterThan(0)
57
57
  })
58
58
 
59
+ it('surfaces the upstream match count as _meta.total_entries (TAM signal)', async () => {
60
+ globalThis.fetch = vi.fn(async () =>
61
+ new Response(JSON.stringify({ organizations: [{ name: 'ColdIQ' }], pagination: { total_entries: 8371 } }), { status: 200 })
62
+ ) as typeof fetch
63
+
64
+ const result = await searchCompaniesHandler({ keywords: ['SaaS'], limit: 250, use_providers: ['apollo'] })
65
+
66
+ expect(result.isError).toBeUndefined()
67
+ const parsed = JSON.parse(result.content[0].text)
68
+ expect(parsed._meta.provider).toBe('apollo')
69
+ expect(parsed._meta.total_entries).toBe(8371)
70
+ })
71
+
72
+ it('limit accepts up to 500 and rejects above', () => {
73
+ expect(searchCompaniesSchema.limit.safeParse(500).success).toBe(true)
74
+ expect(searchCompaniesSchema.limit.safeParse(600).success).toBe(false)
75
+ })
76
+
59
77
  describe('use_providers', () => {
60
78
  it('Scenario A — pinned provider succeeds, skips others', async () => {
61
79
  let callCount = 0
@@ -122,4 +122,73 @@ describe('search_reddit handler', () => {
122
122
  reddit.async!.pollIntervalMs = orig.i
123
123
  }
124
124
  })
125
+
126
+ it('rewrites a bare subreddit URL + query into an in-subreddit search URL', async () => {
127
+ let capturedBody: Record<string, unknown> | null = null
128
+ globalThis.fetch = vi.fn(async (url: string | URL | Request, opts?: RequestInit) => {
129
+ const u = url.toString()
130
+ if (u.includes('/reddit/scrape') && !u.includes('/scrape/')) {
131
+ capturedBody = JSON.parse((opts?.body as string) ?? '{}')
132
+ return new Response(JSON.stringify({ jobId: 'r-4' }), { status: 200 })
133
+ }
134
+ if (u.includes('/reddit/scrape/r-4')) {
135
+ return new Response(JSON.stringify({ jobId: 'r-4', status: 'done', items: [{ title: 'lemlist review' }] }), { status: 200 })
136
+ }
137
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
138
+ }) as typeof fetch
139
+
140
+ const { getProviders } = await import('../../src/registry.js')
141
+ const reddit = getProviders('search_reddit').find((p) => p.id === 'reddit')!
142
+ const orig = { t: reddit.async!.timeoutMs, i: reddit.async!.pollIntervalMs }
143
+ try {
144
+ reddit.async!.timeoutMs = 500
145
+ reddit.async!.pollIntervalMs = 20
146
+ await searchRedditHandler({
147
+ start_urls: ['https://www.reddit.com/r/coldemail/'],
148
+ query: 'lemlist',
149
+ time: 'month',
150
+ limit: 5,
151
+ })
152
+ const urls = (capturedBody!.startUrls as Array<{ url: string }>).map((s) => s.url)
153
+ expect(urls[0]).toContain('/r/coldemail/search/')
154
+ expect(urls[0]).toContain('q=lemlist')
155
+ expect(urls[0]).toContain('restrict_sr=1')
156
+ expect(urls[0]).toContain('t=month')
157
+ // Query now lives in the URL — top-level searchQueries is dropped to avoid a global search.
158
+ expect(capturedBody!.searchQueries).toBeUndefined()
159
+ } finally {
160
+ reddit.async!.timeoutMs = orig.t
161
+ reddit.async!.pollIntervalMs = orig.i
162
+ }
163
+ })
164
+
165
+ it('leaves an existing search URL untouched and keeps it as the start url', async () => {
166
+ let capturedBody: Record<string, unknown> | null = null
167
+ const searchUrl = 'https://www.reddit.com/search/?q=lemlist&sort=new'
168
+ globalThis.fetch = vi.fn(async (url: string | URL | Request, opts?: RequestInit) => {
169
+ const u = url.toString()
170
+ if (u.includes('/reddit/scrape') && !u.includes('/scrape/')) {
171
+ capturedBody = JSON.parse((opts?.body as string) ?? '{}')
172
+ return new Response(JSON.stringify({ jobId: 'r-5' }), { status: 200 })
173
+ }
174
+ if (u.includes('/reddit/scrape/r-5')) {
175
+ return new Response(JSON.stringify({ jobId: 'r-5', status: 'done', items: [{ title: 'x' }] }), { status: 200 })
176
+ }
177
+ return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
178
+ }) as typeof fetch
179
+
180
+ const { getProviders } = await import('../../src/registry.js')
181
+ const reddit = getProviders('search_reddit').find((p) => p.id === 'reddit')!
182
+ const orig = { t: reddit.async!.timeoutMs, i: reddit.async!.pollIntervalMs }
183
+ try {
184
+ reddit.async!.timeoutMs = 500
185
+ reddit.async!.pollIntervalMs = 20
186
+ await searchRedditHandler({ start_urls: [searchUrl], query: 'lemlist', limit: 5 })
187
+ const urls = (capturedBody!.startUrls as Array<{ url: string }>).map((s) => s.url)
188
+ expect(urls[0]).toBe(searchUrl)
189
+ } finally {
190
+ reddit.async!.timeoutMs = orig.t
191
+ reddit.async!.pollIntervalMs = orig.i
192
+ }
193
+ })
125
194
  })