@coldiq/mcp 0.4.6 → 0.5.0

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 (46) hide show
  1. package/dist/client.d.ts.map +1 -1
  2. package/dist/client.js +3 -0
  3. package/dist/client.js.map +1 -1
  4. package/dist/index.js +37 -41
  5. package/dist/index.js.map +1 -1
  6. package/dist/instructions.d.ts +3 -0
  7. package/dist/instructions.d.ts.map +1 -0
  8. package/dist/instructions.js +9 -0
  9. package/dist/instructions.js.map +1 -0
  10. package/dist/knowledge-policy.d.ts +3 -0
  11. package/dist/knowledge-policy.d.ts.map +1 -0
  12. package/dist/knowledge-policy.js +5 -0
  13. package/dist/knowledge-policy.js.map +1 -0
  14. package/dist/registry.d.ts.map +1 -1
  15. package/dist/registry.js +125 -42
  16. package/dist/registry.js.map +1 -1
  17. package/dist/tools/list-skills.d.ts.map +1 -1
  18. package/dist/tools/list-skills.js +4 -3
  19. package/dist/tools/list-skills.js.map +1 -1
  20. package/dist/tools/load-skill.d.ts.map +1 -1
  21. package/dist/tools/load-skill.js +2 -1
  22. package/dist/tools/load-skill.js.map +1 -1
  23. package/dist/tools/search-knowledge.d.ts +22 -0
  24. package/dist/tools/search-knowledge.d.ts.map +1 -0
  25. package/dist/tools/search-knowledge.js +45 -0
  26. package/dist/tools/search-knowledge.js.map +1 -0
  27. package/dist/utils/provider-resolver.js +2 -3
  28. package/dist/utils/provider-resolver.js.map +1 -1
  29. package/dist/utils/selection-insight.js +1 -1
  30. package/dist/utils/selection-insight.js.map +1 -1
  31. package/package.json +1 -1
  32. package/src/client.ts +3 -0
  33. package/src/index.ts +42 -41
  34. package/src/instructions.ts +10 -0
  35. package/src/knowledge-policy.ts +6 -0
  36. package/src/registry.ts +120 -41
  37. package/src/tools/list-skills.ts +4 -3
  38. package/src/tools/load-skill.ts +2 -1
  39. package/src/tools/search-knowledge.ts +50 -0
  40. package/src/utils/provider-resolver.ts +2 -3
  41. package/src/utils/selection-insight.ts +1 -1
  42. package/tests/client.test.ts +12 -0
  43. package/tests/instructions.test.ts +67 -0
  44. package/tests/registry.test.ts +16 -29
  45. package/tests/tools/get-credit-balance.test.ts +11 -0
  46. package/tests/tools/search-knowledge.test.ts +104 -0
