@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.
- package/dist/executor.js +1 -1
- package/dist/executor.js.map +1 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +27 -5
- package/dist/registry.js.map +1 -1
- package/dist/tools/call-endpoint.d.ts +1 -0
- package/dist/tools/call-endpoint.d.ts.map +1 -1
- package/dist/tools/call-endpoint.js +3 -1
- package/dist/tools/call-endpoint.js.map +1 -1
- package/dist/tools/extract-post-engagement.d.ts +0 -7
- package/dist/tools/extract-post-engagement.d.ts.map +1 -1
- package/dist/tools/extract-post-engagement.js +205 -21
- package/dist/tools/extract-post-engagement.js.map +1 -1
- package/dist/tools/get-endpoint-details.d.ts +1 -0
- package/dist/tools/get-endpoint-details.d.ts.map +1 -1
- package/dist/tools/get-endpoint-details.js +3 -1
- package/dist/tools/get-endpoint-details.js.map +1 -1
- package/dist/tools/search-web.d.ts +1 -1
- package/dist/tools/search-web.js +2 -2
- package/dist/tools/search-web.js.map +1 -1
- package/package.json +1 -1
- package/src/executor.ts +1 -1
- package/src/registry.ts +25 -5
- package/src/tools/call-endpoint.ts +3 -1
- package/src/tools/extract-post-engagement.ts +229 -27
- package/src/tools/get-endpoint-details.ts +3 -1
- package/src/tools/search-web.ts +2 -2
- package/tests/registry-enrich-person.test.ts +16 -14
- package/tests/registry.test.ts +7 -0
- package/tests/search-web-exa-compat.test.ts +11 -0
- package/tests/tools/call-endpoint.test.ts +16 -0
- package/tests/tools/extract-post-engagement.test.ts +167 -3
- 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
|
})
|