proteum 2.4.1 → 2.4.3
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 +2 -2
- package/agents/project/AGENTS.md +13 -4
- package/agents/project/diagnostics.md +3 -3
- package/agents/project/optimizations.md +1 -1
- package/agents/project/root/AGENTS.md +6 -4
- package/agents/project/server/services/AGENTS.md +1 -0
- package/agents/project/tests/AGENTS.md +1 -1
- package/cli/commands/db.ts +160 -0
- package/cli/commands/mcp.ts +24 -7
- package/cli/mcp/router.ts +18 -0
- package/cli/presentation/commands.ts +23 -1
- package/cli/presentation/help.ts +1 -1
- package/cli/presentation/mcp.ts +27 -0
- package/cli/runtime/commands.ts +34 -0
- package/cli/utils/agents.ts +9 -6
- package/common/dev/database.ts +229 -0
- package/common/dev/mcpPayloads.ts +25 -0
- package/common/dev/mcpServer.ts +18 -0
- package/docs/agent-routing.md +1 -1
- package/docs/mcp.md +8 -1
- package/package.json +2 -1
- package/server/app/container/console/http-client-error-context.test.cjs +87 -0
- package/server/app/container/console/index.ts +60 -2
- package/server/app/devDatabase.ts +183 -0
- package/server/app/devDiagnostics.ts +23 -0
- package/server/app/devMcp.ts +10 -0
- package/server/services/prisma/mariadb.ts +7 -3
- package/server/services/router/http/index.ts +25 -0
- package/tests/agents-utils.test.cjs +2 -0
- package/tests/cli-mcp-command.test.cjs +170 -0
- package/tests/mcp.test.cjs +33 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import mysql from 'mysql2/promise';
|
|
2
|
+
import { Client as PostgresClient } from 'pg';
|
|
3
|
+
import { performance } from 'perf_hooks';
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
TDatabaseReadQueryColumn,
|
|
7
|
+
TDatabaseReadQueryResponse,
|
|
8
|
+
TDatabaseReadQueryRow,
|
|
9
|
+
TDatabaseReadQueryValue,
|
|
10
|
+
} from '@common/dev/database';
|
|
11
|
+
import { parseMariaDbDatabaseUrl } from '@server/services/prisma/mariadb';
|
|
12
|
+
|
|
13
|
+
type TDatabaseProtocol = 'mariadb' | 'postgresql';
|
|
14
|
+
|
|
15
|
+
const normalizeDatabaseValue = (value: unknown): TDatabaseReadQueryValue => {
|
|
16
|
+
if (value === null || value === undefined) return null;
|
|
17
|
+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value;
|
|
18
|
+
if (typeof value === 'bigint') return value.toString();
|
|
19
|
+
if (value instanceof Date) return value.toISOString();
|
|
20
|
+
if (Buffer.isBuffer(value)) return `[Buffer ${value.byteLength} bytes]`;
|
|
21
|
+
|
|
22
|
+
return JSON.stringify(value);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const normalizeDatabaseRow = (row: unknown): TDatabaseReadQueryRow => {
|
|
26
|
+
if (!row || typeof row !== 'object' || Array.isArray(row)) return {};
|
|
27
|
+
|
|
28
|
+
return Object.fromEntries(
|
|
29
|
+
Object.entries(row).map(([key, value]) => [key, normalizeDatabaseValue(value)]),
|
|
30
|
+
) as TDatabaseReadQueryRow;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const databaseProtocolFromUrl = (databaseUrl: string): TDatabaseProtocol => {
|
|
34
|
+
const { protocol } = new URL(databaseUrl);
|
|
35
|
+
|
|
36
|
+
if (protocol === 'mysql:' || protocol === 'mariadb:') return 'mariadb';
|
|
37
|
+
if (protocol === 'postgres:' || protocol === 'postgresql:') return 'postgresql';
|
|
38
|
+
|
|
39
|
+
throw new Error(
|
|
40
|
+
`Unsupported DATABASE_URL protocol "${protocol}". Proteum database diagnostics support mysql://, mariadb://, postgres://, and postgresql://.`,
|
|
41
|
+
);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const columnsFromMariaDbFields = (fields: unknown): TDatabaseReadQueryColumn[] =>
|
|
45
|
+
Array.isArray(fields)
|
|
46
|
+
? fields.map((field) => {
|
|
47
|
+
const candidate = field as { name?: unknown; table?: unknown; type?: unknown };
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
name: typeof candidate.name === 'string' ? candidate.name : '',
|
|
51
|
+
...(typeof candidate.table === 'string' && candidate.table ? { table: candidate.table } : {}),
|
|
52
|
+
...(typeof candidate.type === 'number' || typeof candidate.type === 'string' ? { type: candidate.type } : {}),
|
|
53
|
+
};
|
|
54
|
+
})
|
|
55
|
+
: [];
|
|
56
|
+
|
|
57
|
+
const readMariaDb = async ({
|
|
58
|
+
databaseUrl,
|
|
59
|
+
limit,
|
|
60
|
+
sql,
|
|
61
|
+
timeoutMs,
|
|
62
|
+
}: {
|
|
63
|
+
databaseUrl: string;
|
|
64
|
+
limit: number;
|
|
65
|
+
sql: string;
|
|
66
|
+
timeoutMs: number;
|
|
67
|
+
}) => {
|
|
68
|
+
const connectionConfig = parseMariaDbDatabaseUrl(databaseUrl);
|
|
69
|
+
const connection = await mysql.createConnection({
|
|
70
|
+
host: connectionConfig.host,
|
|
71
|
+
port: connectionConfig.port,
|
|
72
|
+
user: connectionConfig.user,
|
|
73
|
+
password: connectionConfig.password,
|
|
74
|
+
database: connectionConfig.database,
|
|
75
|
+
connectTimeout: connectionConfig.connectTimeout,
|
|
76
|
+
multipleStatements: false,
|
|
77
|
+
supportBigNumbers: true,
|
|
78
|
+
bigNumberStrings: true,
|
|
79
|
+
dateStrings: true,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
await connection.query('START TRANSACTION READ ONLY');
|
|
84
|
+
const [rows, fields] = await connection.query({ sql, timeout: timeoutMs });
|
|
85
|
+
await connection.rollback();
|
|
86
|
+
|
|
87
|
+
const rowList = Array.isArray(rows) ? rows : [];
|
|
88
|
+
const normalizedRows = rowList.map(normalizeDatabaseRow);
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
columns: columnsFromMariaDbFields(fields),
|
|
92
|
+
limited: normalizedRows.length > limit,
|
|
93
|
+
rowCount: normalizedRows.length,
|
|
94
|
+
rows: normalizedRows.slice(0, limit),
|
|
95
|
+
};
|
|
96
|
+
} catch (error) {
|
|
97
|
+
try {
|
|
98
|
+
await connection.rollback();
|
|
99
|
+
} catch (_rollbackError) {}
|
|
100
|
+
|
|
101
|
+
throw error;
|
|
102
|
+
} finally {
|
|
103
|
+
await connection.end();
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const readPostgreSql = async ({
|
|
108
|
+
databaseUrl,
|
|
109
|
+
limit,
|
|
110
|
+
sql,
|
|
111
|
+
timeoutMs,
|
|
112
|
+
}: {
|
|
113
|
+
databaseUrl: string;
|
|
114
|
+
limit: number;
|
|
115
|
+
sql: string;
|
|
116
|
+
timeoutMs: number;
|
|
117
|
+
}) => {
|
|
118
|
+
const client = new PostgresClient({
|
|
119
|
+
connectionString: databaseUrl,
|
|
120
|
+
statement_timeout: timeoutMs,
|
|
121
|
+
query_timeout: timeoutMs,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
await client.connect();
|
|
126
|
+
await client.query('BEGIN READ ONLY');
|
|
127
|
+
await client.query('SET TRANSACTION READ ONLY');
|
|
128
|
+
const result = await client.query(sql);
|
|
129
|
+
await client.query('ROLLBACK');
|
|
130
|
+
|
|
131
|
+
const normalizedRows = result.rows.map(normalizeDatabaseRow);
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
columns: result.fields.map((field) => ({
|
|
135
|
+
name: field.name,
|
|
136
|
+
...(field.tableID ? { table: String(field.tableID) } : {}),
|
|
137
|
+
type: field.dataTypeID,
|
|
138
|
+
})),
|
|
139
|
+
limited: normalizedRows.length > limit,
|
|
140
|
+
rowCount: normalizedRows.length,
|
|
141
|
+
rows: normalizedRows.slice(0, limit),
|
|
142
|
+
};
|
|
143
|
+
} catch (error) {
|
|
144
|
+
try {
|
|
145
|
+
await client.query('ROLLBACK');
|
|
146
|
+
} catch (_rollbackError) {}
|
|
147
|
+
|
|
148
|
+
throw error;
|
|
149
|
+
} finally {
|
|
150
|
+
try {
|
|
151
|
+
await client.end();
|
|
152
|
+
} catch (_endError) {}
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
export const runDatabaseReadQuery = async ({
|
|
157
|
+
databaseUrl,
|
|
158
|
+
kind,
|
|
159
|
+
limit,
|
|
160
|
+
sql,
|
|
161
|
+
timeoutMs,
|
|
162
|
+
}: {
|
|
163
|
+
databaseUrl: string;
|
|
164
|
+
kind: TDatabaseReadQueryResponse['kind'];
|
|
165
|
+
limit: number;
|
|
166
|
+
sql: string;
|
|
167
|
+
timeoutMs: number;
|
|
168
|
+
}): Promise<TDatabaseReadQueryResponse> => {
|
|
169
|
+
const startedAt = performance.now();
|
|
170
|
+
const response =
|
|
171
|
+
databaseProtocolFromUrl(databaseUrl) === 'postgresql'
|
|
172
|
+
? await readPostgreSql({ databaseUrl, limit, sql, timeoutMs })
|
|
173
|
+
: await readMariaDb({ databaseUrl, limit, sql, timeoutMs });
|
|
174
|
+
const elapsedMs = Math.max(0, Math.round(performance.now() - startedAt));
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
...response,
|
|
178
|
+
elapsedMs,
|
|
179
|
+
kind,
|
|
180
|
+
limit,
|
|
181
|
+
sql,
|
|
182
|
+
};
|
|
183
|
+
};
|
|
@@ -2,7 +2,15 @@ import fs from 'fs-extra';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
|
|
4
4
|
import type { Application } from './index';
|
|
5
|
+
import { runDatabaseReadQuery } from './devDatabase';
|
|
5
6
|
import type { TDevConsoleLogLevel, TDevConsoleLogsResponse } from '@common/dev/console';
|
|
7
|
+
import {
|
|
8
|
+
normalizeDatabaseReadLimit,
|
|
9
|
+
normalizeDatabaseReadTimeoutMs,
|
|
10
|
+
validateDatabaseReadQuery,
|
|
11
|
+
type TDatabaseReadQueryInput,
|
|
12
|
+
type TDatabaseReadQueryResponse,
|
|
13
|
+
} from '@common/dev/database';
|
|
6
14
|
import {
|
|
7
15
|
buildDoctorResponse,
|
|
8
16
|
explainSectionNames,
|
|
@@ -88,6 +96,21 @@ export default class DevDiagnosticsRegistry {
|
|
|
88
96
|
return { logs: this.app.container.Console.listLogs(limit, isConsoleLogLevel(minimumLevel) ? minimumLevel : 'log') };
|
|
89
97
|
}
|
|
90
98
|
|
|
99
|
+
public async databaseReadQuery({
|
|
100
|
+
limit: rawLimit,
|
|
101
|
+
sql: rawSql,
|
|
102
|
+
timeoutMs: rawTimeoutMs,
|
|
103
|
+
}: TDatabaseReadQueryInput): Promise<TDatabaseReadQueryResponse> {
|
|
104
|
+
const databaseUrl = process.env.DATABASE_URL;
|
|
105
|
+
if (!databaseUrl) throw new Error('DATABASE_URL is required before running database diagnostics.');
|
|
106
|
+
|
|
107
|
+
const { kind, sql } = validateDatabaseReadQuery(rawSql);
|
|
108
|
+
const limit = normalizeDatabaseReadLimit(rawLimit);
|
|
109
|
+
const timeoutMs = normalizeDatabaseReadTimeoutMs(rawTimeoutMs);
|
|
110
|
+
|
|
111
|
+
return runDatabaseReadQuery({ databaseUrl, kind, limit, sql, timeoutMs });
|
|
112
|
+
}
|
|
113
|
+
|
|
91
114
|
private resolveRequestTrace({ path, requestId }: { path?: string; requestId?: string }): TRequestTrace | undefined {
|
|
92
115
|
if (requestId) return this.app.container.Trace.getRequest(requestId);
|
|
93
116
|
if (!path) return this.app.container.Trace.getLatestRequest();
|
package/server/app/devMcp.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { buildContractsDoctorResponse } from '@common/dev/contractsDoctor';
|
|
|
2
2
|
import { buildDoctorResponse } from '@common/dev/diagnostics';
|
|
3
3
|
import { buildOrientationResponse, explainOwner } from '@common/dev/inspection';
|
|
4
4
|
import {
|
|
5
|
+
compactDatabaseReadQueryResponse,
|
|
5
6
|
buildRuntimeStatusPayload,
|
|
6
7
|
compactDiagnoseResponse,
|
|
7
8
|
compactDoctorResponse,
|
|
@@ -189,6 +190,15 @@ export const createRuntimeProteumMcpProvider = ({
|
|
|
189
190
|
response: diagnostics().readLogs(limit, level),
|
|
190
191
|
});
|
|
191
192
|
},
|
|
193
|
+
async dbQuery({ limit, sql, timeoutMs }) {
|
|
194
|
+
return compactDatabaseReadQueryResponse(
|
|
195
|
+
await diagnostics().databaseReadQuery({
|
|
196
|
+
limit,
|
|
197
|
+
sql,
|
|
198
|
+
timeoutMs,
|
|
199
|
+
}),
|
|
200
|
+
);
|
|
201
|
+
},
|
|
192
202
|
async readResource(uri) {
|
|
193
203
|
if (uri === 'proteum://runtime/status') return await provider.runtimeStatus({});
|
|
194
204
|
if (uri === 'proteum://instructions/router') return await provider.instructionsResolve({});
|
|
@@ -19,7 +19,7 @@ const decodeUrlSegment = (value: string) => {
|
|
|
19
19
|
return decodeURIComponent(value);
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
-
export const
|
|
22
|
+
export const parseMariaDbDatabaseUrl = (databaseUrl: string) => {
|
|
23
23
|
const url = new URL(databaseUrl);
|
|
24
24
|
|
|
25
25
|
if (url.protocol !== 'mysql:' && url.protocol !== 'mariadb:')
|
|
@@ -34,7 +34,7 @@ export const createMariaDbAdapter = (databaseUrl: string) => {
|
|
|
34
34
|
const connectTimeoutSeconds = parseInteger(url.searchParams.get('connect_timeout'));
|
|
35
35
|
const idleTimeoutSeconds = parseInteger(url.searchParams.get('max_idle_connection_lifetime'));
|
|
36
36
|
|
|
37
|
-
return
|
|
37
|
+
return {
|
|
38
38
|
host: url.hostname,
|
|
39
39
|
port: parseInteger(url.port) ?? defaultPort,
|
|
40
40
|
user: decodeUrlSegment(url.username),
|
|
@@ -43,5 +43,9 @@ export const createMariaDbAdapter = (databaseUrl: string) => {
|
|
|
43
43
|
connectTimeout: connectTimeoutSeconds ? connectTimeoutSeconds * 1_000 : defaultConnectTimeout,
|
|
44
44
|
idleTimeout: idleTimeoutSeconds ?? defaultIdleTimeout,
|
|
45
45
|
...(connectionLimit !== undefined ? { connectionLimit } : {}),
|
|
46
|
-
}
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const createMariaDbAdapter = (databaseUrl: string) => {
|
|
50
|
+
return new PrismaMariaDb(parseMariaDbDatabaseUrl(databaseUrl));
|
|
47
51
|
};
|
|
@@ -606,6 +606,31 @@ export default class HttpServer<TRouter extends TServerRouter = TServerRouter> {
|
|
|
606
606
|
}
|
|
607
607
|
});
|
|
608
608
|
|
|
609
|
+
routes.post('/__proteum/db/query', async (req, res) => {
|
|
610
|
+
const sql = typeof req.body?.sql === 'string' ? req.body.sql : '';
|
|
611
|
+
const rawLimit = Number(req.body?.limit);
|
|
612
|
+
const rawTimeoutMs = Number(req.body?.timeoutMs);
|
|
613
|
+
|
|
614
|
+
try {
|
|
615
|
+
res.json(
|
|
616
|
+
await this.app.getDevDiagnostics().databaseReadQuery({
|
|
617
|
+
sql,
|
|
618
|
+
limit: Number.isFinite(rawLimit) ? rawLimit : undefined,
|
|
619
|
+
timeoutMs: Number.isFinite(rawTimeoutMs) ? rawTimeoutMs : undefined,
|
|
620
|
+
}),
|
|
621
|
+
);
|
|
622
|
+
} catch (error) {
|
|
623
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
624
|
+
const isUsageError =
|
|
625
|
+
message.includes('SQL') ||
|
|
626
|
+
message.includes('allowed') ||
|
|
627
|
+
message.includes('not allowed') ||
|
|
628
|
+
message.includes('DATABASE_URL');
|
|
629
|
+
|
|
630
|
+
res.status(isUsageError ? 400 : 500).json({ error: message });
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
|
|
609
634
|
routes.get('/__proteum/diagnose', (req, res) => {
|
|
610
635
|
const readString = (value: unknown) => (Array.isArray(value) ? value[0] : value);
|
|
611
636
|
const readNumber = (value: unknown, fallback: number) => {
|
|
@@ -102,12 +102,14 @@ test('standalone configure creates tracked instruction files with routing contra
|
|
|
102
102
|
assert.match(agentsContent, /Read full files only before edits or git writes/);
|
|
103
103
|
assert.match(agentsContent, /explain_summary/);
|
|
104
104
|
assert.match(agentsContent, /\/__proteum\/mcp/);
|
|
105
|
+
assert.match(agentsContent, /central MCP ready banner/);
|
|
105
106
|
assert.match(agentsContent, /proteum-mcp-v1/);
|
|
106
107
|
assert.match(agentsContent, /## Triggered Instruction Reads/);
|
|
107
108
|
assert.match(agentsContent, /Git lifecycle/);
|
|
108
109
|
assert.match(agentsContent, /read Root contract fallback before any git write/);
|
|
109
110
|
assert.match(agentsContent, /add or update focused unit tests/);
|
|
110
111
|
assert.match(agentsContent, /read Root contract fallback, `CODING_STYLE\.md`, `tests\/AGENTS\.md`/);
|
|
112
|
+
assert.match(agentsContent, /GEO\/SEO\/crawler\/structured-data\/AI-source changes/);
|
|
111
113
|
assert.match(agentsContent, /MCP-selected previews are enough/);
|
|
112
114
|
assert.doesNotMatch(agentsContent, /Conventional Commits/);
|
|
113
115
|
assert.match(agentsContent, /They are not deleted/);
|
|
@@ -85,6 +85,63 @@ const runCli = async (args, { cwd }) =>
|
|
|
85
85
|
child.once('close', (status) => resolve({ status, stdout, stderr }));
|
|
86
86
|
});
|
|
87
87
|
|
|
88
|
+
const waitForChildOutput = async (child, predicate, timeoutMs = 10000) =>
|
|
89
|
+
await new Promise((resolve, reject) => {
|
|
90
|
+
let output = '';
|
|
91
|
+
let settled = false;
|
|
92
|
+
let timer;
|
|
93
|
+
const cleanup = () => {
|
|
94
|
+
if (timer) clearTimeout(timer);
|
|
95
|
+
child.stdout.off('data', handleData);
|
|
96
|
+
child.stderr.off('data', handleData);
|
|
97
|
+
child.off('close', handleClose);
|
|
98
|
+
};
|
|
99
|
+
const settle = (callback) => {
|
|
100
|
+
if (settled) return;
|
|
101
|
+
settled = true;
|
|
102
|
+
cleanup();
|
|
103
|
+
callback();
|
|
104
|
+
};
|
|
105
|
+
const handleData = (chunk) => {
|
|
106
|
+
output += chunk.toString();
|
|
107
|
+
if (predicate(output)) settle(() => resolve(output));
|
|
108
|
+
};
|
|
109
|
+
const handleClose = (status, signal) => {
|
|
110
|
+
settle(() => reject(new Error(`Child exited before expected output. status=${status} signal=${signal}\n${output}`)));
|
|
111
|
+
};
|
|
112
|
+
timer = setTimeout(() => {
|
|
113
|
+
child.kill('SIGTERM');
|
|
114
|
+
settle(() => reject(new Error(`Timed out waiting for expected output.\n${output}`)));
|
|
115
|
+
}, timeoutMs);
|
|
116
|
+
|
|
117
|
+
child.stdout.on('data', handleData);
|
|
118
|
+
child.stderr.on('data', handleData);
|
|
119
|
+
child.once('close', handleClose);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const writeLiveDaemonRecord = (registryDir, { port }) => {
|
|
123
|
+
const timestamp = new Date().toISOString();
|
|
124
|
+
|
|
125
|
+
writeFile(
|
|
126
|
+
path.join(registryDir, 'router.json'),
|
|
127
|
+
JSON.stringify(
|
|
128
|
+
{
|
|
129
|
+
version: 1,
|
|
130
|
+
pid: process.pid,
|
|
131
|
+
port,
|
|
132
|
+
host: '127.0.0.1',
|
|
133
|
+
mcpUrl: `http://127.0.0.1:${port}/mcp`,
|
|
134
|
+
healthUrl: `http://127.0.0.1:${port}/health`,
|
|
135
|
+
startedAt: timestamp,
|
|
136
|
+
updatedAt: timestamp,
|
|
137
|
+
command: [process.execPath, cliBin, 'mcp', '--daemon'],
|
|
138
|
+
},
|
|
139
|
+
null,
|
|
140
|
+
2,
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
};
|
|
144
|
+
|
|
88
145
|
test('top-level help lists the machine-scope mcp router', () => {
|
|
89
146
|
const result = spawnSync(process.execPath, [cliBin, '--help'], {
|
|
90
147
|
cwd: coreRoot,
|
|
@@ -110,6 +167,67 @@ test('mcp help describes projectId routing', () => {
|
|
|
110
167
|
assert.match(output, /--stdio/);
|
|
111
168
|
});
|
|
112
169
|
|
|
170
|
+
test('mcp daemon launch prints a central MCP connection banner', async () => {
|
|
171
|
+
const registryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-mcp-daemon-launch-'));
|
|
172
|
+
const reserveServer = http.createServer((req, res) => res.end('reserved'));
|
|
173
|
+
const port = await listen(reserveServer);
|
|
174
|
+
await closeServer(reserveServer);
|
|
175
|
+
const child = spawn(process.execPath, [cliBin, 'mcp', '--daemon', '--port', String(port)], {
|
|
176
|
+
cwd: coreRoot,
|
|
177
|
+
env: { ...process.env, PROTEUM_MACHINE_MCP_DIR: registryDir },
|
|
178
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
179
|
+
});
|
|
180
|
+
let closed = false;
|
|
181
|
+
child.once('close', () => {
|
|
182
|
+
closed = true;
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
const output = await waitForChildOutput(child, (value) =>
|
|
187
|
+
value.includes(`Connect MCP client (HTTP): http://127.0.0.1:${port}/mcp`),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
assert.match(output, /CENTRAL MCP READY/);
|
|
191
|
+
assert.match(output, /Launched central MCP server/);
|
|
192
|
+
} finally {
|
|
193
|
+
if (!closed) {
|
|
194
|
+
child.kill('SIGTERM');
|
|
195
|
+
await new Promise((resolve) => child.once('close', resolve));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('mcp daemon reuse prints a central MCP connection banner', () => {
|
|
201
|
+
const registryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-mcp-daemon-reuse-'));
|
|
202
|
+
const port = 37977;
|
|
203
|
+
writeLiveDaemonRecord(registryDir, { port });
|
|
204
|
+
|
|
205
|
+
const result = spawnSync(process.execPath, [cliBin, 'mcp', '--daemon', '--port', String(port)], {
|
|
206
|
+
cwd: coreRoot,
|
|
207
|
+
encoding: 'utf8',
|
|
208
|
+
env: { ...process.env, PROTEUM_MACHINE_MCP_DIR: registryDir },
|
|
209
|
+
});
|
|
210
|
+
const output = `${result.stdout}\n${result.stderr}`;
|
|
211
|
+
|
|
212
|
+
assert.equal(result.status, 0);
|
|
213
|
+
assert.match(output, /CENTRAL MCP READY/);
|
|
214
|
+
assert.match(output, /Connected to central MCP server/);
|
|
215
|
+
assert.match(output, new RegExp(`Connect MCP client \\(HTTP\\): http://127\\.0\\.0\\.1:${port}/mcp`));
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('db help describes read-only SQL diagnostics', () => {
|
|
219
|
+
const result = spawnSync(process.execPath, [cliBin, 'db', '--help'], {
|
|
220
|
+
cwd: coreRoot,
|
|
221
|
+
encoding: 'utf8',
|
|
222
|
+
});
|
|
223
|
+
const output = `${result.stdout}\n${result.stderr}`;
|
|
224
|
+
|
|
225
|
+
assert.equal(result.status, 0);
|
|
226
|
+
assert.match(output, /SELECT, SHOW, and EXPLAIN/);
|
|
227
|
+
assert.match(output, /--limit/);
|
|
228
|
+
assert.match(output, /--timeout/);
|
|
229
|
+
});
|
|
230
|
+
|
|
113
231
|
test('explain help describes compact section summaries', () => {
|
|
114
232
|
const result = spawnSync(process.execPath, [cliBin, 'explain', '--help'], {
|
|
115
233
|
cwd: coreRoot,
|
|
@@ -219,6 +337,58 @@ test('runtime status reports occupied configured port without probing page bodie
|
|
|
219
337
|
}
|
|
220
338
|
});
|
|
221
339
|
|
|
340
|
+
test('db query posts one read-only SQL statement to the running dev endpoint', async () => {
|
|
341
|
+
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-cli-db-'));
|
|
342
|
+
const appRoot = path.join(repoRoot, 'apps', 'product');
|
|
343
|
+
let receivedBody = '';
|
|
344
|
+
const server = http.createServer((req, res) => {
|
|
345
|
+
if (req.url === '/__proteum/db/query' && req.method === 'POST') {
|
|
346
|
+
req.on('data', (chunk) => {
|
|
347
|
+
receivedBody += chunk.toString();
|
|
348
|
+
});
|
|
349
|
+
req.on('end', () => {
|
|
350
|
+
res.setHeader('content-type', 'application/json');
|
|
351
|
+
res.end(
|
|
352
|
+
JSON.stringify({
|
|
353
|
+
kind: 'select',
|
|
354
|
+
sql: 'SELECT 1',
|
|
355
|
+
elapsedMs: 7,
|
|
356
|
+
limit: 5,
|
|
357
|
+
limited: false,
|
|
358
|
+
rowCount: 1,
|
|
359
|
+
columns: [{ name: 'value', type: 3 }],
|
|
360
|
+
rows: [{ value: 1 }],
|
|
361
|
+
}),
|
|
362
|
+
);
|
|
363
|
+
});
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
res.statusCode = 404;
|
|
368
|
+
res.end('not found');
|
|
369
|
+
});
|
|
370
|
+
const port = await listen(server);
|
|
371
|
+
|
|
372
|
+
try {
|
|
373
|
+
createProteumApp(appRoot, { routerPort: port });
|
|
374
|
+
|
|
375
|
+
const result = await runCli(['db', 'query', 'SELECT 1', '--limit', '5'], {
|
|
376
|
+
cwd: appRoot,
|
|
377
|
+
});
|
|
378
|
+
const payload = JSON.parse(result.stdout);
|
|
379
|
+
const body = JSON.parse(receivedBody);
|
|
380
|
+
|
|
381
|
+
assert.equal(result.status, 0);
|
|
382
|
+
assert.equal(body.sql, 'SELECT 1');
|
|
383
|
+
assert.equal(body.limit, 5);
|
|
384
|
+
assert.equal(payload.ok, true);
|
|
385
|
+
assert.equal(payload.data.elapsedMs, 7);
|
|
386
|
+
assert.deepEqual(payload.data.rows, [{ value: 1 }]);
|
|
387
|
+
} finally {
|
|
388
|
+
await closeServer(server);
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
|
|
222
392
|
test('runtime status avoids starting a second dev server when the same app owns the port', async () => {
|
|
223
393
|
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-cli-same-port-'));
|
|
224
394
|
const appRoot = path.join(repoRoot, 'apps', 'product');
|
package/tests/mcp.test.cjs
CHANGED
|
@@ -21,6 +21,11 @@ const {
|
|
|
21
21
|
resolveInstructionRouting,
|
|
22
22
|
} = require('../common/dev/mcpPayloads.ts');
|
|
23
23
|
const { createProteumMcpServer } = require('../common/dev/mcpServer.ts');
|
|
24
|
+
const {
|
|
25
|
+
normalizeDatabaseReadLimit,
|
|
26
|
+
validateDatabaseReadQuery,
|
|
27
|
+
} = require('../common/dev/database.ts');
|
|
28
|
+
const { databaseProtocolFromUrl } = require('../server/app/devDatabase.ts');
|
|
24
29
|
const { createProteumMachineMcpServer } = require('../cli/mcp/router.ts');
|
|
25
30
|
const {
|
|
26
31
|
createDevSessionRecord,
|
|
@@ -126,6 +131,32 @@ test('instruction routing returns compact selected files for a page query', () =
|
|
|
126
131
|
assert.equal(payload.data.readWhen.some((entry) => entry.file && entry.file.endsWith('diagnostics.md')), true);
|
|
127
132
|
});
|
|
128
133
|
|
|
134
|
+
test('database read query policy allows only capped SELECT SHOW and EXPLAIN diagnostics', () => {
|
|
135
|
+
assert.deepEqual(validateDatabaseReadQuery(' SELECT 1; '), { kind: 'select', sql: 'SELECT 1' });
|
|
136
|
+
assert.deepEqual(validateDatabaseReadQuery('/* plan */ EXPLAIN SELECT * FROM User'), {
|
|
137
|
+
kind: 'explain',
|
|
138
|
+
sql: '/* plan */ EXPLAIN SELECT * FROM User',
|
|
139
|
+
});
|
|
140
|
+
assert.deepEqual(validateDatabaseReadQuery('SHOW TABLES'), { kind: 'show', sql: 'SHOW TABLES' });
|
|
141
|
+
assert.equal(normalizeDatabaseReadLimit(999), 500);
|
|
142
|
+
|
|
143
|
+
assert.throws(() => validateDatabaseReadQuery('UPDATE User SET role = "admin"'), /Only SELECT, SHOW, and EXPLAIN/);
|
|
144
|
+
assert.throws(() => validateDatabaseReadQuery('SELECT 1; DROP TABLE User'), /Only one read-only SQL statement/);
|
|
145
|
+
assert.throws(() => validateDatabaseReadQuery('EXPLAIN ANALYZE SELECT * FROM User'), /EXPLAIN ANALYZE/);
|
|
146
|
+
assert.throws(() => validateDatabaseReadQuery('SELECT LOAD_FILE("/etc/passwd")'), /file-read/);
|
|
147
|
+
assert.throws(() => validateDatabaseReadQuery("SELECT pg_read_file('/etc/passwd')"), /file-read/);
|
|
148
|
+
assert.throws(() => validateDatabaseReadQuery('SELECT * FROM "User" FOR SHARE'), /Locking read/);
|
|
149
|
+
assert.throws(() => validateDatabaseReadQuery('SELECT pg_sleep(1)'), /Sleep and benchmark/);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('database diagnostics support MySQL MariaDB and PostgreSQL URLs', () => {
|
|
153
|
+
assert.equal(databaseProtocolFromUrl('mysql://user:pass@localhost:3306/app'), 'mariadb');
|
|
154
|
+
assert.equal(databaseProtocolFromUrl('mariadb://user:pass@localhost:3306/app'), 'mariadb');
|
|
155
|
+
assert.equal(databaseProtocolFromUrl('postgres://user:pass@localhost:5432/app'), 'postgresql');
|
|
156
|
+
assert.equal(databaseProtocolFromUrl('postgresql://user:pass@localhost:5432/app'), 'postgresql');
|
|
157
|
+
assert.throws(() => databaseProtocolFromUrl('sqlite://local.db'), /postgresql/);
|
|
158
|
+
});
|
|
159
|
+
|
|
129
160
|
test('instruction routing promotes triggered full instruction files', () => {
|
|
130
161
|
const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-mcp-trigger-app-'));
|
|
131
162
|
const fallbackRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-mcp-trigger-core-'));
|
|
@@ -367,6 +398,7 @@ test('trace payload keeps default output compact and paginates full details', ()
|
|
|
367
398
|
test('MCP server registers the Proteum read-only tool contract', async () => {
|
|
368
399
|
const payload = createMcpPayload({ summary: 'ok', data: { value: 1 } });
|
|
369
400
|
const provider = {
|
|
401
|
+
dbQuery: async () => payload,
|
|
370
402
|
diagnose: async () => payload,
|
|
371
403
|
doctor: async () => payload,
|
|
372
404
|
explainSummary: async () => payload,
|
|
@@ -396,6 +428,7 @@ test('MCP server registers the Proteum read-only tool contract', async () => {
|
|
|
396
428
|
assert.equal(tools.tools.some((tool) => tool.name === 'runtime_status'), true);
|
|
397
429
|
assert.equal(tools.tools.some((tool) => tool.name === 'workflow_start'), true);
|
|
398
430
|
assert.equal(tools.tools.some((tool) => tool.name === 'route_candidates'), true);
|
|
431
|
+
assert.equal(tools.tools.some((tool) => tool.name === 'db_query'), true);
|
|
399
432
|
assert.match(result.content[0].text, /proteum-mcp-v1/);
|
|
400
433
|
assert.match(resource.contents[0].text, /proteum-mcp-v1/);
|
|
401
434
|
|