brakit 0.7.6 → 0.8.0
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/README.md +10 -3
- package/dist/api.d.ts +47 -2
- package/dist/api.js +250 -47
- package/dist/bin/brakit.js +1119 -29
- package/dist/mcp/server.d.ts +3 -0
- package/dist/mcp/server.js +737 -0
- package/dist/runtime/index.js +270 -13
- package/package.json +5 -1
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
// src/mcp/server.ts
|
|
2
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import {
|
|
5
|
+
ListToolsRequestSchema,
|
|
6
|
+
CallToolRequestSchema,
|
|
7
|
+
ListPromptsRequestSchema,
|
|
8
|
+
GetPromptRequestSchema
|
|
9
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
10
|
+
|
|
11
|
+
// src/constants/routes.ts
|
|
12
|
+
var DASHBOARD_API_REQUESTS = "/__brakit/api/requests";
|
|
13
|
+
var DASHBOARD_API_CLEAR = "/__brakit/api/clear";
|
|
14
|
+
var DASHBOARD_API_FETCHES = "/__brakit/api/fetches";
|
|
15
|
+
var DASHBOARD_API_ERRORS = "/__brakit/api/errors";
|
|
16
|
+
var DASHBOARD_API_QUERIES = "/__brakit/api/queries";
|
|
17
|
+
var DASHBOARD_API_ACTIVITY = "/__brakit/api/activity";
|
|
18
|
+
var DASHBOARD_API_METRICS_LIVE = "/__brakit/api/metrics/live";
|
|
19
|
+
var DASHBOARD_API_INSIGHTS = "/__brakit/api/insights";
|
|
20
|
+
var DASHBOARD_API_SECURITY = "/__brakit/api/security";
|
|
21
|
+
var DASHBOARD_API_FINDINGS = "/__brakit/api/findings";
|
|
22
|
+
|
|
23
|
+
// src/constants/mcp.ts
|
|
24
|
+
var MCP_SERVER_NAME = "brakit";
|
|
25
|
+
var MCP_SERVER_VERSION = "0.8.0";
|
|
26
|
+
var INITIAL_DISCOVERY_TIMEOUT_MS = 5e3;
|
|
27
|
+
var LAZY_DISCOVERY_TIMEOUT_MS = 2e3;
|
|
28
|
+
var CLIENT_FETCH_TIMEOUT_MS = 1e4;
|
|
29
|
+
var HEALTH_CHECK_TIMEOUT_MS = 3e3;
|
|
30
|
+
var DISCOVERY_POLL_INTERVAL_MS = 500;
|
|
31
|
+
var MAX_TIMELINE_EVENTS = 20;
|
|
32
|
+
var MAX_RESOLVED_DISPLAY = 5;
|
|
33
|
+
var ENRICHMENT_SEVERITY_FILTER = ["critical", "warning"];
|
|
34
|
+
|
|
35
|
+
// src/mcp/client.ts
|
|
36
|
+
var BrakitClient = class {
|
|
37
|
+
constructor(baseUrl) {
|
|
38
|
+
this.baseUrl = baseUrl;
|
|
39
|
+
}
|
|
40
|
+
async getRequests(params) {
|
|
41
|
+
const url = new URL(`${this.baseUrl}${DASHBOARD_API_REQUESTS}`);
|
|
42
|
+
if (params?.method) url.searchParams.set("method", params.method);
|
|
43
|
+
if (params?.status) url.searchParams.set("status", params.status);
|
|
44
|
+
if (params?.search) url.searchParams.set("search", params.search);
|
|
45
|
+
if (params?.limit) url.searchParams.set("limit", String(params.limit));
|
|
46
|
+
if (params?.offset) url.searchParams.set("offset", String(params.offset));
|
|
47
|
+
return this.fetchJson(url);
|
|
48
|
+
}
|
|
49
|
+
async getSecurityFindings() {
|
|
50
|
+
return this.fetchJson(`${this.baseUrl}${DASHBOARD_API_SECURITY}`);
|
|
51
|
+
}
|
|
52
|
+
async getInsights() {
|
|
53
|
+
return this.fetchJson(`${this.baseUrl}${DASHBOARD_API_INSIGHTS}`);
|
|
54
|
+
}
|
|
55
|
+
async getQueries(requestId) {
|
|
56
|
+
const url = new URL(`${this.baseUrl}${DASHBOARD_API_QUERIES}`);
|
|
57
|
+
if (requestId) url.searchParams.set("requestId", requestId);
|
|
58
|
+
return this.fetchJson(url);
|
|
59
|
+
}
|
|
60
|
+
async getFetches(requestId) {
|
|
61
|
+
const url = new URL(`${this.baseUrl}${DASHBOARD_API_FETCHES}`);
|
|
62
|
+
if (requestId) url.searchParams.set("requestId", requestId);
|
|
63
|
+
return this.fetchJson(url);
|
|
64
|
+
}
|
|
65
|
+
async getErrors() {
|
|
66
|
+
return this.fetchJson(`${this.baseUrl}${DASHBOARD_API_ERRORS}`);
|
|
67
|
+
}
|
|
68
|
+
async getActivity(requestId) {
|
|
69
|
+
const url = new URL(`${this.baseUrl}${DASHBOARD_API_ACTIVITY}`);
|
|
70
|
+
url.searchParams.set("requestId", requestId);
|
|
71
|
+
return this.fetchJson(url);
|
|
72
|
+
}
|
|
73
|
+
async getLiveMetrics() {
|
|
74
|
+
return this.fetchJson(`${this.baseUrl}${DASHBOARD_API_METRICS_LIVE}`);
|
|
75
|
+
}
|
|
76
|
+
async getFindings(state) {
|
|
77
|
+
const url = new URL(`${this.baseUrl}${DASHBOARD_API_FINDINGS}`);
|
|
78
|
+
if (state) url.searchParams.set("state", state);
|
|
79
|
+
return this.fetchJson(url);
|
|
80
|
+
}
|
|
81
|
+
async clearAll() {
|
|
82
|
+
const res = await fetch(`${this.baseUrl}${DASHBOARD_API_CLEAR}`, {
|
|
83
|
+
method: "POST",
|
|
84
|
+
signal: AbortSignal.timeout(CLIENT_FETCH_TIMEOUT_MS)
|
|
85
|
+
});
|
|
86
|
+
return res.ok;
|
|
87
|
+
}
|
|
88
|
+
async isAlive() {
|
|
89
|
+
try {
|
|
90
|
+
const res = await fetch(`${this.baseUrl}${DASHBOARD_API_REQUESTS}?limit=1`, {
|
|
91
|
+
signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS)
|
|
92
|
+
});
|
|
93
|
+
return res.ok;
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async fetchJson(url) {
|
|
99
|
+
const res = await fetch(url.toString(), {
|
|
100
|
+
signal: AbortSignal.timeout(CLIENT_FETCH_TIMEOUT_MS)
|
|
101
|
+
});
|
|
102
|
+
if (!res.ok) {
|
|
103
|
+
throw new Error(`Brakit API error: ${res.status} ${res.statusText}`);
|
|
104
|
+
}
|
|
105
|
+
return res.json();
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// src/mcp/discovery.ts
|
|
110
|
+
import { readFileSync, existsSync } from "fs";
|
|
111
|
+
import { resolve } from "path";
|
|
112
|
+
|
|
113
|
+
// src/constants/metrics.ts
|
|
114
|
+
var PORT_FILE = ".brakit/port";
|
|
115
|
+
|
|
116
|
+
// src/mcp/discovery.ts
|
|
117
|
+
function discoverBrakitPort(cwd) {
|
|
118
|
+
const root = cwd ?? process.cwd();
|
|
119
|
+
const portPath = resolve(root, PORT_FILE);
|
|
120
|
+
if (!existsSync(portPath)) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`Brakit is not running. No port file found at ${portPath}.
|
|
123
|
+
Start your app with brakit enabled first.`
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
const raw = readFileSync(portPath, "utf-8").trim();
|
|
127
|
+
const port = parseInt(raw, 10);
|
|
128
|
+
if (isNaN(port) || port < 1 || port > 65535) {
|
|
129
|
+
throw new Error(`Invalid port in ${portPath}: "${raw}"`);
|
|
130
|
+
}
|
|
131
|
+
return { port, baseUrl: `http://localhost:${port}` };
|
|
132
|
+
}
|
|
133
|
+
async function waitForBrakit(cwd, timeoutMs = 1e4, pollMs = DISCOVERY_POLL_INTERVAL_MS) {
|
|
134
|
+
const deadline = Date.now() + timeoutMs;
|
|
135
|
+
while (Date.now() < deadline) {
|
|
136
|
+
try {
|
|
137
|
+
const result = discoverBrakitPort(cwd);
|
|
138
|
+
const res = await fetch(`${result.baseUrl}${DASHBOARD_API_REQUESTS}?limit=1`);
|
|
139
|
+
if (res.ok) return result;
|
|
140
|
+
} catch {
|
|
141
|
+
}
|
|
142
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
143
|
+
}
|
|
144
|
+
throw new Error(
|
|
145
|
+
"Timed out waiting for Brakit to start. Is your app running with brakit enabled?"
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// src/mcp/enrichment.ts
|
|
150
|
+
import { createHash as createHash2 } from "crypto";
|
|
151
|
+
|
|
152
|
+
// src/store/finding-id.ts
|
|
153
|
+
import { createHash } from "crypto";
|
|
154
|
+
function computeFindingId(finding) {
|
|
155
|
+
const key = `${finding.rule}:${finding.endpoint}:${finding.desc}`;
|
|
156
|
+
return createHash("sha256").update(key).digest("hex").slice(0, 16);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// src/utils/endpoint.ts
|
|
160
|
+
function parseEndpointKey(endpoint) {
|
|
161
|
+
const spaceIdx = endpoint.indexOf(" ");
|
|
162
|
+
if (spaceIdx > 0) {
|
|
163
|
+
return { method: endpoint.slice(0, spaceIdx), path: endpoint.slice(spaceIdx + 1) };
|
|
164
|
+
}
|
|
165
|
+
return { path: endpoint };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// src/mcp/enrichment.ts
|
|
169
|
+
function computeInsightId(type, endpoint, desc) {
|
|
170
|
+
const key = `${type}:${endpoint}:${desc}`;
|
|
171
|
+
return createHash2("sha256").update(key).digest("hex").slice(0, 16);
|
|
172
|
+
}
|
|
173
|
+
async function enrichFindings(client) {
|
|
174
|
+
const [securityData, insightsData] = await Promise.all([
|
|
175
|
+
client.getSecurityFindings(),
|
|
176
|
+
client.getInsights()
|
|
177
|
+
]);
|
|
178
|
+
const enriched = [];
|
|
179
|
+
for (const f of securityData.findings) {
|
|
180
|
+
let context = "";
|
|
181
|
+
try {
|
|
182
|
+
const { path } = parseEndpointKey(f.endpoint);
|
|
183
|
+
const reqData = await client.getRequests({ search: path, limit: 1 });
|
|
184
|
+
if (reqData.requests.length > 0) {
|
|
185
|
+
const req = reqData.requests[0];
|
|
186
|
+
if (req.id) {
|
|
187
|
+
const activity = await client.getActivity(req.id);
|
|
188
|
+
const queryCount = activity.counts?.queries ?? 0;
|
|
189
|
+
const fetchCount = activity.counts?.fetches ?? 0;
|
|
190
|
+
context = `Request took ${req.durationMs}ms. ${queryCount} DB queries, ${fetchCount} fetches.`;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
context = "(context unavailable)";
|
|
195
|
+
}
|
|
196
|
+
enriched.push({
|
|
197
|
+
findingId: computeFindingId(f),
|
|
198
|
+
severity: f.severity,
|
|
199
|
+
title: f.title,
|
|
200
|
+
endpoint: f.endpoint,
|
|
201
|
+
description: f.desc,
|
|
202
|
+
hint: f.hint,
|
|
203
|
+
occurrences: f.count,
|
|
204
|
+
context
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
for (const i of insightsData.insights) {
|
|
208
|
+
if (!ENRICHMENT_SEVERITY_FILTER.includes(i.severity)) continue;
|
|
209
|
+
const endpoint = i.nav ?? "global";
|
|
210
|
+
enriched.push({
|
|
211
|
+
findingId: computeInsightId(i.type, endpoint, i.desc),
|
|
212
|
+
severity: i.severity,
|
|
213
|
+
title: i.title,
|
|
214
|
+
endpoint,
|
|
215
|
+
description: i.desc,
|
|
216
|
+
hint: i.hint,
|
|
217
|
+
occurrences: 1,
|
|
218
|
+
context: i.detail ?? ""
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
return enriched;
|
|
222
|
+
}
|
|
223
|
+
async function enrichEndpoints(client, sortBy) {
|
|
224
|
+
const data = await client.getLiveMetrics();
|
|
225
|
+
const endpoints = data.endpoints.map((ep) => ({
|
|
226
|
+
...ep.summary,
|
|
227
|
+
endpoint: ep.endpoint
|
|
228
|
+
}));
|
|
229
|
+
if (sortBy === "error_rate") {
|
|
230
|
+
endpoints.sort((a, b) => b.errorRate - a.errorRate);
|
|
231
|
+
} else if (sortBy === "query_count") {
|
|
232
|
+
endpoints.sort((a, b) => b.avgQueryCount - a.avgQueryCount);
|
|
233
|
+
} else if (sortBy === "requests") {
|
|
234
|
+
endpoints.sort((a, b) => b.totalRequests - a.totalRequests);
|
|
235
|
+
}
|
|
236
|
+
return endpoints;
|
|
237
|
+
}
|
|
238
|
+
async function enrichRequestDetail(client, opts) {
|
|
239
|
+
if (opts.requestId) {
|
|
240
|
+
const data = await client.getRequests({ search: opts.requestId, limit: 1 });
|
|
241
|
+
if (data.requests.length > 0) {
|
|
242
|
+
return buildRequestDetail(client, data.requests[0].id);
|
|
243
|
+
}
|
|
244
|
+
} else if (opts.endpoint) {
|
|
245
|
+
const { method, path } = parseEndpointKey(opts.endpoint);
|
|
246
|
+
const data = await client.getRequests({ method, search: path, limit: 1 });
|
|
247
|
+
if (data.requests.length > 0) {
|
|
248
|
+
return buildRequestDetail(client, data.requests[0].id);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
async function buildRequestDetail(client, requestId) {
|
|
254
|
+
const [reqData, activity, queries, fetches] = await Promise.all([
|
|
255
|
+
client.getRequests({ search: requestId, limit: 1 }),
|
|
256
|
+
client.getActivity(requestId),
|
|
257
|
+
client.getQueries(requestId),
|
|
258
|
+
client.getFetches(requestId)
|
|
259
|
+
]);
|
|
260
|
+
const req = reqData.requests[0];
|
|
261
|
+
return {
|
|
262
|
+
id: requestId,
|
|
263
|
+
method: req.method,
|
|
264
|
+
url: req.url,
|
|
265
|
+
statusCode: req.statusCode,
|
|
266
|
+
durationMs: req.durationMs,
|
|
267
|
+
queries: queries.entries,
|
|
268
|
+
fetches: fetches.entries,
|
|
269
|
+
timeline: activity.timeline
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// src/mcp/tools/get-findings.ts
|
|
274
|
+
var VALID_SEVERITIES = /* @__PURE__ */ new Set(["critical", "warning"]);
|
|
275
|
+
var VALID_STATES = /* @__PURE__ */ new Set(["open", "fixing", "resolved"]);
|
|
276
|
+
var getFindings = {
|
|
277
|
+
name: "get_findings",
|
|
278
|
+
description: "Get all security findings and performance insights from the running app. Returns enriched findings with actionable fix hints, endpoint context, and evidence. Use this to understand what issues exist in the running application.",
|
|
279
|
+
inputSchema: {
|
|
280
|
+
type: "object",
|
|
281
|
+
properties: {
|
|
282
|
+
severity: {
|
|
283
|
+
type: "string",
|
|
284
|
+
enum: ["critical", "warning"],
|
|
285
|
+
description: "Filter by severity level"
|
|
286
|
+
},
|
|
287
|
+
state: {
|
|
288
|
+
type: "string",
|
|
289
|
+
enum: ["open", "fixing", "resolved"],
|
|
290
|
+
description: "Filter by finding state (from finding lifecycle)"
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
async handler(client, args) {
|
|
295
|
+
const severity = args.severity;
|
|
296
|
+
const state = args.state;
|
|
297
|
+
if (severity && !VALID_SEVERITIES.has(severity)) {
|
|
298
|
+
return { content: [{ type: "text", text: `Invalid severity "${severity}". Use: critical, warning.` }], isError: true };
|
|
299
|
+
}
|
|
300
|
+
if (state && !VALID_STATES.has(state)) {
|
|
301
|
+
return { content: [{ type: "text", text: `Invalid state "${state}". Use: open, fixing, resolved.` }], isError: true };
|
|
302
|
+
}
|
|
303
|
+
let findings = await enrichFindings(client);
|
|
304
|
+
if (severity) {
|
|
305
|
+
findings = findings.filter((f) => f.severity === severity);
|
|
306
|
+
}
|
|
307
|
+
if (state) {
|
|
308
|
+
const stateful = await client.getFindings(state);
|
|
309
|
+
const statefulIds = new Set(stateful.findings.map((f) => f.findingId));
|
|
310
|
+
findings = findings.filter((f) => statefulIds.has(f.findingId));
|
|
311
|
+
}
|
|
312
|
+
if (findings.length === 0) {
|
|
313
|
+
return { content: [{ type: "text", text: "No findings detected. The application looks healthy." }] };
|
|
314
|
+
}
|
|
315
|
+
const lines = [`Found ${findings.length} issue(s):
|
|
316
|
+
`];
|
|
317
|
+
for (const f of findings) {
|
|
318
|
+
lines.push(`[${f.severity.toUpperCase()}] ${f.title}`);
|
|
319
|
+
lines.push(` Endpoint: ${f.endpoint}`);
|
|
320
|
+
lines.push(` Issue: ${f.description}`);
|
|
321
|
+
if (f.context) lines.push(` Context: ${f.context}`);
|
|
322
|
+
lines.push(` Fix: ${f.hint}`);
|
|
323
|
+
lines.push("");
|
|
324
|
+
}
|
|
325
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
// src/mcp/tools/get-endpoints.ts
|
|
330
|
+
var VALID_SORT_KEYS = /* @__PURE__ */ new Set(["p95", "error_rate", "query_count", "requests"]);
|
|
331
|
+
var getEndpoints = {
|
|
332
|
+
name: "get_endpoints",
|
|
333
|
+
description: "Get a summary of all observed API endpoints with performance stats. Shows p95 latency, error rate, query count, and time breakdown for each endpoint. Use this to identify which endpoints need attention.",
|
|
334
|
+
inputSchema: {
|
|
335
|
+
type: "object",
|
|
336
|
+
properties: {
|
|
337
|
+
sort_by: {
|
|
338
|
+
type: "string",
|
|
339
|
+
enum: ["p95", "error_rate", "query_count", "requests"],
|
|
340
|
+
description: "Sort endpoints by this metric (default: p95 latency)"
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
},
|
|
344
|
+
async handler(client, args) {
|
|
345
|
+
const sortBy = args.sort_by;
|
|
346
|
+
if (sortBy && !VALID_SORT_KEYS.has(sortBy)) {
|
|
347
|
+
return { content: [{ type: "text", text: `Invalid sort_by "${sortBy}". Use: p95, error_rate, query_count, requests.` }], isError: true };
|
|
348
|
+
}
|
|
349
|
+
const endpoints = await enrichEndpoints(client, sortBy);
|
|
350
|
+
if (endpoints.length === 0) {
|
|
351
|
+
return {
|
|
352
|
+
content: [{ type: "text", text: "No endpoints observed yet. Make some requests to your app first." }]
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
const lines = [`${endpoints.length} endpoint(s) observed:
|
|
356
|
+
`];
|
|
357
|
+
for (const ep of endpoints) {
|
|
358
|
+
lines.push(`${ep.endpoint}`);
|
|
359
|
+
lines.push(` p95: ${ep.p95Ms}ms | Errors: ${(ep.errorRate * 100).toFixed(1)}% | Queries: ${ep.avgQueryCount}/req | Requests: ${ep.totalRequests}`);
|
|
360
|
+
lines.push(` Time breakdown: DB ${ep.avgQueryTimeMs}ms + Fetch ${ep.avgFetchTimeMs}ms + App ${ep.avgAppTimeMs}ms`);
|
|
361
|
+
lines.push("");
|
|
362
|
+
}
|
|
363
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
// src/mcp/tools/get-request-detail.ts
|
|
368
|
+
var getRequestDetail = {
|
|
369
|
+
name: "get_request_detail",
|
|
370
|
+
description: "Get full details of a specific HTTP request including all DB queries it fired, all fetches it made, the response, and a timeline of events. Use this to deeply understand what happens when a specific endpoint is hit.",
|
|
371
|
+
inputSchema: {
|
|
372
|
+
type: "object",
|
|
373
|
+
properties: {
|
|
374
|
+
request_id: {
|
|
375
|
+
type: "string",
|
|
376
|
+
description: "The specific request ID to look up"
|
|
377
|
+
},
|
|
378
|
+
endpoint: {
|
|
379
|
+
type: "string",
|
|
380
|
+
description: "Alternatively, get the latest request for an endpoint like 'GET /api/users'"
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
},
|
|
384
|
+
async handler(client, args) {
|
|
385
|
+
const requestId = args.request_id;
|
|
386
|
+
const endpoint = args.endpoint;
|
|
387
|
+
if (!requestId && !endpoint) {
|
|
388
|
+
return {
|
|
389
|
+
content: [{ type: "text", text: "Please provide either a request_id or an endpoint (e.g. 'GET /api/users')." }]
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
const detail = await enrichRequestDetail(client, { requestId, endpoint });
|
|
393
|
+
if (!detail) {
|
|
394
|
+
return {
|
|
395
|
+
content: [{ type: "text", text: `No request found for ${requestId ?? endpoint}.` }]
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
const lines = [
|
|
399
|
+
`Request: ${detail.method} ${detail.url}`,
|
|
400
|
+
`Status: ${detail.statusCode}`,
|
|
401
|
+
`Duration: ${detail.durationMs}ms`,
|
|
402
|
+
""
|
|
403
|
+
];
|
|
404
|
+
if (detail.queries.length > 0) {
|
|
405
|
+
lines.push(`DB Queries (${detail.queries.length}):`);
|
|
406
|
+
for (const q of detail.queries) {
|
|
407
|
+
const sql = q.sql ?? `${q.operation} ${q.table ?? q.model ?? ""}`;
|
|
408
|
+
lines.push(` [${q.durationMs}ms] ${sql}`);
|
|
409
|
+
}
|
|
410
|
+
lines.push("");
|
|
411
|
+
}
|
|
412
|
+
if (detail.fetches.length > 0) {
|
|
413
|
+
lines.push(`Outgoing Fetches (${detail.fetches.length}):`);
|
|
414
|
+
for (const f of detail.fetches) {
|
|
415
|
+
lines.push(` [${f.durationMs}ms] ${f.method} ${f.url} \u2192 ${f.statusCode}`);
|
|
416
|
+
}
|
|
417
|
+
lines.push("");
|
|
418
|
+
}
|
|
419
|
+
if (detail.timeline.length > 0) {
|
|
420
|
+
lines.push(`Timeline (${detail.timeline.length} events):`);
|
|
421
|
+
for (const event of detail.timeline.slice(0, MAX_TIMELINE_EVENTS)) {
|
|
422
|
+
lines.push(` ${event.type}: ${JSON.stringify(event.data)}`);
|
|
423
|
+
}
|
|
424
|
+
if (detail.timeline.length > MAX_TIMELINE_EVENTS) {
|
|
425
|
+
lines.push(` ... and ${detail.timeline.length - MAX_TIMELINE_EVENTS} more events`);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
// src/mcp/tools/verify-fix.ts
|
|
433
|
+
var verifyFix = {
|
|
434
|
+
name: "verify_fix",
|
|
435
|
+
description: "Verify whether a previously found security or performance issue has been resolved. After you fix code, the user should trigger the endpoint again, then call this tool to check if the finding still appears in Brakit's analysis.",
|
|
436
|
+
inputSchema: {
|
|
437
|
+
type: "object",
|
|
438
|
+
properties: {
|
|
439
|
+
finding_id: {
|
|
440
|
+
type: "string",
|
|
441
|
+
description: "The finding ID to verify"
|
|
442
|
+
},
|
|
443
|
+
endpoint: {
|
|
444
|
+
type: "string",
|
|
445
|
+
description: "Alternatively, check if a specific endpoint still has issues (e.g. 'GET /api/users')"
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
},
|
|
449
|
+
async handler(client, args) {
|
|
450
|
+
const findingId = args.finding_id;
|
|
451
|
+
const endpoint = args.endpoint;
|
|
452
|
+
if (findingId !== void 0 && findingId.trim() === "") {
|
|
453
|
+
return { content: [{ type: "text", text: "finding_id cannot be empty." }], isError: true };
|
|
454
|
+
}
|
|
455
|
+
if (endpoint !== void 0 && endpoint.trim() === "") {
|
|
456
|
+
return { content: [{ type: "text", text: "endpoint cannot be empty." }], isError: true };
|
|
457
|
+
}
|
|
458
|
+
if (findingId) {
|
|
459
|
+
const data = await client.getFindings();
|
|
460
|
+
const finding = data.findings.find((f) => f.findingId === findingId);
|
|
461
|
+
if (!finding) {
|
|
462
|
+
return {
|
|
463
|
+
content: [{
|
|
464
|
+
type: "text",
|
|
465
|
+
text: `Finding ${findingId} not found. It may have already been resolved and cleaned up.`
|
|
466
|
+
}]
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
if (finding.state === "resolved") {
|
|
470
|
+
return {
|
|
471
|
+
content: [{
|
|
472
|
+
type: "text",
|
|
473
|
+
text: `RESOLVED: "${finding.finding.title}" on ${finding.finding.endpoint} is no longer detected. The fix worked.`
|
|
474
|
+
}]
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
return {
|
|
478
|
+
content: [{
|
|
479
|
+
type: "text",
|
|
480
|
+
text: [
|
|
481
|
+
`STILL PRESENT: "${finding.finding.title}" on ${finding.finding.endpoint}`,
|
|
482
|
+
` State: ${finding.state}`,
|
|
483
|
+
` Last seen: ${new Date(finding.lastSeenAt).toISOString()}`,
|
|
484
|
+
` Occurrences: ${finding.occurrences}`,
|
|
485
|
+
` Issue: ${finding.finding.desc}`,
|
|
486
|
+
` Hint: ${finding.finding.hint}`,
|
|
487
|
+
"",
|
|
488
|
+
"Make sure the user has triggered the endpoint again after the fix, so Brakit can re-analyze."
|
|
489
|
+
].join("\n")
|
|
490
|
+
}]
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
if (endpoint) {
|
|
494
|
+
const data = await client.getFindings();
|
|
495
|
+
const endpointFindings = data.findings.filter(
|
|
496
|
+
(f) => f.finding.endpoint === endpoint || f.finding.endpoint.endsWith(` ${endpoint}`)
|
|
497
|
+
);
|
|
498
|
+
if (endpointFindings.length === 0) {
|
|
499
|
+
return {
|
|
500
|
+
content: [{
|
|
501
|
+
type: "text",
|
|
502
|
+
text: `No findings found for endpoint "${endpoint}". Either it's clean or it hasn't been analyzed yet.`
|
|
503
|
+
}]
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
const open = endpointFindings.filter((f) => f.state === "open");
|
|
507
|
+
const resolved = endpointFindings.filter((f) => f.state === "resolved");
|
|
508
|
+
const lines = [
|
|
509
|
+
`Endpoint: ${endpoint}`,
|
|
510
|
+
`Open issues: ${open.length}`,
|
|
511
|
+
`Resolved: ${resolved.length}`,
|
|
512
|
+
""
|
|
513
|
+
];
|
|
514
|
+
for (const f of open) {
|
|
515
|
+
lines.push(` [${f.finding.severity}] ${f.finding.title}: ${f.finding.desc}`);
|
|
516
|
+
}
|
|
517
|
+
for (const f of resolved) {
|
|
518
|
+
lines.push(` [resolved] ${f.finding.title}`);
|
|
519
|
+
}
|
|
520
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
521
|
+
}
|
|
522
|
+
return {
|
|
523
|
+
content: [{
|
|
524
|
+
type: "text",
|
|
525
|
+
text: "Please provide either a finding_id or an endpoint to verify."
|
|
526
|
+
}]
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
// src/mcp/tools/get-report.ts
|
|
532
|
+
var getReport = {
|
|
533
|
+
name: "get_report",
|
|
534
|
+
description: "Generate a summary report of all findings: total found, open, resolved. Use this to get a high-level overview of the application's health.",
|
|
535
|
+
inputSchema: {
|
|
536
|
+
type: "object",
|
|
537
|
+
properties: {}
|
|
538
|
+
},
|
|
539
|
+
async handler(client, _args) {
|
|
540
|
+
const [findingsData, securityData, insightsData, metricsData] = await Promise.all([
|
|
541
|
+
client.getFindings(),
|
|
542
|
+
client.getSecurityFindings(),
|
|
543
|
+
client.getInsights(),
|
|
544
|
+
client.getLiveMetrics()
|
|
545
|
+
]);
|
|
546
|
+
const findings = findingsData.findings;
|
|
547
|
+
const open = findings.filter((f) => f.state === "open");
|
|
548
|
+
const resolved = findings.filter((f) => f.state === "resolved");
|
|
549
|
+
const fixing = findings.filter((f) => f.state === "fixing");
|
|
550
|
+
const criticalOpen = open.filter((f) => f.finding.severity === "critical");
|
|
551
|
+
const warningOpen = open.filter((f) => f.finding.severity === "warning");
|
|
552
|
+
const totalRequests = metricsData.endpoints.reduce(
|
|
553
|
+
(s, ep) => s + ep.summary.totalRequests,
|
|
554
|
+
0
|
|
555
|
+
);
|
|
556
|
+
const lines = [
|
|
557
|
+
"=== Brakit Report ===",
|
|
558
|
+
"",
|
|
559
|
+
`Endpoints observed: ${metricsData.endpoints.length}`,
|
|
560
|
+
`Total requests captured: ${totalRequests}`,
|
|
561
|
+
`Active security rules: ${securityData.findings.length} finding(s)`,
|
|
562
|
+
`Performance insights: ${insightsData.insights.length} insight(s)`,
|
|
563
|
+
"",
|
|
564
|
+
"--- Finding Summary ---",
|
|
565
|
+
`Total: ${findings.length}`,
|
|
566
|
+
` Open: ${open.length} (${criticalOpen.length} critical, ${warningOpen.length} warning)`,
|
|
567
|
+
` In progress: ${fixing.length}`,
|
|
568
|
+
` Resolved: ${resolved.length}`
|
|
569
|
+
];
|
|
570
|
+
if (criticalOpen.length > 0) {
|
|
571
|
+
lines.push("");
|
|
572
|
+
lines.push("--- Critical Issues (fix first) ---");
|
|
573
|
+
for (const f of criticalOpen) {
|
|
574
|
+
lines.push(` [CRITICAL] ${f.finding.title} \u2014 ${f.finding.endpoint}`);
|
|
575
|
+
lines.push(` ${f.finding.desc}`);
|
|
576
|
+
lines.push(` Fix: ${f.finding.hint}`);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
if (resolved.length > 0) {
|
|
580
|
+
lines.push("");
|
|
581
|
+
lines.push("--- Recently Resolved ---");
|
|
582
|
+
for (const f of resolved.slice(0, MAX_RESOLVED_DISPLAY)) {
|
|
583
|
+
lines.push(` \u2713 ${f.finding.title} \u2014 ${f.finding.endpoint}`);
|
|
584
|
+
}
|
|
585
|
+
if (resolved.length > MAX_RESOLVED_DISPLAY) {
|
|
586
|
+
lines.push(` ... and ${resolved.length - MAX_RESOLVED_DISPLAY} more`);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
// src/mcp/tools/clear-findings.ts
|
|
594
|
+
var clearFindings = {
|
|
595
|
+
name: "clear_findings",
|
|
596
|
+
description: "Reset finding history for a fresh session. Use this when you want to start tracking findings from scratch.",
|
|
597
|
+
inputSchema: {
|
|
598
|
+
type: "object",
|
|
599
|
+
properties: {}
|
|
600
|
+
},
|
|
601
|
+
async handler(client, _args) {
|
|
602
|
+
const ok = await client.clearAll();
|
|
603
|
+
if (!ok) {
|
|
604
|
+
return {
|
|
605
|
+
content: [{ type: "text", text: "Failed to clear findings. Is the app still running?" }]
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
return {
|
|
609
|
+
content: [{ type: "text", text: "All findings and captured data have been cleared. Start making requests to capture fresh data." }]
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
// src/mcp/tools/index.ts
|
|
615
|
+
var TOOL_MAP = new Map(
|
|
616
|
+
[getFindings, getEndpoints, getRequestDetail, verifyFix, getReport, clearFindings].map((t) => [t.name, t])
|
|
617
|
+
);
|
|
618
|
+
function getToolDefinitions() {
|
|
619
|
+
return [...TOOL_MAP.values()].map((t) => ({
|
|
620
|
+
name: t.name,
|
|
621
|
+
description: t.description,
|
|
622
|
+
inputSchema: t.inputSchema
|
|
623
|
+
}));
|
|
624
|
+
}
|
|
625
|
+
function handleToolCall(client, name, args) {
|
|
626
|
+
const tool = TOOL_MAP.get(name);
|
|
627
|
+
if (!tool) {
|
|
628
|
+
return Promise.resolve({
|
|
629
|
+
content: [{ type: "text", text: `Unknown tool: ${name}` }],
|
|
630
|
+
isError: true
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
return tool.handler(client, args);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// src/mcp/prompts.ts
|
|
637
|
+
var PROMPTS = [
|
|
638
|
+
{
|
|
639
|
+
name: "check-app",
|
|
640
|
+
description: "Check your running app for security vulnerabilities and performance issues"
|
|
641
|
+
},
|
|
642
|
+
{
|
|
643
|
+
name: "fix-findings",
|
|
644
|
+
description: "Find all open brakit findings and fix them one by one"
|
|
645
|
+
}
|
|
646
|
+
];
|
|
647
|
+
var PROMPT_MESSAGES = {
|
|
648
|
+
"check-app": [
|
|
649
|
+
"Check my running app for security and performance issues using brakit.",
|
|
650
|
+
"First get all findings, then get the endpoint summary.",
|
|
651
|
+
"For any critical or warning findings, get the request detail to understand the root cause.",
|
|
652
|
+
"Give me a clear report of what's wrong and offer to fix each issue."
|
|
653
|
+
].join(" "),
|
|
654
|
+
"fix-findings": [
|
|
655
|
+
"Get all open brakit findings.",
|
|
656
|
+
"For each finding, get the request detail to understand the exact issue.",
|
|
657
|
+
"Then find the source code responsible and fix it.",
|
|
658
|
+
"After fixing, ask me to re-trigger the endpoint so you can verify the fix with brakit."
|
|
659
|
+
].join(" ")
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
// src/mcp/server.ts
|
|
663
|
+
async function startMcpServer() {
|
|
664
|
+
let discovery;
|
|
665
|
+
try {
|
|
666
|
+
discovery = await waitForBrakit(void 0, INITIAL_DISCOVERY_TIMEOUT_MS);
|
|
667
|
+
} catch {
|
|
668
|
+
discovery = null;
|
|
669
|
+
}
|
|
670
|
+
let cachedClient = discovery ? new BrakitClient(discovery.baseUrl) : null;
|
|
671
|
+
const server = new Server(
|
|
672
|
+
{ name: MCP_SERVER_NAME, version: MCP_SERVER_VERSION },
|
|
673
|
+
{ capabilities: { tools: {}, prompts: {} } }
|
|
674
|
+
);
|
|
675
|
+
server.setRequestHandler(ListPromptsRequestSchema, async () => ({
|
|
676
|
+
prompts: [...PROMPTS]
|
|
677
|
+
}));
|
|
678
|
+
server.setRequestHandler(GetPromptRequestSchema, async (request) => ({
|
|
679
|
+
description: PROMPTS.find((p) => p.name === request.params.name)?.description,
|
|
680
|
+
messages: [{
|
|
681
|
+
role: "user",
|
|
682
|
+
content: {
|
|
683
|
+
type: "text",
|
|
684
|
+
text: PROMPT_MESSAGES[request.params.name] ?? "Check my app for issues."
|
|
685
|
+
}
|
|
686
|
+
}]
|
|
687
|
+
}));
|
|
688
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
689
|
+
tools: getToolDefinitions()
|
|
690
|
+
}));
|
|
691
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
692
|
+
const { name, arguments: args } = request.params;
|
|
693
|
+
let activeClient = cachedClient;
|
|
694
|
+
if (!activeClient) {
|
|
695
|
+
try {
|
|
696
|
+
const disc = await waitForBrakit(void 0, LAZY_DISCOVERY_TIMEOUT_MS);
|
|
697
|
+
activeClient = new BrakitClient(disc.baseUrl);
|
|
698
|
+
cachedClient = activeClient;
|
|
699
|
+
} catch {
|
|
700
|
+
return {
|
|
701
|
+
content: [{
|
|
702
|
+
type: "text",
|
|
703
|
+
text: "Brakit is not running. Start your app with brakit enabled first."
|
|
704
|
+
}],
|
|
705
|
+
isError: true
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
const alive = await activeClient.isAlive();
|
|
710
|
+
if (!alive) {
|
|
711
|
+
return {
|
|
712
|
+
content: [{
|
|
713
|
+
type: "text",
|
|
714
|
+
text: "Brakit appears to be down. Is your app still running?"
|
|
715
|
+
}],
|
|
716
|
+
isError: true
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
try {
|
|
720
|
+
return await handleToolCall(activeClient, name, args ?? {});
|
|
721
|
+
} catch (err) {
|
|
722
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
723
|
+
return {
|
|
724
|
+
content: [{
|
|
725
|
+
type: "text",
|
|
726
|
+
text: `Error calling ${name}: ${message}`
|
|
727
|
+
}],
|
|
728
|
+
isError: true
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
});
|
|
732
|
+
const transport = new StdioServerTransport();
|
|
733
|
+
await server.connect(transport);
|
|
734
|
+
}
|
|
735
|
+
export {
|
|
736
|
+
startMcpServer
|
|
737
|
+
};
|