@coldiq/mcp 0.3.14 → 0.3.16

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 (44) hide show
  1. package/dist/index.js +4 -0
  2. package/dist/index.js.map +1 -1
  3. package/dist/registry.d.ts.map +1 -1
  4. package/dist/registry.js +0 -28
  5. package/dist/registry.js.map +1 -1
  6. package/dist/skills.d.ts +20 -0
  7. package/dist/skills.d.ts.map +1 -0
  8. package/dist/skills.js +89 -0
  9. package/dist/skills.js.map +1 -0
  10. package/dist/tools/list-skills.d.ts +17 -0
  11. package/dist/tools/list-skills.d.ts.map +1 -0
  12. package/dist/tools/list-skills.js +22 -0
  13. package/dist/tools/list-skills.js.map +1 -0
  14. package/dist/tools/load-skill.d.ts +20 -0
  15. package/dist/tools/load-skill.d.ts.map +1 -0
  16. package/dist/tools/load-skill.js +29 -0
  17. package/dist/tools/load-skill.js.map +1 -0
  18. package/dist/tools/search-ads.d.ts +2 -6
  19. package/dist/tools/search-ads.d.ts.map +1 -1
  20. package/dist/tools/search-ads.js +4 -13
  21. package/dist/tools/search-ads.js.map +1 -1
  22. package/dist/utils/provider-display.d.ts.map +1 -1
  23. package/dist/utils/provider-display.js +2 -3
  24. package/dist/utils/provider-display.js.map +1 -1
  25. package/dist/utils/provider-resolver.js +0 -1
  26. package/dist/utils/provider-resolver.js.map +1 -1
  27. package/dist/utils/selection-insight.d.ts.map +1 -1
  28. package/dist/utils/selection-insight.js +0 -1
  29. package/dist/utils/selection-insight.js.map +1 -1
  30. package/package.json +1 -1
  31. package/src/index.ts +16 -0
  32. package/src/registry.ts +0 -24
  33. package/src/skills.ts +108 -0
  34. package/src/tools/list-skills.ts +25 -0
  35. package/src/tools/load-skill.ts +32 -0
  36. package/src/tools/search-ads.ts +4 -14
  37. package/src/utils/provider-display.ts +2 -3
  38. package/src/utils/provider-resolver.ts +0 -1
  39. package/src/utils/selection-insight.ts +0 -1
  40. package/tests/registry.test.ts +3 -49
  41. package/tests/skills.test.ts +130 -0
  42. package/tests/tools/list-skills.test.ts +44 -0
  43. package/tests/tools/load-skill.test.ts +53 -0
  44. package/tests/utils/provider-display.test.ts +2 -2
