@mcp-b/smart-dom-reader-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,828 @@
1
+ #!/usr/bin/env tsx
2
+
3
+ import { constants as fsConstants } from 'node:fs';
4
+ import { access, readFile, stat } from 'node:fs/promises';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
8
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
9
+ import { type CallToolResult, ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
10
+ import { type Browser, chromium, type LaunchOptions, type Page } from 'playwright';
11
+ import { z } from 'zod';
12
+
13
+ const { F_OK } = fsConstants;
14
+
15
+ const __filename = fileURLToPath(import.meta.url);
16
+ const __dirname = dirname(__filename);
17
+
18
+ const EMBEDDED_LIBRARY_RELATIVE_PATH = join('..', 'lib', 'smart-dom-reader.bundle.js');
19
+ const DEFAULT_LAUNCH_ARGS = ['--no-sandbox', '--disable-setuid-sandbox'] as const;
20
+
21
+ type ConnectBrowserArgs = {
22
+ executablePath?: string;
23
+ headless: boolean;
24
+ };
25
+
26
+ type NavigateArgs = {
27
+ url: string;
28
+ };
29
+
30
+ type OptionalSelectorArgs = {
31
+ selector?: string;
32
+ detail?: 'summary' | 'region' | 'deep';
33
+ maxTextLength?: number;
34
+ maxElements?: number;
35
+ };
36
+
37
+ type RegionArgs = {
38
+ selector: string;
39
+ options?: ProgressiveExtractorRegionConfig & FormatOptions;
40
+ };
41
+
42
+ type ContentArgs = {
43
+ selector: string;
44
+ options?: ProgressiveExtractorContentConfig & FormatOptions;
45
+ };
46
+
47
+ type InteractiveArgs = {
48
+ selector?: string;
49
+ options?: SmartDomReaderOptions & FormatOptions;
50
+ };
51
+
52
+ type ScreenshotArgs = {
53
+ path?: string;
54
+ fullPage: boolean;
55
+ };
56
+
57
+ type LibraryOperation = 'structure' | 'region' | 'content' | 'interactive';
58
+
59
+ type StructureOperationArgs = {
60
+ selector: string | null;
61
+ };
62
+
63
+ type RegionOperationArgs = {
64
+ selector: string;
65
+ options: ProgressiveExtractorRegionConfig;
66
+ };
67
+
68
+ type ContentOperationArgs = {
69
+ selector: string;
70
+ options: ProgressiveExtractorContentConfig;
71
+ };
72
+
73
+ type InteractiveOperationArgs = {
74
+ selector: string | null;
75
+ options: SmartDomReaderOptions;
76
+ };
77
+
78
+ const CHROME_ENV_VARS = ['CHROME_PATH', 'GOOGLE_CHROME_SHIM', 'BROWSER_PATH'] as const;
79
+
80
+ const DEFAULT_CHROME_LOCATIONS: Partial<Record<NodeJS.Platform, readonly string[]>> = {
81
+ darwin: [
82
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
83
+ '/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta',
84
+ '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
85
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
86
+ ],
87
+ linux: [
88
+ '/usr/bin/google-chrome',
89
+ '/usr/bin/google-chrome-stable',
90
+ '/usr/bin/chromium-browser',
91
+ '/usr/bin/chromium',
92
+ '/snap/bin/chromium',
93
+ ],
94
+ win32: [
95
+ ...(process.env.PROGRAMFILES
96
+ ? [join(process.env.PROGRAMFILES, 'Google/Chrome/Application/chrome.exe')]
97
+ : []),
98
+ ...(process.env['ProgramFiles(x86)']
99
+ ? [join(process.env['ProgramFiles(x86)']!, 'Google/Chrome/Application/chrome.exe')]
100
+ : []),
101
+ ...(process.env.LOCALAPPDATA
102
+ ? [join(process.env.LOCALAPPDATA, 'Google/Chrome/Application/chrome.exe')]
103
+ : []),
104
+ ],
105
+ };
106
+
107
+ interface ProgressiveExtractorRegionConfig {
108
+ mode?: 'interactive' | 'full';
109
+ includeHidden?: boolean;
110
+ maxDepth?: number;
111
+ }
112
+
113
+ interface ProgressiveExtractorContentConfig {
114
+ includeHeadings?: boolean;
115
+ includeLists?: boolean;
116
+ includeMedia?: boolean;
117
+ maxTextLength?: number;
118
+ }
119
+
120
+ interface SmartDomReaderOptions {
121
+ viewportOnly?: boolean;
122
+ maxDepth?: number;
123
+ }
124
+
125
+ interface FormatOptions {
126
+ detail?: 'summary' | 'region' | 'deep';
127
+ maxTextLength?: number;
128
+ maxElements?: number;
129
+ }
130
+
131
+ type LibraryCache = {
132
+ path: string;
133
+ code: string;
134
+ mtimeMs: number;
135
+ };
136
+
137
+ async function pathExists(candidate: string | undefined): Promise<boolean> {
138
+ if (!candidate) {
139
+ return false;
140
+ }
141
+
142
+ try {
143
+ await access(candidate, F_OK);
144
+ return true;
145
+ } catch {
146
+ return false;
147
+ }
148
+ }
149
+
150
+ function createTextResult(text: string): CallToolResult {
151
+ return {
152
+ content: [
153
+ {
154
+ type: 'text',
155
+ text,
156
+ },
157
+ ],
158
+ };
159
+ }
160
+
161
+ class SmartDomReaderServer {
162
+ private readonly server: McpServer;
163
+ private browser: Browser | null = null;
164
+ private page: Page | null = null;
165
+ private cachedLibrary: LibraryCache | null = null;
166
+
167
+ constructor() {
168
+ this.server = new McpServer({
169
+ name: 'smart-dom-reader-server',
170
+ version: '0.2.0',
171
+ });
172
+
173
+ this.registerTools();
174
+ }
175
+
176
+ async run(): Promise<void> {
177
+ const transport = new StdioServerTransport();
178
+ await this.server.connect(transport);
179
+ console.error('Smart DOM Reader MCP server running on stdio');
180
+ }
181
+
182
+ private registerTools(): void {
183
+ this.server.registerTool(
184
+ 'browser_connect',
185
+ {
186
+ title: 'Connect with Playwright',
187
+ description:
188
+ 'Launch Chromium for subsequent tools. Use this first. Returns a brief status message. Run headless=false for visual debugging.',
189
+ inputSchema: {
190
+ executablePath: z
191
+ .string()
192
+ .trim()
193
+ .min(1, { message: 'Provide a non-empty executablePath' })
194
+ .describe(
195
+ 'Optional path to a Chrome/Chromium binary. If omitted, Playwright resolves a system or bundled build.'
196
+ )
197
+ .optional(),
198
+ headless: z
199
+ .boolean()
200
+ .default(false)
201
+ .describe(
202
+ 'Run without a visible window. Set false to watch interactions. Default: false.'
203
+ ),
204
+ },
205
+ },
206
+ async (args) => this.connectBrowser(args)
207
+ );
208
+
209
+ this.server.registerTool(
210
+ 'browser_navigate',
211
+ {
212
+ title: 'Navigate to URL',
213
+ description:
214
+ 'Load an absolute URL in the active tab and wait for network idle. Use after browser_connect.',
215
+ inputSchema: {
216
+ url: z
217
+ .string()
218
+ .url('url must be a valid absolute URL')
219
+ .describe('Absolute URL to navigate to (e.g., https://example.com).'),
220
+ },
221
+ },
222
+ async (args) => this.navigate(args)
223
+ );
224
+
225
+ this.server.registerTool(
226
+ 'dom_extract_structure',
227
+ {
228
+ title: 'Extract DOM Structure',
229
+ description:
230
+ 'Start here. Returns an XML-wrapped Markdown outline (<outline>) describing page regions. Next: pick a selector/section and call dom_extract_region.',
231
+ inputSchema: {
232
+ selector: z
233
+ .union([z.string().trim().min(1), z.literal('')])
234
+ .describe(
235
+ 'Optional container CSS selector to scope the outline. Omit or pass empty string to analyze the whole document.'
236
+ )
237
+ .optional(),
238
+ detail: z
239
+ .enum(['summary', 'region', 'deep'])
240
+ .default('summary')
241
+ .describe("Output depth. 'summary' is concise, 'deep' expands more structure.")
242
+ .optional(),
243
+ maxTextLength: z
244
+ .number()
245
+ .int()
246
+ .min(0)
247
+ .describe('Truncate text snippets to this many characters to save tokens.')
248
+ .optional(),
249
+ maxElements: z
250
+ .number()
251
+ .int()
252
+ .min(0)
253
+ .describe('Limit number of listed items per group (buttons, links, etc.).')
254
+ .optional(),
255
+ },
256
+ },
257
+ async (args) => this.extractStructure(args)
258
+ );
259
+
260
+ this.server.registerTool(
261
+ 'dom_extract_region',
262
+ {
263
+ title: 'Extract DOM Region',
264
+ description:
265
+ 'Use after the outline. Returns XML-wrapped Markdown (<section>) with actionable selectors for a specific region. Next: write a script or rerun with higher detail.',
266
+ inputSchema: {
267
+ selector: z
268
+ .string()
269
+ .trim()
270
+ .min(1)
271
+ .describe('CSS selector for the region to analyze (e.g., from the outline).'),
272
+ options: z
273
+ .object({
274
+ mode: z
275
+ .enum(['interactive', 'full'])
276
+ .describe(
277
+ "Extraction mode. 'interactive' lists controls; 'full' also inspects content structure."
278
+ )
279
+ .optional(),
280
+ includeHidden: z
281
+ .boolean()
282
+ .describe('Include elements that are hidden/offscreen. Default: false.')
283
+ .optional(),
284
+ maxDepth: z
285
+ .number()
286
+ .int()
287
+ .min(0)
288
+ .describe(
289
+ 'Traversal depth for nested elements. Larger values inspect deeper trees.'
290
+ )
291
+ .optional(),
292
+ detail: z
293
+ .enum(['summary', 'region', 'deep'])
294
+ .default('region')
295
+ .describe("Output depth. 'region' is actionable; 'deep' expands more details.")
296
+ .optional(),
297
+ maxTextLength: z
298
+ .number()
299
+ .int()
300
+ .min(0)
301
+ .describe('Truncate text snippets to this many characters to save tokens.')
302
+ .optional(),
303
+ maxElements: z
304
+ .number()
305
+ .int()
306
+ .min(0)
307
+ .describe('Limit number of listed items per group (buttons, links, inputs).')
308
+ .optional(),
309
+ })
310
+ .optional(),
311
+ },
312
+ },
313
+ async (args) => this.extractRegion(args)
314
+ );
315
+
316
+ this.server.registerTool(
317
+ 'dom_extract_content',
318
+ {
319
+ title: 'Extract DOM Content',
320
+ description:
321
+ 'Readable text for a region as XML-wrapped Markdown (<content>). Use for comprehension; for selectors use dom_extract_region.',
322
+ inputSchema: {
323
+ selector: z
324
+ .string()
325
+ .trim()
326
+ .min(1)
327
+ .describe('CSS selector for the content region (e.g., main article or section).'),
328
+ options: z
329
+ .object({
330
+ includeHeadings: z
331
+ .boolean()
332
+ .describe('Include H1–H6 headings in the output.')
333
+ .optional(),
334
+ includeLists: z
335
+ .boolean()
336
+ .describe('Include unordered/ordered lists in the output.')
337
+ .optional(),
338
+ includeMedia: z
339
+ .boolean()
340
+ .describe('Include media references (images, video, audio).')
341
+ .optional(),
342
+ maxTextLength: z
343
+ .number()
344
+ .int()
345
+ .min(0)
346
+ .describe('Truncate text snippets to this many characters to save tokens.')
347
+ .optional(),
348
+ detail: z
349
+ .enum(['summary', 'region', 'deep'])
350
+ .default('region')
351
+ .describe("Output depth. 'summary' is brief; 'deep' expands more content examples.")
352
+ .optional(),
353
+ maxElements: z
354
+ .number()
355
+ .int()
356
+ .min(0)
357
+ .describe('Limit number of listed items (paragraphs, list items, rows).')
358
+ .optional(),
359
+ })
360
+ .optional(),
361
+ },
362
+ },
363
+ async (args) => this.extractContent(args)
364
+ );
365
+
366
+ this.server.registerTool(
367
+ 'dom_extract_interactive',
368
+ {
369
+ title: 'Extract Interactive Elements',
370
+ description:
371
+ 'Quick list of controls within a scope as XML-wrapped Markdown (<section>). Alternative to region when you only need controls.',
372
+ inputSchema: {
373
+ selector: z
374
+ .union([z.string().trim().min(1), z.literal('')])
375
+ .describe(
376
+ 'Optional container CSS selector. Omit or pass empty string to scan the whole document.'
377
+ )
378
+ .optional(),
379
+ options: z
380
+ .object({
381
+ viewportOnly: z
382
+ .boolean()
383
+ .default(false)
384
+ .describe('Only include elements currently within the viewport. Default: false.'),
385
+ maxDepth: z
386
+ .number()
387
+ .int()
388
+ .min(0)
389
+ .describe(
390
+ 'Traversal depth for nested elements. Larger values inspect deeper trees.'
391
+ )
392
+ .optional(),
393
+ detail: z
394
+ .enum(['summary', 'region', 'deep'])
395
+ .default('region')
396
+ .describe("Output depth. 'region' is actionable; 'deep' expands more details.")
397
+ .optional(),
398
+ maxTextLength: z
399
+ .number()
400
+ .int()
401
+ .min(0)
402
+ .describe('Truncate text snippets to this many characters to save tokens.')
403
+ .optional(),
404
+ maxElements: z
405
+ .number()
406
+ .int()
407
+ .min(0)
408
+ .describe('Limit number of listed items per group (buttons, links, inputs).')
409
+ .optional(),
410
+ })
411
+ .optional(),
412
+ },
413
+ },
414
+ async (args) => this.extractInteractive(args)
415
+ );
416
+
417
+ this.server.registerTool(
418
+ 'browser_screenshot',
419
+ {
420
+ title: 'Capture Screenshot',
421
+ description:
422
+ 'Capture a PNG screenshot of the current page. Returns a short Markdown summary (path and fullPage flag).',
423
+ inputSchema: {
424
+ path: z
425
+ .string()
426
+ .trim()
427
+ .min(1)
428
+ .describe(
429
+ 'Optional output path (absolute or workspace-relative). Default: screenshot-<timestamp>.png'
430
+ )
431
+ .optional(),
432
+ fullPage: z
433
+ .boolean()
434
+ .default(false)
435
+ .describe('Capture the full scrollable page. Default: false.'),
436
+ },
437
+ },
438
+ async (args) => this.captureScreenshot(args)
439
+ );
440
+
441
+ this.server.registerTool(
442
+ 'browser_close',
443
+ {
444
+ title: 'Close Browser',
445
+ description:
446
+ 'Shut down the launched browser instance and release resources. Safe to call multiple times.',
447
+ inputSchema: {},
448
+ },
449
+ async () => this.closeBrowser()
450
+ );
451
+ }
452
+
453
+ private async connectBrowser(args: ConnectBrowserArgs): Promise<CallToolResult> {
454
+ if (this.browser?.isConnected()) {
455
+ return createTextResult('Browser already connected');
456
+ }
457
+
458
+ const launchOptions: LaunchOptions = {
459
+ headless: args.headless,
460
+ args: [...DEFAULT_LAUNCH_ARGS],
461
+ };
462
+
463
+ const resolution = await this.resolveChromeExecutable(args.executablePath);
464
+ if (resolution) {
465
+ launchOptions.executablePath = resolution.path;
466
+ }
467
+
468
+ try {
469
+ this.browser = await chromium.launch(launchOptions);
470
+ } catch (error) {
471
+ this.browser = null;
472
+ this.page = null;
473
+ const locationDescription = resolution
474
+ ? `Failed to launch Chromium at ${resolution.path}`
475
+ : 'Failed to launch bundled Chromium';
476
+ this.handleToolError(error, locationDescription);
477
+ }
478
+
479
+ this.browser?.on('disconnected', () => {
480
+ this.browser = null;
481
+ this.page = null;
482
+ });
483
+
484
+ this.page = await this.browser?.newPage();
485
+ const descriptor = args.headless ? ' (headless)' : '';
486
+ const sourceDetails = resolution
487
+ ? ` using ${resolution.path} (${resolution.source}).`
488
+ : ' using bundled Chromium.';
489
+ return createTextResult(`Browser connected${descriptor}${sourceDetails}`);
490
+ }
491
+
492
+ private async resolveChromeExecutable(
493
+ explicitPath?: string
494
+ ): Promise<{ path: string; source: string } | null> {
495
+ if (explicitPath && (await pathExists(explicitPath))) {
496
+ return { path: resolve(explicitPath), source: 'user' };
497
+ }
498
+
499
+ for (const envVar of CHROME_ENV_VARS) {
500
+ const envPath = process.env[envVar];
501
+ if (await pathExists(envPath)) {
502
+ return { path: resolve(envPath as string), source: `env:${envVar}` };
503
+ }
504
+ }
505
+
506
+ const candidates = DEFAULT_CHROME_LOCATIONS[process.platform] ?? [];
507
+ for (const candidate of candidates) {
508
+ if (await pathExists(candidate)) {
509
+ return { path: resolve(candidate), source: 'default' };
510
+ }
511
+ }
512
+
513
+ return null;
514
+ }
515
+
516
+ private async navigate(args: NavigateArgs): Promise<CallToolResult> {
517
+ const page = this.getActivePage();
518
+ try {
519
+ await page.goto(args.url, { waitUntil: 'networkidle' });
520
+ } catch (error) {
521
+ this.handleToolError(error, `Failed to navigate to ${args.url}`);
522
+ }
523
+
524
+ return createTextResult(`Navigated to ${args.url}`);
525
+ }
526
+
527
+ private async extractStructure(args: OptionalSelectorArgs): Promise<CallToolResult> {
528
+ const started = Date.now();
529
+ try {
530
+ const text = await this.runLibraryOperation<
531
+ string,
532
+ StructureOperationArgs & { format: FormatOptions }
533
+ >('structure', {
534
+ selector: args.selector && args.selector.trim().length > 0 ? args.selector : null,
535
+ format: {
536
+ detail: args.detail ?? 'summary',
537
+ maxTextLength: args.maxTextLength,
538
+ maxElements: args.maxElements,
539
+ },
540
+ });
541
+ const duration = Date.now() - started;
542
+ return createTextResult(`${text}\n\nDuration: ${duration}ms`);
543
+ } catch (error) {
544
+ this.handleToolError(error, 'Failed to extract structure');
545
+ }
546
+ }
547
+
548
+ private async extractRegion(args: RegionArgs): Promise<CallToolResult> {
549
+ const started = Date.now();
550
+ try {
551
+ const text = await this.runLibraryOperation<
552
+ string,
553
+ RegionOperationArgs & { format: FormatOptions }
554
+ >('region', {
555
+ selector: args.selector,
556
+ options: args.options ?? {},
557
+ format: {
558
+ detail: args.options?.detail ?? 'region',
559
+ maxTextLength: args.options?.maxTextLength,
560
+ maxElements: args.options?.maxElements,
561
+ },
562
+ });
563
+ const duration = Date.now() - started;
564
+ return createTextResult(`${text}\n\nDuration: ${duration}ms`);
565
+ } catch (error) {
566
+ this.handleToolError(error, `Failed to extract region for selector ${args.selector}`);
567
+ }
568
+ }
569
+
570
+ private async extractContent(args: ContentArgs): Promise<CallToolResult> {
571
+ const started = Date.now();
572
+ try {
573
+ const text = await this.runLibraryOperation<
574
+ string,
575
+ ContentOperationArgs & { format: FormatOptions }
576
+ >('content', {
577
+ selector: args.selector,
578
+ options: args.options ?? {},
579
+ format: {
580
+ detail: args.options?.detail ?? 'region',
581
+ maxTextLength: args.options?.maxTextLength,
582
+ maxElements: args.options?.maxElements,
583
+ },
584
+ });
585
+ const duration = Date.now() - started;
586
+ return createTextResult(`${text}\n\nDuration: ${duration}ms`);
587
+ } catch (error) {
588
+ this.handleToolError(error, `Failed to extract content for selector ${args.selector}`);
589
+ }
590
+ }
591
+
592
+ private async extractInteractive(args: InteractiveArgs): Promise<CallToolResult> {
593
+ const started = Date.now();
594
+ try {
595
+ const text = await this.runLibraryOperation<
596
+ string,
597
+ InteractiveOperationArgs & { format: FormatOptions }
598
+ >('interactive', {
599
+ selector: args.selector && args.selector.trim().length > 0 ? args.selector : null,
600
+ options: args.options ?? {},
601
+ format: {
602
+ detail: args.options?.detail ?? 'region',
603
+ maxTextLength: args.options?.maxTextLength,
604
+ maxElements: args.options?.maxElements,
605
+ },
606
+ });
607
+ const duration = Date.now() - started;
608
+ return createTextResult(`${text}\n\nDuration: ${duration}ms`);
609
+ } catch (error) {
610
+ this.handleToolError(error, 'Failed to extract interactive elements');
611
+ }
612
+ }
613
+
614
+ private async captureScreenshot(args: ScreenshotArgs): Promise<CallToolResult> {
615
+ const page = this.getActivePage();
616
+ const outputPath = resolve(process.cwd(), args.path ?? `screenshot-${Date.now()}.png`);
617
+
618
+ try {
619
+ await page.screenshot({ path: outputPath, fullPage: args.fullPage });
620
+ } catch (error) {
621
+ this.handleToolError(error, `Failed to capture screenshot to ${outputPath}`);
622
+ }
623
+
624
+ return createTextResult(
625
+ `Screenshot captured\n- Path: \`${outputPath}\`\n- Full page: ${args.fullPage ? 'yes' : 'no'}`
626
+ );
627
+ }
628
+
629
+ private async closeBrowser(): Promise<CallToolResult> {
630
+ if (!this.browser) {
631
+ return createTextResult('Browser already closed');
632
+ }
633
+
634
+ if (this.page) {
635
+ try {
636
+ await this.page.close({ runBeforeUnload: true });
637
+ } catch {
638
+ // Ignore failures when shutting down the page.
639
+ }
640
+ }
641
+
642
+ try {
643
+ await this.browser.close();
644
+ } finally {
645
+ this.browser = null;
646
+ this.page = null;
647
+ }
648
+
649
+ return createTextResult('Browser closed');
650
+ }
651
+
652
+ private async runLibraryOperation<TResult, TArgs extends Record<string, unknown>>(
653
+ operation: LibraryOperation,
654
+ args: TArgs
655
+ ): Promise<TResult> {
656
+ const page = this.getActivePage();
657
+ const code = await this.loadLibraryCode();
658
+
659
+ return page.evaluate<TResult, { code: string; operation: LibraryOperation; args: TArgs }>(
660
+ async ({ code, operation, args }) => {
661
+ const blob = new Blob([code], { type: 'text/javascript' });
662
+ const url = URL.createObjectURL(blob);
663
+
664
+ try {
665
+ const moduleExports = await import(/* webpackIgnore: true */ url);
666
+ const { ProgressiveExtractor, MarkdownFormatter } = moduleExports;
667
+ const SmartDOMReader = moduleExports.SmartDOMReader ?? moduleExports.default;
668
+
669
+ switch (operation) {
670
+ case 'structure': {
671
+ if (!ProgressiveExtractor) {
672
+ throw new Error('ProgressiveExtractor export is unavailable.');
673
+ }
674
+
675
+ const { selector } = args as StructureOperationArgs;
676
+ const target = selector ? (document.querySelector(selector) ?? document) : document;
677
+ const overview = ProgressiveExtractor.extractStructure(target);
678
+ if (!MarkdownFormatter) throw new Error('MarkdownFormatter export is unavailable.');
679
+ const argsWithFormat = args as Record<string, unknown> & {
680
+ format?: Record<string, unknown>;
681
+ };
682
+ const fmt = argsWithFormat.format ?? {};
683
+ const meta = { title: document.title, url: location.href };
684
+ return MarkdownFormatter.structure(overview, fmt, meta) as TResult;
685
+ }
686
+
687
+ case 'region': {
688
+ if (!ProgressiveExtractor) {
689
+ throw new Error('ProgressiveExtractor export is unavailable.');
690
+ }
691
+
692
+ const { selector, options } = args as RegionOperationArgs;
693
+ const result = ProgressiveExtractor.extractRegion(
694
+ selector,
695
+ document,
696
+ options ?? {},
697
+ SmartDOMReader
698
+ );
699
+ if (!result)
700
+ return `No matching region for selector ${selector}` as unknown as TResult;
701
+ if (!MarkdownFormatter) throw new Error('MarkdownFormatter export is unavailable.');
702
+ const argsWithFormat = args as Record<string, unknown> & {
703
+ format?: Record<string, unknown>;
704
+ };
705
+ const fmt = argsWithFormat.format ?? {};
706
+ const meta = { title: document.title, url: location.href };
707
+ return MarkdownFormatter.region(result, fmt, meta) as TResult;
708
+ }
709
+
710
+ case 'content': {
711
+ if (!ProgressiveExtractor) {
712
+ throw new Error('ProgressiveExtractor export is unavailable.');
713
+ }
714
+
715
+ const { selector, options } = args as ContentOperationArgs;
716
+ const content = ProgressiveExtractor.extractContent(
717
+ selector,
718
+ document,
719
+ options ?? {}
720
+ );
721
+ if (!content) return `No content for selector ${selector}` as unknown as TResult;
722
+ if (!MarkdownFormatter) throw new Error('MarkdownFormatter export is unavailable.');
723
+ const argsWithFormat = args as Record<string, unknown> & {
724
+ format?: Record<string, unknown>;
725
+ };
726
+ const fmt = argsWithFormat.format ?? {};
727
+ const meta = { title: document.title, url: location.href };
728
+ return MarkdownFormatter.content(content, fmt, meta) as TResult;
729
+ }
730
+
731
+ case 'interactive': {
732
+ if (!SmartDOMReader) {
733
+ throw new Error('SmartDOMReader export is unavailable.');
734
+ }
735
+
736
+ const { selector, options } = args as InteractiveOperationArgs;
737
+ const target = selector ? (document.querySelector(selector) ?? document) : document;
738
+
739
+ if (typeof SmartDOMReader.extractInteractive === 'function') {
740
+ const result = SmartDOMReader.extractInteractive(target, options ?? {});
741
+ if (!MarkdownFormatter) throw new Error('MarkdownFormatter export is unavailable.');
742
+ const argsWithFormat = args as Record<string, unknown> & {
743
+ format?: Record<string, unknown>;
744
+ };
745
+ const fmt = argsWithFormat.format ?? {};
746
+ const meta = { title: document.title, url: location.href };
747
+ return MarkdownFormatter.region(result, fmt, meta) as TResult;
748
+ }
749
+
750
+ const reader = new SmartDOMReader({ ...options, mode: 'interactive' });
751
+ const result = reader.extract(target, options ?? {});
752
+ if (!MarkdownFormatter) throw new Error('MarkdownFormatter export is unavailable.');
753
+ const argsWithFormat = args as Record<string, unknown> & {
754
+ format?: Record<string, unknown>;
755
+ };
756
+ const fmt = argsWithFormat.format ?? {};
757
+ const meta = { title: document.title, url: location.href };
758
+ return MarkdownFormatter.region(result, fmt, meta) as TResult;
759
+ }
760
+
761
+ default: {
762
+ throw new Error(`Unsupported library operation: ${operation}`);
763
+ }
764
+ }
765
+ } finally {
766
+ URL.revokeObjectURL(url);
767
+ }
768
+ },
769
+ { code, operation, args }
770
+ );
771
+ }
772
+
773
+ private getActivePage(): Page {
774
+ if (!this.browser || !this.browser.isConnected() || !this.page) {
775
+ throw new McpError(
776
+ ErrorCode.InvalidRequest,
777
+ 'Browser not connected. Call browser_connect first.'
778
+ );
779
+ }
780
+
781
+ return this.page;
782
+ }
783
+
784
+ // All formatting happens in-page via the bundled library; no server-side dynamic imports.
785
+
786
+ private async loadLibraryCode(): Promise<string> {
787
+ const embeddedPath = resolve(__dirname, EMBEDDED_LIBRARY_RELATIVE_PATH);
788
+ return this.readLibraryFile(embeddedPath);
789
+ }
790
+
791
+ private async readLibraryFile(resolvedPath: string): Promise<string> {
792
+ if (!(await pathExists(resolvedPath))) {
793
+ throw new McpError(
794
+ ErrorCode.InvalidParams,
795
+ `Embedded library file not found at ${resolvedPath}. Ensure the bundled file exists.`
796
+ );
797
+ }
798
+
799
+ // If cached, validate mtime to support hot updates without restart
800
+ const { mtimeMs } = await stat(resolvedPath);
801
+ if (this.cachedLibrary?.path === resolvedPath && this.cachedLibrary.mtimeMs === mtimeMs) {
802
+ return this.cachedLibrary.code;
803
+ }
804
+
805
+ const code = await readFile(resolvedPath, 'utf8');
806
+ this.cachedLibrary = { path: resolvedPath, code, mtimeMs };
807
+ return code;
808
+ }
809
+
810
+ private handleToolError(error: unknown, fallbackMessage: string): never {
811
+ if (error instanceof McpError) {
812
+ throw error;
813
+ }
814
+
815
+ if (error instanceof Error) {
816
+ console.error(fallbackMessage, error);
817
+ throw new McpError(ErrorCode.InternalError, error.message);
818
+ }
819
+
820
+ throw new McpError(ErrorCode.InternalError, fallbackMessage);
821
+ }
822
+ }
823
+
824
+ const server = new SmartDomReaderServer();
825
+ server.run().catch((error) => {
826
+ console.error('Smart DOM Reader server failed to start', error);
827
+ process.exitCode = 1;
828
+ });