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.
Files changed (56) hide show
  1. package/AGENTS.md +8 -3
  2. package/README.md +20 -15
  3. package/agents/project/AGENTS.md +16 -10
  4. package/agents/project/DOCUMENTATION.md +1326 -0
  5. package/agents/project/app-root/AGENTS.md +2 -2
  6. package/agents/project/diagnostics.md +10 -9
  7. package/agents/project/optimizations.md +1 -1
  8. package/agents/project/root/AGENTS.md +15 -8
  9. package/agents/project/server/services/AGENTS.md +1 -0
  10. package/agents/project/tests/AGENTS.md +1 -0
  11. package/cli/commands/db.ts +160 -0
  12. package/cli/commands/dev.ts +148 -25
  13. package/cli/commands/diagnose.ts +2 -0
  14. package/cli/commands/explain.ts +38 -9
  15. package/cli/commands/mcp.ts +126 -9
  16. package/cli/commands/orient.ts +44 -17
  17. package/cli/commands/runtime.ts +100 -17
  18. package/cli/mcp/router.ts +1028 -0
  19. package/cli/presentation/commands.ts +56 -25
  20. package/cli/presentation/help.ts +1 -1
  21. package/cli/runtime/commands.ts +163 -21
  22. package/cli/runtime/devSessions.ts +328 -2
  23. package/cli/runtime/mcpDaemon.ts +288 -0
  24. package/cli/runtime/ports.ts +151 -0
  25. package/cli/utils/agents.ts +94 -17
  26. package/cli/utils/appRoots.ts +232 -0
  27. package/common/dev/database.ts +226 -0
  28. package/common/dev/diagnostics.ts +1 -1
  29. package/common/dev/inspection.ts +8 -1
  30. package/common/dev/mcpPayloads.ts +456 -17
  31. package/common/dev/mcpServer.ts +51 -0
  32. package/docs/agent-routing.md +32 -21
  33. package/docs/dev-commands.md +1 -1
  34. package/docs/dev-sessions.md +3 -1
  35. package/docs/diagnostics.md +21 -20
  36. package/docs/mcp.md +114 -50
  37. package/docs/migrate-from-2.1.3.md +3 -5
  38. package/docs/request-tracing.md +3 -3
  39. package/package.json +10 -3
  40. package/server/app/devDiagnostics.ts +92 -0
  41. package/server/app/devMcp.ts +55 -0
  42. package/server/services/prisma/mariadb.ts +7 -3
  43. package/server/services/router/http/index.ts +25 -0
  44. package/server/services/router/request/ip.test.cjs +0 -1
  45. package/tests/agents-utils.test.cjs +58 -3
  46. package/tests/cli-mcp-command.test.cjs +327 -0
  47. package/tests/codex-mcp-usage.test.cjs +307 -0
  48. package/tests/dev-sessions.test.cjs +113 -0
  49. package/tests/dev-transpile-watch.test.cjs +0 -1
  50. package/tests/eslint-rules.test.cjs +0 -1
  51. package/tests/inspection.test.cjs +0 -1
  52. package/tests/mcp.test.cjs +769 -2
  53. package/tests/router-cache-config.test.cjs +0 -1
  54. package/vitest.config.mjs +9 -0
  55. package/cli/mcp/provider.ts +0 -365
  56. package/cli/mcp/stdio.ts +0 -16
