@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.
Files changed (49) hide show
  1. package/README.md +559 -0
  2. package/bin/cli.js +88 -0
  3. package/package.json +54 -0
  4. package/src/bridge/http-server.js +290 -0
  5. package/src/bridge/middleware.js +56 -0
  6. package/src/bridge/routes.js +1003 -0
  7. package/src/bridge-daemon.js +172 -0
  8. package/src/cli/auto-config.js +120 -0
  9. package/src/constants.js +13 -0
  10. package/src/index.js +279 -0
  11. package/src/mcp-bridge.js +136 -0
  12. package/src/metrics/error-codes.js +44 -0
  13. package/src/metrics/index.js +3 -0
  14. package/src/metrics/metrics-db.js +269 -0
  15. package/src/metrics/metrics-recorder.js +240 -0
  16. package/src/metrics/metrics-report.js +146 -0
  17. package/src/profiles/profile-db.js +159 -0
  18. package/src/profiles/profile-enricher.js +333 -0
  19. package/src/profiles/profile-manager.js +563 -0
  20. package/src/profiles/profile-repo.js +183 -0
  21. package/src/state/bridge-client.js +272 -0
  22. package/src/state/bridge-persistence.js +205 -0
  23. package/src/state/cache.js +38 -0
  24. package/src/state/extension-state.js +321 -0
  25. package/src/tools/action_tools.js +218 -0
  26. package/src/tools/analyze-page.js +247 -0
  27. package/src/tools/debug-mcp-state.js +172 -0
  28. package/src/tools/discover-apis.js +186 -0
  29. package/src/tools/execute-js.js +284 -0
  30. package/src/tools/export-session.js +171 -0
  31. package/src/tools/extract-data.js +395 -0
  32. package/src/tools/get-element.js +281 -0
  33. package/src/tools/get-network-trace.js +471 -0
  34. package/src/tools/index.js +110 -0
  35. package/src/tools/manage-site-profile.js +153 -0
  36. package/src/tools/paginate.js +444 -0
  37. package/src/tools/quick-scan.js +418 -0
  38. package/src/tools/screenshot_tools.js +117 -0
  39. package/src/utils/circuit-breaker.js +112 -0
  40. package/src/utils/extract-density.js +21 -0
  41. package/src/utils/logger.js +31 -0
  42. package/src/utils/paginate-detector.js +24 -0
  43. package/src/utils/rate-limiter.js +244 -0
  44. package/src/utils/run-script.js +37 -0
  45. package/src/utils/selector-validator.js +95 -0
  46. package/src/utils/state-validator.js +354 -0
  47. package/src/utils/tab-resolver.js +70 -0
  48. package/src/utils/workflow-helper.js +292 -0
  49. package/src/utils/workflow-state.js +177 -0