package/src/skills.ts ADDED
@@ -0,0 +1,108 @@
1
+ // ColdIQ GTM skills, served over MCP.
2
+ //
3
+ // Skills are step-by-step playbooks authored in the coldiq-marketplace-skills
4
+ // repo. Claude Code and Cursor load them natively (progressive disclosure), but
5
+ // agents without a skills loader (Codex, Windsurf, Cline, …) can't. Since every
6
+ // agent that installs this MCP gets these two tools, the MCP is the universal
7
+ // delivery channel: list_skills surfaces the catalog, load_skill fetches one
8
+ // playbook on demand.
9
+ //
10
+ // Source is the repo's discovery index on GitHub raw, so a push to the skills
11
+ // repo updates what every agent sees — no MCP republish needed. Override with
12
+ // COLDIQ_SKILLS_INDEX_URL.
13
+
14
+ const DEFAULT_INDEX_URL =
15
+ 'https://raw.githubusercontent.com/Cold-IQ/coldiq-marketplace-skills/main/.well-known/agent-skills/index.json'
16
+ const INDEX_TTL_MS = 10 * 60 * 1000 // re-fetch the catalog at most every 10 min
17
+
18
+ export interface SkillEntry {
19
+ name: string
20
+ description: string
21
+ url: string
22
+ digest?: string
23
+ }
24
+
25
+ interface CachedIndex {
26
+ skills: SkillEntry[]
27
+ fetchedAt: number
28
+ }
29
+
30
+ let _indexCache: CachedIndex | null = null
31
+ const _bodyCache = new Map<string, string>()
32
+
33
+ /** Test seam: clear in-memory caches. */
34
+ export function __resetSkillsCache(): void {
35
+ _indexCache = null
36
+ _bodyCache.clear()
37
+ }
38
+
39
+ function indexUrl(): string {
40
+ return process.env.COLDIQ_SKILLS_INDEX_URL || DEFAULT_INDEX_URL
41
+ }
42
+
43
+ async function fetchText(url: string): Promise<string> {
44
+ const res = await fetch(url, { headers: { 'user-agent': 'coldiq-mcp' } })
45
+ if (!res.ok) throw new Error(`request to ${url} failed (HTTP ${res.status})`)
46
+ return await res.text()
47
+ }
48
+
49
+ async function getIndex(): Promise<SkillEntry[]> {
50
+ if (_indexCache && Date.now() - _indexCache.fetchedAt < INDEX_TTL_MS) {
51
+ return _indexCache.skills
52
+ }
53
+ const parsed = JSON.parse(await fetchText(indexUrl()))
54
+ const raw = Array.isArray(parsed?.skills) ? parsed.skills : []
55
+ const skills: SkillEntry[] = raw
56
+ .filter((s: unknown): s is Record<string, unknown> => !!s && typeof s === 'object')
57
+ .map((s: Record<string, unknown>) => ({
58
+ name: String(s.name ?? ''),
59
+ description: String(s.description ?? ''),
60
+ url: String(s.url ?? ''),
61
+ digest: typeof s.digest === 'string' ? s.digest : undefined,
62
+ }))
63
+ .filter((s: SkillEntry) => s.name && s.url)
64
+ _indexCache = { skills, fetchedAt: Date.now() }
65
+ return skills
66
+ }
67
+
68
+ /** The catalog: each skill's name + one-line description (no bodies). */
69
+ export async function listSkills(): Promise<{ name: string; description: string }[]> {
70
+ const skills = await getIndex()
71
+ return skills.map((s) => ({ name: s.name, description: s.description }))
72
+ }
73
+
74
+ /**
75
+ * Full playbook body for `name`. Any `resources/<file>.md` the skill references
76
+ * is fetched and appended so the returned document is self-contained (agents
77
+ * reached over MCP can't follow the relative links themselves).
78
+ */
79
+ export async function loadSkill(name: string): Promise<string> {
80
+ const skills = await getIndex()
81
+ const entry = skills.find((s) => s.name === name)
82
+ if (!entry) {
83
+ throw new Error(`unknown skill "${name}". Available: ${skills.map((s) => s.name).join(', ')}`)
84
+ }
85
+ if (_bodyCache.has(entry.url)) return _bodyCache.get(entry.url) as string
86
+
87
+ const body = await fetchText(entry.url)
88
+ const refs = new Set<string>()
89
+ for (const m of body.matchAll(/resources\/([A-Za-z0-9._-]+\.md)/g)) refs.add(m[1])
90
+
91
+ let combined = body
92
+ if (refs.size > 0) {
93
+ const baseDir = entry.url.replace(/\/[^/]*$/, '') // strip trailing SKILL.md
94
+ const sections: string[] = []
95
+ for (const ref of refs) {
96
+ try {
97
+ const text = await fetchText(`${baseDir}/resources/${ref}`)
98
+ sections.push(`\n\n---\n\n## Resource: resources/${ref}\n\n${text.trim()}`)
99
+ } catch {
100
+ // A missing resource shouldn't fail the whole skill load.
101
+ }
102
+ }
103
+ combined = body.replace(/\s+$/, '') + sections.join('') + '\n'
104
+ }
105
+
106
+ _bodyCache.set(entry.url, combined)
107
+ return combined
108
+ }
@@ -0,0 +1,25 @@
1
+ import { listSkills } from '../skills.js'
2
+
3
+ export const listSkillsName = 'list_skills'
4
+
5
+ export const listSkillsDescription =
6
+ 'List the available ColdIQ GTM playbooks ("skills") — step-by-step expert workflows for go-to-market ' +
7
+ 'tasks: building a TAM, Apollo / company / people search, contact enrichment and email waterfalls, ' +
8
+ 'signal detection, ICP & personas, cold-email copy, campaign delivery, and more. Returns each skill\'s ' +
9
+ 'name and a one-line description of when to use it. Call this when starting a prospecting / outreach / ' +
10
+ 'GTM task to check for a relevant playbook, then call load_skill to read the full step-by-step guide. ' +
11
+ '(Claude Code and Cursor load these skills natively; on other agents this is how you reach them.)'
12
+
13
+ export const listSkillsSchema = {}
14
+
15
+ export async function listSkillsHandler(_input: Record<string, unknown>) {
16
+ try {
17
+ const skills = await listSkills()
18
+ return { content: [{ type: 'text' as const, text: JSON.stringify({ data: skills }) }] }
19
+ } catch (err) {
20
+ return {
21
+ content: [{ type: 'text' as const, text: JSON.stringify({ error: `Could not load the ColdIQ skills catalog: ${(err as Error).message}` }) }],
22
+ isError: true,
23
+ }
24
+ }
25
+ }
@@ -0,0 +1,32 @@
1
+ import { z } from 'zod'
2
+ import { loadSkill } from '../skills.js'
3
+
4
+ export const loadSkillName = 'load_skill'
5
+
6
+ export const loadSkillDescription =
7
+ 'Load the full ColdIQ GTM playbook ("skill") by name — the complete step-by-step expert workflow, ' +
8
+ 'including which ColdIQ tools/endpoints to call at each step. Call list_skills first to get the available ' +
9
+ 'names. The returned guide (with any referenced reference docs inlined) tells you exactly how to run the ' +
10
+ 'task using the other ColdIQ tools.'
11
+
12
+ export const loadSkillSchema = {
13
+ name: z
14
+ .string()
15
+ .describe('The skill name from list_skills, e.g. "contact-enrichment", "apollo-search", or "tam-scoring".'),
16
+ }
17
+
18
+ export async function loadSkillHandler(input: Record<string, unknown>) {
19
+ const name = String(input.name ?? '').trim()
20
+ if (!name) {
21
+ return {
22
+ content: [{ type: 'text' as const, text: 'Provide a skill "name" — call list_skills to see the available skills.' }],
23
+ isError: true,
24
+ }
25
+ }
26
+ try {
27
+ const body = await loadSkill(name)
28
+ return { content: [{ type: 'text' as const, text: body }] }
29
+ } catch (err) {
30
+ return { content: [{ type: 'text' as const, text: (err as Error).message }], isError: true }
31
+ }
32
+ }
@@ -5,17 +5,17 @@ import { getProvidersForCapability } from '../utils/provider-resolver.js'
5
5
  export const searchAdsName = 'search_ads'