@@ -0,0 +1,1028 @@
1
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
3
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
6
+ import { isInitializeRequest, type CallToolResult } from '@modelcontextprotocol/sdk/types.js';
7
+ import fs from 'fs-extra';
8
+ import http from 'http';
9
+ import { randomUUID } from 'crypto';
10
+ import path from 'path';
11
+ import { realpath } from 'fs/promises';
12
+ import { z } from 'zod/v4';
13
+
14
+ import { buildContractsDoctorResponse } from '../../common/dev/contractsDoctor';
15
+ import { buildDoctorResponse } from '../../common/dev/diagnostics';
16
+ import { explainOwner } from '../../common/dev/inspection';
17
+ import { compactWorkflowStartResponse, createMcpPayload, stringifyMcpPayload } from '../../common/dev/mcpPayloads';
18
+ import { readProteumManifest } from '../compiler/common/proteumManifest';
19
+ import {
20
+ createMachineMcpDaemonRecord,
21
+ removeMachineMcpDaemonRecord,
22
+ removeMachineMcpDaemonRecordSync,
23
+ writeMachineMcpDaemonRecord,
24
+ } from '../runtime/mcpDaemon';
25
+ import {
26
+ listMachineDevSessionInspections,
27
+ resolveProteumProjectId,
28
+ resolveMachineDevSessionInspection,
29
+ type TMachineDevSessionRecord,
30
+ } from '../runtime/devSessions';
31
+ import {
32
+ createStartDevCommand,
33
+ createRuntimeStatusCommand,
34
+ findNearestProteumAppRoot,
35
+ findProteumAppRootsUnder,
36
+ readProteumAppRootSummary,
37
+ type TProteumAppRootSummary,
38
+ } from '../utils/appRoots';
39
+ import { inspectDevPort, type TDevPortInspection } from '../runtime/ports';
40
+
41
+ type TDevMcpClient = {
42
+ callTool: (input: { arguments?: Record<string, unknown>; name: string }) => Promise<CallToolResult>;
43
+ close: () => Promise<void>;
44
+ };
45
+
46
+ type TCreateDevMcpClient = (record: TMachineDevSessionRecord) => Promise<TDevMcpClient>;
47
+
48
+ type TCreateProteumMachineMcpServerArgs = {
49
+ createDevMcpClient?: TCreateDevMcpClient;
50
+ version: string;
51
+ };
52
+
53
+ const readOnlyAnnotations = {
54
+ destructiveHint: false,
55
+ idempotentHint: true,
56
+ openWorldHint: false,
57
+ readOnlyHint: true,
58
+ };
59
+
60
+ const detailSchema = z.enum(['compact', 'full']).optional();
61
+ const databaseLimitSchema = z.number().int().min(1).max(500).optional();
62
+ const databaseTimeoutSchema = z.number().int().min(100).max(30_000).optional();
63
+ const logsLevelSchema = z.enum(['silly', 'log', 'info', 'warn', 'error']).optional();
64
+ const offsetSchema = z.number().int().min(0).max(10_000).optional();
65
+ const positiveLimitSchema = z.number().int().min(1).max(100).optional();
66
+ const projectIdSchema = z
67
+ .string()
68
+ .min(1)
69
+ .optional()
70
+ .describe('Required stable project id from projects_list, for example prj_0123abcd4567.');
71
+
72
+ const jsonToolResult = (payload: object, isError = false): CallToolResult => ({
73
+ content: [
74
+ {
75
+ type: 'text',
76
+ text: stringifyMcpPayload(payload),
77
+ },
78
+ ],
79
+ ...(isError ? { isError: true } : {}),
80
+ });
81
+
82
+ const errorToolResult = (summary: string, data: Record<string, unknown> = {}) =>
83
+ jsonToolResult(
84
+ {
85
+ ok: false,
86
+ format: 'proteum-mcp-v1',
87
+ summary,
88
+ data,
89
+ nextActions: [
90
+ {
91
+ label: 'List Projects',
92
+ tool: 'projects_list',
93
+ reason: 'Resolve the Proteum project before calling app-bound tools.',
94
+ },
95
+ ],
96
+ },
97
+ true,
98
+ );
99
+
100
+ const compactProject = (record: TMachineDevSessionRecord) => ({
101
+ projectId: record.projectId,
102
+ appRoot: record.appRoot,
103
+ live: true,
104
+ pid: record.pid,
105
+ routerPort: record.routerPort,
106
+ publicUrl: record.publicUrl,
107
+ mcpUrl: record.mcpUrl,
108
+ sessionFilePath: record.sessionFilePath,
109
+ state: record.state,
110
+ startedAt: record.startedAt,
111
+ updatedAt: record.updatedAt,
112
+ });
113
+
114
+ const compactProjectMatch = (record: TMachineDevSessionRecord, matchReason: string) => ({
115
+ ...compactProject(record),
116
+ matchReason,
117
+ });
118
+
119
+ type TOfflineProject = TProteumAppRootSummary & {
120
+ devPort?: TDevPortInspection;
121
+ live: false;
122
+ matchReason: string;
123
+ nextAction: {
124
+ command: string;
125
+ label: string;
126
+ reason: string;
127
+ };
128
+ projectId: string;
129
+ state: 'offline';
130
+ };
131
+
132
+ const createOfflineNextAction = async ({
133
+ appRoot,
134
+ baseRoot,
135
+ portInspection,
136
+ summary,
137
+ }: {
138
+ appRoot: string;
139
+ baseRoot?: string;
140
+ portInspection?: TDevPortInspection;
141
+ summary: TProteumAppRootSummary;
142
+ }) => {
143
+ if (!summary.manifest) {
144
+ return {
145
+ label: 'Check Runtime Status',
146
+ command: createRuntimeStatusCommand({ appRoot, baseRoot }),
147
+ reason: 'Resolve the app manifest and exact dev-session recovery action before runtime diagnosis.',
148
+ };
149
+ }
150
+
151
+ if (portInspection?.router.proteum && portInspection.router.matchesApp) {
152
+ return {
153
+ label: 'Repair Runtime Tracking',
154
+ command: createRuntimeStatusCommand({ appRoot, baseRoot }),
155
+ reason:
156
+ 'A Proteum runtime for this app already responds on the configured port but is not registered as a live machine project. Do not start a second dev server; use runtime status to repair or stop it before retrying workflow_start.',
157
+ };
158
+ }
159
+
160
+ const startPort =
161
+ portInspection && !portInspection.canStartOnConfiguredPort ? portInspection.recommendedPort : summary.manifest.routerPort;
162
+
163
+ return {
164
+ label: 'Start Dev',
165
+ command: createStartDevCommand({
166
+ appRoot: summary.appRoot,
167
+ baseRoot,
168
+ port: startPort,
169
+ }),
170
+ reason:
171
+ portInspection && !portInspection.canStartOnConfiguredPort
172
+ ? 'The configured router/HMR port pair is occupied; start exactly one tracked Proteum dev server on this alternate free pair before runtime diagnosis.'
173
+ : 'Start exactly one tracked Proteum dev server from this app root before runtime diagnosis.',
174
+ };
175
+ };
176
+
177
+ const compactOfflineProject = async ({
178
+ appRoot,
179
+ baseRoot,
180
+ matchReason,
181
+ }: {
182
+ appRoot: string;
183
+ baseRoot?: string;
184
+ matchReason: string;
185
+ }): Promise<TOfflineProject> => {
186
+ const summary = readProteumAppRootSummary(appRoot, baseRoot);
187
+ const portInspection = summary.manifest
188
+ ? await inspectDevPort({
189
+ appRoot: summary.appRoot,
190
+ port: summary.manifest.routerPort,
191
+ })
192
+ : undefined;
193
+
194
+ return {
195
+ ...summary,
196
+ devPort: portInspection,
197
+ live: false,
198
+ matchReason,
199
+ nextAction: await createOfflineNextAction({ appRoot: summary.appRoot, baseRoot, portInspection, summary }),
200
+ projectId: await resolveProteumProjectId(summary.appRoot),
201
+ state: 'offline',
202
+ };
203
+ };
204
+
205
+ const createHttpDevMcpClient = (version: string): TCreateDevMcpClient => async (record) => {
206
+ const client = new Client({ name: 'proteum-machine-router', version });
207
+ const transport = new StreamableHTTPClientTransport(new URL(record.mcpUrl));
208
+
209
+ await client.connect(transport);
210
+
211
+ return {
212
+ callTool: async (input) => (await client.callTool(input)) as CallToolResult,
213
+ close: async () => await client.close(),
214
+ };
215
+ };
216
+
217
+ const stripProjectRouting = ({ cwd: _cwd, projectId: _projectId, ...input }: Record<string, unknown>) => input;
218
+
219
+ const normalizeExistingPath = async (value: string) => {
220
+ const resolved = path.resolve(value);
221
+
222
+ try {
223
+ return path.normalize(await realpath(resolved));
224
+ } catch (_error) {
225
+ return path.normalize(resolved);
226
+ }
227
+ };
228
+
229
+ const withTrailingSeparator = (value: string) => (value.endsWith(path.sep) ? value : `${value}${path.sep}`);
230
+
231
+ const isSameOrDescendant = (candidate: string, ancestor: string) =>
232
+ candidate === ancestor || candidate.startsWith(withTrailingSeparator(ancestor));
233
+
234
+ export const createProteumMachineMcpServer = ({ createDevMcpClient, version }: TCreateProteumMachineMcpServerArgs) => {
235
+ const server = new McpServer(
236
+ {
237
+ name: 'proteum',
238
+ version,
239
+ },
240
+ {
241
+ capabilities: {
242
+ logging: {},
243
+ },
244
+ },
245
+ );
246
+ const clients = new Map<string, TDevMcpClient>();
247
+ const resolveDevMcpClient = createDevMcpClient || createHttpDevMcpClient(version);
248
+
249
+ const cacheKey = (record: TMachineDevSessionRecord) => `${record.projectId}:${record.mcpUrl}`;
250
+
251
+ const getClient = async (record: TMachineDevSessionRecord) => {
252
+ const key = cacheKey(record);
253
+ const cached = clients.get(key);
254
+ if (cached) return cached;
255
+
256
+ const client = await resolveDevMcpClient(record);
257
+ clients.set(key, client);
258
+ return client;
259
+ };
260
+
261
+ const closeClient = async (record: TMachineDevSessionRecord) => {
262
+ const key = cacheKey(record);
263
+ const client = clients.get(key);
264
+ clients.delete(key);
265
+ if (client) await client.close().catch(() => undefined);
266
+ };
267
+
268
+ const closeAllClients = async () => {
269
+ const cachedClients = [...clients.values()];
270
+ clients.clear();
271
+ await Promise.all(cachedClients.map(async (client) => await client.close().catch(() => undefined)));
272
+ };
273
+
274
+ const listLiveRecords = async () =>
275
+ (await listMachineDevSessionInspections())
276
+ .map((inspection) => inspection.record)
277
+ .filter((record): record is TMachineDevSessionRecord => record !== null);
278
+
279
+ type TProjectMatch = {
280
+ offline?: TOfflineProject;
281
+ project: ReturnType<typeof compactProjectMatch> | TOfflineProject;
282
+ record?: TMachineDevSessionRecord;
283
+ };
284
+
285
+ const resolveProjectMatches = async ({
286
+ cwd,
287
+ projectId,
288
+ query,
289
+ }: {
290
+ cwd?: unknown;
291
+ projectId?: unknown;
292
+ query?: unknown;
293
+ }) => {
294
+ const records = await listLiveRecords();
295
+ const matches = new Map<string, TProjectMatch>();
296
+ const addMatch = (record: TMachineDevSessionRecord, reason: string) => {
297
+ if (!matches.has(record.projectId)) matches.set(record.projectId, { project: compactProjectMatch(record, reason), record });
298
+ };
299
+ const addOfflineMatch = async (appRoot: string, reason: string, baseRoot?: string) => {
300
+ const offline = await compactOfflineProject({ appRoot, baseRoot, matchReason: reason });
301
+ if (!matches.has(offline.projectId)) matches.set(offline.projectId, { offline, project: offline });
302
+ };
303
+ const normalizedProjectId = typeof projectId === 'string' ? projectId.trim() : '';
304
+ const normalizedQuery = typeof query === 'string' ? query.trim() : '';
305
+
306
+ if (normalizedProjectId) {
307
+ const record = records.find((candidate) => candidate.projectId === normalizedProjectId);
308
+ if (record) addMatch(record, 'projectId');
309
+ }
310
+
311
+ if (normalizedQuery) {
312
+ const queryIsExistingAbsolutePath = path.isAbsolute(normalizedQuery) && fs.pathExistsSync(normalizedQuery);
313
+ const normalizedQueryPath = queryIsExistingAbsolutePath ? await normalizeExistingPath(normalizedQuery) : '';
314
+
315
+ for (const record of records) {
316
+ if (
317
+ record.projectId === normalizedQuery ||
318
+ record.appRoot === normalizedQuery ||
319
+ record.appRoot.includes(normalizedQuery) ||
320
+ (normalizedQueryPath &&
321
+ (isSameOrDescendant(normalizedQueryPath, path.normalize(record.appRoot)) ||
322
+ isSameOrDescendant(path.normalize(record.appRoot), normalizedQueryPath)))
323
+ ) {
324
+ addMatch(record, 'query');
325
+ }
326
+ }
327
+
328
+ if (queryIsExistingAbsolutePath) {
329
+ const nearestAppRoot = findNearestProteumAppRoot(normalizedQueryPath);
330
+ if (nearestAppRoot) await addOfflineMatch(nearestAppRoot, 'query-inside-app', nearestAppRoot);
331
+ else {
332
+ for (const appRoot of findProteumAppRootsUnder(normalizedQueryPath)) {
333
+ await addOfflineMatch(appRoot, 'app-under-query', normalizedQueryPath);
334
+ }
335
+ }
336
+ }
337
+ }
338
+
339
+ if (typeof cwd === 'string' && cwd.trim()) {
340
+ const normalizedCwd = await normalizeExistingPath(cwd.trim());
341
+ const directMatches = records
342
+ .filter((record) => isSameOrDescendant(normalizedCwd, path.normalize(record.appRoot)))
343
+ .sort((left, right) => right.appRoot.length - left.appRoot.length);
344
+
345
+ for (const record of directMatches) addMatch(record, 'cwd-inside-app');
346
+
347
+ if (directMatches.length === 0) {
348
+ const childMatches = records
349
+ .filter((record) => isSameOrDescendant(path.normalize(record.appRoot), normalizedCwd))
350
+ .sort((left, right) => left.appRoot.localeCompare(right.appRoot));
351
+
352
+ for (const record of childMatches) addMatch(record, 'app-under-cwd');
353
+ }
354
+
355
+ const nearestAppRoot = findNearestProteumAppRoot(normalizedCwd);
356
+ if (nearestAppRoot) {
357
+ await addOfflineMatch(nearestAppRoot, 'cwd-inside-app', normalizedCwd);
358
+ } else {
359
+ for (const appRoot of findProteumAppRootsUnder(normalizedCwd)) {
360
+ await addOfflineMatch(appRoot, 'app-under-cwd', normalizedCwd);
361
+ }
362
+ }
363
+ }
364
+
365
+ return [...matches.values()];
366
+ };
367
+
368
+ const resolveProject = async (projectId: unknown) => {
369
+ if (typeof projectId !== 'string' || !projectId.trim()) {
370
+ return {
371
+ error: errorToolResult('Missing required projectId. Call projects_list, then pass the selected projectId.'),
372
+ record: null,
373
+ };
374
+ }
375
+
376
+ const inspection = await resolveMachineDevSessionInspection(projectId.trim());
377
+ if (!inspection?.record) {
378
+ return {
379
+ error: errorToolResult(`Unknown or stale Proteum projectId: ${projectId}. Call projects_list and retry.`, {
380
+ projectId,
381
+ }),
382
+ record: null,
383
+ };
384
+ }
385
+
386
+ return { error: null, record: inspection.record };
387
+ };
388
+
389
+ const forwardTool = async (name: string, input: Record<string, unknown>) => {
390
+ const resolution = await resolveProject(input.projectId);
391
+ if (!resolution.record) return resolution.error;
392
+
393
+ try {
394
+ const client = await getClient(resolution.record);
395
+ return await client.callTool({
396
+ arguments: stripProjectRouting(input),
397
+ name,
398
+ });
399
+ } catch (error) {
400
+ await closeClient(resolution.record);
401
+ return errorToolResult(`Could not reach Proteum dev MCP for ${resolution.record.projectId}.`, {
402
+ error: error instanceof Error ? error.message : String(error),
403
+ mcpUrl: resolution.record.mcpUrl,
404
+ projectId: resolution.record.projectId,
405
+ });
406
+ }
407
+ };
408
+
409
+ const createOfflineWorkflowStartResult = (offline: TOfflineProject, input: Record<string, unknown>) => {
410
+ let manifest: ReturnType<typeof readProteumManifest>;
411
+ try {
412
+ manifest = readProteumManifest(offline.appRoot);
413
+ } catch (error) {
414
+ return jsonToolResult(
415
+ createMcpPayload({
416
+ summary: `Matched offline Proteum app ${offline.appRoot}, but no readable manifest is available.`,
417
+ data: {
418
+ project: offline,
419
+ error: error instanceof Error ? error.message : String(error),
420
+ },
421
+ nextActions: [
422
+ offline.nextAction,
423
+ {
424
+ label: 'Refresh Manifest',
425
+ command: 'npx proteum refresh',
426
+ reason: 'Generate the compact manifest before owner, route, or instruction routing reads.',
427
+ },
428
+ ],
429
+ }),
430
+ );
431
+ }
432
+
433
+ const doctor = buildDoctorResponse(manifest);
434
+ const contracts = buildContractsDoctorResponse(manifest);
435
+ const route = typeof input.route === 'string' ? input.route : undefined;
436
+ const file = typeof input.file === 'string' ? input.file : undefined;
437
+ const query = typeof input.query === 'string' ? input.query : undefined;
438
+ const task = typeof input.task === 'string' ? input.task : undefined;
439
+ const ownerQuery = [route, file, query]
440
+ .map((value) => value?.trim())
441
+ .find((value): value is string => Boolean(value));
442
+ const payload = compactWorkflowStartResponse({
443
+ contracts,
444
+ doctor,
445
+ file,
446
+ health: {
447
+ reachable: false,
448
+ error: 'No live tracked Proteum dev session is available for this app.',
449
+ },
450
+ manifest,
451
+ owner: ownerQuery ? explainOwner(manifest, ownerQuery) : undefined,
452
+ query,
453
+ route,
454
+ task,
455
+ });
456
+
457
+ return jsonToolResult({
458
+ ...payload,
459
+ data: {
460
+ project: offline,
461
+ ...payload.data,
462
+ },
463
+ nextActions: [
464
+ offline.nextAction,
465
+ ...(Array.isArray(payload.nextActions)
466
+ ? payload.nextActions.filter((action: { label?: unknown }) => action.label !== 'Start Dev')
467
+ : []),
468
+ ],
469
+ });
470
+ };
471
+
472
+ const workflowStart = async (input: Record<string, unknown>) => {
473
+ const matches = await resolveProjectMatches({
474
+ cwd: input.cwd,
475
+ projectId: input.projectId,
476
+ query: input.projectId ? undefined : input.cwd ? undefined : input.query,
477
+ });
478
+
479
+ if (matches.length !== 1) {
480
+ return errorToolResult(
481
+ matches.length === 0
482
+ ? 'Could not resolve a live or offline Proteum project for workflow_start. Pass projectId or cwd, or call project_resolve.'
483
+ : 'workflow_start matched multiple Proteum projects. Pass the intended projectId or app cwd.',
484
+ {
485
+ matches: matches.map((match) => match.project),
486
+ },
487
+ );
488
+ }
489
+
490
+ const selectedMatch = matches[0];
491
+ const record = selectedMatch.record;
492
+
493
+ if (!record && selectedMatch.offline) return createOfflineWorkflowStartResult(selectedMatch.offline, input);
494
+ if (!record) {
495
+ return errorToolResult('Could not resolve a live Proteum project for workflow_start. Call projects_list or project_resolve.', {
496
+ matches: matches.map((match) => match.project),
497
+ });
498
+ }
499
+
500
+ try {
501
+ const client = await getClient(record);
502
+ const result = await client.callTool({
503
+ arguments: stripProjectRouting(input),
504
+ name: 'workflow_start',
505
+ });
506
+
507
+ if (result.content[0]?.type !== 'text') return result;
508
+
509
+ const payload = JSON.parse(result.content[0].text);
510
+ const routedNextActions = Array.isArray(payload.nextActions)
511
+ ? payload.nextActions.map((action: Record<string, unknown>) =>
512
+ action.tool && typeof action.tool === 'string'
513
+ ? {
514
+ ...action,
515
+ toolArgs: {
516
+ projectId: record.projectId,
517
+ ...((action.toolArgs as Record<string, unknown> | undefined) || {}),
518
+ },
519
+ }
520
+ : action,
521
+ )
522
+ : undefined;
523
+
524
+ return jsonToolResult({
525
+ ...payload,
526
+ data: {
527
+ project: compactProject(record),
528
+ ...payload.data,
529
+ },
530
+ ...(routedNextActions && routedNextActions.length > 0 ? { nextActions: routedNextActions } : {}),
531
+ });
532
+ } catch (error) {
533
+ await closeClient(record);
534
+ return errorToolResult(`Could not reach Proteum dev MCP for ${record.projectId}.`, {
535
+ error: error instanceof Error ? error.message : String(error),
536
+ mcpUrl: record.mcpUrl,
537
+ projectId: record.projectId,
538
+ });
539
+ }
540
+ };
541
+
542
+ server.registerTool(
543
+ 'projects_list',
544
+ {
545
+ annotations: readOnlyAnnotations,
546
+ description: 'List live Proteum dev projects on this machine and their stable projectId values.',
547
+ inputSchema: {},
548
+ title: 'Proteum Projects List',
549
+ },
550
+ async () => {
551
+ const inspections = await listMachineDevSessionInspections();
552
+ const projects = inspections
553
+ .map((inspection) => inspection.record)
554
+ .filter((record): record is TMachineDevSessionRecord => record !== null)
555
+ .map(compactProject);
556
+
557
+ return jsonToolResult(
558
+ createMcpPayload({
559
+ summary:
560
+ projects.length === 0
561
+ ? 'No live Proteum dev projects found on this machine.'
562
+ : `Found ${projects.length} live Proteum dev project${projects.length === 1 ? '' : 's'}.`,
563
+ data: { projects },
564
+ nextActions:
565
+ projects.length === 0
566
+ ? [
567
+ {
568
+ label: 'Resolve Project',
569
+ tool: 'project_resolve',
570
+ reason:
571
+ 'Pass the intended cwd so Proteum can choose the app root and inspect configured ports before suggesting a dev start.',
572
+ },
573
+ ]
574
+ : [],
575
+ }),
576
+ );
577
+ },
578
+ );
579
+
580
+ server.registerTool(
581
+ 'project_resolve',
582
+ {
583
+ annotations: readOnlyAnnotations,
584
+ description:
585
+ 'Resolve a Proteum project by projectId, cwd, app root, or app-root substring from live sessions or offline app roots.',
586
+ inputSchema: {
587
+ cwd: z.string().optional().describe('Current working directory to match to the nearest live app root.'),
588
+ projectId: z.string().optional().describe('Optional exact project id from projects_list.'),
589
+ query: z.string().optional().describe('Project id, app root, or distinctive app root substring.'),
590
+ },
591
+ title: 'Proteum Project Resolve',
592
+ },
593
+ async ({ cwd, projectId, query }) => {
594
+ const normalizedQuery = query?.trim() || projectId?.trim() || cwd?.trim() || '';
595
+ const matches = await resolveProjectMatches({ cwd, projectId, query });
596
+ const projects = matches.map((match) => match.project);
597
+
598
+ return jsonToolResult(
599
+ createMcpPayload({
600
+ summary:
601
+ projects.length === 0
602
+ ? `No live or offline Proteum project matched ${normalizedQuery || 'the provided project selector'}.`
603
+ : `Matched ${projects.length} Proteum project${projects.length === 1 ? '' : 's'}.`,
604
+ data: { cwd, projectId, projects, query: normalizedQuery },
605
+ nextActions:
606
+ projects.length === 1
607
+ ? [
608
+ {
609
+ label: 'Workflow Start',
610
+ tool: 'workflow_start',
611
+ toolArgs:
612
+ projects[0].live === true
613
+ ? { projectId: projects[0].projectId }
614
+ : { cwd: projects[0].appRoot },
615
+ reason: 'Bootstrap compact runtime, instruction, owner, and next-action context in one MCP call.',
616
+ },
617
+ ]
618
+ : projects.length === 0
619
+ ? [
620
+ {
621
+ label: 'List Projects',
622
+ tool: 'projects_list',
623
+ reason: 'Inspect all live Proteum dev projectId values.',
624
+ },
625
+ ]
626
+ : [],
627
+ }),
628
+ );
629
+ },
630
+ );
631
+
632
+ server.registerTool(
633
+ 'workflow_start',
634
+ {
635
+ annotations: readOnlyAnnotations,
636
+ description:
637
+ 'Resolve one live or offline project and return compact runtime, instruction, owner, doctor, and next-action context in one read.',
638
+ inputSchema: {
639
+ cwd: z.string().optional().describe('Current working directory. Used only to resolve projectId.'),
640
+ file: z.string().optional().describe('Optional source file or generated artifact path in scope.'),
641
+ projectId: projectIdSchema,
642
+ query: z.string().optional().describe('Optional task, route, controller, file, or owner query.'),
643
+ route: z.string().optional().describe('Optional route path in scope.'),
644
+ task: z.string().optional().describe('Optional short natural-language task description.'),
645
+ },
646
+ title: 'Proteum Workflow Start',
647
+ },
648
+ async (input) => await workflowStart(input),
649
+ );
650
+
651
+ server.registerTool(
652
+ 'runtime_status',
653
+ {
654
+ annotations: readOnlyAnnotations,
655
+ description: 'Return compact runtime status for one live Proteum dev project selected by projectId.',
656
+ inputSchema: { projectId: projectIdSchema },
657
+ title: 'Proteum Runtime Status',
658
+ },
659
+ async (input) => await forwardTool('runtime_status', input),
660
+ );
661
+
662
+ server.registerTool(
663
+ 'orient',
664
+ {
665
+ annotations: readOnlyAnnotations,
666
+ description: 'Resolve owners, instructions, connected boundaries, and next actions for a project query.',
667
+ inputSchema: {
668
+ projectId: projectIdSchema,
669
+ query: z.string().min(1).describe('Route, controller, file path, connected namespace, or task query.'),
670
+ },
671
+ title: 'Proteum Orient',
672
+ },
673
+ async (input) => await forwardTool('orient', input),
674
+ );
675
+
676
+ server.registerTool(
677
+ 'instructions_resolve',
678
+ {
679
+ annotations: readOnlyAnnotations,
680
+ description: 'Return routed Proteum instruction files for one live project.',
681
+ inputSchema: {
682
+ projectId: projectIdSchema,
683
+ query: z.string().optional().describe('Optional task, route, file path, or area query.'),
684
+ },
685
+ title: 'Proteum Instruction Routing',
686
+ },
687
+ async (input) => await forwardTool('instructions_resolve', input),
688
+ );
689
+
690
+ server.registerTool(
691
+ 'explain_summary',
692
+ {
693
+ annotations: readOnlyAnnotations,
694
+ description: 'Return compact manifest summary or owner ranking for one live project.',
695
+ inputSchema: {
696
+ projectId: projectIdSchema,
697
+ query: z.string().optional().describe('Optional owner query. Omit for manifest summary.'),
698
+ },
699
+ title: 'Proteum Explain Summary',
700
+ },
701
+ async (input) => await forwardTool('explain_summary', input),
702
+ );
703
+
704
+ server.registerTool(
705
+ 'route_candidates',
706
+ {
707
+ annotations: readOnlyAnnotations,
708
+ description: 'Return compact route candidates for a live project without dumping raw route arrays.',
709
+ inputSchema: {
710
+ limit: z.number().int().min(1).max(50).optional(),
711
+ projectId: projectIdSchema,
712
+ query: z.string().min(1).describe('Route path or route-like search query.'),
713
+ },
714
+ title: 'Proteum Route Candidates',
715
+ },
716
+ async (input) => await forwardTool('route_candidates', input),
717
+ );
718
+
719
+ server.registerTool(
720
+ 'doctor',
721
+ {
722
+ annotations: readOnlyAnnotations,
723
+ description: 'Return compact diagnostics for one live Proteum project.',
724
+ inputSchema: {
725
+ contracts: z.boolean().optional().describe('Include generated contract diagnostics.'),
726
+ projectId: projectIdSchema,
727
+ },
728
+ title: 'Proteum Doctor',
729
+ },
730
+ async (input) => await forwardTool('doctor', input),
731
+ );
732
+
733
+ server.registerTool(
734
+ 'diagnose',
735
+ {
736
+ annotations: readOnlyAnnotations,
737
+ description: 'Read composite diagnosis for one live Proteum project.',
738
+ inputSchema: {
739
+ logsLevel: logsLevelSchema,
740
+ logsLimit: z.number().int().min(0).max(100).optional(),
741
+ path: z.string().optional(),
742
+ projectId: projectIdSchema,
743
+ query: z.string().optional(),
744
+ requestId: z.string().optional(),
745
+ },
746
+ title: 'Proteum Diagnose',
747
+ },
748
+ async (input) => await forwardTool('diagnose', input),
749
+ );
750
+
751
+ server.registerTool(
752
+ 'trace_latest',
753
+ {
754
+ annotations: readOnlyAnnotations,
755
+ description: 'Return latest trace summary for one live Proteum project.',
756
+ inputSchema: {
757
+ detail: detailSchema,
758
+ limit: positiveLimitSchema,
759
+ offset: offsetSchema,
760
+ projectId: projectIdSchema,
761
+ },
762
+ title: 'Proteum Latest Trace',
763
+ },
764
+ async (input) => await forwardTool('trace_latest', input),
765
+ );
766
+
767
+ server.registerTool(
768
+ 'trace_show',
769
+ {
770
+ annotations: readOnlyAnnotations,
771
+ description: 'Return a specific trace summary for one live Proteum project.',
772
+ inputSchema: {
773
+ detail: detailSchema,
774
+ limit: positiveLimitSchema,
775
+ offset: offsetSchema,
776
+ projectId: projectIdSchema,
777
+ requestId: z.string().min(1),
778
+ },
779
+ title: 'Proteum Trace Show',
780
+ },
781
+ async (input) => await forwardTool('trace_show', input),
782
+ );
783
+
784
+ server.registerTool(
785
+ 'perf_top',
786
+ {
787
+ annotations: readOnlyAnnotations,
788
+ description: 'Return compact performance rollups for one live Proteum project.',
789
+ inputSchema: {
790
+ groupBy: z.enum(['path', 'route', 'controller']).optional(),
791
+ limit: z.number().int().min(1).max(50).optional(),
792
+ projectId: projectIdSchema,
793
+ since: z.string().optional(),
794
+ },
795
+ title: 'Proteum Perf Top',
796
+ },
797
+ async (input) => await forwardTool('perf_top', input),
798
+ );
799
+
800
+ server.registerTool(
801
+ 'perf_request',
802
+ {
803
+ annotations: readOnlyAnnotations,
804
+ description: 'Return one request waterfall for one live Proteum project.',
805
+ inputSchema: {
806
+ projectId: projectIdSchema,
807
+ query: z.string().min(1).describe('Request id or path.'),
808
+ },
809
+ title: 'Proteum Perf Request',
810
+ },
811
+ async (input) => await forwardTool('perf_request', input),
812
+ );
813
+
814
+ server.registerTool(
815
+ 'logs_tail',
816
+ {
817
+ annotations: readOnlyAnnotations,
818
+ description: 'Return capped recent logs for one live Proteum project.',
819
+ inputSchema: {
820
+ level: logsLevelSchema,
821
+ limit: z.number().int().min(0).max(100).optional(),
822
+ projectId: projectIdSchema,
823
+ },
824
+ title: 'Proteum Logs Tail',
825
+ },
826
+ async (input) => await forwardTool('logs_tail', input),
827
+ );
828
+
829
+ server.registerTool(
830
+ 'db_query',
831
+ {
832
+ annotations: readOnlyAnnotations,
833
+ description: 'Run one capped read-only database diagnostic query for one live Proteum project.',
834
+ inputSchema: {
835
+ limit: databaseLimitSchema,
836
+ projectId: projectIdSchema,
837
+ sql: z.string().min(1).describe('One SELECT, SHOW, or EXPLAIN SQL statement.'),
838
+ timeoutMs: databaseTimeoutSchema,
839
+ },
840
+ title: 'Proteum Database Query',
841
+ },
842
+ async (input) => await forwardTool('db_query', input),
843
+ );
844
+
845
+ const closeServer = server.close.bind(server);
846
+ server.close = async () => {
847
+ await closeAllClients();
848
+ await closeServer();
849
+ };
850
+
851
+ return server;
852
+ };
853
+
854
+ export const startProteumMachineMcpRouter = async ({ version }: { version: string }) => {
855
+ const server = createProteumMachineMcpServer({ version });
856
+ const transport = new StdioServerTransport();
857
+
858
+ await server.connect(transport);
859
+ };
860
+
861
+ const readJsonBody = async (req: http.IncomingMessage) =>
862
+ await new Promise<unknown>((resolve, reject) => {
863
+ const chunks: Buffer[] = [];
864
+
865
+ req.on('data', (chunk) => {
866
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
867
+ });
868
+ req.on('error', reject);
869
+ req.on('end', () => {
870
+ const rawBody = Buffer.concat(chunks).toString('utf8').trim();
871
+ if (!rawBody) {
872
+ resolve(undefined);
873
+ return;
874
+ }
875
+
876
+ try {
877
+ resolve(JSON.parse(rawBody));
878
+ } catch (error) {
879
+ reject(error);
880
+ }
881
+ });
882
+ });
883
+
884
+ const readSessionId = (req: http.IncomingMessage) => {
885
+ const value = req.headers['mcp-session-id'];
886
+ if (Array.isArray(value)) return value[0];
887
+ return typeof value === 'string' && value.trim() ? value : undefined;
888
+ };
889
+
890
+ const writeJson = (res: http.ServerResponse, statusCode: number, payload: unknown) => {
891
+ const body = JSON.stringify(payload);
892
+
893
+ res.writeHead(statusCode, {
894
+ 'content-length': Buffer.byteLength(body),
895
+ 'content-type': 'application/json',
896
+ });
897
+ res.end(body);
898
+ };
899
+
900
+ const writeJsonRpcError = (res: http.ServerResponse, statusCode: number, message: string) => {
901
+ writeJson(res, statusCode, {
902
+ jsonrpc: '2.0',
903
+ error: {
904
+ code: -32000,
905
+ message,
906
+ },
907
+ id: null,
908
+ });
909
+ };
910
+
911
+ export const startProteumMachineMcpRouterHttp = async ({
912
+ port,
913
+ version,
914
+ }: {
915
+ port: number;
916
+ version: string;
917
+ }) => {
918
+ type TMcpTransportEntry = {
919
+ server: ReturnType<typeof createProteumMachineMcpServer>;
920
+ transport: StreamableHTTPServerTransport;
921
+ };
922
+ const transports = new Map<string, TMcpTransportEntry>();
923
+ let daemonRecordWritten = false;
924
+
925
+ const httpServer = http.createServer(async (req, res) => {
926
+ const requestUrl = new URL(req.url || '/', `http://${req.headers.host || `127.0.0.1:${port}`}`);
927
+
928
+ if (requestUrl.pathname === '/health') {
929
+ writeJson(res, 200, {
930
+ ok: true,
931
+ format: 'proteum-mcp-daemon-v1',
932
+ pid: process.pid,
933
+ mcpUrl: `http://127.0.0.1:${port}/mcp`,
934
+ });
935
+ return;
936
+ }
937
+
938
+ if (requestUrl.pathname !== '/mcp') {
939
+ writeJson(res, 404, {
940
+ ok: false,
941
+ format: 'proteum-mcp-daemon-v1',
942
+ summary: 'Unknown Proteum MCP daemon route.',
943
+ });
944
+ return;
945
+ }
946
+
947
+ const sessionId = readSessionId(req);
948
+ let entry = sessionId ? transports.get(sessionId) : undefined;
949
+
950
+ try {
951
+ const parsedBody = req.method === 'POST' ? await readJsonBody(req) : undefined;
952
+
953
+ if (!entry && !sessionId && req.method === 'POST' && isInitializeRequest(parsedBody)) {
954
+ const server = createProteumMachineMcpServer({ version });
955
+ const transport = new StreamableHTTPServerTransport({
956
+ sessionIdGenerator: () => randomUUID(),
957
+ onsessioninitialized: (initializedSessionId) => {
958
+ transports.set(initializedSessionId, { server, transport });
959
+ },
960
+ });
961
+
962
+ transport.onclose = () => {
963
+ const transportSessionId = transport.sessionId;
964
+ if (transportSessionId) transports.delete(transportSessionId);
965
+ void server.close().catch(() => undefined);
966
+ };
967
+
968
+ await server.connect(transport);
969
+ entry = { server, transport };
970
+ }
971
+
972
+ if (!entry) {
973
+ writeJsonRpcError(res, 400, 'Bad Request: initialize the Proteum machine MCP session before sending requests.');
974
+ return;
975
+ }
976
+
977
+ await entry.transport.handleRequest(req, res, parsedBody);
978
+ } catch (error) {
979
+ if (!res.headersSent) {
980
+ writeJsonRpcError(
981
+ res,
982
+ 500,
983
+ error instanceof Error ? error.message : 'Internal Proteum machine MCP server error.',
984
+ );
985
+ }
986
+ }
987
+ });
988
+
989
+ const closeTransports = async () => {
990
+ await Promise.all(
991
+ [...transports.values()].map(async (entry) => {
992
+ entry.transport.onclose = undefined;
993
+ await entry.server.close().catch(() => undefined);
994
+ await entry.transport.close().catch(() => undefined);
995
+ }),
996
+ );
997
+ transports.clear();
998
+ };
999
+
1000
+ process.once('exit', () => {
1001
+ if (daemonRecordWritten) removeMachineMcpDaemonRecordSync();
1002
+ });
1003
+ for (const signal of ['SIGINT', 'SIGTERM'] as const) {
1004
+ process.once(signal, () => {
1005
+ void (async () => {
1006
+ await closeTransports();
1007
+ await removeMachineMcpDaemonRecord();
1008
+ httpServer.close(() => process.exit(0));
1009
+ })();
1010
+ });
1011
+ }
1012
+
1013
+ await new Promise<void>((resolve, reject) => {
1014
+ httpServer.once('error', reject);
1015
+ httpServer.listen(port, '127.0.0.1', () => {
1016
+ httpServer.off('error', reject);
1017
+ resolve();
1018
+ });
1019
+ });
1020
+
1021
+ await writeMachineMcpDaemonRecord(
1022
+ createMachineMcpDaemonRecord({
1023
+ command: [process.execPath, ...process.argv.slice(1)],
1024
+ port,
1025
+ }),
1026
+ );
1027
+ daemonRecordWritten = true;
1028
+ };