Files
browser-gateway/gateway.js
T

203 lines
7.5 KiB
JavaScript

// LAN gateway for the local browser-mcp stdio server.
// Endpoints (all require "Authorization: Bearer <token>"):
// 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": "<tool>", "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);
});