203 lines
7.5 KiB
JavaScript
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);
|
|
});
|