browser-mcp-lite 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (5) hide show
  1. package/index.js +194 -0
  2. package/package.json +27 -0
  3. package/setup.js +30 -0
  4. package/token.js +36 -0
  5. package/tools.js +60 -0
package/index.js ADDED
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env node
2
+ import Fastify from 'fastify';
3
+ import websocket from '@fastify/websocket';
4
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
5
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
6
+ import { randomUUID } from 'crypto';
7
+ import { registerTools } from './tools.js';
8
+ import { ensureToken } from './token.js';
9
+
10
+ // --- Config ---
11
+ const PORT = process.env.MCP_PORT || 12307;
12
+ const HOST = '127.0.0.1';
13
+
14
+ // --- Ensure token (auto-setup on first run) ---
15
+ const { token: TOKEN, isNew } = ensureToken();
16
+
17
+ // --- Extension connection state ---
18
+ let extensionWs = null;
19
+ const pendingRequests = new Map(); // id -> {resolve, reject, timer}
20
+ let requestCounter = 0;
21
+
22
+ export function sendToExtension(method, params = {}) {
23
+ return new Promise((resolve, reject) => {
24
+ if (!extensionWs || extensionWs.readyState !== 1) {
25
+ reject(new Error('Chrome Extension not connected. Open Chrome and click Connect.'));
26
+ return;
27
+ }
28
+ const id = ++requestCounter;
29
+ const timer = setTimeout(() => {
30
+ pendingRequests.delete(id);
31
+ reject(new Error(`Extension request timed out (${method})`));
32
+ }, 30000);
33
+ pendingRequests.set(id, { resolve, reject, timer });
34
+ extensionWs.send(JSON.stringify({ id, method, params }));
35
+ });
36
+ }
37
+
38
+ // --- Per-session MCP transport management ---
39
+ const sessions = new Map(); // sessionId -> transport
40
+
41
+ function createSessionTransport() {
42
+ const server = new McpServer({ name: 'browser-mcp-lite', version: '1.0.0' });
43
+ registerTools(server);
44
+ let sessionId = null;
45
+ const transport = new StreamableHTTPServerTransport({
46
+ sessionIdGenerator: () => randomUUID(),
47
+ onsessioninitialized: (id) => {
48
+ sessionId = id;
49
+ sessions.set(id, transport);
50
+ console.log(`[MCP] Session started: ${id.slice(0, 8)}...`);
51
+ },
52
+ });
53
+ transport.onclose = () => {
54
+ if (sessionId) {
55
+ sessions.delete(sessionId);
56
+ console.log(`[MCP] Session closed: ${sessionId.slice(0, 8)}...`);
57
+ }
58
+ };
59
+ server.connect(transport);
60
+ return transport;
61
+ }
62
+
63
+ // --- Fastify app ---
64
+ const app = Fastify({ logger: false });
65
+ await app.register(websocket);
66
+
67
+ // Auth check — only for /mcp routes
68
+ function checkAuth(request, reply) {
69
+ const auth = request.headers.authorization;
70
+ if (!auth || auth !== `Bearer ${TOKEN}`) {
71
+ reply.code(401).send({ error: 'Unauthorized' });
72
+ return false;
73
+ }
74
+ return true;
75
+ }
76
+
77
+ // MCP route — per-session transport management
78
+ app.all('/mcp', async (request, reply) => {
79
+ if (!checkAuth(request, reply)) return;
80
+
81
+ const sessionId = request.headers['mcp-session-id'];
82
+ let transport = sessions.get(sessionId);
83
+
84
+ if (!transport) {
85
+ if (request.method === 'GET' || request.method === 'DELETE') {
86
+ reply.code(400).send({ jsonrpc: '2.0', error: { code: -32000, message: 'No active session' } });
87
+ return;
88
+ }
89
+ // POST without session -> new session (initialize request)
90
+ transport = createSessionTransport();
91
+ }
92
+
93
+ reply.hijack();
94
+ await transport.handleRequest(request.raw, reply.raw, request.body);
95
+ });
96
+
97
+ // Health check (no auth)
98
+ app.get('/ping', async () => ({ status: 'ok', extension: extensionWs?.readyState === 1 }));
99
+
100
+ // WebSocket endpoint for Chrome Extension
101
+ // First message must be: { "type": "auth", "token": "<TOKEN>" }
102
+ app.register(async function (fastify) {
103
+ fastify.get('/ws', { websocket: true }, (socket, request) => {
104
+ let authenticated = false;
105
+
106
+ // Auth timeout — close if no valid auth within 5 seconds
107
+ const authTimeout = setTimeout(() => {
108
+ if (!authenticated) {
109
+ console.log('[WS] Auth timeout — closing');
110
+ socket.close(4001, 'Auth timeout');
111
+ }
112
+ }, 5000);
113
+
114
+ socket.on('message', (raw) => {
115
+ let msg;
116
+ try { msg = JSON.parse(raw.toString()); } catch { return; }
117
+
118
+ // First message must be auth
119
+ if (!authenticated) {
120
+ if (msg.type === 'auth' && msg.token === TOKEN) {
121
+ authenticated = true;
122
+ clearTimeout(authTimeout);
123
+ // Close previous extension connection if any
124
+ if (extensionWs && extensionWs !== socket) {
125
+ extensionWs.close(4002, 'Replaced by new connection');
126
+ }
127
+ extensionWs = socket;
128
+ console.log('[WS] Extension authenticated and connected');
129
+ socket.send(JSON.stringify({ type: 'auth_ok' }));
130
+ } else {
131
+ console.log('[WS] Invalid auth — closing');
132
+ socket.close(4003, 'Invalid token');
133
+ }
134
+ return;
135
+ }
136
+
137
+ // Ignore pings
138
+ if (msg.type === 'ping') return;
139
+
140
+ const pending = pendingRequests.get(msg.id);
141
+ if (pending) {
142
+ clearTimeout(pending.timer);
143
+ pendingRequests.delete(msg.id);
144
+ if (msg.error) {
145
+ pending.reject(new Error(msg.error));
146
+ } else {
147
+ pending.resolve(msg.result);
148
+ }
149
+ }
150
+ });
151
+
152
+ socket.on('close', () => {
153
+ clearTimeout(authTimeout);
154
+ if (extensionWs === socket) {
155
+ console.log('[WS] Extension disconnected');
156
+ extensionWs = null;
157
+ for (const [id, pending] of pendingRequests) {
158
+ clearTimeout(pending.timer);
159
+ pending.reject(new Error('Extension disconnected'));
160
+ pendingRequests.delete(id);
161
+ }
162
+ }
163
+ });
164
+ });
165
+ });
166
+
167
+ // --- Start ---
168
+ await app.listen({ port: PORT, host: HOST });
169
+
170
+ const mcpUrl = `http://${HOST}:${PORT}/mcp`;
171
+ const wsUrl = `ws://${HOST}:${PORT}/ws`;
172
+ const sep = '\u2501'.repeat(53);
173
+
174
+ console.log(`[browser-mcp-lite] MCP server: ${mcpUrl}`);
175
+ console.log(`[browser-mcp-lite] WebSocket: ${wsUrl}`);
176
+ if (isNew) console.log('\n\u26A0 First run \u2014 new token generated');
177
+
178
+ console.log(`\n\u2501\u2501\u2501 Auth Token (paste into Chrome Extension popup) \u2501\u2501\u2501`);
179
+ console.log(TOKEN);
180
+ console.log(sep);
181
+
182
+ console.log(`\n\u2501\u2501\u2501 MCP Client Config (save as .mcp.json) \u2501\u2501\u2501`);
183
+ console.log(JSON.stringify({
184
+ mcpServers: {
185
+ browser: {
186
+ type: 'http',
187
+ url: mcpUrl,
188
+ headers: { Authorization: `Bearer ${TOKEN}` },
189
+ },
190
+ },
191
+ }, null, 2));
192
+ console.log(sep);
193
+
194
+ console.log('\n[browser-mcp-lite] Waiting for Chrome Extension...');
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "browser-mcp-lite",
3
+ "version": "1.0.1",
4
+ "description": "Minimal, auth-secured MCP server for real browser access",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "bin": {
8
+ "browser-mcp-lite": "index.js"
9
+ },
10
+ "files": [
11
+ "index.js",
12
+ "token.js",
13
+ "setup.js",
14
+ "tools.js"
15
+ ],
16
+ "scripts": {
17
+ "start": "node index.js",
18
+ "setup": "node setup.js"
19
+ },
20
+ "dependencies": {
21
+ "@modelcontextprotocol/sdk": "^1.29.0",
22
+ "fastify": "^5.0.0",
23
+ "@fastify/websocket": "^11.0.0",
24
+ "zod": "^3.25.0"
25
+ },
26
+ "license": "MIT"
27
+ }
package/setup.js ADDED
@@ -0,0 +1,30 @@
1
+ import { ensureToken } from './token.js';
2
+
3
+ const PORT = process.env.MCP_PORT || 12307;
4
+ const HOST = '127.0.0.1';
5
+ const mcpUrl = `http://${HOST}:${PORT}/mcp`;
6
+ const sep = '\u2501'.repeat(53);
7
+
8
+ const { token, isNew } = ensureToken();
9
+
10
+ if (isNew) {
11
+ console.log('\u26A0 New token generated');
12
+ } else {
13
+ console.log('Token already exists');
14
+ }
15
+
16
+ console.log(`\n\u2501\u2501\u2501 Auth Token (paste into Chrome Extension popup) \u2501\u2501\u2501`);
17
+ console.log(token);
18
+ console.log(sep);
19
+
20
+ console.log(`\n\u2501\u2501\u2501 MCP Client Config (save as .mcp.json) \u2501\u2501\u2501`);
21
+ console.log(JSON.stringify({
22
+ mcpServers: {
23
+ browser: {
24
+ type: 'http',
25
+ url: mcpUrl,
26
+ headers: { Authorization: `Bearer ${token}` },
27
+ },
28
+ },
29
+ }, null, 2));
30
+ console.log(sep);
package/token.js ADDED
@@ -0,0 +1,36 @@
1
+ import { randomBytes } from 'crypto';
2
+ import { readFileSync, writeFileSync, chmodSync } from 'fs';
3
+ import { homedir } from 'os';
4
+ import { join } from 'path';
5
+
6
+ const secretsPath = join(homedir(), '.browser-mcp-secrets.json');
7
+
8
+ /** Read token from ~/.browser-mcp-secrets.json, or null if missing/invalid. */
9
+ export function loadToken() {
10
+ try {
11
+ const secrets = JSON.parse(readFileSync(secretsPath, 'utf8'));
12
+ return secrets.token && secrets.token.length >= 32 ? secrets.token : null;
13
+ } catch {
14
+ return null;
15
+ }
16
+ }
17
+
18
+ /** Load token; generate if missing. Always enforces 600 permissions. Returns { token, isNew }. */
19
+ export function ensureToken() {
20
+ const existing = loadToken();
21
+ if (existing) {
22
+ // Enforce permissions on every startup (file may have been loosened)
23
+ try { chmodSync(secretsPath, 0o600); } catch { /* may not own file */ }
24
+ return { token: existing, isNew: false };
25
+ }
26
+
27
+ const token = randomBytes(32).toString('hex');
28
+ let secrets = {};
29
+ try { secrets = JSON.parse(readFileSync(secretsPath, 'utf8')); } catch { /* fresh */ }
30
+ secrets.token = token;
31
+
32
+ writeFileSync(secretsPath, JSON.stringify(secrets, null, 2) + '\n', 'utf8');
33
+ chmodSync(secretsPath, 0o600);
34
+
35
+ return { token, isNew: true };
36
+ }
package/tools.js ADDED
@@ -0,0 +1,60 @@
1
+ import { z } from 'zod';
2
+ import { sendToExtension } from './index.js';
3
+
4
+ export function registerTools(server) {
5
+
6
+ // --- list_tabs ---
7
+ server.tool('list_tabs', 'List all open browser tabs with their URLs and titles', async () => {
8
+ const tabs = await sendToExtension('list_tabs');
9
+ return { content: [{ type: 'text', text: JSON.stringify(tabs, null, 2) }] };
10
+ });
11
+
12
+ // --- read_page ---
13
+ // URL restriction is enforced by the extension (single source of truth).
14
+ server.tool(
15
+ 'read_page',
16
+ 'Read the DOM structure of a browser tab as an accessibility tree',
17
+ { tabId: z.number().optional().describe('Tab ID to read. Defaults to the active tab.') },
18
+ async ({ tabId }) => {
19
+ const result = await sendToExtension('read_page', { tabId });
20
+ return { content: [{ type: 'text', text: result }] };
21
+ }
22
+ );
23
+
24
+ // --- screenshot ---
25
+ server.tool(
26
+ 'screenshot',
27
+ 'Capture a screenshot of the visible area of a browser tab',
28
+ { tabId: z.number().optional().describe('Tab ID to capture. Defaults to the active tab.') },
29
+ async ({ tabId }) => {
30
+ const dataUrl = await sendToExtension('screenshot', { tabId });
31
+ const base64 = dataUrl.replace(/^data:image\/png;base64,/, '');
32
+ return { content: [{ type: 'image', data: base64, mimeType: 'image/png' }] };
33
+ }
34
+ );
35
+
36
+ // --- focus_tab ---
37
+ server.tool(
38
+ 'focus_tab',
39
+ 'Switch to a specific browser tab (bring it to the foreground)',
40
+ { tabId: z.number().describe('Tab ID to focus.') },
41
+ async ({ tabId }) => {
42
+ await sendToExtension('focus_tab', { tabId });
43
+ return { content: [{ type: 'text', text: `Focused tab ${tabId}` }] };
44
+ }
45
+ );
46
+
47
+ // --- inject_script ---
48
+ server.tool(
49
+ 'inject_script',
50
+ 'Execute custom JavaScript code in a browser tab and return the result',
51
+ {
52
+ code: z.string().max(10000).describe('JavaScript code to execute (max 10,000 chars). Must return a JSON-serializable value.'),
53
+ tabId: z.number().optional().describe('Tab ID to inject into. Defaults to the active tab.'),
54
+ },
55
+ async ({ code, tabId }) => {
56
+ const result = await sendToExtension('inject_script', { code, tabId });
57
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
58
+ }
59
+ );
60
+ }