@lumenflow/surfaces 5.1.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/LICENSE +190 -0
- package/README.md +40 -0
- package/cli/__tests__/gates.test.ts +97 -0
- package/cli/__tests__/inspect.test.ts +184 -0
- package/cli/__tests__/task-lifecycle.test.ts +203 -0
- package/cli/gates.ts +46 -0
- package/cli/index.ts +6 -0
- package/cli/inspect.ts +138 -0
- package/cli/task-lifecycle.ts +46 -0
- package/http/__tests__/agent-runtime-remote-controls.test.ts +249 -0
- package/http/__tests__/auth-boundary.test.ts +57 -0
- package/http/__tests__/channel-send-governance.test.ts +158 -0
- package/http/__tests__/event-stream.test.ts +340 -0
- package/http/__tests__/phone-device-tool-api.test.ts +177 -0
- package/http/__tests__/remote-exposure.test.ts +212 -0
- package/http/__tests__/run-agent.test.ts +447 -0
- package/http/__tests__/scope-enforcement.test.ts +349 -0
- package/http/__tests__/sidecar-entry.test.ts +158 -0
- package/http/__tests__/tool-api-schema-validation.test.ts +213 -0
- package/http/__tests__/tool-api.test.ts +491 -0
- package/http/__tests__/tool-discovery.test.ts +384 -0
- package/http/ag-ui-adapter.ts +352 -0
- package/http/auth.ts +294 -0
- package/http/control-plane-event-subscriber.ts +233 -0
- package/http/event-stream.ts +216 -0
- package/http/index.ts +10 -0
- package/http/run-agent.ts +416 -0
- package/http/server.ts +329 -0
- package/http/sidecar-entry.ts +218 -0
- package/http/task-api.ts +307 -0
- package/http/tool-api.ts +373 -0
- package/http/tool-discovery.ts +159 -0
- package/mcp/__tests__/server.test.ts +554 -0
- package/mcp/index.ts +4 -0
- package/mcp/server.ts +250 -0
- package/package.json +51 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Copyright (c) 2026 Hellmai Ltd
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
5
|
+
import { createToolScopeForToolName } from '../../control-plane-sdk/src/scope-grammar.js';
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* WU-2639 / INIT-057 Phase 3: GET /tools discovery endpoint.
|
|
9
|
+
*
|
|
10
|
+
* Returns the full tool inventory with pack-aware metadata so pack-gallery
|
|
11
|
+
* clients and the phone UI can render dynamic action surfaces without
|
|
12
|
+
* hardcoded per-pack lists. capabilities_required aligns with the scope
|
|
13
|
+
* grammar from ADR-058 / WU-2636 so per-tool auth (WU-2640) can enforce
|
|
14
|
+
* using the same vocabulary.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const HTTP_METHOD_GET = 'GET';
|
|
18
|
+
const HTTP_STATUS_OK = 200;
|
|
19
|
+
const HTTP_STATUS_METHOD_NOT_ALLOWED = 405;
|
|
20
|
+
const HTTP_STATUS_NOT_FOUND = 404;
|
|
21
|
+
const HEADER_CONTENT_TYPE = 'content-type';
|
|
22
|
+
const CONTENT_TYPE_JSON = 'application/json; charset=utf-8';
|
|
23
|
+
const RESPONSE_ERROR_KEY = 'error';
|
|
24
|
+
const RESPONSE_MESSAGE_KEY = 'message';
|
|
25
|
+
|
|
26
|
+
export interface ToolDiscoveryCostEstimate {
|
|
27
|
+
tokens?: number;
|
|
28
|
+
wall_time_ms?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ToolDiscoveryEntryInput {
|
|
32
|
+
name: string;
|
|
33
|
+
pack: string;
|
|
34
|
+
description: string;
|
|
35
|
+
input_schema: unknown;
|
|
36
|
+
output_schema: unknown;
|
|
37
|
+
requires_approval: boolean;
|
|
38
|
+
capabilities_required?: string[];
|
|
39
|
+
estimated_cost?: ToolDiscoveryCostEstimate;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ToolDiscoveryEntry {
|
|
43
|
+
name: string;
|
|
44
|
+
pack: string;
|
|
45
|
+
description: string;
|
|
46
|
+
input_schema: unknown;
|
|
47
|
+
output_schema: unknown;
|
|
48
|
+
requires_approval: boolean;
|
|
49
|
+
capabilities_required: string[];
|
|
50
|
+
estimated_cost?: ToolDiscoveryCostEstimate;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ToolDiscoveryResponse {
|
|
54
|
+
tools: ToolDiscoveryEntry[];
|
|
55
|
+
surfaces_version: string;
|
|
56
|
+
workspace_id: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ToolDiscoveryRouterOptions {
|
|
60
|
+
toolCatalog: readonly ToolDiscoveryEntryInput[];
|
|
61
|
+
surfacesVersion: string;
|
|
62
|
+
workspaceId: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ToolDiscoveryRouter {
|
|
66
|
+
handleRequest(
|
|
67
|
+
request: IncomingMessage,
|
|
68
|
+
response: ServerResponse<IncomingMessage>,
|
|
69
|
+
routeSegments: string[],
|
|
70
|
+
): Promise<void>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function writeJson(
|
|
74
|
+
response: ServerResponse<IncomingMessage>,
|
|
75
|
+
statusCode: number,
|
|
76
|
+
payload: unknown,
|
|
77
|
+
): void {
|
|
78
|
+
response.statusCode = statusCode;
|
|
79
|
+
response.setHeader(HEADER_CONTENT_TYPE, CONTENT_TYPE_JSON);
|
|
80
|
+
response.end(JSON.stringify(payload));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function writeMethodNotAllowed(response: ServerResponse<IncomingMessage>, method: string): void {
|
|
84
|
+
writeJson(response, HTTP_STATUS_METHOD_NOT_ALLOWED, {
|
|
85
|
+
[RESPONSE_ERROR_KEY]: {
|
|
86
|
+
[RESPONSE_MESSAGE_KEY]: `Unsupported method on /tools: ${method}. Only GET is supported for discovery.`,
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function writeNotFound(response: ServerResponse<IncomingMessage>): void {
|
|
92
|
+
writeJson(response, HTTP_STATUS_NOT_FOUND, {
|
|
93
|
+
[RESPONSE_ERROR_KEY]: {
|
|
94
|
+
[RESPONSE_MESSAGE_KEY]: 'Route not found.',
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function resolveCapabilitiesRequired(entry: ToolDiscoveryEntryInput): string[] {
|
|
100
|
+
if (entry.capabilities_required && entry.capabilities_required.length > 0) {
|
|
101
|
+
return [...entry.capabilities_required];
|
|
102
|
+
}
|
|
103
|
+
return [createToolScopeForToolName(entry.name)];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function normalizeCatalog(catalog: readonly ToolDiscoveryEntryInput[]): ToolDiscoveryEntry[] {
|
|
107
|
+
const deduped = new Map<string, ToolDiscoveryEntry>();
|
|
108
|
+
for (const entry of catalog) {
|
|
109
|
+
if (deduped.has(entry.name)) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const normalized: ToolDiscoveryEntry = {
|
|
113
|
+
name: entry.name,
|
|
114
|
+
pack: entry.pack,
|
|
115
|
+
description: entry.description,
|
|
116
|
+
input_schema: entry.input_schema,
|
|
117
|
+
output_schema: entry.output_schema,
|
|
118
|
+
requires_approval: entry.requires_approval,
|
|
119
|
+
capabilities_required: resolveCapabilitiesRequired(entry),
|
|
120
|
+
};
|
|
121
|
+
if (entry.estimated_cost) {
|
|
122
|
+
normalized.estimated_cost = entry.estimated_cost;
|
|
123
|
+
}
|
|
124
|
+
deduped.set(entry.name, normalized);
|
|
125
|
+
}
|
|
126
|
+
return [...deduped.values()];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function createToolDiscoveryRouter(
|
|
130
|
+
options: ToolDiscoveryRouterOptions,
|
|
131
|
+
): ToolDiscoveryRouter {
|
|
132
|
+
const tools = normalizeCatalog(options.toolCatalog);
|
|
133
|
+
const payload: ToolDiscoveryResponse = {
|
|
134
|
+
tools,
|
|
135
|
+
surfaces_version: options.surfacesVersion,
|
|
136
|
+
workspace_id: options.workspaceId,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
async handleRequest(
|
|
141
|
+
request: IncomingMessage,
|
|
142
|
+
response: ServerResponse<IncomingMessage>,
|
|
143
|
+
routeSegments: string[],
|
|
144
|
+
): Promise<void> {
|
|
145
|
+
if (routeSegments.length !== 0) {
|
|
146
|
+
writeNotFound(response);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const method = request.method ?? '';
|
|
151
|
+
if (method !== HTTP_METHOD_GET) {
|
|
152
|
+
writeMethodNotAllowed(response, method);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
writeJson(response, HTTP_STATUS_OK, payload);
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
// Copyright (c) 2026 Hellmai Ltd
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
8
|
+
import type { KernelRuntime } from '@lumenflow/kernel';
|
|
9
|
+
import { initializeTaskLifecycleCommands } from '../../cli/task-lifecycle.js';
|
|
10
|
+
import { createMcpServer } from '../server.js';
|
|
11
|
+
|
|
12
|
+
const READ_SCOPE = {
|
|
13
|
+
type: 'path' as const,
|
|
14
|
+
pattern: '**',
|
|
15
|
+
access: 'read' as const,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
async function writeWorkspaceFixture(root: string): Promise<void> {
|
|
19
|
+
const packsRoot = join(root, 'packs');
|
|
20
|
+
const packRoot = join(packsRoot, 'software-delivery');
|
|
21
|
+
await mkdir(packRoot, { recursive: true });
|
|
22
|
+
|
|
23
|
+
await writeFile(
|
|
24
|
+
join(root, 'workspace.yaml'),
|
|
25
|
+
[
|
|
26
|
+
'id: workspace-surfaces-mcp',
|
|
27
|
+
'name: Surfaces MCP Workspace',
|
|
28
|
+
'packs:',
|
|
29
|
+
' - id: software-delivery',
|
|
30
|
+
' version: 1.0.0',
|
|
31
|
+
' integrity: dev',
|
|
32
|
+
' source: local',
|
|
33
|
+
'lanes:',
|
|
34
|
+
' - id: framework-mcp',
|
|
35
|
+
' title: Framework MCP',
|
|
36
|
+
' allowed_scopes:',
|
|
37
|
+
' - type: path',
|
|
38
|
+
' pattern: "**"',
|
|
39
|
+
' access: read',
|
|
40
|
+
'security:',
|
|
41
|
+
' allowed_scopes:',
|
|
42
|
+
' - type: path',
|
|
43
|
+
' pattern: "**"',
|
|
44
|
+
' access: read',
|
|
45
|
+
' network_default: off',
|
|
46
|
+
' deny_overlays: []',
|
|
47
|
+
'software_delivery: {}',
|
|
48
|
+
'memory_namespace: mem',
|
|
49
|
+
'event_namespace: evt',
|
|
50
|
+
].join('\n'),
|
|
51
|
+
'utf8',
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
await writeFile(
|
|
55
|
+
join(packRoot, 'manifest.yaml'),
|
|
56
|
+
[
|
|
57
|
+
'id: software-delivery',
|
|
58
|
+
'version: 1.0.0',
|
|
59
|
+
'task_types:',
|
|
60
|
+
' - work-unit',
|
|
61
|
+
'tools: []',
|
|
62
|
+
'policies:',
|
|
63
|
+
' - id: runtime.completion.allow',
|
|
64
|
+
' trigger: on_completion',
|
|
65
|
+
' decision: allow',
|
|
66
|
+
'state_aliases:',
|
|
67
|
+
' active: in_progress',
|
|
68
|
+
'evidence_types: []',
|
|
69
|
+
'lane_templates: []',
|
|
70
|
+
'config_key: software_delivery',
|
|
71
|
+
].join('\n'),
|
|
72
|
+
'utf8',
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe('surfaces/mcp runtime-backed server', () => {
|
|
77
|
+
let tempRoot: string;
|
|
78
|
+
|
|
79
|
+
beforeEach(async () => {
|
|
80
|
+
tempRoot = await mkdtemp(join(tmpdir(), 'lumenflow-surfaces-mcp-'));
|
|
81
|
+
await writeWorkspaceFixture(tempRoot);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
afterEach(async () => {
|
|
85
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('routes task:* names to KernelRuntime use-cases', async () => {
|
|
89
|
+
const createTask = vi.fn(async () => ({ ok: true }));
|
|
90
|
+
const claimTask = vi.fn(async () => ({ ok: true }));
|
|
91
|
+
const completeTask = vi.fn(async () => ({ ok: true }));
|
|
92
|
+
const inspectTask = vi.fn(async () => ({ ok: true }));
|
|
93
|
+
|
|
94
|
+
const runtime = {
|
|
95
|
+
createTask,
|
|
96
|
+
claimTask,
|
|
97
|
+
completeTask,
|
|
98
|
+
inspectTask,
|
|
99
|
+
executeTool: vi.fn(),
|
|
100
|
+
getToolHost: vi.fn(),
|
|
101
|
+
getPolicyEngine: vi.fn(),
|
|
102
|
+
} as unknown as KernelRuntime;
|
|
103
|
+
|
|
104
|
+
const server = createMcpServer(runtime);
|
|
105
|
+
|
|
106
|
+
await server.handleInvocation({
|
|
107
|
+
name: 'task:create',
|
|
108
|
+
arguments: {
|
|
109
|
+
id: 'WU-1738-create',
|
|
110
|
+
workspace_id: 'workspace-surfaces-mcp',
|
|
111
|
+
lane_id: 'framework-mcp',
|
|
112
|
+
domain: 'software-delivery',
|
|
113
|
+
title: 'Create',
|
|
114
|
+
description: 'Create through MCP',
|
|
115
|
+
acceptance: ['ok'],
|
|
116
|
+
declared_scopes: [READ_SCOPE],
|
|
117
|
+
risk: 'medium',
|
|
118
|
+
type: 'feature',
|
|
119
|
+
priority: 'P1',
|
|
120
|
+
created: '2026-02-16',
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
await server.handleInvocation({
|
|
124
|
+
name: 'task:claim',
|
|
125
|
+
arguments: {
|
|
126
|
+
task_id: 'WU-1738-create',
|
|
127
|
+
by: 'maintainer@example.com',
|
|
128
|
+
session_id: 'session-1738',
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
await server.handleInvocation({
|
|
132
|
+
name: 'task:complete',
|
|
133
|
+
arguments: {
|
|
134
|
+
task_id: 'WU-1738-create',
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
await server.handleInvocation({
|
|
138
|
+
name: 'task:inspect',
|
|
139
|
+
arguments: {
|
|
140
|
+
task_id: 'WU-1738-create',
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
expect(createTask).toHaveBeenCalledTimes(1);
|
|
145
|
+
expect(claimTask).toHaveBeenCalledTimes(1);
|
|
146
|
+
expect(completeTask).toHaveBeenCalledTimes(1);
|
|
147
|
+
expect(inspectTask).toHaveBeenCalledTimes(1);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('passes optional claim/complete fields through to runtime handlers', async () => {
|
|
151
|
+
const createTask = vi.fn(async () => ({ ok: true }));
|
|
152
|
+
const claimTask = vi.fn(async () => ({ ok: true }));
|
|
153
|
+
const completeTask = vi.fn(async () => ({ ok: true }));
|
|
154
|
+
const inspectTask = vi.fn(async () => ({ ok: true }));
|
|
155
|
+
|
|
156
|
+
const runtime = {
|
|
157
|
+
createTask,
|
|
158
|
+
claimTask,
|
|
159
|
+
completeTask,
|
|
160
|
+
inspectTask,
|
|
161
|
+
executeTool: vi.fn(),
|
|
162
|
+
getToolHost: vi.fn(),
|
|
163
|
+
getPolicyEngine: vi.fn(),
|
|
164
|
+
} as unknown as KernelRuntime;
|
|
165
|
+
|
|
166
|
+
const server = createMcpServer(runtime);
|
|
167
|
+
|
|
168
|
+
await server.handleInvocation({
|
|
169
|
+
name: 'task:claim',
|
|
170
|
+
arguments: {
|
|
171
|
+
task_id: 'WU-1738-claim-optional',
|
|
172
|
+
by: 'maintainer@example.com',
|
|
173
|
+
session_id: 'session-claim-optional',
|
|
174
|
+
timestamp: '2026-02-17T10:00:00.000Z',
|
|
175
|
+
domain_data: {
|
|
176
|
+
source: 'mcp',
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
await server.handleInvocation({
|
|
182
|
+
name: 'task:complete',
|
|
183
|
+
arguments: {
|
|
184
|
+
task_id: 'WU-1738-claim-optional',
|
|
185
|
+
run_id: 'run-WU-1738-claim-optional-1',
|
|
186
|
+
timestamp: '2026-02-17T10:05:00.000Z',
|
|
187
|
+
evidence_refs: ['evidence://run/1'],
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
expect(claimTask).toHaveBeenCalledWith({
|
|
192
|
+
task_id: 'WU-1738-claim-optional',
|
|
193
|
+
by: 'maintainer@example.com',
|
|
194
|
+
session_id: 'session-claim-optional',
|
|
195
|
+
timestamp: '2026-02-17T10:00:00.000Z',
|
|
196
|
+
domain_data: {
|
|
197
|
+
source: 'mcp',
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
expect(completeTask).toHaveBeenCalledWith({
|
|
202
|
+
task_id: 'WU-1738-claim-optional',
|
|
203
|
+
run_id: 'run-WU-1738-claim-optional-1',
|
|
204
|
+
timestamp: '2026-02-17T10:05:00.000Z',
|
|
205
|
+
evidence_refs: ['evidence://run/1'],
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('rejects invalid claim/complete payload shapes at schema boundaries', async () => {
|
|
210
|
+
const claimTask = vi.fn(async () => ({ ok: true }));
|
|
211
|
+
const completeTask = vi.fn(async () => ({ ok: true }));
|
|
212
|
+
|
|
213
|
+
const runtime = {
|
|
214
|
+
createTask: vi.fn(),
|
|
215
|
+
claimTask,
|
|
216
|
+
completeTask,
|
|
217
|
+
inspectTask: vi.fn(),
|
|
218
|
+
executeTool: vi.fn(),
|
|
219
|
+
getToolHost: vi.fn(),
|
|
220
|
+
getPolicyEngine: vi.fn(),
|
|
221
|
+
} as unknown as KernelRuntime;
|
|
222
|
+
|
|
223
|
+
const server = createMcpServer(runtime);
|
|
224
|
+
|
|
225
|
+
await expect(
|
|
226
|
+
server.handleInvocation({
|
|
227
|
+
name: 'task:claim',
|
|
228
|
+
arguments: {
|
|
229
|
+
task_id: 'WU-1738-invalid',
|
|
230
|
+
by: 'maintainer@example.com',
|
|
231
|
+
session_id: 'session-invalid',
|
|
232
|
+
domain_data: 'not-an-object',
|
|
233
|
+
},
|
|
234
|
+
}),
|
|
235
|
+
).rejects.toThrow();
|
|
236
|
+
|
|
237
|
+
await expect(
|
|
238
|
+
server.handleInvocation({
|
|
239
|
+
name: 'task:complete',
|
|
240
|
+
arguments: {
|
|
241
|
+
task_id: 'WU-1738-invalid',
|
|
242
|
+
evidence_refs: ['ok', 42],
|
|
243
|
+
},
|
|
244
|
+
}),
|
|
245
|
+
).rejects.toThrow();
|
|
246
|
+
|
|
247
|
+
expect(claimTask).not.toHaveBeenCalled();
|
|
248
|
+
expect(completeTask).not.toHaveBeenCalled();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('routes non-task names to runtime.executeTool', async () => {
|
|
252
|
+
const executeTool = vi.fn(async () => ({ success: true }));
|
|
253
|
+
|
|
254
|
+
const runtime = {
|
|
255
|
+
createTask: vi.fn(),
|
|
256
|
+
claimTask: vi.fn(),
|
|
257
|
+
completeTask: vi.fn(),
|
|
258
|
+
inspectTask: vi.fn(),
|
|
259
|
+
executeTool,
|
|
260
|
+
getToolHost: vi.fn(),
|
|
261
|
+
getPolicyEngine: vi.fn(),
|
|
262
|
+
} as unknown as KernelRuntime;
|
|
263
|
+
|
|
264
|
+
const server = createMcpServer(runtime);
|
|
265
|
+
await server.handleInvocation(
|
|
266
|
+
{
|
|
267
|
+
name: 'fs:read',
|
|
268
|
+
arguments: { path: 'README.md' },
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
run_id: 'run-1738-tool',
|
|
272
|
+
task_id: 'WU-1738-tool',
|
|
273
|
+
session_id: 'session-1738-tool',
|
|
274
|
+
allowed_scopes: [READ_SCOPE],
|
|
275
|
+
metadata: {
|
|
276
|
+
workspace_allowed_scopes: [READ_SCOPE],
|
|
277
|
+
lane_allowed_scopes: [READ_SCOPE],
|
|
278
|
+
task_declared_scopes: [READ_SCOPE],
|
|
279
|
+
workspace_config_hash: 'a'.repeat(64),
|
|
280
|
+
runtime_version: '2.21.0',
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
expect(executeTool).toHaveBeenCalledWith(
|
|
286
|
+
'fs:read',
|
|
287
|
+
{ path: 'README.md' },
|
|
288
|
+
expect.objectContaining({
|
|
289
|
+
run_id: 'run-1738-tool',
|
|
290
|
+
task_id: 'WU-1738-tool',
|
|
291
|
+
}),
|
|
292
|
+
);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('routes pack/workspace parity tools through runtime.executeTool with context enforcement', async () => {
|
|
296
|
+
const executeTool = vi.fn(async () => ({ success: true }));
|
|
297
|
+
|
|
298
|
+
const runtime = {
|
|
299
|
+
createTask: vi.fn(),
|
|
300
|
+
claimTask: vi.fn(),
|
|
301
|
+
completeTask: vi.fn(),
|
|
302
|
+
inspectTask: vi.fn(),
|
|
303
|
+
executeTool,
|
|
304
|
+
getToolHost: vi.fn(),
|
|
305
|
+
getPolicyEngine: vi.fn(),
|
|
306
|
+
} as unknown as KernelRuntime;
|
|
307
|
+
|
|
308
|
+
const server = createMcpServer(runtime);
|
|
309
|
+
|
|
310
|
+
await server.handleInvocation(
|
|
311
|
+
{
|
|
312
|
+
name: 'pack:list',
|
|
313
|
+
arguments: {},
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
run_id: 'run-1738-pack-list',
|
|
317
|
+
task_id: 'WU-1738-pack-list',
|
|
318
|
+
session_id: 'session-1738-pack-list',
|
|
319
|
+
allowed_scopes: [READ_SCOPE],
|
|
320
|
+
metadata: {
|
|
321
|
+
workspace_allowed_scopes: [READ_SCOPE],
|
|
322
|
+
lane_allowed_scopes: [READ_SCOPE],
|
|
323
|
+
task_declared_scopes: [READ_SCOPE],
|
|
324
|
+
workspace_config_hash: 'a'.repeat(64),
|
|
325
|
+
runtime_version: '2.21.0',
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
await server.handleInvocation(
|
|
331
|
+
{
|
|
332
|
+
name: 'pack:install',
|
|
333
|
+
arguments: {
|
|
334
|
+
id: 'software-delivery',
|
|
335
|
+
source: 'registry',
|
|
336
|
+
version: '1.0.0',
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
run_id: 'run-1738-pack-install',
|
|
341
|
+
task_id: 'WU-1738-pack-install',
|
|
342
|
+
session_id: 'session-1738-pack-install',
|
|
343
|
+
allowed_scopes: [READ_SCOPE],
|
|
344
|
+
metadata: {
|
|
345
|
+
workspace_allowed_scopes: [READ_SCOPE],
|
|
346
|
+
lane_allowed_scopes: [READ_SCOPE],
|
|
347
|
+
task_declared_scopes: [READ_SCOPE],
|
|
348
|
+
workspace_config_hash: 'b'.repeat(64),
|
|
349
|
+
runtime_version: '2.21.0',
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
await server.handleInvocation(
|
|
355
|
+
{
|
|
356
|
+
name: 'workspace:info',
|
|
357
|
+
arguments: {},
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
run_id: 'run-1738-workspace-info',
|
|
361
|
+
task_id: 'WU-1738-workspace-info',
|
|
362
|
+
session_id: 'session-1738-workspace-info',
|
|
363
|
+
allowed_scopes: [READ_SCOPE],
|
|
364
|
+
metadata: {
|
|
365
|
+
workspace_allowed_scopes: [READ_SCOPE],
|
|
366
|
+
lane_allowed_scopes: [READ_SCOPE],
|
|
367
|
+
task_declared_scopes: [READ_SCOPE],
|
|
368
|
+
workspace_config_hash: 'c'.repeat(64),
|
|
369
|
+
runtime_version: '2.21.0',
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
expect(executeTool).toHaveBeenCalledWith(
|
|
375
|
+
'pack:list',
|
|
376
|
+
{},
|
|
377
|
+
expect.objectContaining({
|
|
378
|
+
run_id: 'run-1738-pack-list',
|
|
379
|
+
task_id: 'WU-1738-pack-list',
|
|
380
|
+
}),
|
|
381
|
+
);
|
|
382
|
+
expect(executeTool).toHaveBeenCalledWith(
|
|
383
|
+
'pack:install',
|
|
384
|
+
{
|
|
385
|
+
id: 'software-delivery',
|
|
386
|
+
source: 'registry',
|
|
387
|
+
version: '1.0.0',
|
|
388
|
+
},
|
|
389
|
+
expect.objectContaining({
|
|
390
|
+
run_id: 'run-1738-pack-install',
|
|
391
|
+
task_id: 'WU-1738-pack-install',
|
|
392
|
+
}),
|
|
393
|
+
);
|
|
394
|
+
expect(executeTool).toHaveBeenCalledWith(
|
|
395
|
+
'workspace:info',
|
|
396
|
+
{},
|
|
397
|
+
expect.objectContaining({
|
|
398
|
+
run_id: 'run-1738-workspace-info',
|
|
399
|
+
task_id: 'WU-1738-workspace-info',
|
|
400
|
+
}),
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
await expect(
|
|
404
|
+
server.handleInvocation({
|
|
405
|
+
name: 'pack:list',
|
|
406
|
+
arguments: {},
|
|
407
|
+
}),
|
|
408
|
+
).rejects.toThrow('requires execution context');
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('builds MCP tool schemas from zod via Kernel JSON schema conversion', () => {
|
|
412
|
+
const runtime = {
|
|
413
|
+
createTask: vi.fn(),
|
|
414
|
+
claimTask: vi.fn(),
|
|
415
|
+
completeTask: vi.fn(),
|
|
416
|
+
inspectTask: vi.fn(),
|
|
417
|
+
executeTool: vi.fn(),
|
|
418
|
+
getToolHost: vi.fn(),
|
|
419
|
+
getPolicyEngine: vi.fn(),
|
|
420
|
+
} as unknown as KernelRuntime;
|
|
421
|
+
|
|
422
|
+
const server = createMcpServer(runtime);
|
|
423
|
+
const tools = server.listTools();
|
|
424
|
+
|
|
425
|
+
const createTool = tools.find((tool) => tool.name === 'task:create');
|
|
426
|
+
expect(createTool).toBeDefined();
|
|
427
|
+
expect(createTool?.input_schema.type).toBe('object');
|
|
428
|
+
|
|
429
|
+
const claimTool = tools.find((tool) => tool.name === 'task:claim');
|
|
430
|
+
expect(claimTool).toBeDefined();
|
|
431
|
+
expect(claimTool?.input_schema.required).toEqual(
|
|
432
|
+
expect.arrayContaining(['task_id', 'by', 'session_id']),
|
|
433
|
+
);
|
|
434
|
+
expect(claimTool?.input_schema.properties?.task_id).toBeDefined();
|
|
435
|
+
expect(claimTool?.input_schema.properties?.id).toBeUndefined();
|
|
436
|
+
|
|
437
|
+
const completeTool = tools.find((tool) => tool.name === 'task:complete');
|
|
438
|
+
expect(completeTool).toBeDefined();
|
|
439
|
+
expect(completeTool?.input_schema.required).toEqual(expect.arrayContaining(['task_id']));
|
|
440
|
+
expect(completeTool?.input_schema.properties?.task_id).toBeDefined();
|
|
441
|
+
expect(completeTool?.input_schema.properties?.id).toBeUndefined();
|
|
442
|
+
|
|
443
|
+
const packListTool = tools.find((tool) => tool.name === 'pack:list');
|
|
444
|
+
expect(packListTool).toBeDefined();
|
|
445
|
+
expect(packListTool?.input_schema).toEqual({
|
|
446
|
+
type: 'object',
|
|
447
|
+
properties: {},
|
|
448
|
+
additionalProperties: false,
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
const packInstallTool = tools.find((tool) => tool.name === 'pack:install');
|
|
452
|
+
expect(packInstallTool).toBeDefined();
|
|
453
|
+
expect(packInstallTool?.input_schema.required).toEqual(
|
|
454
|
+
expect.arrayContaining(['id', 'source', 'version']),
|
|
455
|
+
);
|
|
456
|
+
expect(packInstallTool?.input_schema.properties?.id).toEqual({ type: 'string' });
|
|
457
|
+
expect(packInstallTool?.input_schema.properties?.source).toEqual({
|
|
458
|
+
type: 'string',
|
|
459
|
+
enum: ['local', 'git', 'registry'],
|
|
460
|
+
});
|
|
461
|
+
expect(packInstallTool?.input_schema.properties?.version).toEqual({ type: 'string' });
|
|
462
|
+
|
|
463
|
+
const workspaceInfoTool = tools.find((tool) => tool.name === 'workspace:info');
|
|
464
|
+
expect(workspaceInfoTool).toBeDefined();
|
|
465
|
+
expect(workspaceInfoTool?.input_schema).toEqual({
|
|
466
|
+
type: 'object',
|
|
467
|
+
properties: {},
|
|
468
|
+
additionalProperties: false,
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it('CLI lifecycle and MCP lifecycle produce identical event sequences', async () => {
|
|
473
|
+
const initialized = await initializeTaskLifecycleCommands({
|
|
474
|
+
workspaceRoot: tempRoot,
|
|
475
|
+
packsRoot: join(tempRoot, 'packs'),
|
|
476
|
+
taskSpecRoot: join(tempRoot, 'tasks'),
|
|
477
|
+
eventsFilePath: join(tempRoot, 'events.jsonl'),
|
|
478
|
+
eventLockFilePath: join(tempRoot, 'events.lock'),
|
|
479
|
+
evidenceRoot: join(tempRoot, 'evidence'),
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
const cliCommands = initialized.commands;
|
|
483
|
+
const server = createMcpServer(initialized.runtime);
|
|
484
|
+
|
|
485
|
+
const cliTask = {
|
|
486
|
+
id: 'WU-1738-cli',
|
|
487
|
+
workspace_id: 'workspace-surfaces-mcp',
|
|
488
|
+
lane_id: 'framework-mcp',
|
|
489
|
+
domain: 'software-delivery',
|
|
490
|
+
title: 'CLI lifecycle',
|
|
491
|
+
description: 'CLI path',
|
|
492
|
+
acceptance: ['ok'],
|
|
493
|
+
declared_scopes: [READ_SCOPE],
|
|
494
|
+
risk: 'medium' as const,
|
|
495
|
+
type: 'feature',
|
|
496
|
+
priority: 'P1' as const,
|
|
497
|
+
created: '2026-02-16',
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
const mcpTask = {
|
|
501
|
+
...cliTask,
|
|
502
|
+
id: 'WU-1738-mcp',
|
|
503
|
+
title: 'MCP lifecycle',
|
|
504
|
+
description: 'MCP path',
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
await cliCommands['task:create'](cliTask);
|
|
508
|
+
await cliCommands['task:claim']({
|
|
509
|
+
task_id: cliTask.id,
|
|
510
|
+
by: 'maintainer@example.com',
|
|
511
|
+
session_id: 'session-cli',
|
|
512
|
+
});
|
|
513
|
+
await cliCommands['task:complete']({ task_id: cliTask.id });
|
|
514
|
+
|
|
515
|
+
await server.handleInvocation({ name: 'task:create', arguments: mcpTask });
|
|
516
|
+
await server.handleInvocation({
|
|
517
|
+
name: 'task:claim',
|
|
518
|
+
arguments: {
|
|
519
|
+
task_id: mcpTask.id,
|
|
520
|
+
by: 'maintainer@example.com',
|
|
521
|
+
session_id: 'session-mcp',
|
|
522
|
+
},
|
|
523
|
+
});
|
|
524
|
+
await server.handleInvocation({
|
|
525
|
+
name: 'task:complete',
|
|
526
|
+
arguments: {
|
|
527
|
+
task_id: mcpTask.id,
|
|
528
|
+
},
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
const cliInspection = await cliCommands['task:status'](cliTask.id);
|
|
532
|
+
const mcpInspection = await server.handleInvocation({
|
|
533
|
+
name: 'task:inspect',
|
|
534
|
+
arguments: {
|
|
535
|
+
task_id: mcpTask.id,
|
|
536
|
+
},
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
expect(cliInspection.events.map((event) => event.kind)).toEqual(
|
|
540
|
+
mcpInspection.events.map((event) => event.kind),
|
|
541
|
+
);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it('contains no CLI package imports or spawn/exec shell-outs', async () => {
|
|
545
|
+
const source = await readFile(
|
|
546
|
+
join(process.cwd(), 'packages', '@lumenflow', 'surfaces', 'mcp', 'server.ts'),
|
|
547
|
+
'utf8',
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
expect(source.includes('@lumenflow/cli')).toBe(false);
|
|
551
|
+
expect(source.includes('execFile')).toBe(false);
|
|
552
|
+
expect(source.includes('spawn(')).toBe(false);
|
|
553
|
+
});
|
|
554
|
+
});
|
package/mcp/index.ts
ADDED