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.
@@ -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();
@@ -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 createMariaDbAdapter = (databaseUrl: string) => {
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 new PrismaMariaDb({
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');
@@ -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