mcp-web-inspector 0.5.0 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -3
- package/dist/tools/browser/base.d.ts +9 -9
- package/dist/tools/browser/base.js +55 -20
- package/dist/tools/browser/console/__tests__/console.test.js +42 -0
- package/dist/tools/browser/console/get_console_logs.js +3 -3
- package/dist/tools/browser/content/__tests__/screenshot.test.js +63 -45
- package/dist/tools/browser/content/get_html.js +2 -2
- package/dist/tools/browser/content/screenshot.js +58 -76
- package/dist/tools/browser/evaluation/evaluate.js +2 -2
- package/dist/tools/browser/inspection/compare_element_alignment.js +37 -13
- package/dist/tools/browser/inspection/inspect_dom.js +9 -5
- package/dist/tools/browser/inspection/query_selector.js +3 -1
- package/dist/tools/common/confirm_output.d.ts +26 -0
- package/dist/tools/common/confirm_output.js +77 -3
- package/dist/tools/common/types.js +26 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -595,7 +595,7 @@ COMPARE TWO ELEMENTS: Get comprehensive alignment and dimension comparison in on
|
|
|
595
595
|
- selector2 (string, required): CSS selector, text selector, or testid shorthand for the second element (e.g., 'testid:chat-header', '#secondary-header')
|
|
596
596
|
|
|
597
597
|
- Output Format:
|
|
598
|
-
- Optional warnings when a selector matched multiple elements (
|
|
598
|
+
- Optional warnings when a selector matched multiple elements (uses first visible; suggests adding unique data-testid).
|
|
599
599
|
- Header: Alignment: <elem1> vs <elem2>
|
|
600
600
|
- Two lines with each element's position and size: @ (x,y) w×h px
|
|
601
601
|
- Edges block: Top/Left/Right/Bottom with ✓/✗ and diffs
|
|
@@ -1031,8 +1031,6 @@ Upload a file to an input[type='file'] element on the page
|
|
|
1031
1031
|
|
|
1032
1032
|
⚠️ Token cost: ~1,500 tokens to read. Structural tools: <100 tokens.
|
|
1033
1033
|
|
|
1034
|
-
Admin control (optional): set env MCP_SCREENSHOT_GUARD=strict to block execution (prevents misuse by default). Unset to allow visuals for human review.
|
|
1035
|
-
|
|
1036
1034
|
Screenshots saved to ./.mcp-web-inspector/screenshots. Example: { name: "login-page", fullPage: true } or { name: "submit-btn", selector: "testid:submit" }
|
|
1037
1035
|
|
|
1038
1036
|
- Parameters:
|
|
@@ -12,14 +12,14 @@ export declare abstract class BrowserToolBase implements ToolHandler {
|
|
|
12
12
|
*/
|
|
13
13
|
abstract execute(args: any, context: ToolContext): Promise<ToolResponse>;
|
|
14
14
|
/**
|
|
15
|
-
* Normalize selector shortcuts
|
|
16
|
-
* - "testid:foo" → "[data-testid
|
|
17
|
-
* - "data-test:bar" → "[data-test
|
|
18
|
-
* - "data-cy:baz" → "[data-cy
|
|
19
|
-
* -
|
|
20
|
-
*
|
|
21
|
-
* -
|
|
22
|
-
*
|
|
15
|
+
* Normalize selector shortcuts and fix common escaping mistakes safely.
|
|
16
|
+
* - "testid:foo" → "[data-testid=\"foo\"]"
|
|
17
|
+
* - "data-test:bar" → "[data-test=\"bar\"]"
|
|
18
|
+
* - "data-cy:baz" → "[data-cy=\"baz\"]"
|
|
19
|
+
* - Convert simple ID-only selectors with special chars to Playwright's id engine:
|
|
20
|
+
* "#radix-\:rc\:-content-123" → "id=radix-:rc:-content-123"
|
|
21
|
+
* - Remove unnecessary escapes for bracket characters only (\\[ and \\])
|
|
22
|
+
* DO NOT unescape colons globally — colons in class/ID names must stay escaped in CSS.
|
|
23
23
|
* @param selector The selector string
|
|
24
24
|
* @returns Normalized selector
|
|
25
25
|
*/
|
|
@@ -28,7 +28,7 @@ export declare abstract class BrowserToolBase implements ToolHandler {
|
|
|
28
28
|
* Sanitize verbose Playwright selector engine messages by removing stack traces and
|
|
29
29
|
* keeping only the essential syntax error information.
|
|
30
30
|
*/
|
|
31
|
-
|
|
31
|
+
protected sanitizeSelectorEngineMessage(msg: string): string;
|
|
32
32
|
/**
|
|
33
33
|
* Ensures a page is available and returns it
|
|
34
34
|
* @param context The tool context containing browser and page
|
|
@@ -8,14 +8,14 @@ export class BrowserToolBase {
|
|
|
8
8
|
this.server = server;
|
|
9
9
|
}
|
|
10
10
|
/**
|
|
11
|
-
* Normalize selector shortcuts
|
|
12
|
-
* - "testid:foo" → "[data-testid
|
|
13
|
-
* - "data-test:bar" → "[data-test
|
|
14
|
-
* - "data-cy:baz" → "[data-cy
|
|
15
|
-
* -
|
|
16
|
-
*
|
|
17
|
-
* -
|
|
18
|
-
*
|
|
11
|
+
* Normalize selector shortcuts and fix common escaping mistakes safely.
|
|
12
|
+
* - "testid:foo" → "[data-testid=\"foo\"]"
|
|
13
|
+
* - "data-test:bar" → "[data-test=\"bar\"]"
|
|
14
|
+
* - "data-cy:baz" → "[data-cy=\"baz\"]"
|
|
15
|
+
* - Convert simple ID-only selectors with special chars to Playwright's id engine:
|
|
16
|
+
* "#radix-\:rc\:-content-123" → "id=radix-:rc:-content-123"
|
|
17
|
+
* - Remove unnecessary escapes for bracket characters only (\\[ and \\])
|
|
18
|
+
* DO NOT unescape colons globally — colons in class/ID names must stay escaped in CSS.
|
|
19
19
|
* @param selector The selector string
|
|
20
20
|
* @returns Normalized selector
|
|
21
21
|
*/
|
|
@@ -32,14 +32,33 @@ export class BrowserToolBase {
|
|
|
32
32
|
return `[${attr}="${value}"]`;
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
|
-
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
35
|
+
const trimmed = selector.trim();
|
|
36
|
+
// Helper: unescape simple backslash-escapes used inside IDs (e.g., \:, \[, \])
|
|
37
|
+
const unescapeCssIdentifier = (s) => {
|
|
38
|
+
// Collapse multiple backslashes before a single char to the char itself
|
|
39
|
+
return s
|
|
40
|
+
.replace(/\\+:/g, ':')
|
|
41
|
+
.replace(/\\+\[/g, '[')
|
|
42
|
+
.replace(/\\+\]/g, ']');
|
|
43
|
+
};
|
|
44
|
+
// If this looks like a simple, standalone ID selector (no combinators or descendants),
|
|
45
|
+
// switch to Playwright's id engine. This avoids CSS escaping pitfalls with colons.
|
|
46
|
+
if (/^#[^\s>+~]+$/.test(trimmed)) {
|
|
47
|
+
const idToken = trimmed.slice(1);
|
|
48
|
+
// Only switch to id= engine if ID contains characters that commonly break CSS (#... with colons or escapes)
|
|
49
|
+
if (idToken.includes('\\') || idToken.includes(':') || idToken.includes('[') || idToken.includes(']')) {
|
|
50
|
+
const idValue = unescapeCssIdentifier(idToken);
|
|
51
|
+
return `id=${idValue}`;
|
|
52
|
+
}
|
|
53
|
+
// Otherwise, keep simple IDs as-is
|
|
54
|
+
return trimmed;
|
|
55
|
+
}
|
|
56
|
+
// For general CSS selectors, preserve required escapes for special chars.
|
|
57
|
+
// Collapse over-escaping (e.g., \\\\[ → \\[, but keep a single backslash before [ ] :)
|
|
58
|
+
let cleaned = trimmed;
|
|
59
|
+
cleaned = cleaned.replace(/\\{2,}(?=\[)/g, '\\');
|
|
60
|
+
cleaned = cleaned.replace(/\\{2,}(?=\])/g, '\\');
|
|
61
|
+
cleaned = cleaned.replace(/\\{2,}(?=:)/g, '\\');
|
|
43
62
|
return cleaned;
|
|
44
63
|
}
|
|
45
64
|
/**
|
|
@@ -274,12 +293,19 @@ export class BrowserToolBase {
|
|
|
274
293
|
* @returns Formatted string or empty if only one element
|
|
275
294
|
*/
|
|
276
295
|
formatElementSelectionInfo(selector, elementIndex, totalCount, preferredVisible = true) {
|
|
296
|
+
const usesNth = selector.includes('>> nth=');
|
|
277
297
|
if (totalCount <= 1) {
|
|
298
|
+
// Even when a single element is ultimately targeted, discourage nth usage
|
|
299
|
+
// because it is brittle across layout/content changes.
|
|
300
|
+
if (usesNth) {
|
|
301
|
+
return "💡 Tip: Selector uses '>> nth='. Prefer adding a unique data-testid for robust selection.";
|
|
302
|
+
}
|
|
278
303
|
return '';
|
|
279
304
|
}
|
|
280
305
|
const duplicateWarning = this.getDuplicateTestIdWarning(selector, totalCount).trimEnd();
|
|
281
306
|
const nthHint = this.buildNthSelectorHint(selector, totalCount).trimEnd();
|
|
282
|
-
const
|
|
307
|
+
const avoidNth = usesNth ? "💡 Tip: Avoid relying on '>> nth='; add a unique data-testid instead." : '';
|
|
308
|
+
const extraHints = [duplicateWarning, nthHint, avoidNth].filter(Boolean).join('\n');
|
|
283
309
|
const baseMessage = preferredVisible
|
|
284
310
|
? `⚠ Found ${totalCount} elements matching "${selector}", using element ${elementIndex + 1} (first visible)`
|
|
285
311
|
: `⚠ Found ${totalCount} elements matching "${selector}", using element ${elementIndex + 1}`;
|
|
@@ -302,10 +328,14 @@ export class BrowserToolBase {
|
|
|
302
328
|
selector.startsWith('data-cy:') ||
|
|
303
329
|
selector.match(/^\[data-(testid|test|cy)=/);
|
|
304
330
|
if (isTestIdSelector) {
|
|
305
|
-
return `💡 Tip: Test IDs should be unique. Consider making this test ID unique to avoid ambiguity.\n
|
|
331
|
+
return (`💡 Tip: Test IDs should be unique. Consider making this test ID unique to avoid ambiguity.\n` +
|
|
332
|
+
` Primary fix: assign a unique data-testid to the intended element.\n` +
|
|
333
|
+
` Workaround: if you cannot change markup, you may use '>> nth=<index>' temporarily.\n\n`);
|
|
306
334
|
}
|
|
307
335
|
// Suggest testid for non-testid selectors
|
|
308
|
-
return `💡 Tip: Consider adding a unique data-testid attribute for more reliable selection.\n
|
|
336
|
+
return (`💡 Tip: Consider adding a unique data-testid attribute for more reliable selection.\n` +
|
|
337
|
+
` Primary fix: add data-testid and target it (e.g., testid:submit).\n` +
|
|
338
|
+
` Workaround: use '>> nth=<index>' only when you can't add test IDs.\n\n`);
|
|
309
339
|
}
|
|
310
340
|
/**
|
|
311
341
|
* Provide a hint for using >> nth= when multiple elements match a selector
|
|
@@ -321,6 +351,11 @@ export class BrowserToolBase {
|
|
|
321
351
|
const firstExample = `${trimmed} >> nth=0`;
|
|
322
352
|
const lastIndex = Math.max(totalCount - 1, 1);
|
|
323
353
|
const lastExample = `${trimmed} >> nth=${lastIndex}`;
|
|
324
|
-
return
|
|
354
|
+
return (`Primary fix: add a unique data-testid to the intended element and select it directly.\n` +
|
|
355
|
+
`Workaround: Append ">> nth=<index>" to target a specific match when you cannot change markup.\n` +
|
|
356
|
+
` Example: ${firstExample} (first match)\n` +
|
|
357
|
+
` Or: ${lastExample} (last match)\n` +
|
|
358
|
+
`Note: nth selectors are brittle and may break with layout/content changes.\n` +
|
|
359
|
+
`Prefer unique data-testid attributes for long-term stability.`);
|
|
325
360
|
}
|
|
326
361
|
}
|
|
@@ -195,4 +195,46 @@ describe('GetConsoleLogsTool', () => {
|
|
|
195
195
|
// (The truncation happens in toolHandler.ts before calling registerConsoleMessage)
|
|
196
196
|
expect(logsText).toContain(longStackError);
|
|
197
197
|
});
|
|
198
|
+
test('raw format preview should include confirm token and confirmation returns full payload', async () => {
|
|
199
|
+
// Generate enough long logs to exceed preview threshold
|
|
200
|
+
for (let i = 0; i < 40; i++) {
|
|
201
|
+
consoleLogsTool.registerConsoleMessage('log', `RAW-LONG-${i} ` + 'X'.repeat(160));
|
|
202
|
+
}
|
|
203
|
+
const previewResult = await consoleLogsTool.execute({ format: 'raw' }, mockContext);
|
|
204
|
+
expect(previewResult.isError).toBe(false);
|
|
205
|
+
const previewText = previewResult.content.map(c => c.text).join('\n');
|
|
206
|
+
// Should include confirm_output token reference
|
|
207
|
+
expect(previewText).toMatch(/confirm_output\(\{ token: \"([\w\d]+)\" \}\)/);
|
|
208
|
+
const token = (previewText.match(/confirm_output\(\{ token: \"([\w\d]+)\" \}\)/) || [])[1];
|
|
209
|
+
// Confirm to retrieve full payload
|
|
210
|
+
const { ConfirmOutputTool } = await import('../../../common/confirm_output.js');
|
|
211
|
+
const confirmTool = new ConfirmOutputTool({});
|
|
212
|
+
const full = await confirmTool.execute({ token }, mockContext);
|
|
213
|
+
expect(full.isError).toBe(false);
|
|
214
|
+
const fullText = full.content.map(c => c.text).join('\n');
|
|
215
|
+
// By default limit=20 → header should reflect 20 lines
|
|
216
|
+
expect(fullText).toContain('Retrieved 20 console log(s):');
|
|
217
|
+
// And include one of our long messages
|
|
218
|
+
expect(fullText).toContain('RAW-LONG-39');
|
|
219
|
+
});
|
|
220
|
+
test('grouped format preview should include confirm token and confirmation returns full payload', async () => {
|
|
221
|
+
// Generate 30 unique messages to form distinct groups and exceed threshold
|
|
222
|
+
for (let i = 0; i < 30; i++) {
|
|
223
|
+
consoleLogsTool.registerConsoleMessage('log', `GROUP-LONG-${i} ` + 'G'.repeat(160));
|
|
224
|
+
}
|
|
225
|
+
const previewResult = await consoleLogsTool.execute({}, mockContext); // grouped is default
|
|
226
|
+
expect(previewResult.isError).toBe(false);
|
|
227
|
+
const previewText = previewResult.content.map(c => c.text).join('\n');
|
|
228
|
+
expect(previewText).toMatch(/confirm_output\(\{ token: \"([\w\d]+)\" \}\)/);
|
|
229
|
+
const token = (previewText.match(/confirm_output\(\{ token: \"([\w\d]+)\" \}\)/) || [])[1];
|
|
230
|
+
const { ConfirmOutputTool } = await import('../../../common/confirm_output.js');
|
|
231
|
+
const confirmTool = new ConfirmOutputTool({});
|
|
232
|
+
const full = await confirmTool.execute({ token }, mockContext);
|
|
233
|
+
expect(full.isError).toBe(false);
|
|
234
|
+
const fullText = full.content.map(c => c.text).join('\n');
|
|
235
|
+
// Header reflects number of groups shown (limit default 20)
|
|
236
|
+
expect(fullText).toContain('Retrieved 20 console log(s):');
|
|
237
|
+
// Should include one of the first 20 grouped messages
|
|
238
|
+
expect(fullText).toContain('GROUP-LONG-0');
|
|
239
|
+
});
|
|
198
240
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { BrowserToolBase } from '../base.js';
|
|
2
2
|
import { createSuccessResponse } from '../../common/types.js';
|
|
3
|
-
import { makeConfirmPreview } from '../../common/
|
|
3
|
+
import { makeConfirmPreview } from '../../common/confirm_output.js';
|
|
4
4
|
/**
|
|
5
5
|
* Tool for retrieving and filtering console logs from the browser
|
|
6
6
|
*/
|
|
@@ -122,7 +122,7 @@ export class GetConsoleLogsTool extends BrowserToolBase {
|
|
|
122
122
|
`Matched ${logs.length} log(s). Showing ${Math.min(messages.length, 10)} line(s) preview.`,
|
|
123
123
|
...messages.slice(0, Math.min(messages.length, 10)),
|
|
124
124
|
];
|
|
125
|
-
const preview = makeConfirmPreview(textPayload, {
|
|
125
|
+
const preview = makeConfirmPreview(() => textPayload, {
|
|
126
126
|
counts: { totalLength: textPayload.length, shownLength: previewLines.join('\n').length, totalMatched: logs.length, shownCount: Math.min(messages.length, 10), truncated: true },
|
|
127
127
|
previewLines,
|
|
128
128
|
extraTips: ['Tip: refine with search/type/since/limit or prefer grouped format.'],
|
|
@@ -163,7 +163,7 @@ export class GetConsoleLogsTool extends BrowserToolBase {
|
|
|
163
163
|
`Matched ${groups.size} group(s). Showing ${limitedGroups.length}.`,
|
|
164
164
|
...lines.slice(0, Math.min(lines.length, 12)),
|
|
165
165
|
];
|
|
166
|
-
const preview = makeConfirmPreview(textPayload, {
|
|
166
|
+
const preview = makeConfirmPreview(() => textPayload, {
|
|
167
167
|
counts: { totalLength: textPayload.length, shownLength: previewLines.join('\n').length, totalMatched: groups.size, shownCount: limitedGroups.length, truncated: true },
|
|
168
168
|
previewLines,
|
|
169
169
|
extraTips: ['Tip: refine with search/type/since/limit.'],
|
|
@@ -52,47 +52,65 @@ describe('ScreenshotTool', () => {
|
|
|
52
52
|
afterEach(() => {
|
|
53
53
|
jest.restoreAllMocks();
|
|
54
54
|
});
|
|
55
|
-
test('should take a full page screenshot', async () => {
|
|
55
|
+
test('should take a full page screenshot (via confirm_output)', async () => {
|
|
56
56
|
const args = {
|
|
57
57
|
name: 'test-screenshot',
|
|
58
58
|
fullPage: true
|
|
59
59
|
};
|
|
60
|
-
//
|
|
60
|
+
// First call returns preview with confirm token; no screenshot yet
|
|
61
|
+
const previewResult = await screenshotTool.execute(args, mockContext);
|
|
62
|
+
expect(previewResult.isError).toBe(false);
|
|
63
|
+
const text = previewResult.content.map(c => c.text).join('\n');
|
|
64
|
+
expect(text).toMatch(/confirm_output\(\{ token: \"[\w\d]+\" \}\)/);
|
|
65
|
+
// Extract token and confirm
|
|
66
|
+
const tokenMatch = text.match(/confirm_output\(\{ token: \"([\w\d]+)\" \}\)/);
|
|
67
|
+
expect(tokenMatch).not.toBeNull();
|
|
68
|
+
const token = tokenMatch[1];
|
|
69
|
+
const { ConfirmOutputTool } = await import('../../../common/confirm_output.js');
|
|
70
|
+
const confirmTool = new ConfirmOutputTool({});
|
|
61
71
|
const screenshotBuffer = Buffer.from('mock-screenshot');
|
|
62
72
|
mockScreenshot.mockImplementationOnce(() => Promise.resolve(screenshotBuffer));
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
expect(
|
|
66
|
-
|
|
67
|
-
type: 'png'
|
|
68
|
-
}));
|
|
69
|
-
// Check that the result contains success message
|
|
70
|
-
expect(result.isError).toBe(false);
|
|
71
|
-
expect(result.content[0].text).toContain('Screenshot saved to');
|
|
73
|
+
const finalResult = await confirmTool.execute({ token }, mockContext);
|
|
74
|
+
expect(finalResult.isError).toBe(false);
|
|
75
|
+
expect(finalResult.content[0].text).toContain('Screenshot saved to');
|
|
76
|
+
expect(mockScreenshot).toHaveBeenCalledWith(expect.objectContaining({ fullPage: true, type: 'png' }));
|
|
72
77
|
});
|
|
73
|
-
test('should handle element screenshot', async () => {
|
|
78
|
+
test('should handle element screenshot (via confirm_output)', async () => {
|
|
74
79
|
const args = {
|
|
75
80
|
name: 'test-element-screenshot',
|
|
76
81
|
selector: '#test-element'
|
|
77
82
|
};
|
|
78
|
-
|
|
83
|
+
const previewResult = await screenshotTool.execute(args, mockContext);
|
|
84
|
+
expect(previewResult.isError).toBe(false);
|
|
85
|
+
const text = previewResult.content.map(c => c.text).join('\n');
|
|
86
|
+
const tokenMatch = text.match(/confirm_output\(\{ token: \"([\w\d]+)\" \}\)/);
|
|
87
|
+
expect(tokenMatch).not.toBeNull();
|
|
88
|
+
const token = tokenMatch[1];
|
|
89
|
+
const { ConfirmOutputTool } = await import('../../../common/confirm_output.js');
|
|
90
|
+
const confirmTool = new ConfirmOutputTool({});
|
|
79
91
|
const screenshotBuffer = Buffer.from('mock-element-screenshot');
|
|
80
92
|
mockLocatorScreenshot.mockImplementationOnce(() => Promise.resolve(screenshotBuffer));
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
expect(
|
|
84
|
-
expect(result.content[0].text).toContain('Screenshot saved to');
|
|
93
|
+
const finalResult = await confirmTool.execute({ token }, mockContext);
|
|
94
|
+
expect(finalResult.isError).toBe(false);
|
|
95
|
+
expect(finalResult.content[0].text).toContain('Screenshot saved to');
|
|
85
96
|
});
|
|
86
|
-
test('should handle screenshot errors', async () => {
|
|
97
|
+
test('should handle screenshot errors (on confirm)', async () => {
|
|
87
98
|
const args = {
|
|
88
99
|
name: 'test-screenshot'
|
|
89
100
|
};
|
|
90
|
-
|
|
101
|
+
const previewResult = await screenshotTool.execute(args, mockContext);
|
|
102
|
+
expect(previewResult.isError).toBe(false);
|
|
103
|
+
const text = previewResult.content.map(c => c.text).join('\n');
|
|
104
|
+
const tokenMatch = text.match(/confirm_output\(\{ token: \"([\w\d]+)\" \}\)/);
|
|
105
|
+
expect(tokenMatch).not.toBeNull();
|
|
106
|
+
const token = tokenMatch[1];
|
|
107
|
+
const { ConfirmOutputTool } = await import('../../../common/confirm_output.js');
|
|
108
|
+
const confirmTool = new ConfirmOutputTool({});
|
|
109
|
+
// Mock a screenshot error on confirmation
|
|
91
110
|
mockScreenshot.mockImplementationOnce(() => Promise.reject(new Error('Screenshot failed')));
|
|
92
|
-
const
|
|
93
|
-
expect(
|
|
94
|
-
expect(
|
|
95
|
-
expect(result.content[0].text).toContain('Operation failed');
|
|
111
|
+
const finalResult = await confirmTool.execute({ token }, mockContext);
|
|
112
|
+
expect(finalResult.isError).toBe(true);
|
|
113
|
+
expect(finalResult.content[0].text).toContain('Screenshot failed');
|
|
96
114
|
});
|
|
97
115
|
test('should handle missing page', async () => {
|
|
98
116
|
const args = {
|
|
@@ -108,60 +126,60 @@ describe('ScreenshotTool', () => {
|
|
|
108
126
|
expect(result.isError).toBe(true);
|
|
109
127
|
expect(result.content[0].text).toContain('Browser page not initialized');
|
|
110
128
|
});
|
|
111
|
-
test('should store screenshots in a map', async () => {
|
|
129
|
+
test('should store screenshots in a map (after confirm)', async () => {
|
|
112
130
|
const args = {
|
|
113
131
|
name: 'test-screenshot',
|
|
114
132
|
storeBase64: true
|
|
115
133
|
};
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
134
|
+
const preview = await screenshotTool.execute(args, mockContext);
|
|
135
|
+
const token = (preview.content.map(c => c.text).join('\n').match(/confirm_output\(\{ token: \"([\w\d]+)\" \}\)/) || [])[1];
|
|
136
|
+
const { ConfirmOutputTool } = await import('../../../common/confirm_output.js');
|
|
137
|
+
const confirmTool = new ConfirmOutputTool({});
|
|
138
|
+
mockScreenshot.mockImplementationOnce(() => Promise.resolve(Buffer.from('mock-screenshot')));
|
|
139
|
+
await confirmTool.execute({ token }, mockContext);
|
|
120
140
|
// Check that the screenshot was stored in the map
|
|
121
141
|
const screenshots = screenshotTool.getScreenshots();
|
|
122
142
|
expect(screenshots.has('test-screenshot')).toBe(true);
|
|
123
143
|
});
|
|
124
|
-
test('should take a screenshot with specific browser type', async () => {
|
|
144
|
+
test('should take a screenshot with specific browser type (via confirm_output)', async () => {
|
|
125
145
|
const args = {
|
|
126
146
|
name: 'browser-type-test',
|
|
127
147
|
browserType: 'firefox'
|
|
128
148
|
};
|
|
129
|
-
|
|
130
|
-
const
|
|
149
|
+
const preview = await screenshotTool.execute(args, mockContext);
|
|
150
|
+
const text = preview.content.map(c => c.text).join('\n');
|
|
151
|
+
const token = (text.match(/confirm_output\(\{ token: \"([\w\d]+)\" \}\)/) || [])[1];
|
|
152
|
+
const { ConfirmOutputTool } = await import('../../../common/confirm_output.js');
|
|
153
|
+
const confirmTool = new ConfirmOutputTool({});
|
|
154
|
+
mockScreenshot.mockImplementationOnce(() => Promise.resolve(Buffer.from('mock-screenshot')));
|
|
155
|
+
const result = await confirmTool.execute({ token }, mockContext);
|
|
131
156
|
expect(mockScreenshot).toHaveBeenCalled();
|
|
132
157
|
expect(result.isError).toBe(false);
|
|
133
158
|
expect(result.content[0].text).toContain('Screenshot saved to');
|
|
134
159
|
});
|
|
135
|
-
test('should
|
|
160
|
+
test('preview should suggest alternative tools', async () => {
|
|
136
161
|
const args = {
|
|
137
162
|
name: 'test-screenshot',
|
|
138
163
|
fullPage: true
|
|
139
164
|
};
|
|
140
|
-
const screenshotBuffer = Buffer.from('mock-screenshot');
|
|
141
|
-
mockScreenshot.mockImplementationOnce(() => Promise.resolve(screenshotBuffer));
|
|
142
165
|
const result = await screenshotTool.execute(args, mockContext);
|
|
143
166
|
expect(result.isError).toBe(false);
|
|
144
|
-
// Concatenate all content items to check the full response
|
|
145
167
|
const fullResponseText = result.content.map(c => c.text).join('\n');
|
|
146
|
-
|
|
147
|
-
expect(fullResponseText).toContain('💡 To debug layout issues');
|
|
168
|
+
expect(fullResponseText).toContain('Screenshot requested');
|
|
148
169
|
expect(fullResponseText).toContain('inspect_dom');
|
|
149
|
-
expect(fullResponseText).toContain('
|
|
170
|
+
expect(fullResponseText).toContain('compare_element_alignment');
|
|
171
|
+
expect(fullResponseText).toContain('get_computed_styles');
|
|
150
172
|
expect(fullResponseText).toContain('inspect_ancestors');
|
|
151
173
|
});
|
|
152
|
-
test('should
|
|
174
|
+
test('preview should also suggest alternatives for element screenshots', async () => {
|
|
153
175
|
const args = {
|
|
154
176
|
name: 'test-element-screenshot',
|
|
155
177
|
selector: '#test-element'
|
|
156
178
|
};
|
|
157
|
-
const screenshotBuffer = Buffer.from('mock-element-screenshot');
|
|
158
|
-
mockLocatorScreenshot.mockImplementationOnce(() => Promise.resolve(screenshotBuffer));
|
|
159
179
|
const result = await screenshotTool.execute(args, mockContext);
|
|
160
180
|
expect(result.isError).toBe(false);
|
|
161
|
-
// Concatenate all content items to check the full response
|
|
162
181
|
const fullResponseText = result.content.map(c => c.text).join('\n');
|
|
163
|
-
|
|
164
|
-
expect(fullResponseText).
|
|
165
|
-
expect(fullResponseText).not.toContain('inspect_dom');
|
|
182
|
+
expect(fullResponseText).toContain('Screenshot requested');
|
|
183
|
+
expect(fullResponseText).toContain('inspect_dom');
|
|
166
184
|
});
|
|
167
185
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { BrowserToolBase } from '../base.js';
|
|
2
2
|
import { createSuccessResponse, createErrorResponse, } from '../../common/types.js';
|
|
3
|
-
import { makeConfirmPreview } from '../../common/
|
|
3
|
+
import { makeConfirmPreview } from '../../common/confirm_output.js';
|
|
4
4
|
/**
|
|
5
5
|
* Tool for getting HTML from the page
|
|
6
6
|
*/
|
|
@@ -105,7 +105,7 @@ export class GetHtmlTool extends BrowserToolBase {
|
|
|
105
105
|
// Generate key for this HTML request
|
|
106
106
|
// Check if HTML is too large => return preview + token for confirm_output
|
|
107
107
|
if (originalLength >= PREVIEW_THRESHOLD) {
|
|
108
|
-
const preview = makeConfirmPreview(processedHtml, {
|
|
108
|
+
const preview = makeConfirmPreview(() => processedHtml, {
|
|
109
109
|
counts: { totalLength: originalLength, shownLength: Math.min(500, originalLength), truncated: true },
|
|
110
110
|
previewLines: [
|
|
111
111
|
'Preview (first 500 chars):',
|
|
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import { BrowserToolBase } from '../base.js';
|
|
4
4
|
import { createSuccessResponse } from '../../common/types.js';
|
|
5
|
+
import { makeConfirmPreview } from '../../common/confirm_output.js';
|
|
5
6
|
/**
|
|
6
7
|
* Tool for taking screenshots of pages or elements
|
|
7
8
|
*/
|
|
@@ -30,8 +31,6 @@ export class ScreenshotTool extends BrowserToolBase {
|
|
|
30
31
|
'',
|
|
31
32
|
'⚠️ Token cost: ~1,500 tokens to read. Structural tools: <100 tokens.',
|
|
32
33
|
'',
|
|
33
|
-
'Admin control (optional): set env MCP_SCREENSHOT_GUARD=strict to block execution (prevents misuse by default). Unset to allow visuals for human review.',
|
|
34
|
-
'',
|
|
35
34
|
`Screenshots saved to ${screenshotsDir}. Example: { name: "login-page", fullPage: true } or { name: "submit-btn", selector: "testid:submit" }`
|
|
36
35
|
].join('\n');
|
|
37
36
|
return {
|
|
@@ -63,81 +62,64 @@ export class ScreenshotTool extends BrowserToolBase {
|
|
|
63
62
|
}
|
|
64
63
|
async execute(args, context) {
|
|
65
64
|
return this.safeExecute(context, async (page) => {
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
lines.push('');
|
|
80
|
-
lines.push('If a human needs to review visuals, ask an admin to unset MCP_SCREENSHOT_GUARD.');
|
|
81
|
-
return createSuccessResponse(lines.join('\n'));
|
|
82
|
-
}
|
|
83
|
-
const screenshotOptions = {
|
|
84
|
-
type: args.type || "png",
|
|
85
|
-
fullPage: !!args.fullPage
|
|
86
|
-
};
|
|
87
|
-
if (args.selector) {
|
|
88
|
-
// Normalize selector (support testid: shorthand)
|
|
89
|
-
const selector = this.normalizeSelector(args.selector);
|
|
90
|
-
const element = await page.$(selector);
|
|
91
|
-
if (!element) {
|
|
92
|
-
return {
|
|
93
|
-
content: [{
|
|
94
|
-
type: "text",
|
|
95
|
-
text: `Element not found: ${selector}`,
|
|
96
|
-
}],
|
|
97
|
-
isError: true
|
|
98
|
-
};
|
|
65
|
+
// Defer the screenshot capture until confirmation via confirm_output
|
|
66
|
+
const thunk = async () => {
|
|
67
|
+
const screenshotOptions = {
|
|
68
|
+
type: args.type || "png",
|
|
69
|
+
fullPage: !!args.fullPage
|
|
70
|
+
};
|
|
71
|
+
if (args.selector) {
|
|
72
|
+
const selector = this.normalizeSelector(args.selector);
|
|
73
|
+
const element = await page.$(selector);
|
|
74
|
+
if (!element) {
|
|
75
|
+
throw new Error(`Element not found: ${selector}`);
|
|
76
|
+
}
|
|
77
|
+
screenshotOptions.element = element;
|
|
99
78
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
79
|
+
const { getScreenshotsDir } = await import('../../../toolHandler.js');
|
|
80
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
81
|
+
const filename = `${args.name || 'screenshot'}-${timestamp}.png`;
|
|
82
|
+
const downloadsDir = args.downloadsDir || getScreenshotsDir();
|
|
83
|
+
if (!fs.existsSync(downloadsDir)) {
|
|
84
|
+
fs.mkdirSync(downloadsDir, { recursive: true });
|
|
85
|
+
}
|
|
86
|
+
const outputPath = path.join(downloadsDir, filename);
|
|
87
|
+
screenshotOptions.path = outputPath;
|
|
88
|
+
const screenshot = await page.screenshot(screenshotOptions);
|
|
89
|
+
const base64Screenshot = screenshot.toString('base64');
|
|
90
|
+
const messages = [`✓ Screenshot saved to: ${path.relative(process.cwd(), outputPath)}`];
|
|
91
|
+
if (args.storeBase64 !== false) {
|
|
92
|
+
this.screenshots.set(args.name || 'screenshot', base64Screenshot);
|
|
93
|
+
context.server.notification({ method: "notifications/resources/list_changed" });
|
|
94
|
+
messages.push(`Screenshot also stored in memory with name: '${args.name || 'screenshot'}'`);
|
|
95
|
+
}
|
|
96
|
+
messages.push('');
|
|
97
|
+
messages.push('📸 Open the file in your IDE to view the screenshot');
|
|
98
|
+
messages.push('⚠️ Reading the image file consumes ~1,500 tokens — use structural tools for layout debugging');
|
|
99
|
+
messages.push('');
|
|
100
|
+
messages.push('💡 To debug layout issues without reading the screenshot:');
|
|
101
|
+
if (args.selector) {
|
|
102
|
+
messages.push(` inspect_ancestors({ selector: "${args.selector}" })`);
|
|
103
|
+
messages.push(' → See parent constraints (width, margins, overflow, borders)');
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
messages.push(' 1) Find the element: inspect_dom({}) or get_test_ids()');
|
|
107
|
+
messages.push(' 2) Check parent constraints: inspect_ancestors({ selector: "..." })');
|
|
108
|
+
messages.push(' 3) Compare alignment: compare_element_alignment({ selector1: "...", selector2: "..." })');
|
|
109
|
+
}
|
|
110
|
+
return messages.join('\n');
|
|
111
|
+
};
|
|
112
|
+
// Return a minimal preview that suggests better alternatives
|
|
113
|
+
const preview = makeConfirmPreview(thunk, {
|
|
114
|
+
previewLines: [
|
|
115
|
+
'Screenshot requested. For debugging, prefer:',
|
|
116
|
+
' • inspect_dom() - structure, positions, visibility',
|
|
117
|
+
' • compare_element_alignment() - alignment with pixel diffs',
|
|
118
|
+
' • get_computed_styles() - CSS values',
|
|
119
|
+
' • inspect_ancestors() - constraints and overflow',
|
|
120
|
+
],
|
|
121
|
+
});
|
|
122
|
+
return createSuccessResponse(preview.lines.join('\n'));
|
|
141
123
|
});
|
|
142
124
|
}
|
|
143
125
|
/**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { BrowserToolBase } from '../base.js';
|
|
2
2
|
import { createSuccessResponse, createErrorResponse, } from '../../common/types.js';
|
|
3
|
-
import { makeConfirmPreview } from '../../common/
|
|
3
|
+
import { makeConfirmPreview } from '../../common/confirm_output.js';
|
|
4
4
|
/**
|
|
5
5
|
* Tool for executing JavaScript in the browser
|
|
6
6
|
*/
|
|
@@ -274,7 +274,7 @@ export class EvaluateTool extends BrowserToolBase {
|
|
|
274
274
|
if (totalLength >= PREVIEW_THRESHOLD) {
|
|
275
275
|
const previewLen = Math.min(500, totalLength);
|
|
276
276
|
const preview = resultStr.slice(0, previewLen);
|
|
277
|
-
const previewBlock = makeConfirmPreview(resultStr, {
|
|
277
|
+
const previewBlock = makeConfirmPreview(() => resultStr, {
|
|
278
278
|
headerLine: '✓ JavaScript execution result (preview):',
|
|
279
279
|
counts: { totalLength, shownLength: previewLen, truncated: true },
|
|
280
280
|
previewLines: [
|
|
@@ -9,7 +9,7 @@ export class CompareElementAlignmentTool extends BrowserToolBase {
|
|
|
9
9
|
name: "compare_element_alignment",
|
|
10
10
|
description: "COMPARE TWO ELEMENTS: Get comprehensive alignment and dimension comparison in one call. Shows edge alignment (top, left, right, bottom), center alignment (horizontal, vertical), and dimensions (width, height). Perfect for debugging 'are these headers aligned?' or 'do these panels match?'. Returns all alignment info with ✓/✗ symbols and pixel differences. For parent-child centering, use inspect_dom() instead (automatically shows if children are centered in parent). More efficient than evaluate() with manual getBoundingClientRect() calculations.",
|
|
11
11
|
outputs: [
|
|
12
|
-
"Optional warnings when a selector matched multiple elements (
|
|
12
|
+
"Optional warnings when a selector matched multiple elements (uses first visible; suggests adding unique data-testid).",
|
|
13
13
|
"Header: Alignment: <elem1> vs <elem2>",
|
|
14
14
|
"Two lines with each element's position and size: @ (x,y) w×h px",
|
|
15
15
|
"Edges block: Top/Left/Right/Bottom with ✓/✗ and diffs",
|
|
@@ -52,10 +52,10 @@ export class CompareElementAlignmentTool extends BrowserToolBase {
|
|
|
52
52
|
const selector1 = this.normalizeSelector(args.selector1);
|
|
53
53
|
const selector2 = this.normalizeSelector(args.selector2);
|
|
54
54
|
try {
|
|
55
|
-
//
|
|
55
|
+
// Build locators for both elements
|
|
56
56
|
const locator1 = page.locator(selector1);
|
|
57
57
|
const locator2 = page.locator(selector2);
|
|
58
|
-
// Check
|
|
58
|
+
// Check existence first to preserve legacy error messages expected by tests
|
|
59
59
|
const count1 = await locator1.count();
|
|
60
60
|
const count2 = await locator2.count();
|
|
61
61
|
if (count1 === 0) {
|
|
@@ -64,19 +64,43 @@ export class CompareElementAlignmentTool extends BrowserToolBase {
|
|
|
64
64
|
if (count2 === 0) {
|
|
65
65
|
return createErrorResponse(`Second element not found: ${args.selector2}`);
|
|
66
66
|
}
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
let
|
|
67
|
+
// Prefer first visible element and produce clear selection info
|
|
68
|
+
let selectionWarning1 = '';
|
|
69
|
+
let selectionWarning2 = '';
|
|
70
|
+
let targetLocator1 = locator1.first();
|
|
71
|
+
let targetLocator2 = locator2.first();
|
|
72
|
+
try {
|
|
73
|
+
const sel1 = await this.selectPreferredLocator(locator1, {
|
|
74
|
+
originalSelector: args.selector1,
|
|
75
|
+
});
|
|
76
|
+
selectionWarning1 = this.formatElementSelectionInfo(args.selector1, sel1.elementIndex, sel1.totalCount);
|
|
77
|
+
targetLocator1 = sel1.element;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// Fallback to first() when visibility checks are unavailable (tests/mocks)
|
|
81
|
+
selectionWarning1 = this.formatElementSelectionInfo(args.selector1, 0, count1);
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const sel2 = await this.selectPreferredLocator(locator2, {
|
|
85
|
+
originalSelector: args.selector2,
|
|
86
|
+
});
|
|
87
|
+
selectionWarning2 = this.formatElementSelectionInfo(args.selector2, sel2.elementIndex, sel2.totalCount);
|
|
88
|
+
targetLocator2 = sel2.element;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
selectionWarning2 = this.formatElementSelectionInfo(args.selector2, 0, count2);
|
|
92
|
+
}
|
|
93
|
+
// Maintain legacy warning format for multiple matches, then append richer hints
|
|
94
|
+
let legacyWarnings = '';
|
|
71
95
|
if (count1 > 1) {
|
|
72
|
-
|
|
96
|
+
legacyWarnings += `⚠ Warning: First selector matched ${count1} elements, using first\n`;
|
|
73
97
|
}
|
|
74
98
|
if (count2 > 1) {
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
if (warnings) {
|
|
78
|
-
warnings += '\n';
|
|
99
|
+
legacyWarnings += `⚠ Warning: Second selector matched ${count2} elements, using first\n`;
|
|
79
100
|
}
|
|
101
|
+
const warnings = [legacyWarnings.trimEnd(), selectionWarning1, selectionWarning2]
|
|
102
|
+
.filter(Boolean)
|
|
103
|
+
.join('\n');
|
|
80
104
|
// Get bounding boxes
|
|
81
105
|
const box1 = await targetLocator1.boundingBox();
|
|
82
106
|
const box2 = await targetLocator2.boundingBox();
|
|
@@ -182,7 +206,7 @@ export class CompareElementAlignmentTool extends BrowserToolBase {
|
|
|
182
206
|
};
|
|
183
207
|
// Build output
|
|
184
208
|
const lines = [
|
|
185
|
-
warnings,
|
|
209
|
+
warnings ? `${warnings}\n` : '',
|
|
186
210
|
`Alignment: ${descriptor1} vs ${descriptor2}`,
|
|
187
211
|
` ${name1}: @ (${left1},${top1}) ${width1}×${height1}px`,
|
|
188
212
|
` ${name2}: @ (${left2},${top2}) ${width2}×${height2}px`,
|
|
@@ -104,11 +104,8 @@ RELATED TOOLS: For comparing TWO elements' alignment (not parent-child), use com
|
|
|
104
104
|
const maxDepth = args.maxDepth ?? 5;
|
|
105
105
|
try {
|
|
106
106
|
// Use consistent element selection (Playwright's visibility detection)
|
|
107
|
+
// Delegate count + selector error handling to selectPreferredLocator()
|
|
107
108
|
const locator = page.locator(selector);
|
|
108
|
-
const count = await locator.count();
|
|
109
|
-
if (count === 0) {
|
|
110
|
-
return createErrorResponse(`Element not found: ${args.selector || 'body'}`);
|
|
111
|
-
}
|
|
112
109
|
const { element, elementIndex, totalCount } = await this.selectPreferredLocator(locator, {
|
|
113
110
|
originalSelector: args.selector || 'body',
|
|
114
111
|
});
|
|
@@ -716,7 +713,14 @@ RELATED TOOLS: For comparing TWO elements' alignment (not parent-child), use com
|
|
|
716
713
|
return createSuccessResponse(lines.join('\n'));
|
|
717
714
|
}
|
|
718
715
|
catch (error) {
|
|
719
|
-
|
|
716
|
+
const msg = error.message || '';
|
|
717
|
+
// Map common not-found message to a user-friendly line
|
|
718
|
+
if (msg === 'No elements found') {
|
|
719
|
+
return createErrorResponse(`Element not found: ${args.selector || 'body'}`);
|
|
720
|
+
}
|
|
721
|
+
// Sanitize verbose selector engine traces and return concise message
|
|
722
|
+
const concise = this.sanitizeSelectorEngineMessage(msg) || msg;
|
|
723
|
+
return createErrorResponse(`Failed to inspect DOM: ${concise}`);
|
|
720
724
|
}
|
|
721
725
|
});
|
|
722
726
|
}
|
|
@@ -243,7 +243,9 @@ export class QuerySelectorTool extends BrowserToolBase {
|
|
|
243
243
|
return createSuccessResponse(lines.join('\n'));
|
|
244
244
|
}
|
|
245
245
|
catch (error) {
|
|
246
|
-
|
|
246
|
+
const msg = error.message || '';
|
|
247
|
+
const concise = this.sanitizeSelectorEngineMessage(msg) || msg;
|
|
248
|
+
return createErrorResponse(`Failed to query selector: ${concise}`);
|
|
247
249
|
}
|
|
248
250
|
});
|
|
249
251
|
}
|
|
@@ -1,7 +1,33 @@
|
|
|
1
1
|
import type { ToolHandler, ToolContext, ToolResponse, ToolMetadata, SessionConfig } from './types.js';
|
|
2
|
+
type PayloadThunk = () => Promise<string> | string;
|
|
3
|
+
export declare function registerPayloadThunk(thunk: PayloadThunk, ttlMs?: number): string;
|
|
4
|
+
export declare function consumeThunk(token: string): {
|
|
5
|
+
ok: true;
|
|
6
|
+
thunk: PayloadThunk;
|
|
7
|
+
} | {
|
|
8
|
+
ok: false;
|
|
9
|
+
error: string;
|
|
10
|
+
};
|
|
11
|
+
export interface PreviewCounts {
|
|
12
|
+
totalLength?: number;
|
|
13
|
+
shownLength?: number;
|
|
14
|
+
totalMatched?: number;
|
|
15
|
+
shownCount?: number;
|
|
16
|
+
truncated?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export declare function makeConfirmPreview(payloadOrThunk: string | PayloadThunk, options: {
|
|
19
|
+
headerLine?: string;
|
|
20
|
+
previewLines: string[];
|
|
21
|
+
counts?: PreviewCounts;
|
|
22
|
+
extraTips?: string[];
|
|
23
|
+
}): {
|
|
24
|
+
token: string;
|
|
25
|
+
lines: string[];
|
|
26
|
+
};
|
|
2
27
|
export declare class ConfirmOutputTool implements ToolHandler {
|
|
3
28
|
private server;
|
|
4
29
|
constructor(server: any);
|
|
5
30
|
static getMetadata(sessionConfig?: SessionConfig): ToolMetadata;
|
|
6
31
|
execute(args: any, context: ToolContext): Promise<ToolResponse>;
|
|
7
32
|
}
|
|
33
|
+
export {};
|
|
@@ -1,5 +1,73 @@
|
|
|
1
1
|
import { createSuccessResponse, createErrorResponse } from './types.js';
|
|
2
|
-
|
|
2
|
+
const STORE = new Map();
|
|
3
|
+
function now() {
|
|
4
|
+
return Date.now();
|
|
5
|
+
}
|
|
6
|
+
function purgeExpired() {
|
|
7
|
+
const t = now();
|
|
8
|
+
for (const [k, v] of STORE.entries()) {
|
|
9
|
+
if (v.expiresAt <= t)
|
|
10
|
+
STORE.delete(k);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function genToken(len = 12) {
|
|
14
|
+
return Math.random().toString(36).slice(2, 2 + len);
|
|
15
|
+
}
|
|
16
|
+
export function registerPayloadThunk(thunk, ttlMs = 120000) {
|
|
17
|
+
purgeExpired();
|
|
18
|
+
const token = genToken();
|
|
19
|
+
const expiresAt = now() + Math.max(1000, ttlMs);
|
|
20
|
+
STORE.set(token, { thunk, expiresAt });
|
|
21
|
+
return token;
|
|
22
|
+
}
|
|
23
|
+
export function consumeThunk(token) {
|
|
24
|
+
purgeExpired();
|
|
25
|
+
const entry = STORE.get(token);
|
|
26
|
+
if (!entry)
|
|
27
|
+
return { ok: false, error: 'Invalid or expired token' };
|
|
28
|
+
STORE.delete(token);
|
|
29
|
+
if (entry.expiresAt <= now())
|
|
30
|
+
return { ok: false, error: 'Invalid or expired token' };
|
|
31
|
+
return { ok: true, thunk: entry.thunk };
|
|
32
|
+
}
|
|
33
|
+
export function makeConfirmPreview(payloadOrThunk, options) {
|
|
34
|
+
const thunk = typeof payloadOrThunk === 'function' ? payloadOrThunk : () => payloadOrThunk;
|
|
35
|
+
const token = registerPayloadThunk(thunk);
|
|
36
|
+
const lines = [];
|
|
37
|
+
if (options.headerLine) {
|
|
38
|
+
lines.push(options.headerLine);
|
|
39
|
+
}
|
|
40
|
+
const parts = [];
|
|
41
|
+
const c = options.counts || {};
|
|
42
|
+
if (typeof c.totalLength === 'number')
|
|
43
|
+
parts.push(`totalLength=${c.totalLength}`);
|
|
44
|
+
if (typeof c.shownLength === 'number')
|
|
45
|
+
parts.push(`shownLength=${c.shownLength}`);
|
|
46
|
+
if (typeof c.totalMatched === 'number')
|
|
47
|
+
parts.push(`totalMatched=${c.totalMatched}`);
|
|
48
|
+
if (typeof c.shownCount === 'number')
|
|
49
|
+
parts.push(`shownCount=${c.shownCount}`);
|
|
50
|
+
if (typeof c.truncated === 'boolean')
|
|
51
|
+
parts.push(`truncated=${c.truncated}`);
|
|
52
|
+
if (parts.length) {
|
|
53
|
+
lines.push(`counts: ${parts.join(', ')}`);
|
|
54
|
+
}
|
|
55
|
+
if (lines.length)
|
|
56
|
+
lines.push('');
|
|
57
|
+
// Caller provides preview content lines (e.g., label + excerpt or list)
|
|
58
|
+
for (const l of options.previewLines)
|
|
59
|
+
lines.push(l);
|
|
60
|
+
if (options.previewLines.length)
|
|
61
|
+
lines.push('');
|
|
62
|
+
const baseLen = typeof c.totalLength === 'number' ? c.totalLength : (typeof payloadOrThunk === 'string' ? payloadOrThunk.length : 0);
|
|
63
|
+
const estTokens = Math.round(baseLen / 3);
|
|
64
|
+
lines.push(`Output is large (~${estTokens} tokens). To fetch full content without resending parameters, call confirm_output({ token: "${token}" }).`);
|
|
65
|
+
if (options.extraTips && options.extraTips.length) {
|
|
66
|
+
for (const tip of options.extraTips)
|
|
67
|
+
lines.push(tip);
|
|
68
|
+
}
|
|
69
|
+
return { token, lines };
|
|
70
|
+
}
|
|
3
71
|
export class ConfirmOutputTool {
|
|
4
72
|
constructor(server) {
|
|
5
73
|
this.server = server;
|
|
@@ -30,11 +98,17 @@ export class ConfirmOutputTool {
|
|
|
30
98
|
const token = typeof args.token === 'string' ? args.token : '';
|
|
31
99
|
if (!token)
|
|
32
100
|
return createErrorResponse('Token is required');
|
|
33
|
-
const res =
|
|
101
|
+
const res = consumeThunk(token);
|
|
34
102
|
if (!res.ok) {
|
|
35
103
|
const err = res.error;
|
|
36
104
|
return createErrorResponse(err);
|
|
37
105
|
}
|
|
38
|
-
|
|
106
|
+
try {
|
|
107
|
+
const out = await res.thunk();
|
|
108
|
+
return createSuccessResponse(out);
|
|
109
|
+
}
|
|
110
|
+
catch (e) {
|
|
111
|
+
return createErrorResponse(e.message || 'Failed to produce output');
|
|
112
|
+
}
|
|
39
113
|
}
|
|
40
114
|
}
|
|
@@ -1,9 +1,34 @@
|
|
|
1
1
|
// Helper functions for creating responses
|
|
2
|
+
// Global error message sanitizer: removes stack traces and noisy engine frames
|
|
3
|
+
function sanitizeErrorMessage(message) {
|
|
4
|
+
if (!message)
|
|
5
|
+
return '';
|
|
6
|
+
// If message already contains helpful guidance (e.g., Tips), avoid truncation.
|
|
7
|
+
const hasGuidance = /\bTips?:\b|💡/.test(message);
|
|
8
|
+
// Trim to concise selector phrase only when there is no guidance to preserve.
|
|
9
|
+
if (!hasGuidance) {
|
|
10
|
+
const cutoffPhrases = [
|
|
11
|
+
'is not a valid selector.',
|
|
12
|
+
'is not a valid selector',
|
|
13
|
+
];
|
|
14
|
+
for (const phrase of cutoffPhrases) {
|
|
15
|
+
const idx = message.indexOf(phrase);
|
|
16
|
+
if (idx !== -1) {
|
|
17
|
+
return message.slice(0, idx + phrase.length).trim();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// Remove typical stack lines (e.g., " at query (...)" or " at ... (<anonymous>:x:y)")
|
|
22
|
+
const lines = message.split(/\r?\n/);
|
|
23
|
+
const filtered = lines.filter(l => !/^\s*at\b/.test(l) && !/<anonymous>:\d+:\d+/.test(l));
|
|
24
|
+
return filtered.join('\n').trim();
|
|
25
|
+
}
|
|
2
26
|
export function createErrorResponse(message) {
|
|
27
|
+
const sanitized = sanitizeErrorMessage(message);
|
|
3
28
|
return {
|
|
4
29
|
content: [{
|
|
5
30
|
type: "text",
|
|
6
|
-
text:
|
|
31
|
+
text: sanitized
|
|
7
32
|
}],
|
|
8
33
|
isError: true
|
|
9
34
|
};
|