package/src/registry.ts CHANGED
@@ -1857,104 +1857,183 @@ const verifyEmailProviders: ProviderEntry[] = [
1857
1857
  // ---------------------------------------------------------------------------
1858
1858
 
1859
1859
  const findPhoneProviders: ProviderEntry[] = [
1860
+ // Mirror of the API registry (validation/listing only — execution order and
1861
+ // predicates live server-side in src/router/registry.ts). 2026-07-24 v2:
1862
+ // prospeo → leadmagic → ai-ark → limadata → fullenrich (premium closer);
1863
+ // findymail dropped (1 hit/30d); ai-ark/limadata accept the schema's `domain`.
1860
1864
  {
1861
- id: 'findymail',
1862
- endpoint: '/findymail/search/phone',
1865
+ id: 'prospeo',
1866
+ endpoint: '/prospeo/enrich-person',
1867
+ method: 'POST',
1868
+ priority: 1,
1869
+ isApplicable: (input) => {
1870
+ if (typeof input.linkedin_url === 'string' && (input.linkedin_url as string).length > 0) return true
1871
+ const hasName = typeof input.first_name === 'string' && (input.first_name as string).length > 0 &&
1872
+ typeof input.last_name === 'string' && (input.last_name as string).length > 0
1873
+ const domain = (input.domain ?? input.company_domain) as string | undefined
1874
+ const hasCompany = (typeof domain === 'string' && domain.length > 0) ||
1875
+ (typeof input.company_name === 'string' && (input.company_name as string).length > 0)
1876
+ return hasName && hasCompany
1877
+ },
1878
+ mapParams: (input) => {
1879
+ const domain = (input.domain ?? input.company_domain) as string | undefined
1880
+ const data: Record<string, unknown> = input.linkedin_url
1881
+ ? { linkedin_url: input.linkedin_url }
1882
+ : {
1883
+ first_name: input.first_name,
1884
+ last_name: input.last_name,
1885
+ ...(domain ? { company_website: domain } : {}),
1886
+ ...(input.company_name ? { company_name: input.company_name } : {}),
1887
+ }
1888
+ return { body: { enrich_mobile: true, data } }
1889
+ },
1890
+ hasResult: (data) => {
1891
+ const d = data as Record<string, unknown>
1892
+ const person = d.person as Record<string, unknown> | undefined
1893
+ const mobile = person?.mobile as Record<string, unknown> | undefined
1894
+ return typeof mobile?.mobile === 'string' && (mobile.mobile as string).length > 0
1895
+ },
1896
+ },
1897
+ {
1898
+ id: 'leadmagic',
1899
+ endpoint: '/leadmagic/people/mobile-finder',
1863
1900
  method: 'POST',
1864
1901
  priority: 2,
1865
- isApplicable: (input) => typeof input.linkedin_url === 'string' && (input.linkedin_url as string).length > 0,
1866
- mapParams: (input) => ({ body: { linkedin_url: input.linkedin_url } }),
1902
+ isApplicable: (input) =>
1903
+ (typeof input.linkedin_url === 'string' && (input.linkedin_url as string).length > 0) ||
1904
+ (typeof input.email === 'string' && (input.email as string).length > 0),
1905
+ mapParams: (input) => ({
1906
+ body: input.linkedin_url
1907
+ ? { profile_url: input.linkedin_url }
1908
+ : { work_email: input.email },
1909
+ }),
1867
1910
  hasResult: (data) => {
1868
1911
  const d = data as Record<string, unknown>
1869
- return typeof d.phone === 'string' || isNonEmptyArray(d.phones)
1912
+ return typeof d.mobile_number === 'string' && (d.mobile_number as string).length > 0
1870
1913
  },
1871
1914
  },
1872
1915
  {
1873
- id: 'limadata',
1874
- endpoint: '/limadata/find/phone',
1916
+ id: 'ai-ark',
1917
+ endpoint: '/ai-ark/people/mobile-phone-finder',
1875
1918
  method: 'POST',
1876
1919
  priority: 3,
1877
1920
  isApplicable: (input) => {
1878
1921
  if (typeof input.linkedin_url === 'string' && (input.linkedin_url as string).length > 0) return true
1879
1922
  const hasName = typeof input.first_name === 'string' && (input.first_name as string).length > 0 &&
1880
1923
  typeof input.last_name === 'string' && (input.last_name as string).length > 0
1881
- const hasCompany = (typeof input.company_domain === 'string' && (input.company_domain as string).length > 0) ||
1882
- (typeof input.company_name === 'string' && (input.company_name as string).length > 0)
1883
- return hasName && hasCompany
1924
+ const domain = (input.domain ?? input.company_domain) as string | undefined
1925
+ return hasName && typeof domain === 'string' && domain.length > 0
1884
1926
  },
1885
1927
  mapParams: (input) => {
1886
1928
  const firstName = input.first_name as string | undefined
1887
1929
  const lastName = input.last_name as string | undefined
1888
1930
  const fullName = [firstName, lastName].filter(Boolean).join(' ') || undefined
1931
+ const domain = (input.domain ?? input.company_domain) as string | undefined
1889
1932
  return {
1890
1933
  body: {
1891
- ...(input.linkedin_url ? { linkedin_url: input.linkedin_url } : {}),
1934
+ ...(input.linkedin_url ? { linkedin: input.linkedin_url } : {}),
1935
+ ...(domain ? { domain } : {}),
1892
1936
  ...(fullName ? { name: fullName } : {}),
1893
- ...(input.company_name ? { company_name: input.company_name } : {}),
1894
- ...(input.company_domain ? { company_domain: input.company_domain } : {}),
1895
1937
  },
1896
1938
  }
1897
1939
  },
1898
1940
  hasResult: (data) => {
1899
1941
  const d = data as Record<string, unknown>
1900
- if (typeof d.phone === 'string' && (d.phone as string).length > 0) return true
1901
- if (isNonEmptyArray(d.phones)) return true
1902
- if (isNonEmptyArray(d.phone_numbers)) return true
1903
- const nested = d.data as Record<string, unknown> | undefined
1904
- if (nested && typeof nested.phone === 'string' && (nested.phone as string).length > 0) return true
1905
- if (nested && isNonEmptyArray(nested.phones)) return true
1906
- if (nested && isNonEmptyArray(nested.phone_numbers)) return true
1907
- return false
1942
+ return Array.isArray(d.data) &&
1943
+ (d.data as unknown[]).some(
1944
+ (arr) => Array.isArray(arr) && (arr as unknown[]).length > 0 && typeof (arr as unknown[])[0] === 'string',
1945
+ )
1908
1946
  },
1909
1947
  },
1910
1948
  {
1911
- id: 'ai-ark',
1912
- endpoint: '/ai-ark/people/mobile-phone-finder',
1949
+ id: 'limadata',
1950
+ endpoint: '/limadata/find/phone',
1913
1951
  method: 'POST',
1914
1952
  priority: 4,
1915
1953
  isApplicable: (input) => {
1916
1954
  if (typeof input.linkedin_url === 'string' && (input.linkedin_url as string).length > 0) return true
1917
1955
  const hasName = typeof input.first_name === 'string' && (input.first_name as string).length > 0 &&
1918
1956
  typeof input.last_name === 'string' && (input.last_name as string).length > 0
1919
- const hasDomain = typeof input.company_domain === 'string' && (input.company_domain as string).length > 0
1920
- return hasName && hasDomain
1957
+ const domain = (input.domain ?? input.company_domain) as string | undefined
1958
+ const hasCompany = (typeof domain === 'string' && domain.length > 0) ||
1959
+ (typeof input.company_name === 'string' && (input.company_name as string).length > 0)
1960
+ return hasName && hasCompany
1921
1961
  },
1922
1962
  mapParams: (input) => {
1923
1963
  const firstName = input.first_name as string | undefined
1924
1964
  const lastName = input.last_name as string | undefined
1925
1965
  const fullName = [firstName, lastName].filter(Boolean).join(' ') || undefined
1966
+ const domain = (input.domain ?? input.company_domain) as string | undefined
1926
1967
  return {
1927
1968
  body: {
1928
- ...(input.linkedin_url ? { linkedin: input.linkedin_url } : {}),
1929
- ...(input.company_domain ? { domain: input.company_domain } : {}),
1969
+ ...(input.linkedin_url ? { linkedin_url: input.linkedin_url } : {}),
1930
1970
  ...(fullName ? { name: fullName } : {}),
1971
+ ...(input.company_name ? { company_name: input.company_name } : {}),
1972
+ ...(domain ? { company_domain: domain } : {}),
1931
1973
  },
1932
1974
  }
1933
1975
  },
1934
1976
  hasResult: (data) => {
1935
1977
  const d = data as Record<string, unknown>
1936
- return Array.isArray(d.data) &&
1937
- (d.data as unknown[]).some(
1938
- (arr) => Array.isArray(arr) && (arr as unknown[]).length > 0 && typeof (arr as unknown[])[0] === 'string',
1939
- )
1978
+ if (typeof d.phone === 'string' && (d.phone as string).length > 0) return true
1979
+ if (isNonEmptyArray(d.phones)) return true
1980
+ if (isNonEmptyArray(d.phone_numbers)) return true
1981
+ const nested = d.data as Record<string, unknown> | undefined
1982
+ if (nested && typeof nested.phone === 'string' && (nested.phone as string).length > 0) return true
1983
+ if (nested && isNonEmptyArray(nested.phones)) return true
1984
+ if (nested && isNonEmptyArray(nested.phone_numbers)) return true
1985
+ return false
1940
1986
  },
1941
1987
  },
1942
1988
  {
1943
- id: 'leadmagic',
1944
- endpoint: '/leadmagic/people/mobile-finder',
1989
+ id: 'fullenrich',
1990
+ endpoint: '/fullenrich/contact/enrich/bulk',
1945
1991
  method: 'POST',
1946
- priority: 1,
1947
- isApplicable: (input) =>
1948
- (typeof input.linkedin_url === 'string' && (input.linkedin_url as string).length > 0) ||
1949
- (typeof input.email === 'string' && (input.email as string).length > 0),
1992
+ priority: 5,
1993
+ isApplicable: (input) => {
1994
+ if (typeof input.linkedin_url === 'string' && (input.linkedin_url as string).length > 0) return true
1995
+ const hasName = typeof input.first_name === 'string' && (input.first_name as string).length > 0 &&
1996
+ typeof input.last_name === 'string' && (input.last_name as string).length > 0
1997
+ const domain = (input.domain ?? input.company_domain) as string | undefined
1998
+ const hasCompany = (typeof domain === 'string' && domain.length > 0) ||
1999
+ (typeof input.company_name === 'string' && (input.company_name as string).length > 0)
2000
+ return hasName && hasCompany
2001
+ },
1950
2002
  mapParams: (input) => ({
1951
- body: input.linkedin_url
1952
- ? { profile_url: input.linkedin_url }
1953
- : { work_email: input.email },
2003
+ body: {
2004
+ name: 'mcp-find-phone',
2005
+ data: [{
2006
+ first_name: input.first_name,
2007
+ last_name: input.last_name,
2008
+ domain: input.domain ?? input.company_domain,
2009
+ company_name: input.company_name,
2010
+ ...(input.linkedin_url ? { linkedin_url: input.linkedin_url } : {}),
2011
+ enrich_fields: ['contact.phones'],
2012
+ }],
2013
+ },
1954
2014
  }),
1955
2015
  hasResult: (data) => {
1956
2016
  const d = data as Record<string, unknown>
1957
- return typeof d.mobile_number === 'string' && (d.mobile_number as string).length > 0
2017
+ const items = d.data as Array<Record<string, unknown>> | undefined
2018
+ if (!Array.isArray(items) || items.length === 0) return false
2019
+ const contactInfo = items[0]?.contact_info as Record<string, unknown> | undefined
2020
+ if (!contactInfo) return false
2021
+ const phones = contactInfo.phones as Array<Record<string, unknown>> | undefined
2022
+ return Array.isArray(phones) && phones.some((p) => typeof p?.number === 'string' && (p.number as string).length > 0)
2023
+ },
2024
+ async: {
2025
+ extractId: (response) => {
2026
+ const d = response as Record<string, unknown>
2027
+ return d.enrichment_id as string
2028
+ },
2029
+ pollEndpoint: (id) => `/fullenrich/contact/enrich/bulk/${id}`,
2030
+ pollIntervalMs: 2500,
2031
+ timeoutMs: 60_000,
2032
+ isComplete: (data) => {
2033
+ const d = data as Record<string, unknown>
2034
+ const status = d.status as string | undefined
2035
+ return status === 'FINISHED' || status === 'CANCELED'
2036
+ },
1958
2037
  },
1959
2038
  },
1960
2039
  ]
@@ -3,11 +3,12 @@ import { listSkills } from '../skills.js'
3
3
  export const listSkillsName = 'list_skills'
4
4
 
5
5
  export const listSkillsDescription =
6
- 'List the available ColdIQ GTM playbooks ("skills") step-by-step expert workflows for go-to-market ' +
6
+ 'Legacy compatibility tool. Prefer search_knowledge for each new substantive GTM task because it returns the ' +
7
+ 'current ranked workflow and provider order. Lists the available ColdIQ GTM playbooks ("skills") — ' +
8
+ 'step-by-step expert workflows for go-to-market ' +
7
9
  'tasks: building a TAM, Apollo / company / people search, contact enrichment and email waterfalls, ' +
8
10
  '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
+ 'name and a one-line description of when to use it; load_skill reads the full legacy guide. ' +
11
12
  '(Claude Code and Cursor load these skills natively; on other agents this is how you reach them.)'
12
13
 
13
14
  export const listSkillsSchema = {}
@@ -4,7 +4,8 @@ import { loadSkill } from '../skills.js'
4
4
  export const loadSkillName = 'load_skill'
5
5
 
6
6
  export const loadSkillDescription =
7
- 'Load the full ColdIQ GTM playbook ("skill") by name the complete step-by-step expert workflow, ' +
7
+ 'Legacy compatibility tool. Prefer search_knowledge for the current task. Loads a ColdIQ GTM playbook ' +
8
+ '("skill") by name — the complete step-by-step expert workflow, ' +
8
9
  'including which ColdIQ tools/endpoints to call at each step. Call list_skills first to get the available ' +
9
10
  'names. The returned guide (with any referenced reference docs inlined) tells you exactly how to run the ' +
10
11
  'task using the other ColdIQ tools.'
@@ -0,0 +1,50 @@
1
+ import { z } from 'zod'
2
+ import { callApi } from '../client.js'
3
+
4
+ const KNOWLEDGE_DOC_TYPES = ['play', 'provider', 'strategy'] as const
5
+ const DEFAULT_KNOWLEDGE_DOC_TYPES = ['play', 'strategy'] as const
6
+
7
+ export const searchKnowledgeName = 'search_knowledge'
8
+
9
+ export const searchKnowledgeDescription =
10
+ 'MANDATORY FIRST TOOL: for every new substantive GTM task, call search_knowledge before any other ColdIQ tool. ' +
11
+ 'Pass the user’s complete task as the query and do not reuse results from a previous task. FREE. Searches ColdIQ ' +
12
+ 'plays, provider deep-docs, and GTM strategy, returning ranked metadata and bounded document bodies. Follow the ' +
13
+ 'retrieved workflow and provider order. If search fails or returns no usable result, continue with endpoint discovery.'
14
+
15
+ export const searchKnowledgeSchema = {
16
+ query: z
17
+ .string()
18
+ .describe('The user’s complete substantive GTM task, including requested entities, filters, outputs, and count.'),
19
+ types: z
20
+ .array(z.enum(KNOWLEDGE_DOC_TYPES))
21
+ .max(3)
22
+ .optional()
23
+ .describe('Optional document filters: play, provider, or strategy. Omit to search plays and strategy; request provider explicitly for provider documentation.'),
24
+ limit: z
25
+ .number()
26
+ .int()
27
+ .min(1)
28
+ .max(15)
29
+ .default(5)
30
+ .optional()
31
+ .describe('Maximum ranked documents to return (default 5, max 15).'),
32
+ }
33
+
34
+ export async function searchKnowledgeHandler(input: Record<string, unknown>) {
35
+ const res = await callApi('POST', '/knowledge/search', {
36
+ query: input.query,
37
+ types: input.types ?? [...DEFAULT_KNOWLEDGE_DOC_TYPES],
38
+ limit: input.limit,
39
+ })
40
+ if (!res.ok) {
41
+ return {
42
+ content: [{
43
+ type: 'text' as const,
44
+ text: JSON.stringify({ error: 'Knowledge search failed', status: res.status }),
45
+ }],
46
+ isError: true,
47
+ }
48
+ }
49
+ return { content: [{ type: 'text' as const, text: JSON.stringify(res.data) }] }
50
+ }
@@ -226,9 +226,8 @@ const GATED_DESCRIPTIONS: Partial<Record<Capability, Partial<Record<string, Gate
226
226
  verify_email: {},
227
227
  // -------------------------------------------------------------------------
228
228
  find_phone: {
229
- findymail: { kind: 'requires', fields: 'linkedin_url' },
230
- limadata: { kind: 'requires', fields: 'linkedin_url or (first_name, last_name, and company domain)' },
231
- 'ai-ark': { kind: 'requires', fields: 'linkedin_url or (first_name, last_name, and company domain)' },
229
+ limadata: { kind: 'requires', fields: 'linkedin_url or (first_name, last_name, and domain/company_name)' },
230
+ 'ai-ark': { kind: 'requires', fields: 'linkedin_url or (first_name, last_name, and domain)' },
232
231
  },
233
232
  // -------------------------------------------------------------------------
234
233
  enrich_company: {
@@ -96,9 +96,9 @@ const STRENGTHS: Partial<Record<InsightCapability, Record<string, string>>> = {
96
96
  'linkupapi-validate': 'LinkedIn-aware email validation',
97
97
  },
98
98
  find_phone: {
99
- findymail: 'verified mobile-number coverage',
100
99
  limadata: 'phone lookup by LinkedIn URL or name + company',
101
100
  'ai-ark': 'phone lookup across multiple identifiers',
101
+ fullenrich: 'a phone waterfall aggregated across many upstream sources',
102
102
  },
103
103
  enrich_company: {
104
104
  companyenrich: 'broad firmographic enrichment from a domain',
@@ -41,6 +41,18 @@ describe('client', () => {
41
41
  expect(capturedHeaders['Content-Type']).toBe('application/json')
42
42
  })
43
43
 
44
+ it('declares the mcp client surface on every request (usage attribution)', async () => {
45
+ let capturedHeaders: Record<string, string> = {}
46
+ globalThis.fetch = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
47
+ capturedHeaders = Object.fromEntries(Object.entries(init?.headers ?? {}))
48
+ return new Response(JSON.stringify({}), { status: 200 })
49
+ }) as typeof fetch
50
+
51
+ await callApi('GET', '/me/credits')
52
+
53
+ expect(capturedHeaders['X-ColdIQ-Client-Surface']).toBe('mcp')
54
+ })
55
+
44
56
  it('appends query params for GET requests', async () => {
45
57
  let capturedUrl = ''
46
58
  globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
@@ -0,0 +1,67 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { describe, expect, it } from 'vitest'
4
+ import {
5
+ MANDATORY_KNOWLEDGE_FIRST_RULE,
6
+ MCP_SERVER_INSTRUCTIONS,
7
+ } from '../src/instructions.js'
8
+ import {
9
+ KNOWLEDGE_FIRST_REQUIREMENT,
10
+ withKnowledgeFirstRequirement,
11
+ } from '../src/knowledge-policy.js'
12
+
13
+ const VALID_INSTRUCTION_TOOL_NAMES = new Set([
14
+ 'search_knowledge',
15
+ 'search_endpoints',
16
+ 'get_endpoint_details',
17
+ 'call_endpoint',
18
+ 'list_skills',
19
+ 'load_skill',
20
+ ])
21
+
22
+ describe('MCP knowledge-first policy', () => {
23
+ it('keeps server instructions within client limits and front-loads the complete rule', () => {
24
+ expect(Buffer.byteLength(MCP_SERVER_INSTRUCTIONS, 'utf8')).toBeLessThan(2_048)
25
+ expect(Buffer.byteLength(MANDATORY_KNOWLEDGE_FIRST_RULE, 'utf8')).toBeLessThanOrEqual(512)
26
+ expect(MCP_SERVER_INSTRUCTIONS.startsWith(MANDATORY_KNOWLEDGE_FIRST_RULE)).toBe(true)
27
+ expect(Buffer.from(MCP_SERVER_INSTRUCTIONS, 'utf8').subarray(0, 512).toString())
28
+ .toContain(MANDATORY_KNOWLEDGE_FIRST_RULE)
29
+ })
30
+
31
+ it('mentions only real, valid MCP tool names', () => {
32
+ const referencedNames = [...MCP_SERVER_INSTRUCTIONS.matchAll(/`([a-z0-9_-]+)`/g)]
33
+ .map((match) => match[1])
34
+
35
+ expect(referencedNames.length).toBeGreaterThan(0)
36
+ for (const name of referencedNames) {
37
+ expect(name).toMatch(/^[a-zA-Z0-9_-]{1,128}$/)
38
+ expect(VALID_INSTRUCTION_TOOL_NAMES.has(name)).toBe(true)
39
+ }
40
+ })
41
+
42
+ it('prepends the knowledge prerequisite to operational descriptions', () => {
43
+ const description = withKnowledgeFirstRequirement('Perform an operation.')
44
+
45
+ expect(description.startsWith(KNOWLEDGE_FIRST_REQUIREMENT)).toBe(true)
46
+ expect(description).toContain('search_knowledge as the first ColdIQ tool')
47
+ })
48
+
49
+ it('registers search_knowledge first and guards every operational description', () => {
50
+ const indexPath = fileURLToPath(new URL('../src/index.ts', import.meta.url))
51
+ const registrations = readFileSync(indexPath, 'utf8')
52
+ .split('\n')
53
+ .filter((line) => line.trimStart().startsWith('server.tool('))
54
+ const unguardedCompatibilityTools = [
55
+ 'getCreditBalanceName',
56
+ 'getSessionUsageName',
57
+ 'listSkillsName',
58
+ 'loadSkillName',
59
+ ]
60
+
61
+ expect(registrations[0]).toContain('server.tool(searchKnowledgeName, searchKnowledgeDescription')
62
+ for (const registration of registrations.slice(1)) {
63
+ if (unguardedCompatibilityTools.some((name) => registration.includes(`server.tool(${name},`))) continue
64
+ expect(registration).toContain('withKnowledgeFirstRequirement(')
65
+ }
66
+ })
67
+ })
@@ -950,35 +950,12 @@ describe('registry', () => {
950
950
  })
951
951
 
952
952
  describe('find_phone', () => {
953
- it('providers are ordered leadmagic → findymail → limadata → ai-ark', () => {
953
+ it('providers mirror the server order: prospeo → leadmagic → ai-ark → limadata → fullenrich', () => {
954
954
  const providers = getProviders('find_phone')
955
- expect(providers.map((p) => p.id)).toEqual(['leadmagic', 'findymail', 'limadata', 'ai-ark'])
955
+ expect(providers.map((p) => p.id)).toEqual(['prospeo', 'leadmagic', 'ai-ark', 'limadata', 'fullenrich'])
956
956
  })
957
957
 
958
- it('Findymail isApplicable requires linkedin_url', () => {
959
- const fm = getProviders('find_phone').find((p) => p.id === 'findymail')!
960
- expect(fm.isApplicable!({})).toBe(false)
961
- expect(fm.isApplicable!({ first_name: 'Michel', last_name: 'Lieben', company_domain: 'coldiq.com' })).toBe(false)
962
- expect(fm.isApplicable!({ linkedin_url: 'https://linkedin.com/in/michel-lieben' })).toBe(true)
963
- })
964
-
965
- it('Findymail mapParams passes linkedin_url', () => {
966
- const fm = getProviders('find_phone').find((p) => p.id === 'findymail')!
967
- expect(fm.mapParams({ linkedin_url: 'https://linkedin.com/in/michel-lieben' }).body).toEqual({
968
- linkedin_url: 'https://linkedin.com/in/michel-lieben',
969
- })
970
- })
971
-
972
- it('Findymail hasResult accepts top-level phone or phones array', () => {
973
- const fm = getProviders('find_phone').find((p) => p.id === 'findymail')!
974
- expect(fm.hasResult({ phone: '+33612345678' })).toBe(true)
975
- expect(fm.hasResult({ phones: ['+33612345678'] })).toBe(true)
976
- expect(fm.hasResult({})).toBe(false)
977
- expect(fm.hasResult({ phone: null })).toBe(false)
978
- expect(fm.hasResult({ phones: [] })).toBe(false)
979
- })
980
-
981
- it('Lima Data isApplicable accepts linkedin_url or name+company', () => {
958
+ it('Lima Data isApplicable accepts linkedin_url or name+company (incl. the schema `domain` field)', () => {
982
959
  const ld = getProviders('find_phone').find((p) => p.id === 'limadata')!
983
960
  expect(ld.isApplicable!({})).toBe(false)
984
961
  expect(ld.isApplicable!({ first_name: 'Michel' })).toBe(false)
@@ -986,6 +963,7 @@ describe('registry', () => {
986
963
  expect(ld.isApplicable!({ linkedin_url: 'https://linkedin.com/in/michel-lieben' })).toBe(true)
987
964
  expect(ld.isApplicable!({ first_name: 'Michel', last_name: 'Lieben', company_name: 'ColdIQ' })).toBe(true)
988
965
  expect(ld.isApplicable!({ first_name: 'Michel', last_name: 'Lieben', company_domain: 'coldiq.com' })).toBe(true)
966
+ expect(ld.isApplicable!({ first_name: 'Michel', last_name: 'Lieben', domain: 'coldiq.com' })).toBe(true)
989
967
  })
990
968
 
991
969
  it('Lima Data mapParams forwards linkedin_url path', () => {
@@ -995,12 +973,16 @@ describe('registry', () => {
995
973
  })
996
974
  })
997
975
 
998
- it('Lima Data mapParams forwards name+company path', () => {
976
+ it('Lima Data mapParams forwards name+company path (schema `domain` maps to company_domain)', () => {
999
977
  const ld = getProviders('find_phone').find((p) => p.id === 'limadata')!
1000
978
  expect(ld.mapParams({ first_name: 'Michel', last_name: 'Lieben', company_domain: 'coldiq.com' }).body).toEqual({
1001
979
  name: 'Michel Lieben',
1002
980
  company_domain: 'coldiq.com',
1003
981
  })
982
+ expect(ld.mapParams({ first_name: 'Michel', last_name: 'Lieben', domain: 'coldiq.com' }).body).toEqual({
983
+ name: 'Michel Lieben',
984
+ company_domain: 'coldiq.com',
985
+ })
1004
986
  })
1005
987
 
1006
988
  it('Lima Data mapParams forwards company_name when no domain', () => {
@@ -1026,12 +1008,13 @@ describe('registry', () => {
1026
1008
  expect(ld.hasResult({ data: {} })).toBe(false)
1027
1009
  })
1028
1010
 
1029
- it('AI Ark isApplicable accepts linkedin_url or name+company_domain (not company_name)', () => {
1011
+ it('AI Ark isApplicable accepts linkedin_url or name+domain (not company_name)', () => {
1030
1012
  const ark = getProviders('find_phone').find((p) => p.id === 'ai-ark')!
1031
1013
  expect(ark.isApplicable!({})).toBe(false)
1032
1014
  expect(ark.isApplicable!({ first_name: 'Michel', last_name: 'Lieben', company_name: 'ColdIQ' })).toBe(false)
1033
1015
  expect(ark.isApplicable!({ linkedin_url: 'https://linkedin.com/in/michel-lieben' })).toBe(true)
1034
1016
  expect(ark.isApplicable!({ first_name: 'Michel', last_name: 'Lieben', company_domain: 'coldiq.com' })).toBe(true)
1017
+ expect(ark.isApplicable!({ first_name: 'Michel', last_name: 'Lieben', domain: 'coldiq.com' })).toBe(true)
1035
1018
  })
1036
1019
 
1037
1020
  it('AI Ark mapParams uses linkedin key (not linkedin_url)', () => {
@@ -1041,12 +1024,16 @@ describe('registry', () => {
1041
1024
  })
1042
1025
  })
1043
1026
 
1044
- it('AI Ark mapParams maps name+domain path', () => {
1027
+ it('AI Ark mapParams maps name+domain path (schema `domain` accepted)', () => {
1045
1028
  const ark = getProviders('find_phone').find((p) => p.id === 'ai-ark')!
1046
1029
  expect(ark.mapParams({ first_name: 'Michel', last_name: 'Lieben', company_domain: 'coldiq.com' }).body).toEqual({
1047
1030
  name: 'Michel Lieben',
1048
1031
  domain: 'coldiq.com',
1049
1032
  })
1033
+ expect(ark.mapParams({ first_name: 'Michel', last_name: 'Lieben', domain: 'coldiq.com' }).body).toEqual({
1034
+ name: 'Michel Lieben',
1035
+ domain: 'coldiq.com',
1036
+ })
1050
1037
  })
1051
1038
 
1052
1039
  it('AI Ark hasResult requires non-empty inner string arrays in data', () => {
@@ -29,6 +29,17 @@ describe('get_credit_balance handler', () => {
29
29
  expect(parsed.data).toEqual({ balance: 250, usedThisMonth: 47 })
30
30
  })
31
31
 
32
+ it('passes through a negative true-up balance', async () => {
33
+ globalThis.fetch = vi.fn(async () =>
34
+ new Response(JSON.stringify({ balance: -1.674351, usedThisMonth: 2.199351 }), { status: 200 })
35
+ ) as typeof fetch
36
+
37
+ const result = await getCreditBalanceHandler({})
38
+ const parsed = JSON.parse(result.content[0].text)
39
+
40
+ expect(parsed.data).toEqual({ balance: -1.674351, usedThisMonth: 2.199351 })
41
+ })
42
+
32
43
  it('sends the API key as a bearer token', async () => {
33
44
  let capturedAuth: string | null = null
34
45
  globalThis.fetch = vi.fn(async (_url: string | URL | Request, opts?: RequestInit) => {
@@ -0,0 +1,104 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { z } from 'zod'
3
+ import { initClient } from '../../src/client.js'
4
+ import {
5
+ searchKnowledgeDescription,
6
+ searchKnowledgeHandler,
7
+ searchKnowledgeName,
8
+ searchKnowledgeSchema,
9
+ } from '../../src/tools/search-knowledge.js'
10
+
11
+ describe('search_knowledge', () => {
12
+ const originalFetch = globalThis.fetch
13
+
14
+ beforeEach(() => initClient('http://test-api.local', 'test-key'))
15
+ afterEach(() => {
16
+ globalThis.fetch = originalFetch
17
+ })
18
+
19
+ it('matches the agent schema and repeats the mandatory first-tool rule', () => {
20
+ const schema = z.object(searchKnowledgeSchema).strict()
21
+
22
+ expect(searchKnowledgeName).toBe('search_knowledge')
23
+ expect(searchKnowledgeDescription).toMatch(/mandatory first tool/i)
24
+ expect(searchKnowledgeDescription).toContain('before any other ColdIQ tool')
25
+ expect(schema.parse({ query: 'Find ColdIQ decision makers' })).toEqual({
26
+ query: 'Find ColdIQ decision makers',
27
+ })
28
+ expect(schema.parse({
29
+ query: 'Find ColdIQ decision makers',
30
+ types: ['play', 'provider'],
31
+ limit: 10,
32
+ })).toEqual({
33
+ query: 'Find ColdIQ decision makers',
34
+ types: ['play', 'provider'],
35
+ limit: 10,
36
+ })
37
+ expect(() => schema.parse({ query: 'x', types: ['unknown'] })).toThrow()
38
+ expect(() => schema.parse({ query: 'x', limit: 16 })).toThrow()
39
+ })
40
+
41
+ it('proxies the complete request to /v1/knowledge/search', async () => {
42
+ let capturedUrl = ''
43
+ let capturedBody: unknown
44
+ globalThis.fetch = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
45
+ capturedUrl = url.toString()
46
+ capturedBody = JSON.parse(String(init?.body))
47
+ return new Response(JSON.stringify({
48
+ results: [{ slug: 'find-people', body: '# Find people' }],
49
+ }), { status: 200 })
50
+ }) as typeof fetch
51
+
52
+ const result = await searchKnowledgeHandler({
53
+ query: 'Find ColdIQ decision makers',
54
+ types: ['play'],
55
+ limit: 3,
56
+ })
57
+
58
+ expect(capturedUrl).toBe('http://test-api.local/v1/knowledge/search')
59
+ expect(capturedBody).toEqual({
60
+ query: 'Find ColdIQ decision makers',
61
+ types: ['play'],
62
+ limit: 3,
63
+ })
64
+ expect(result.isError).toBeFalsy()
65
+ expect(JSON.parse(result.content[0].text).results[0].slug).toBe('find-people')
66
+ })
67
+
68
+ it('defaults to workflow knowledge while preserving explicit provider searches', async () => {
69
+ const bodies: unknown[] = []
70
+ globalThis.fetch = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
71
+ bodies.push(JSON.parse(String(init?.body)))
72
+ return new Response(JSON.stringify({ results: [] }), { status: 200 })
73
+ }) as typeof fetch
74
+
75
+ await searchKnowledgeHandler({ query: 'Build a list of ColdIQ decision makers' })
76
+ await searchKnowledgeHandler({ query: 'Apollo request schema', types: ['provider'] })
77
+
78
+ expect(bodies).toEqual([
79
+ {
80
+ query: 'Build a list of ColdIQ decision makers',
81
+ types: ['play', 'strategy'],
82
+ },
83
+ {
84
+ query: 'Apollo request schema',
85
+ types: ['provider'],
86
+ },
87
+ ])
88
+ })
89
+
90
+ it('returns a sanitized error without forwarding response details', async () => {
91
+ globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({
92
+ error: 'database password secret leaked',
93
+ stack: 'internal stack',
94
+ }), { status: 500 })) as typeof fetch
95
+
96
+ const result = await searchKnowledgeHandler({ query: 'Find decision makers' })
97
+ const error = JSON.parse(result.content[0].text)
98
+
99
+ expect(result.isError).toBe(true)
100
+ expect(error).toEqual({ error: 'Knowledge search failed', status: 500 })
101
+ expect(result.content[0].text).not.toContain('secret')
102
+ expect(result.content[0].text).not.toContain('stack')
103
+ })
104
+ })