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.
@@ -695,6 +695,38 @@ class PerfCommand extends ProteumCommand {
695
695
  }
696
696
  }
697
697
 
698
+ class DbCommand extends ProteumCommand {
699
+ public static paths = [['db']];
700
+
701
+ public static usage = buildUsage('db');
702
+
703
+ public port = Option.String('--port', { description: 'Target an existing dev server on the given port.' });
704
+ public url = Option.String('--url', { description: 'Target an existing dev server at the given base URL.' });
705
+ public limit = Option.String('--limit', { description: 'Maximum number of result rows to return, up to 500.' });
706
+ public timeout = Option.String('--timeout', { description: 'Database query timeout in milliseconds, up to 30000.' });
707
+ public json = Option.Boolean('--json', false, { description: 'Compatibility flag; compact JSON is the default output.' });
708
+ public full = Option.Boolean('--full', false, { description: 'Print the full database query payload.' });
709
+ public args = Option.Rest();
710
+
711
+ public async execute() {
712
+ const [first = '', ...restArgs] = this.args;
713
+ const sql = first === 'query' ? restArgs.join(' ').trim() : [first, ...restArgs].join(' ').trim();
714
+
715
+ this.setCliArgs({
716
+ action: 'query',
717
+ full: this.full,
718
+ json: this.json,
719
+ limit: this.limit ?? '',
720
+ port: this.port ?? '',
721
+ sql,
722
+ timeout: this.timeout ?? '',
723
+ url: this.url ?? '',
724
+ });
725
+
726
+ await runCommandModule(() => import('../commands/db'));
727
+ }
728
+ }
729
+
698
730
  class RuntimeCommand extends ProteumCommand {
699
731
  public static paths = [['runtime']];
700
732
 
@@ -859,6 +891,7 @@ export const registeredCommands = {
859
891
  orient: OrientCommand,
860
892
  diagnose: DiagnoseCommand,
861
893
  perf: PerfCommand,
894
+ db: DbCommand,
862
895
  runtime: RuntimeCommand,
863
896
  mcp: McpCommand,
864
897
  trace: TraceCommand,
@@ -894,6 +927,7 @@ export const createCli = (version: string) => {
894
927
  clipanion.register(OrientCommand);
895
928
  clipanion.register(DiagnoseCommand);
896
929
  clipanion.register(PerfCommand);
930
+ clipanion.register(DbCommand);
897
931
  clipanion.register(RuntimeCommand);
898
932
  clipanion.register(McpCommand);
899
933
  clipanion.register(TraceCommand);
@@ -612,16 +612,17 @@ function renderEmbeddedProjectInstructions({ appRoot, coreRoot, includeMonorepoR
612
612
  '',
613
613
  '1. When a Proteum MCP client is available, call MCP `workflow_start` first. Pass `cwd` when `projectId` is not known, or pass the stable `projectId` from `projects_list` when it is known.',
614
614
  '2. Use the `projectId` returned by live `workflow_start` for every follow-up app-bound MCP tool. If `workflow_start` is ambiguous or returns offline candidates, call MCP `project_resolve { cwd }`, select the intended app root, follow its port-inspected next action when needed, then retry `workflow_start`.',
615
- '3. After `projectId` is selected, use MCP `runtime_status`, `orient`, `instructions_resolve`, `explain_summary`, `route_candidates`, `doctor`, `diagnose`, `trace_show`, `perf_request`, and `logs_tail` for read-only runtime, owner, instruction, route, trace, perf, and log reads.',
615
+ '3. After `projectId` is selected, use MCP `runtime_status`, `orient`, `instructions_resolve`, `explain_summary`, `route_candidates`, `doctor`, `diagnose`, `trace_show`, `perf_request`, `logs_tail`, and `db_query` for read-only runtime, owner, instruction, route, trace, perf, log, and database reads.',
616
616
  '4. Do not run CLI equivalents after a successful MCP result for the same read. Do not run broad source searches for route/page/controller ownership after `workflow_start`, `orient`, or `explain_summary` already returned the owner.',
617
617
  '5. Treat selected instruction previews returned by MCP as the instruction source for read-only discovery and diagnostics. Read full files only before edits or git writes, when the returned `fullRead`/`fullReadPolicy` requires it, or when the preview is insufficient.',
618
618
  '6. Use `npx proteum runtime status` before starting a dev server only when MCP runtime status is unavailable, so an existing tracked session can be reused and the configured router/HMR ports can be checked without probing page bodies. If it says health is unreachable, do not run `diagnose`, `trace`, or `perf`; stop/repair/start the dev session first.',
619
619
  '7. During `npx proteum dev`, Proteum ensures one managed machine MCP daemon is running and routes app-bound reads to the read-only runtime endpoint at `/__proteum/mcp` instead of spawning equivalent CLI diagnostics.',
620
- '8. If machine MCP routing fails, run `npx proteum mcp status` and `npx proteum runtime status`; if no live session exists, use the exact next action from MCP offline routing or runtime status instead of assuming the manifest default port. If the same app already responds on the configured port without live tracking, use or repair that runtime instead of starting another server.',
621
- '9. If a live session exists but runtime/MCP is unreachable, stop the listed session file first, then start dev again. Do not start a second dev server in the same worktree or a second managed MCP daemon. Then retry MCP `workflow_start`.',
622
- '10. Use MCP `diagnose { projectId, path }` for request-time issues before raw trace, perf, browser, or broad source search; use `npx proteum diagnose <target>` only as fallback or final terminal evidence.',
623
- '11. Use `route_candidates`, `explain_summary`, or `npx proteum explain owner <query>` to pick routes. Do not run `npx proteum explain --routes --full` unless compact route/owner tools explicitly cannot answer the raw route-array question.',
624
- '12. Use `--full`, `--manifest`, `--events`, or MCP `detail: "full"` only when compact output says the omitted detail is needed.',
620
+ '8. Terminal `npx proteum mcp` prints a compact central MCP ready banner with the HTTP client URL when it starts or reuses the managed daemon.',
621
+ '9. If machine MCP routing fails, run `npx proteum mcp status` and `npx proteum runtime status`; if no live session exists, use the exact next action from MCP offline routing or runtime status instead of assuming the manifest default port. If the same app already responds on the configured port without live tracking, use or repair that runtime instead of starting another server.',
622
+ '10. If a live session exists but runtime/MCP is unreachable, stop the listed session file first, then start dev again. Do not start a second dev server in the same worktree or a second managed MCP daemon. Then retry MCP `workflow_start`.',
623
+ '11. Use MCP `diagnose { projectId, path }` for request-time issues before raw trace, perf, browser, or broad source search; use `npx proteum diagnose <target>` only as fallback or final terminal evidence.',
624
+ '12. Use `route_candidates`, `explain_summary`, or `npx proteum explain owner <query>` to pick routes. Do not run `npx proteum explain --routes --full` unless compact route/owner tools explicitly cannot answer the raw route-array question.',
625
+ '13. Use `--full`, `--manifest`, `--events`, or MCP `detail: "full"` only when compact output says the omitted detail is needed.',
625
626
  '',
626
627
  'CLI remains the reproducible surface for `dev`, `build`, `check`, `verify`, migrations, and final command evidence. MCP remains read-only and returns compact `proteum-mcp-v1` JSON.',
627
628
  '',
@@ -631,6 +632,7 @@ function renderEmbeddedProjectInstructions({ appRoot, coreRoot, includeMonorepoR
631
632
  '- Never edit generated files under `.proteum`.',
632
633
  '- Never create or edit Prisma migration files manually.',
633
634
  '- Never run schema-mutating SQL such as `ALTER TABLE`, `CREATE TABLE`, `DROP TABLE`, or `CREATE INDEX`.',
635
+ '- For read-only SQL diagnosis, use MCP `db_query` or `npx proteum db query "<sql>"`; only one capped `SELECT`, `SHOW`, or `EXPLAIN` statement is allowed.',
634
636
  '- If `schema.prisma` changes, ask the user to run `npx prisma migrate dev --config ./prisma.config.ts --name <migration name>` and wait for `continue` before validation.',
635
637
  '- For production changes, add or update focused unit tests for touched behavior when applicable, targeting 100% meaningful coverage for changed production paths.',
636
638
  '- Do not run `git restore` or `git reset`.',
@@ -649,6 +651,7 @@ function renderEmbeddedProjectInstructions({ appRoot, coreRoot, includeMonorepoR
649
651
  '## Routing Table',
650
652
  '',
651
653
  '- Non-trivial coding tasks, feature docs, product intent, acceptance criteria, or docs updates: read `DOCUMENTATION.md`.',
654
+ '- GEO/SEO/crawler/structured-data/AI-source changes: read `DOCUMENTATION.md`, `CODING_STYLE.md`, `tests/AGENTS.md`, and update or create a docs page under `docs/` describing the public contract, routes, validation, and operational caveats.',
652
655
  '- Raw errors, failing requests, traces, perf, or reproduction: read `diagnostics.md`.',
653
656
  '- Implementation edits: read `CODING_STYLE.md` before editing.',
654
657
  '- Client files or pages: read `client/AGENTS.md`; for page route/data/render work also read `client/pages/AGENTS.md`.',
@@ -0,0 +1,229 @@
1
+ export type TDatabaseReadQueryKind = 'explain' | 'select' | 'show';
2
+
3
+ export type TDatabaseReadQueryInput = {
4
+ limit?: number;
5
+ sql: string;
6
+ timeoutMs?: number;
7
+ };
8
+
9
+ export type TDatabaseReadQueryColumn = {
10
+ name: string;
11
+ table?: string;
12
+ type?: number | string;
13
+ };
14
+
15
+ export type TDatabaseReadQueryValue = boolean | number | string | null;
16
+
17
+ export type TDatabaseReadQueryRow = Record<string, TDatabaseReadQueryValue>;
18
+
19
+ export type TDatabaseReadQueryResponse = {
20
+ columns: TDatabaseReadQueryColumn[];
21
+ elapsedMs: number;
22
+ kind: TDatabaseReadQueryKind;
23
+ limit: number;
24
+ limited: boolean;
25
+ rowCount: number;
26
+ rows: TDatabaseReadQueryRow[];
27
+ sql: string;
28
+ };
29
+
30
+ export type TValidatedDatabaseReadQuery = {
31
+ kind: TDatabaseReadQueryKind;
32
+ sql: string;
33
+ };
34
+
35
+ export const defaultDatabaseReadLimit = 50;
36
+ export const maxDatabaseReadLimit = 500;
37
+ export const defaultDatabaseReadTimeoutMs = 5_000;
38
+ export const maxDatabaseReadTimeoutMs = 30_000;
39
+
40
+ const allowedQueryKinds = new Set<TDatabaseReadQueryKind>(['explain', 'select', 'show']);
41
+ const sqlKeywordPattern = /^[A-Za-z]+/;
42
+ const sqlCommentPattern = /\/\*[\s\S]*?\*\//g;
43
+ const sqlLineCommentPattern = /(?:^|\n)\s*(?:--|#).*?(?=\n|$)/g;
44
+
45
+ const clampInteger = ({ fallback, max, min, value }: { fallback: number; max: number; min: number; value?: number }) => {
46
+ if (value === undefined || !Number.isInteger(value) || value < min) return fallback;
47
+
48
+ return Math.min(value, max);
49
+ };
50
+
51
+ export const normalizeDatabaseReadLimit = (limit?: number) =>
52
+ clampInteger({
53
+ fallback: defaultDatabaseReadLimit,
54
+ max: maxDatabaseReadLimit,
55
+ min: 1,
56
+ value: limit,
57
+ });
58
+
59
+ export const normalizeDatabaseReadTimeoutMs = (timeoutMs?: number) =>
60
+ clampInteger({
61
+ fallback: defaultDatabaseReadTimeoutMs,
62
+ max: maxDatabaseReadTimeoutMs,
63
+ min: 100,
64
+ value: timeoutMs,
65
+ });
66
+
67
+ const skipLeadingTrivia = (sql: string) => {
68
+ let index = 0;
69
+
70
+ while (index < sql.length) {
71
+ const char = sql[index];
72
+ const next = sql[index + 1];
73
+
74
+ if (/\s/.test(char)) {
75
+ index += 1;
76
+ continue;
77
+ }
78
+
79
+ if (char === '-' && next === '-') {
80
+ index = sql.indexOf('\n', index + 2);
81
+ if (index === -1) return sql.length;
82
+ continue;
83
+ }
84
+
85
+ if (char === '#') {
86
+ index = sql.indexOf('\n', index + 1);
87
+ if (index === -1) return sql.length;
88
+ continue;
89
+ }
90
+
91
+ if (char === '/' && next === '*') {
92
+ const end = sql.indexOf('*/', index + 2);
93
+ if (end === -1) throw new Error('SQL contains an unterminated block comment.');
94
+ index = end + 2;
95
+ continue;
96
+ }
97
+
98
+ return index;
99
+ }
100
+
101
+ return index;
102
+ };
103
+
104
+ const stripSqlComments = (sql: string) =>
105
+ sql.replace(sqlCommentPattern, ' ').replace(sqlLineCommentPattern, '\n');
106
+
107
+ const findFirstStatementEnd = (sql: string) => {
108
+ let quote: "'" | '"' | '`' | undefined;
109
+ let lineComment = false;
110
+ let blockComment = false;
111
+
112
+ for (let index = 0; index < sql.length; index += 1) {
113
+ const char = sql[index];
114
+ const next = sql[index + 1];
115
+
116
+ if (lineComment) {
117
+ if (char === '\n') lineComment = false;
118
+ continue;
119
+ }
120
+
121
+ if (blockComment) {
122
+ if (char === '*' && next === '/') {
123
+ blockComment = false;
124
+ index += 1;
125
+ }
126
+ continue;
127
+ }
128
+
129
+ if (quote) {
130
+ if (char === '\\') {
131
+ index += 1;
132
+ continue;
133
+ }
134
+ if (char === quote) quote = undefined;
135
+ continue;
136
+ }
137
+
138
+ if (char === '-' && next === '-') {
139
+ lineComment = true;
140
+ index += 1;
141
+ continue;
142
+ }
143
+
144
+ if (char === '#') {
145
+ lineComment = true;
146
+ continue;
147
+ }
148
+
149
+ if (char === '/' && next === '*') {
150
+ blockComment = true;
151
+ index += 1;
152
+ continue;
153
+ }
154
+
155
+ if (char === '\'' || char === '"' || char === '`') {
156
+ quote = char;
157
+ continue;
158
+ }
159
+
160
+ if (char === ';') return index;
161
+ }
162
+
163
+ if (quote) throw new Error('SQL contains an unterminated quoted string.');
164
+ if (blockComment) throw new Error('SQL contains an unterminated block comment.');
165
+
166
+ return -1;
167
+ };
168
+
169
+ const assertSingleStatement = (sql: string) => {
170
+ const end = findFirstStatementEnd(sql);
171
+ if (end === -1) return sql.trim();
172
+
173
+ const first = sql.slice(0, end).trim();
174
+ const rest = stripSqlComments(sql.slice(end + 1)).trim();
175
+ if (rest) throw new Error('Only one read-only SQL statement may be executed.');
176
+
177
+ return first;
178
+ };
179
+
180
+ const getReadQueryKind = (sql: string): TDatabaseReadQueryKind => {
181
+ const start = skipLeadingTrivia(sql);
182
+ const keyword = sql.slice(start).match(sqlKeywordPattern)?.[0]?.toLowerCase();
183
+
184
+ if (!keyword || !allowedQueryKinds.has(keyword as TDatabaseReadQueryKind)) {
185
+ throw new Error('Only SELECT, SHOW, and EXPLAIN SQL statements are allowed.');
186
+ }
187
+
188
+ return keyword as TDatabaseReadQueryKind;
189
+ };
190
+
191
+ const assertAllowedReadQueryShape = (sql: string) => {
192
+ const normalized = stripSqlComments(sql).replace(/\s+/g, ' ').trim().toLowerCase();
193
+
194
+ if (/\bexplain\s+analyze\b/.test(normalized)) {
195
+ throw new Error('EXPLAIN ANALYZE is not allowed because it executes the target query.');
196
+ }
197
+
198
+ if (/\binto\s+(?:out|dump)file\b/.test(normalized)) {
199
+ throw new Error('SELECT INTO OUTFILE and SELECT INTO DUMPFILE are not allowed.');
200
+ }
201
+
202
+ if (/\b(?:load_file|pg_read_file|pg_read_binary_file|pg_ls_dir)\s*\(/.test(normalized)) {
203
+ throw new Error('Database file-read functions are not allowed in database diagnostics.');
204
+ }
205
+
206
+ if (
207
+ /\bfor\s+(?:update|no\s+key\s+update|share|key\s+share)\b/.test(normalized) ||
208
+ /\block\s+in\s+share\s+mode\b/.test(normalized)
209
+ ) {
210
+ throw new Error('Locking read statements are not allowed in database diagnostics.');
211
+ }
212
+
213
+ if (/\b(?:sleep|benchmark|pg_sleep|pg_sleep_for|pg_sleep_until)\s*\(/.test(normalized)) {
214
+ throw new Error('Sleep and benchmark functions are not allowed in database diagnostics.');
215
+ }
216
+ };
217
+
218
+ export const validateDatabaseReadQuery = (rawSql: string): TValidatedDatabaseReadQuery => {
219
+ const normalizedSql = rawSql.replace(/^\uFEFF/, '').trim();
220
+ if (!normalizedSql) throw new Error('SQL query is required.');
221
+ if (normalizedSql.length > 20_000) throw new Error('SQL query is too long for database diagnostics.');
222
+
223
+ const sql = assertSingleStatement(normalizedSql);
224
+ const kind = getReadQueryKind(sql);
225
+
226
+ assertAllowedReadQueryShape(sql);
227
+
228
+ return { kind, sql };
229
+ };
@@ -1,4 +1,5 @@
1
1
  import type { TDevConsoleLogLevel, TDevConsoleLogsResponse } from './console';
2
+ import type { TDatabaseReadQueryResponse } from './database';
2
3
  import type { TDoctorResponse } from './diagnostics';
3
4
  import { buildExplainSummaryItems } from './diagnostics';
4
5
  import { explainOwner, type TDiagnoseResponse, type TExplainOwnerResponse, type TOrientResponse } from './inspection';
@@ -733,6 +734,30 @@ export const compactLogsResponse = ({
733
734
  : undefined,
734
735
  });
735
736
 
737
+ export const compactDatabaseReadQueryResponse = (response: TDatabaseReadQueryResponse) =>
738
+ createMcpPayload({
739
+ summary: `${response.kind.toUpperCase()} returned ${response.rows.length}/${response.rowCount} rows in ${response.elapsedMs} ms${response.limited ? ` (limited to ${response.limit})` : ''}.`,
740
+ data: {
741
+ kind: response.kind,
742
+ elapsedMs: response.elapsedMs,
743
+ rowCount: response.rowCount,
744
+ returnedRowCount: response.rows.length,
745
+ limit: response.limit,
746
+ limited: response.limited,
747
+ columns: response.columns,
748
+ rows: response.rows,
749
+ },
750
+ omitted: response.limited
751
+ ? [
752
+ {
753
+ reason: `Rows are capped at ${response.limit}. Raise the limit up to 500 or make the read query narrower if more detail is needed.`,
754
+ tool: 'db_query',
755
+ toolArgs: { sql: response.sql, limit: Math.min(response.limit * 2, 500) },
756
+ },
757
+ ]
758
+ : undefined,
759
+ });
760
+
736
761
  const readPreview = (filepath: string) => {
737
762
  if (fs === undefined) return undefined;
738
763
  try {
@@ -7,6 +7,7 @@ import { stringifyMcpPayload, type TProteumMcpPayload } from './mcpPayloads';
7
7
  export type TProteumMcpDetail = 'compact' | 'full';
8
8
 
9
9
  export type TProteumMcpProvider = {
10
+ dbQuery: (input: { limit?: number; sql: string; timeoutMs?: number }) => Promise<TProteumMcpPayload>;
10
11
  diagnose: (input: {
11
12
  logsLevel?: 'silly' | 'log' | 'info' | 'warn' | 'error';
12
13
  logsLimit?: number;
@@ -64,6 +65,8 @@ const detailSchema = z.enum(['compact', 'full']).optional();
64
65
  const logsLevelSchema = z.enum(['silly', 'log', 'info', 'warn', 'error']).optional();
65
66
  const positiveLimitSchema = z.number().int().min(1).max(100).optional();
66
67
  const offsetSchema = z.number().int().min(0).max(10_000).optional();
68
+ const databaseLimitSchema = z.number().int().min(1).max(500).optional();
69
+ const databaseTimeoutSchema = z.number().int().min(100).max(30_000).optional();
67
70
 
68
71
  export const createProteumMcpServer = ({ provider, version }: TCreateProteumMcpServerArgs) => {
69
72
  const server = new McpServer(
@@ -264,6 +267,21 @@ export const createProteumMcpServer = ({ provider, version }: TCreateProteumMcpS
264
267
  async ({ level, limit }) => jsonToolResult(await provider.logsTail({ level, limit })),
265
268
  );
266
269
 
270
+ server.registerTool(
271
+ 'db_query',
272
+ {
273
+ annotations: readOnlyAnnotations,
274
+ description: 'Run one capped read-only database diagnostic query. Only SELECT, SHOW, and EXPLAIN are allowed.',
275
+ inputSchema: {
276
+ limit: databaseLimitSchema,
277
+ sql: z.string().min(1).describe('One SELECT, SHOW, or EXPLAIN SQL statement.'),
278
+ timeoutMs: databaseTimeoutSchema,
279
+ },
280
+ title: 'Proteum Database Query',
281
+ },
282
+ async ({ limit, sql, timeoutMs }) => jsonToolResult(await provider.dbQuery({ limit, sql, timeoutMs })),
283
+ );
284
+
267
285
  for (const [name, uri, description] of [
268
286
  ['runtime-status', 'proteum://runtime/status', 'Current compact runtime status.'],
269
287
  ['instructions-router', 'proteum://instructions/router', 'Current instruction routing contract.'],
@@ -66,7 +66,7 @@ Use MCP for repeated reads when a client is available:
66
66
  proteum mcp
67
67
  ```
68
68
 
69
- The machine router discovers live `proteum dev` sessions and offline Proteum app roots under a cwd. `proteum dev` ensures one managed machine MCP daemon is running; terminal `proteum mcp` starts or reuses that daemon, while MCP clients can use stdio. Agents should call MCP `workflow_start` with `cwd` or a known `projectId`, use `project_resolve { cwd }` when routing is ambiguous or offline, and pass the returned live `projectId` to every follow-up app-bound MCP tool. Offline candidates include port-inspected next actions, so agents should follow those instead of guessing the manifest default port. The router forwards to the selected dev-hosted `/__proteum/mcp` endpoint and strips routing fields before the app sees the call.
69
+ The machine router discovers live `proteum dev` sessions and offline Proteum app roots under a cwd. `proteum dev` ensures one managed machine MCP daemon is running; terminal `proteum mcp` starts or reuses that daemon and prints a compact central MCP banner with the HTTP client URL, while MCP clients can use stdio. Agents should call MCP `workflow_start` with `cwd` or a known `projectId`, use `project_resolve { cwd }` when routing is ambiguous or offline, and pass the returned live `projectId` to every follow-up app-bound MCP tool. Offline candidates include port-inspected next actions, so agents should follow those instead of guessing the manifest default port. The router forwards to the selected dev-hosted `/__proteum/mcp` endpoint and strips routing fields before the app sees the call.
70
70
 
71
71
  If machine MCP routing returns offline candidates, choose the intended app root and follow that candidate's next action from the app root, not from the monorepo wrapper. If machine MCP routing fails, run `proteum mcp status` and `proteum runtime status` from the intended app root; if no live session exists, use the exact Start Dev next action from runtime status so occupied router/HMR ports are avoided. If the same app already responds on the configured port without live tracking, use or repair that runtime instead of starting another server. Do not `curl` normal page routes to identify which app owns a port; use runtime status or Proteum dev-only endpoints. If a live session exists but runtime/MCP is unreachable, stop the listed session file first, then start dev again. Do not run diagnose, trace, or perf reads while runtime health is unreachable. Do not start a second dev server in the same worktree, and do not start a second managed MCP daemon. Then retry MCP `workflow_start`.
72
72
 
package/docs/mcp.md CHANGED
@@ -15,7 +15,7 @@ Start the router from any directory:
15
15
  proteum mcp
16
16
  ```
17
17
 
18
- When run from a terminal, `proteum mcp` starts or reuses the managed local daemon at `http://127.0.0.1:3769/mcp`. When an MCP client launches it over pipes, use stdio:
18
+ When run from a terminal, `proteum mcp` starts or reuses the managed local daemon at `http://127.0.0.1:3769/mcp`. The terminal output prints a compact `CENTRAL MCP READY` banner with the one-line client setup instruction, `Connect MCP client (HTTP): <mcp-url>`. When an MCP client launches it over pipes, use stdio:
19
19
 
20
20
  ```bash
21
21
  proteum mcp --stdio
@@ -44,6 +44,7 @@ Example tool calls:
44
44
  {"tool":"route_candidates","arguments":{"projectId":"prj_0123abcd4567","query":"dashboard","limit":8}}
45
45
  {"tool":"explain_summary","arguments":{"projectId":"prj_0123abcd4567","query":"/dashboard"}}
46
46
  {"tool":"diagnose","arguments":{"projectId":"prj_0123abcd4567","path":"/dashboard"}}
47
+ {"tool":"db_query","arguments":{"projectId":"prj_0123abcd4567","sql":"SELECT id, email FROM User LIMIT 5","limit":5}}
47
48
  ```
48
49
 
49
50
  `workflow_start` is the only app-bound bootstrap tool that may resolve from `cwd` when `projectId` is not known. It may return offline app candidates when no matching dev server is running yet. Other app-bound tools require a live `projectId`; if they omit it, the router returns a compact error that tells the agent to call `projects_list` or `project_resolve`. There is no single-project fallback, because wrong-project reads are worse than an explicit routing retry.
@@ -131,6 +132,7 @@ App-bound tools require `projectId` when called through `proteum mcp`:
131
132
  | `perf_top` | Hot-path perf rollup |
132
133
  | `perf_request` | One-request waterfall and attribution |
133
134
  | `logs_tail` | Capped recent server logs |
135
+ | `db_query` | Capped read-only database diagnostics for one `SELECT`, `SHOW`, or `EXPLAIN` statement |
134
136
 
135
137
  ## CLI Boundary
136
138
 
@@ -145,6 +147,7 @@ proteum diagnose /dashboard --port 3101
145
147
  proteum verify request /dashboard --port 3101
146
148
  proteum trace show <requestId> --events --port 3101
147
149
  proteum explain owner /dashboard
150
+ proteum db query "SELECT id, email FROM User LIMIT 5" --port 3101
148
151
  proteum explain --routes --controllers --full # only when the raw route/controller arrays are required
149
152
  ```
150
153
 
@@ -163,10 +166,14 @@ trace_show { projectId, requestId }
163
166
  trace_latest { projectId }
164
167
  perf_request { projectId, query }
165
168
  logs_tail { projectId }
169
+ db_query { projectId, sql, limit? }
166
170
  ```
167
171
 
168
172
  After an MCP read succeeds, do not run the equivalent CLI command for the same state, and do not run broad source searches for ownership that MCP already returned. CLI output is for fallback, validation, command evidence, and human-shareable reproductions.
169
173
 
174
+ Database diagnostics are intentionally read-only. `db_query` and `proteum db query` support MySQL, MariaDB, PostgreSQL, and PostgreSQL-compatible `DATABASE_URL` protocols. They accept only one `SELECT`, `SHOW`, or `EXPLAIN` statement, return rows, columns, elapsed milliseconds, and cap metadata, and reject multi-statement SQL, `EXPLAIN ANALYZE`, locking reads, file reads/writes, sleep, and benchmark functions.
175
+
176
+
170
177
  ## Benchmark
171
178
 
172
179
  The Product `/domains` diagnostic loop measured on May 7, 2026 used `ceil(UTF-8 bytes / 4)` as an output-token estimate:
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "proteum",
3
3
  "description": "LLM-first Opinionated Typescript Framework for web applications.",
4
- "version": "2.4.1",
4
+ "version": "2.4.3",
5
5
  "author": "Gaetan Le Gac (https://github.com/gaetanlegac)",
6
6
  "repository": "git://github.com/gaetanlegac/proteum.git",
7
7
  "license": "MIT",
@@ -76,6 +76,7 @@
76
76
  "node-cmd": "^5.0.0",
77
77
  "null-loader": "^4.0.1",
78
78
  "path-to-regexp": "^6.2.0",
79
+ "pg": "^8.21.0",
79
80
  "postcss-loader": "^8.2.0",
80
81
  "preact": "^10.27.1",
81
82
  "preact-render-to-string": "^6.6.1",
@@ -0,0 +1,87 @@
1
+ const assert = require('node:assert/strict');
2
+ const path = require('node:path');
3
+
4
+ const coreRoot = path.resolve(__dirname, '../../../..');
5
+ process.env.TS_NODE_PROJECT = path.join(coreRoot, 'cli', 'tsconfig.json');
6
+ process.env.TS_NODE_TRANSPILE_ONLY = '1';
7
+ require('ts-node/register/transpile-only');
8
+
9
+ const moduleAlias = require('module-alias');
10
+ moduleAlias.addAliases({
11
+ '@client': path.join(coreRoot, 'client'),
12
+ '@common': path.join(coreRoot, 'common'),
13
+ '@server': path.join(coreRoot, 'server'),
14
+ });
15
+
16
+ const { getHttpClientErrorContext, normalizeBugReportError } = require('./index.ts');
17
+
18
+ test('wraps got HTTP errors as anomalies with original error context', () => {
19
+ const error = new Error('Response code 422 (Unprocessable Entity)');
20
+ error.name = 'HTTPError';
21
+ error.code = 'ERR_NON_2XX_3XX_RESPONSE';
22
+ error.timings = {
23
+ phases: {
24
+ total: 321,
25
+ },
26
+ };
27
+ error.request = {
28
+ options: {
29
+ method: 'GET',
30
+ url: 'https://api.example.test/ip/2001:db8::1',
31
+ headers: {
32
+ 'x-key': 'secret-token',
33
+ },
34
+ },
35
+ };
36
+ error.response = {
37
+ statusCode: 422,
38
+ statusMessage: 'Unprocessable Entity',
39
+ headers: {
40
+ 'set-cookie': 'session=secret',
41
+ },
42
+ body: {
43
+ message: 'Invalid IP address',
44
+ },
45
+ };
46
+
47
+ const wrapped = normalizeBugReportError(error);
48
+
49
+ assert.equal(wrapped.message, 'HTTP client request failed.');
50
+ assert.equal(wrapped.originalError, error);
51
+ assert.deepEqual(wrapped.dataForDebugging, {
52
+ code: 'ERR_NON_2XX_3XX_RESPONSE',
53
+ statusCode: 422,
54
+ statusMessage: 'Unprocessable Entity',
55
+ method: 'GET',
56
+ url: 'https://api.example.test/ip/2001:db8::1',
57
+ timings: {
58
+ phases: {
59
+ total: 321,
60
+ },
61
+ },
62
+ request: {
63
+ options: {
64
+ method: 'GET',
65
+ url: 'https://api.example.test/ip/2001:db8::1',
66
+ headers: {
67
+ 'x-key': 'secret-token',
68
+ },
69
+ },
70
+ },
71
+ response: {
72
+ statusCode: 422,
73
+ statusMessage: 'Unprocessable Entity',
74
+ headers: {
75
+ 'set-cookie': 'session=secret',
76
+ },
77
+ body: {
78
+ message: 'Invalid IP address',
79
+ },
80
+ },
81
+ options: null,
82
+ });
83
+ });
84
+
85
+ test('ignores normal application errors', () => {
86
+ assert.equal(getHttpClientErrorContext(new Error('Something else failed')), null);
87
+ });
@@ -17,7 +17,7 @@ import Ansi2Html from 'ansi-to-html';
17
17
  import type ApplicationContainer from '..';
18
18
  import context from '@server/context';
19
19
  import type { TDevConsoleLogChannel, TDevConsoleLogEntry, TDevConsoleLogLevel } from '@common/dev/console';
20
- import type { ServerBug, TCatchedError } from '@common/errors';
20
+ import { Anomaly, type ServerBug, type TCatchedError } from '@common/errors';
21
21
  import type { TTraceCallOrigin, TTraceSqlQueryKind } from '@common/dev/requestTrace';
22
22
  import type ServerRequest from '@server/services/router/request';
23
23
 
@@ -74,6 +74,13 @@ export type TDbQueryLog = ChannelInfos & { date: Date; query: string; time: numb
74
74
  export type TLogLevel = keyof typeof logLevels;
75
75
 
76
76
  export type TJsonLog = { time: Date; level: TLogLevel; args: unknown[]; channel: ChannelInfos };
77
+ type TGotLikeError = Error & {
78
+ code?: unknown;
79
+ timings?: unknown;
80
+ request?: unknown;
81
+ response?: unknown;
82
+ options?: unknown;
83
+ };
77
84
 
78
85
  /*----------------------------------
79
86
  - CONST
@@ -111,6 +118,57 @@ var ansi2Html = new Ansi2Html({
111
118
 
112
119
  type TWrappedConsole = typeof console & { _wrapped?: boolean };
113
120
 
121
+ const isRecord = (value: unknown): value is Record<string, unknown> => {
122
+ return typeof value === 'object' && value !== null;
123
+ };
124
+
125
+ const firstString = (...values: Array<unknown>): string | null => {
126
+ for (const value of values) {
127
+ if (typeof value === 'string' && value.trim()) return value;
128
+ if (value instanceof URL) return value.toString();
129
+ }
130
+
131
+ return null;
132
+ };
133
+
134
+ export const getHttpClientErrorContext = (error: Error): object | null => {
135
+ const gotError = error as TGotLikeError;
136
+ const errorRecord = gotError as unknown as Record<string, unknown>;
137
+ const code = typeof gotError.code === 'string' ? gotError.code : null;
138
+ const response = isRecord(gotError.response) ? gotError.response : null;
139
+ const request = isRecord(gotError.request) ? gotError.request : null;
140
+ const requestOptions = request && isRecord(request.options) ? request.options : null;
141
+ const fallbackOptions = isRecord(gotError.options) ? gotError.options : null;
142
+ const options = requestOptions || fallbackOptions;
143
+
144
+ if (error.name !== 'HTTPError' && code !== 'ERR_NON_2XX_3XX_RESPONSE' && !response) return null;
145
+
146
+ return {
147
+ code,
148
+ statusCode: response && typeof response.statusCode === 'number' ? response.statusCode : null,
149
+ statusMessage: response && typeof response.statusMessage === 'string' ? response.statusMessage : null,
150
+ method: options && typeof options.method === 'string' ? options.method : null,
151
+ url: firstString(
152
+ response ? response.requestUrl : null,
153
+ response ? response.url : null,
154
+ request ? request.requestUrl : null,
155
+ options ? options.url : null,
156
+ errorRecord.url,
157
+ ),
158
+ timings: gotError.timings || null,
159
+ request: request || null,
160
+ response: response || null,
161
+ options: fallbackOptions,
162
+ };
163
+ };
164
+
165
+ export const normalizeBugReportError = (error: TCatchedError): TCatchedError => {
166
+ const httpClientErrorContext = getHttpClientErrorContext(error);
167
+ if (!httpClientErrorContext) return error;
168
+
169
+ return new Anomaly('HTTP client request failed.', httpClientErrorContext, error);
170
+ };
171
+
114
172
  /*----------------------------------
115
173
  - LOGGER
116
174
  ----------------------------------*/
@@ -307,7 +365,7 @@ export default class Console {
307
365
  // On envoi l'email avant l'insertion dans bla bdd
308
366
  // Car cette denrière a plus de chances de provoquer une erreur
309
367
  //const logs = this.logs.filter(e => e.channel.channelId === channelId).slice(-100);
310
- const inspection = this.getDetailledError(error);
368
+ const inspection = this.getDetailledError(normalizeBugReportError(error));
311
369
 
312
370
  // Genertae unique error hash
313
371
  const hash = md5(inspection.stacktraces[0]);