feat: 设置页 MCP 管理卡片(方案B)— DB 真开关 + 即时生效 + env 仅首次种子

- app_settings 表(key/value):MCP 开关与令牌存 DB,不属于任何用户、不参与同步协议
- 配置读取/写入收敛到 core/mcp.js(readMcpConfig/saveMcpToken/setMcpEnabled)
- 状态语义:未配置→404(端点视为不存在);已配置关闭→503(可重新开启);令牌错→401
- env MCP_AUTH_TOKEN 仅首次种子(库里从没有过令牌时迁入 DB),不设也行;后台改动即时生效无需重启
- 管理 API(登录态):GET /api/mcp/status(掩码)、POST /api/mcp/token(生成/重置,明文仅一次)、GET /api/mcp/token(复制用)、POST /api/mcp/enabled
- 设置页新增「AI 接入 (MCP)」卡片:开关 toggle、掩码展示、一键生成并复制(writeClipboard 兜底)、重置令牌;未登录徽章「本设备请先登录」
- 入口接线:node-server.js 与 worker.js 均挂 /mcp(seedToken 透传);sw.js 放行裸 /mcp(v2→v3)
- 两个适配器补 getSetting/setSetting(node:sqlite 与 D1 同语义)
- 测试:server/test/mcp.test.js 39 项(未配置关闭/鉴权/JSON-RPC 全链路/开关热切换/令牌轮换);npm test 全绿(107+72+39)
- 线上 E2E:生产令牌 initialize/tools 7 个/2026-09 大盘(出勤11天/82h/挣得1599);公网 HTTPS 链路通;UI puppeteer 17/17(含关→503→开→200 热切换,全程无重启)
- 文档:README + server/README(环境变量表/接口表/数据模型/MCP 章节)
This commit is contained in:
OpenClaw Agent
2026-09-13 10:24:33 +08:00
parent 9702d1949a
commit 26306eee52
14 changed files with 1006 additions and 12 deletions
+177
View File
@@ -0,0 +1,177 @@
// MCP 端点与管理 API 自测:同样直调 core,不起 HTTP 端口,跑得快也不占端口。
// 覆盖:未配置关闭、从零生成 Token、鉴权、JSON-RPC 全链路、DB 真开关即时生效、Token 轮换。
//
// node --disable-warning=ExperimentalWarning test/mcp.test.js
import { handleMcp } from '../core/mcp.js';
import { handleApi } from '../core/api.js';
import { SqliteStore } from '../adapters/sqlite-node.js';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
let pass = 0, fail = 0;
const tmp = mkdtempSync(join(tmpdir(), 'wht-mcp-test-'));
const store = new SqliteStore(join(tmp, 'test.db'));
function section(t) { console.log(`\n── ${t}`); }
function ok(name, cond, extra = '') {
if (cond) { pass++; console.log(` ✅ ${name}`); }
else { fail++; console.log(` ❌ ${name}${extra ? ' → ' + extra : ''}`); }
}
function eq(name, actual, expected) {
const a = JSON.stringify(actual), e = JSON.stringify(expected);
ok(name, a === e, `实得 ${a},应为 ${e}`);
}
// MCP:直接构造 JSON-RPC POST 请求
async function rpc(msg, token) {
const headers = { 'content-type': 'application/json' };
if (token) headers.authorization = `Bearer ${token}`;
const res = await handleMcp(new Request('http://t/mcp', {
method: 'POST', headers, body: JSON.stringify(msg)
}), store, {});
const text = await res.text();
let json = null;
try { json = JSON.parse(text); } catch {}
return { status: res.status, body: json, text };
}
// 管理 API:与 api.test.js 同一套调用模式
let TOKEN = null;
async function call(method, path, body, token = TOKEN) {
const headers = { 'content-type': 'application/json' };
if (token) headers.authorization = `Bearer ${token}`;
const res = await handleApi(new Request('http://t' + path, {
method, headers, body: body === undefined ? undefined : JSON.stringify(body)
}), store, {});
let json = null;
try { json = await res.json(); } catch {}
return { status: res.status, body: json };
}
const INIT = { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18' } };
// ==========================================================
section('未配置时:整个端点关闭');
{
let r = await rpc(INIT);
eq('无 Token → 404', r.status, 404);
ok('404 说明未配置', /未配置/.test(r.text), r.text);
r = await handleMcp(new Request('http://t/mcp', { method: 'GET' }), store, {});
eq('GET 无 Token 同样 404', r.status, 404);
r = await call('GET', '/api/mcp/status');
eq('管理 API 未登录 401', r.status, 401);
}
// ==========================================================
section('管理 API:从零生成 Token(注册 + 登录)');
{
let r = await call('POST', '/api/register', { username: 'kory', password: 'kory-pass-123' });
eq('注册首个用户 201', r.status, 201);
r = await call('POST', '/api/login', { username: 'kory', password: 'kory-pass-123' });
eq('登录 200', r.status, 200);
TOKEN = r.body.token;
r = await call('POST', '/api/mcp/token');
eq('零配置生成 Token 200', r.status, 200);
ok('返回明文 Token', typeof r.body.token === 'string' && r.body.token.length >= 32);
ok('自动启用', r.body.enabled === true);
globalThis.__T1 = r.body.token;
r = await call('GET', '/api/mcp/status');
eq('状态 200', r.status, 200);
ok('configured=true', r.body.configured === true);
ok('enabled=true', r.body.enabled === true);
ok('Token 掩码化', typeof r.body.masked === 'string' && r.body.masked.includes('…'), JSON.stringify(r.body.masked));
ok('掩码不是明文', r.body.masked !== globalThis.__T1);
r = await call('GET', '/api/mcp/token');
eq('取回明文一致', r.body && r.body.token, globalThis.__T1);
r = await call('POST', '/api/mcp/enabled', { enabled: 'yes' });
eq('非布尔 enabled 400', r.status, 400);
}
// ==========================================================
section('MCP 端点:鉴权与 JSON-RPC 全链路');
{
let r = await rpc(INIT, 'wrong-token');
eq('错 token 401', r.status, 401);
r = await rpc(INIT, globalThis.__T1);
eq('initialize 200', r.status, 200);
eq('协议版本原样回', r.body && r.body.result && r.body.result.protocolVersion, '2025-06-18');
const res = await handleMcp(new Request('http://t/mcp', {
method: 'GET', headers: { authorization: `Bearer ${globalThis.__T1}` }
}), store, {});
eq('GET → 405(只收 POST)', res.status, 405);
r = await rpc({ jsonrpc: '2.0', method: 'notifications/initialized' }, globalThis.__T1);
eq('通知 → 202 空响应', r.status, 202);
r = await rpc({ jsonrpc: '2.0', id: 2, method: 'tools/list' }, globalThis.__T1);
ok('工具共 7 个', r.body && r.body.result && r.body.result.tools.length === 7,
r.body && r.body.result ? String(r.body.result.tools.length) : 'no result');
ok('含 get_month_summary / simulate_leave',
r.body.result.tools.some(t => t.name === 'get_month_summary') &&
r.body.result.tools.some(t => t.name === 'simulate_leave'));
r = await rpc({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'get_month_summary', arguments: { year: 2026, month: 9 } } }, globalThis.__T1);
eq('tools/call 200', r.status, 200);
ok('返回 text content', r.body.result && r.body.result.content && r.body.result.content[0].type === 'text');
ok('structuredContent period 正确', r.body.result.structuredContent.period === '2026-09');
r = await rpc({ jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'nope' } }, globalThis.__T1);
eq('未知工具 -32602', r.body && r.body.error && r.body.error.code, -32602);
r = await rpc({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'get_month_summary', arguments: { year: 2026, month: 13 } } }, globalThis.__T1);
eq('month 越界 -32602', r.body && r.body.error && r.body.error.code, -32602);
r = await rpc({ jsonrpc: '2.0', id: 6, method: 'bogus/method' }, globalThis.__T1);
eq('未知方法 -32601', r.body && r.body.error && r.body.error.code, -32601);
}
// ==========================================================
section('DB 真开关:即时生效无需重启');
{
let r = await call('POST', '/api/mcp/enabled', { enabled: false });
eq('关闭 200', r.status, 200);
ok('返回 false', r.body.enabled === false);
r = await rpc(INIT, globalThis.__T1);
eq('关闭后 → 503', r.status, 503);
ok('503 说明可重新启用', /重新开启|重新启用/.test(r.text), r.text);
r = await call('POST', '/api/mcp/enabled', { enabled: true });
eq('重新启用 200', r.status, 200);
r = await rpc(INIT, globalThis.__T1);
eq('恢复 200', r.status, 200);
}
// ==========================================================
section('Token 轮换:旧的立即失效');
{
let r = await call('POST', '/api/mcp/token');
ok('新 Token 与旧不同', r.body.token && r.body.token !== globalThis.__T1);
globalThis.__T2 = r.body.token;
r = await rpc(INIT, globalThis.__T1);
eq('旧 token 401', r.status, 401);
r = await rpc(INIT, globalThis.__T2);
eq('新 token 200', r.status, 200);
r = await call('GET', '/api/mcp/token');
eq('取回明文是新 Token', r.body.token, globalThis.__T2);
}
// ==========================================================
store.close();
rmSync(tmp, { recursive: true, force: true });
console.log(`\n${'='.repeat(46)}`);
console.log(`通过 ${pass} 项,失败 ${fail} 项`);
console.log('='.repeat(46));
process.exit(fail === 0 ? 0 : 1);