@coldiq/mcp 0.5.2 → 0.5.4

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 (33) hide show
  1. package/dist/executor.js +1 -1
  2. package/dist/executor.js.map +1 -1
  3. package/dist/registry.d.ts.map +1 -1
  4. package/dist/registry.js +27 -5
  5. package/dist/registry.js.map +1 -1
  6. package/dist/tools/call-endpoint.d.ts +1 -0
  7. package/dist/tools/call-endpoint.d.ts.map +1 -1
  8. package/dist/tools/call-endpoint.js +3 -1
  9. package/dist/tools/call-endpoint.js.map +1 -1
  10. package/dist/tools/extract-post-engagement.d.ts +0 -7
  11. package/dist/tools/extract-post-engagement.d.ts.map +1 -1
  12. package/dist/tools/extract-post-engagement.js +205 -21
  13. package/dist/tools/extract-post-engagement.js.map +1 -1
  14. package/dist/tools/get-endpoint-details.d.ts +1 -0
  15. package/dist/tools/get-endpoint-details.d.ts.map +1 -1
  16. package/dist/tools/get-endpoint-details.js +3 -1
  17. package/dist/tools/get-endpoint-details.js.map +1 -1
  18. package/dist/tools/search-web.d.ts +1 -1
  19. package/dist/tools/search-web.js +2 -2
  20. package/dist/tools/search-web.js.map +1 -1
  21. package/package.json +1 -1
  22. package/src/executor.ts +1 -1
  23. package/src/registry.ts +25 -5
  24. package/src/tools/call-endpoint.ts +3 -1
  25. package/src/tools/extract-post-engagement.ts +229 -27
  26. package/src/tools/get-endpoint-details.ts +3 -1
  27. package/src/tools/search-web.ts +2 -2
  28. package/tests/registry-enrich-person.test.ts +16 -14
  29. package/tests/registry.test.ts +7 -0
  30. package/tests/search-web-exa-compat.test.ts +11 -0
  31. package/tests/tools/call-endpoint.test.ts +16 -0
  32. package/tests/tools/extract-post-engagement.test.ts +167 -3
  33. package/tests/tools/get-endpoint-details.test.ts +12 -0
