@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.86.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 scripts/ci/__tests__"
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 stubs (PRD-00265, implementation in PRD-00268)
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