@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.
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +3 -0
- package/dist/client.js.map +1 -1
- package/dist/index.js +37 -41
- package/dist/index.js.map +1 -1
- package/dist/instructions.d.ts +3 -0
- package/dist/instructions.d.ts.map +1 -0
- package/dist/instructions.js +9 -0
- package/dist/instructions.js.map +1 -0
- package/dist/knowledge-policy.d.ts +3 -0
- package/dist/knowledge-policy.d.ts.map +1 -0
- package/dist/knowledge-policy.js +5 -0
- package/dist/knowledge-policy.js.map +1 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +125 -42
- package/dist/registry.js.map +1 -1
- package/dist/tools/list-skills.d.ts.map +1 -1
- package/dist/tools/list-skills.js +4 -3
- package/dist/tools/list-skills.js.map +1 -1
- package/dist/tools/load-skill.d.ts.map +1 -1
- package/dist/tools/load-skill.js +2 -1
- package/dist/tools/load-skill.js.map +1 -1
- package/dist/tools/search-knowledge.d.ts +22 -0
- package/dist/tools/search-knowledge.d.ts.map +1 -0
- package/dist/tools/search-knowledge.js +45 -0
- package/dist/tools/search-knowledge.js.map +1 -0
- package/dist/utils/provider-resolver.js +2 -3
- package/dist/utils/provider-resolver.js.map +1 -1
- package/dist/utils/selection-insight.js +1 -1
- package/dist/utils/selection-insight.js.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +3 -0
- package/src/index.ts +42 -41
- package/src/instructions.ts +10 -0
- package/src/knowledge-policy.ts +6 -0
- package/src/registry.ts +120 -41
- package/src/tools/list-skills.ts +4 -3
- package/src/tools/load-skill.ts +2 -1
- package/src/tools/search-knowledge.ts +50 -0
- package/src/utils/provider-resolver.ts +2 -3
- package/src/utils/selection-insight.ts +1 -1
- package/tests/client.test.ts +12 -0
- package/tests/instructions.test.ts +67 -0
- package/tests/registry.test.ts +16 -29
- package/tests/tools/get-credit-balance.test.ts +11 -0
- 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: '
|
|
1862
|
-
endpoint: '/
|
|
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) =>
|
|
1866
|
-
|
|
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.
|
|
1912
|
+
return typeof d.mobile_number === 'string' && (d.mobile_number as string).length > 0
|
|
1870
1913
|
},
|
|
1871
1914
|
},
|
|
1872
1915
|
{
|
|
1873
|
-
id: '
|
|
1874
|
-
endpoint: '/
|
|
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
|
|
1882
|
-
|
|
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 ? {
|
|
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
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
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: '
|
|
1912
|
-
endpoint: '/
|
|
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
|
|
1920
|
-
|
|
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 ? {
|
|
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
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
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: '
|
|
1944
|
-
endpoint: '/
|
|
1989
|
+
id: 'fullenrich',
|
|
1990
|
+
endpoint: '/fullenrich/contact/enrich/bulk',
|
|
1945
1991
|
method: 'POST',
|
|
1946
|
-
priority:
|
|
1947
|
-
isApplicable: (input) =>
|
|
1948
|
-
(typeof input.linkedin_url === 'string' && (input.linkedin_url as string).length > 0)
|
|
1949
|
-
|
|
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:
|
|
1952
|
-
|
|
1953
|
-
: {
|
|
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
|
-
|
|
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
|
]
|
package/src/tools/list-skills.ts
CHANGED
|
@@ -3,11 +3,12 @@ import { listSkills } from '../skills.js'
|
|
|
3
3
|
export const listSkillsName = 'list_skills'
|
|
4
4
|
|
|
5
5
|
export const listSkillsDescription =
|
|
6
|
-
'
|
|
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
|
|
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 = {}
|
package/src/tools/load-skill.ts
CHANGED
|
@@ -4,7 +4,8 @@ import { loadSkill } from '../skills.js'
|
|
|
4
4
|
export const loadSkillName = 'load_skill'
|
|
5
5
|
|
|
6
6
|
export const loadSkillDescription =
|
|
7
|
-
'
|
|
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
|
-
|
|
230
|
-
|
|
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',
|
package/tests/client.test.ts
CHANGED
|
@@ -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
|
+
})
|
package/tests/registry.test.ts
CHANGED
|
@@ -950,35 +950,12 @@ describe('registry', () => {
|
|
|
950
950
|
})
|
|
951
951
|
|
|
952
952
|
describe('find_phone', () => {
|
|
953
|
-
it('providers
|
|
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', '
|
|
955
|
+
expect(providers.map((p) => p.id)).toEqual(['prospeo', 'leadmagic', 'ai-ark', 'limadata', 'fullenrich'])
|
|
956
956
|
})
|
|
957
957
|
|
|
958
|
-
it('
|
|
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+
|
|
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
|
+
})
|