Files
OpenClaw Agent 53733b39fc feat: 支持多用户与用户级MCP隔离,优化汇总诊断与UI展示
- 多用户支持:新增 user_mcp_tokens 表,实现按用户 Token 哈希隔离 MCP 查询
- 注册控制:支持 ALLOW_REGISTER 环境变量自适应开关多用户注册
- 前端同步:增加跨账号防脏提交安全拦截,优化登录/注册状态提示
- 汇总页优化:移除合计应得卡片冗余说明,诊断卡片新增「综合时薪诊断」
- 文档更新:同步更新 README.md 关于多用户、MCP 与部署说明
2026-09-14 14:00:07 +08:00

222 lines
9.2 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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, opts = { allowRegister: true }) {
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, opts);
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);
}
// ==========================================================
section('多用户 MCP 隔离:不同 Token 独立绑定不同用户');
{
// 注册第二个用户 user2
let r = await call('POST', '/api/register', { username: 'alice', password: 'alice-pass-123' }, null, { allowRegister: true });
eq('第二个用户注册 201', r.status, 201);
r = await call('POST', '/api/login', { username: 'alice', password: 'alice-pass-123' }, null);
eq('第二个用户登录 200', r.status, 200);
const user2Token = r.body.token;
// 用户2 推送自己的打卡数据(9/10 10小时)
await call('POST', '/api/push', {
records: {
'2026-09-10': { status: 'work', shift_type: 'day', hours: 10 }
}
}, user2Token);
// 用户1 推送自己的打卡数据(9/10 6小时)
await call('POST', '/api/push', {
records: {
'2026-09-10': { status: 'work', shift_type: 'day', hours: 6 }
}
}, TOKEN);
// 用户2 生成自己的 MCP Token
r = await call('POST', '/api/mcp/token', undefined, user2Token);
eq('用户2 生成 MCP Token 200', r.status, 200);
const user2McpToken = r.body.token;
ok('用户2 的 MCP Token 与用户1 不同', user2McpToken !== globalThis.__T2);
// 用 用户1 的 MCP Token 查 9月总结
const CALL_SUMMARY = {
jsonrpc: '2.0', id: 2, method: 'tools/call',
params: { name: 'get_month_summary', arguments: { year: 2026, month: 9 } }
};
const r1 = await rpc(CALL_SUMMARY, globalThis.__T2);
eq('用户1 MCP 调用成功', r1.status, 200);
eq('用户1 工时是 6h', r1.body.result.structuredContent.hours.total, 6);
// 用 用户2 的 MCP Token 查 9月总结
const r2 = await rpc(CALL_SUMMARY, user2McpToken);
eq('用户2 MCP 调用支持成功', r2.status, 200);
eq('用户2 工时是 10h', r2.body.result.structuredContent.hours.total, 10);
}
// ==========================================================
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);