6
6
 
7
7
  export const searchAdsDescription =
8
- 'Search live ad creatives across 5 ad libraries (Google Ads Transparency, LinkedIn Ad Library, Meta Ads Library, Twitter/X Ads, Reddit Ads) — a high-signal GTM input for competitive intelligence, ICP refinement, and pitch personalization. Routes by input: domains/advertiser_ids → Google only; search_urls → LinkedIn only; bare query → Google → Meta → Twitter → Reddit waterfall. Use platform="google"|"linkedin"|"meta"|"twitter"|"reddit" to pin to one platform. All providers are async (~10–60s). Cost: ~5 credits per call (Twitter charges 1 credit per ad returned). Credits are fully refunded when a run returns zero ads. NOTE: Google Ads creatives return image URLs + creative IDs, not ad copy text — open the image URLs to read the ad. There is no "currently running only" filter; results can span past campaigns.'
8
+ 'Search live ad creatives across 4 ad libraries (Google Ads Transparency, LinkedIn Ad Library, Meta Ads Library, Twitter/X Ads) — a high-signal GTM input for competitive intelligence, ICP refinement, and pitch personalization. Routes by input: domains/advertiser_ids → Google only; search_urls → LinkedIn only; bare query → Google → Meta → Twitter waterfall. Use platform="google"|"linkedin"|"meta"|"twitter" to pin to one platform. All providers are async (~10–60s). Cost: ~5 credits per call (Twitter charges 1 credit per ad returned). Credits are fully refunded when a run returns zero ads. NOTE: Google Ads creatives return image URLs + creative IDs, not ad copy text — open the image URLs to read the ad. There is no "currently running only" filter; results can span past campaigns.'
9
9
 