@@ -26,9 +26,9 @@ describe('extract_post_engagement handler', () => {
26
26
  const people = [{ name: 'Alex Vacca', profile_url: 'https://www.linkedin.com/in/alexvacca' }]
27
27
  globalThis.fetch = vi.fn(async (url: string | URL) => {
28
28
  const u = url.toString()
29
- if (u.includes('/jungler/workbooks/wb_1/contacts')) return res(people)
29
+ if (u.includes('/jungler/workbooks/wb_1/contacts')) return res({ items: people, total: 1, page: 1, pages: 1 })
30
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' })
31
+ if (u.includes('/jungler/workbooks')) return res({ task_id: 'task_1', status: 'PENDING', workbook_id: 'wb_1' }, 200, { 'x-coldiq-credits-charged': '10', 'x-coldiq-credits-remaining': '90' })
32
32
  throw new Error(`unexpected url ${u}`)
33
33
  }) as typeof fetch
34
34
 
@@ -36,15 +36,80 @@ describe('extract_post_engagement handler', () => {
36
36
  const parsed = JSON.parse(result.content[0].text)
37
37
  expect(result.isError).toBeUndefined()
38
38
  expect(parsed.data.people).toEqual(people)
39
+ expect(parsed.data.count).toBe(1)
39
40
  expect(parsed.data.type).toBe('both')
40
41
  expect(parsed._meta.credits_charged).toBe(10)
41
42
  })
42
43
 
44
+ it('walks every contacts page — a viral post exceeds one page', async () => {
45
+ const pageOf = (n: number) => Array.from({ length: n }, (_, i) => ({ name: `Person ${i}` }))
46
+ const seenPages: string[] = []
47
+ globalThis.fetch = vi.fn(async (url: string | URL) => {
48
+ const u = url.toString()
49
+ if (u.includes('/contacts')) {
50
+ const page = new URL(u).searchParams.get('page') ?? '1'
51
+ seenPages.push(page)
52
+ return res({ items: pageOf(page === '1' ? 500 : 103), total: 603, page: Number(page), pages: 2 })
53
+ }
54
+ if (u.includes('/jungler/tasks/task_1/status')) return res({ task_id: 'task_1', status: 'success', workbook_id: 'wb_1' })
55
+ if (u.includes('/jungler/workbooks')) return res({ task_id: 'task_1', status: 'PENDING', workbook_id: 'wb_1' })
56
+ throw new Error(`unexpected url ${u}`)
57
+ }) as typeof fetch
58
+
59
+ const result = await extractPostEngagementHandler({ post_url: POST_URL, type: 'both' })
60
+ const parsed = JSON.parse(result.content[0].text)
61
+ expect(seenPages).toEqual(['1', '2'])
62
+ expect(parsed.data.count).toBe(603)
63
+ expect(parsed._meta.truncated_from).toBeUndefined()
64
+ })
65
+
66
+ it('drops the post author, who is collected with zero engagement and never engaged', async () => {
67
+ // The workbook always collects the post itself, so its author comes back as a
68
+ // contact with stats 0/0. This tool promises the people who ENGAGED.
69
+ globalThis.fetch = vi.fn(async (url: string | URL) => {
70
+ const u = url.toString()
71
+ if (u.includes('/contacts')) return res({
72
+ items: [
73
+ { name: 'Michel Lieben', stats: { comments: 0, reactions: 0 } },
74
+ { name: 'Jane Smith', stats: { comments: 2, reactions: 1 } },
75
+ { name: 'No Stats Person' },
76
+ ],
77
+ total: 3, page: 1, pages: 1,
78
+ })
79
+ if (u.includes('/jungler/tasks/task_1/status')) return res({ task_id: 'task_1', status: 'success', workbook_id: 'wb_1' })
80
+ if (u.includes('/jungler/workbooks')) return res({ task_id: 'task_1', status: 'PENDING', workbook_id: 'wb_1' })
81
+ throw new Error(`unexpected url ${u}`)
82
+ }) as typeof fetch
83
+
84
+ const parsed = JSON.parse((await extractPostEngagementHandler({ post_url: POST_URL, type: 'both' })).content[0].text)
85
+ // Unknown stats are kept — never drop a contact on ambiguity.
86
+ expect(parsed.data.people.map((p: { name: string }) => p.name)).toEqual(['Jane Smith', 'No Stats Person'])
87
+ })
88
+
89
+ it('reports the task and workbook ids instead of losing the paid run when polling times out', async () => {
90
+ vi.stubEnv('COLDIQ_ENGAGEMENT_TIMEOUT_MS', '30')
91
+ globalThis.fetch = vi.fn(async (url: string | URL) => {
92
+ const u = url.toString()
93
+ // Never terminal — the caller must still be able to collect what they paid for.
94
+ if (u.includes('/jungler/tasks/task_1/status')) return res({ task_id: 'task_1', status: 'started' })
95
+ if (u.includes('/jungler/workbooks')) return res({ task_id: 'task_1', status: 'PENDING', workbook_id: 'wb_1' })
96
+ throw new Error(`unexpected url ${u}`)
97
+ }) as typeof fetch
98
+
99
+ const result = await extractPostEngagementHandler({ post_url: POST_URL, type: 'both' })
100
+ const parsed = JSON.parse(result.content[0].text)
101
+ expect(result.isError).toBe(true)
102
+ expect(parsed.task_id).toBe('task_1')
103
+ expect(parsed.workbook_id).toBe('wb_1')
104
+ expect(parsed.error).toMatch(/still running/i)
105
+ expect(parsed.error).toMatch(/NOT been cancelled/i)
106
+ })
107
+
43
108
  it('passes activity_filter=commenters when type=comments', async () => {
44
109
  let contactsUrl = ''
45
110
  globalThis.fetch = vi.fn(async (url: string | URL) => {
46
111
  const u = url.toString()
47
- if (u.includes('/jungler/workbooks/wb_1/contacts')) { contactsUrl = u; return res([]) }
112
+ if (u.includes('/jungler/workbooks/wb_1/contacts')) { contactsUrl = u; return res({ items: [], total: 0, pages: 0 }) }
48
113
  if (u.includes('/jungler/tasks/task_1/status')) return res({ task_id: 'task_1', status: 'success', workbook_id: 'wb_1' })
49
114
  if (u.includes('/jungler/workbooks')) return res({ task_id: 'task_1', status: 'PENDING' })
50
115
  throw new Error(`unexpected url ${u}`)
@@ -67,6 +132,105 @@ describe('extract_post_engagement handler', () => {
67
132
  expect(JSON.parse(result.content[0].text).error).toMatch(/extraction failed/i)
68
133
  })
69
134
 
135
+ // ── Fallback to the second provider ──────────────────────────────────────
136
+ //
137
+ // Only for failures that are OURS (dead run, our provider account out of
138
+ // credits) — never for a post that genuinely has no engagement, which would
139
+ // buy an empty second result.
140
+ const LIMA_PAYLOAD = {
141
+ items: [{
142
+ status: 'Completed',
143
+ data: {
144
+ post_url: POST_URL,
145
+ commenters: { comments: [{ actor: { name: 'Jane Smith', profile_url: 'https://linkedin.com/in/jane', headline: 'VP Eng' } }] },
146
+ reactors: {
147
+ reactions: [
148
+ { actor: { name: 'Bob Johnson', profile_url: 'https://linkedin.com/in/bob', headline: 'Sales' } },
149
+ // Same person also reacted — must collapse to one contact.
150
+ { actor: { name: 'Jane Smith', profile_url: 'https://linkedin.com/in/jane', headline: 'VP Eng' } },
151
+ ],
152
+ },
153
+ },
154
+ }],
155
+ }
156
+
157
+ function stubExhaustedThenLima(limaOk = true) {
158
+ globalThis.fetch = vi.fn(async (url: string | URL) => {
159
+ const u = url.toString()
160
+ if (u.includes('/limadata/batch/post-engagements')) {
161
+ return limaOk ? res({ batch_id: 99 }, 200, { 'x-coldiq-credits-charged': '35' }) : res({ error: 'lima down' }, 502)
162
+ }
163
+ if (u.includes('/limadata/batch/results')) return res(LIMA_PAYLOAD)
164
+ if (u.includes('/jungler/tasks/task_1/status')) return res({ task_id: 'task_1', status: 'success', credits_exhausted: true })
165
+ if (u.includes('/jungler/workbooks')) return res({ task_id: 'task_1', status: 'PENDING', workbook_id: 'wb_1' })
166
+ throw new Error(`unexpected url ${u}`)
167
+ }) as typeof fetch
168
+ }
169
+
170
+ it('falls back to the second provider when our provider account is out of credits', async () => {
171
+ stubExhaustedThenLima()
172
+ const result = await extractPostEngagementHandler({ post_url: POST_URL, type: 'both' })
173
+ const parsed = JSON.parse(result.content[0].text)
174
+
175
+ expect(result.isError).toBeUndefined()
176
+ expect(parsed._meta.provider).toBe('limadata')
177
+ expect(parsed._meta.credits_charged).toBe(35)
178
+ // Jane commented AND reacted — one contact, both engagement types.
179
+ expect(parsed.data.count).toBe(2)
180
+ const jane = parsed.data.people.find((p: { name: string }) => p.name === 'Jane Smith')
181
+ expect(jane.engagement_types.sort()).toEqual(['COMMENT', 'REACTION'])
182
+ expect(jane.profile_url).toBe('https://linkedin.com/in/jane')
183
+ // The caller must know these records are thinner than the primary's.
184
+ expect(parsed._meta.note).toMatch(/no work email/i)
185
+ })
186
+
187
+ it('honours type=comments when falling back', async () => {
188
+ stubExhaustedThenLima()
189
+ const parsed = JSON.parse((await extractPostEngagementHandler({ post_url: POST_URL, type: 'comments' })).content[0].text)
190
+ expect(parsed.data.people.map((p: { name: string }) => p.name)).toEqual(['Jane Smith'])
191
+ })
192
+
193
+ it('reports the ORIGINAL failure when the fallback also fails', async () => {
194
+ stubExhaustedThenLima(false)
195
+ const result = await extractPostEngagementHandler({ post_url: POST_URL, type: 'both' })
196
+ const parsed = JSON.parse(result.content[0].text)
197
+ expect(result.isError).toBe(true)
198
+ expect(parsed.error).toMatch(/out of credits/i)
199
+ expect(parsed.fallback_error).toBeTruthy()
200
+ })
201
+
202
+ it('does not spend on a fallback when it is switched off', async () => {
203
+ vi.stubEnv('COLDIQ_ENGAGEMENT_FALLBACK', '0')
204
+ let limaCalled = false
205
+ globalThis.fetch = vi.fn(async (url: string | URL) => {
206
+ const u = url.toString()
207
+ if (u.includes('/limadata')) { limaCalled = true; return res({}) }
208
+ if (u.includes('/jungler/tasks/task_1/status')) return res({ task_id: 'task_1', status: 'success', credits_exhausted: true })
209
+ if (u.includes('/jungler/workbooks')) return res({ task_id: 'task_1', status: 'PENDING', workbook_id: 'wb_1' })
210
+ throw new Error(`unexpected url ${u}`)
211
+ }) as typeof fetch
212
+
213
+ const result = await extractPostEngagementHandler({ post_url: POST_URL, type: 'both' })
214
+ expect(result.isError).toBe(true)
215
+ expect(limaCalled).toBe(false)
216
+ })
217
+
218
+ it('does NOT fall back for a genuinely empty post — that would buy a second empty result', async () => {
219
+ let limaCalled = false
220
+ globalThis.fetch = vi.fn(async (url: string | URL) => {
221
+ const u = url.toString()
222
+ if (u.includes('/limadata')) { limaCalled = true; return res({}) }
223
+ if (u.includes('/contacts')) return res({ items: [], total: 0, pages: 0 })
224
+ if (u.includes('/jungler/tasks/task_1/status')) return res({ task_id: 'task_1', status: 'success', workbook_id: 'wb_1' })
225
+ if (u.includes('/jungler/workbooks')) return res({ task_id: 'task_1', status: 'PENDING', workbook_id: 'wb_1' })
226
+ throw new Error(`unexpected url ${u}`)
227
+ }) as typeof fetch
228
+
229
+ const parsed = JSON.parse((await extractPostEngagementHandler({ post_url: POST_URL, type: 'both' })).content[0].text)
230
+ expect(limaCalled).toBe(false)
231
+ expect(parsed.data.count).toBe(0)
232
+ })
233
+
70
234
  it('surfaces the create error when the job cannot start', async () => {
71
235
  globalThis.fetch = vi.fn(async () => res({ error: 'Invalid LinkedIn post URL' }, 400)) as typeof fetch
72
236
  const result = await extractPostEngagementHandler({ post_url: POST_URL, type: 'both' })
@@ -34,4 +34,16 @@ describe('get_endpoint_details handler', () => {
34
34
  expect(parsed.status).toBe(404)
35
35
  expect(parsed.error).toBe('Endpoint not found or details unavailable')
36
36
  })
37
+
38
+ it('forwards the HTTP method for a shared endpoint path', async () => {
39
+ let capturedBody: unknown
40
+ globalThis.fetch = vi.fn(async (_url: string | URL | Request, opts?: RequestInit) => {
41
+ capturedBody = opts?.body ? JSON.parse(opts.body as string) : undefined
42
+ return new Response(JSON.stringify({ path: '/exa/agent/runs', method: 'POST' }), { status: 200 })
43
+ }) as typeof fetch
44
+
45
+ await getEndpointDetailsHandler({ api: 'exa', path: '/exa/agent/runs', method: 'POST' })
46
+
47
+ expect(capturedBody).toEqual({ api: 'exa', path: '/exa/agent/runs', method: 'POST' })
48
+ })
37
49
  })