@yusufffararatt/dombridge-mcp 2.7.5
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 +559 -0
- package/bin/cli.js +88 -0
- package/package.json +54 -0
- package/src/bridge/http-server.js +290 -0
- package/src/bridge/middleware.js +56 -0
- package/src/bridge/routes.js +1003 -0
- package/src/bridge-daemon.js +172 -0
- package/src/cli/auto-config.js +120 -0
- package/src/constants.js +13 -0
- package/src/index.js +279 -0
- package/src/mcp-bridge.js +136 -0
- package/src/metrics/error-codes.js +44 -0
- package/src/metrics/index.js +3 -0
- package/src/metrics/metrics-db.js +269 -0
- package/src/metrics/metrics-recorder.js +240 -0
- package/src/metrics/metrics-report.js +146 -0
- package/src/profiles/profile-db.js +159 -0
- package/src/profiles/profile-enricher.js +333 -0
- package/src/profiles/profile-manager.js +563 -0
- package/src/profiles/profile-repo.js +183 -0
- package/src/state/bridge-client.js +272 -0
- package/src/state/bridge-persistence.js +205 -0
- package/src/state/cache.js +38 -0
- package/src/state/extension-state.js +321 -0
- package/src/tools/action_tools.js +218 -0
- package/src/tools/analyze-page.js +247 -0
- package/src/tools/debug-mcp-state.js +172 -0
- package/src/tools/discover-apis.js +186 -0
- package/src/tools/execute-js.js +284 -0
- package/src/tools/export-session.js +171 -0
- package/src/tools/extract-data.js +395 -0
- package/src/tools/get-element.js +281 -0
- package/src/tools/get-network-trace.js +471 -0
- package/src/tools/index.js +110 -0
- package/src/tools/manage-site-profile.js +153 -0
- package/src/tools/paginate.js +444 -0
- package/src/tools/quick-scan.js +418 -0
- package/src/tools/screenshot_tools.js +117 -0
- package/src/utils/circuit-breaker.js +112 -0
- package/src/utils/extract-density.js +21 -0
- package/src/utils/logger.js +31 -0
- package/src/utils/paginate-detector.js +24 -0
- package/src/utils/rate-limiter.js +244 -0
- package/src/utils/run-script.js +37 -0
- package/src/utils/selector-validator.js +95 -0
- package/src/utils/state-validator.js +354 -0
- package/src/utils/tab-resolver.js +70 -0
- package/src/utils/workflow-helper.js +292 -0
- package/src/utils/workflow-state.js +177 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State Validator
|
|
3
|
+
* Validates extension state for freshness, completeness, and consistency.
|
|
4
|
+
*
|
|
5
|
+
* Phase 2.4: Accepts either extensionData (in-process) or bridgeClient (thin client).
|
|
6
|
+
* Both provide the same property getters, so internal logic is unchanged.
|
|
7
|
+
*
|
|
8
|
+
* Uses workflow-state.js for per-tab timestamps where available.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { getTabState } from './workflow-state.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Default max age for data freshness (15 minutes)
|
|
15
|
+
*/
|
|
16
|
+
const DEFAULT_MAX_AGE = 15 * 60 * 1000;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* State Validator Class
|
|
20
|
+
*/
|
|
21
|
+
export class StateValidator {
|
|
22
|
+
/**
|
|
23
|
+
* Validate selected element state
|
|
24
|
+
* @param {object} state - Extension state (extensionData or bridgeClient)
|
|
25
|
+
* @param {number} maxAge - Maximum age in ms
|
|
26
|
+
* @returns {object} - { valid, error, data }
|
|
27
|
+
*/
|
|
28
|
+
static validateSelectedElement(state, maxAge = DEFAULT_MAX_AGE) {
|
|
29
|
+
if (!state.selectedElement) {
|
|
30
|
+
return {
|
|
31
|
+
valid: false,
|
|
32
|
+
error: 'No element selected',
|
|
33
|
+
suggestion: 'Use the Chrome extension or call get_element with a CSS/XPath selector',
|
|
34
|
+
nextStep: 'get_element({ selectorInfo: { css: "..." } })'
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const el = state.selectedElement;
|
|
39
|
+
|
|
40
|
+
// Check required fields
|
|
41
|
+
if (!el.cssSelector || !el.xpath) {
|
|
42
|
+
return {
|
|
43
|
+
valid: false,
|
|
44
|
+
error: 'Selected element data incomplete',
|
|
45
|
+
suggestion: 'Re-select the element in Chrome extension'
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Check freshness
|
|
50
|
+
if (el.timestamp && !this.isDataFresh(el.timestamp, maxAge)) {
|
|
51
|
+
return {
|
|
52
|
+
valid: false,
|
|
53
|
+
error: `Selected element data is stale (${this.getAgeString(el.timestamp)})`,
|
|
54
|
+
suggestion: 'Re-select the element to get fresh data',
|
|
55
|
+
stale: true
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
valid: true,
|
|
61
|
+
data: el
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Validate network trace state
|
|
67
|
+
* @param {object} state - Extension state (extensionData or bridgeClient)
|
|
68
|
+
* @param {number} maxAge - Maximum age in ms
|
|
69
|
+
* @returns {object} - { valid, error, data }
|
|
70
|
+
*/
|
|
71
|
+
static validateNetworkTrace(state, maxAge = DEFAULT_MAX_AGE) {
|
|
72
|
+
if (!state.networkTrace || state.networkTrace.totalMatches === 0) {
|
|
73
|
+
return {
|
|
74
|
+
valid: false,
|
|
75
|
+
error: 'No network trace available',
|
|
76
|
+
suggestion: 'Select a DOM element first to capture network trace',
|
|
77
|
+
nextStep: 'get_element → get_network_trace'
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const trace = state.networkTrace;
|
|
82
|
+
|
|
83
|
+
// Check freshness
|
|
84
|
+
if (trace.timestamp && !this.isDataFresh(trace.timestamp, maxAge)) {
|
|
85
|
+
return {
|
|
86
|
+
valid: false,
|
|
87
|
+
error: `Network trace data is stale (${this.getAgeString(trace.timestamp)})`,
|
|
88
|
+
suggestion: 'Re-select the element to get fresh network trace',
|
|
89
|
+
stale: true
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Check data quality
|
|
94
|
+
if (!trace.matches || trace.matches.length === 0) {
|
|
95
|
+
return {
|
|
96
|
+
valid: false,
|
|
97
|
+
error: 'Network trace has no matches',
|
|
98
|
+
suggestion: 'The selected element may not be populated by API calls. Check Data Source Analysis in get_selected_element output.'
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
valid: true,
|
|
104
|
+
data: trace
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Validate WebSocket trace state
|
|
110
|
+
* @param {object} state - Extension state (extensionData or bridgeClient)
|
|
111
|
+
* @param {number} maxAge - Maximum age in ms
|
|
112
|
+
* @returns {object} - { valid, error, data }
|
|
113
|
+
*/
|
|
114
|
+
static validateWebSocketTrace(state, maxAge = DEFAULT_MAX_AGE) {
|
|
115
|
+
if (!state.websocketTrace || state.websocketTrace.totalMatches === 0) {
|
|
116
|
+
return {
|
|
117
|
+
valid: false,
|
|
118
|
+
error: 'No WebSocket trace available',
|
|
119
|
+
suggestion: 'This page may not use WebSockets, or no matching messages found',
|
|
120
|
+
nextStep: 'Try get_network_trace for REST API calls instead'
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const trace = state.websocketTrace;
|
|
125
|
+
|
|
126
|
+
// Check freshness
|
|
127
|
+
if (trace.timestamp && !this.isDataFresh(trace.timestamp, maxAge)) {
|
|
128
|
+
return {
|
|
129
|
+
valid: false,
|
|
130
|
+
error: `WebSocket trace data is stale (${this.getAgeString(trace.timestamp)})`,
|
|
131
|
+
suggestion: 'Re-select the element to get fresh WebSocket trace',
|
|
132
|
+
stale: true
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
valid: true,
|
|
138
|
+
data: trace
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Validate connection status.
|
|
144
|
+
* @param {object} state - Extension state (extensionData or bridgeClient)
|
|
145
|
+
* @param {string|number} [tabId] - Optional tab ID for per-tab navigation checks
|
|
146
|
+
* @returns {object} - { valid, error }
|
|
147
|
+
*/
|
|
148
|
+
static validateConnection(state, tabId) {
|
|
149
|
+
if (!state.isConnected) {
|
|
150
|
+
return {
|
|
151
|
+
valid: false,
|
|
152
|
+
error: 'Extension not connected',
|
|
153
|
+
suggestion: 'Make sure the Chrome extension is installed and a tab is open',
|
|
154
|
+
nextStep: '1. Install Chrome extension\n2. Open any webpage\n3. Click extension icon'
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Check last update time
|
|
159
|
+
if (state.lastUpdateTime) {
|
|
160
|
+
const age = Date.now() - state.lastUpdateTime;
|
|
161
|
+
if (age > 25 * 60 * 1000) { // 25 minutes
|
|
162
|
+
return {
|
|
163
|
+
valid: false,
|
|
164
|
+
error: 'Extension connection seems inactive',
|
|
165
|
+
suggestion: 'Interact with the extension or refresh the page',
|
|
166
|
+
stale: true
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Attach tab-level context if available (informational, not blocking)
|
|
172
|
+
if (tabId) {
|
|
173
|
+
const tabState = getTabState(tabId);
|
|
174
|
+
return { valid: true, tabState };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return { valid: true };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Validate JS execution request
|
|
182
|
+
* @param {object} state - Extension state (extensionData or bridgeClient)
|
|
183
|
+
* @returns {object} - { valid, error, data }
|
|
184
|
+
*/
|
|
185
|
+
static validateJsExecutionRequest(state) {
|
|
186
|
+
if (!state.jsExecutionRequest) {
|
|
187
|
+
return {
|
|
188
|
+
valid: false,
|
|
189
|
+
error: 'No pending JavaScript execution request',
|
|
190
|
+
suggestion: 'Call execute_js to execute code',
|
|
191
|
+
nextStep: 'execute_js'
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const request = state.jsExecutionRequest;
|
|
196
|
+
|
|
197
|
+
// Check if request is too old (30 seconds timeout)
|
|
198
|
+
if (request.timestamp) {
|
|
199
|
+
const age = Date.now() - new Date(request.timestamp).getTime();
|
|
200
|
+
if (age > 30000) {
|
|
201
|
+
return {
|
|
202
|
+
valid: false,
|
|
203
|
+
error: 'Execution request timed out',
|
|
204
|
+
suggestion: 'Execute the code again with execute_js',
|
|
205
|
+
stale: true
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
valid: true,
|
|
212
|
+
data: request
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Validate that there's NO pending execution (prevents consecutive execute_js/query_dom calls)
|
|
218
|
+
* CRITICAL: This prevents LLMs from calling execute_js/query_dom multiple times without reading results
|
|
219
|
+
* @param {object} state - Extension state (extensionData or bridgeClient)
|
|
220
|
+
* @returns {object} - { valid, error }
|
|
221
|
+
*/
|
|
222
|
+
static validateNoPendingExecution(state) {
|
|
223
|
+
// Check JavaScript execution
|
|
224
|
+
const jsRequest = state.jsExecutionRequest;
|
|
225
|
+
const jsResult = state.jsExecutionResult;
|
|
226
|
+
|
|
227
|
+
if (jsRequest && jsRequest.timestamp) {
|
|
228
|
+
const requestTime = new Date(jsRequest.timestamp).getTime();
|
|
229
|
+
const resultTime = jsResult?.timestamp ? new Date(jsResult.timestamp).getTime() : 0;
|
|
230
|
+
|
|
231
|
+
// If request is newer than result (or no result), there's a pending execution
|
|
232
|
+
if (requestTime > resultTime) {
|
|
233
|
+
return {
|
|
234
|
+
valid: false,
|
|
235
|
+
error: 'Previous execute_js call is still pending',
|
|
236
|
+
suggestion: '⚠️ CRITICAL: The previous code is still executing. Please wait a moment.',
|
|
237
|
+
nextStep: '1. Wait for the result to return automatically',
|
|
238
|
+
pendingType: 'execute_js',
|
|
239
|
+
requestId: jsRequest.id
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
valid: true
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Check if data is fresh
|
|
251
|
+
* @param {number} timestamp - Data timestamp
|
|
252
|
+
* @param {number} maxAge - Maximum age in ms
|
|
253
|
+
* @returns {boolean}
|
|
254
|
+
*/
|
|
255
|
+
static isDataFresh(timestamp, maxAge = DEFAULT_MAX_AGE) {
|
|
256
|
+
if (!timestamp) return false;
|
|
257
|
+
|
|
258
|
+
const age = Date.now() - timestamp;
|
|
259
|
+
return age <= maxAge;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Get human-readable age string
|
|
264
|
+
* @param {number} timestamp - Data timestamp
|
|
265
|
+
* @returns {string}
|
|
266
|
+
*/
|
|
267
|
+
static getAgeString(timestamp) {
|
|
268
|
+
if (!timestamp) return 'unknown age';
|
|
269
|
+
|
|
270
|
+
const age = Date.now() - timestamp;
|
|
271
|
+
const seconds = Math.floor(age / 1000);
|
|
272
|
+
const minutes = Math.floor(seconds / 60);
|
|
273
|
+
|
|
274
|
+
if (minutes > 0) {
|
|
275
|
+
return `${minutes} minute${minutes !== 1 ? 's' : ''} old`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return `${seconds} second${seconds !== 1 ? 's' : ''} old`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Validate generic state requirements
|
|
283
|
+
* @param {object} requirements - { required: string[], maxAge: number }
|
|
284
|
+
* @param {object} state - Extension state (extensionData or bridgeClient)
|
|
285
|
+
* @returns {object} - { valid, error, missing }
|
|
286
|
+
*/
|
|
287
|
+
static validateGenericState(requirements, state) {
|
|
288
|
+
const missing = [];
|
|
289
|
+
const stale = [];
|
|
290
|
+
|
|
291
|
+
for (const field of requirements.required || []) {
|
|
292
|
+
// Check if field exists
|
|
293
|
+
if (!state[field]) {
|
|
294
|
+
missing.push(field);
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Check freshness if timestamp available
|
|
299
|
+
const data = state[field];
|
|
300
|
+
if (data.timestamp && requirements.maxAge) {
|
|
301
|
+
if (!this.isDataFresh(data.timestamp, requirements.maxAge)) {
|
|
302
|
+
stale.push({
|
|
303
|
+
field,
|
|
304
|
+
age: this.getAgeString(data.timestamp)
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (missing.length > 0) {
|
|
311
|
+
return {
|
|
312
|
+
valid: false,
|
|
313
|
+
error: `Missing required data: ${missing.join(', ')}`,
|
|
314
|
+
missing
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (stale.length > 0) {
|
|
319
|
+
return {
|
|
320
|
+
valid: false,
|
|
321
|
+
error: `Stale data detected: ${stale.map(s => `${s.field} (${s.age})`).join(', ')}`,
|
|
322
|
+
stale
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
valid: true
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Format validation error as MCP response
|
|
333
|
+
* @param {object} validation - Validation result
|
|
334
|
+
* @returns {object} - MCP response format
|
|
335
|
+
*/
|
|
336
|
+
static formatValidationError(validation) {
|
|
337
|
+
let text = `❌ ${validation.error}\n`;
|
|
338
|
+
|
|
339
|
+
if (validation.suggestion) {
|
|
340
|
+
text += `\n💡 ${validation.suggestion}\n`;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (validation.nextStep) {
|
|
344
|
+
text += `\n📋 NEXT STEPS:\n${validation.nextStep}\n`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
content: [{
|
|
349
|
+
type: 'text',
|
|
350
|
+
text
|
|
351
|
+
}]
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tab Resolver
|
|
3
|
+
* Resolves tabId to URL by querying the extension for open tabs.
|
|
4
|
+
* Used by tools that need domain resolution for profile operations.
|
|
5
|
+
*
|
|
6
|
+
* Phase 2.4: Refactored from (extensionData, httpPort) to (bridgeClient).
|
|
7
|
+
* All tab resolution now goes through BridgeClient HTTP calls.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { getTabState, extractDomain } from './workflow-state.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Fetch open tabs from extension via bridge daemon.
|
|
14
|
+
* @param {BridgeClient} bridgeClient
|
|
15
|
+
* @returns {Promise<Array<{id: number, url: string, title: string, active: boolean}>>}
|
|
16
|
+
*/
|
|
17
|
+
async function fetchTabs(bridgeClient) {
|
|
18
|
+
const requestId = `tabs-resolve-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
await bridgeClient.queueRequest('tabs', { id: requestId });
|
|
22
|
+
const result = await bridgeClient.waitForResult('tabs', requestId, 2000);
|
|
23
|
+
if (result && result.tabs) {
|
|
24
|
+
return result.tabs;
|
|
25
|
+
}
|
|
26
|
+
} catch {
|
|
27
|
+
// Non-critical — tab resolution is best-effort
|
|
28
|
+
}
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve a tabId to its page URL. Tries workflow-state cache first, then live query.
|
|
34
|
+
* @param {BridgeClient} bridgeClient
|
|
35
|
+
* @param {number} tabId
|
|
36
|
+
* @returns {Promise<string>} URL or empty string
|
|
37
|
+
*/
|
|
38
|
+
export async function resolveTabUrl(bridgeClient, tabId) {
|
|
39
|
+
// 1. Check workflow-state cache
|
|
40
|
+
const cached = getTabState(tabId);
|
|
41
|
+
if (cached?.url) return cached.url;
|
|
42
|
+
|
|
43
|
+
// 2. Live query to extension
|
|
44
|
+
const tabs = await fetchTabs(bridgeClient);
|
|
45
|
+
const match = tabs.find((t) => t.id === tabId);
|
|
46
|
+
return match?.url || '';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Get the correct domain for a given tool call, accounting for tabId.
|
|
51
|
+
* Falls back to activeTabUrl → selectedElement URLs if no tabId specified.
|
|
52
|
+
* @param {BridgeClient} bridgeClient
|
|
53
|
+
* @param {number|undefined} tabId
|
|
54
|
+
* @returns {Promise<string>} hostname or empty string
|
|
55
|
+
*/
|
|
56
|
+
export async function resolveActiveDomain(bridgeClient, tabId) {
|
|
57
|
+
// If tabId specified, resolve that tab's URL — do NOT fall back to active tab
|
|
58
|
+
// (falling back would silently save to wrong domain if tab resolution fails)
|
|
59
|
+
if (tabId !== undefined) {
|
|
60
|
+
const url = await resolveTabUrl(bridgeClient, tabId);
|
|
61
|
+
return url ? extractDomain(url) : '';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Fallback: activeTabUrl → selectedElement URLs
|
|
65
|
+
const candidate = bridgeClient.activeTabUrl
|
|
66
|
+
|| bridgeClient.selectedElement?.sessionInfo?.url
|
|
67
|
+
|| bridgeClient.selectedElement?.pageUrl
|
|
68
|
+
|| '';
|
|
69
|
+
return candidate ? extractDomain(candidate) : '';
|
|
70
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Helper
|
|
3
|
+
* Manages tool dependencies, prerequisites, and workflow guidance for LLM agents.
|
|
4
|
+
*
|
|
5
|
+
* This module reads from workflow-state.js but does NOT modify it.
|
|
6
|
+
* State mutations happen inside tool handlers via the workflow-state API.
|
|
7
|
+
*
|
|
8
|
+
* References:
|
|
9
|
+
* - https://www.anthropic.com/engineering/code-execution-with-mcp
|
|
10
|
+
* - https://docs.claude.com/en/docs/build-with-claude/prompt-engineering/claude-4-best-practices
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { getTabState, isProfileFresh } from './workflow-state.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Tool dependency graph
|
|
17
|
+
* Defines which tools must be called before others.
|
|
18
|
+
*/
|
|
19
|
+
const ToolDependencies = {
|
|
20
|
+
// get_network_trace requires an element to have been selected first
|
|
21
|
+
'get_network_trace': {
|
|
22
|
+
description: 'Retrieves network API calls that match the selected DOM element',
|
|
23
|
+
requires: ['get_element']
|
|
24
|
+
},
|
|
25
|
+
// JavaScript execution workflow
|
|
26
|
+
'execute_js': {
|
|
27
|
+
description: 'Executes custom JavaScript code'
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Validate tool prerequisites.
|
|
34
|
+
* @param {string} toolName - Name of the tool to validate
|
|
35
|
+
* @param {object} state - Current state (extensionData or bridgeClient)
|
|
36
|
+
* @param {object} [context] - Optional context: { tabId, domain }
|
|
37
|
+
* @returns {object} - { valid: boolean, missing: string[], suggestions: string[] }
|
|
38
|
+
*/
|
|
39
|
+
export function validatePrerequisites(toolName, state, context = {}) {
|
|
40
|
+
const dependency = ToolDependencies[toolName];
|
|
41
|
+
|
|
42
|
+
// No dependencies = always valid
|
|
43
|
+
if (!dependency) {
|
|
44
|
+
return { valid: true, missing: [], suggestions: [] };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const missing = [];
|
|
48
|
+
const suggestions = [];
|
|
49
|
+
|
|
50
|
+
// Check required dependencies
|
|
51
|
+
for (const requiredTool of dependency.requires || []) {
|
|
52
|
+
const isAvailable = checkToolDataAvailable(requiredTool, state);
|
|
53
|
+
|
|
54
|
+
if (!isAvailable) {
|
|
55
|
+
missing.push(requiredTool);
|
|
56
|
+
suggestions.push(`Call ${requiredTool} first`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Attach workflow state hints when context is available
|
|
61
|
+
const hints = buildWorkflowHints(toolName, context);
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
valid: missing.length === 0,
|
|
65
|
+
missing,
|
|
66
|
+
suggestions,
|
|
67
|
+
reason: dependency.description,
|
|
68
|
+
hints
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Build soft hints based on workflow state (no blocking — informational only).
|
|
74
|
+
* @param {string} toolName
|
|
75
|
+
* @param {{ tabId?: string|number, domain?: string }} context
|
|
76
|
+
* @returns {string[]}
|
|
77
|
+
*/
|
|
78
|
+
function buildWorkflowHints(toolName, context) {
|
|
79
|
+
const hints = [];
|
|
80
|
+
const { tabId, domain } = context;
|
|
81
|
+
|
|
82
|
+
if (domain && isProfileFresh(domain)) {
|
|
83
|
+
if (toolName === 'discover_apis') {
|
|
84
|
+
hints.push(`A fresh profile exists for ${domain}. Consider load_site_profile to skip re-discovery.`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (tabId) {
|
|
89
|
+
const tabState = getTabState(tabId);
|
|
90
|
+
if (tabState.lastAnalyzeAt && toolName === 'analyze_page') {
|
|
91
|
+
const ageS = Math.round((Date.now() - tabState.lastAnalyzeAt) / 1000);
|
|
92
|
+
if (ageS < 60) {
|
|
93
|
+
hints.push(`analyze_page was called ${ageS}s ago on this tab — data is likely still fresh.`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return hints;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Check if required data for a tool is available
|
|
103
|
+
* @param {string} toolName - Tool name
|
|
104
|
+
* @param {object} state - Extension state (extensionData or bridgeClient)
|
|
105
|
+
* @returns {boolean}
|
|
106
|
+
*/
|
|
107
|
+
function checkToolDataAvailable(toolName, state) {
|
|
108
|
+
switch (toolName) {
|
|
109
|
+
case 'get_element':
|
|
110
|
+
return state.selectedElement &&
|
|
111
|
+
state.selectedElement.cssSelector;
|
|
112
|
+
|
|
113
|
+
case 'get_network_trace':
|
|
114
|
+
return state.networkTrace &&
|
|
115
|
+
state.networkTrace.totalMatches > 0;
|
|
116
|
+
|
|
117
|
+
case 'execute_js':
|
|
118
|
+
// Pending request OR result present means execution has been issued
|
|
119
|
+
return state.jsExecutionRequest !== null ||
|
|
120
|
+
state.jsExecutionResult !== null;
|
|
121
|
+
|
|
122
|
+
default:
|
|
123
|
+
return true; // Unknown tool, assume available
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Suggest next tools in workflow
|
|
129
|
+
* @param {string} currentTool - Current tool name
|
|
130
|
+
* @returns {Array} - Array of { tool, reason, priority }
|
|
131
|
+
*/
|
|
132
|
+
export function suggestNextTools(currentTool) {
|
|
133
|
+
const suggestions = [];
|
|
134
|
+
|
|
135
|
+
// After element selection - find which API populates it
|
|
136
|
+
if (currentTool === 'get_element') {
|
|
137
|
+
suggestions.push({
|
|
138
|
+
tool: 'get_network_trace',
|
|
139
|
+
reason: 'Find matching API calls and inspect details',
|
|
140
|
+
priority: 'high'
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
// After JS execution
|
|
146
|
+
if (currentTool === 'execute_js') {
|
|
147
|
+
// We don't suggest get_execution_results anymore as it's blocking
|
|
148
|
+
// But we might suggest analyzing the result or another JS execution
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return suggestions;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Format a hard guard (missing prerequisites) as an MCP response object.
|
|
157
|
+
* Use this when a tool MUST be blocked.
|
|
158
|
+
* @param {string} toolName
|
|
159
|
+
* @param {object} validation - { missing: string[], suggestions: string[] }
|
|
160
|
+
* @returns {object} MCP content response
|
|
161
|
+
*/
|
|
162
|
+
export function formatHardGuard(toolName, validation) {
|
|
163
|
+
const next = validation.missing[0] || 'unknown';
|
|
164
|
+
const text =
|
|
165
|
+
`❌ Cannot run \`${toolName}\`: missing prerequisites.\n\n` +
|
|
166
|
+
`Required first: \`${validation.missing.join(' → ')}\`\n` +
|
|
167
|
+
`Next step: \`${next}\``;
|
|
168
|
+
|
|
169
|
+
return { content: [{ type: 'text', text }] };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Format a soft guard (stale-but-usable hint) as an MCP response object.
|
|
174
|
+
* Appends to existing output — does NOT block execution.
|
|
175
|
+
* @param {string[]} hints
|
|
176
|
+
* @returns {string} Text to append (empty string if no hints)
|
|
177
|
+
*/
|
|
178
|
+
export function formatSoftGuard(hints) {
|
|
179
|
+
if (!hints || hints.length === 0) return '';
|
|
180
|
+
return '\n\n' + hints.map(h => `💡 ${h}`).join('\n');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Evaluate soft guard for discover_apis.
|
|
185
|
+
* Returns hints array (empty = no soft guard triggered).
|
|
186
|
+
* @param {object} state - Extension state (extensionData or bridgeClient)
|
|
187
|
+
* @param {{ tabId?: string|number, domain?: string, force?: boolean }} context
|
|
188
|
+
* @returns {string[]}
|
|
189
|
+
*/
|
|
190
|
+
export function softGuardDiscoverApis(state, context = {}) {
|
|
191
|
+
const { domain, force } = context;
|
|
192
|
+
if (force) return [];
|
|
193
|
+
if (domain && isProfileFresh(domain)) {
|
|
194
|
+
return [
|
|
195
|
+
`A fresh profile exists for \`${domain}\`. ` +
|
|
196
|
+
`Consider \`load_site_profile({ domain: "${domain}" })\` to skip re-discovery. ` +
|
|
197
|
+
`Pass \`force: true\` to run anyway.`
|
|
198
|
+
];
|
|
199
|
+
}
|
|
200
|
+
return [];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Evaluate soft guard for analyze_page.
|
|
205
|
+
* Returns hints array (empty = no soft guard triggered).
|
|
206
|
+
* @param {{ tabId?: string|number }} context
|
|
207
|
+
* @returns {string[]}
|
|
208
|
+
*/
|
|
209
|
+
export function softGuardAnalyzePage(context = {}) {
|
|
210
|
+
const { tabId } = context;
|
|
211
|
+
if (!tabId) return [];
|
|
212
|
+
const tabState = getTabState(tabId);
|
|
213
|
+
if (!tabState.lastAnalyzeAt) return [];
|
|
214
|
+
const ageS = Math.round((Date.now() - tabState.lastAnalyzeAt) / 1000);
|
|
215
|
+
if (ageS < 60) {
|
|
216
|
+
return [`analyze_page was called ${ageS}s ago on this tab — data is likely still fresh.`];
|
|
217
|
+
}
|
|
218
|
+
return [];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Evaluate soft guard for load_site_profile.
|
|
223
|
+
* Returns hints array (empty = no soft guard triggered).
|
|
224
|
+
* @param {string} domain
|
|
225
|
+
* @returns {string[]}
|
|
226
|
+
*/
|
|
227
|
+
export function softGuardLoadSiteProfile(domain) {
|
|
228
|
+
// No saved profile for this domain — suggest quick_scan
|
|
229
|
+
// Caller must check loadProfile result first; this just provides the hint text.
|
|
230
|
+
if (!domain) return [];
|
|
231
|
+
return [
|
|
232
|
+
`No saved profile for \`${domain}\`. ` +
|
|
233
|
+
`Run \`quick_scan\` to discover the site and build a profile automatically.`
|
|
234
|
+
];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* @deprecated Use formatHardGuard instead.
|
|
239
|
+
* Format prerequisite error message (kept for call sites not yet migrated).
|
|
240
|
+
*/
|
|
241
|
+
export function formatPrerequisiteError(toolName, validation) {
|
|
242
|
+
if (validation.valid) return null;
|
|
243
|
+
let message = `Cannot execute ${toolName}: Missing prerequisites\n\n`;
|
|
244
|
+
message += `Required: ${validation.missing.join(' -> ')}\n`;
|
|
245
|
+
message += `Next: ${validation.missing[0]}`;
|
|
246
|
+
return message;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Format workflow suggestion message
|
|
251
|
+
* @param {Array} suggestions - Next tool suggestions
|
|
252
|
+
* @returns {string} - Formatted suggestion message
|
|
253
|
+
*/
|
|
254
|
+
export function formatWorkflowSuggestions(suggestions) {
|
|
255
|
+
if (!suggestions || suggestions.length === 0) {
|
|
256
|
+
return '';
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Get the highest priority suggestion
|
|
260
|
+
const nextTool = suggestions.find(s => s.priority === 'high') || suggestions[0];
|
|
261
|
+
return `\nNext: ${nextTool.tool}`;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Wait for a result to appear in extensionData
|
|
265
|
+
* @deprecated Use bridgeClient.waitForResult(type, requestId, timeoutMs) instead.
|
|
266
|
+
* This function is kept for backward compatibility but should not be used in new code.
|
|
267
|
+
* @param {object} state - Extension state (extensionData or bridgeClient)
|
|
268
|
+
* @param {string} resultKey - Key to check (e.g., 'jsExecutionResult')
|
|
269
|
+
* @param {number} requestId - Request ID to match
|
|
270
|
+
* @param {number} timeoutMs - Max wait time
|
|
271
|
+
* @returns {Promise<object|null>} - Found result or null
|
|
272
|
+
*/
|
|
273
|
+
export async function waitForResult(state, resultKey, requestId, timeoutMs = 10000) {
|
|
274
|
+
const startTime = Date.now();
|
|
275
|
+
const pollInterval = 500; // 500ms
|
|
276
|
+
|
|
277
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
278
|
+
const result = state[resultKey];
|
|
279
|
+
|
|
280
|
+
// Check if we have a result that matches our request
|
|
281
|
+
// Note: requestId might not be present in results from older extensions,
|
|
282
|
+
// so we also check timestamp if available.
|
|
283
|
+
if (result && (!requestId || result.id === requestId || result.requestId === requestId)) {
|
|
284
|
+
return result;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Wait for next poll
|
|
288
|
+
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return null;
|
|
292
|
+
}
|