10
10
  export const searchAdsSchema = {
11
- query: z.string().optional().describe('Advertiser/company name or keyword. Routes to Google→Meta→Twitter→Reddit when no platform-specific input is set.'),
11
+ query: z.string().optional().describe('Advertiser/company name or keyword. Routes to Google→Meta→Twitter when no platform-specific input is set.'),
12
12
 
13
13
  domains: z.array(z.string()).optional().describe('Company domains (e.g. ["salesforce.com"]). Routes to Google Ads only.'),
14
14
  advertiser_ids: z.array(z.string()).optional().describe('Google Ads Transparency advertiser IDs (e.g. ["AR16735076323512287233"]). Routes to Google Ads only.'),
15
15
 
16
16
  search_urls: z.array(z.string()).optional().describe('Pre-built LinkedIn Ad Library URLs from linkedin.com/ad-library. Routes to LinkedIn only. Build at linkedin.com/ad-library/search using accountOwner/countries/dateOption filters.'),
17
17
 
18
- country: z.string().optional().describe('ISO country code (2-letter for Meta/Twitter; longer accepted by Google as region). Ignored by Reddit.'),
18
+ country: z.string().optional().describe('ISO country code (2-letter for Meta/Twitter; longer accepted by Google as region).'),
19
19
  max_results: z.number().int().min(1).max(200).default(25).describe('Max ads per provider call (1–200). Per-provider caps: Google 1000, LinkedIn 200, Meta 200, Twitter 100. Twitter charges 1 credit per ad returned.'),
20
20
 
21
21
  ad_type: z.enum(['ALL', 'POLITICAL_AND_ISSUE_ADS']).optional().describe('Meta only. Defaults to ALL.'),
@@ -23,17 +23,7 @@ export const searchAdsSchema = {
23
23
  start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Twitter only. Date range start, YYYY-MM-DD.'),
24
24
  end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Twitter only. Date range end, YYYY-MM-DD.'),
25
25
 
26
- industry: z.enum([
27
- 'TECH_B2C', 'TECH_B2B', 'EDUCATION', 'ENTERTAINMENT', 'HEALTH_AND_BEAUTY',
28
- 'GAMING', 'EMPLOYMENT', 'RETAIL_AND_ECOMMERCE', 'AUTO', 'FINANCIAL_SERVICES',
29
- 'TRAVEL', 'REAL_ESTATE', 'GAMBLING_AND_FANTASY_SPORTS', 'POLITICS_AND_GOVERNMENT',
30
- 'CONSUMER_PACKAGED_GOODS', 'OTHER',
31
- ]).optional().describe('Reddit only. Filter by industry category.'),
32
- budget_category: z.enum(['LOW', 'MEDIUM', 'HIGH']).optional().describe('Reddit only.'),
33
- post_type: z.enum(['IMAGE', 'VIDEO', 'TEXT']).optional().describe('Reddit only.'),
34
- objective_type: z.enum(['AWARENESS', 'CONVERSIONS', 'APP_INSTALLS', 'TRAFFIC', 'VIDEO_VIEWABLE_IMPRESSIONS']).optional().describe('Reddit only.'),
35
-
36
- platform: z.enum(['google', 'linkedin', 'meta', 'twitter', 'reddit']).optional().describe('Pin to one platform; skips all others. Useful for cross-platform comparison via separate calls.'),
26
+ platform: z.enum(['google', 'linkedin', 'meta', 'twitter']).optional().describe('Pin to one platform; skips all others. Useful for cross-platform comparison via separate calls.'),
37
27
  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_ads').join(', ')}. Provider names are matched fuzzily, so minor typos are tolerated.`),
38
28
  }
39
29
 
@@ -3,7 +3,7 @@
3
3
  // ---------------------------------------------------------------------------
4
4
  //
5
5
  // Internal provider IDs (registry.ts + the find_emails waterfall) are a mix of
6
- // clean vendor names ("apollo"), already-branded slugs ("reddit_ads"), and ugly
6
+ // clean vendor names ("apollo"), already-branded slugs ("linkedin_ad_library"), and ugly
7
7
  // internal slugs ("limadata-work-email"). User-facing surfaces — the selection
8
8
  // insight and `_meta.provider_name` — must show a clean branded label instead.
9
9
  //
@@ -11,7 +11,7 @@
11
11
  // the ColdIQ "we picked the best tool" intelligence on display. The ONLY thing
12
12
  // that must never surface is the Apify backend — and since no ID contains the
13
13
  // word "Apify", that constraint is satisfied by mapping the Apify-backed scrapers
14
- // to their branded data-source name (e.g. "Reddit Ads").
14
+ // to their branded data-source name (e.g. "LinkedIn Ad Library").
15
15
  // ---------------------------------------------------------------------------
16
16
 
17
17
  const DISPLAY_NAMES: Record<string, string> = {
@@ -96,7 +96,6 @@ const DISPLAY_NAMES: Record<string, string> = {
96
96
  linkedin_ad_library: 'LinkedIn Ad Library',
97
97
  meta_ads: 'Meta Ads',
98
98
  twitter_ads: 'X Ads',
99
- reddit_ads: 'Reddit Ads',
100
99
 
101
100
  // --- search_places / reviews ---------------------------------------------
102
101
  google_maps: 'Google Maps',
@@ -268,7 +268,6 @@ const GATED_DESCRIPTIONS: Partial<Record<Capability, Partial<Record<string, Gate
268
268
  linkedin_ad_library: { kind: 'requires', fields: 'search_urls (pre-built LinkedIn Ad Library URLs)' },
269
269
  meta_ads: { kind: 'incompatible_with', fields: 'search_urls (LinkedIn-specific input)' },
270
270
  twitter_ads: { kind: 'incompatible_with', fields: 'search_urls (LinkedIn-specific input)' },
271
- reddit_ads: { kind: 'incompatible_with', fields: 'search_urls (LinkedIn-specific input)' },
272
271
  },
273
272
  // -------------------------------------------------------------------------
274
273
  search_places: {
@@ -148,7 +148,6 @@ const STRENGTHS: Partial<Record<InsightCapability, Record<string, string>>> = {
148
148
  linkedin_ad_library: 'LinkedIn Ad Library coverage',
149
149
  meta_ads: 'Meta (Facebook/Instagram) ad-library coverage',
150
150
  twitter_ads: 'X ad coverage',
151
- reddit_ads: 'Reddit ad coverage',
152
151
  },
153
152
  search_places: {
154
153
  openmart: 'rich local-business data across the US, CA, AU, PR and NZ',
@@ -1493,9 +1493,9 @@ describe('registry', () => {
1493
1493
  })
1494
1494
 
1495
1495
  describe('search_ads', () => {
1496
- it('providers are ordered google_ads → linkedin_ad_library → meta_ads → twitter_ads → reddit_ads', () => {
1496
+ it('providers are ordered google_ads → linkedin_ad_library → meta_ads → twitter_ads', () => {
1497
1497
  const ids = getProviders('search_ads').map((p) => p.id)
1498
- expect(ids).toEqual(['google_ads', 'linkedin_ad_library', 'meta_ads', 'twitter_ads', 'reddit_ads'])
1498
+ expect(ids).toEqual(['google_ads', 'linkedin_ad_library', 'meta_ads', 'twitter_ads'])
1499
1499
  })
1500
1500
 
1501
1501
  it('google_ads is async POST to /google-ads/search', () => {
@@ -1537,17 +1537,6 @@ describe('registry', () => {
1537
1537
  expect(t.async!.pollEndpoint('8')).toBe('/twitter-ads-scraper/search/8')
1538
1538
  })
1539
1539
 
1540
- it('reddit_ads is async POST to /reddit-ads/scrape', () => {
1541
- const r = getProviders('search_ads').find((p) => p.id === 'reddit_ads')!
1542
- expect(r.method).toBe('POST')
1543
- expect(r.endpoint).toBe('/reddit-ads/scrape')
1544
- expect(r.async!.pollEndpoint('5')).toBe('/reddit-ads/scrape/5')
1545
- expect(r.async!.isComplete({ status: 'done' })).toBe(true)
1546
- expect(r.async!.isComplete({ status: 'failed' })).toBe(true)
1547
- expect(r.async!.isComplete({ status: 'timed_out' })).toBe(true)
1548
- expect(r.async!.isComplete({ status: 'processing' })).toBe(false)
1549
- })
1550
-
1551
1540
  it('google_ads.isApplicable: true for query/domains/advertiser_ids, false for empty or wrong platform', () => {
1552
1541
  const g = getProviders('search_ads').find((p) => p.id === 'google_ads')!
1553
1542
  expect(g.isApplicable!({ query: 'Salesforce' })).toBe(true)
@@ -1591,18 +1580,6 @@ describe('registry', () => {
1591
1580
  expect(t.isApplicable!({ query: 'ColdIQ', platform: 'twitter' })).toBe(true)
1592
1581
  })
1593
1582
 
1594
- it('reddit_ads.isApplicable: true for empty/query, false when domains/advertiser_ids/search_urls set', () => {
1595
- const r = getProviders('search_ads').find((p) => p.id === 'reddit_ads')!
1596
- expect(r.isApplicable!({})).toBe(true)
1597
- expect(r.isApplicable!({ query: 'sales automation' })).toBe(true)
1598
- expect(r.isApplicable!({ industry: 'TECH_B2B' })).toBe(true)
1599
- expect(r.isApplicable!({ domains: ['coldiq.com'] })).toBe(false)
1600
- expect(r.isApplicable!({ advertiser_ids: ['AR123'] })).toBe(false)
1601
- expect(r.isApplicable!({ search_urls: ['https://...'] })).toBe(false)
1602
- expect(r.isApplicable!({ platform: 'google' })).toBe(false)
1603
- expect(r.isApplicable!({ platform: 'reddit' })).toBe(true)
1604
- })
1605
-
1606
1583
  it('platform pin: platform=linkedin makes only linkedin_ad_library applicable (even without search_urls it fails the URL check)', () => {
1607
1584
  const providers = getProviders('search_ads')
1608
1585
  const pinned = { platform: 'linkedin', query: 'Salesforce' }
@@ -1667,30 +1644,7 @@ describe('registry', () => {
1667
1644
  expect(capped.maxItems).toBe(100)
1668
1645
  })
1669
1646
 
1670
- it('reddit_ads.mapParams: query→keywords, forwards industry/budget_category/post_type/objective_type', () => {
1671
- const r = getProviders('search_ads').find((p) => p.id === 'reddit_ads')!
1672
- const out = r.mapParams({
1673
- query: 'sales automation',
1674
- industry: 'TECH_B2B',
1675
- budget_category: 'HIGH',
1676
- post_type: 'VIDEO',
1677
- objective_type: 'CONVERSIONS',
1678
- }).body as Record<string, unknown>
1679
- expect(out.keywords).toBe('sales automation')
1680
- expect(out.industry).toBe('TECH_B2B')
1681
- expect(out.budgetCategory).toBe('HIGH')
1682
- expect(out.postType).toBe('VIDEO')
1683
- expect(out.objectiveType).toBe('CONVERSIONS')
1684
- })
1685
-
1686
- it('reddit_ads.mapParams: query is optional (scrape-all)', () => {
1687
- const r = getProviders('search_ads').find((p) => p.id === 'reddit_ads')!
1688
- const out = r.mapParams({ industry: 'TECH_B2B' }).body as Record<string, unknown>
1689
- expect(out.keywords).toBeUndefined()
1690
- expect(out.industry).toBe('TECH_B2B')
1691
- })
1692
-
1693
- it('hasResult requires ads[] non-empty for all 5 providers', () => {
1647
+ it('hasResult requires ads[] non-empty for all 4 providers', () => {
1694
1648
  const providers = getProviders('search_ads')
1695
1649
  for (const p of providers) {
1696
1650
  expect(p.hasResult({ ads: [{ id: '1' }] }), `${p.id} should return true`).toBe(true)
@@ -0,0 +1,130 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
+ import { listSkills, loadSkill, __resetSkillsCache } from '../src/skills.js'
3
+
4
+ const INDEX = {
5
+ $schema: 'https://schemas.agentskills.io/discovery/0.2.0/schema.json',
6
+ name: 'coldiq',
7
+ skills: [
8
+ {
9
+ name: 'contact-enrichment',
10
+ type: 'skill-md',
11
+ description: 'Enrich contacts with emails and phones.',
12
+ url: 'https://raw.example/skills/contact-enrichment/SKILL.md',
13
+ digest: 'sha256:abc',
14
+ },
15
+ {
16
+ name: 'coldiq-search-enrich',
17
+ type: 'skill-md',
18
+ description: 'Search and enrich at scale.',
19
+ url: 'https://raw.example/skills/lima-data-api/SKILL.md',
20
+ },
21
+ ],
22
+ }
23
+
24
+ const BODY_WITH_RES =
25
+ '# Contact enrichment\n\nSee [resources/async-job-pattern.md](resources/async-job-pattern.md) and ' +
26
+ '[resources/credit-optimization.md](resources/credit-optimization.md).\n'
27
+
28
+ function mockFetch(map: Record<string, { status?: number; body: string }>) {
29
+ return vi.fn(async (url: string | URL | Request) => {
30
+ const u = String(url)
31
+ const hit = map[u]
32
+ if (!hit) return { ok: false, status: 404, text: async () => 'not found' } as Response
33
+ const status = hit.status ?? 200
34
+ return { ok: status >= 200 && status < 300, status, text: async () => hit.body } as Response
35
+ })
36
+ }
37
+
38
+ const originalFetch = globalThis.fetch
39
+
40
+ describe('skills module', () => {
41
+ beforeEach(() => {
42
+ __resetSkillsCache()
43
+ delete process.env.COLDIQ_SKILLS_INDEX_URL
44
+ })
45
+ afterEach(() => {
46
+ globalThis.fetch = originalFetch
47
+ vi.restoreAllMocks()
48
+ })
49
+
50
+ it('listSkills returns name + description from the index', async () => {
51
+ globalThis.fetch = mockFetch({
52
+ 'https://raw.githubusercontent.com/Cold-IQ/coldiq-marketplace-skills/main/.well-known/agent-skills/index.json': {
53
+ body: JSON.stringify(INDEX),
54
+ },
55
+ }) as typeof fetch
56
+ const skills = await listSkills()
57
+ expect(skills).toHaveLength(2)
58
+ expect(skills[0]).toEqual({ name: 'contact-enrichment', description: 'Enrich contacts with emails and phones.' })
59
+ expect(skills.map((s) => s.name)).toContain('coldiq-search-enrich')
60
+ })
61
+
62
+ it('honors COLDIQ_SKILLS_INDEX_URL override', async () => {
63
+ process.env.COLDIQ_SKILLS_INDEX_URL = 'https://custom/index.json'
64
+ globalThis.fetch = mockFetch({ 'https://custom/index.json': { body: JSON.stringify(INDEX) } }) as typeof fetch
65
+ const skills = await listSkills()
66
+ expect(skills).toHaveLength(2)
67
+ })
68
+
69
+ it('caches the index (one network call across two listSkills)', async () => {
70
+ const f = mockFetch({
71
+ 'https://raw.githubusercontent.com/Cold-IQ/coldiq-marketplace-skills/main/.well-known/agent-skills/index.json': {
72
+ body: JSON.stringify(INDEX),
73
+ },
74
+ })
75
+ globalThis.fetch = f as typeof fetch
76
+ await listSkills()
77
+ await listSkills()
78
+ expect(f).toHaveBeenCalledTimes(1)
79
+ })
80
+
81
+ it('loadSkill returns the body with referenced resources inlined', async () => {
82
+ globalThis.fetch = mockFetch({
83
+ 'https://raw.githubusercontent.com/Cold-IQ/coldiq-marketplace-skills/main/.well-known/agent-skills/index.json': {
84
+ body: JSON.stringify(INDEX),
85
+ },
86
+ 'https://raw.example/skills/contact-enrichment/SKILL.md': { body: BODY_WITH_RES },
87
+ 'https://raw.example/skills/contact-enrichment/resources/async-job-pattern.md': { body: 'POLL LOOP DOC' },
88
+ 'https://raw.example/skills/contact-enrichment/resources/credit-optimization.md': { body: 'CREDIT DOC' },
89
+ }) as typeof fetch
90
+ const doc = await loadSkill('contact-enrichment')
91
+ expect(doc).toContain('# Contact enrichment')
92
+ expect(doc).toContain('## Resource: resources/async-job-pattern.md')
93
+ expect(doc).toContain('POLL LOOP DOC')
94
+ expect(doc).toContain('## Resource: resources/credit-optimization.md')
95
+ expect(doc).toContain('CREDIT DOC')
96
+ })
97
+
98
+ it('loadSkill tolerates a missing resource (skips it, still returns body)', async () => {
99
+ globalThis.fetch = mockFetch({
100
+ 'https://raw.githubusercontent.com/Cold-IQ/coldiq-marketplace-skills/main/.well-known/agent-skills/index.json': {
101
+ body: JSON.stringify(INDEX),
102
+ },
103
+ 'https://raw.example/skills/contact-enrichment/SKILL.md': { body: BODY_WITH_RES },
104
+ // resources intentionally 404
105
+ }) as typeof fetch
106
+ const doc = await loadSkill('contact-enrichment')
107
+ expect(doc).toContain('# Contact enrichment')
108
+ expect(doc).not.toContain('POLL LOOP DOC')
109
+ })
110
+
111
+ it('loadSkill resolves folder≠name via the index url', async () => {
112
+ globalThis.fetch = mockFetch({
113
+ 'https://raw.githubusercontent.com/Cold-IQ/coldiq-marketplace-skills/main/.well-known/agent-skills/index.json': {
114
+ body: JSON.stringify(INDEX),
115
+ },
116
+ 'https://raw.example/skills/lima-data-api/SKILL.md': { body: '# Search & enrich\n' },
117
+ }) as typeof fetch
118
+ const doc = await loadSkill('coldiq-search-enrich')
119
+ expect(doc).toContain('# Search & enrich')
120
+ })
121
+
122
+ it('loadSkill throws a helpful error for an unknown skill', async () => {
123
+ globalThis.fetch = mockFetch({
124
+ 'https://raw.githubusercontent.com/Cold-IQ/coldiq-marketplace-skills/main/.well-known/agent-skills/index.json': {
125
+ body: JSON.stringify(INDEX),
126
+ },
127
+ }) as typeof fetch
128
+ await expect(loadSkill('does-not-exist')).rejects.toThrow(/unknown skill/i)
129
+ })
130
+ })
@@ -0,0 +1,44 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
+ import {
3
+ listSkillsHandler,
4
+ listSkillsName,
5
+ listSkillsSchema,
6
+ } from '../../src/tools/list-skills.js'
7
+ import { __resetSkillsCache } from '../../src/skills.js'
8
+
9
+ const INDEX_URL =
10
+ 'https://raw.githubusercontent.com/Cold-IQ/coldiq-marketplace-skills/main/.well-known/agent-skills/index.json'
11
+ const originalFetch = globalThis.fetch
12
+
13
+ describe('list_skills handler', () => {
14
+ beforeEach(() => __resetSkillsCache())
15
+ afterEach(() => {
16
+ globalThis.fetch = originalFetch
17
+ vi.restoreAllMocks()
18
+ })
19
+
20
+ it('is a zero-arg tool', () => {
21
+ expect(listSkillsName).toBe('list_skills')
22
+ expect(listSkillsSchema).toEqual({})
23
+ })
24
+
25
+ it('returns the catalog as { data: [...] }', async () => {
26
+ globalThis.fetch = vi.fn(async () => ({
27
+ ok: true,
28
+ status: 200,
29
+ text: async () =>
30
+ JSON.stringify({ skills: [{ name: 'tam-scoring', description: 'Score a TAM.', url: 'https://x/SKILL.md' }] }),
31
+ })) as unknown as typeof fetch
32
+ const res = await listSkillsHandler({})
33
+ expect(res.isError).toBeFalsy()
34
+ const parsed = JSON.parse(res.content[0].text)
35
+ expect(parsed.data).toEqual([{ name: 'tam-scoring', description: 'Score a TAM.' }])
36
+ })
37
+
38
+ it('returns an error result (not a throw) when the index is unreachable', async () => {
39
+ globalThis.fetch = vi.fn(async () => ({ ok: false, status: 500, text: async () => 'err' })) as unknown as typeof fetch
40
+ const res = await listSkillsHandler({})
41
+ expect(res.isError).toBe(true)
42
+ expect(JSON.parse(res.content[0].text).error).toMatch(/could not load/i)
43
+ })
44
+ })
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
+ import { loadSkillHandler, loadSkillName, loadSkillSchema } from '../../src/tools/load-skill.js'
3
+ import { __resetSkillsCache } from '../../src/skills.js'
4
+
5
+ const INDEX_URL =
6
+ 'https://raw.githubusercontent.com/Cold-IQ/coldiq-marketplace-skills/main/.well-known/agent-skills/index.json'
7
+
8
+ function mockFetch(map: Record<string, string>) {
9
+ return vi.fn(async (url: string | URL | Request) => {
10
+ const body = map[String(url)]
11
+ return { ok: body !== undefined, status: body !== undefined ? 200 : 404, text: async () => body ?? 'nf' } as Response
12
+ })
13
+ }
14
+ const originalFetch = globalThis.fetch
15
+
16
+ describe('load_skill handler', () => {
17
+ beforeEach(() => __resetSkillsCache())
18
+ afterEach(() => {
19
+ globalThis.fetch = originalFetch
20
+ vi.restoreAllMocks()
21
+ })
22
+
23
+ it('declares a required name arg', () => {
24
+ expect(loadSkillName).toBe('load_skill')
25
+ expect(Object.keys(loadSkillSchema)).toContain('name')
26
+ })
27
+
28
+ it('returns the skill body as text', async () => {
29
+ globalThis.fetch = mockFetch({
30
+ [INDEX_URL]: JSON.stringify({ skills: [{ name: 'apollo-search', description: 'd', url: 'https://x/skills/apollo-search/SKILL.md' }] }),
31
+ 'https://x/skills/apollo-search/SKILL.md': '# Apollo search\nstep 1',
32
+ }) as typeof fetch
33
+ const res = await loadSkillHandler({ name: 'apollo-search' })
34
+ expect(res.isError).toBeFalsy()
35
+ expect(res.content[0].text).toContain('# Apollo search')
36
+ })
37
+
38
+ it('errors (not throws) on a missing name', async () => {
39
+ const res = await loadSkillHandler({})
40
+ expect(res.isError).toBe(true)
41
+ expect(res.content[0].text).toMatch(/list_skills/)
42
+ })
43
+
44
+ it('errors with the available list on an unknown skill', async () => {
45
+ globalThis.fetch = mockFetch({
46
+ [INDEX_URL]: JSON.stringify({ skills: [{ name: 'apollo-search', description: 'd', url: 'https://x/SKILL.md' }] }),
47
+ }) as typeof fetch
48
+ const res = await loadSkillHandler({ name: 'nope' })
49
+ expect(res.isError).toBe(true)
50
+ expect(res.content[0].text).toMatch(/unknown skill/i)
51
+ expect(res.content[0].text).toMatch(/apollo-search/)
52
+ })
53
+ })
@@ -16,7 +16,7 @@ describe('providerDisplayName', () => {
16
16
  })
17
17
 
18
18
  it('brands Apify-backed scrapers as their data source, never raw slug', () => {
19
- expect(providerDisplayName('reddit_ads')).toBe('Reddit Ads')
19
+ expect(providerDisplayName('meta_ads')).toBe('Meta Ads')
20
20
  expect(providerDisplayName('linkedin_ad_library')).toBe('LinkedIn Ad Library')
21
21
  expect(providerDisplayName('google_maps')).toBe('Google Maps')
22
22
  })
@@ -28,7 +28,7 @@ describe('providerDisplayName', () => {
28
28
 
29
29
  it('never returns a raw slug or the word "Apify"', () => {
30
30
  const ids = [
31
- 'limadata-work-email', 'reddit_ads', 'fullenrich', 'ai-ark-people',
31
+ 'limadata-work-email', 'twitter_ads', 'fullenrich', 'ai-ark-people',
32
32
  'linkupapi-validate', 'theirstack-buying-intents', 'completely_unknown_slug',
33
33
  ]
34
34
  for (const id of ids) {