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
package/cli/runtime/commands.ts
CHANGED
|
@@ -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);
|
package/cli/utils/agents.ts
CHANGED
|
@@ -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 `
|
|
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.
|
|
621
|
-
'9. If
|
|
622
|
-
'10.
|
|
623
|
-
'11. Use
|
|
624
|
-
'12. Use
|
|
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 {
|
package/common/dev/mcpServer.ts
CHANGED
|
@@ -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.'],
|
package/docs/agent-routing.md
CHANGED
|
@@ -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.
|
|
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
|
|
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]);
|