@@ -0,0 +1,418 @@
1
+ /**
2
+ * Tool: quick_scan
3
+ * Profile-first bootstrap for agent workflows.
4
+ *
5
+ * Phase 2.4: Refactored from (args, extensionData, httpPort) to (args, bridgeClient).
6
+ * All state reads go through bridgeClient property getters.
7
+ * All request/result operations go through bridgeClient.queueRequest/waitForResult.
8
+ */
9
+
10
+ import { loadProfile, extractDomain } from '../profiles/profile-manager.js';
11
+ import { markProfileChecked, markProfileLoaded, touchTabField, isFieldFresh } from '../utils/workflow-state.js';
12
+ import { enrichProfile } from '../profiles/profile-enricher.js';
13
+
14
+ async function callAnalyzePage(bridgeClient, tabId) {
15
+ const { analyzePageTool } = await import('./analyze-page.js');
16
+ return analyzePageTool.handler({ includeApis: true, verbose: false, tabId }, bridgeClient);
17
+ }
18
+
19
+ async function callGetTabs(bridgeClient) {
20
+ const requestId = `tabs-quick-scan-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
21
+
22
+ try {
23
+ await bridgeClient.queueRequest('tabs', { id: requestId });
24
+ const result = await bridgeClient.waitForResult('tabs', requestId, 3000);
25
+ if (result && result.tabs) {
26
+ return result.tabs;
27
+ }
28
+ } catch {
29
+ // Non-critical — tab resolution is best-effort
30
+ }
31
+
32
+ return [];
33
+ }
34
+
35
+ async function callExtractData(bridgeClient, tabId) {
36
+ const { extractDataTool } = await import('./extract-data.js');
37
+ return extractDataTool.handler({ verbose: false, tabId }, bridgeClient);
38
+ }
39
+
40
+ async function callDiscoverApis(bridgeClient, tabId) {
41
+ const { discoverApisTool } = await import('./discover-apis.js');
42
+ return discoverApisTool.handler({ limit: 5, force: true, tabId }, bridgeClient);
43
+ }
44
+
45
+ async function callCheckSiteChanges(bridgeClient, domain) {
46
+ // Bug #5 fix: check_site_changes was merged into manage_site_profile (unified profile tool)
47
+ const { manageSiteProfileTool } = await import('./manage-site-profile.js');
48
+ return manageSiteProfileTool.handler({ action: 'check', domain }, bridgeClient);
49
+ }
50
+
51
+ async function callCaptureScreenshot(bridgeClient, tabId) {
52
+ const { captureScreenshotTool } = await import('./screenshot_tools.js');
53
+ return captureScreenshotTool.handler({ tabId }, bridgeClient);
54
+ }
55
+
56
+ function textOf(mcpResult) {
57
+ return mcpResult?.content?.[0]?.text || '';
58
+ }
59
+
60
+ function domainFromAnalyzeText(analyzeText) {
61
+ const urlLine = analyzeText.split('\n').find((line) => line.startsWith('URL: '));
62
+ if (!urlLine) return '';
63
+ try {
64
+ return extractDomain(urlLine.replace(/^URL:\s*/, '').trim());
65
+ } catch {
66
+ return '';
67
+ }
68
+ }
69
+
70
+ function domainFromExtension(bridgeClient) {
71
+ try {
72
+ const url = bridgeClient.activeTabUrl
73
+ || bridgeClient.selectedElement?.sessionInfo?.url
74
+ || bridgeClient.selectedElement?.pageUrl
75
+ || '';
76
+ return extractDomain(url);
77
+ } catch {
78
+ return '';
79
+ }
80
+ }
81
+
82
+ function summarize(text, lines = 3) {
83
+ return text
84
+ .split('\n')
85
+ .filter((line) => line.trim())
86
+ .slice(0, lines)
87
+ .join('\n');
88
+ }
89
+
90
+ function getProfilePageType(profile) {
91
+ return profile?.pageCharacteristics?.type || 'unknown';
92
+ }
93
+
94
+ function buildProfileCoverage(profile) {
95
+ if (!profile) return [];
96
+
97
+ const coverage = [];
98
+ if (profile.framework?.length) coverage.push(`framework=${profile.framework.join(', ')}`);
99
+ if (profile.dataSchema?.sources?.length) coverage.push(`${profile.dataSchema.sources.length} data source(s)`);
100
+ if (profile.apiEndpoints?.length) coverage.push(`${profile.apiEndpoints.length} endpoint(s)`);
101
+ if (profile.knownPaths?.length) coverage.push(`${profile.knownPaths.length} extraction path(s)`);
102
+ return coverage;
103
+ }
104
+
105
+ function getDiscoveryMode({ usedProfile, ranAnalyze, ranExtract, ranDiscover }) {
106
+ if (usedProfile && !ranAnalyze && !ranExtract && !ranDiscover) return 'Profile-only';
107
+ if (usedProfile && (ranAnalyze || ranExtract || ranDiscover)) return 'Profile + selective refresh';
108
+ return 'Live discovery';
109
+ }
110
+
111
+ function buildInteractionHints(sourceText, intent, profile, { ranAnalyze = false, ranExtract = false, ranDiscover = false } = {}) {
112
+ const hints = [];
113
+ const lower = `${sourceText || ''}\n${intent || ''}`.toLowerCase();
114
+
115
+ if (/forms.*[1-9]/.test(lower) || (profile?.pageCharacteristics?.forms ?? 0) > 0) {
116
+ hints.push('Form detected - `execute_action` can fill and submit it');
117
+ }
118
+ if (/inputs.*[1-9]/.test(lower) || (profile?.pageCharacteristics?.inputs ?? 0) > 0) {
119
+ hints.push('Input fields detected - use `execute_action({ actionType: "type", ... })`');
120
+ }
121
+ if (/paginat|next.*page|load more/i.test(lower) || profile?.paginationPatterns?.type) {
122
+ hints.push('Pagination signal - consider `paginate` for list/archive extraction');
123
+ }
124
+ if (/button.*[1-9]/.test(lower) || (profile?.pageCharacteristics?.buttons ?? 0) > 0) {
125
+ hints.push('Buttons detected - `execute_action({ actionType: "click", ... })` can trigger interactions');
126
+ }
127
+ if (/login|sign.?in|auth|cookie|session|password/i.test(lower) || profile?.authInfo?.type) {
128
+ hints.push('Auth-required page detected - call `export_session()` to capture cookies/tokens for Playwright handoff');
129
+ }
130
+ if (!profile && (ranAnalyze || ranExtract || ranDiscover)) {
131
+ hints.push('No profile saved for this site - call `manage_site_profile({ action: "save" })` after discovery to persist findings');
132
+ }
133
+
134
+ return hints;
135
+ }
136
+
137
+ /**
138
+ * Resolves target tab in a single fetch, returning both tabId and tabUrl.
139
+ * Single fetch avoids a second tabs request (which times out if extension just responded).
140
+ */
141
+ async function resolveTargetTab(bridgeClient, argTabId, tabUrl) {
142
+ if (!tabUrl && argTabId === undefined) return { tabId: undefined, tabUrl: '' };
143
+
144
+ const tabs = await callGetTabs(bridgeClient);
145
+
146
+ if (argTabId !== undefined) {
147
+ const match = tabs.find((t) => t.id === argTabId);
148
+ return { tabId: argTabId, tabUrl: match?.url || '' };
149
+ }
150
+
151
+ const needle = tabUrl.toLowerCase();
152
+ const match = tabs.find((tab) => typeof tab.url === 'string' && tab.url.toLowerCase().includes(needle));
153
+ return match ? { tabId: match.id, tabUrl: match.url } : { tabId: undefined, tabUrl: '' };
154
+ }
155
+
156
+ export const quickScanTool = {
157
+ name: 'quick_scan',
158
+ description: `One-call context bootstrap — analyze + profile check + discovery in one step.
159
+
160
+ WORKFLOW POSITION: First Step — use before get_element / get_network_trace
161
+
162
+ WHAT IT DOES:
163
+ 1. Resolves target tab/domain
164
+ 2. Loads saved profile (or runs live discovery if stale)
165
+ 3. Refreshes only stale or missing fields
166
+ 4. Returns concise summary of profile vs live data
167
+
168
+ WHAT IT DOES NOT DO:
169
+ - Does NOT select elements or run network trace
170
+ - Does NOT click, type, scroll, or paginate
171
+ - Does NOT print screenshot binary
172
+
173
+ PARAMETERS:
174
+ - tabUrl (optional): Match tab by URL substring
175
+ - tabId (optional): Target specific tab by ID
176
+ - intent (optional): What you want (e.g. "extract product prices")
177
+ - verbose (optional): Return full detail instead of summary
178
+ - refreshProfile (optional): Force drift check with manage_site_profile({action:"check"})
179
+ - preferLive (optional): Ignore saved profile, run live discovery
180
+
181
+ EXAMPLE:
182
+ quick_scan({ tabUrl: "trendyol.com", intent: "extract prices" })
183
+ quick_scan({ tabId: 142, preferLive: true })`,
184
+
185
+ inputSchema: {
186
+ type: 'object',
187
+ properties: {
188
+ tabUrl: {
189
+ type: 'string',
190
+ description: 'URL substring to target a specific tab (e.g. "trendyol.com", "sofascore").'
191
+ },
192
+ intent: {
193
+ type: 'string',
194
+ description: 'What you want to do (e.g. "extract product prices", "find pagination API")'
195
+ },
196
+ verbose: {
197
+ type: 'boolean',
198
+ description: 'Return full detail from analyze/discovery subtools (default: false)',
199
+ default: false
200
+ },
201
+ refreshProfile: {
202
+ type: 'boolean',
203
+ description: 'Force a live drift check with manage_site_profile({action:"check"}) (default: false)',
204
+ default: false
205
+ },
206
+ preferLive: {
207
+ type: 'boolean',
208
+ description: 'Bypass profile freshness and run live discovery (default: false)',
209
+ default: false
210
+ },
211
+ tabId: {
212
+ type: 'number',
213
+ description: 'Target a specific tab by ID (from debug_mcp_state).'
214
+ }
215
+ }
216
+ },
217
+
218
+ handler: async (args, bridgeClient) => {
219
+ const { tabUrl, tabId: argTabId, intent = '', verbose = false, refreshProfile = false, preferLive = false } = args || {};
220
+
221
+ if (!bridgeClient.isConnected) {
222
+ return {
223
+ content: [{
224
+ type: 'text',
225
+ text: 'Error: Extension not connected.\n\nREQUIRED:\n1. Reload the webpage\n2. Ensure the Chrome extension is active'
226
+ }],
227
+ isError: true
228
+ };
229
+ }
230
+
231
+ const { tabId, tabUrl: resolvedTabUrl } = await resolveTargetTab(bridgeClient, argTabId, tabUrl);
232
+ if (tabUrl && tabId === undefined) {
233
+ return {
234
+ content: [{
235
+ type: 'text',
236
+ text: `Error: No open tab matched "${tabUrl}". Use \`debug_mcp_state()\` to inspect available tabs or pass \`tabId\` directly.`
237
+ }],
238
+ isError: true
239
+ };
240
+ }
241
+
242
+ if (tabId !== undefined) touchTabField(tabId, 'lastQuickScanAt');
243
+
244
+ let domain = (resolvedTabUrl ? extractDomain(resolvedTabUrl) : '') || domainFromExtension(bridgeClient);
245
+ let profile = domain && !preferLive ? loadProfile(domain) : null;
246
+ let analyzeText = '';
247
+ const sections = [];
248
+
249
+ const needAnalyze = preferLive || !domain || !profile || !isFieldFresh(domain, 'framework') || !isFieldFresh(domain, 'pageCharacteristics');
250
+ let ranAnalyze = false;
251
+ let ranExtract = false;
252
+ let ranDiscover = false;
253
+ let cspBlocked = false;
254
+ let usedProfile = Boolean(profile) && !preferLive;
255
+
256
+ if (needAnalyze) {
257
+ try {
258
+ const analyzeResult = await callAnalyzePage(bridgeClient, tabId);
259
+ analyzeText = textOf(analyzeResult);
260
+ domain = domainFromAnalyzeText(analyzeText) || domain;
261
+ sections.push({ label: 'Page Analysis', text: analyzeText, origin: 'live' });
262
+ ranAnalyze = true;
263
+ if (tabId !== undefined) touchTabField(tabId, 'lastAnalyzeAt');
264
+ if (domain && !preferLive) {
265
+ profile = loadProfile(domain);
266
+ usedProfile = Boolean(profile);
267
+ }
268
+ } catch (e) {
269
+ sections.push({ label: 'Page Analysis', text: `Analysis failed: ${e.message}`, origin: 'live' });
270
+ }
271
+ } else if (profile) {
272
+ sections.push({
273
+ label: 'Page Analysis',
274
+ text: `Using saved analysis for ${domain}: frameworks=${profile.framework?.join(', ') || 'unknown'}, pageType=${getProfilePageType(profile)}`,
275
+ origin: 'profile'
276
+ });
277
+ }
278
+
279
+ if (profile) {
280
+ const coverage = buildProfileCoverage(profile).join(', ');
281
+ sections.push({
282
+ label: 'Profile Status',
283
+ text: `Saved profile found for ${domain} (v${profile.version}). Coverage: ${coverage || 'basic metadata only'}.`,
284
+ origin: 'profile'
285
+ });
286
+ markProfileLoaded(domain);
287
+ }
288
+
289
+ if (profile && refreshProfile) {
290
+ try {
291
+ const changeResult = await callCheckSiteChanges(bridgeClient, domain);
292
+ sections.push({ label: 'Drift Check', text: textOf(changeResult), origin: 'live' });
293
+ markProfileChecked(domain);
294
+ } catch (e) {
295
+ sections.push({ label: 'Drift Check', text: `Profile check failed: ${e.message}`, origin: 'live' });
296
+ }
297
+ }
298
+
299
+ const pageType = ranAnalyze
300
+ ? (/SSR \(|API-driven/i.test(analyzeText) ? (/SSR \(/.test(analyzeText) ? 'SSR' : 'SPA') : getProfilePageType(profile))
301
+ : getProfilePageType(profile);
302
+
303
+ const needExtract = preferLive || !profile || !isFieldFresh(domain, 'dataSchema') || !isFieldFresh(domain, 'knownPaths');
304
+ const needDiscover = preferLive || !profile || !isFieldFresh(domain, 'apiEndpoints');
305
+
306
+ if (needExtract && (pageType === 'SSR' || pageType === 'unknown')) {
307
+ try {
308
+ const extractResult = await callExtractData(bridgeClient, tabId);
309
+ sections.push({ label: 'Embedded Data', text: textOf(extractResult), origin: 'live' });
310
+ ranExtract = true;
311
+ } catch (e) {
312
+ sections.push({ label: 'Embedded Data', text: `extract_data failed: ${e.message}`, origin: 'live' });
313
+ if (e.message?.includes('CSP') || e.message?.includes('unsafe-eval')) cspBlocked = true;
314
+ }
315
+ } else if (profile?.dataSchema?.sources?.length) {
316
+ sections.push({
317
+ label: 'Embedded Data',
318
+ text: `Using ${profile.dataSchema.sources.length} saved source(s); primary=${profile.dataSchema.sources.slice(0, 3).map((source) => source.key).join(', ')}`,
319
+ origin: 'profile'
320
+ });
321
+ }
322
+
323
+ if (needDiscover && (pageType === 'SPA' || pageType === 'unknown' || preferLive)) {
324
+ try {
325
+ const discoverResult = await callDiscoverApis(bridgeClient, tabId);
326
+ sections.push({ label: 'API Discovery', text: textOf(discoverResult), origin: 'live' });
327
+ ranDiscover = true;
328
+ if (tabId !== undefined) touchTabField(tabId, 'lastDiscoverApisAt');
329
+ } catch (e) {
330
+ sections.push({ label: 'API Discovery', text: `discover_apis failed: ${e.message}`, origin: 'live' });
331
+ }
332
+ } else if (profile?.apiEndpoints?.length) {
333
+ sections.push({
334
+ label: 'API Discovery',
335
+ text: `Using ${profile.apiEndpoints.length} saved endpoint(s); top=${profile.apiEndpoints.slice(0, 3).map((endpoint) => `${endpoint.method} ${endpoint.url}`).join(' | ')}`,
336
+ origin: 'profile'
337
+ });
338
+ }
339
+
340
+ if (!ranAnalyze && !ranExtract && !ranDiscover && !profile) {
341
+ try {
342
+ await callCaptureScreenshot(bridgeClient, tabId);
343
+ sections.push({
344
+ label: 'Visual Fallback',
345
+ text: 'No profile and no live discovery signals available. Screenshot captured; call `capture_screenshot()` to inspect the page visually.',
346
+ origin: 'live'
347
+ });
348
+ } catch {
349
+ // Non-fatal.
350
+ }
351
+ }
352
+
353
+ profile = domain ? loadProfile(domain) : profile;
354
+ const hints = buildInteractionHints(analyzeText, intent, profile, { ranAnalyze, ranExtract, ranDiscover });
355
+ const discoveryMode = getDiscoveryMode({ usedProfile, ranAnalyze, ranExtract, ranDiscover });
356
+ const lines = ['## quick_scan', ''];
357
+
358
+ if (domain) lines.push(`**Site:** ${domain}`);
359
+ if (tabId !== undefined) lines.push(`**Tab ID:** ${tabId}`);
360
+ if (tabUrl) lines.push(`**Tab match:** ${tabUrl}`);
361
+ if (intent) lines.push(`**Intent:** ${intent}`);
362
+ lines.push(`**Discovery mode:** ${discoveryMode}`);
363
+ lines.push('');
364
+
365
+ if (profile) {
366
+ lines.push(`**Profile freshness:** framework=${isFieldFresh(domain, 'framework') ? 'fresh' : 'stale'}, dataSchema=${isFieldFresh(domain, 'dataSchema') ? 'fresh' : 'stale'}, apiEndpoints=${isFieldFresh(domain, 'apiEndpoints') ? 'fresh' : 'stale'}`);
367
+ lines.push('');
368
+ }
369
+
370
+ for (const section of sections) {
371
+ if (!section.text || section.text.trim() === '') continue;
372
+ const prefix = section.origin === 'profile' ? '[profile]' : '[live]';
373
+ if (verbose) {
374
+ lines.push(`### ${section.label} ${prefix}`);
375
+ lines.push(section.text.trim());
376
+ lines.push('');
377
+ } else {
378
+ lines.push(`**${section.label} ${prefix}:** ${summarize(section.text, 3)}`);
379
+ lines.push('');
380
+ }
381
+ }
382
+
383
+ if (profile?.autoNotes?.length) {
384
+ lines.push('### Profile Highlights');
385
+ profile.autoNotes.slice(-4).forEach((entry) => {
386
+ lines.push(`- [${entry.source}] ${entry.text}`);
387
+ });
388
+ lines.push('');
389
+ }
390
+
391
+ if (hints.length > 0) {
392
+ lines.push('### Interaction Hints');
393
+ hints.forEach((hint) => lines.push(`- ${hint}`));
394
+ lines.push('');
395
+ }
396
+
397
+ // CSP detection: save to profile + show warning
398
+ const profileCsp = profile?.cspStatus === 'strict';
399
+ if (cspBlocked && domain) {
400
+ enrichProfile(domain, 'csp_detection', { level: 'strict' });
401
+ }
402
+
403
+ lines.push('---');
404
+ lines.push('**Next steps:**');
405
+ if (cspBlocked || profileCsp) {
406
+ lines.push(`- ⚠️ **CSP strict site** — \`execute_js\` auto-bypasses CSP (tab reloads once). \`discover_apis()\` works without CSP.`);
407
+ }
408
+ if (profile) lines.push(`- \`manage_site_profile({ action: "load", domain: "${domain}" })\` -> inspect the full site dossier`);
409
+ if (profile && (ranAnalyze || ranExtract || ranDiscover)) lines.push(`- \`manage_site_profile({ action: "save", domain: "${domain}" })\` -> update profile with fresh live data`);
410
+ lines.push('- `get_element({ css: "SELECTOR" })` -> target the exact field you want to scrape');
411
+ lines.push('- `get_network_trace()` -> connect a DOM value to its API response when needed');
412
+ if (profile && !refreshProfile) lines.push(`- \`manage_site_profile({ action: "check", domain: "${domain}" })\` -> verify drift before long scraping runs`);
413
+
414
+ return {
415
+ content: [{ type: 'text', text: lines.join('\n') }]
416
+ };
417
+ }
418
+ };
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Screenshot Tool for Visual Feedback
3
+ * Ajanın sayfanın görünür alanının (viewport) ekran görüntüsünü almasını sağlar
4
+ *
5
+ * Phase 2.4: Refactored from (args, extensionData, httpPort) to (args, bridgeClient).
6
+ */
7
+
8
+ export const captureScreenshotTool = {
9
+ name: 'capture_screenshot',
10
+ description: `This is a tool from the dombridge MCP server.
11
+ Take a screenshot of a Chrome tab's visible area (viewport).
12
+
13
+ WORKFLOW POSITION: 🔧 Visual Feedback - Use when actions fail or visual confirmation is needed
14
+
15
+ PREREQUISITES:
16
+ - ✅ Extension must be connected
17
+
18
+ FEATURES:
19
+ - Returns a Base64 encoded JPEG image data URL of the current visible screen.
20
+ - High performance (JPEG, 80% quality) limits size to ~1MB.
21
+ - Best used to verify if UI has changed or why elements can't be interacted with.
22
+
23
+ MULTI-TAB: Call debug_mcp_state() first to get tab IDs, then pass tabId to screenshot a specific tab.
24
+
25
+ Use this tool if execute_action fails multiple times or if you need to visually check the page state.`,
26
+ inputSchema: {
27
+ type: 'object',
28
+ properties: {
29
+ tabId: {
30
+ type: 'number',
31
+ description: 'Target tab ID (optional). Omit to use active tab. Get IDs from debug_mcp_state().'
32
+ }
33
+ },
34
+ required: []
35
+ },
36
+ handler: async (args, bridgeClient) => {
37
+ if (!bridgeClient.isConnected) {
38
+ return {
39
+ content: [
40
+ {
41
+ type: 'text',
42
+ text: `❌ Error: Extension not connected.\n\nREQUIRED STEPS:\n1. Ensure Chrome extension is active\n2. Reload target page`
43
+ }
44
+ ],
45
+ isError: true
46
+ };
47
+ }
48
+
49
+ const { tabId } = args || {};
50
+ const requestId = `screenshot-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
51
+
52
+ try {
53
+ // Send request via bridge daemon
54
+ await bridgeClient.queueRequest('capture-screenshot', {
55
+ id: requestId,
56
+ ...(tabId ? { tabId } : {})
57
+ });
58
+
59
+ // Wait for extension to process and return result
60
+ const timeout = 15000; // Screenshots may take longer
61
+ const resultItem = await bridgeClient.waitForResult('capture-screenshot', requestId, timeout + 3000);
62
+
63
+ if (resultItem) {
64
+ if (resultItem.error) {
65
+ return {
66
+ content: [
67
+ {
68
+ type: 'text',
69
+ text: `❌ Screenshot Error: ${resultItem.error}\n\nREQUIRED STEPS:\n1. Check page compatibility and permissions`
70
+ }
71
+ ],
72
+ isError: true
73
+ };
74
+ }
75
+
76
+ // MCP SDK protocol format: flat data + mimeType fields
77
+ const base64Data = resultItem.dataUrl.split(',')[1];
78
+ const mimeType = resultItem.dataUrl.substring(resultItem.dataUrl.indexOf(':') + 1, resultItem.dataUrl.indexOf(';')) || 'image/jpeg';
79
+
80
+ return {
81
+ content: [
82
+ {
83
+ type: 'text',
84
+ text: `✅ Screenshot captured successfully. Here is the visual context:`
85
+ },
86
+ {
87
+ type: 'image',
88
+ data: base64Data,
89
+ mimeType: mimeType
90
+ }
91
+ ]
92
+ };
93
+ }
94
+
95
+ return {
96
+ content: [
97
+ {
98
+ type: 'text',
99
+ text: `❌ Action Timeout: The extension did not report back within ${timeout}ms.`
100
+ }
101
+ ],
102
+ isError: true
103
+ };
104
+
105
+ } catch (e) {
106
+ return {
107
+ content: [
108
+ {
109
+ type: 'text',
110
+ text: `❌ Server Error: ${e.message}\n\nREQUIRED STEPS:\n1. Check daemon status\n2. Reconnect extension`
111
+ }
112
+ ],
113
+ isError: true
114
+ };
115
+ }
116
+ }
117
+ };
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Circuit Breaker Utility
3
+ * Extension tool çağrılarını hata durumunda korur.
4
+ *
5
+ * States:
6
+ * CLOSED — normal, requests pass through
7
+ * OPEN — 3 consecutive failures → requests rejected immediately
8
+ * HALF_OPEN — after 30s cooldown, 1 probe request allowed
9
+ */
10
+
11
+ const FAILURE_THRESHOLD = 3;
12
+ const RECOVERY_TIMEOUT_MS = 30000;
13
+
14
+ export class CircuitBreaker {
15
+ constructor(name) {
16
+ this.name = name;
17
+ this.state = 'CLOSED';
18
+ this.failures = 0;
19
+ this.lastFailureTime = null;
20
+ this.probePending = false;
21
+ }
22
+
23
+ /**
24
+ * Execute a function through the circuit breaker.
25
+ * @param {Function} fn — async function to execute
26
+ * @returns {Promise<any>} result of fn, or throws if circuit is OPEN
27
+ */
28
+ async execute(fn) {
29
+ if (this.state === 'OPEN') {
30
+ const elapsed = Date.now() - this.lastFailureTime;
31
+ if (elapsed >= RECOVERY_TIMEOUT_MS) {
32
+ this.state = 'HALF_OPEN';
33
+ this.probePending = false;
34
+ } else {
35
+ const remaining = Math.ceil((RECOVERY_TIMEOUT_MS - elapsed) / 1000);
36
+ throw new Error(
37
+ `Circuit breaker OPEN for "${this.name}". Retry in ${remaining}s.`
38
+ );
39
+ }
40
+ }
41
+
42
+ if (this.state === 'HALF_OPEN') {
43
+ if (this.probePending) {
44
+ throw new Error(
45
+ `Circuit breaker HALF_OPEN for "${this.name}". Probe in progress, retry shortly.`
46
+ );
47
+ }
48
+ this.probePending = true;
49
+ }
50
+
51
+ try {
52
+ const result = await fn();
53
+ this._onSuccess();
54
+ return result;
55
+ } catch (err) {
56
+ this._onFailure();
57
+ throw err;
58
+ }
59
+ }
60
+
61
+ _onSuccess() {
62
+ this.failures = 0;
63
+ this.state = 'CLOSED';
64
+ this.probePending = false;
65
+ }
66
+
67
+ _onFailure() {
68
+ this.failures++;
69
+ this.lastFailureTime = Date.now();
70
+ this.probePending = false;
71
+ if (this.failures >= FAILURE_THRESHOLD) {
72
+ this.state = 'OPEN';
73
+ }
74
+ }
75
+
76
+ getStatus() {
77
+ return {
78
+ name: this.name,
79
+ state: this.state,
80
+ failures: this.failures,
81
+ lastFailureTime: this.lastFailureTime
82
+ };
83
+ }
84
+
85
+ reset() {
86
+ this.state = 'CLOSED';
87
+ this.failures = 0;
88
+ this.lastFailureTime = null;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Pre-created circuit breakers for each tool type
94
+ */
95
+ export const circuitBreakers = {
96
+ executeJs: new CircuitBreaker('execute_js'),
97
+ executeAction: new CircuitBreaker('execute_action'),
98
+ captureScreenshot: new CircuitBreaker('capture_screenshot'),
99
+ discoverApis: new CircuitBreaker('discover_apis'),
100
+ selectElement: new CircuitBreaker('select_element')
101
+ };
102
+
103
+ /**
104
+ * Reset all circuit breakers to CLOSED state.
105
+ * Called when extension reconnects after a disconnect — breakers that tripped
106
+ * during the disconnected period should not penalize the newly connected extension.
107
+ */
108
+ export function resetAllCircuitBreakers() {
109
+ for (const cb of Object.values(circuitBreakers)) {
110
+ cb.reset();
111
+ }
112
+ }
@@ -0,0 +1,21 @@
1
+ export function computeSourceDensity(source) {
2
+ const leafCount = (source.leafPaths || []).length;
3
+ const topKeyCount = Object.keys(source.schema || {}).filter(k => !k.startsWith('__')).length;
4
+
5
+ if (leafCount <= 1 && topKeyCount <= 1) return 'noise';
6
+ if (leafCount >= 20) return 'rich';
7
+ return 'normal';
8
+ }
9
+
10
+ export function filterNoiseSources(sources) {
11
+ if (sources.length <= 5) return sources;
12
+
13
+ const annotated = sources.map((s) => ({ source: s, density: computeSourceDensity(s) }));
14
+ const noiseCount = annotated.filter(a => a.density === 'noise').length;
15
+
16
+ // Only filter noise if >50% of sources are noise AND there are >5 sources
17
+ if (noiseCount > sources.length / 2) {
18
+ return annotated.filter(a => a.density !== 'noise').map(a => a.source);
19
+ }
20
+ return sources;
21
+ }