- 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 章节)
177 lines
7.4 KiB
JavaScript
177 lines
7.4 KiB
JavaScript
// 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); |