browser-debug-mcp-bridge 1.11.0 → 1.12.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 +3 -1
- package/apps/mcp-server/dist/db/automation-repository.js +9 -4
- package/apps/mcp-server/dist/db/automation-repository.js.map +1 -1
- package/apps/mcp-server/dist/db/migrations.js +79 -0
- package/apps/mcp-server/dist/db/migrations.js.map +1 -1
- package/apps/mcp-server/dist/db/schema.js +60 -1
- package/apps/mcp-server/dist/db/schema.js.map +1 -1
- package/apps/mcp-server/dist/mcp/server.js +3455 -357
- package/apps/mcp-server/dist/mcp/server.js.map +1 -1
- package/apps/mcp-server/dist/mcp/target-resolution.js +390 -0
- package/apps/mcp-server/dist/mcp/target-resolution.js.map +1 -0
- package/apps/mcp-server/dist/mcp/tool-loop-guard.js +655 -0
- package/apps/mcp-server/dist/mcp/tool-loop-guard.js.map +1 -0
- package/apps/mcp-server/dist/override-audit.js +3 -3
- package/apps/mcp-server/dist/override-audit.js.map +1 -1
- package/apps/mcp-server/dist/override-capabilities.js +22 -1
- package/apps/mcp-server/dist/override-capabilities.js.map +1 -1
- package/apps/mcp-server/dist/override-poc.js +4 -4
- package/apps/mcp-server/dist/override-poc.js.map +1 -1
- package/apps/mcp-server/dist/override-profile-generator.js +3 -9
- package/apps/mcp-server/dist/override-profile-generator.js.map +1 -1
- package/apps/mcp-server/dist/override-response-planner.js +6 -4
- package/apps/mcp-server/dist/override-response-planner.js.map +1 -1
- package/apps/mcp-server/dist/websocket/messages.js +5 -0
- package/apps/mcp-server/dist/websocket/messages.js.map +1 -1
- package/package.json +8 -3
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'crypto';
|
|
2
|
+
const RECENT_WINDOW_MS = 5 * 60_000;
|
|
3
|
+
const TOOL_BLOCK_MS = 2 * 60_000;
|
|
4
|
+
const FAMILY_BLOCK_MS = 5 * 60_000;
|
|
5
|
+
const DEFAULT_WARN_THRESHOLD = 2;
|
|
6
|
+
const DEFAULT_BLOCK_THRESHOLD = 4;
|
|
7
|
+
const HIGH_RISK_WARN_THRESHOLD = 2;
|
|
8
|
+
const HIGH_RISK_BLOCK_THRESHOLD = 3;
|
|
9
|
+
const FAMILY_BLOCK_THRESHOLD = 4;
|
|
10
|
+
const HIGH_RISK_TOOLS = new Set([
|
|
11
|
+
'enable_overrides',
|
|
12
|
+
'disable_overrides',
|
|
13
|
+
'observe_override_assets',
|
|
14
|
+
'capture_override_response_body',
|
|
15
|
+
'plan_override_response_patch',
|
|
16
|
+
'plan_next_source_override',
|
|
17
|
+
'map_next_override_assets',
|
|
18
|
+
'execute_ui_action',
|
|
19
|
+
'run_ui_steps',
|
|
20
|
+
]);
|
|
21
|
+
const FAMILY_BLOCKED_TOOLS = new Set([
|
|
22
|
+
'enable_overrides',
|
|
23
|
+
'observe_override_assets',
|
|
24
|
+
'capture_override_response_body',
|
|
25
|
+
'plan_override_response_patch',
|
|
26
|
+
'plan_next_source_override',
|
|
27
|
+
'map_next_override_assets',
|
|
28
|
+
]);
|
|
29
|
+
const LOOP_PRONE_NEXT_ACTIONS = new Set([
|
|
30
|
+
'DIAGNOSE_OVERRIDES',
|
|
31
|
+
'ENABLE_OVERRIDES',
|
|
32
|
+
'GET_OVERRIDE_STATUS',
|
|
33
|
+
'LOAD_ROUTE',
|
|
34
|
+
'OBSERVE_ASSETS',
|
|
35
|
+
'OBSERVE_OVERRIDE_ASSETS',
|
|
36
|
+
'OBSERVE_TARGET_ROUTE',
|
|
37
|
+
'PLAN_OVERRIDE',
|
|
38
|
+
'PLAN_RESPONSE_PATCH',
|
|
39
|
+
'RECONNECT_OR_RETRY_DISABLE',
|
|
40
|
+
'RECONNECT_OR_RETRY_OVERRIDE_STATUS',
|
|
41
|
+
'RECONNECT_SESSION',
|
|
42
|
+
'RELOAD_OR_INTERACT',
|
|
43
|
+
'RELOAD_TAB',
|
|
44
|
+
'VERIFY_DISABLED',
|
|
45
|
+
'WRITE_CONFIG',
|
|
46
|
+
'WRITE_OVERRIDE_CONFIG',
|
|
47
|
+
'WRITE_RESPONSE_BODY',
|
|
48
|
+
]);
|
|
49
|
+
function hashText(value) {
|
|
50
|
+
return createHash('sha256').update(value).digest('hex');
|
|
51
|
+
}
|
|
52
|
+
function safeStringify(value) {
|
|
53
|
+
return JSON.stringify(value, (_key, nestedValue) => {
|
|
54
|
+
if (typeof nestedValue === 'bigint') {
|
|
55
|
+
return String(nestedValue);
|
|
56
|
+
}
|
|
57
|
+
return nestedValue;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
function normalizeForHash(value) {
|
|
61
|
+
if (Array.isArray(value)) {
|
|
62
|
+
return value.map(normalizeForHash);
|
|
63
|
+
}
|
|
64
|
+
if (!value || typeof value !== 'object') {
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
const record = value;
|
|
68
|
+
return Object.fromEntries(Object.keys(record)
|
|
69
|
+
.sort()
|
|
70
|
+
.map((key) => [key, normalizeForHash(record[key])]));
|
|
71
|
+
}
|
|
72
|
+
function isSensitiveKey(key) {
|
|
73
|
+
const normalized = key.toLowerCase();
|
|
74
|
+
return normalized.includes('authorization')
|
|
75
|
+
|| normalized.includes('cookie')
|
|
76
|
+
|| normalized.includes('password')
|
|
77
|
+
|| normalized.includes('secret')
|
|
78
|
+
|| normalized.includes('token');
|
|
79
|
+
}
|
|
80
|
+
function summarizeInput(value, depth = 0) {
|
|
81
|
+
if (depth > 4) {
|
|
82
|
+
return '[depth-limit]';
|
|
83
|
+
}
|
|
84
|
+
if (typeof value === 'string') {
|
|
85
|
+
return value.length > 240 ? `${value.slice(0, 240)}...[truncated ${value.length}]` : value;
|
|
86
|
+
}
|
|
87
|
+
if (typeof value === 'number' || typeof value === 'boolean' || value === null || value === undefined) {
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
if (Array.isArray(value)) {
|
|
91
|
+
return value.slice(0, 20).map((entry) => summarizeInput(entry, depth + 1));
|
|
92
|
+
}
|
|
93
|
+
if (typeof value === 'object') {
|
|
94
|
+
const result = {};
|
|
95
|
+
for (const [key, nestedValue] of Object.entries(value).slice(0, 40)) {
|
|
96
|
+
result[key] = isSensitiveKey(key) ? '[redacted]' : summarizeInput(nestedValue, depth + 1);
|
|
97
|
+
}
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
return String(value);
|
|
101
|
+
}
|
|
102
|
+
function getSessionId(input) {
|
|
103
|
+
return typeof input.sessionId === 'string' ? input.sessionId : undefined;
|
|
104
|
+
}
|
|
105
|
+
function getToolFamily(toolName) {
|
|
106
|
+
if (toolName.includes('override')) {
|
|
107
|
+
return 'override';
|
|
108
|
+
}
|
|
109
|
+
if (toolName.includes('automation') || toolName.includes('ui_') || toolName.includes('page_state')) {
|
|
110
|
+
return 'automation';
|
|
111
|
+
}
|
|
112
|
+
return 'general';
|
|
113
|
+
}
|
|
114
|
+
function getRecentThresholds(toolName) {
|
|
115
|
+
if (HIGH_RISK_TOOLS.has(toolName)) {
|
|
116
|
+
return { warn: HIGH_RISK_WARN_THRESHOLD, block: HIGH_RISK_BLOCK_THRESHOLD };
|
|
117
|
+
}
|
|
118
|
+
return { warn: DEFAULT_WARN_THRESHOLD, block: DEFAULT_BLOCK_THRESHOLD };
|
|
119
|
+
}
|
|
120
|
+
function isRecord(value) {
|
|
121
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
122
|
+
}
|
|
123
|
+
function collectIssueCodes(value, codes = []) {
|
|
124
|
+
if (Array.isArray(value)) {
|
|
125
|
+
for (const entry of value) {
|
|
126
|
+
collectIssueCodes(entry, codes);
|
|
127
|
+
}
|
|
128
|
+
return codes;
|
|
129
|
+
}
|
|
130
|
+
if (!isRecord(value)) {
|
|
131
|
+
return codes;
|
|
132
|
+
}
|
|
133
|
+
if (typeof value.code === 'string' && value.code.trim().length > 0) {
|
|
134
|
+
codes.push(value.code.trim());
|
|
135
|
+
}
|
|
136
|
+
for (const nestedValue of Object.values(value)) {
|
|
137
|
+
collectIssueCodes(nestedValue, codes);
|
|
138
|
+
}
|
|
139
|
+
return codes;
|
|
140
|
+
}
|
|
141
|
+
function collectNextActionCodes(response) {
|
|
142
|
+
const actions = Array.isArray(response.nextActions) ? response.nextActions : [];
|
|
143
|
+
return actions
|
|
144
|
+
.map((action) => isRecord(action) && typeof action.code === 'string' ? action.code : undefined)
|
|
145
|
+
.filter((code) => typeof code === 'string' && code.length > 0);
|
|
146
|
+
}
|
|
147
|
+
function extractRootCauseFromError(error) {
|
|
148
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
149
|
+
const explicit = message.match(/\b([A-Z][A-Z0-9_]{2,})\b/u)?.[1];
|
|
150
|
+
if (explicit) {
|
|
151
|
+
return explicit;
|
|
152
|
+
}
|
|
153
|
+
if (/timed out|timeout/i.test(message)) {
|
|
154
|
+
return 'TOOL_TIMEOUT';
|
|
155
|
+
}
|
|
156
|
+
return 'TOOL_ERROR';
|
|
157
|
+
}
|
|
158
|
+
function extractRootCauseFromResponse(response) {
|
|
159
|
+
const directCandidates = [
|
|
160
|
+
response.code,
|
|
161
|
+
response.failureCode,
|
|
162
|
+
response.lastErrorCode,
|
|
163
|
+
isRecord(response.write) ? response.write.failureCode : undefined,
|
|
164
|
+
isRecord(response.liveStatus) ? response.liveStatus.code : undefined,
|
|
165
|
+
isRecord(response.disableAttempt) ? response.disableAttempt.code : undefined,
|
|
166
|
+
isRecord(response.latestRun) ? response.latestRun.lastErrorCode : undefined,
|
|
167
|
+
];
|
|
168
|
+
for (const candidate of directCandidates) {
|
|
169
|
+
if (typeof candidate === 'string' && candidate.length > 0) {
|
|
170
|
+
return candidate;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const codes = collectIssueCodes(response);
|
|
174
|
+
return codes[0];
|
|
175
|
+
}
|
|
176
|
+
function isBadResponse(response) {
|
|
177
|
+
if (response.blocked === true) {
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
if (response.valid === false || response.ready === false || response.ok === false) {
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
if (Array.isArray(response.issues) && response.issues.length > 0) {
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
if (Array.isArray(response.errors) && response.errors.length > 0) {
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
if (Array.isArray(response.blockers) && response.blockers.length > 0) {
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
if (isRecord(response.write) && typeof response.write.failureCode === 'string') {
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
if (isRecord(response.liveStatus) && (response.liveStatus.ok === false || response.liveStatus.available === false)) {
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
if (isRecord(response.disableAttempt) && response.disableAttempt.ok === false) {
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
if (isRecord(response.latestRun) && typeof response.latestRun.lastErrorCode === 'string') {
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
if (isRecord(response.preflight) && response.preflight.ready === false) {
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
if (isRecord(response.diagnosis) && Array.isArray(response.diagnosis.issues) && response.diagnosis.issues.length > 0) {
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
if (isRecord(response.diagnosis) && Array.isArray(response.diagnosis.blockers) && response.diagnosis.blockers.length > 0) {
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
if (response.bodyCaptured === false) {
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
function classifyResponse(response) {
|
|
219
|
+
const rootCauseCode = extractRootCauseFromResponse(response);
|
|
220
|
+
const nextActionCodes = collectNextActionCodes(response);
|
|
221
|
+
const issueCodes = collectIssueCodes(response);
|
|
222
|
+
const badResponse = isBadResponse(response);
|
|
223
|
+
const loopProneNoProgress = !badResponse
|
|
224
|
+
&& nextActionCodes.length > 0
|
|
225
|
+
&& nextActionCodes.every((code) => LOOP_PRONE_NEXT_ACTIONS.has(code));
|
|
226
|
+
const stateSummary = {
|
|
227
|
+
valid: response.valid,
|
|
228
|
+
ready: response.ready,
|
|
229
|
+
preflightReady: isRecord(response.preflight) ? response.preflight.ready : undefined,
|
|
230
|
+
ok: response.ok,
|
|
231
|
+
active: response.active,
|
|
232
|
+
statusSource: response.statusSource,
|
|
233
|
+
rootCauseCode,
|
|
234
|
+
issueCodes: issueCodes.slice(0, 8),
|
|
235
|
+
nextActionCodes: nextActionCodes.slice(0, 8),
|
|
236
|
+
latestRunError: isRecord(response.latestRun) ? response.latestRun.lastErrorCode : undefined,
|
|
237
|
+
writeFailure: isRecord(response.write) ? response.write.failureCode : undefined,
|
|
238
|
+
bodyCaptured: response.bodyCaptured,
|
|
239
|
+
};
|
|
240
|
+
const outcomeType = badResponse
|
|
241
|
+
? 'failed'
|
|
242
|
+
: loopProneNoProgress
|
|
243
|
+
? 'no_progress'
|
|
244
|
+
: 'success';
|
|
245
|
+
return {
|
|
246
|
+
outcomeType,
|
|
247
|
+
rootCauseCode: rootCauseCode ?? (loopProneNoProgress ? nextActionCodes[0] : undefined),
|
|
248
|
+
stateHash: hashText(safeStringify(normalizeForHash(stateSummary))),
|
|
249
|
+
stateSummary,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
function classifyError(error) {
|
|
253
|
+
const rootCauseCode = extractRootCauseFromError(error);
|
|
254
|
+
const stateSummary = {
|
|
255
|
+
rootCauseCode,
|
|
256
|
+
message: error instanceof Error ? error.message.slice(0, 500) : String(error).slice(0, 500),
|
|
257
|
+
};
|
|
258
|
+
return {
|
|
259
|
+
outcomeType: 'failed',
|
|
260
|
+
rootCauseCode,
|
|
261
|
+
stateHash: hashText(safeStringify(normalizeForHash(stateSummary))),
|
|
262
|
+
stateSummary,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
function requiredStateChangeFor(rootCauseCode) {
|
|
266
|
+
switch (rootCauseCode) {
|
|
267
|
+
case 'CONFIG_DISABLED':
|
|
268
|
+
return ['override config/profile enabled state changes', 'override profile is regenerated or selected'];
|
|
269
|
+
case 'PROFILE_DISABLED':
|
|
270
|
+
case 'NO_ENABLED_RULES':
|
|
271
|
+
return ['override profile enabled rules change'];
|
|
272
|
+
case 'LIVE_SESSION_DISCONNECTED':
|
|
273
|
+
return ['live extension connection becomes connected', 'sessionId changes'];
|
|
274
|
+
case 'NO_OBSERVED_ASSETS':
|
|
275
|
+
case 'TARGET_ASSET_NOT_OBSERVED':
|
|
276
|
+
case 'TARGET_ASSET_NOT_OBSERVED_FOR_RULE':
|
|
277
|
+
return ['target route is loaded or interacted with', 'observed asset inventory changes'];
|
|
278
|
+
case 'SESSION_SCOPE_DRIFT':
|
|
279
|
+
return ['bound tab selection changes', 'observed asset tab scope changes'];
|
|
280
|
+
case 'LOCAL_FILE_MISSING':
|
|
281
|
+
return ['local override file exists', 'override profile localFilePath changes'];
|
|
282
|
+
case 'OVERRIDE_LIVE_COMMAND_TIMEOUT':
|
|
283
|
+
case 'TOOL_TIMEOUT':
|
|
284
|
+
return ['live extension returns a successful command result', 'session/tab state changes'];
|
|
285
|
+
default:
|
|
286
|
+
return ['tool input changes', 'session/page/override state changes'];
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
function buildDecision(options) {
|
|
290
|
+
const rootCause = options.rootCauseCode ?? 'NO_PROGRESS';
|
|
291
|
+
const message = options.severity === 'warning'
|
|
292
|
+
? `Repeated ${options.toolName} attempts are returning the same ${rootCause} result. Change state before continuing.`
|
|
293
|
+
: `Blocked repeated ${options.toolName} attempts with unchanged ${rootCause} result before spending another tool call.`;
|
|
294
|
+
return {
|
|
295
|
+
status: options.severity,
|
|
296
|
+
reason: options.reason,
|
|
297
|
+
scope: options.scope,
|
|
298
|
+
attemptCount: options.attemptCount,
|
|
299
|
+
recentWindowMs: RECENT_WINDOW_MS,
|
|
300
|
+
rootCauseCode: options.rootCauseCode,
|
|
301
|
+
blockUntil: options.blockUntil,
|
|
302
|
+
message,
|
|
303
|
+
requiredStateChange: requiredStateChangeFor(options.rootCauseCode),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
function buildBlockedResponse(call, decision) {
|
|
307
|
+
return {
|
|
308
|
+
sessionId: call.sessionId,
|
|
309
|
+
limitsApplied: {
|
|
310
|
+
maxResults: 0,
|
|
311
|
+
truncated: false,
|
|
312
|
+
},
|
|
313
|
+
redactionSummary: {
|
|
314
|
+
totalFields: 0,
|
|
315
|
+
redactedFields: 0,
|
|
316
|
+
rulesApplied: [],
|
|
317
|
+
},
|
|
318
|
+
blocked: true,
|
|
319
|
+
tool: call.toolName,
|
|
320
|
+
loopGuard: decision,
|
|
321
|
+
nextActions: [{
|
|
322
|
+
code: 'CHANGE_STATE_BEFORE_RETRY',
|
|
323
|
+
message: decision.message,
|
|
324
|
+
requiredStateChange: decision.requiredStateChange,
|
|
325
|
+
retryAfterMs: decision.blockUntil ? Math.max(0, decision.blockUntil - Date.now()) : undefined,
|
|
326
|
+
}],
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
function getOpenIncident(db, call, now) {
|
|
330
|
+
const sessionKey = call.sessionId ?? '';
|
|
331
|
+
const rows = db.prepare(`
|
|
332
|
+
SELECT fingerprint, scope, attempt_count, root_cause_code, blocked_until
|
|
333
|
+
FROM mcp_loop_incidents
|
|
334
|
+
WHERE status = 'open'
|
|
335
|
+
AND blocked_until IS NOT NULL
|
|
336
|
+
AND blocked_until > ?
|
|
337
|
+
AND (
|
|
338
|
+
(scope = 'tool-input' AND tool_name = ? AND input_hash = ? AND COALESCE(session_id, '') = ?)
|
|
339
|
+
OR
|
|
340
|
+
(scope = 'family-root-cause' AND family = ? AND COALESCE(session_id, '') = ?)
|
|
341
|
+
)
|
|
342
|
+
ORDER BY blocked_until DESC
|
|
343
|
+
LIMIT 5
|
|
344
|
+
`).all(now, call.toolName, call.inputHash, sessionKey, call.family, sessionKey);
|
|
345
|
+
const toolInputIncident = rows.find((row) => row.scope === 'tool-input');
|
|
346
|
+
if (toolInputIncident) {
|
|
347
|
+
return toolInputIncident;
|
|
348
|
+
}
|
|
349
|
+
return FAMILY_BLOCKED_TOOLS.has(call.toolName)
|
|
350
|
+
? rows.find((row) => row.scope === 'family-root-cause')
|
|
351
|
+
: undefined;
|
|
352
|
+
}
|
|
353
|
+
function countRecentExactFailures(db, call, outcome, now) {
|
|
354
|
+
const row = db.prepare(`
|
|
355
|
+
SELECT COUNT(*) AS count
|
|
356
|
+
FROM mcp_tool_invocations
|
|
357
|
+
WHERE tool_name = ?
|
|
358
|
+
AND input_hash = ?
|
|
359
|
+
AND COALESCE(session_id, '') = ?
|
|
360
|
+
AND outcome_type IN ('failed', 'no_progress')
|
|
361
|
+
AND COALESCE(root_cause_code, '') = ?
|
|
362
|
+
AND COALESCE(state_hash, '') = ?
|
|
363
|
+
AND created_at >= ?
|
|
364
|
+
`).get(call.toolName, call.inputHash, call.sessionId ?? '', outcome.rootCauseCode ?? '', outcome.stateHash ?? '', now - RECENT_WINDOW_MS);
|
|
365
|
+
return row.count + 1;
|
|
366
|
+
}
|
|
367
|
+
function countRecentFamilyFailures(db, call, outcome, now) {
|
|
368
|
+
const row = db.prepare(`
|
|
369
|
+
SELECT COUNT(*) AS count
|
|
370
|
+
FROM mcp_tool_invocations
|
|
371
|
+
WHERE family = ?
|
|
372
|
+
AND COALESCE(session_id, '') = ?
|
|
373
|
+
AND outcome_type IN ('failed', 'no_progress')
|
|
374
|
+
AND COALESCE(root_cause_code, '') = ?
|
|
375
|
+
AND COALESCE(state_hash, '') = ?
|
|
376
|
+
AND created_at >= ?
|
|
377
|
+
`).get(call.family, call.sessionId ?? '', outcome.rootCauseCode ?? '', outcome.stateHash ?? '', now - RECENT_WINDOW_MS);
|
|
378
|
+
return row.count + 1;
|
|
379
|
+
}
|
|
380
|
+
function insertInvocation(db, options) {
|
|
381
|
+
db.prepare(`
|
|
382
|
+
INSERT INTO mcp_tool_invocations (
|
|
383
|
+
invocation_id, tool_name, session_id, family, input_hash, input_summary_json,
|
|
384
|
+
outcome_type, root_cause_code, state_hash, state_summary_json, response_bytes,
|
|
385
|
+
duration_ms, blocked, warning, message, created_at
|
|
386
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
387
|
+
`).run(randomUUID(), options.call.toolName, options.call.sessionId ?? null, options.call.family, options.call.inputHash, safeStringify(options.call.inputSummary), options.outcome.outcomeType, options.outcome.rootCauseCode ?? null, options.outcome.stateHash ?? null, safeStringify(options.outcome.stateSummary), options.responseBytes ?? null, options.durationMs, options.blocked ? 1 : 0, options.warning ? 1 : 0, options.message ?? null, options.now);
|
|
388
|
+
}
|
|
389
|
+
function upsertIncident(db, options) {
|
|
390
|
+
const fingerprint = hashText(safeStringify([
|
|
391
|
+
options.scope,
|
|
392
|
+
options.scope === 'tool-input' ? options.call.toolName : options.call.family,
|
|
393
|
+
options.call.sessionId ?? '',
|
|
394
|
+
options.scope === 'tool-input' ? options.call.inputHash : '',
|
|
395
|
+
options.outcome.rootCauseCode ?? '',
|
|
396
|
+
options.outcome.stateHash ?? '',
|
|
397
|
+
]));
|
|
398
|
+
const existing = db.prepare(`
|
|
399
|
+
SELECT incident_id
|
|
400
|
+
FROM mcp_loop_incidents
|
|
401
|
+
WHERE fingerprint = ? AND status = 'open'
|
|
402
|
+
LIMIT 1
|
|
403
|
+
`).get(fingerprint);
|
|
404
|
+
if (existing) {
|
|
405
|
+
db.prepare(`
|
|
406
|
+
UPDATE mcp_loop_incidents
|
|
407
|
+
SET last_seen_at = ?,
|
|
408
|
+
attempt_count = ?,
|
|
409
|
+
severity = ?,
|
|
410
|
+
blocked_until = COALESCE(?, blocked_until),
|
|
411
|
+
message = ?,
|
|
412
|
+
updated_at = ?
|
|
413
|
+
WHERE incident_id = ?
|
|
414
|
+
`).run(options.now, options.attemptCount, options.severity, options.blockUntil ?? null, options.message, options.now, existing.incident_id);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
db.prepare(`
|
|
418
|
+
INSERT INTO mcp_loop_incidents (
|
|
419
|
+
incident_id, fingerprint, scope, status, tool_name, session_id, family, input_hash,
|
|
420
|
+
root_cause_code, state_hash, first_seen_at, last_seen_at, attempt_count,
|
|
421
|
+
blocked_until, severity, message, created_at, updated_at
|
|
422
|
+
) VALUES (?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
423
|
+
`).run(randomUUID(), fingerprint, options.scope, options.scope === 'tool-input' ? options.call.toolName : null, options.call.sessionId ?? null, options.call.family, options.scope === 'tool-input' ? options.call.inputHash : null, options.outcome.rootCauseCode ?? null, options.outcome.stateHash ?? null, options.now, options.now, options.attemptCount, options.blockUntil ?? null, options.severity, options.message, options.now, options.now);
|
|
424
|
+
}
|
|
425
|
+
function resolveOpenIncidentsForSuccess(db, call, now) {
|
|
426
|
+
db.prepare(`
|
|
427
|
+
UPDATE mcp_loop_incidents
|
|
428
|
+
SET status = 'resolved', updated_at = ?
|
|
429
|
+
WHERE status = 'open'
|
|
430
|
+
AND (
|
|
431
|
+
(scope = 'tool-input' AND tool_name = ? AND input_hash = ? AND COALESCE(session_id, '') = ?)
|
|
432
|
+
OR
|
|
433
|
+
(scope = 'family-root-cause' AND family = ? AND COALESCE(session_id, '') = ?)
|
|
434
|
+
)
|
|
435
|
+
`).run(now, call.toolName, call.inputHash, call.sessionId ?? '', call.family, call.sessionId ?? '');
|
|
436
|
+
}
|
|
437
|
+
function responseBytes(response) {
|
|
438
|
+
return typeof response.responseBytes === 'number' && Number.isFinite(response.responseBytes)
|
|
439
|
+
? Math.floor(response.responseBytes)
|
|
440
|
+
: undefined;
|
|
441
|
+
}
|
|
442
|
+
function emit(options, event) {
|
|
443
|
+
try {
|
|
444
|
+
options.onEvent?.(event);
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
// Loop-guard notifications must never break tool execution.
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
function withGuardWarning(response, decision) {
|
|
451
|
+
return {
|
|
452
|
+
...response,
|
|
453
|
+
loopGuard: decision,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
export function createToolLoopGuard(options) {
|
|
457
|
+
const enabled = options.enabled !== false && process.env.MCP_LOOP_GUARD !== '0';
|
|
458
|
+
const safeDb = () => {
|
|
459
|
+
if (!enabled) {
|
|
460
|
+
return undefined;
|
|
461
|
+
}
|
|
462
|
+
try {
|
|
463
|
+
return options.getDb();
|
|
464
|
+
}
|
|
465
|
+
catch (error) {
|
|
466
|
+
emit(options, {
|
|
467
|
+
event: 'agent_loop_guard_error',
|
|
468
|
+
toolName: 'unknown',
|
|
469
|
+
message: error instanceof Error ? error.message : String(error),
|
|
470
|
+
});
|
|
471
|
+
return undefined;
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
return {
|
|
475
|
+
prepareCall: (toolName, input) => {
|
|
476
|
+
const normalizedInput = normalizeForHash(input);
|
|
477
|
+
return {
|
|
478
|
+
toolName,
|
|
479
|
+
input,
|
|
480
|
+
sessionId: getSessionId(input),
|
|
481
|
+
family: getToolFamily(toolName),
|
|
482
|
+
inputHash: hashText(safeStringify(normalizedInput)),
|
|
483
|
+
inputSummary: summarizeInput(input),
|
|
484
|
+
startedAt: Date.now(),
|
|
485
|
+
};
|
|
486
|
+
},
|
|
487
|
+
beforeCall: async (call) => {
|
|
488
|
+
const db = safeDb();
|
|
489
|
+
if (!db) {
|
|
490
|
+
return { blocked: false };
|
|
491
|
+
}
|
|
492
|
+
try {
|
|
493
|
+
const now = Date.now();
|
|
494
|
+
const incident = getOpenIncident(db, call, now);
|
|
495
|
+
if (!incident) {
|
|
496
|
+
return { blocked: false };
|
|
497
|
+
}
|
|
498
|
+
const decision = buildDecision({
|
|
499
|
+
severity: 'blocked',
|
|
500
|
+
reason: incident.scope === 'family-root-cause' ? 'repeated_family_failure' : 'repeated_same_failure',
|
|
501
|
+
scope: incident.scope,
|
|
502
|
+
attemptCount: incident.attempt_count,
|
|
503
|
+
rootCauseCode: incident.root_cause_code ?? undefined,
|
|
504
|
+
blockUntil: incident.blocked_until ?? undefined,
|
|
505
|
+
toolName: call.toolName,
|
|
506
|
+
});
|
|
507
|
+
insertInvocation(db, {
|
|
508
|
+
call,
|
|
509
|
+
outcome: {
|
|
510
|
+
outcomeType: 'blocked',
|
|
511
|
+
rootCauseCode: incident.root_cause_code ?? undefined,
|
|
512
|
+
stateHash: undefined,
|
|
513
|
+
stateSummary: { blockedByIncident: incident.fingerprint },
|
|
514
|
+
},
|
|
515
|
+
durationMs: 0,
|
|
516
|
+
blocked: true,
|
|
517
|
+
warning: false,
|
|
518
|
+
message: decision.message,
|
|
519
|
+
now,
|
|
520
|
+
});
|
|
521
|
+
emit(options, {
|
|
522
|
+
event: 'agent_loop_guard_blocked',
|
|
523
|
+
toolName: call.toolName,
|
|
524
|
+
sessionId: call.sessionId,
|
|
525
|
+
reason: decision.reason,
|
|
526
|
+
rootCauseCode: decision.rootCauseCode,
|
|
527
|
+
attemptCount: decision.attemptCount,
|
|
528
|
+
message: decision.message,
|
|
529
|
+
});
|
|
530
|
+
return {
|
|
531
|
+
blocked: true,
|
|
532
|
+
response: buildBlockedResponse(call, decision),
|
|
533
|
+
decision,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
catch (error) {
|
|
537
|
+
emit(options, {
|
|
538
|
+
event: 'agent_loop_guard_error',
|
|
539
|
+
toolName: call.toolName,
|
|
540
|
+
sessionId: call.sessionId,
|
|
541
|
+
message: error instanceof Error ? error.message : String(error),
|
|
542
|
+
});
|
|
543
|
+
return { blocked: false };
|
|
544
|
+
}
|
|
545
|
+
},
|
|
546
|
+
afterCall: async (call, result) => {
|
|
547
|
+
const db = safeDb();
|
|
548
|
+
if (!db) {
|
|
549
|
+
return { response: result.response };
|
|
550
|
+
}
|
|
551
|
+
try {
|
|
552
|
+
const now = Date.now();
|
|
553
|
+
const outcome = result.error ? classifyError(result.error) : classifyResponse(result.response ?? {});
|
|
554
|
+
if (outcome.outcomeType === 'success') {
|
|
555
|
+
resolveOpenIncidentsForSuccess(db, call, now);
|
|
556
|
+
insertInvocation(db, {
|
|
557
|
+
call,
|
|
558
|
+
outcome,
|
|
559
|
+
durationMs: result.durationMs,
|
|
560
|
+
responseBytes: result.response ? responseBytes(result.response) : undefined,
|
|
561
|
+
blocked: false,
|
|
562
|
+
warning: false,
|
|
563
|
+
now,
|
|
564
|
+
});
|
|
565
|
+
return { response: result.response };
|
|
566
|
+
}
|
|
567
|
+
const exactCount = countRecentExactFailures(db, call, outcome, now);
|
|
568
|
+
const familyCount = countRecentFamilyFailures(db, call, outcome, now);
|
|
569
|
+
const thresholds = getRecentThresholds(call.toolName);
|
|
570
|
+
const shouldBlockExact = exactCount >= thresholds.block;
|
|
571
|
+
const shouldWarnExact = exactCount >= thresholds.warn;
|
|
572
|
+
const shouldBlockFamily = call.family !== 'general' && familyCount >= FAMILY_BLOCK_THRESHOLD;
|
|
573
|
+
const blockUntil = now + (shouldBlockFamily ? FAMILY_BLOCK_MS : TOOL_BLOCK_MS);
|
|
574
|
+
const decision = shouldBlockExact || shouldBlockFamily
|
|
575
|
+
? buildDecision({
|
|
576
|
+
severity: 'blocked_next_attempt',
|
|
577
|
+
reason: shouldBlockFamily ? 'repeated_family_failure' : 'repeated_same_failure',
|
|
578
|
+
scope: shouldBlockFamily ? 'family-root-cause' : 'tool-input',
|
|
579
|
+
attemptCount: shouldBlockFamily ? familyCount : exactCount,
|
|
580
|
+
rootCauseCode: outcome.rootCauseCode,
|
|
581
|
+
blockUntil,
|
|
582
|
+
toolName: call.toolName,
|
|
583
|
+
})
|
|
584
|
+
: shouldWarnExact
|
|
585
|
+
? buildDecision({
|
|
586
|
+
severity: 'warning',
|
|
587
|
+
reason: 'repeated_same_failure',
|
|
588
|
+
scope: 'tool-input',
|
|
589
|
+
attemptCount: exactCount,
|
|
590
|
+
rootCauseCode: outcome.rootCauseCode,
|
|
591
|
+
toolName: call.toolName,
|
|
592
|
+
})
|
|
593
|
+
: undefined;
|
|
594
|
+
insertInvocation(db, {
|
|
595
|
+
call,
|
|
596
|
+
outcome,
|
|
597
|
+
durationMs: result.durationMs,
|
|
598
|
+
responseBytes: result.response ? responseBytes(result.response) : undefined,
|
|
599
|
+
blocked: false,
|
|
600
|
+
warning: decision !== undefined,
|
|
601
|
+
message: decision?.message,
|
|
602
|
+
now,
|
|
603
|
+
});
|
|
604
|
+
if (decision?.status === 'blocked_next_attempt') {
|
|
605
|
+
upsertIncident(db, {
|
|
606
|
+
call,
|
|
607
|
+
outcome,
|
|
608
|
+
scope: decision.scope,
|
|
609
|
+
attemptCount: decision.attemptCount,
|
|
610
|
+
severity: 'blocked',
|
|
611
|
+
message: decision.message,
|
|
612
|
+
blockUntil,
|
|
613
|
+
now,
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
else if (decision?.status === 'warning') {
|
|
617
|
+
upsertIncident(db, {
|
|
618
|
+
call,
|
|
619
|
+
outcome,
|
|
620
|
+
scope: 'tool-input',
|
|
621
|
+
attemptCount: exactCount,
|
|
622
|
+
severity: 'warning',
|
|
623
|
+
message: decision.message,
|
|
624
|
+
now,
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
if (decision) {
|
|
628
|
+
emit(options, {
|
|
629
|
+
event: decision.status === 'warning' ? 'agent_loop_guard_warning' : 'agent_loop_guard_blocked',
|
|
630
|
+
toolName: call.toolName,
|
|
631
|
+
sessionId: call.sessionId,
|
|
632
|
+
reason: decision.reason,
|
|
633
|
+
rootCauseCode: decision.rootCauseCode,
|
|
634
|
+
attemptCount: decision.attemptCount,
|
|
635
|
+
message: decision.message,
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
return {
|
|
639
|
+
response: result.response && decision ? withGuardWarning(result.response, decision) : result.response,
|
|
640
|
+
decision,
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
catch (error) {
|
|
644
|
+
emit(options, {
|
|
645
|
+
event: 'agent_loop_guard_error',
|
|
646
|
+
toolName: call.toolName,
|
|
647
|
+
sessionId: call.sessionId,
|
|
648
|
+
message: error instanceof Error ? error.message : String(error),
|
|
649
|
+
});
|
|
650
|
+
return { response: result.response };
|
|
651
|
+
}
|
|
652
|
+
},
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
//# sourceMappingURL=tool-loop-guard.js.map
|