// LAN gateway for the local browser-mcp stdio server. // Endpoints (all require "Authorization: Bearer "): // POST /mcp - MCP Streamable HTTP (stateless), for MCP clients on the LAN // GET /api/health - liveness + upstream status // GET /api/tools - list available browser tools // POST /api/call - { "name": "", "arguments": {...} } -> tool result JSON // Config: PORT env (default 9109), token read from token.txt next to this file. import http from 'node:http'; import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { CallToolRequestSchema, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; import { renderDocs, renderDocsHtml, renderHome, renderJsonViewer } from './pages.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PORT = Number(process.env.PORT || 9109); const HOST = process.env.HOST || '0.0.0.0'; const TOKEN = (process.env.GATEWAY_TOKEN || readFileSync(join(__dirname, 'token.txt'), 'utf8')).trim(); const BROWSER_MCP_CLI = process.env.BROWSER_MCP_CLI || 'C:\\Users\\ljq29\\AppData\\Roaming\\npm\\node_modules\\@agent360\\browser-mcp\\bin\\cli.js'; // ---------- upstream stdio client (spawns browser-mcp, reconnects on exit) ---------- let client = null; let upstreamReady = false; async function connectUpstream() { const transport = new StdioClientTransport({ command: process.execPath, args: [BROWSER_MCP_CLI], stderr: 'pipe', }); const c = new Client( { name: 'browser-mcp-gateway', version: '1.0.0' }, { capabilities: {} }, ); transport.stderr?.on('data', (d) => console.error('[upstream]', d.toString().trim())); c.onclose = () => { upstreamReady = false; console.error('[upstream] closed, reconnecting in 3s...'); setTimeout(() => connectUpstream().catch(console.error), 3000); }; await c.connect(transport); client = c; upstreamReady = true; console.log('[upstream] connected to browser-mcp stdio server'); } function requireUpstream(res) { if (!upstreamReady || !client) { sendJson(res, 503, { error: 'upstream browser-mcp not ready' }); return false; } return true; } // ---------- MCP proxy server (per request, stateless) ---------- function buildProxyServer() { const server = new Server( { name: 'browser-mcp-gateway', version: '1.0.0' }, { capabilities: { tools: {}, resources: {}, prompts: {} } }, ); server.setRequestHandler(ListToolsRequestSchema, (req) => client.listTools(req.params)); server.setRequestHandler(CallToolRequestSchema, (req) => client.callTool(req.params)); server.setRequestHandler(ListResourcesRequestSchema, (req) => client.listResources(req.params)); server.setRequestHandler(ReadResourceRequestSchema, (req) => client.readResource(req.params)); server.setRequestHandler(ListResourceTemplatesRequestSchema, (req) => client.listResourceTemplates(req.params)); server.setRequestHandler(ListPromptsRequestSchema, (req) => client.listPrompts(req.params)); server.setRequestHandler(GetPromptRequestSchema, (req) => client.getPrompt(req.params)); return server; } async function handleMcp(req, res, body) { const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); const server = buildProxyServer(); res.on('close', () => { transport.close().catch(() => {}); server.close().catch(() => {}); }); await server.connect(transport); await transport.handleRequest(req, res, body); } // ---------- HTTP plumbing ---------- function sendJson(res, status, obj) { const data = JSON.stringify(obj); res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(data); } function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; req.on('data', (c) => chunks.push(c)); req.on('end', () => { if (!chunks.length) return resolve(undefined); try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); } catch (e) { reject(new Error('invalid JSON body')); } }); req.on('error', reject); }); } function authed(req, url) { const h = req.headers.authorization || ''; return h === `Bearer ${TOKEN}` || url.searchParams.get('token') === TOKEN; } const server = http.createServer(async (req, res) => { const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); const path = url.pathname.replace(/\/+$/, '') || '/'; try { if (!authed(req, url)) { res.writeHead(401, { 'WWW-Authenticate': 'Bearer', 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ error: 'unauthorized' })); } const baseUrl = `http://${req.headers.host || `localhost:${PORT}`}`; if ((path === '/' || path === '') && req.method === 'GET') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(renderHome(baseUrl, TOKEN)); } if (path === '/docs' && req.method === 'GET') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(renderDocsHtml(baseUrl, TOKEN)); } if (path === '/docs.json' && req.method === 'GET') { const doc = renderDocs(baseUrl, TOKEN); if ((req.headers.accept || '').includes('text/html')) { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(renderJsonViewer(baseUrl, TOKEN, doc)); } return sendJson(res, 200, doc); } if (path === '/mcp') { if (req.method !== 'POST') return sendJson(res, 405, { error: 'stateless /mcp only accepts POST' }); if (!requireUpstream(res)) return; const body = await readBody(req); return await handleMcp(req, res, body); } if (path === '/api/health' && req.method === 'GET') { return sendJson(res, 200, { ok: true, upstream: upstreamReady }); } if (path === '/api/tools' && req.method === 'GET') { if (!requireUpstream(res)) return; return sendJson(res, 200, await client.listTools()); } if (path === '/api/call' && req.method === 'POST') { if (!requireUpstream(res)) return; const body = await readBody(req); if (!body || typeof body.name !== 'string') { return sendJson(res, 400, { error: 'body must be { "name": string, "arguments"?: object }' }); } const result = await client.callTool({ name: body.name, arguments: body.arguments || {} }); return sendJson(res, 200, result); } sendJson(res, 404, { error: 'not found', routes: ['/', '/docs', '/docs.json', '/mcp', '/api/health', '/api/tools', '/api/call'] }); } catch (e) { console.error('[error]', e); if (!res.headersSent) sendJson(res, 500, { error: String(e?.message || e) }); else res.end(); } }); server.listen(PORT, HOST, () => { console.log(`browser-mcp gateway listening on http://${HOST}:${PORT} (routes: /mcp, /api/*)`); }); connectUpstream().catch((e) => { console.error('[upstream] initial connect failed, retrying in 3s:', e.message); setTimeout(() => connectUpstream().catch(console.error), 3000); });