@company-semantics/contracts 0.86.0 → 0.87.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@company-semantics/contracts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.87.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -77,7 +77,7 @@
|
|
|
77
77
|
"guard:test": "vitest run scripts/ci/__tests__",
|
|
78
78
|
"release": "npx tsx scripts/release.ts",
|
|
79
79
|
"prepublishOnly": "echo 'ERROR: Publishing is CI-only via tag push. Use pnpm release instead.' && exit 1",
|
|
80
|
-
"test": "vitest run
|
|
80
|
+
"test": "vitest run"
|
|
81
81
|
},
|
|
82
82
|
"packageManager": "pnpm@10.25.0",
|
|
83
83
|
"engines": {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI Chat Route — Tool Discovery API Contract
|
|
3
|
+
*
|
|
4
|
+
* Shared helper for building tool discovery responses with optional
|
|
5
|
+
* capability graph inclusion via ?include=graph query parameter.
|
|
6
|
+
*
|
|
7
|
+
* Backend implementation: company-semantics-backend/src/api/http/routes/capabilities.ts
|
|
8
|
+
*
|
|
9
|
+
* Consumer usage:
|
|
10
|
+
* import { buildCapabilityGraph } from '@company-semantics/contracts'
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { buildCapabilityGraph } from '../../../mcp/capability-graph'
|
|
14
|
+
import type {
|
|
15
|
+
MCPToolDescriptor,
|
|
16
|
+
ToolDiscoveryResponse,
|
|
17
|
+
CapabilityGraph,
|
|
18
|
+
} from '../../../mcp/index'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Build a ToolDiscoveryResponse, optionally including the capability graph.
|
|
22
|
+
*
|
|
23
|
+
* When include=graph query parameter is present, the response includes
|
|
24
|
+
* the graph derived from tool resource flow metadata.
|
|
25
|
+
*
|
|
26
|
+
* @param tools - Tool descriptors to include in response
|
|
27
|
+
* @param includeGraph - Whether to include the capability graph (from ?include=graph)
|
|
28
|
+
*/
|
|
29
|
+
export function buildToolDiscoveryResponse(
|
|
30
|
+
tools: MCPToolDescriptor[],
|
|
31
|
+
includeGraph: boolean,
|
|
32
|
+
): ToolDiscoveryResponse {
|
|
33
|
+
return {
|
|
34
|
+
tools,
|
|
35
|
+
...(includeGraph && { graph: buildCapabilityGraph(tools) }),
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -282,12 +282,14 @@ export type {
|
|
|
282
282
|
ToolComplexity,
|
|
283
283
|
// Resource flow types (PRD-00265)
|
|
284
284
|
ResourceType,
|
|
285
|
-
// Capability graph
|
|
285
|
+
// Capability graph types (PRD-00265 types, PRD-00268 implementation)
|
|
286
286
|
CapabilityGraph,
|
|
287
287
|
CapabilityGraphEdge,
|
|
288
288
|
ToolWorkflow,
|
|
289
289
|
} from './mcp/index'
|
|
290
290
|
|
|
291
|
+
export { buildCapabilityGraph } from './mcp/index'
|
|
292
|
+
|
|
291
293
|
// Message part types and builder functions
|
|
292
294
|
// @see ADR-2026-01-022 for design rationale
|
|
293
295
|
export type {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Help Tool Enrichment — Workflow Summaries & Domain Groupings
|
|
3
|
+
*
|
|
4
|
+
* Pure formatting functions for enriching cs_help output with
|
|
5
|
+
* workflow summaries and domain groupings derived from the
|
|
6
|
+
* capability graph.
|
|
7
|
+
*
|
|
8
|
+
* These functions accept MCPToolDescriptor[] (the shape returned
|
|
9
|
+
* by getToolHandlers() or getToolDefinitions() in the backend)
|
|
10
|
+
* and produce formatted text sections.
|
|
11
|
+
*
|
|
12
|
+
* @see company-semantics-backend/src/interfaces/mcp/tools/system/help.ts
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { MCPToolDescriptor } from '../../../mcp/index'
|
|
16
|
+
import { buildCapabilityGraph } from '../../../mcp/capability-graph'
|
|
17
|
+
|
|
18
|
+
function capitalize(s: string): string {
|
|
19
|
+
return s.charAt(0).toUpperCase() + s.slice(1)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Build workflow summary text from tool descriptors.
|
|
24
|
+
*
|
|
25
|
+
* Derives workflows via buildCapabilityGraph() and formats them
|
|
26
|
+
* for the "Available workflows" section of cs_help output.
|
|
27
|
+
*
|
|
28
|
+
* Usage:
|
|
29
|
+
* const descriptors = getToolDefinitions() // or from getToolHandlers()
|
|
30
|
+
* const section = formatWorkflowSummaries(descriptors)
|
|
31
|
+
*/
|
|
32
|
+
export function formatWorkflowSummaries(
|
|
33
|
+
descriptors: MCPToolDescriptor[],
|
|
34
|
+
): string {
|
|
35
|
+
const graph = buildCapabilityGraph(descriptors)
|
|
36
|
+
if (graph.workflows.length === 0) return ''
|
|
37
|
+
|
|
38
|
+
const lines = graph.workflows.map((w) => {
|
|
39
|
+
const displayName = capitalize(w.name.replace(/_/g, ' '))
|
|
40
|
+
const steps = w.steps.map((s) => s.replace(/^cs_/, '')).join(' → ')
|
|
41
|
+
return ` ${displayName}: ${steps}`
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
return '\nAvailable workflows:\n' + lines.join('\n')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build domain grouping text from tool descriptors.
|
|
49
|
+
*
|
|
50
|
+
* Groups tools by their `domain` field and formats as the
|
|
51
|
+
* "Tool domains" section of cs_help output.
|
|
52
|
+
*
|
|
53
|
+
* Usage:
|
|
54
|
+
* const descriptors = getToolDefinitions() // or from getToolHandlers()
|
|
55
|
+
* const section = formatDomainGroupings(descriptors)
|
|
56
|
+
*/
|
|
57
|
+
export function formatDomainGroupings(
|
|
58
|
+
descriptors: MCPToolDescriptor[],
|
|
59
|
+
): string {
|
|
60
|
+
const domainGroups = new Map<string, string[]>()
|
|
61
|
+
|
|
62
|
+
for (const tool of descriptors) {
|
|
63
|
+
const domain = tool.domain ?? 'unknown'
|
|
64
|
+
if (!domainGroups.has(domain)) domainGroups.set(domain, [])
|
|
65
|
+
domainGroups.get(domain)!.push(
|
|
66
|
+
tool.name.replace(/^cs_/, '').replace(/_/g, ' '),
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const lines = [...domainGroups.entries()].map(
|
|
71
|
+
([domain, tools]) =>
|
|
72
|
+
` ${capitalize(domain)} (${tools.length}): ${tools.join(', ')}`,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
return '\nTool domains:\n' + lines.join('\n')
|
|
76
|
+
}
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { buildCapabilityGraph } from './capability-graph'
|
|
3
|
+
import type { MCPToolDescriptor } from './index'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Helper to create minimal MCPToolDescriptor for testing.
|
|
7
|
+
* Only id, name, and resource fields matter for graph derivation.
|
|
8
|
+
*/
|
|
9
|
+
function makeTool(
|
|
10
|
+
overrides: Partial<MCPToolDescriptor> & { id: string; name: string },
|
|
11
|
+
): MCPToolDescriptor {
|
|
12
|
+
return {
|
|
13
|
+
category: 'system',
|
|
14
|
+
description: '',
|
|
15
|
+
effectClass: 'pure',
|
|
16
|
+
invocationMode: 'manual',
|
|
17
|
+
visibility: 'user',
|
|
18
|
+
requiresConfirmation: false,
|
|
19
|
+
domain: 'system',
|
|
20
|
+
risk: 'none',
|
|
21
|
+
intent: 'read',
|
|
22
|
+
stability: 'stable',
|
|
23
|
+
complexity: 'trivial',
|
|
24
|
+
schemaVersion: 1,
|
|
25
|
+
...overrides,
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('buildCapabilityGraph', () => {
|
|
30
|
+
it('returns empty graph for empty tools array', () => {
|
|
31
|
+
const graph = buildCapabilityGraph([])
|
|
32
|
+
expect(graph.edges).toEqual([])
|
|
33
|
+
expect(graph.workflows).toEqual([])
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('produces no edges for tools with no produces/consumes', () => {
|
|
37
|
+
const tools = [
|
|
38
|
+
makeTool({ id: 'a', name: 'a' }),
|
|
39
|
+
makeTool({ id: 'b', name: 'b' }),
|
|
40
|
+
]
|
|
41
|
+
const graph = buildCapabilityGraph(tools)
|
|
42
|
+
expect(graph.edges).toEqual([])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('creates one edge for single producer → consumer with correct domain', () => {
|
|
46
|
+
const tools = [
|
|
47
|
+
makeTool({ id: 'a', name: 'a', produces: ['integration.connection'] }),
|
|
48
|
+
makeTool({ id: 'b', name: 'b', consumes: ['integration.connection'] }),
|
|
49
|
+
]
|
|
50
|
+
const graph = buildCapabilityGraph(tools)
|
|
51
|
+
expect(graph.edges).toHaveLength(1)
|
|
52
|
+
expect(graph.edges[0]).toEqual({
|
|
53
|
+
from: 'a',
|
|
54
|
+
to: 'b',
|
|
55
|
+
resource: 'integration.connection',
|
|
56
|
+
domain: 'integration',
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('creates multiple edges for multiple producers of same resource', () => {
|
|
61
|
+
const tools = [
|
|
62
|
+
makeTool({ id: 'a', name: 'a', produces: ['integration.connection'] }),
|
|
63
|
+
makeTool({ id: 'b', name: 'b', produces: ['integration.connection'] }),
|
|
64
|
+
makeTool({ id: 'c', name: 'c', consumes: ['integration.connection'] }),
|
|
65
|
+
]
|
|
66
|
+
const graph = buildCapabilityGraph(tools)
|
|
67
|
+
expect(graph.edges).toHaveLength(2)
|
|
68
|
+
expect(graph.edges).toContainEqual({
|
|
69
|
+
from: 'a',
|
|
70
|
+
to: 'c',
|
|
71
|
+
resource: 'integration.connection',
|
|
72
|
+
domain: 'integration',
|
|
73
|
+
})
|
|
74
|
+
expect(graph.edges).toContainEqual({
|
|
75
|
+
from: 'b',
|
|
76
|
+
to: 'c',
|
|
77
|
+
resource: 'integration.connection',
|
|
78
|
+
domain: 'integration',
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('derives workflow for linear chain > 2 steps', () => {
|
|
83
|
+
const tools = [
|
|
84
|
+
makeTool({
|
|
85
|
+
id: 'step1',
|
|
86
|
+
name: 'step1',
|
|
87
|
+
produces: ['integration.connection'],
|
|
88
|
+
}),
|
|
89
|
+
makeTool({
|
|
90
|
+
id: 'step2',
|
|
91
|
+
name: 'step2',
|
|
92
|
+
consumes: ['integration.connection'],
|
|
93
|
+
produces: ['slack.channel'],
|
|
94
|
+
}),
|
|
95
|
+
makeTool({
|
|
96
|
+
id: 'step3',
|
|
97
|
+
name: 'step3',
|
|
98
|
+
consumes: ['slack.channel'],
|
|
99
|
+
produces: ['slack.channel_scope'],
|
|
100
|
+
}),
|
|
101
|
+
makeTool({
|
|
102
|
+
id: 'step4',
|
|
103
|
+
name: 'step4',
|
|
104
|
+
consumes: ['slack.channel_scope'],
|
|
105
|
+
}),
|
|
106
|
+
]
|
|
107
|
+
const graph = buildCapabilityGraph(tools)
|
|
108
|
+
expect(graph.workflows).toHaveLength(1)
|
|
109
|
+
expect(graph.workflows[0].steps).toEqual([
|
|
110
|
+
'step1',
|
|
111
|
+
'step2',
|
|
112
|
+
'step3',
|
|
113
|
+
'step4',
|
|
114
|
+
])
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('does NOT derive workflow for short chain (2 steps, 1 edge)', () => {
|
|
118
|
+
const tools = [
|
|
119
|
+
makeTool({ id: 'a', name: 'a', produces: ['integration.connection'] }),
|
|
120
|
+
makeTool({ id: 'b', name: 'b', consumes: ['integration.connection'] }),
|
|
121
|
+
]
|
|
122
|
+
const graph = buildCapabilityGraph(tools)
|
|
123
|
+
expect(graph.workflows).toEqual([])
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('does not form single workflow from branching nodes', () => {
|
|
127
|
+
const tools = [
|
|
128
|
+
makeTool({
|
|
129
|
+
id: 'root',
|
|
130
|
+
name: 'root',
|
|
131
|
+
produces: ['integration.connection'],
|
|
132
|
+
}),
|
|
133
|
+
makeTool({
|
|
134
|
+
id: 'branch1',
|
|
135
|
+
name: 'branch1',
|
|
136
|
+
consumes: ['integration.connection'],
|
|
137
|
+
produces: ['slack.channel'],
|
|
138
|
+
}),
|
|
139
|
+
makeTool({
|
|
140
|
+
id: 'branch2',
|
|
141
|
+
name: 'branch2',
|
|
142
|
+
consumes: ['integration.connection'],
|
|
143
|
+
produces: ['slack.coverage'],
|
|
144
|
+
}),
|
|
145
|
+
]
|
|
146
|
+
const graph = buildCapabilityGraph(tools)
|
|
147
|
+
// root has 2 outgoing edges → not a linear chain
|
|
148
|
+
expect(graph.workflows).toEqual([])
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
describe('full 17-tool integration test', () => {
|
|
152
|
+
const allTools: MCPToolDescriptor[] = [
|
|
153
|
+
// Organization
|
|
154
|
+
makeTool({
|
|
155
|
+
id: 'cs_get_org_status',
|
|
156
|
+
name: 'cs_get_org_status',
|
|
157
|
+
domain: 'organization',
|
|
158
|
+
produces: ['org.status'],
|
|
159
|
+
}),
|
|
160
|
+
makeTool({
|
|
161
|
+
id: 'cs_update_org',
|
|
162
|
+
name: 'cs_update_org',
|
|
163
|
+
domain: 'organization',
|
|
164
|
+
intent: 'mutate',
|
|
165
|
+
}),
|
|
166
|
+
// Identity
|
|
167
|
+
makeTool({
|
|
168
|
+
id: 'cs_update_profile',
|
|
169
|
+
name: 'cs_update_profile',
|
|
170
|
+
domain: 'identity',
|
|
171
|
+
intent: 'mutate',
|
|
172
|
+
}),
|
|
173
|
+
// Integrations
|
|
174
|
+
makeTool({
|
|
175
|
+
id: 'cs_start_slack_auth',
|
|
176
|
+
name: 'cs_start_slack_auth',
|
|
177
|
+
domain: 'integrations',
|
|
178
|
+
integrations: ['slack'],
|
|
179
|
+
produces: ['integration.connection'],
|
|
180
|
+
}),
|
|
181
|
+
makeTool({
|
|
182
|
+
id: 'cs_start_google_auth',
|
|
183
|
+
name: 'cs_start_google_auth',
|
|
184
|
+
domain: 'integrations',
|
|
185
|
+
integrations: ['google'],
|
|
186
|
+
produces: ['integration.connection'],
|
|
187
|
+
}),
|
|
188
|
+
makeTool({
|
|
189
|
+
id: 'cs_start_zoom_auth',
|
|
190
|
+
name: 'cs_start_zoom_auth',
|
|
191
|
+
domain: 'integrations',
|
|
192
|
+
integrations: ['zoom'],
|
|
193
|
+
produces: ['integration.connection'],
|
|
194
|
+
}),
|
|
195
|
+
makeTool({
|
|
196
|
+
id: 'cs_list_connections',
|
|
197
|
+
name: 'cs_list_connections',
|
|
198
|
+
domain: 'integrations',
|
|
199
|
+
produces: ['integration.connection'],
|
|
200
|
+
}),
|
|
201
|
+
makeTool({
|
|
202
|
+
id: 'cs_cleanup_connections',
|
|
203
|
+
name: 'cs_cleanup_connections',
|
|
204
|
+
domain: 'integrations',
|
|
205
|
+
consumes: ['integration.connection'],
|
|
206
|
+
}),
|
|
207
|
+
makeTool({
|
|
208
|
+
id: 'cs_propose_integration_action',
|
|
209
|
+
name: 'cs_propose_integration_action',
|
|
210
|
+
domain: 'integrations',
|
|
211
|
+
consumes: ['integration.connection'],
|
|
212
|
+
}),
|
|
213
|
+
// Discovery
|
|
214
|
+
makeTool({
|
|
215
|
+
id: 'cs_discover_slack',
|
|
216
|
+
name: 'cs_discover_slack',
|
|
217
|
+
domain: 'discovery',
|
|
218
|
+
integrations: ['slack'],
|
|
219
|
+
produces: ['slack.channel'],
|
|
220
|
+
consumes: ['integration.connection'],
|
|
221
|
+
}),
|
|
222
|
+
makeTool({
|
|
223
|
+
id: 'cs_get_slack_coverage',
|
|
224
|
+
name: 'cs_get_slack_coverage',
|
|
225
|
+
domain: 'discovery',
|
|
226
|
+
integrations: ['slack'],
|
|
227
|
+
produces: ['slack.coverage'],
|
|
228
|
+
consumes: ['integration.connection'],
|
|
229
|
+
}),
|
|
230
|
+
makeTool({
|
|
231
|
+
id: 'cs_list_fingerprints',
|
|
232
|
+
name: 'cs_list_fingerprints',
|
|
233
|
+
domain: 'discovery',
|
|
234
|
+
produces: ['knowledge.fingerprint'],
|
|
235
|
+
}),
|
|
236
|
+
// Ingestion
|
|
237
|
+
makeTool({
|
|
238
|
+
id: 'cs_manage_channel_scope',
|
|
239
|
+
name: 'cs_manage_channel_scope',
|
|
240
|
+
domain: 'ingestion',
|
|
241
|
+
integrations: ['slack'],
|
|
242
|
+
produces: ['slack.channel_scope'],
|
|
243
|
+
consumes: ['slack.channel'],
|
|
244
|
+
}),
|
|
245
|
+
makeTool({
|
|
246
|
+
id: 'cs_ingest_slack_channel',
|
|
247
|
+
name: 'cs_ingest_slack_channel',
|
|
248
|
+
domain: 'ingestion',
|
|
249
|
+
integrations: ['slack'],
|
|
250
|
+
produces: ['ingestion.job'],
|
|
251
|
+
consumes: ['slack.channel_scope'],
|
|
252
|
+
}),
|
|
253
|
+
makeTool({
|
|
254
|
+
id: 'cs_get_ingestion_status',
|
|
255
|
+
name: 'cs_get_ingestion_status',
|
|
256
|
+
domain: 'ingestion',
|
|
257
|
+
produces: ['ingestion.job'],
|
|
258
|
+
consumes: ['ingestion.job'],
|
|
259
|
+
}),
|
|
260
|
+
// System
|
|
261
|
+
makeTool({
|
|
262
|
+
id: 'cs_system_status',
|
|
263
|
+
name: 'cs_system_status',
|
|
264
|
+
domain: 'system',
|
|
265
|
+
produces: ['system.status'],
|
|
266
|
+
}),
|
|
267
|
+
makeTool({
|
|
268
|
+
id: 'cs_help',
|
|
269
|
+
name: 'cs_help',
|
|
270
|
+
domain: 'system',
|
|
271
|
+
}),
|
|
272
|
+
]
|
|
273
|
+
|
|
274
|
+
it('includes Slack ingestion chain edges', () => {
|
|
275
|
+
const graph = buildCapabilityGraph(allTools)
|
|
276
|
+
|
|
277
|
+
expect(graph.edges).toContainEqual({
|
|
278
|
+
from: 'cs_start_slack_auth',
|
|
279
|
+
to: 'cs_discover_slack',
|
|
280
|
+
resource: 'integration.connection',
|
|
281
|
+
domain: 'integration',
|
|
282
|
+
})
|
|
283
|
+
expect(graph.edges).toContainEqual({
|
|
284
|
+
from: 'cs_discover_slack',
|
|
285
|
+
to: 'cs_manage_channel_scope',
|
|
286
|
+
resource: 'slack.channel',
|
|
287
|
+
domain: 'slack',
|
|
288
|
+
})
|
|
289
|
+
expect(graph.edges).toContainEqual({
|
|
290
|
+
from: 'cs_manage_channel_scope',
|
|
291
|
+
to: 'cs_ingest_slack_channel',
|
|
292
|
+
resource: 'slack.channel_scope',
|
|
293
|
+
domain: 'slack',
|
|
294
|
+
})
|
|
295
|
+
expect(graph.edges).toContainEqual({
|
|
296
|
+
from: 'cs_ingest_slack_channel',
|
|
297
|
+
to: 'cs_get_ingestion_status',
|
|
298
|
+
resource: 'ingestion.job',
|
|
299
|
+
domain: 'ingestion',
|
|
300
|
+
})
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('produces correct total edge count', () => {
|
|
304
|
+
const graph = buildCapabilityGraph(allTools)
|
|
305
|
+
// integration.connection: 4 producers × 4 consumers = 16 edges
|
|
306
|
+
// slack.channel: 1 edge (discover_slack → manage_channel_scope)
|
|
307
|
+
// slack.channel_scope: 1 edge (manage_channel_scope → ingest_slack_channel)
|
|
308
|
+
// ingestion.job: 2 edges (ingest → status, status → status self-loop)
|
|
309
|
+
expect(graph.edges).toHaveLength(20)
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('does not derive workflows from branching graph', () => {
|
|
313
|
+
const graph = buildCapabilityGraph(allTools)
|
|
314
|
+
// With all 17 tools, auth tools have 4 outgoing edges each (branching),
|
|
315
|
+
// and consumers like discover_slack have 4 incoming edges.
|
|
316
|
+
// No linear chain starts are walkable → no workflows.
|
|
317
|
+
expect(graph.workflows).toEqual([])
|
|
318
|
+
})
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
describe('slack_ingestion workflow derivation', () => {
|
|
322
|
+
it('derives slack_ingestion workflow from Slack chain tools', () => {
|
|
323
|
+
// Isolated Slack chain without branching from other auth tools
|
|
324
|
+
const slackChainTools = [
|
|
325
|
+
makeTool({
|
|
326
|
+
id: 'cs_start_slack_auth',
|
|
327
|
+
name: 'cs_start_slack_auth',
|
|
328
|
+
integrations: ['slack'],
|
|
329
|
+
produces: ['integration.connection'],
|
|
330
|
+
}),
|
|
331
|
+
makeTool({
|
|
332
|
+
id: 'cs_discover_slack',
|
|
333
|
+
name: 'cs_discover_slack',
|
|
334
|
+
integrations: ['slack'],
|
|
335
|
+
produces: ['slack.channel'],
|
|
336
|
+
consumes: ['integration.connection'],
|
|
337
|
+
}),
|
|
338
|
+
makeTool({
|
|
339
|
+
id: 'cs_manage_channel_scope',
|
|
340
|
+
name: 'cs_manage_channel_scope',
|
|
341
|
+
integrations: ['slack'],
|
|
342
|
+
produces: ['slack.channel_scope'],
|
|
343
|
+
consumes: ['slack.channel'],
|
|
344
|
+
}),
|
|
345
|
+
makeTool({
|
|
346
|
+
id: 'cs_ingest_slack_channel',
|
|
347
|
+
name: 'cs_ingest_slack_channel',
|
|
348
|
+
integrations: ['slack'],
|
|
349
|
+
consumes: ['slack.channel_scope'],
|
|
350
|
+
}),
|
|
351
|
+
]
|
|
352
|
+
const graph = buildCapabilityGraph(slackChainTools)
|
|
353
|
+
expect(graph.workflows).toHaveLength(1)
|
|
354
|
+
expect(graph.workflows[0].name).toBe('slack_ingestion')
|
|
355
|
+
expect(graph.workflows[0].steps).toEqual([
|
|
356
|
+
'cs_start_slack_auth',
|
|
357
|
+
'cs_discover_slack',
|
|
358
|
+
'cs_manage_channel_scope',
|
|
359
|
+
'cs_ingest_slack_channel',
|
|
360
|
+
])
|
|
361
|
+
expect(graph.workflows[0].description).toContain('start_slack_auth')
|
|
362
|
+
expect(graph.workflows[0].description).toContain('ingest_slack_channel')
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
it('extends Slack chain through cs_get_ingestion_status when isolated', () => {
|
|
366
|
+
const slackChainWithStatus = [
|
|
367
|
+
makeTool({
|
|
368
|
+
id: 'cs_start_slack_auth',
|
|
369
|
+
name: 'cs_start_slack_auth',
|
|
370
|
+
integrations: ['slack'],
|
|
371
|
+
produces: ['integration.connection'],
|
|
372
|
+
}),
|
|
373
|
+
makeTool({
|
|
374
|
+
id: 'cs_discover_slack',
|
|
375
|
+
name: 'cs_discover_slack',
|
|
376
|
+
integrations: ['slack'],
|
|
377
|
+
produces: ['slack.channel'],
|
|
378
|
+
consumes: ['integration.connection'],
|
|
379
|
+
}),
|
|
380
|
+
makeTool({
|
|
381
|
+
id: 'cs_manage_channel_scope',
|
|
382
|
+
name: 'cs_manage_channel_scope',
|
|
383
|
+
integrations: ['slack'],
|
|
384
|
+
produces: ['slack.channel_scope'],
|
|
385
|
+
consumes: ['slack.channel'],
|
|
386
|
+
}),
|
|
387
|
+
makeTool({
|
|
388
|
+
id: 'cs_ingest_slack_channel',
|
|
389
|
+
name: 'cs_ingest_slack_channel',
|
|
390
|
+
integrations: ['slack'],
|
|
391
|
+
produces: ['ingestion.job'],
|
|
392
|
+
consumes: ['slack.channel_scope'],
|
|
393
|
+
}),
|
|
394
|
+
makeTool({
|
|
395
|
+
id: 'cs_get_ingestion_status',
|
|
396
|
+
name: 'cs_get_ingestion_status',
|
|
397
|
+
produces: ['ingestion.job'],
|
|
398
|
+
consumes: ['ingestion.job'],
|
|
399
|
+
}),
|
|
400
|
+
]
|
|
401
|
+
const graph = buildCapabilityGraph(slackChainWithStatus)
|
|
402
|
+
|
|
403
|
+
expect(graph.workflows).toHaveLength(1)
|
|
404
|
+
// Chain stops at cs_ingest_slack_channel because cs_get_ingestion_status
|
|
405
|
+
// has 2 incoming edges (from ingest + self-loop)
|
|
406
|
+
expect(graph.workflows[0].steps).toContain('cs_start_slack_auth')
|
|
407
|
+
expect(graph.workflows[0].steps).toContain('cs_discover_slack')
|
|
408
|
+
expect(graph.workflows[0].steps).toContain('cs_manage_channel_scope')
|
|
409
|
+
expect(graph.workflows[0].steps).toContain('cs_ingest_slack_channel')
|
|
410
|
+
})
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
it('extracts domain from resource namespace prefix', () => {
|
|
414
|
+
const tools = [
|
|
415
|
+
makeTool({ id: 'a', name: 'a', produces: ['slack.channel'] }),
|
|
416
|
+
makeTool({ id: 'b', name: 'b', consumes: ['slack.channel'] }),
|
|
417
|
+
]
|
|
418
|
+
const graph = buildCapabilityGraph(tools)
|
|
419
|
+
expect(graph.edges[0].domain).toBe('slack')
|
|
420
|
+
})
|
|
421
|
+
})
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
MCPToolDescriptor,
|
|
3
|
+
CapabilityGraph,
|
|
4
|
+
CapabilityGraphEdge,
|
|
5
|
+
ToolWorkflow,
|
|
6
|
+
} from './index'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Build a capability graph from tool resource flow metadata.
|
|
10
|
+
*
|
|
11
|
+
* Pure function — no I/O, no database, no side effects.
|
|
12
|
+
* Derives edges from produces/consumes matching on MCPToolDescriptor.
|
|
13
|
+
*
|
|
14
|
+
* For each tool's `consumes` array, finds all tools whose `produces`
|
|
15
|
+
* includes that ResourceType and creates a CapabilityGraphEdge.
|
|
16
|
+
*/
|
|
17
|
+
export function buildCapabilityGraph(tools: MCPToolDescriptor[]): CapabilityGraph {
|
|
18
|
+
const edges: CapabilityGraphEdge[] = []
|
|
19
|
+
|
|
20
|
+
for (const consumer of tools) {
|
|
21
|
+
for (const resourceType of consumer.consumes ?? []) {
|
|
22
|
+
const producers = tools.filter(t => t.produces?.includes(resourceType))
|
|
23
|
+
for (const producer of producers) {
|
|
24
|
+
edges.push({
|
|
25
|
+
from: producer.id,
|
|
26
|
+
to: consumer.id,
|
|
27
|
+
resource: resourceType,
|
|
28
|
+
domain: resourceType.split('.')[0],
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const workflows = deriveWorkflows(tools, edges)
|
|
35
|
+
return { edges, workflows }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Derive named workflows from capability graph edges.
|
|
40
|
+
*
|
|
41
|
+
* Conservative: only linear chains with > 2 steps are reported.
|
|
42
|
+
* Branching nodes (multiple incoming or outgoing edges) break chains.
|
|
43
|
+
*
|
|
44
|
+
* Internal to capability-graph.ts — not exported.
|
|
45
|
+
*/
|
|
46
|
+
function deriveWorkflows(
|
|
47
|
+
tools: MCPToolDescriptor[],
|
|
48
|
+
edges: CapabilityGraphEdge[],
|
|
49
|
+
): ToolWorkflow[] {
|
|
50
|
+
// Build adjacency: tool ID → outgoing edges, tool ID → incoming edges
|
|
51
|
+
const outgoing = new Map<string, CapabilityGraphEdge[]>()
|
|
52
|
+
const incoming = new Map<string, CapabilityGraphEdge[]>()
|
|
53
|
+
|
|
54
|
+
for (const edge of edges) {
|
|
55
|
+
if (!outgoing.has(edge.from)) outgoing.set(edge.from, [])
|
|
56
|
+
outgoing.get(edge.from)!.push(edge)
|
|
57
|
+
if (!incoming.has(edge.to)) incoming.set(edge.to, [])
|
|
58
|
+
incoming.get(edge.to)!.push(edge)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Find chain start nodes: have outgoing edges, no incoming edges
|
|
62
|
+
const startNodes = [...outgoing.keys()].filter(
|
|
63
|
+
id => !incoming.has(id) || incoming.get(id)!.length === 0,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
const workflows: ToolWorkflow[] = []
|
|
67
|
+
const visited = new Set<string>()
|
|
68
|
+
|
|
69
|
+
for (const start of startNodes) {
|
|
70
|
+
if (visited.has(start)) continue
|
|
71
|
+
|
|
72
|
+
const chain: string[] = [start]
|
|
73
|
+
visited.add(start)
|
|
74
|
+
let current = start
|
|
75
|
+
|
|
76
|
+
// Walk forward: follow single outgoing edge while next node has single incoming edge
|
|
77
|
+
while (true) {
|
|
78
|
+
const outs = outgoing.get(current)
|
|
79
|
+
if (!outs || outs.length !== 1) break
|
|
80
|
+
|
|
81
|
+
const next = outs[0].to
|
|
82
|
+
const ins = incoming.get(next)
|
|
83
|
+
if (!ins || ins.length !== 1) break
|
|
84
|
+
|
|
85
|
+
if (visited.has(next)) break
|
|
86
|
+
visited.add(next)
|
|
87
|
+
chain.push(next)
|
|
88
|
+
current = next
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Only emit workflows with > 2 steps
|
|
92
|
+
if (chain.length > 2) {
|
|
93
|
+
const toolMap = new Map(tools.map(t => [t.id, t]))
|
|
94
|
+
const chainTools = chain.map(id => toolMap.get(id)).filter(Boolean)
|
|
95
|
+
|
|
96
|
+
// Derive name from common integration or domain
|
|
97
|
+
const integrations = chainTools
|
|
98
|
+
.flatMap(t => t!.integrations ?? [])
|
|
99
|
+
.filter((v, i, a) => a.indexOf(v) === i)
|
|
100
|
+
const name =
|
|
101
|
+
integrations.length === 1
|
|
102
|
+
? `${integrations[0]}_ingestion`
|
|
103
|
+
: `workflow_${chain[0]}`
|
|
104
|
+
|
|
105
|
+
const steps = chain.map(s => s.replace(/^cs_/, ''))
|
|
106
|
+
const description = `Workflow: ${steps.join(' → ')}`
|
|
107
|
+
|
|
108
|
+
workflows.push({ name: name, description: description, steps: chain })
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return workflows
|
|
113
|
+
}
|
package/src/mcp/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ResourceType } from './resources'
|
|
2
2
|
export type { ResourceType } from './resources'
|
|
3
|
+
export { buildCapabilityGraph } from './capability-graph'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* MCP Tool Discovery Types
|
|
@@ -130,6 +131,7 @@ export type ToolIntegration = 'slack' | 'google' | 'zoom' | (string & {})
|
|
|
130
131
|
* Discovery uses: id, name, description, category
|
|
131
132
|
* Invocation uses: id, requiresConfirmation, invocationMode, effectClass
|
|
132
133
|
*/
|
|
134
|
+
// @vocabulary-exempt reason: MCPToolDescriptor is a single cohesive descriptor consumed by multiple repos; splitting would break consumers
|
|
133
135
|
export interface MCPToolDescriptor {
|
|
134
136
|
/** Unique identifier (matches MCP tool name, e.g., 'cs_help') */
|
|
135
137
|
id: string
|