proteum 2.3.0 → 2.4.2
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/AGENTS.md +8 -3
- package/README.md +20 -15
- package/agents/project/AGENTS.md +16 -10
- package/agents/project/DOCUMENTATION.md +1326 -0
- package/agents/project/app-root/AGENTS.md +2 -2
- package/agents/project/diagnostics.md +10 -9
- package/agents/project/optimizations.md +1 -1
- package/agents/project/root/AGENTS.md +15 -8
- package/agents/project/server/services/AGENTS.md +1 -0
- package/agents/project/tests/AGENTS.md +1 -0
- package/cli/commands/db.ts +160 -0
- package/cli/commands/dev.ts +148 -25
- package/cli/commands/diagnose.ts +2 -0
- package/cli/commands/explain.ts +38 -9
- package/cli/commands/mcp.ts +126 -9
- package/cli/commands/orient.ts +44 -17
- package/cli/commands/runtime.ts +100 -17
- package/cli/mcp/router.ts +1028 -0
- package/cli/presentation/commands.ts +56 -25
- package/cli/presentation/help.ts +1 -1
- package/cli/runtime/commands.ts +163 -21
- package/cli/runtime/devSessions.ts +328 -2
- package/cli/runtime/mcpDaemon.ts +288 -0
- package/cli/runtime/ports.ts +151 -0
- package/cli/utils/agents.ts +94 -17
- package/cli/utils/appRoots.ts +232 -0
- package/common/dev/database.ts +226 -0
- package/common/dev/diagnostics.ts +1 -1
- package/common/dev/inspection.ts +8 -1
- package/common/dev/mcpPayloads.ts +456 -17
- package/common/dev/mcpServer.ts +51 -0
- package/docs/agent-routing.md +32 -21
- package/docs/dev-commands.md +1 -1
- package/docs/dev-sessions.md +3 -1
- package/docs/diagnostics.md +21 -20
- package/docs/mcp.md +114 -50
- package/docs/migrate-from-2.1.3.md +3 -5
- package/docs/request-tracing.md +3 -3
- package/package.json +10 -3
- package/server/app/devDiagnostics.ts +92 -0
- package/server/app/devMcp.ts +55 -0
- package/server/services/prisma/mariadb.ts +7 -3
- package/server/services/router/http/index.ts +25 -0
- package/server/services/router/request/ip.test.cjs +0 -1
- package/tests/agents-utils.test.cjs +58 -3
- package/tests/cli-mcp-command.test.cjs +327 -0
- package/tests/codex-mcp-usage.test.cjs +307 -0
- package/tests/dev-sessions.test.cjs +113 -0
- package/tests/dev-transpile-watch.test.cjs +0 -1
- package/tests/eslint-rules.test.cjs +0 -1
- package/tests/inspection.test.cjs +0 -1
- package/tests/mcp.test.cjs +769 -2
- package/tests/router-cache-config.test.cjs +0 -1
- package/vitest.config.mjs +9 -0
- package/cli/mcp/provider.ts +0 -365
- package/cli/mcp/stdio.ts +0 -16
package/tests/mcp.test.cjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
const assert = require('node:assert/strict');
|
|
2
2
|
const fs = require('node:fs');
|
|
3
|
+
const http = require('node:http');
|
|
3
4
|
const os = require('node:os');
|
|
4
5
|
const path = require('node:path');
|
|
5
|
-
const test = require('node:test');
|
|
6
6
|
|
|
7
7
|
const coreRoot = path.resolve(__dirname, '..');
|
|
8
8
|
process.env.TS_NODE_PROJECT = path.join(coreRoot, 'cli', 'tsconfig.json');
|
|
@@ -12,20 +12,110 @@ require('../cli/context.ts');
|
|
|
12
12
|
|
|
13
13
|
const { Client } = require('@modelcontextprotocol/sdk/client/index.js');
|
|
14
14
|
const { InMemoryTransport } = require('@modelcontextprotocol/sdk/inMemory.js');
|
|
15
|
-
const {
|
|
15
|
+
const {
|
|
16
|
+
createMcpPayload,
|
|
17
|
+
compactOrientationResponse,
|
|
18
|
+
compactRouteCandidatesResponse,
|
|
19
|
+
compactTraceResponse,
|
|
20
|
+
compactWorkflowStartResponse,
|
|
21
|
+
resolveInstructionRouting,
|
|
22
|
+
} = require('../common/dev/mcpPayloads.ts');
|
|
16
23
|
const { createProteumMcpServer } = require('../common/dev/mcpServer.ts');
|
|
24
|
+
const {
|
|
25
|
+
normalizeDatabaseReadLimit,
|
|
26
|
+
validateDatabaseReadQuery,
|
|
27
|
+
} = require('../common/dev/database.ts');
|
|
28
|
+
const { createProteumMachineMcpServer } = require('../cli/mcp/router.ts');
|
|
29
|
+
const {
|
|
30
|
+
createDevSessionRecord,
|
|
31
|
+
listMachineDevSessionInspections,
|
|
32
|
+
resolveMachineDevSessionFilePath,
|
|
33
|
+
resolveProteumProjectId,
|
|
34
|
+
writeMachineDevSessionRecord,
|
|
35
|
+
} = require('../cli/runtime/devSessions.ts');
|
|
36
|
+
const {
|
|
37
|
+
createMachineMcpDaemonRecord,
|
|
38
|
+
inspectMachineMcpDaemonRecord,
|
|
39
|
+
resolveMachineMcpDaemonPort,
|
|
40
|
+
resolveMachineMcpDaemonRecordFilePath,
|
|
41
|
+
writeMachineMcpDaemonRecord,
|
|
42
|
+
} = require('../cli/runtime/mcpDaemon.ts');
|
|
17
43
|
|
|
18
44
|
const writeFile = (filepath, content) => {
|
|
19
45
|
fs.mkdirSync(path.dirname(filepath), { recursive: true });
|
|
20
46
|
fs.writeFileSync(filepath, content);
|
|
21
47
|
};
|
|
22
48
|
|
|
49
|
+
const listen = async (server, port = 0) =>
|
|
50
|
+
await new Promise((resolve, reject) => {
|
|
51
|
+
server.once('error', reject);
|
|
52
|
+
server.listen(port, '127.0.0.1', () => resolve(server.address().port));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const closeServer = async (server) =>
|
|
56
|
+
await new Promise((resolve, reject) => {
|
|
57
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const createManifest = (appRoot, overrides = {}) => ({
|
|
61
|
+
version: 10,
|
|
62
|
+
app: {
|
|
63
|
+
root: appRoot,
|
|
64
|
+
coreRoot,
|
|
65
|
+
identityFilepath: path.join(appRoot, 'identity.config.ts'),
|
|
66
|
+
setupFilepath: path.join(appRoot, 'proteum.config.ts'),
|
|
67
|
+
identity: {
|
|
68
|
+
name: overrides.name || 'Test App',
|
|
69
|
+
identifier: overrides.identifier || 'TestApp',
|
|
70
|
+
description: '',
|
|
71
|
+
},
|
|
72
|
+
setup: {},
|
|
73
|
+
},
|
|
74
|
+
conventions: { routeOptionKeys: [], reservedRouteOptionKeys: [] },
|
|
75
|
+
env: {
|
|
76
|
+
source: 'test',
|
|
77
|
+
loadedVariableKeys: [],
|
|
78
|
+
requiredVariables: [],
|
|
79
|
+
resolved: {
|
|
80
|
+
name: 'test',
|
|
81
|
+
profile: 'dev',
|
|
82
|
+
routerPort: overrides.routerPort || 3104,
|
|
83
|
+
routerCurrentDomain: 'localhost',
|
|
84
|
+
routerInternalUrl: `http://localhost:${overrides.routerPort || 3104}`,
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
connectedProjects: [],
|
|
88
|
+
services: { app: [], routerPlugins: [] },
|
|
89
|
+
controllers: overrides.controllers || [],
|
|
90
|
+
commands: [],
|
|
91
|
+
routes: {
|
|
92
|
+
client: overrides.clientRoutes || [],
|
|
93
|
+
server: overrides.serverRoutes || [],
|
|
94
|
+
},
|
|
95
|
+
layouts: [],
|
|
96
|
+
diagnostics: overrides.diagnostics || [],
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const writeProteumAppFixture = (appRoot, manifestOverrides = {}) => {
|
|
100
|
+
writeFile(path.join(appRoot, 'package.json'), '{"name":"fixture"}\n');
|
|
101
|
+
writeFile(path.join(appRoot, 'identity.config.ts'), 'export default {};\n');
|
|
102
|
+
writeFile(path.join(appRoot, 'proteum.config.ts'), 'export default {};\n');
|
|
103
|
+
writeFile(path.join(appRoot, 'client', 'AGENTS.md'), '# Client\n');
|
|
104
|
+
writeFile(path.join(appRoot, 'client', 'pages', 'AGENTS.md'), '# Pages\n');
|
|
105
|
+
writeFile(path.join(appRoot, 'server', 'AGENTS.md'), '# Server\n');
|
|
106
|
+
writeFile(path.join(appRoot, 'server', 'routes', 'AGENTS.md'), '# Routes\n');
|
|
107
|
+
writeFile(path.join(appRoot, 'AGENTS.md'), '# App\n');
|
|
108
|
+
writeFile(path.join(appRoot, 'diagnostics.md'), '# Diagnostics\n');
|
|
109
|
+
writeFile(path.join(appRoot, '.proteum', 'manifest.json'), JSON.stringify(createManifest(appRoot, manifestOverrides), null, 2));
|
|
110
|
+
};
|
|
111
|
+
|
|
23
112
|
test('instruction routing returns compact selected files for a page query', () => {
|
|
24
113
|
const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-mcp-app-'));
|
|
25
114
|
|
|
26
115
|
writeFile(path.join(appRoot, 'AGENTS.md'), '# App Agents\n\n- root\n');
|
|
27
116
|
writeFile(path.join(appRoot, 'client', 'AGENTS.md'), '# Client Agents\n\n- client\n');
|
|
28
117
|
writeFile(path.join(appRoot, 'client', 'pages', 'AGENTS.md'), '# Page Agents\n\n- pages\n');
|
|
118
|
+
writeFile(path.join(appRoot, 'DOCUMENTATION.md'), '# Documentation\n\n- docs\n');
|
|
29
119
|
writeFile(path.join(appRoot, 'diagnostics.md'), '# Diagnostics\n\n- diagnose\n');
|
|
30
120
|
|
|
31
121
|
const payload = resolveInstructionRouting({ appRoot, query: '/domains/:slug client/pages/domain.tsx' });
|
|
@@ -36,9 +126,211 @@ test('instruction routing returns compact selected files for a page query', () =
|
|
|
36
126
|
payload.data.selected.map((entry) => path.relative(appRoot, entry.file)).sort(),
|
|
37
127
|
['AGENTS.md', 'client/AGENTS.md', 'client/pages/AGENTS.md'],
|
|
38
128
|
);
|
|
129
|
+
assert.equal(payload.data.readWhen.some((entry) => entry.file && entry.file.endsWith('DOCUMENTATION.md')), true);
|
|
39
130
|
assert.equal(payload.data.readWhen.some((entry) => entry.file && entry.file.endsWith('diagnostics.md')), true);
|
|
40
131
|
});
|
|
41
132
|
|
|
133
|
+
test('database read query policy allows only capped SELECT SHOW and EXPLAIN diagnostics', () => {
|
|
134
|
+
assert.deepEqual(validateDatabaseReadQuery(' SELECT 1; '), { kind: 'select', sql: 'SELECT 1' });
|
|
135
|
+
assert.deepEqual(validateDatabaseReadQuery('/* plan */ EXPLAIN SELECT * FROM User'), {
|
|
136
|
+
kind: 'explain',
|
|
137
|
+
sql: '/* plan */ EXPLAIN SELECT * FROM User',
|
|
138
|
+
});
|
|
139
|
+
assert.deepEqual(validateDatabaseReadQuery('SHOW TABLES'), { kind: 'show', sql: 'SHOW TABLES' });
|
|
140
|
+
assert.equal(normalizeDatabaseReadLimit(999), 500);
|
|
141
|
+
|
|
142
|
+
assert.throws(() => validateDatabaseReadQuery('UPDATE User SET role = "admin"'), /Only SELECT, SHOW, and EXPLAIN/);
|
|
143
|
+
assert.throws(() => validateDatabaseReadQuery('SELECT 1; DROP TABLE User'), /Only one read-only SQL statement/);
|
|
144
|
+
assert.throws(() => validateDatabaseReadQuery('EXPLAIN ANALYZE SELECT * FROM User'), /EXPLAIN ANALYZE/);
|
|
145
|
+
assert.throws(() => validateDatabaseReadQuery('SELECT LOAD_FILE("/etc/passwd")'), /LOAD_FILE/);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('instruction routing promotes triggered full instruction files', () => {
|
|
149
|
+
const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-mcp-trigger-app-'));
|
|
150
|
+
const fallbackRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-mcp-trigger-core-'));
|
|
151
|
+
const fallbackAgents = path.join(fallbackRoot, 'agents', 'project', 'AGENTS.md');
|
|
152
|
+
|
|
153
|
+
writeFile(fallbackAgents, '# Full Root Contract\n\n- Conventional Commits live here.\n');
|
|
154
|
+
writeFile(
|
|
155
|
+
path.join(appRoot, 'AGENTS.md'),
|
|
156
|
+
['# App Router', '', `- Root contract fallback: ${fallbackAgents}`, ''].join('\n'),
|
|
157
|
+
);
|
|
158
|
+
writeFile(path.join(appRoot, 'CODING_STYLE.md'), '# Coding Style\n\n- Style\n');
|
|
159
|
+
writeFile(path.join(appRoot, 'diagnostics.md'), '# Diagnostics\n\n- Diagnose\n');
|
|
160
|
+
writeFile(path.join(appRoot, 'DOCUMENTATION.md'), '# Documentation\n\n- Docs\n');
|
|
161
|
+
writeFile(path.join(appRoot, 'optimizations.md'), '# Optimizations\n\n- Optimize\n');
|
|
162
|
+
|
|
163
|
+
const payload = resolveInstructionRouting({ appRoot, query: 'increase quota and commit' });
|
|
164
|
+
const selected = payload.data.selected.map((entry) => entry.file);
|
|
165
|
+
|
|
166
|
+
assert.equal(selected.includes(path.join(appRoot, 'AGENTS.md')), true);
|
|
167
|
+
assert.equal(selected.includes(fallbackAgents), true);
|
|
168
|
+
assert.equal(selected.includes(path.join(appRoot, 'CODING_STYLE.md')), true);
|
|
169
|
+
assert.equal(
|
|
170
|
+
payload.data.selected.some(
|
|
171
|
+
(entry) => entry.file === fallbackAgents && /Git lifecycle trigger/.test(entry.reason),
|
|
172
|
+
),
|
|
173
|
+
true,
|
|
174
|
+
);
|
|
175
|
+
assert.equal(payload.data.fullReadPolicy.default.includes('read-only'), true);
|
|
176
|
+
assert.equal(payload.data.selected.some((entry) => entry.fullRead === 'full-before-action'), true);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('workflow start payload combines compact runtime, instructions, owner, and duplicate guidance', () => {
|
|
180
|
+
const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-mcp-workflow-app-'));
|
|
181
|
+
const pageFile = path.join(appRoot, 'client/pages/domains.tsx');
|
|
182
|
+
|
|
183
|
+
writeFile(path.join(appRoot, 'AGENTS.md'), '# App Agents\n\n- root\n');
|
|
184
|
+
writeFile(path.join(appRoot, 'client', 'AGENTS.md'), '# Client Agents\n\n- client\n');
|
|
185
|
+
writeFile(path.join(appRoot, 'client', 'pages', 'AGENTS.md'), '# Page Agents\n\n- pages\n');
|
|
186
|
+
writeFile(pageFile, 'export default function Domains() { return null; }\n');
|
|
187
|
+
|
|
188
|
+
const manifest = {
|
|
189
|
+
version: 10,
|
|
190
|
+
app: {
|
|
191
|
+
root: appRoot,
|
|
192
|
+
coreRoot,
|
|
193
|
+
identityFilepath: path.join(appRoot, 'identity.config.ts'),
|
|
194
|
+
setupFilepath: path.join(appRoot, 'proteum.config.ts'),
|
|
195
|
+
identity: {
|
|
196
|
+
name: 'Workflow App',
|
|
197
|
+
identifier: 'WorkflowApp',
|
|
198
|
+
description: '',
|
|
199
|
+
},
|
|
200
|
+
setup: {},
|
|
201
|
+
},
|
|
202
|
+
conventions: { routeOptionKeys: [], reservedRouteOptionKeys: [] },
|
|
203
|
+
env: {
|
|
204
|
+
source: 'test',
|
|
205
|
+
loadedVariableKeys: [],
|
|
206
|
+
requiredVariables: [],
|
|
207
|
+
resolved: {
|
|
208
|
+
name: 'test',
|
|
209
|
+
profile: 'dev',
|
|
210
|
+
routerPort: 3104,
|
|
211
|
+
routerCurrentDomain: 'localhost',
|
|
212
|
+
routerInternalUrl: 'http://localhost:3104',
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
connectedProjects: [],
|
|
216
|
+
services: { app: [], routerPlugins: [] },
|
|
217
|
+
controllers: [],
|
|
218
|
+
commands: [],
|
|
219
|
+
routes: { client: [], server: [] },
|
|
220
|
+
layouts: [],
|
|
221
|
+
diagnostics: [],
|
|
222
|
+
};
|
|
223
|
+
const doctor = { summary: { errors: 0, warnings: 0, strictFailed: false }, diagnostics: [] };
|
|
224
|
+
const payload = compactWorkflowStartResponse({
|
|
225
|
+
contracts: doctor,
|
|
226
|
+
doctor,
|
|
227
|
+
manifest,
|
|
228
|
+
owner: {
|
|
229
|
+
matches: [
|
|
230
|
+
{
|
|
231
|
+
details: [],
|
|
232
|
+
kind: 'route',
|
|
233
|
+
label: '/domains',
|
|
234
|
+
matchedOn: ['path'],
|
|
235
|
+
originHint: 'manifest',
|
|
236
|
+
scopeLabel: 'local',
|
|
237
|
+
score: 100,
|
|
238
|
+
source: { filepath: pageFile, line: 1, column: 1 },
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
normalizedQuery: '/domains',
|
|
242
|
+
query: '/domains',
|
|
243
|
+
},
|
|
244
|
+
route: '/domains',
|
|
245
|
+
runtime: { publicUrl: 'http://localhost:3104', mcpUrl: 'http://localhost:3104/__proteum/mcp' },
|
|
246
|
+
task: 'read-only runtime health pass',
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
assert.equal(payload.data.runtime.manifest.identifier, 'WorkflowApp');
|
|
250
|
+
assert.equal(payload.data.instructions.selected.length >= 2, true);
|
|
251
|
+
assert.equal(payload.data.owner.top.label, '/domains');
|
|
252
|
+
assert.equal(payload.nextActions[0].tool, 'diagnose');
|
|
253
|
+
assert.equal(payload.data.duplicateAvoidance.some((line) => /do not run CLI runtime status/.test(line)), true);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('orientation payload suggests MCP owner and runtime next actions before CLI fallback', () => {
|
|
257
|
+
const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-mcp-orient-app-'));
|
|
258
|
+
|
|
259
|
+
writeFile(path.join(appRoot, 'AGENTS.md'), '# App Agents\n\n- root\n');
|
|
260
|
+
writeFile(path.join(appRoot, 'diagnostics.md'), '# Diagnostics\n\n- diagnose\n');
|
|
261
|
+
writeFile(path.join(appRoot, 'DOCUMENTATION.md'), '# Documentation\n\n- docs\n');
|
|
262
|
+
writeFile(path.join(appRoot, 'CODING_STYLE.md'), '# Coding Style\n\n- style\n');
|
|
263
|
+
writeFile(path.join(appRoot, 'optimizations.md'), '# Optimizations\n\n- optimize\n');
|
|
264
|
+
|
|
265
|
+
const payload = compactOrientationResponse({
|
|
266
|
+
app: { appRoot, identifier: 'TestApp', repoRoot: appRoot, routerPort: 3101 },
|
|
267
|
+
connected: { imports: [], producers: [] },
|
|
268
|
+
guidance: {
|
|
269
|
+
agents: path.join(appRoot, 'AGENTS.md'),
|
|
270
|
+
areaAgents: [],
|
|
271
|
+
codingStyle: path.join(appRoot, 'CODING_STYLE.md'),
|
|
272
|
+
diagnostics: path.join(appRoot, 'diagnostics.md'),
|
|
273
|
+
documentation: path.join(appRoot, 'DOCUMENTATION.md'),
|
|
274
|
+
optimizations: path.join(appRoot, 'optimizations.md'),
|
|
275
|
+
},
|
|
276
|
+
normalizedQuery: '/auth/login',
|
|
277
|
+
nextSteps: [{ command: 'proteum orient /auth/login', label: 'CLI Orient', reason: 'Fallback command.' }],
|
|
278
|
+
owner: {
|
|
279
|
+
matches: [
|
|
280
|
+
{
|
|
281
|
+
details: [],
|
|
282
|
+
kind: 'route',
|
|
283
|
+
label: '/auth/login',
|
|
284
|
+
matchedOn: ['path'],
|
|
285
|
+
originHint: 'manifest',
|
|
286
|
+
scopeLabel: 'local',
|
|
287
|
+
score: 100,
|
|
288
|
+
source: { filepath: path.join(appRoot, 'client/pages/auth.tsx'), line: 1, column: 1 },
|
|
289
|
+
},
|
|
290
|
+
],
|
|
291
|
+
normalizedQuery: '/auth/login',
|
|
292
|
+
query: '/auth/login',
|
|
293
|
+
},
|
|
294
|
+
query: '/auth/login',
|
|
295
|
+
warnings: [],
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
assert.equal(payload.nextActions[0].tool, 'explain_summary');
|
|
299
|
+
assert.equal(payload.nextActions[1].tool, 'diagnose');
|
|
300
|
+
assert.equal(payload.nextActions[2].tool, 'perf_request');
|
|
301
|
+
assert.equal(payload.nextActions.some((action) => action.command === 'proteum orient /auth/login'), true);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test('route candidates payload avoids raw route dumps', () => {
|
|
305
|
+
const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-mcp-routes-app-'));
|
|
306
|
+
const routeFile = path.join(appRoot, 'client/pages/domains.tsx');
|
|
307
|
+
const manifest = createManifest(appRoot, {
|
|
308
|
+
clientRoutes: [
|
|
309
|
+
{
|
|
310
|
+
chunkId: 'domains',
|
|
311
|
+
filepath: routeFile,
|
|
312
|
+
hasData: false,
|
|
313
|
+
invalidOptionKeys: [],
|
|
314
|
+
kind: 'client-page',
|
|
315
|
+
methodName: 'page',
|
|
316
|
+
normalizedOptionKeys: [],
|
|
317
|
+
optionKeys: [],
|
|
318
|
+
path: '/domains',
|
|
319
|
+
scope: 'app',
|
|
320
|
+
serviceLocalName: 'Router',
|
|
321
|
+
sourceLocation: { line: 1, column: 1 },
|
|
322
|
+
targetResolution: 'literal',
|
|
323
|
+
},
|
|
324
|
+
],
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
const payload = compactRouteCandidatesResponse({ manifest, query: '/domains' });
|
|
328
|
+
|
|
329
|
+
assert.equal(payload.data.candidates.length, 1);
|
|
330
|
+
assert.equal(payload.data.candidates[0].label, '/domains');
|
|
331
|
+
assert.equal(payload.nextActions[0].tool, 'explain_summary');
|
|
332
|
+
});
|
|
333
|
+
|
|
42
334
|
test('trace payload keeps default output compact and paginates full details', () => {
|
|
43
335
|
const request = {
|
|
44
336
|
id: 'req_1',
|
|
@@ -94,6 +386,7 @@ test('trace payload keeps default output compact and paginates full details', ()
|
|
|
94
386
|
test('MCP server registers the Proteum read-only tool contract', async () => {
|
|
95
387
|
const payload = createMcpPayload({ summary: 'ok', data: { value: 1 } });
|
|
96
388
|
const provider = {
|
|
389
|
+
dbQuery: async () => payload,
|
|
97
390
|
diagnose: async () => payload,
|
|
98
391
|
doctor: async () => payload,
|
|
99
392
|
explainSummary: async () => payload,
|
|
@@ -103,9 +396,11 @@ test('MCP server registers the Proteum read-only tool contract', async () => {
|
|
|
103
396
|
perfRequest: async () => payload,
|
|
104
397
|
perfTop: async () => payload,
|
|
105
398
|
readResource: async () => payload,
|
|
399
|
+
routeCandidates: async () => payload,
|
|
106
400
|
runtimeStatus: async () => payload,
|
|
107
401
|
traceLatest: async () => payload,
|
|
108
402
|
traceShow: async () => payload,
|
|
403
|
+
workflowStart: async () => payload,
|
|
109
404
|
};
|
|
110
405
|
const server = createProteumMcpServer({ provider, version: 'test' });
|
|
111
406
|
const client = new Client({ name: 'mcp-test', version: '1.0.0' });
|
|
@@ -119,9 +414,481 @@ test('MCP server registers the Proteum read-only tool contract', async () => {
|
|
|
119
414
|
const resource = await client.readResource({ uri: 'proteum://runtime/status' });
|
|
120
415
|
|
|
121
416
|
assert.equal(tools.tools.some((tool) => tool.name === 'runtime_status'), true);
|
|
417
|
+
assert.equal(tools.tools.some((tool) => tool.name === 'workflow_start'), true);
|
|
418
|
+
assert.equal(tools.tools.some((tool) => tool.name === 'route_candidates'), true);
|
|
419
|
+
assert.equal(tools.tools.some((tool) => tool.name === 'db_query'), true);
|
|
122
420
|
assert.match(result.content[0].text, /proteum-mcp-v1/);
|
|
123
421
|
assert.match(resource.contents[0].text, /proteum-mcp-v1/);
|
|
124
422
|
|
|
125
423
|
await client.close();
|
|
126
424
|
await server.close();
|
|
127
425
|
});
|
|
426
|
+
|
|
427
|
+
test('machine project id is deterministic for a canonical app root', async () => {
|
|
428
|
+
const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-project-id-'));
|
|
429
|
+
|
|
430
|
+
const firstProjectId = await resolveProteumProjectId(appRoot);
|
|
431
|
+
const secondProjectId = await resolveProteumProjectId(appRoot);
|
|
432
|
+
|
|
433
|
+
assert.equal(firstProjectId, secondProjectId);
|
|
434
|
+
assert.match(firstProjectId, /^prj_[a-f0-9]{12}$/);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test('machine registry cleans stale sessions and preserves live sessions', async (t) => {
|
|
438
|
+
const previousRegistryDir = process.env.PROTEUM_MACHINE_DEV_SESSION_DIR;
|
|
439
|
+
const registryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-machine-registry-'));
|
|
440
|
+
process.env.PROTEUM_MACHINE_DEV_SESSION_DIR = registryDir;
|
|
441
|
+
t.onTestFinished(() => {
|
|
442
|
+
if (previousRegistryDir === undefined) delete process.env.PROTEUM_MACHINE_DEV_SESSION_DIR;
|
|
443
|
+
else process.env.PROTEUM_MACHINE_DEV_SESSION_DIR = previousRegistryDir;
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
const liveAppRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-machine-live-'));
|
|
447
|
+
const staleAppRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-machine-stale-'));
|
|
448
|
+
const liveRecord = createDevSessionRecord({
|
|
449
|
+
appRoot: liveAppRoot,
|
|
450
|
+
port: 3101,
|
|
451
|
+
sessionFilePath: path.join(liveAppRoot, 'var/run/proteum/dev/3101.json'),
|
|
452
|
+
});
|
|
453
|
+
const staleRecord = {
|
|
454
|
+
...createDevSessionRecord({
|
|
455
|
+
appRoot: staleAppRoot,
|
|
456
|
+
port: 3102,
|
|
457
|
+
sessionFilePath: path.join(staleAppRoot, 'var/run/proteum/dev/3102.json'),
|
|
458
|
+
}),
|
|
459
|
+
pid: 999999,
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
const liveMachineRecord = await writeMachineDevSessionRecord(liveRecord);
|
|
463
|
+
const staleMachineRecord = await writeMachineDevSessionRecord(staleRecord);
|
|
464
|
+
const invalidFilePath = path.join(registryDir, 'invalid.json');
|
|
465
|
+
fs.writeFileSync(invalidFilePath, '{ invalid json');
|
|
466
|
+
|
|
467
|
+
const inspections = await listMachineDevSessionInspections();
|
|
468
|
+
|
|
469
|
+
assert.deepEqual(
|
|
470
|
+
inspections.map((inspection) => inspection.record.projectId),
|
|
471
|
+
[liveMachineRecord.projectId],
|
|
472
|
+
);
|
|
473
|
+
assert.equal(fs.existsSync(resolveMachineDevSessionFilePath(liveMachineRecord.projectId)), true);
|
|
474
|
+
assert.equal(fs.existsSync(resolveMachineDevSessionFilePath(staleMachineRecord.projectId)), false);
|
|
475
|
+
assert.equal(fs.existsSync(invalidFilePath), false);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test('machine MCP daemon registry cleans stale records and resolves port override', async (t) => {
|
|
479
|
+
const previousRegistryDir = process.env.PROTEUM_MACHINE_MCP_DIR;
|
|
480
|
+
const previousPort = process.env.PROTEUM_MCP_PORT;
|
|
481
|
+
const registryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-mcp-daemon-'));
|
|
482
|
+
process.env.PROTEUM_MACHINE_MCP_DIR = registryDir;
|
|
483
|
+
process.env.PROTEUM_MCP_PORT = '4567';
|
|
484
|
+
t.onTestFinished(() => {
|
|
485
|
+
if (previousRegistryDir === undefined) delete process.env.PROTEUM_MACHINE_MCP_DIR;
|
|
486
|
+
else process.env.PROTEUM_MACHINE_MCP_DIR = previousRegistryDir;
|
|
487
|
+
if (previousPort === undefined) delete process.env.PROTEUM_MCP_PORT;
|
|
488
|
+
else process.env.PROTEUM_MCP_PORT = previousPort;
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
const liveRecord = createMachineMcpDaemonRecord({
|
|
492
|
+
command: [process.execPath, 'cli/bin.js', 'mcp', '--daemon'],
|
|
493
|
+
port: resolveMachineMcpDaemonPort(),
|
|
494
|
+
});
|
|
495
|
+
await writeMachineMcpDaemonRecord(liveRecord);
|
|
496
|
+
|
|
497
|
+
const liveInspection = await inspectMachineMcpDaemonRecord();
|
|
498
|
+
|
|
499
|
+
assert.equal(liveInspection.live, true);
|
|
500
|
+
assert.equal(liveInspection.record.port, 4567);
|
|
501
|
+
assert.equal(liveInspection.record.mcpUrl, 'http://127.0.0.1:4567/mcp');
|
|
502
|
+
|
|
503
|
+
await writeMachineMcpDaemonRecord({
|
|
504
|
+
...liveRecord,
|
|
505
|
+
pid: 999999,
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
const staleInspection = await inspectMachineMcpDaemonRecord();
|
|
509
|
+
|
|
510
|
+
assert.equal(staleInspection.live, false);
|
|
511
|
+
assert.equal(staleInspection.stale, true);
|
|
512
|
+
assert.equal(fs.existsSync(resolveMachineMcpDaemonRecordFilePath()), false);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test('machine MCP router rejects app-bound tools without projectId', async () => {
|
|
516
|
+
const server = createProteumMachineMcpServer({ version: 'test' });
|
|
517
|
+
const client = new Client({ name: 'machine-mcp-test', version: '1.0.0' });
|
|
518
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
519
|
+
|
|
520
|
+
await server.connect(serverTransport);
|
|
521
|
+
await client.connect(clientTransport);
|
|
522
|
+
|
|
523
|
+
const result = await client.callTool({ name: 'runtime_status', arguments: {} });
|
|
524
|
+
|
|
525
|
+
assert.equal(result.isError, true);
|
|
526
|
+
assert.match(result.content[0].text, /Missing required projectId/);
|
|
527
|
+
assert.match(result.content[0].text, /projects_list/);
|
|
528
|
+
|
|
529
|
+
await client.close();
|
|
530
|
+
await server.close();
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
test('machine MCP router forwards app tools without leaking projectId', async (t) => {
|
|
534
|
+
const previousRegistryDir = process.env.PROTEUM_MACHINE_DEV_SESSION_DIR;
|
|
535
|
+
const registryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-machine-router-'));
|
|
536
|
+
process.env.PROTEUM_MACHINE_DEV_SESSION_DIR = registryDir;
|
|
537
|
+
t.onTestFinished(() => {
|
|
538
|
+
if (previousRegistryDir === undefined) delete process.env.PROTEUM_MACHINE_DEV_SESSION_DIR;
|
|
539
|
+
else process.env.PROTEUM_MACHINE_DEV_SESSION_DIR = previousRegistryDir;
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-machine-app-'));
|
|
543
|
+
const sessionRecord = createDevSessionRecord({
|
|
544
|
+
appRoot,
|
|
545
|
+
port: 3103,
|
|
546
|
+
sessionFilePath: path.join(appRoot, 'var/run/proteum/dev/3103.json'),
|
|
547
|
+
});
|
|
548
|
+
const machineRecord = await writeMachineDevSessionRecord({
|
|
549
|
+
...sessionRecord,
|
|
550
|
+
publicUrl: 'http://localhost:3103',
|
|
551
|
+
state: 'ready',
|
|
552
|
+
});
|
|
553
|
+
let forwardedCall = null;
|
|
554
|
+
let closeCount = 0;
|
|
555
|
+
const server = createProteumMachineMcpServer({
|
|
556
|
+
createDevMcpClient: async () => ({
|
|
557
|
+
callTool: async (input) => {
|
|
558
|
+
forwardedCall = input;
|
|
559
|
+
return {
|
|
560
|
+
content: [
|
|
561
|
+
{
|
|
562
|
+
type: 'text',
|
|
563
|
+
text: JSON.stringify({ ok: true, format: 'proteum-mcp-v1', summary: 'forwarded', data: {} }),
|
|
564
|
+
},
|
|
565
|
+
],
|
|
566
|
+
};
|
|
567
|
+
},
|
|
568
|
+
close: async () => {
|
|
569
|
+
closeCount += 1;
|
|
570
|
+
},
|
|
571
|
+
}),
|
|
572
|
+
version: 'test',
|
|
573
|
+
});
|
|
574
|
+
const client = new Client({ name: 'machine-mcp-test', version: '1.0.0' });
|
|
575
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
576
|
+
|
|
577
|
+
await server.connect(serverTransport);
|
|
578
|
+
await client.connect(clientTransport);
|
|
579
|
+
|
|
580
|
+
const projects = await client.callTool({ name: 'projects_list', arguments: {} });
|
|
581
|
+
await client.callTool({
|
|
582
|
+
name: 'orient',
|
|
583
|
+
arguments: {
|
|
584
|
+
projectId: machineRecord.projectId,
|
|
585
|
+
query: '/domains',
|
|
586
|
+
},
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
assert.equal(projects.content[0].text.includes(machineRecord.projectId), true);
|
|
590
|
+
assert.equal(forwardedCall.name, 'orient');
|
|
591
|
+
assert.deepEqual(forwardedCall.arguments, { query: '/domains' });
|
|
592
|
+
|
|
593
|
+
await client.close();
|
|
594
|
+
await server.close();
|
|
595
|
+
assert.equal(closeCount, 1);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
test('machine MCP router resolves projects by cwd and bootstraps workflow without duplicate discovery', async (t) => {
|
|
599
|
+
const previousRegistryDir = process.env.PROTEUM_MACHINE_DEV_SESSION_DIR;
|
|
600
|
+
const registryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-machine-workflow-router-'));
|
|
601
|
+
process.env.PROTEUM_MACHINE_DEV_SESSION_DIR = registryDir;
|
|
602
|
+
t.onTestFinished(() => {
|
|
603
|
+
if (previousRegistryDir === undefined) delete process.env.PROTEUM_MACHINE_DEV_SESSION_DIR;
|
|
604
|
+
else process.env.PROTEUM_MACHINE_DEV_SESSION_DIR = previousRegistryDir;
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-machine-monorepo-'));
|
|
608
|
+
const productRoot = path.join(repoRoot, 'apps', 'product');
|
|
609
|
+
const websiteRoot = path.join(repoRoot, 'apps', 'website');
|
|
610
|
+
const productCwd = path.join(productRoot, 'client', 'pages');
|
|
611
|
+
fs.mkdirSync(productCwd, { recursive: true });
|
|
612
|
+
fs.mkdirSync(websiteRoot, { recursive: true });
|
|
613
|
+
|
|
614
|
+
const productMachineRecord = await writeMachineDevSessionRecord({
|
|
615
|
+
...createDevSessionRecord({
|
|
616
|
+
appRoot: productRoot,
|
|
617
|
+
port: 3105,
|
|
618
|
+
sessionFilePath: path.join(productRoot, 'var/run/proteum/dev/3105.json'),
|
|
619
|
+
}),
|
|
620
|
+
publicUrl: 'http://localhost:3105',
|
|
621
|
+
state: 'ready',
|
|
622
|
+
});
|
|
623
|
+
await writeMachineDevSessionRecord({
|
|
624
|
+
...createDevSessionRecord({
|
|
625
|
+
appRoot: websiteRoot,
|
|
626
|
+
port: 3106,
|
|
627
|
+
sessionFilePath: path.join(websiteRoot, 'var/run/proteum/dev/3106.json'),
|
|
628
|
+
}),
|
|
629
|
+
publicUrl: 'http://localhost:3106',
|
|
630
|
+
state: 'ready',
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
let forwardedCall = null;
|
|
634
|
+
const server = createProteumMachineMcpServer({
|
|
635
|
+
createDevMcpClient: async () => ({
|
|
636
|
+
callTool: async (input) => {
|
|
637
|
+
forwardedCall = input;
|
|
638
|
+
return {
|
|
639
|
+
content: [
|
|
640
|
+
{
|
|
641
|
+
type: 'text',
|
|
642
|
+
text: JSON.stringify({
|
|
643
|
+
ok: true,
|
|
644
|
+
format: 'proteum-mcp-v1',
|
|
645
|
+
summary: 'workflow',
|
|
646
|
+
data: { runtime: { appRoot: productRoot } },
|
|
647
|
+
nextActions: [{ label: 'Diagnose Route', tool: 'diagnose', toolArgs: { path: '/domains' } }],
|
|
648
|
+
}),
|
|
649
|
+
},
|
|
650
|
+
],
|
|
651
|
+
};
|
|
652
|
+
},
|
|
653
|
+
close: async () => {},
|
|
654
|
+
}),
|
|
655
|
+
version: 'test',
|
|
656
|
+
});
|
|
657
|
+
const client = new Client({ name: 'machine-mcp-test', version: '1.0.0' });
|
|
658
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
659
|
+
|
|
660
|
+
await server.connect(serverTransport);
|
|
661
|
+
await client.connect(clientTransport);
|
|
662
|
+
|
|
663
|
+
const monorepoResolve = await client.callTool({ name: 'project_resolve', arguments: { cwd: repoRoot } });
|
|
664
|
+
const monorepoPayload = JSON.parse(monorepoResolve.content[0].text);
|
|
665
|
+
assert.equal(monorepoPayload.data.projects.length, 2);
|
|
666
|
+
assert.equal(monorepoPayload.data.projects.every((project) => project.matchReason === 'app-under-cwd'), true);
|
|
667
|
+
|
|
668
|
+
const directResolve = await client.callTool({ name: 'project_resolve', arguments: { cwd: productCwd } });
|
|
669
|
+
const directPayload = JSON.parse(directResolve.content[0].text);
|
|
670
|
+
assert.equal(directPayload.data.projects.length, 1);
|
|
671
|
+
assert.equal(directPayload.data.projects[0].projectId, productMachineRecord.projectId);
|
|
672
|
+
assert.equal(directPayload.data.projects[0].matchReason, 'cwd-inside-app');
|
|
673
|
+
|
|
674
|
+
const workflow = await client.callTool({
|
|
675
|
+
name: 'workflow_start',
|
|
676
|
+
arguments: { cwd: productCwd, route: '/domains', task: 'read-only runtime health pass' },
|
|
677
|
+
});
|
|
678
|
+
const workflowPayload = JSON.parse(workflow.content[0].text);
|
|
679
|
+
|
|
680
|
+
assert.equal(forwardedCall.name, 'workflow_start');
|
|
681
|
+
assert.deepEqual(forwardedCall.arguments, { route: '/domains', task: 'read-only runtime health pass' });
|
|
682
|
+
assert.equal(workflowPayload.data.project.projectId, productMachineRecord.projectId);
|
|
683
|
+
assert.equal(workflowPayload.nextActions[0].toolArgs.projectId, productMachineRecord.projectId);
|
|
684
|
+
|
|
685
|
+
await client.close();
|
|
686
|
+
await server.close();
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
test('machine MCP router resolves offline monorepo app candidates before dev is running', async (t) => {
|
|
690
|
+
const previousRegistryDir = process.env.PROTEUM_MACHINE_DEV_SESSION_DIR;
|
|
691
|
+
const registryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-machine-offline-registry-'));
|
|
692
|
+
process.env.PROTEUM_MACHINE_DEV_SESSION_DIR = registryDir;
|
|
693
|
+
t.onTestFinished(() => {
|
|
694
|
+
if (previousRegistryDir === undefined) delete process.env.PROTEUM_MACHINE_DEV_SESSION_DIR;
|
|
695
|
+
else process.env.PROTEUM_MACHINE_DEV_SESSION_DIR = previousRegistryDir;
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-machine-offline-monorepo-'));
|
|
699
|
+
const productRoot = path.join(repoRoot, 'apps', 'product');
|
|
700
|
+
const websiteRoot = path.join(repoRoot, 'apps', 'website');
|
|
701
|
+
const domainsFile = path.join(productRoot, 'client/pages/domains.tsx');
|
|
702
|
+
|
|
703
|
+
writeProteumAppFixture(productRoot, {
|
|
704
|
+
identifier: 'ProductApp',
|
|
705
|
+
name: 'Product',
|
|
706
|
+
routerPort: 3020,
|
|
707
|
+
clientRoutes: [
|
|
708
|
+
{
|
|
709
|
+
chunkId: 'domains',
|
|
710
|
+
filepath: domainsFile,
|
|
711
|
+
hasData: false,
|
|
712
|
+
invalidOptionKeys: [],
|
|
713
|
+
kind: 'client-page',
|
|
714
|
+
methodName: 'page',
|
|
715
|
+
normalizedOptionKeys: [],
|
|
716
|
+
optionKeys: [],
|
|
717
|
+
path: '/domains',
|
|
718
|
+
scope: 'app',
|
|
719
|
+
serviceLocalName: 'Router',
|
|
720
|
+
sourceLocation: { line: 1, column: 1 },
|
|
721
|
+
targetResolution: 'literal',
|
|
722
|
+
},
|
|
723
|
+
],
|
|
724
|
+
});
|
|
725
|
+
writeProteumAppFixture(websiteRoot, {
|
|
726
|
+
identifier: 'WebsiteApp',
|
|
727
|
+
name: 'Website',
|
|
728
|
+
routerPort: 3021,
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
const server = createProteumMachineMcpServer({ version: 'test' });
|
|
732
|
+
const client = new Client({ name: 'machine-mcp-test', version: '1.0.0' });
|
|
733
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
734
|
+
|
|
735
|
+
await server.connect(serverTransport);
|
|
736
|
+
await client.connect(clientTransport);
|
|
737
|
+
|
|
738
|
+
const resolve = await client.callTool({ name: 'project_resolve', arguments: { cwd: repoRoot } });
|
|
739
|
+
const resolvePayload = JSON.parse(resolve.content[0].text);
|
|
740
|
+
|
|
741
|
+
assert.equal(resolvePayload.data.projects.length, 2);
|
|
742
|
+
assert.equal(resolvePayload.data.projects.every((project) => project.live === false), true);
|
|
743
|
+
const canonicalProductRoot = fs.realpathSync(productRoot);
|
|
744
|
+
assert.equal(
|
|
745
|
+
resolvePayload.data.projects.some(
|
|
746
|
+
(project) => project.appRoot === canonicalProductRoot && project.manifest.routerPort === 3020,
|
|
747
|
+
),
|
|
748
|
+
true,
|
|
749
|
+
);
|
|
750
|
+
assert.match(resolvePayload.data.projects[0].nextAction.command, /npx proteum dev/);
|
|
751
|
+
|
|
752
|
+
const workflow = await client.callTool({
|
|
753
|
+
name: 'workflow_start',
|
|
754
|
+
arguments: { cwd: path.join(productRoot, 'client', 'pages'), route: '/domains', task: 'read-only runtime health pass' },
|
|
755
|
+
});
|
|
756
|
+
const workflowPayload = JSON.parse(workflow.content[0].text);
|
|
757
|
+
|
|
758
|
+
assert.equal(workflowPayload.ok, true);
|
|
759
|
+
assert.equal(workflowPayload.data.project.live, false);
|
|
760
|
+
assert.equal(workflowPayload.data.owner.top.label, '/domains');
|
|
761
|
+
assert.equal(workflowPayload.nextActions[0].label, 'Start Dev');
|
|
762
|
+
assert.equal(workflowPayload.nextActions.some((action) => action.tool === 'diagnose'), false);
|
|
763
|
+
|
|
764
|
+
await client.close();
|
|
765
|
+
await server.close();
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
test('machine MCP offline resolution inspects occupied ports before suggesting dev start', async (t) => {
|
|
769
|
+
const previousRegistryDir = process.env.PROTEUM_MACHINE_DEV_SESSION_DIR;
|
|
770
|
+
const registryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-machine-offline-port-registry-'));
|
|
771
|
+
process.env.PROTEUM_MACHINE_DEV_SESSION_DIR = registryDir;
|
|
772
|
+
t.onTestFinished(() => {
|
|
773
|
+
if (previousRegistryDir === undefined) delete process.env.PROTEUM_MACHINE_DEV_SESSION_DIR;
|
|
774
|
+
else process.env.PROTEUM_MACHINE_DEV_SESSION_DIR = previousRegistryDir;
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-machine-offline-port-'));
|
|
778
|
+
const otherRoot = path.join(repoRoot, 'apps', 'other');
|
|
779
|
+
const productRoot = path.join(repoRoot, 'apps', 'product');
|
|
780
|
+
const ownerServer = http.createServer((req, res) => {
|
|
781
|
+
if (req.url && req.url.startsWith('/__proteum/explain')) {
|
|
782
|
+
res.setHeader('content-type', 'application/json');
|
|
783
|
+
res.end(
|
|
784
|
+
JSON.stringify({
|
|
785
|
+
app: {
|
|
786
|
+
root: otherRoot,
|
|
787
|
+
identity: { identifier: 'OtherApp', name: 'Other' },
|
|
788
|
+
},
|
|
789
|
+
}),
|
|
790
|
+
);
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
res.setHeader('content-type', 'text/html');
|
|
795
|
+
res.end('<html><body>wrong app page body that should not be routed into MCP</body></html>');
|
|
796
|
+
});
|
|
797
|
+
const occupiedPort = await listen(ownerServer);
|
|
798
|
+
|
|
799
|
+
try {
|
|
800
|
+
writeProteumAppFixture(productRoot, {
|
|
801
|
+
identifier: 'ProductApp',
|
|
802
|
+
name: 'Product',
|
|
803
|
+
routerPort: occupiedPort,
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
const server = createProteumMachineMcpServer({ version: 'test' });
|
|
807
|
+
const client = new Client({ name: 'machine-mcp-test', version: '1.0.0' });
|
|
808
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
809
|
+
|
|
810
|
+
await server.connect(serverTransport);
|
|
811
|
+
await client.connect(clientTransport);
|
|
812
|
+
|
|
813
|
+
const resolve = await client.callTool({ name: 'project_resolve', arguments: { cwd: productRoot } });
|
|
814
|
+
const payload = JSON.parse(resolve.content[0].text);
|
|
815
|
+
const project = payload.data.projects[0];
|
|
816
|
+
|
|
817
|
+
assert.equal(project.devPort.router.port, occupiedPort);
|
|
818
|
+
assert.equal(project.devPort.router.proteum, true);
|
|
819
|
+
assert.equal(project.devPort.router.matchesApp, false);
|
|
820
|
+
assert.equal(project.devPort.router.app.identifier, 'OtherApp');
|
|
821
|
+
assert.equal(project.nextAction.label, 'Start Dev');
|
|
822
|
+
assert.match(project.nextAction.command, /npx proteum dev/);
|
|
823
|
+
assert.doesNotMatch(project.nextAction.command, new RegExp(`--port ${occupiedPort}(\\D|$)`));
|
|
824
|
+
assert.match(project.nextAction.reason, /alternate free pair/);
|
|
825
|
+
assert.doesNotMatch(resolve.content[0].text, /wrong app page body/);
|
|
826
|
+
|
|
827
|
+
await client.close();
|
|
828
|
+
await server.close();
|
|
829
|
+
} finally {
|
|
830
|
+
await closeServer(ownerServer);
|
|
831
|
+
}
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
test('machine MCP offline resolution does not start a second server for an untracked same-app runtime', async (t) => {
|
|
835
|
+
const previousRegistryDir = process.env.PROTEUM_MACHINE_DEV_SESSION_DIR;
|
|
836
|
+
const registryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-machine-same-port-registry-'));
|
|
837
|
+
process.env.PROTEUM_MACHINE_DEV_SESSION_DIR = registryDir;
|
|
838
|
+
t.onTestFinished(() => {
|
|
839
|
+
if (previousRegistryDir === undefined) delete process.env.PROTEUM_MACHINE_DEV_SESSION_DIR;
|
|
840
|
+
else process.env.PROTEUM_MACHINE_DEV_SESSION_DIR = previousRegistryDir;
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-machine-same-port-'));
|
|
844
|
+
const productRoot = path.join(repoRoot, 'apps', 'product');
|
|
845
|
+
const ownerServer = http.createServer((req, res) => {
|
|
846
|
+
if (req.url && req.url.startsWith('/__proteum/explain')) {
|
|
847
|
+
res.setHeader('content-type', 'application/json');
|
|
848
|
+
res.end(
|
|
849
|
+
JSON.stringify({
|
|
850
|
+
app: {
|
|
851
|
+
root: productRoot,
|
|
852
|
+
identity: { identifier: 'ProductApp', name: 'Product' },
|
|
853
|
+
},
|
|
854
|
+
}),
|
|
855
|
+
);
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
res.setHeader('content-type', 'text/html');
|
|
860
|
+
res.end('<html><body>same app page body that should not be routed into MCP</body></html>');
|
|
861
|
+
});
|
|
862
|
+
const occupiedPort = await listen(ownerServer);
|
|
863
|
+
|
|
864
|
+
try {
|
|
865
|
+
writeProteumAppFixture(productRoot, {
|
|
866
|
+
identifier: 'ProductApp',
|
|
867
|
+
name: 'Product',
|
|
868
|
+
routerPort: occupiedPort,
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
const server = createProteumMachineMcpServer({ version: 'test' });
|
|
872
|
+
const client = new Client({ name: 'machine-mcp-test', version: '1.0.0' });
|
|
873
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
874
|
+
|
|
875
|
+
await server.connect(serverTransport);
|
|
876
|
+
await client.connect(clientTransport);
|
|
877
|
+
|
|
878
|
+
const resolve = await client.callTool({ name: 'project_resolve', arguments: { cwd: productRoot } });
|
|
879
|
+
const payload = JSON.parse(resolve.content[0].text);
|
|
880
|
+
const project = payload.data.projects[0];
|
|
881
|
+
|
|
882
|
+
assert.equal(project.devPort.router.matchesApp, true);
|
|
883
|
+
assert.equal(project.nextAction.label, 'Repair Runtime Tracking');
|
|
884
|
+
assert.match(project.nextAction.command, /npx proteum runtime status/);
|
|
885
|
+
assert.doesNotMatch(project.nextAction.command, /npx proteum dev/);
|
|
886
|
+
assert.match(project.nextAction.reason, /Do not start a second dev server/);
|
|
887
|
+
assert.doesNotMatch(resolve.content[0].text, /same app page body/);
|
|
888
|
+
|
|
889
|
+
await client.close();
|
|
890
|
+
await server.close();
|
|
891
|
+
} finally {
|
|
892
|
+
await closeServer(ownerServer);
|
|
893
|
+
}
|
|
894
|
+
});
|