mcpbrowser 0.3.36 → 0.3.38

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 CHANGED
@@ -38,6 +38,7 @@ Example workflow for AI assistant to use MCPBrowser
38
38
  - [scroll_page](#scroll_page)
39
39
  - [take_screenshot](#take_screenshot)
40
40
  - [close_tab](#close_tab)
41
+ - [CLI Mode](#cli-mode)
41
42
  - [Configuration](#configuration-optional)
42
43
  - [Troubleshooting](#troubleshooting)
43
44
  - [Links](#links)
@@ -397,6 +398,38 @@ Closes the browser tab for the given URL's hostname. Removes the page from the t
397
398
  - Reset to fresh state before new login
398
399
 
399
400
 
401
+ ## CLI Mode
402
+
403
+ MCPBrowser can also be used as a **standalone command-line tool**, making it easy to use from shell scripts, CI/CD pipelines, or AI agents that work through shell commands (like GitHub Copilot CLI).
404
+
405
+ ```bash
406
+ # Show help
407
+ mcpbrowser --help
408
+
409
+ # Fetch a page (handles auth, SSO, SPAs automatically)
410
+ mcpbrowser fetch https://eng.ms/docs/my-page
411
+
412
+ # Fetch with raw HTML output
413
+ mcpbrowser fetch https://github.com --browser chrome --raw
414
+
415
+ # Take a screenshot
416
+ mcpbrowser screenshot https://example.com --output page.png --full-page
417
+
418
+ # Click an element on a loaded page
419
+ mcpbrowser click https://example.com --selector "#login-btn"
420
+
421
+ # Type into a form field
422
+ mcpbrowser type https://example.com --selector "input[name=q]" --text "search query"
423
+
424
+ # Execute JavaScript
425
+ mcpbrowser exec https://example.com --script "document.title"
426
+
427
+ # Get current HTML of a loaded page
428
+ mcpbrowser html https://example.com
429
+ ```
430
+
431
+ **CLI vs MCP mode:** When run without arguments, MCPBrowser starts as an MCP server (stdin/stdout JSON-RPC). When run with a subcommand, it executes the command and exits — no MCP protocol needed.
432
+
400
433
 
401
434
  ## Configuration (Optional)
402
435
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcpbrowser",
3
- "version": "0.3.36",
3
+ "version": "0.3.38",
4
4
  "mcpName": "io.github.cherchyk/mcpbrowser",
5
5
  "type": "module",
6
6
  "description": "MCP browser server - fetch web pages using real Chrome/Edge/Brave browser. Handles authentication, SSO, CAPTCHAs, and anti-bot protection. Browser automation for AI assistants.",
@@ -12,6 +12,7 @@
12
12
  "mcp": "node src/mcp-browser.js",
13
13
  "test": "node tests/run-all.js",
14
14
  "test:unit": "node tests/run-unit.js",
15
+ "test:cli": "node tests/cli.test.js",
15
16
  "test:descriptions": "node tests/tool-selection/run-tool-selection-tests.js"
16
17
  },
17
18
  "keywords": [
@@ -139,6 +139,11 @@ export const CLICK_ELEMENT_TOOL = {
139
139
  type: "array",
140
140
  items: { type: "string" },
141
141
  description: "Suggested next actions"
142
+ },
143
+ recommendedPlugins: {
144
+ type: "array",
145
+ items: { type: "object" },
146
+ description: "Detected site-specific plugins available for this domain"
142
147
  }
143
148
  },
144
149
  required: ["status", "fallbackUsed", "nativeAttempt", "currentUrl", "message", "html", "nextSteps"],
@@ -81,7 +81,12 @@ export const EXECUTE_JAVASCRIPT_TOOL = {
81
81
  urlChanged: { type: 'boolean', description: 'True if page URL changed during execution' },
82
82
  currentUrl: { type: 'string', description: 'URL after execution' },
83
83
  nextSteps: { type: 'array', items: { type: 'string' } },
84
- error: { type: ['object', 'null'], description: 'Error object when script throws or times out' }
84
+ error: { type: ['object', 'null'], description: 'Error object when script throws or times out' },
85
+ recommendedPlugins: {
86
+ type: 'array',
87
+ items: { type: 'object' },
88
+ description: 'Detected site-specific plugins available for this domain'
89
+ }
85
90
  },
86
91
  required: ['type', 'executionTimeMs', 'truncated', 'urlChanged', 'currentUrl', 'nextSteps'],
87
92
  additionalProperties: false
@@ -91,6 +91,11 @@ export const FETCH_WEBPAGE_TOOL = {
91
91
  type: "array",
92
92
  items: { type: "string" },
93
93
  description: "Suggested next actions"
94
+ },
95
+ recommendedPlugins: {
96
+ type: "array",
97
+ items: { type: "object" },
98
+ description: "Detected site-specific plugins available for this domain"
94
99
  }
95
100
  },
96
101
  required: ["currentUrl", "html", "nextSteps"],
@@ -83,6 +83,11 @@ export const GET_CURRENT_HTML_TOOL = {
83
83
  type: "array",
84
84
  items: { type: "string" },
85
85
  description: "Suggested next actions"
86
+ },
87
+ recommendedPlugins: {
88
+ type: "array",
89
+ items: { type: "object" },
90
+ description: "Detected site-specific plugins available for this domain"
86
91
  }
87
92
  },
88
93
  required: ["currentUrl", "html", "nextSteps"],
@@ -0,0 +1,81 @@
1
+ /**
2
+ * cli/args.js — Argument parsing and schema-driven flag coercion.
3
+ */
4
+
5
+ /**
6
+ * Parse CLI argv into { command, positional, flags }.
7
+ * Supports --flag value, --flag=value, --flag (boolean), -h, -v.
8
+ */
9
+ export function parseArgs(argv) {
10
+ const flags = {};
11
+ const positional = [];
12
+ let command = null;
13
+
14
+ for (let i = 0; i < argv.length; i++) {
15
+ const arg = argv[i];
16
+ if (arg === '--help' || arg === '-h') {
17
+ flags.help = true;
18
+ } else if (arg === '--version' || arg === '-v') {
19
+ flags.version = true;
20
+ } else if (arg.startsWith('--')) {
21
+ const eqIdx = arg.indexOf('=');
22
+ if (eqIdx !== -1) {
23
+ // --flag=value
24
+ flags[arg.slice(2, eqIdx)] = arg.slice(eqIdx + 1);
25
+ } else {
26
+ const key = arg.slice(2);
27
+ const next = argv[i + 1];
28
+ if (next && !next.startsWith('--')) {
29
+ flags[key] = next;
30
+ i++;
31
+ } else {
32
+ flags[key] = true;
33
+ }
34
+ }
35
+ } else if (!command) {
36
+ command = arg;
37
+ } else {
38
+ positional.push(arg);
39
+ }
40
+ }
41
+
42
+ return { command, positional, flags };
43
+ }
44
+
45
+ /**
46
+ * Coerce flag values to correct types using MCP tool inputSchema.
47
+ * Returns { coerced, errors } where errors is an array of validation messages.
48
+ */
49
+ export function coerceFlags(flags, tool, flagMap = {}) {
50
+ const props = tool?.inputSchema?.properties || {};
51
+ const coerced = { ...flags };
52
+ const errors = [];
53
+
54
+ // Build reverse map: CLI flag → MCP param name
55
+ const reverseMap = {};
56
+ for (const [cliFlag, mcpParam] of Object.entries(flagMap)) {
57
+ if (!mcpParam.startsWith('_')) reverseMap[cliFlag] = mcpParam;
58
+ }
59
+
60
+ for (const [key, value] of Object.entries(coerced)) {
61
+ // Skip built-in flags
62
+ if (key === 'help' || key === 'version' || key === 'json' || key === 'output') continue;
63
+
64
+ const mcpParam = reverseMap[key] || key;
65
+ const schema = props[mcpParam];
66
+ if (!schema) continue; // unknown flag — leave as-is
67
+
68
+ if (schema.type === 'number' && typeof value === 'string') {
69
+ const n = Number(value);
70
+ if (Number.isNaN(n)) {
71
+ errors.push(`--${key} must be a number, got '${value}'`);
72
+ } else {
73
+ coerced[key] = n;
74
+ }
75
+ } else if (schema.type === 'boolean' && typeof value === 'string') {
76
+ coerced[key] = value !== 'false' && value !== '0';
77
+ }
78
+ }
79
+
80
+ return { coerced, errors };
81
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * cli/help.js — Auto-generated help from CLI_REGISTRY + MCP tool schemas.
3
+ */
4
+
5
+ import { readFileSync } from 'fs';
6
+ import { dirname, join } from 'path';
7
+ import { fileURLToPath } from 'url';
8
+
9
+ import { CLI_REGISTRY } from './registry.js';
10
+
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = dirname(__filename);
13
+
14
+ export function getVersion() {
15
+ return JSON.parse(readFileSync(join(__dirname, '../../package.json'), 'utf-8')).version;
16
+ }
17
+
18
+ /**
19
+ * Format inputSchema properties into CLI option lines.
20
+ * Skips 'url' (positional) and internal-only params.
21
+ */
22
+ function formatSchemaOptions(tool, entry) {
23
+ const props = tool.inputSchema?.properties || {};
24
+ const required = new Set(tool.inputSchema?.required || []);
25
+ const reverseMap = {};
26
+ for (const [cliFlag, mcpParam] of Object.entries(entry.flagMap || {})) {
27
+ if (!mcpParam.startsWith('_')) reverseMap[mcpParam] = cliFlag;
28
+ }
29
+
30
+ const lines = [];
31
+ for (const [name, schema] of Object.entries(props)) {
32
+ if (name === 'url') continue;
33
+
34
+ const cliName = reverseMap[name] || name;
35
+ let typeHint = '';
36
+ if (schema.enum) {
37
+ typeHint = `<${schema.enum.filter(Boolean).join('|')}>`;
38
+ } else if (schema.type === 'string') {
39
+ typeHint = `<${cliName}>`;
40
+ } else if (schema.type === 'number') {
41
+ typeHint = '<n>';
42
+ } else if (schema.type === 'boolean') {
43
+ typeHint = '';
44
+ } else if (schema.type === 'array' && schema.items?.properties) {
45
+ lines.push(` --${cliName} (structured — see MCP schema)`);
46
+ const itemReq = new Set(schema.items.required || []);
47
+ for (const [iName, iProp] of Object.entries(schema.items.properties)) {
48
+ const ir = itemReq.has(iName) ? ' (required)' : '';
49
+ const id = iProp.default !== undefined ? ` [default: ${iProp.default}]` : '';
50
+ lines.push(` .${iName} (${iProp.type})${ir}${id} — ${iProp.description || ''}`);
51
+ }
52
+ continue;
53
+ }
54
+
55
+ const defVal = schema.default !== undefined ? ` [default: ${schema.default}]` : '';
56
+ const req = required.has(name) ? ' (required)' : '';
57
+ const desc = schema.description || '';
58
+ const shortDesc = desc.length > 80 ? desc.slice(0, 77) + '...' : desc;
59
+ lines.push(` --${cliName} ${typeHint}${req}${defVal} ${shortDesc}`);
60
+ }
61
+ return lines;
62
+ }
63
+
64
+ /**
65
+ * Format outputSchema into a compact output description.
66
+ */
67
+ function formatSchemaOutput(tool) {
68
+ const props = tool.outputSchema?.properties || {};
69
+ const lines = [];
70
+ for (const [name, prop] of Object.entries(props)) {
71
+ let t = prop.type || 'any';
72
+ if (Array.isArray(t)) t = t.filter(x => x !== 'null').join('|');
73
+ if (t === 'array') t = `array<${prop.items?.type || 'any'}>`;
74
+ if (t === 'object' && !prop.description) continue;
75
+ lines.push(` ${name} (${t})${prop.description ? ' — ' + prop.description : ''}`);
76
+ }
77
+ return lines;
78
+ }
79
+
80
+ export function printHelp() {
81
+ const version = getVersion();
82
+ const o = (s) => process.stdout.write(s + '\n');
83
+
84
+ o(`MCPBrowser v${version} — Browser automation for AI agents and CLI`);
85
+ o('');
86
+ o('USAGE');
87
+ o(' mcpbrowser Start MCP server (stdin/stdout)');
88
+ o(' mcpbrowser <command> <url> [options] Run a CLI command and exit');
89
+ o(' mcpbrowser -h | --help Show this help');
90
+ o(' mcpbrowser -v | --version Show version');
91
+ o('');
92
+ o('GLOBAL FLAGS');
93
+ o(' --json Output raw MCP result as JSON (for agent/programmatic use)');
94
+ o('');
95
+ o('WORKFLOW');
96
+ o(' fetch ──▶ click / type / exec / scroll ──▶ html ──▶ close');
97
+ o(' (load) (interact) (read) (cleanup)');
98
+ o('');
99
+ o(' Start with "fetch" to load a page, then interact. The browser keeps tabs');
100
+ o(' open between commands. Auth, SSO, CAPTCHAs are handled automatically.');
101
+ o('');
102
+ o('BROWSERS');
103
+ o(' chrome, edge, brave — auto-detected, or set with --browser');
104
+ o(' Uses your real browser profile (existing logins/cookies available).');
105
+ o('');
106
+ o('━'.repeat(70));
107
+ o('COMMANDS');
108
+ o('━'.repeat(70));
109
+
110
+ for (const entry of CLI_REGISTRY) {
111
+ o('');
112
+ const depTag = entry.requiresFetch ? ' [requires: fetch]' : '';
113
+ const desc = (entry.tool.description || '')
114
+ .replace(/\*\*/g, '')
115
+ .replace(/\\n/g, ' ')
116
+ .split('\n')[0];
117
+
118
+ o(`${entry.cmd} <url> [options]${depTag}`);
119
+ o(` ${desc}`);
120
+ o('');
121
+
122
+ const schemaOpts = formatSchemaOptions(entry.tool, entry);
123
+ if (schemaOpts.length > 0 || entry.cliNote) {
124
+ o(' Options:');
125
+ for (const line of schemaOpts) o(line);
126
+ if (entry.cliNote) o(` ${entry.cliNote}`);
127
+ o('');
128
+ }
129
+
130
+ const outFields = formatSchemaOutput(entry.tool);
131
+ if (outFields.length > 0) {
132
+ o(' Output fields:');
133
+ for (const line of outFields) o(line);
134
+ o('');
135
+ }
136
+
137
+ if (entry.examples?.length) {
138
+ o(' Examples:');
139
+ for (const ex of entry.examples) o(` ${ex}`);
140
+ }
141
+ }
142
+
143
+ o('');
144
+ o('━'.repeat(70));
145
+ o('MULTI-STEP EXAMPLE');
146
+ o('━'.repeat(70));
147
+ o('');
148
+ o(' mcpbrowser fetch https://app.example.com/login');
149
+ o(' mcpbrowser type https://app.example.com/login --selector "#email" --text "me@corp.com"');
150
+ o(' mcpbrowser type https://app.example.com/login --selector "#password" --text "secret"');
151
+ o(' mcpbrowser click https://app.example.com/login --text "Sign In"');
152
+ o(' mcpbrowser html https://app.example.com/dashboard');
153
+ o(' mcpbrowser screenshot https://app.example.com/dashboard --output dash.png');
154
+ o(' mcpbrowser close https://app.example.com');
155
+ o('');
156
+ o(' Use --json with any command for structured output:');
157
+ o(' mcpbrowser fetch https://example.com --json');
158
+ o('');
159
+
160
+ o('━'.repeat(70));
161
+ o('MCP SERVER MODE');
162
+ o('━'.repeat(70));
163
+ o('');
164
+ o(' No arguments → starts MCP server (stdin/stdout JSON-RPC).');
165
+ o(' CLI commands map 1:1 to MCP tools (fetch→fetch_webpage, etc.).');
166
+ o('');
167
+ o(' { "mcpServers": { "mcpbrowser": { "command": "npx", "args": ["-y", "mcpbrowser@latest"] } } }');
168
+ o('');
169
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * cli/index.js — CLI entrypoint and generic command executor.
3
+ *
4
+ * Thin orchestration layer: parses args, coerces types, validates,
5
+ * dispatches to registry actions, and formats output.
6
+ */
7
+
8
+ import { parseArgs, coerceFlags } from './args.js';
9
+ import { CLI_REGISTRY, CMD_MAP } from './registry.js';
10
+ import { getVersion, printHelp } from './help.js';
11
+ import { getPrimaryText } from './utils.js';
12
+
13
+ import { fetchPage } from '../actions/fetch-page.js';
14
+ import { closeBrowser } from '../core/browser.js';
15
+ import logger from '../core/logger.js';
16
+
17
+ logger.setConsoleOutput(false);
18
+
19
+ export function isCliMode(argv) {
20
+ return argv.length > 0;
21
+ }
22
+
23
+ /**
24
+ * Generic command executor — driven by CLI_REGISTRY entry.
25
+ */
26
+ async function executeCommand(entry, url, flags) {
27
+ // Custom validation
28
+ if (entry.validate) {
29
+ const err = entry.validate(flags);
30
+ if (err) {
31
+ process.stderr.write(`Error: ${err}\n`);
32
+ return 1;
33
+ }
34
+ }
35
+
36
+ // Schema-driven type coercion
37
+ const { coerced, errors } = coerceFlags(flags, entry.tool, entry.flagMap);
38
+ if (errors.length > 0) {
39
+ for (const e of errors) process.stderr.write(`Error: ${e}\n`);
40
+ return 1;
41
+ }
42
+
43
+ // Build params
44
+ const params = entry.buildParams
45
+ ? entry.buildParams(url, coerced)
46
+ : { url, ...coerced };
47
+
48
+ // Call the MCP action
49
+ const result = await entry.action(params);
50
+ const mcp = result.toMcpFormat();
51
+
52
+ if (mcp.isError) {
53
+ process.stderr.write(`Error: ${getPrimaryText(mcp)}\n`);
54
+ return 1;
55
+ }
56
+
57
+ // --json: output raw MCP result
58
+ if (coerced.json) {
59
+ const jsonOut = {
60
+ content: mcp.content,
61
+ ...(mcp.structuredContent ? { structuredContent: mcp.structuredContent } : {})
62
+ };
63
+ process.stdout.write(JSON.stringify(jsonOut, null, 2) + '\n');
64
+ return 0;
65
+ }
66
+
67
+ // Formatted output
68
+ const output = entry.formatOutput
69
+ ? entry.formatOutput(mcp, coerced)
70
+ : { stdout: getPrimaryText(mcp) };
71
+
72
+ if (output.error) {
73
+ process.stderr.write(`Error: ${output.error}\n`);
74
+ return 1;
75
+ }
76
+ if (output.stderr) process.stderr.write(output.stderr + '\n');
77
+ if (output.stdout) process.stdout.write(output.stdout + '\n');
78
+
79
+ return 0;
80
+ }
81
+
82
+ /**
83
+ * Main CLI entry point.
84
+ */
85
+ export async function runCli(argv) {
86
+ const { command, positional, flags } = parseArgs(argv);
87
+
88
+ if (flags.help) { printHelp(); return 0; }
89
+ if (flags.version) { process.stdout.write(getVersion() + '\n'); return 0; }
90
+ if (!command) { printHelp(); return 0; }
91
+
92
+ const entry = CMD_MAP.get(command);
93
+ if (!entry) {
94
+ process.stderr.write(`Unknown command: ${command}\n`);
95
+ process.stderr.write(`Available: ${[...CMD_MAP.keys()].join(', ')}\n`);
96
+ process.stderr.write('Run mcpbrowser --help for usage\n');
97
+ return 1;
98
+ }
99
+
100
+ const url = positional[0];
101
+ if (!url) {
102
+ process.stderr.write(`Error: <url> is required for '${command}'\n`);
103
+ process.stderr.write(`Usage: mcpbrowser ${command} <url> [options]\n`);
104
+ return 1;
105
+ }
106
+
107
+ let exitCode = 1;
108
+ try {
109
+ // Auto-fetch for commands that declare it (e.g. screenshot)
110
+ if (entry.autoFetch) {
111
+ const fetchResult = await fetchPage({ url, browser: flags.browser || '', removeUnnecessaryHTML: true });
112
+ const fetchMcp = fetchResult.toMcpFormat();
113
+ if (fetchMcp.isError) {
114
+ process.stderr.write(`Error loading page: ${getPrimaryText(fetchMcp)}\n`);
115
+ return 1;
116
+ }
117
+ const actualUrl = fetchMcp.structuredContent?.currentUrl || url;
118
+ exitCode = await executeCommand(entry, actualUrl, flags);
119
+ } else {
120
+ exitCode = await executeCommand(entry, url, flags);
121
+ }
122
+ } catch (err) {
123
+ process.stderr.write(`Error: ${err.message}\n`);
124
+ exitCode = 1;
125
+ } finally {
126
+ try { await closeBrowser(); } catch { /* ignore */ }
127
+ }
128
+
129
+ return exitCode;
130
+ }
@@ -0,0 +1,256 @@
1
+ /**
2
+ * cli/registry.js — Single source of truth for all CLI commands.
3
+ *
4
+ * To add a new CLI command:
5
+ * 1. Import the MCP TOOL definition and action function
6
+ * 2. Add an entry to CLI_REGISTRY below
7
+ * 3. Done — help, routing, flag mapping, coercion all auto-update
8
+ */
9
+
10
+ import { writeFileSync } from 'fs';
11
+
12
+ import { fetchPage, FETCH_WEBPAGE_TOOL } from '../actions/fetch-page.js';
13
+ import { clickElement, CLICK_ELEMENT_TOOL } from '../actions/click-element.js';
14
+ import { typeText, TYPE_TEXT_TOOL } from '../actions/type-text.js';
15
+ import { executeJavascript, EXECUTE_JAVASCRIPT_TOOL } from '../actions/execute-javascript.js';
16
+ import { getCurrentHtml, GET_CURRENT_HTML_TOOL } from '../actions/get-current-html.js';
17
+ import { takeScreenshot, TAKE_SCREENSHOT_TOOL } from '../actions/take-screenshot.js';
18
+ import { scrollPage, SCROLL_PAGE_TOOL } from '../actions/scroll-page.js';
19
+ import { navigateHistory, NAVIGATE_HISTORY_TOOL } from '../actions/navigate-history.js';
20
+ import { closeTab, CLOSE_TAB_TOOL } from '../actions/close-tab.js';
21
+
22
+ import { htmlToText, getStructured, getPrimaryText } from './utils.js';
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Factory for back/forward (deduplicated)
26
+ // ---------------------------------------------------------------------------
27
+
28
+ function makeHistoryCommand(direction) {
29
+ return {
30
+ cmd: direction,
31
+ tool: NAVIGATE_HISTORY_TOOL,
32
+ action: navigateHistory,
33
+ requiresFetch: true,
34
+ flagMap: { raw: '_raw' },
35
+ flagDefaults: {},
36
+ buildParams: (url, flags) => ({
37
+ url,
38
+ direction,
39
+ returnHtml: true,
40
+ removeUnnecessaryHTML: !flags.raw,
41
+ }),
42
+ formatOutput: (mcp, flags) => {
43
+ const s = getStructured(mcp);
44
+ const out = {};
45
+ if (s.previousUrl || s.currentUrl) {
46
+ out.stderr = `${s.previousUrl || '?'} → ${s.currentUrl || '?'}`;
47
+ }
48
+ if (s.html) out.stdout = flags.raw ? s.html : htmlToText(s.html);
49
+ return out;
50
+ },
51
+ examples: [`mcpbrowser ${direction} https://example.com`],
52
+ };
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // CLI_REGISTRY
57
+ // ---------------------------------------------------------------------------
58
+
59
+ export const CLI_REGISTRY = [
60
+ {
61
+ cmd: 'fetch',
62
+ tool: FETCH_WEBPAGE_TOOL,
63
+ action: fetchPage,
64
+ requiresFetch: false,
65
+ flagMap: { wait: 'postLoadWait', raw: '_raw' },
66
+ flagDefaults: {},
67
+ buildParams: (url, flags) => ({
68
+ url,
69
+ browser: flags.browser || '',
70
+ removeUnnecessaryHTML: !flags.raw,
71
+ postLoadWait: flags.wait ? Number(flags.wait) : 0
72
+ }),
73
+ formatOutput: (mcp, flags) => {
74
+ const html = getStructured(mcp).html || getPrimaryText(mcp);
75
+ return { stdout: flags.raw ? html : htmlToText(html) };
76
+ },
77
+ examples: [
78
+ 'mcpbrowser fetch https://eng.ms/docs/my-page',
79
+ 'mcpbrowser fetch https://portal.azure.com --browser edge --wait 5000',
80
+ 'mcpbrowser fetch https://github.com --raw',
81
+ ],
82
+ cliNote: '--raw Output full HTML instead of extracted text',
83
+ },
84
+
85
+ {
86
+ cmd: 'screenshot',
87
+ tool: TAKE_SCREENSHOT_TOOL,
88
+ action: takeScreenshot,
89
+ requiresFetch: true,
90
+ autoFetch: true, // screenshot auto-fetches the page first
91
+ flagMap: { 'full-page': 'fullPage' },
92
+ buildParams: (url, flags) => ({
93
+ url,
94
+ fullPage: !!flags['full-page']
95
+ }),
96
+ formatOutput: (mcp, flags) => {
97
+ const base64 = getStructured(mcp).screenshotBase64;
98
+ if (!base64) return { error: 'No screenshot data returned' };
99
+ const outFile = flags.output || 'screenshot.png';
100
+ writeFileSync(outFile, Buffer.from(base64, 'base64'));
101
+ return { stderr: `Screenshot saved to ${outFile}` };
102
+ },
103
+ examples: [
104
+ 'mcpbrowser screenshot https://example.com --output page.png',
105
+ 'mcpbrowser screenshot https://dashboard.corp.com --full-page',
106
+ ],
107
+ cliNote: '--output <path> File path to save (default: screenshot.png)',
108
+ },
109
+
110
+ {
111
+ cmd: 'click',
112
+ tool: CLICK_ELEMENT_TOOL,
113
+ action: clickElement,
114
+ requiresFetch: true,
115
+ flagMap: {},
116
+ buildParams: (url, flags) => ({
117
+ url,
118
+ selector: flags.selector || undefined,
119
+ text: flags.text || undefined,
120
+ returnHtml: flags.returnHtml !== 'false',
121
+ removeUnnecessaryHTML: true,
122
+ postClickWait: flags.postClickWait ? Number(flags.postClickWait) : 1000,
123
+ }),
124
+ validate: (flags) => {
125
+ if (!flags.selector && !flags.text) return '--selector or --text is required for click';
126
+ },
127
+ formatOutput: (mcp) => {
128
+ const html = getStructured(mcp).html;
129
+ return { stdout: html ? htmlToText(html) : getPrimaryText(mcp) };
130
+ },
131
+ examples: [
132
+ 'mcpbrowser click https://example.com --selector "#login-btn"',
133
+ 'mcpbrowser click https://example.com --text "Sign In"',
134
+ ],
135
+ },
136
+
137
+ {
138
+ cmd: 'type',
139
+ tool: TYPE_TEXT_TOOL,
140
+ action: typeText,
141
+ requiresFetch: true,
142
+ flagMap: {},
143
+ buildParams: (url, flags) => ({
144
+ url,
145
+ fields: [{ selector: flags.selector, text: flags.text }],
146
+ returnHtml: false
147
+ }),
148
+ validate: (flags) => {
149
+ if (!flags.selector || !flags.text) return '--selector and --text are required for type';
150
+ },
151
+ formatOutput: (mcp) => ({ stdout: getPrimaryText(mcp) }),
152
+ examples: [
153
+ 'mcpbrowser type https://example.com --selector "#search" --text "query"',
154
+ 'mcpbrowser type https://login.com --selector "input[name=email]" --text "user@corp.com"',
155
+ ],
156
+ cliNote: 'CLI shorthand: --selector + --text fills a single field',
157
+ },
158
+
159
+ {
160
+ cmd: 'exec',
161
+ tool: EXECUTE_JAVASCRIPT_TOOL,
162
+ action: executeJavascript,
163
+ requiresFetch: true,
164
+ flagMap: {},
165
+ buildParams: (url, flags) => ({
166
+ url,
167
+ script: flags.script,
168
+ timeoutMs: flags.timeoutMs ? Number(flags.timeoutMs) : 30000,
169
+ returnType: flags.returnType || 'json',
170
+ }),
171
+ validate: (flags) => {
172
+ if (!flags.script) return '--script is required for exec';
173
+ },
174
+ formatOutput: (mcp) => {
175
+ const r = getStructured(mcp).result;
176
+ if (r !== undefined && r !== null) {
177
+ return { stdout: typeof r === 'string' ? r : JSON.stringify(r, null, 2) };
178
+ }
179
+ return { stdout: getPrimaryText(mcp) };
180
+ },
181
+ examples: [
182
+ 'mcpbrowser exec https://example.com --script "document.title"',
183
+ 'mcpbrowser exec https://mail.google.com --script "[...document.querySelectorAll(\'.zA\')].map(r=>r.textContent)"',
184
+ ],
185
+ },
186
+
187
+ {
188
+ cmd: 'html',
189
+ tool: GET_CURRENT_HTML_TOOL,
190
+ action: getCurrentHtml,
191
+ requiresFetch: true,
192
+ flagMap: { raw: '_raw' },
193
+ buildParams: (url, flags) => ({
194
+ url,
195
+ removeUnnecessaryHTML: !flags.raw,
196
+ }),
197
+ formatOutput: (mcp) => ({ stdout: getStructured(mcp).html || getPrimaryText(mcp) }),
198
+ examples: [
199
+ 'mcpbrowser html https://example.com',
200
+ 'mcpbrowser html https://example.com --raw',
201
+ ],
202
+ cliNote: '--raw Output raw HTML without cleanup',
203
+ },
204
+
205
+ {
206
+ cmd: 'scroll',
207
+ tool: SCROLL_PAGE_TOOL,
208
+ action: scrollPage,
209
+ requiresFetch: true,
210
+ flagMap: {},
211
+ buildParams: (url, flags) => {
212
+ const params = { url };
213
+ if (flags.selector) { params.selector = flags.selector; }
214
+ else if (flags.x !== undefined || flags.y !== undefined) {
215
+ if (flags.x !== undefined) params.x = Number(flags.x);
216
+ if (flags.y !== undefined) params.y = Number(flags.y);
217
+ } else {
218
+ params.direction = flags.direction || 'down';
219
+ if (flags.amount) params.amount = Number(flags.amount);
220
+ }
221
+ return params;
222
+ },
223
+ formatOutput: (mcp) => {
224
+ const s = getStructured(mcp);
225
+ return {
226
+ stdout: JSON.stringify({
227
+ scrollX: s.scrollX, scrollY: s.scrollY,
228
+ pageWidth: s.pageWidth, pageHeight: s.pageHeight,
229
+ viewportWidth: s.viewportWidth, viewportHeight: s.viewportHeight
230
+ }, null, 2)
231
+ };
232
+ },
233
+ examples: [
234
+ 'mcpbrowser scroll https://example.com --direction down --amount 1000',
235
+ 'mcpbrowser scroll https://example.com --selector "#footer"',
236
+ 'mcpbrowser scroll https://example.com --x 0 --y 0',
237
+ ],
238
+ },
239
+
240
+ makeHistoryCommand('back'),
241
+ makeHistoryCommand('forward'),
242
+
243
+ {
244
+ cmd: 'close',
245
+ tool: CLOSE_TAB_TOOL,
246
+ action: closeTab,
247
+ requiresFetch: false,
248
+ flagMap: {},
249
+ buildParams: (url) => ({ url }),
250
+ formatOutput: (mcp) => ({ stdout: getPrimaryText(mcp) }),
251
+ examples: ['mcpbrowser close https://example.com'],
252
+ },
253
+ ];
254
+
255
+ // Lookup map for O(1) command resolution
256
+ export const CMD_MAP = new Map(CLI_REGISTRY.map(entry => [entry.cmd, entry]));
@@ -0,0 +1,39 @@
1
+ /**
2
+ * cli/utils.js — Shared utilities for CLI output formatting and safe data access.
3
+ */
4
+
5
+ /**
6
+ * Convert HTML to readable terminal text (lossy preview).
7
+ */
8
+ export function htmlToText(html) {
9
+ return html
10
+ .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
11
+ .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
12
+ .replace(/<br\s*\/?>/gi, '\n')
13
+ .replace(/<\/p>/gi, '\n\n')
14
+ .replace(/<\/div>/gi, '\n')
15
+ .replace(/<\/li>/gi, '\n')
16
+ .replace(/<\/h[1-6]>/gi, '\n\n')
17
+ .replace(/<[^>]+>/g, '')
18
+ .replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
19
+ .replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;/g, ' ')
20
+ .replace(/[ \t]+/g, ' ')
21
+ .replace(/\n{3,}/g, '\n\n')
22
+ .trim();
23
+ }
24
+
25
+ /**
26
+ * Safely get the primary text content from an MCP result.
27
+ * Falls back to empty string if content is missing.
28
+ */
29
+ export function getPrimaryText(mcp) {
30
+ return mcp?.content?.[0]?.text ?? '';
31
+ }
32
+
33
+ /**
34
+ * Safely get structuredContent from an MCP result.
35
+ * Returns an empty object if missing, so callers can destructure safely.
36
+ */
37
+ export function getStructured(mcp) {
38
+ return mcp?.structuredContent ?? {};
39
+ }
@@ -128,6 +128,23 @@ export class InformationalResponse extends MCPResponse {
128
128
  }
129
129
  return summary;
130
130
  }
131
+
132
+ /**
133
+ * Informational responses omit structuredContent to avoid schema violations.
134
+ * Their fields (message, reason, status) don't match tool-specific outputSchemas.
135
+ * @returns {Object} MCP-compliant response with text content only
136
+ */
137
+ toMcpFormat() {
138
+ return {
139
+ content: [
140
+ {
141
+ type: "text",
142
+ text: this.getTextSummary()
143
+ }
144
+ ],
145
+ isError: false
146
+ };
147
+ }
131
148
  }
132
149
 
133
150
  /**
@@ -272,6 +289,23 @@ export class HttpStatusResponse extends MCPResponse {
272
289
  }
273
290
  return summary;
274
291
  }
292
+
293
+ /**
294
+ * HTTP status responses omit structuredContent to avoid schema violations.
295
+ * Their fields (url, statusCode, etc.) don't match tool-specific outputSchemas.
296
+ * @returns {Object} MCP-compliant response with text content only
297
+ */
298
+ toMcpFormat() {
299
+ return {
300
+ content: [
301
+ {
302
+ type: "text",
303
+ text: this.getTextSummary()
304
+ }
305
+ ],
306
+ isError: false
307
+ };
308
+ }
275
309
  }
276
310
 
277
311
  /**
@@ -12,6 +12,9 @@ import { fileURLToPath } from 'url';
12
12
  import { readFileSync } from 'fs';
13
13
  import { dirname, join } from 'path';
14
14
 
15
+ // Import CLI mode
16
+ import { isCliMode, runCli } from './cli/index.js';
17
+
15
18
  // Import response classes
16
19
  import { ErrorResponse } from './core/responses.js';
17
20
  import logger, { attachServer as attachLoggerServer } from './core/logger.js';
@@ -203,6 +206,9 @@ export {
203
206
  scrollPage,
204
207
  navigateHistory,
205
208
  handleAcceptEula,
209
+ // CLI exports
210
+ isCliMode,
211
+ runCli,
206
212
  // Plugin system exports
207
213
  loadPlugins,
208
214
  getLoadedPlugins,
@@ -216,8 +222,20 @@ export {
216
222
  // Run the MCP server only if this is the main module (not imported for testing)
217
223
  if (import.meta.url === new URL(process.argv[1], 'file://').href ||
218
224
  fileURLToPath(import.meta.url) === process.argv[1]) {
219
- main().catch((err) => {
220
- logger.error(`Server failed: ${err.message}`);
221
- process.exit(1);
222
- });
225
+ const argv = process.argv.slice(2);
226
+ if (isCliMode(argv)) {
227
+ // CLI mode: run command and exit
228
+ runCli(argv).then((code) => {
229
+ process.exit(code);
230
+ }).catch((err) => {
231
+ process.stderr.write(`Error: ${err.message}\n`);
232
+ process.exit(1);
233
+ });
234
+ } else {
235
+ // MCP server mode (default): stdin/stdout JSON-RPC
236
+ main().catch((err) => {
237
+ logger.error(`Server failed: ${err.message}`);
238
+ process.exit(1);
239
+ });
240
+ }
223
241
  }