Initial commit: LAN gateway for browser-mcp with /mcp, /api, /docs endpoints
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
token.txt
|
||||
@@ -0,0 +1,96 @@
|
||||
# browser-mcp-gateway
|
||||
|
||||
把本机的 [browser-mcp](https://github.com/Agent360dk/browser-mcp)(Chrome 扩展 + 本地 stdio MCP server)桥接到局域网,让其他主机远程驱动这台机器上的 Chrome 浏览器,用于网页爬取、自动化操作等场景。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
本机 MCP client ──stdio──┐
|
||||
├─→ 本机 WS 中枢(127.0.0.1:9876) ─→ Chrome 扩展 ─→ Chrome
|
||||
局域网主机 ──HTTP──→ 本网关 ──stdio──┘ (gateway 上游 spawn browser-mcp)
|
||||
```
|
||||
|
||||
网关内部 spawn 一个 browser-mcp stdio 进程作为上游(自动重连),对外在同一端口暴露两类接口,所有接口均需 Bearer 认证(也支持 `?token=` 查询参数)。
|
||||
|
||||
## 接口
|
||||
|
||||
| 路径 | 说明 |
|
||||
|---|---|
|
||||
| `GET /` | HTML 使用说明页(含示例地址设置栏:默认当前访问 host:port,可自定义/重置) |
|
||||
| `GET /docs` | HTML 格式 API 文档 |
|
||||
| `GET /docs.json` | JSON 格式 API 文档(浏览器访问自动渲染为高亮 HTML,curl 获取原始 JSON) |
|
||||
| `POST /mcp` | MCP Streamable HTTP 端点(无状态,仅 POST),供 MCP client 接入 |
|
||||
| `GET /api/health` | 存活与上游状态 |
|
||||
| `GET /api/tools` | 列出全部浏览器工具 |
|
||||
| `POST /api/call` | 调用工具,body:`{"name": "工具名", "arguments": {...}}` |
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
npm install
|
||||
echo "你的token" > token.txt # 或使用环境变量 GATEWAY_TOKEN
|
||||
npm start # 或双击 start-gateway.bat
|
||||
```
|
||||
|
||||
默认监听 `0.0.0.0:9109`。
|
||||
|
||||
### 配置项(环境变量)
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `PORT` | `9109` | 监听端口 |
|
||||
| `HOST` | `0.0.0.0` | 监听地址 |
|
||||
| `GATEWAY_TOKEN` | 读取 `token.txt` | Bearer 认证 token |
|
||||
| `BROWSER_MCP_CLI` | `%APPDATA%\npm\node_modules\@agent360\browser-mcp\bin\cli.js` | 上游 browser-mcp 入口 |
|
||||
|
||||
## 使用
|
||||
|
||||
### REST API(curl / 脚本)
|
||||
|
||||
```bash
|
||||
TOKEN=你的token
|
||||
curl -s http://10.1.1.6:9109/api/call \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"browser_navigate","arguments":{"url":"https://example.com"}}'
|
||||
```
|
||||
|
||||
### MCP client(Kimi Code)
|
||||
|
||||
`~/.kimi-code/mcp.json` 的 `mcpServers` 中加入(保存后新建会话生效):
|
||||
|
||||
```json
|
||||
"browser-remote": {
|
||||
"url": "http://10.1.1.6:9109/mcp",
|
||||
"headers": { "Authorization": "Bearer 你的token" }
|
||||
}
|
||||
```
|
||||
|
||||
### Claude Code
|
||||
|
||||
```bash
|
||||
claude mcp add --transport http browser-remote http://10.1.1.6:9109/mcp \
|
||||
--header "Authorization: Bearer 你的token"
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **浏览器在网关所在机器上**:远程调用驱动的是本机 Chrome(Cookie、登录态共享),需保持 Chrome 的 browser-mcp 扩展处于连接状态。
|
||||
- **会话隔离**:每个 MCP client 获得独立的浏览器会话(各自开标签页),互不干扰;但同一标签页不要两边同时操作。
|
||||
- **防火墙**:Windows 需放行端口,建议限制来源网段(管理员 PowerShell):
|
||||
```powershell
|
||||
New-NetFirewallRule -DisplayName "browser-mcp-gateway" -Direction Inbound -Protocol TCP -LocalPort 9109 -RemoteAddress 10.1.1.0/24 -Action Allow
|
||||
```
|
||||
- **截图路径限制**:`browser_screenshot` 的 `path` 参数只能写在网关进程工作目录内(browser-mcp 的安全限制),远程调用请传相对路径。
|
||||
- **Token 即全部权限**:请勿泄露到不可信网络;`token.txt` 已加入 `.gitignore`。
|
||||
- 持久化运行可挂到 Windows 任务计划程序(开机执行 `start-gateway.bat`)。
|
||||
|
||||
## 文件
|
||||
|
||||
```
|
||||
gateway.js # 网关主程序:HTTP 服务 + 认证 + stdio 上游管理 + MCP 代理
|
||||
pages.js # /、/docs、/docs.json 三个页面的 HTML/JSON 渲染
|
||||
package.json # 仅依赖 @modelcontextprotocol/sdk
|
||||
token.txt # Bearer token(gitignore)
|
||||
start-gateway.bat # Windows 启动脚本
|
||||
```
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
// 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);
|
||||
});
|
||||
Generated
+1202
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "browser-mcp-gateway",
|
||||
"version": "1.0.0",
|
||||
"description": "LAN gateway for local browser-mcp: exposes /mcp (MCP Streamable HTTP) and /api (REST) with Bearer auth",
|
||||
"type": "module",
|
||||
"main": "gateway.js",
|
||||
"scripts": {
|
||||
"start": "node gateway.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.30.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
// HTML home page and JSON API docs for the browser-mcp gateway.
|
||||
// All examples are built from the visitor's Host header so IP/port always match.
|
||||
|
||||
export function renderHome(baseUrl, token) {
|
||||
// B is a placeholder element filled by client-side JS with the effective base
|
||||
// URL (visitor's host by default, overridable in the settings bar).
|
||||
const B = '<b class="bp"></b>';
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>browser-mcp gateway</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; width: 96%; max-width: 1400px; margin: 1.5em auto; padding: 0 1em; color: #24292f; line-height: 1.6; box-sizing: border-box; }
|
||||
h1 { border-bottom: 2px solid #d0d7de; padding-bottom: .3em; }
|
||||
h2 { margin-top: 1.8em; color: #0969da; }
|
||||
pre { background: #f6f8fa; border: 1px solid #d0d7de; border-radius: 6px; padding: 12px; overflow-x: auto; font-size: 13px; }
|
||||
code { background: #f6f8fa; padding: 2px 5px; border-radius: 4px; font-size: 13px; }
|
||||
pre code { padding: 0; background: none; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th, td { border: 1px solid #d0d7de; padding: 6px 10px; text-align: left; font-size: 14px; }
|
||||
th { background: #f6f8fa; }
|
||||
.warn { background: #fff8c5; border: 1px solid #d4a72c; border-radius: 6px; padding: 10px 14px; }
|
||||
.bp { font-weight: inherit; }
|
||||
.settings { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; background: #ddf4e4; border: 1px solid #1a7f37; border-radius: 8px; padding: 12px 16px; margin: 1em 0; }
|
||||
.settings label { font-weight: 600; }
|
||||
.settings input { flex: 1; min-width: 240px; padding: 6px 10px; border: 1px solid #d0d7de; border-radius: 6px; font-family: ui-monospace, Consolas, monospace; font-size: 13px; }
|
||||
.settings input:focus { outline: 2px solid #0969da; outline-offset: -1px; }
|
||||
.settings button { padding: 6px 16px; border-radius: 6px; border: 1px solid #1a7f37; background: #1a7f37; color: #fff; cursor: pointer; font-size: 13px; }
|
||||
.settings button:hover { background: #15652e; }
|
||||
.settings button.ghost { background: #fff; color: #24292f; border-color: #d0d7de; }
|
||||
.settings button.ghost:hover { background: #f6f8fa; }
|
||||
.settings .msg { color: #1a7f37; font-size: 13px; min-height: 1em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>browser-mcp gateway</h1>
|
||||
|
||||
<div class="settings">
|
||||
<label for="hostInput">示例地址</label>
|
||||
<input id="hostInput" type="text" spellcheck="false" placeholder="host:port,例如 10.1.1.6:9109">
|
||||
<button id="applyBtn">应用</button>
|
||||
<button id="resetBtn" class="ghost">重置</button>
|
||||
<span class="msg" id="barMsg"></span>
|
||||
</div>
|
||||
|
||||
<p>本网关把本机 Chrome 浏览器(通过 browser-mcp 扩展)暴露到局域网。页面所有示例基于 <code>${B}</code> 生成(默认为你当前的访问地址,可在上方设置栏修改)。</p>
|
||||
|
||||
<h2>1. 认证</h2>
|
||||
<p>所有接口都需要认证,二选一:</p>
|
||||
<ul>
|
||||
<li>HTTP 头:<code>Authorization: Bearer ${token}</code></li>
|
||||
<li>URL 参数:<code>?token=${token}</code>(方便浏览器直接打开,例如 <a href="/docs?token=${token}">API 文档</a>)</li>
|
||||
</ul>
|
||||
|
||||
<h2>2. 查看 API 文档</h2>
|
||||
<p>HTML 文档:<a href="/docs?token=${token}"><code>${B}/docs</code></a> | JSON 版本:<a href="/docs.json?token=${token}"><code>${B}/docs.json</code></a></p>
|
||||
|
||||
<h2>3. REST API 使用(/api)</h2>
|
||||
<table>
|
||||
<tr><th>方法</th><th>路径</th><th>说明</th></tr>
|
||||
<tr><td>GET</td><td>/api/health</td><td>存活与上游状态</td></tr>
|
||||
<tr><td>GET</td><td>/api/tools</td><td>列出全部浏览器工具(40 个)</td></tr>
|
||||
<tr><td>POST</td><td>/api/call</td><td>调用工具,body 为 <code>{"name": "工具名", "arguments": {...}}</code></td></tr>
|
||||
</table>
|
||||
<p>示例:打开网页并读取内容</p>
|
||||
<pre><code>curl -s -X POST ${B}/api/call \\
|
||||
-H "Authorization: Bearer ${token}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"name":"browser_navigate","arguments":{"url":"https://example.com"}}'
|
||||
|
||||
curl -s -X POST ${B}/api/call \\
|
||||
-H "Authorization: Bearer ${token}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"name":"browser_get_page_content","arguments":{"format":"text"}}'</code></pre>
|
||||
<p>返回格式:MCP 标准结果 <code>{"content":[{"type":"text","text":"..."}]}</code>,错误时 HTTP 状态码非 200 且 body 为 <code>{"error":"..."}</code>。</p>
|
||||
|
||||
<h2>4. 添加 MCP(/mcp 端点)</h2>
|
||||
<p>MCP 端点地址:<code>${B}/mcp</code>(Streamable HTTP 传输,无状态模式)。</p>
|
||||
|
||||
<h3>Kimi Code</h3>
|
||||
<p>编辑 <code>~/.kimi-code/mcp.json</code>,在 <code>mcpServers</code> 中加入:</p>
|
||||
<pre><code>"browser-remote": {
|
||||
"url": "${B}/mcp",
|
||||
"headers": { "Authorization": "Bearer ${token}" }
|
||||
}</code></pre>
|
||||
<p>保存后<strong>新建会话</strong>生效(已打开的会话不会加载新增的 server)。也可以在 TUI 里用 <code>/mcp-config</code> 交互式添加。</p>
|
||||
|
||||
<h3>Claude Code</h3>
|
||||
<pre><code>claude mcp add --transport http browser-remote ${B}/mcp \\
|
||||
--header "Authorization: Bearer ${token}"</code></pre>
|
||||
|
||||
<h2>5. 一句话部署</h2>
|
||||
<p>Linux / macOS(写入 Kimi Code 用户级配置):</p>
|
||||
<pre><code>node -e "const f=process.env.HOME+'/.kimi-code/mcp.json',j=require(f);j.mcpServers['browser-remote']={url:'${B}/mcp',headers:{Authorization:'Bearer ${token}'}};require('fs').writeFileSync(f,JSON.stringify(j,null,2))"</code></pre>
|
||||
<p>Windows PowerShell:</p>
|
||||
<pre><code>$f="$env:USERPROFILE\\.kimi-code\\mcp.json"; $j=Get-Content $f -Raw | ConvertFrom-Json; $j.mcpServers | Add-Member -NotePropertyName browser-remote -NotePropertyValue @{url='${B}/mcp';headers=@{Authorization='Bearer ${token}'}} -Force; $j | ConvertTo-Json -Depth 10 | Set-Content $f -Encoding UTF8</code></pre>
|
||||
|
||||
<div class="warn">
|
||||
<strong>注意:</strong>远程调用驱动的是网关所在机器的 Chrome,请保持该机器的 browser-mcp 扩展处于连接状态。Token 即全部权限,请勿泄露到不可信网络。
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var KEY = 'gw-custom-host';
|
||||
var input = document.getElementById('hostInput');
|
||||
var msg = document.getElementById('barMsg');
|
||||
function curHost() { return localStorage.getItem(KEY) || location.host; }
|
||||
function render() {
|
||||
var base = 'http://' + curHost();
|
||||
var els = document.querySelectorAll('.bp');
|
||||
for (var i = 0; i < els.length; i++) els[i].textContent = base;
|
||||
}
|
||||
function flash(text) {
|
||||
msg.textContent = text;
|
||||
setTimeout(function () { msg.textContent = ''; }, 3000);
|
||||
}
|
||||
document.getElementById('applyBtn').onclick = function () {
|
||||
var v = input.value.trim().replace(/^https?:\\/\\//, '').replace(/\\/+$/, '');
|
||||
if (!v) { flash('请输入 host:port'); return; }
|
||||
localStorage.setItem(KEY, v);
|
||||
input.value = v;
|
||||
render();
|
||||
flash('已应用:' + v);
|
||||
};
|
||||
document.getElementById('resetBtn').onclick = function () {
|
||||
localStorage.removeItem(KEY);
|
||||
input.value = location.host;
|
||||
render();
|
||||
flash('已重置为当前访问地址');
|
||||
};
|
||||
input.value = curHost();
|
||||
render();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export function renderDocs(baseUrl, token) {
|
||||
const auth = { type: 'bearer', header: 'Authorization: Bearer <token>', alt: 'query param ?token=<token>' };
|
||||
return {
|
||||
name: 'browser-mcp-gateway',
|
||||
version: '1.0.0',
|
||||
description: 'LAN gateway exposing the local browser-mcp (Agent360) browser over MCP Streamable HTTP and a simple REST API.',
|
||||
baseUrl,
|
||||
auth,
|
||||
endpoints: [
|
||||
{
|
||||
method: 'GET', path: '/',
|
||||
description: 'HTML 使用说明页(认证、REST API 用法、MCP 接入、一句话部署)。',
|
||||
response: 'text/html',
|
||||
},
|
||||
{
|
||||
method: 'GET', path: '/docs',
|
||||
description: 'HTML 格式的 API 文档(本页)。',
|
||||
response: 'text/html',
|
||||
},
|
||||
{
|
||||
method: 'GET', path: '/docs.json',
|
||||
description: 'JSON 格式的 API 文档(机器可读)。',
|
||||
response: 'application/json',
|
||||
},
|
||||
{
|
||||
method: 'GET', path: '/api/health',
|
||||
description: 'Liveness probe; reports whether the upstream browser-mcp stdio server is connected.',
|
||||
response: { ok: true, upstream: true },
|
||||
example: `curl -s ${baseUrl}/api/health -H "Authorization: Bearer ${token}"`,
|
||||
},
|
||||
{
|
||||
method: 'GET', path: '/api/tools',
|
||||
description: 'List all available browser tools (MCP tools/list).',
|
||||
response: { tools: [{ name: 'browser_navigate', description: '...', inputSchema: {} }] },
|
||||
example: `curl -s ${baseUrl}/api/tools -H "Authorization: Bearer ${token}"`,
|
||||
},
|
||||
{
|
||||
method: 'POST', path: '/api/call',
|
||||
description: 'Call a browser tool (MCP tools/call). Body: {"name": string, "arguments"?: object}.',
|
||||
request: { name: 'browser_navigate', arguments: { url: 'https://example.com' } },
|
||||
response: { content: [{ type: 'text', text: '...' }] },
|
||||
example: `curl -s -X POST ${baseUrl}/api/call -H "Authorization: Bearer ${token}" -H "Content-Type: application/json" -d '{"name":"browser_list_tabs","arguments":{}}'`,
|
||||
},
|
||||
{
|
||||
method: 'POST', path: '/mcp',
|
||||
description: 'MCP Streamable HTTP endpoint (stateless; POST only). Point any MCP client here.',
|
||||
notes: 'Accept header must include application/json and text/event-stream per the MCP spec.',
|
||||
},
|
||||
],
|
||||
mcpClients: {
|
||||
'kimi-code': {
|
||||
file: '~/.kimi-code/mcp.json',
|
||||
config: { 'browser-remote': { url: `${baseUrl}/mcp`, headers: { Authorization: `Bearer ${token}` } } },
|
||||
note: 'Takes effect in new sessions; or use /mcp-config in the TUI.',
|
||||
},
|
||||
'claude-code': {
|
||||
oneLiner: `claude mcp add --transport http browser-remote ${baseUrl}/mcp --header "Authorization: Bearer ${token}"`,
|
||||
},
|
||||
},
|
||||
oneLinerDeploy: {
|
||||
posix: `node -e "const f=process.env.HOME+'/.kimi-code/mcp.json',j=require(f);j.mcpServers['browser-remote']={url:'${baseUrl}/mcp',headers:{Authorization:'Bearer ${token}'}};require('fs').writeFileSync(f,JSON.stringify(j,null,2))"`,
|
||||
powershell: `$f="$env:USERPROFILE\\.kimi-code\\mcp.json"; $j=Get-Content $f -Raw | ConvertFrom-Json; $j.mcpServers | Add-Member -NotePropertyName browser-remote -NotePropertyValue @{url='${baseUrl}/mcp';headers=@{Authorization='Bearer ${token}'}} -Force; $j | ConvertTo-Json -Depth 10 | Set-Content $f -Encoding UTF8`,
|
||||
},
|
||||
errors: {
|
||||
401: 'missing or wrong token',
|
||||
400: 'bad request body',
|
||||
404: 'unknown route',
|
||||
503: 'upstream browser-mcp not connected (is the Chrome extension connected?)',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
export function renderDocsHtml(baseUrl, token) {
|
||||
const d = renderDocs(baseUrl, token);
|
||||
const pre = (o) => `<pre><code>${esc(typeof o === 'string' ? o : JSON.stringify(o, null, 2))}</code></pre>`;
|
||||
const endpointRows = d.endpoints.map((e) =>
|
||||
`<tr><td><span class="m m-${e.method.toLowerCase()}">${e.method}</span></td><td><code>${e.path}</code></td><td>${esc(e.description)}</td></tr>`,
|
||||
).join('\n');
|
||||
const endpointDetails = d.endpoints.map((e) => `
|
||||
<section class="card">
|
||||
<h3><span class="m m-${e.method.toLowerCase()}">${e.method}</span> <code>${e.path}</code></h3>
|
||||
<p>${esc(e.description)}</p>
|
||||
${e.notes ? `<p class="note">${esc(e.notes)}</p>` : ''}
|
||||
${e.request ? `<h4>请求 body</h4>${pre(e.request)}` : ''}
|
||||
${e.response ? `<h4>响应${typeof e.response === 'string' ? '(' + esc(e.response) + ')' : ' 示例'}</h4>${pre(e.response)}` : ''}
|
||||
${e.example ? `<h4>示例</h4>${pre(e.example)}` : ''}
|
||||
</section>`).join('\n');
|
||||
const errorRows = Object.entries(d.errors).map(([code, msg]) =>
|
||||
`<tr><td><code>${code}</code></td><td>${esc(msg)}</td></tr>`,
|
||||
).join('\n');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>API 文档 - browser-mcp gateway</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; width: 96%; max-width: 1400px; margin: 1.5em auto; padding: 0 1em; color: #24292f; line-height: 1.6; box-sizing: border-box; }
|
||||
h1 { border-bottom: 2px solid #d0d7de; padding-bottom: .3em; }
|
||||
h2 { margin-top: 1.8em; color: #0969da; }
|
||||
h3 { margin-bottom: .3em; }
|
||||
h4 { margin: .8em 0 .3em; color: #57606a; font-size: 14px; }
|
||||
pre { background: #f6f8fa; border: 1px solid #d0d7de; border-radius: 6px; padding: 12px; overflow-x: auto; font-size: 13px; }
|
||||
code { background: #f6f8fa; padding: 2px 5px; border-radius: 4px; font-size: 13px; }
|
||||
pre code { padding: 0; background: none; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th, td { border: 1px solid #d0d7de; padding: 6px 10px; text-align: left; font-size: 14px; vertical-align: top; }
|
||||
th { background: #f6f8fa; }
|
||||
.card { border: 1px solid #d0d7de; border-radius: 8px; padding: 4px 18px 14px; margin: 14px 0; }
|
||||
.m { display: inline-block; min-width: 3em; text-align: center; font-weight: 700; font-size: 12px; border-radius: 4px; padding: 2px 8px; color: #fff; }
|
||||
.m-get { background: #1a7f37; }
|
||||
.m-post { background: #0969da; }
|
||||
.note { color: #57606a; font-size: 13px; }
|
||||
.meta { color: #57606a; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>${esc(d.name)} <span class="meta">v${esc(d.version)}</span></h1>
|
||||
<p>${esc(d.description)}</p>
|
||||
<p>Base URL:<code>${esc(d.baseUrl)}</code> | <a href="${esc(d.baseUrl)}/?token=${esc(token)}">返回使用说明</a> | <a href="${esc(d.baseUrl)}/docs.json?token=${esc(token)}">JSON 版本</a></p>
|
||||
|
||||
<h2>认证</h2>
|
||||
<p>HTTP 头:<code>${esc(d.auth.header)}</code>,或 URL 参数:<code>${esc(d.auth.alt)}</code></p>
|
||||
|
||||
<h2>端点总览</h2>
|
||||
<table>
|
||||
<tr><th>方法</th><th>路径</th><th>说明</th></tr>
|
||||
${endpointRows}
|
||||
</table>
|
||||
|
||||
<h2>端点详情</h2>
|
||||
${endpointDetails}
|
||||
|
||||
<h2>MCP 客户端接入</h2>
|
||||
<h3>Kimi Code(${esc(d.mcpClients['kimi-code'].file)})</h3>
|
||||
${pre(JSON.stringify({ mcpServers: d.mcpClients['kimi-code'].config }, null, 2))}
|
||||
<p class="note">${esc(d.mcpClients['kimi-code'].note)}</p>
|
||||
<h3>Claude Code(一句话)</h3>
|
||||
${pre(d.mcpClients['claude-code'].oneLiner)}
|
||||
|
||||
<h2>一句话部署(写入 Kimi Code 配置)</h2>
|
||||
<h3>Linux / macOS</h3>
|
||||
${pre(d.oneLinerDeploy.posix)}
|
||||
<h3>Windows PowerShell</h3>
|
||||
${pre(d.oneLinerDeploy.powershell)}
|
||||
|
||||
<h2>错误码</h2>
|
||||
<table>
|
||||
<tr><th>状态码</th><th>含义</th></tr>
|
||||
${errorRows}
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// Server-side JSON syntax highlighting for the /docs.json browser view.
|
||||
function highlightJson(obj) {
|
||||
const json = JSON.stringify(obj, null, 2);
|
||||
const re = /("(?:\\.|[^"\\])*")(\s*:)?|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\b|(true|false|null)\b/g;
|
||||
let out = '';
|
||||
let last = 0;
|
||||
let m;
|
||||
while ((m = re.exec(json))) {
|
||||
out += esc(json.slice(last, m.index));
|
||||
if (m[1] !== undefined) {
|
||||
if (m[2]) out += `<span class="k">${esc(m[1])}</span>${esc(m[2])}`;
|
||||
else out += `<span class="s">${esc(m[1])}</span>`;
|
||||
} else if (m[3] !== undefined) {
|
||||
out += `<span class="n">${esc(m[3])}</span>`;
|
||||
} else {
|
||||
out += `<span class="b">${esc(m[4])}</span>`;
|
||||
}
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
return out + esc(json.slice(last));
|
||||
}
|
||||
|
||||
export function renderJsonViewer(baseUrl, token, doc) {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>docs.json - browser-mcp gateway</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; width: 96%; max-width: 1400px; margin: 1.5em auto; padding: 0 1em; color: #24292f; line-height: 1.6; box-sizing: border-box; }
|
||||
h1 { border-bottom: 2px solid #d0d7de; padding-bottom: .3em; font-size: 22px; }
|
||||
.meta { color: #57606a; font-size: 14px; }
|
||||
pre { background: #f6f8fa; border: 1px solid #d0d7de; border-radius: 6px; padding: 16px; overflow-x: auto; font-size: 13px; font-family: ui-monospace, Consolas, monospace; line-height: 1.5; }
|
||||
.k { color: #0550ae; }
|
||||
.s { color: #0a3069; }
|
||||
.n { color: #0550ae; }
|
||||
.b { color: #cf222e; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>docs.json <span class="meta">browser-mcp gateway API 文档(JSON)</span></h1>
|
||||
<p class="meta">浏览器访问自动格式化高亮显示;用 curl 或 <code>Accept: application/json</code> 请求可获取原始 JSON。
|
||||
| <a href="${esc(baseUrl)}/docs?token=${esc(token)}">返回 HTML 文档</a>
|
||||
| <a href="${esc(baseUrl)}/?token=${esc(token)}">使用说明</a></p>
|
||||
<pre>${highlightJson(doc)}</pre>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
cd /d "%~dp0"
|
||||
node gateway.js
|
||||
Reference in New Issue
Block a user