diff --git a/README.md b/README.md index c8e9e16..97e71bd 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,8 @@ 额度用尽后转纯后台轮询。技术日志仅输出到控制台(`[boot-guard]` 前缀)。 - `shared/payroll.js`:独立同构薪酬计算模块(包含时薪折算、截断规则、周期合并等 30+ 纯函数)。 - `sync.js`:本地优先增量同步适配层。 -- `server/`:轻量级 REST API 服务端(支持鉴权、版本同步、健康检查 `/api/health`)。 +- `server/`:轻量级 REST API 服务端(支持鉴权、版本同步、健康检查 `/api/health`), + 并内置只读 MCP 端点(`POST /mcp`):AI 客户端可查询出勤与薪酬,配置在页面「设置 → AI 接入 (MCP)」管理,开关/令牌即时生效。 - `test/` & `tools/`:基线测试(Parity Test)、断言验证与回归测试工具。 --- diff --git a/index.html b/index.html index e6144e0..d0fd844 100644 --- a/index.html +++ b/index.html @@ -809,6 +809,31 @@ + +
+
+

AI 接入 (MCP)

+ {{ mcpBadge.text }} +
+
+
+ 接入开关 + +
+
+ 访问令牌 + {{ mcp.masked }} +
+ + +
+

+ 端点 POST /mcp(Bearer 令牌鉴权),只读:AI 只能查询出勤、工时与薪酬,改不了任何数据。开关与令牌改动即时生效,无需重启;重置后旧客户端立即失效。 +

+
+

数据

@@ -1431,7 +1456,7 @@ createApp({ isDrawerOpen.value = true; }; - const openSettings = () => { settingsOpen.value = true; }; + const openSettings = () => { settingsOpen.value = true; loadMcpStatus(); }; const closeSettings = () => { settingsOpen.value = false; }; // ===== 奖惩终稿修正入口 ===== @@ -1933,6 +1958,97 @@ createApp({ window.WHTSync.health().catch(() => {}); }; + // ===== AI 接入 (MCP):状态/开关/令牌管理(走 /api/mcp/*,登录态鉴权)===== + // 开关与令牌存服务器 DB,改动即时生效;令牌明文只在生成/复制响应里出现,页面不留存。 + const mcp = reactive({ configured: false, enabled: false, masked: '', busy: false, loaded: false, authNeeded: false }); + const mcpCopied = ref(false); + const mcpBadge = computed(() => { + if (!mcp.loaded) return { text: '…', cls: 'bg-slate-100 text-slate-400' }; + if (mcp.authNeeded) return { text: '本设备请先登录', cls: 'bg-amber-50 text-amber-600' }; + if (!mcp.configured) return { text: '未配置', cls: 'bg-slate-100 text-slate-400' }; + return mcp.enabled + ? { text: '已开启', cls: 'bg-emerald-50 text-emerald-600' } + : { text: '已关闭', cls: 'bg-amber-50 text-amber-600' }; + }); + + // 复用同步引擎的 apiBase 与登录 token:页面能同步就能管 MCP,不另做一套鉴权 + const mcpHeaders = () => { + const h = { 'content-type': 'application/json' }; + const token = window.WHTSync && window.WHTSync.state.token; + if (token) h.authorization = 'Bearer ' + token; + return h; + }; + + const loadMcpStatus = async () => { + try { + const res = await fetch(window.WHTSync.getApiBase() + '/api/mcp/status', { headers: mcpHeaders() }); + const d = await res.json(); + mcp.authNeeded = res.status === 401 || res.status === 403; + if (d && d.ok) { + mcp.configured = !!d.configured; + mcp.enabled = !!d.enabled; + mcp.masked = d.masked || ''; + if (d.configured) mcp.authNeeded = false; + } + } catch (e) { /* 静态模式/离线:保持未知态,不干扰记账主功能 */ } + mcp.loaded = true; + }; + + const toggleMcp = async () => { + if (mcp.busy) return; + mcp.busy = true; + try { + const res = await fetch(window.WHTSync.getApiBase() + '/api/mcp/enabled', { + method: 'POST', headers: mcpHeaders(), body: JSON.stringify({ enabled: !mcp.enabled }) + }); + const d = await res.json(); + if (!res.ok || !d.ok) throw new Error(d.error || 'HTTP ' + res.status); + mcp.enabled = d.enabled; + showToast(d.enabled ? 'MCP 已开启,客户端即可接入' : 'MCP 已关闭,客户端接入立即失效'); + } catch (e) { + showToast('操作失败:' + (e.message || '未知错误')); + } finally { mcp.busy = false; } + }; + + // 生成/重置令牌:生成后自动复制明文(仅此一次,服务端不留明文出口以外的副本) + const newMcpToken = async () => { + if (mcp.busy) return; + if (mcp.configured && !confirm('重置令牌?正在使用的旧令牌会立即失效。')) return; + mcp.busy = true; + try { + const res = await fetch(window.WHTSync.getApiBase() + '/api/mcp/token', { method: 'POST', headers: mcpHeaders() }); + const d = await res.json(); + if (!res.ok || !d.ok) throw new Error(res.status === 401 ? '本设备请先登录' : (d.error || 'HTTP ' + res.status)); + mcp.configured = true; + mcp.enabled = true; + mcp.authNeeded = false; + writeClipboard(d.token, () => { + mcpCopied.value = true; + setTimeout(() => { mcpCopied.value = false; }, 2500); + }); + showToast('令牌已生成并复制(明文仅此一次)'); + await loadMcpStatus(); + } catch (e) { + showToast('生成失败:' + (e.message || '未知错误')); + } finally { mcp.busy = false; } + }; + + const copyMcpToken = async () => { + if (mcp.busy || !mcp.configured) return; + mcp.busy = true; + try { + const res = await fetch(window.WHTSync.getApiBase() + '/api/mcp/token', { headers: mcpHeaders() }); + const d = await res.json(); + if (!res.ok || !d.ok) throw new Error(d.error || 'HTTP ' + res.status); + writeClipboard(d.token, () => { + mcpCopied.value = true; + setTimeout(() => { mcpCopied.value = false; }, 2000); + }); + } catch (e) { + showToast('取回失败:' + (e.message || '未知错误')); + } finally { mcp.busy = false; } + }; + // ===== 载入与持久化 ===== onMounted(() => { const savedRecords = localStorage.getItem('work_records'); @@ -2002,6 +2118,7 @@ createApp({ sync, syncPanelOpen, loginForm, syncStatusText, syncStatusClass, syncTimeText, mergeOpen, mergeInfo, mergeRows, resolveMerge, deferMerge, reopenMerge, doLogin, doRegister, doLogout, doSyncNow, doPullAll, doPushAll, saveApiBase, + mcp, mcpCopied, mcpBadge, toggleMcp, copyMcpToken, newMcpToken, saveRecord, deleteRecord, copyReport, exportData, copyData, importData, resetSettings, clearMonth, clearAll }; diff --git a/server/README.md b/server/README.md index bac872f..5f93487 100644 --- a/server/README.md +++ b/server/README.md @@ -14,15 +14,16 @@ work-hours-tracker/ ├── core/ ← 平台无关,VPS 与 CF Workers 共用 │ ├── api.js HTTP 接口逻辑(收发标准 Request/Response) │ ├── auth.js PBKDF2 密码 + SHA-256 令牌(纯 Web Crypto) + │ ├── mcp.js 只读 MCP 端点(JSON-RPC 2.0)+ 配置管理 │ ├── store.js 存储契约 + 输入校验 │ └── schema.sql 建表语句(SQLite / D1 通用) ├── adapters/ │ ├── sqlite-node.js VPS:node:sqlite(Node 22 内置,零 npm 依赖) │ └── d1.js Cloudflare D1 - ├── node-server.js VPS 入口:静态页 + /api/* 同一端口 + ├── node-server.js VPS 入口:静态页 + /api/* + /mcp 同一端口 ├── worker.js CF Workers 入口 ├── wrangler.toml CF 部署配置 - └── test/api.test.js 服务端用例(72 项) + └── test/ api.test.js(72 项)+ mcp.test.js(39 项) ``` 换平台只需换适配器,`core/` 一行都不用动。 @@ -43,6 +44,7 @@ npm start # 默认 9523,静态页与 API 同源 | `DB_PATH` | `server/data/work-hours.db` | SQLite 文件位置 | | `STATIC_DIR` | 项目根 | 静态目录 | | `ALLOW_REGISTER` | 未设 | 设 `1` 才开放注册 | +| `MCP_AUTH_TOKEN` | 未设 | MCP 端点接入令牌,**仅首次种子**:库里已有令牌后以 DB 为准(页面后台可改),可不设 | 首次访问 `http://<地址>:9523` → 设置页 → 云端同步 → 创建首个账号。 **建首个账号后注册自动关闭**,公网暴露也不会被陌生人开号。 @@ -50,10 +52,22 @@ npm start # 默认 9523,静态页与 API 同源 跑测试: ```bash -cd server && npm test # 72 项 +cd server && npm test # 72 项 API + 39 项 MCP cd .. && node verify_payroll.js # 220 项 ``` +## MCP Server(POST /mcp) + +服务端内置只读 MCP 端点(Streamable HTTP,JSON-RPC 2.0),让对话式 AI 查询出勤与薪酬。 +工具 7 个:`get_month_summary` / `get_shift_records` / `get_payroll_settings` / `get_penalty_details` / `simulate_leave` / `list_month_overview` / `get_business_rules`。 +算钱一律复用 `shared/payroll.js` 引擎(与前端看板同源),不让 AI 口算。 + +- **配置存 DB**(`app_settings` 表),页面「设置 → AI 接入 (MCP)」可开关/生成/重置令牌,**即时生效无需重启** +- `MCP_AUTH_TOKEN` 环境变量只做首启种子(库里从没有过令牌时迁入 DB),不设也行 +- 状态语义:未配置 → `404`(端点视为不存在);已配置但关闭 → `503`;令牌错误 → `401` +- 管理 API(登录态):`GET /api/mcp/status`(掩码)、`POST /api/mcp/token`(生成/重置,明文仅回一次)、`GET /api/mcp/token`(复制用取回明文)、`POST /api/mcp/enabled` +- 只读约束:任何工具都不写库,AI 最多能看,不能改 + ### systemd 现有 `work-hours-tracker.service` 是 `python3 -m http.server 9522`,只发静态文件, @@ -108,6 +122,10 @@ PBKDF2 十万次迭代实测约 17ms,远低于 Workers 的 CPU 时间预算。 | DELETE | `/api/record/YYYY-MM-DD` | 删单日 | | DELETE | `/api/adjust/YYYY-MM` | 删单月奖惩 | | GET | `/api/export` | 导出 JSON,与前端「导出备份」同构,可互相导入 | +| GET | `/api/mcp/status` | MCP 接入状态(configured/enabled/令牌掩码) | +| POST | `/api/mcp/token` | 生成/重置 MCP 令牌(明文仅此一次,自动启用) | +| GET | `/api/mcp/token` | 取回 MCP 令牌明文(复制用) | +| POST | `/api/mcp/enabled` | 开/关 MCP 端点(即时生效,无需重启) | ## 同步机制 @@ -138,6 +156,7 @@ PBKDF2 十万次迭代实测约 17ms,远低于 Workers 的 CPU 时间预算。 | `records` | (user_id, day) | day = `2026-09-08` | | `month_adjust` | (user_id, month) | month = `2026-09`,`status` = draft/final | | `settings` | user_id | 整份 JSON,字段会迭代所以不拆列 | +| `app_settings` | key | App 级运行时配置(MCP 开关/令牌),不属于任何用户,不参与同步 | ## 安全 diff --git a/server/adapters/d1.js b/server/adapters/d1.js index e9d6220..adfdb39 100644 --- a/server/adapters/d1.js +++ b/server/adapters/d1.js @@ -143,4 +143,17 @@ export class D1Store { for (const a of (adjs.results || [])) adjust[a.month] = a; return { rev: await this.getRev(userId), records, adjust, settings: st ? JSON.parse(st.json) : null }; } + + // ---------- App 级键值 ---------- + async getSetting(key) { + const r = await this.db.prepare('SELECT value FROM app_settings WHERE key = ?').bind(key).first(); + return r ? r.value : null; + } + + async setSetting(key, value) { + await this.db.prepare(` + INSERT INTO app_settings (key, value, updated_at) VALUES (?,?,?) + ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at + `).bind(key, value, new Date().toISOString()).run(); + } } diff --git a/server/adapters/sqlite-node.js b/server/adapters/sqlite-node.js index 51edfb0..5a33fe8 100644 --- a/server/adapters/sqlite-node.js +++ b/server/adapters/sqlite-node.js @@ -174,4 +174,18 @@ export class SqliteStore { settings: st ? JSON.parse(st.json) : null }; } + + // ---------- App 级键值 ---------- + // 查无返回 null(node:sqlite 的 .get() 查无是 undefined,统一掰成 null)。 + async getSetting(key) { + const r = this.db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key); + return r ? r.value : null; + } + + async setSetting(key, value) { + this.db.prepare(` + INSERT INTO app_settings (key, value, updated_at) VALUES (?,?,?) + ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at + `).run(key, value, new Date().toISOString()); + } } diff --git a/server/core/api.js b/server/core/api.js index 0b7386c..77ceabe 100644 --- a/server/core/api.js +++ b/server/core/api.js @@ -5,6 +5,9 @@ import { hashPassword, verifyPassword, newToken, hashToken, bearerFrom, SESSION_TTL_MS } from './auth.js'; +import { + readMcpConfig, saveMcpToken, setMcpEnabled, newMcpToken, maskToken +} from './mcp.js'; import { BadRequest, normalizeRecord, normalizeAdjust, normalizeSettings, recordRowToClient, adjustRowToClient, DAY_RE, MONTH_RE @@ -234,6 +237,38 @@ export async function handleApi(request, store, opts = {}) { }, 200, { 'content-disposition': 'attachment; filename="work-hours-backup.json"' }); } + // ---------- MCP 接入管理(登录态即可操作;单人系统无特权分级) ---------- + // 开关与令牌存 DB,改动即时生效;令牌明文只在 POST /api/mcp/token 的响应里出现一次。 + if (path.startsWith('/api/mcp/')) { + await requireAuth(store, request); + const sub = path.slice('/api/mcp/'.length); + + if (sub === 'status' && method === 'GET') { + const cfg = await readMcpConfig(store, opts.mcpSeedToken); + return json({ + ok: true, configured: cfg.configured, enabled: cfg.enabled, + masked: cfg.configured ? maskToken(cfg.token) : null + }); + } + if (sub === 'token' && method === 'POST') { + const token = newMcpToken(); + await saveMcpToken(store, token); + return json({ ok: true, token, enabled: true, note: '明文仅此一次展示,请立即保存' }); + } + if (sub === 'token' && method === 'GET') { + const cfg = await readMcpConfig(store, opts.mcpSeedToken); + if (!cfg.configured) return err('尚未配置 MCP 令牌,请先生成', 404); + return json({ ok: true, token: cfg.token }); + } + if (sub === 'enabled' && method === 'POST') { + const body = await readJson(request); + if (typeof body.enabled !== 'boolean') return err('enabled 必须是布尔值'); + await setMcpEnabled(store, body.enabled); + return json({ ok: true, enabled: body.enabled }); + } + return err('接口不存在: ' + path, 404); + } + return err('接口不存在: ' + path, 404); } catch (e) { const status = e.status || (e instanceof BadRequest ? 400 : 500); diff --git a/server/core/mcp.js b/server/core/mcp.js new file mode 100644 index 0000000..1a54379 --- /dev/null +++ b/server/core/mcp.js @@ -0,0 +1,582 @@ +// MCP Server(Model Context Protocol)——挂在主服务的 /mcp 端点上 +// +// 为什么不用官方 SDK / 不起新进程: +// 服务端全程零 npm 依赖(连密码哈希都是内置 Web Crypto),MCP 本质就是 JSON-RPC 2.0, +// 无状态 Streamable HTTP 模式只需要 POST + 响应。挂进现有 node-server.js 的 9522 端口, +// 复用同一套 systemd、访问日志与反代,不新增进程与攻击面。 +// +// 传输形态(Streamable HTTP,无状态): +// POST /mcp 单条 JSON-RPC 请求 → 单条响应;通知(无 id)→ 202 空响应 +// GET/DELETE 405(不提供 SSE 长连接与会话) +// +// 安全模型: +// 1. Bearer Token,配置存 DB(app_settings 表):后台「设置 → AI 接入 (MCP)」可随时 +// 开关/轮换,即时生效无需重启。env MCP_AUTH_TOKEN 只做一次性种子:库里还没有 +// 令牌时迁入 DB,之后以 DB 为准(换 env 不会顶掉已生成的令牌)。 +// 两者都没配 → 端点关闭(404)。「没给钥匙的门直接不存在」比「存在但报 401」少暴露一层。 +// 2. 只读工具:任何工具都不写库。补卡 / 改金额这类敏感操作永远不通过 MCP 暴露, +// AI 客户端最多能看,不能改。 +// 3. 定长比较(timingSafeEqual)防逐字节探测 token。 +// +// 引擎复用(本实现最重要的一条设计约束): +// 所有算钱一律调 shared/payroll.js 的 aggregateMonth / flowsOf / penaltyDetails, +// 与前端看板同源。绝不把薪资规则复述给 AI 让它自己算——模型口算 19.5×7.5×28 +// 迟早出错,而引擎带 100+ 基线用例。MCP 只做「取数 + 喂给引擎 + 结构化返回」。 +// +// 测试:node --disable-warning=ExperimentalWarning test/mcp.test.js + +import { timingSafeEqual } from 'node:crypto'; +import payroll from '../../shared/payroll.js'; + +const SERVER_INFO = { name: 'work-hours-tracker-mcp', version: '1.0.0' }; +// 与 MCP 官方已发布版本对齐;客户端发来的版本若认得就原样回,否则回兜底版本。 +const SUPPORTED_PROTOCOLS = new Set(['2025-03-26', '2025-06-18', '2025-11-25']); +const FALLBACK_PROTOCOL = '2025-03-26'; + +// ---------- 业务规则说明(喂给 AI 的口径,避免它用通用常识猜) ---------- +export const BUSINESS_RULES = [ + '时薪制:工资只有工时收入,没有底薪。基础时薪见 settings.rate。', + '双批次发薪:上半月(1 日—cutoff 日,含 cutoff)纯工时 → 当月 payDayA 发;下半月工时 + 当月满勤奖 + 当月已定稿奖惩 → 次月 payDayB 发。', + '满勤奖:当月「上班」出勤天数 ≥ settings.fullDays 才有,金额 = 当月总工时 × settings.fullBonusPerHour;差一天都没有。', + '查获奖励:每件 +settings.foundBonus;漏查处罚:每件 -settings.missedPenalty;其他扣款按日录入(正数)。', + '日常累计奖惩只是记录,不是可发金额:要等次月初组长审核「定稿」后,以定稿终稿金额为准。', + '未定稿的奖惩一律按 0 计入应得/发放口径(与满勤未达标归零同一个道理)。', + '处罚吃穿下半月工时时,次月 payDayB 结转按 0 兜底(floored=true,此时两批合计可能大于挣得总额)。', + '只有 status=work 的日子计工时与出勤;rest(休)/leave(请)都不计。', + '夜班跨天:工时整段归入开始打卡那一天。', + 'earnedTotal 是「挣得口径」(本月干出来的钱),cashTotal 是「收付口径」(本月实际进账,含上月结转),两者永远不相等,不能混称「本月工资」。' +].join('\n'); + +// ---------- 工具清单 ---------- +const TOOLS = [ + { + name: 'get_month_summary', + description: '获取指定月份薪酬大盘:出勤与工时、上下半月拆分、基础工钱、满勤奖(含达标判定)、奖惩定稿状态与金额、挣得总额、两批发薪金额与结转。分析任何月薪问题先用它。', + inputSchema: { + type: 'object', + properties: { + year: { type: 'integer', description: '公历年份,如 2026' }, + month: { type: 'integer', minimum: 1, maximum: 12, description: '月份 1-12' } + }, + required: ['year', 'month'] + } + }, + { + name: 'get_shift_records', + description: '获取某月每日打卡明细(日期、状态 work/rest/leave、班次 day/night、工时、查获/漏查件数、扣款、当日工钱),可按班次或状态过滤。', + inputSchema: { + type: 'object', + properties: { + year: { type: 'integer', description: '公历年份' }, + month: { type: 'integer', minimum: 1, maximum: 12, description: '月份 1-12' }, + filter: { type: 'string', enum: ['all', 'work', 'day', 'night', 'rest', 'leave'], description: '过滤:all 全部 / work 上班 / day 白班 / night 夜班 / rest 休 / leave 请' } + }, + required: ['year', 'month'] + } + }, + { + name: 'get_payroll_settings', + description: '获取薪酬规则参数:时薪、满勤门槛与单价、查获/漏查奖惩单价、上半月截数日、发薪日 A/B、白班/夜班默认工时。', + inputSchema: { type: 'object', properties: {} } + }, + { + name: 'get_penalty_details', + description: '获取某月处罚/奖励明细:按日的漏查与其他扣款清单、查获与漏查件数合计、日常累计净额、组长审核定稿状态与终稿金额。', + inputSchema: { + type: 'object', + properties: { + year: { type: 'integer', description: '公历年份' }, + month: { type: 'integer', minimum: 1, maximum: 12, description: '月份 1-12' } + }, + required: ['year', 'month'] + } + }, + { + name: 'simulate_leave', + description: '假设请假试算(只读,不改任何数据):把某月 N 个工作日按请假重算薪酬,返回与现状的逐项差异(含满勤奖是否丢失)。默认取该月最后 N 个工作日,可用 specific_days 指定具体日期。', + inputSchema: { + type: 'object', + properties: { + year: { type: 'integer', description: '公历年份' }, + month: { type: 'integer', minimum: 1, maximum: 12, description: '月份 1-12' }, + days: { type: 'integer', minimum: 1, maximum: 31, description: '假设请假的天数' }, + specific_days: { + type: 'array', + items: { type: 'integer', minimum: 1, maximum: 31 }, + description: '可选:明确指定请哪几天(几号)。指定后 days 参数被忽略。' + } + }, + required: ['year', 'month', 'days'] + } + }, + { + name: 'list_month_overview', + description: '列出所有有数据的月份及各月核心指标(出勤、工时、工钱、满勤奖、定稿奖惩、挣得总额、实际时薪),用于跨月对比(如近 3 个月时薪趋势)。', + inputSchema: { type: 'object', properties: {} } + }, + { + name: 'get_business_rules', + description: '获取本系统薪酬业务规则说明(双批次发薪、满勤判定、未定稿按 0、结转兜底等口径)。做薪酬分析前建议先读,避免用通用薪资常识算错。', + inputSchema: { type: 'object', properties: {} } + } +]; + +// ---------- 小工具 ---------- +const r2 = v => Math.round((Number(v) || 0) * 100) / 100; + +function json(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json; charset=utf-8' } + }); +} + +function rpcError(id, code, message) { + return { jsonrpc: '2.0', id, error: { code, message } }; +} + +function rpcResult(id, result) { + return { jsonrpc: '2.0', id, result }; +} + +/** token 比对:定长比较,防时序泄漏也防长度探测 */ +function tokenEquals(provided, expected) { + try { + const a = Buffer.from(String(provided)); + const b = Buffer.from(String(expected)); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); + } catch { return false; } +} + +// ---------- 配置(DB 真开关,后台改动即时生效) ---------- +// 开关与令牌存 app_settings 表(不属于任何用户、不参与同步协议,客户端 push 碰不到)。 +// 管理入口:/api/mcp/*(见 core/api.js,登录态)与前端设置页「AI 接入 (MCP)」卡片。 +export const MCP_KEYS = { token: 'mcp_token', enabled: 'mcp_enabled' }; + +/** 掩码展示:头 6 尾 4,中间省略。短令牌整个打码。 */ +export function maskToken(token) { + const t = String(token || ''); + if (t.length <= 10) return '…'; + return t.slice(0, 6) + '…' + t.slice(-4); +} + +/** 新令牌:64 位 hex(Web Crypto,与 auth.js 同源思路,Node 22 与 Workers 都有) */ +export function newMcpToken() { + const buf = crypto.getRandomValues(new Uint8Array(32)); + let s = ''; + for (const b of buf) s += b.toString(16).padStart(2, '0'); + return s; +} + +/** + * 读生效配置:{ configured, enabled, token }。 + * 状态语义刻意分开:未配置 → 端点 404(视为不存在);已配置但关闭 → 503 + * (「配了但被关了,可从后台重新打开」);令牌错 → 401。 + * @param {object} store + * @param {string} seedToken env 里的首启令牌;入口层传入,core 不直接读 process.env(保持平台无关) + */ +export async function readMcpConfig(store, seedToken = '') { + let token = await store.getSetting(MCP_KEYS.token); + if (!token && seedToken) { + // 首次种子:env 有令牌且库里从没有过 → 迁入 DB(兼容旧部署,现有 token 无感切换) + await store.setSetting(MCP_KEYS.token, seedToken); + token = seedToken; + } + if (!token) return { configured: false, enabled: false, token: null }; + const enabled = (await store.getSetting(MCP_KEYS.enabled)) !== '0'; + return { configured: true, enabled, token }; +} + +/** 生成/轮换令牌并自动启用;明文只应出现在本次调用方的响应里,DB 之外不留副本 */ +export async function saveMcpToken(store, token) { + await store.setSetting(MCP_KEYS.token, token); + await store.setSetting(MCP_KEYS.enabled, '1'); +} + +export async function setMcpEnabled(store, enabled) { + await store.setSetting(MCP_KEYS.enabled, enabled ? '1' : '0'); +} + +// ---------- 参数校验 ---------- +function needInt(args, key, { min = null, max = null } = {}) { + const v = args?.[key]; + if (typeof v !== 'number' || !Number.isInteger(v)) { + throw { code: -32602, message: `参数 ${key} 必须是整数` }; + } + if ((min !== null && v < min) || (max !== null && v > max)) { + throw { code: -32602, message: `参数 ${key} 超出范围 [${min}, ${max}]` }; + } + return v; +} + +// ---------- 数据装配 ---------- +/** + * 从存储层拉全量并映射成 payroll 引擎要吃的形态。 + * DB 行是 snake_case(final_amount),payroll 认 camelCase(finalAmount), + * 必须在这里掰过来——引擎是纯函数,不该感知存储形态。 + */ +async function buildCtx(store, userId) { + const dump = await store.fullDump(userId); + const records = {}; + for (const [day, r] of Object.entries(dump.records)) { + records[day] = { + status: r.status, shift_type: r.shift_type, hours: r.hours, + contraband_found: r.contraband_found, contraband_missed: r.contraband_missed, + other_penalty: r.other_penalty, penalty_reason: r.penalty_reason || '' + }; + } + const monthAdjust = {}; + for (const [month, a] of Object.entries(dump.adjust)) { + monthAdjust[month] = { + status: a.status, finalAmount: a.final_amount, + note: a.note || '', finalizedAt: a.finalized_at || '' + }; + } + return { + records, monthAdjust, + cfg: payroll.normalizeSettings(dump.settings || {}), + rev: dump.rev + }; +} + +const monthCtxOf = ctx => ({ records: ctx.records, monthAdjust: ctx.monthAdjust, cfg: ctx.cfg }); + +function summarize(year, month1, ctx) { + const m0 = month1 - 1; + const s = payroll.aggregateMonth(year, m0, monthCtxOf(ctx)); + const prev = payroll.prevMonthOf(year, m0); + const prevAgg = payroll.aggregateMonth(prev.year, prev.month, monthCtxOf(ctx)); + const flows = payroll.flowsOf({ cur: s, prev: prevAgg, cfg: ctx.cfg }); + const c = ctx.cfg; + return { + period: `${year}-${String(month1).padStart(2, '0')}`, + dataRev: ctx.rev, + rules: { + rate: c.rate, fullDays: c.fullDays, fullBonusPerHour: c.fullBonusPerHour, + cutoff: c.cutoff, payDayA: c.payDayA, payDayB: c.payDayB, + foundBonus: c.foundBonus, missedPenalty: c.missedPenalty + }, + attendance: { + workDays: s.workDays, dayShifts: s.dayShifts, nightShifts: s.nightShifts, + restDays: s.restDays, leaveDays: s.leaveDays + }, + hours: { + total: r2(s.totalHours), firstHalf: r2(s.h1), secondHalf: r2(s.h2), + note: `上半月 1-${c.cutoff} 日,下半月 ${c.cutoff + 1}-月末;夜班跨天工时归开始打卡日` + }, + pay: { + basePay: r2(s.basePay), + fullBonus: r2(s.fullBonus), + fullBonusQualified: s.workDays >= c.fullDays, + fullDaysLeft: Math.max(0, c.fullDays - s.workDays), + earnedTotal: r2(flows.earnedTotal), + payoutBatchA: r2(flows.thisA), + payoutBatchB: r2(flows.thisB), + carryToNextMonth: r2(flows.carry), + cashTotal: r2(flows.cashTotal), + floored: flows.floored, + flooredNote: flows.floored ? '处罚吃穿下半月工时,结转按 0 兜底,两批合计会大于挣得总额' : undefined + }, + review: { + foundCount: s.foundCount, missedCount: s.missedCount, + foundBonus: r2(s.foundBonus), missedPenalty: r2(s.missedPenalty), + otherPenalties: r2(s.otherPenalties), + rawReview: r2(s.rawReview), + adjustStatus: s.adjustStatus, adjustNote: s.adjustNote, + finalized: s.finalized, finalizedAt: s.finalizedAt, + finalizedAmount: r2(s.reviewAmount), + reviewNet: r2(s.reviewNet), + reviewDelta: r2(s.reviewDelta), + note: '未定稿时 reviewNet/finalizedAmount 按 0 计' + } + }; +} + +function shiftRecords(year, month1, ctx, filter) { + const c = ctx.cfg; + const prefix = `${year}-${String(month1).padStart(2, '0')}-`; + const out = []; + for (const [day, r] of Object.entries(ctx.records)) { + if (!day.startsWith(prefix)) continue; + if (filter === 'work' && r.status !== 'work') continue; + if (filter === 'day' && !(r.status === 'work' && r.shift_type === 'day')) continue; + if (filter === 'night' && !(r.status === 'work' && r.shift_type === 'night')) continue; + if (filter === 'rest' && r.status !== 'rest') continue; + if (filter === 'leave' && r.status !== 'leave') continue; + out.push({ + day, + status: r.status, + shiftType: r.shift_type, + hours: r2(r.hours), + found: r.contraband_found || 0, + missed: r.contraband_missed || 0, + otherPenalty: (r.other_penalty === null || r.other_penalty === undefined) ? null : r2(r.other_penalty), + penaltyReason: r.penalty_reason || '', + dayPay: r.status === 'work' ? r2(r.hours * c.rate) : 0 + }); + } + out.sort((a, b) => a.day < b.day ? -1 : 1); + return { + period: prefix.slice(0, 7), filter: filter || 'all', + count: out.length, + totalHours: r2(out.reduce((s, x) => s + x.hours, 0)), + totalDayPay: r2(out.reduce((s, x) => s + x.dayPay, 0)), + records: out + }; +} + +function penaltyDetailsOf(year, month1, ctx) { + const c = ctx.cfg; + const details = payroll.penaltyDetails(year, month1 - 1, monthCtxOf(ctx)).map(d => ({ + day: d.day, reason: d.reason, amount: r2(d.amount) + })); + const s = payroll.aggregateMonth(year, month1 - 1, monthCtxOf(ctx)); + return { + period: `${year}-${String(month1).padStart(2, '0')}`, + unitPrices: { foundBonus: c.foundBonus, missedPenalty: c.missedPenalty }, + counts: { found: s.foundCount, missed: s.missedCount }, + totals: { + foundBonus: r2(s.foundBonus), missedPenalty: r2(s.missedPenalty), + otherPenalties: r2(s.otherPenalties), + rawReview: r2(s.rawReview), + note: 'rawReview = 查获 - 漏查 - 其他扣款(日常累计,未含满勤奖)' + }, + details, + finalize: { + status: s.adjustStatus, finalized: s.finalized, + finalizedAmount: r2(s.reviewAmount), note: s.adjustNote, + finalizedAt: s.finalizedAt, + effect: s.finalized ? '定稿终稿金额替代日常累计额计入当月奖惩' : '未定稿 → 当月奖惩按 0 计入应得口径' + } + }; +} + +function simulateLeave(year, month1, ctx, days, specificDays) { + const prefix = `${year}-${String(month1).padStart(2, '0')}-`; + const workDays = Object.entries(ctx.records) + .filter(([day, r]) => day.startsWith(prefix) && r.status === 'work') + .map(([day]) => day) + .sort(); + + // 选哪几天:指定 specific_days → 校验;否则默认取该月最后 N 个工作日 + // (看当前月时它们通常还没到,「把后面的班请掉」最贴近真实请假场景) + let targets; + if (Array.isArray(specificDays) && specificDays.length > 0) { + targets = specificDays.map(d => { + if (typeof d !== 'number' || !Number.isInteger(d) || d < 1 || d > 31) { + throw { code: -32602, message: `specific_days 里的值必须是 1-31 的整数,收到 ${JSON.stringify(d)}` }; + } + const key = prefix + String(d).padStart(2, '0'); + if (!ctx.records[key] || ctx.records[key].status !== 'work') { + throw { code: -32602, message: `日期 ${key} 不是该月的工作日,无法假设请假` }; + } + return key; + }); + } else { + if (days > workDays.length) { + throw { code: -32602, message: `该月只有 ${workDays.length} 个工作日,无法请假 ${days} 天` }; + } + targets = workDays.slice(-days); + } + + const altered = { ...ctx.records }; + for (const key of targets) { + altered[key] = { + ...ctx.records[key], status: 'leave', + shift_type: null, hours: 0, + contraband_found: 0, contraband_missed: 0, + other_penalty: null, penalty_reason: '' + }; + } + const afterCtx = { records: altered, monthAdjust: ctx.monthAdjust, cfg: ctx.cfg }; + const before = payroll.aggregateMonth(year, month1 - 1, monthCtxOf(ctx)); + const prev = payroll.prevMonthOf(year, month1 - 1); + const prevAgg = payroll.aggregateMonth(prev.year, prev.month, monthCtxOf(ctx)); + const beforeFlows = payroll.flowsOf({ cur: before, prev: prevAgg, cfg: ctx.cfg }); + const after = payroll.aggregateMonth(year, month1 - 1, monthCtxOf(afterCtx)); + const afterFlows = payroll.flowsOf({ cur: after, prev: prevAgg, cfg: ctx.cfg }); + + const snap = (s, f) => ({ + workDays: s.workDays, totalHours: r2(s.totalHours), + basePay: r2(s.basePay), fullBonus: r2(s.fullBonus), + fullBonusQualified: s.workDays >= ctx.cfg.fullDays, + earnedTotal: r2(f.earnedTotal), carryToNextMonth: r2(f.carry) + }); + const b = snap(before, beforeFlows), a = snap(after, afterFlows); + const notes = []; + if (b.fullBonusQualified && !a.fullBonusQualified) { + notes.push(`出勤将跌破满勤门槛 ${ctx.cfg.fullDays} 天 → 满勤奖 ${b.fullBonus} 元归零,这是最大的损失项`); + } else if (b.fullBonus > 0) { + notes.push('满勤奖不受影响(仍达标)'); + } + notes.push('请假日本身不产生工时收入,只损失当日工钱与可能的满勤奖'); + return { + period: prefix.slice(0, 7), + assumedLeaveDays: targets, + before: b, after: a, + delta: { + workDays: a.workDays - b.workDays, + totalHours: r2(a.totalHours - b.totalHours), + basePay: r2(a.basePay - b.basePay), + fullBonus: r2(a.fullBonus - b.fullBonus), + earnedTotal: r2(a.earnedTotal - b.earnedTotal), + carryToNextMonth: r2(a.carryToNextMonth - b.carryToNextMonth) + }, + notes, + disclaimer: '只读试算,未写入任何数据;请假日期为假设值,返回中已列明具体日期供核对' + }; +} + +function monthOverview(ctx) { + const months = new Set(); + for (const day of Object.keys(ctx.records)) months.add(day.slice(0, 7)); + for (const month of Object.keys(ctx.monthAdjust)) months.add(month); + const out = []; + for (const period of [...months].sort().reverse()) { + const y = Number(period.slice(0, 4)), m = Number(period.slice(5, 7)); + const s = payroll.aggregateMonth(y, m - 1, monthCtxOf(ctx)); + const prev = payroll.prevMonthOf(y, m - 1); + const flows = payroll.flowsOf({ + cur: s, prev: payroll.aggregateMonth(prev.year, prev.month, monthCtxOf(ctx)), cfg: ctx.cfg + }); + out.push({ + period, + workDays: s.workDays, totalHours: r2(s.totalHours), + basePay: r2(s.basePay), fullBonus: r2(s.fullBonus), + reviewNet: r2(s.reviewNet), finalized: s.finalized, + earnedTotal: r2(flows.earnedTotal), + effectiveHourly: s.totalHours > 0 ? r2(flows.earnedTotal / s.totalHours) : null + }); + } + return { + count: out.length, months: out, + effectiveHourlyNote: 'effectiveHourly = earnedTotal / 总工时(挣得口径的实际时薪,含满勤奖与定稿奖惩)' + }; +} + +// ---------- 工具分发 ---------- +async function callTool(name, args, ctx) { + switch (name) { + case 'get_payroll_settings': + return { settings: ctx.cfg, labels: payroll.SETTING_LABELS, rules: BUSINESS_RULES }; + case 'list_month_overview': + return monthOverview(ctx); + case 'get_business_rules': + return { rules: BUSINESS_RULES }; + case 'get_month_summary': + return summarize(needInt(args, 'year', { min: 2000, max: 2100 }), needInt(args, 'month', { min: 1, max: 12 }), ctx); + case 'get_penalty_details': + return penaltyDetailsOf(needInt(args, 'year', { min: 2000, max: 2100 }), needInt(args, 'month', { min: 1, max: 12 }), ctx); + case 'get_shift_records': { + const year = needInt(args, 'year', { min: 2000, max: 2100 }); + const month = needInt(args, 'month', { min: 1, max: 12 }); + const f = args.filter === undefined ? 'all' : String(args.filter); + if (!['all', 'work', 'day', 'night', 'rest', 'leave'].includes(f)) { + throw { code: -32602, message: `filter 只能是 all/work/day/night/rest/leave,收到 ${f}` }; + } + return shiftRecords(year, month, ctx, f); + } + case 'simulate_leave': { + const year = needInt(args, 'year', { min: 2000, max: 2100 }); + const month = needInt(args, 'month', { min: 1, max: 12 }); + const days = needInt(args, 'days', { min: 1, max: 31 }); + return simulateLeave(year, month, ctx, days, args.specific_days); + } + default: + throw { code: -32602, message: `未知工具: ${name}` }; + } +} + +// ---------- JSON-RPC 入口 ---------- +async function handleRpcMessage(msg, store, userId) { + if (!msg || typeof msg !== 'object' || msg.jsonrpc !== '2.0') { + return rpcError(null, -32600, '不是合法的 JSON-RPC 2.0 请求'); + } + const isNotification = msg.id === undefined || msg.id === null; + try { + switch (msg.method) { + case 'initialize': + if (isNotification) return null; + return rpcResult(msg.id, { + protocolVersion: SUPPORTED_PROTOCOLS.has(msg.params?.protocolVersion) ? msg.params.protocolVersion : FALLBACK_PROTOCOL, + capabilities: { tools: { listChanged: false } }, + serverInfo: SERVER_INFO + }); + case 'notifications/initialized': + return null; // 通知不回包(HTTP 层转 202) + case 'ping': + return isNotification ? null : rpcResult(msg.id, {}); + case 'tools/list': + return isNotification ? null : rpcResult(msg.id, { tools: TOOLS }); + case 'tools/call': { + if (isNotification) return null; + const name = msg.params?.name; + if (typeof name !== 'string' || !TOOLS.some(t => t.name === name)) { + return rpcError(msg.id, -32602, `未知工具: ${String(name)}`); + } + // 每次调用现拉全量,保证 AI 拿到的是最新打卡(全量很小:42 行记录) + const fresh = await buildCtx(store, userId); + const data = await callTool(name, msg.params.arguments || {}, fresh); + const text = JSON.stringify(data, null, 2); + return rpcResult(msg.id, { content: [{ type: 'text', text }], structuredContent: data }); + } + default: + return isNotification ? null : rpcError(msg.id, -32601, `未知方法: ${String(msg.method)}`); + } + } catch (e) { + if (isNotification) return null; + if (e && e.code === -32602) return rpcError(msg.id, -32602, e.message); + return rpcError(msg.id, -32603, '内部错误:' + (e?.message || String(e))); + } +} + +/** + * HTTP 入口。 + * 用户定位:MCP token 与站点登录态无关;当前是单人自用系统,固定取 id=1 的首个用户 + * (与 /api/health 的 users:1 对齐)。多人化时这里要换成 token→user 映射。 + * @param {Request} request + * @param {object} store SqliteStore / D1Store 实例 + * @param {{seedToken?:string}} opts MCP_AUTH_TOKEN 环境变量值(仅首次种子用) + */ +export async function handleMcp(request, store, opts = {}) { + const cfg = await readMcpConfig(store, opts.seedToken); + if (!cfg.configured) { + return new Response('MCP 未启用:尚未配置接入令牌(页面「设置 → AI 接入 (MCP)」可一键生成)', { status: 404 }); + } + if (!cfg.enabled) { + return new Response('MCP 已在后台关闭:可在页面「设置 → AI 接入 (MCP)」重新开启', { status: 503 }); + } + if (request.method !== 'POST') { + return new Response('Method Not Allowed(本端点只接受 POST)', { + status: 405, headers: { allow: 'POST' } + }); + } + const provided = (request.headers.get('authorization') || '').replace(/^Bearer\s+/i, ''); + if (!provided || !tokenEquals(provided, cfg.token)) { + return json({ error: 'unauthorized' }, 401); + } + + let body; + try { + body = JSON.parse(await request.text()); + } catch { + return json(rpcError(null, -32700, 'JSON 解析失败'), 400); + } + + const user = await store.getUserById(1); + if (!user) return json(rpcError(null, -32603, '系统中还没有用户'), 500); + + if (Array.isArray(body)) { + const responses = []; + for (const msg of body) { + const r = await handleRpcMessage(msg, store, user.id); + if (r) responses.push(r); + } + return responses.length ? json(responses) : new Response(null, { status: 202 }); + } + + const response = await handleRpcMessage(body, store, user.id); + if (!response) return new Response(null, { status: 202 }); + return json(response); +} diff --git a/server/core/schema.sql b/server/core/schema.sql index 2b733ea..8bc7522 100644 --- a/server/core/schema.sql +++ b/server/core/schema.sql @@ -65,3 +65,21 @@ CREATE TABLE IF NOT EXISTS settings ( rev INTEGER NOT NULL, updated_at TEXT NOT NULL ); + +-- App 级键值:服务自身的运行时配置(当前用于 MCP 接入开关与令牌)。 +-- 刻意与 users/settings 分开:它不属于任何用户,不参与同步协议, +-- 客户端 push 碰不到它,增量拉取也不会把它带出去。 +CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +-- App 级键值:服务自身的运行时配置(当前用于 MCP 接入开关与令牌)。 +-- 刻意与 users/settings 分开:它不属于任何用户,不参与同步协议, +-- 客户端 push 碰不到它,增量拉取也不会把它带出去。 +CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL + ); diff --git a/server/core/store.js b/server/core/store.js index 5a9bab1..eebe1ed 100644 --- a/server/core/store.js +++ b/server/core/store.js @@ -25,6 +25,10 @@ // // 全量导出,用于备份与首次同步 // fullDump(userId): Promise<{ rev, records, adjust, settings }> // +// // ---- App 级键值 ----(运行时配置,如 MCP 开关/令牌;不属于任何用户,不参与同步) +// getSetting(key): Promise +// setSetting(key, value): Promise +// // UserRow: { id, username, pw_hash, pw_salt, pw_iter, rev, created_at } export const HOUR_MIN = 6; diff --git a/server/node-server.js b/server/node-server.js index 3b2bcf9..41dd1e0 100644 --- a/server/node-server.js +++ b/server/node-server.js @@ -9,6 +9,7 @@ // DB_PATH SQLite 文件(默认 /data/work-hours.db) // STATIC_DIR 静态目录(默认项目根,即 index.html 所在处) // ALLOW_REGISTER=1 开放注册(默认只允许「库里还没有用户」时注册首个账号) +// MCP_AUTH_TOKEN MCP 端点(POST /mcp)访问令牌。只做首次种子:库里已有令牌后以 DB 为准(后台可改) // ACCESS_LOG 访问日志文件(默认 /data/access.log;设 off 关闭) import { createServer } from 'node:http'; @@ -21,6 +22,7 @@ import { fileURLToPath } from 'node:url'; import { dirname, join, resolve, normalize, extname } from 'node:path'; import { SqliteStore } from './adapters/sqlite-node.js'; import { handleApi } from './core/api.js'; +import { handleMcp } from './core/mcp.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -29,6 +31,7 @@ const HOST = process.env.HOST || '0.0.0.0'; const DB_PATH = process.env.DB_PATH || join(__dirname, 'data', 'work-hours.db'); const STATIC_DIR = resolve(process.env.STATIC_DIR || join(__dirname, '..')); const ALLOW_REGISTER = process.env.ALLOW_REGISTER === '1'; +const MCP_SEED_TOKEN = process.env.MCP_AUTH_TOKEN || ''; const ACCESS_LOG = process.env.ACCESS_LOG === 'off' ? null : resolve(process.env.ACCESS_LOG || join(__dirname, 'data', 'access.log')); @@ -350,8 +353,14 @@ const server = createServer(async (req, res) => { res.on('close', () => writeAccess(req, res, startedAt, sock, baseline)); try { const u = new URL(req.url, 'http://x'); + if (u.pathname === '/mcp' || u.pathname.startsWith('/mcp/')) { + const response = await handleMcp(await toRequest(req), store, { seedToken: MCP_SEED_TOKEN }); + return await sendResponse(res, response); + } if (u.pathname.startsWith('/api/')) { - const response = await handleApi(await toRequest(req), store, { allowRegister: ALLOW_REGISTER }); + const response = await handleApi(await toRequest(req), store, { + allowRegister: ALLOW_REGISTER, mcpSeedToken: MCP_SEED_TOKEN + }); return await sendResponse(res, response); } if (req.method !== 'GET' && req.method !== 'HEAD') { diff --git a/server/package.json b/server/package.json index 3f9658f..ea62bc3 100644 --- a/server/package.json +++ b/server/package.json @@ -7,7 +7,7 @@ "main": "node-server.js", "scripts": { "start": "node --disable-warning=ExperimentalWarning node-server.js", - "test": "node --disable-warning=ExperimentalWarning test/api.test.js", + "test": "node --disable-warning=ExperimentalWarning test/api.test.js && node --disable-warning=ExperimentalWarning test/mcp.test.js", "schema:dump": "node scripts/dump-schema.js", "cf:dev": "wrangler dev", "cf:deploy": "wrangler deploy", diff --git a/server/test/mcp.test.js b/server/test/mcp.test.js new file mode 100644 index 0000000..c6144e9 --- /dev/null +++ b/server/test/mcp.test.js @@ -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); \ No newline at end of file diff --git a/server/worker.js b/server/worker.js index ab9edb2..64257e0 100644 --- a/server/worker.js +++ b/server/worker.js @@ -10,21 +10,26 @@ // 所以这里只处理 /api/*,其余交回 env.ASSETS。 import { handleApi } from './core/api.js'; +import { handleMcp } from './core/mcp.js'; import { D1Store } from './adapters/d1.js'; export default { async fetch(request, env) { const url = new URL(request.url); - if (url.pathname.startsWith('/api/')) { + if (url.pathname.startsWith('/api/') || url.pathname === '/mcp' || url.pathname.startsWith('/mcp/')) { if (!env.DB) { return new Response( JSON.stringify({ ok: false, error: 'D1 未绑定:请在 wrangler.toml 配置 [[d1_databases]] binding = "DB"' }), { status: 500, headers: { 'content-type': 'application/json; charset=utf-8' } } ); } + if (url.pathname === '/mcp' || url.pathname.startsWith('/mcp/')) { + return handleMcp(request, new D1Store(env.DB), { seedToken: env.MCP_AUTH_TOKEN || '' }); + } return handleApi(request, new D1Store(env.DB), { - allowRegister: env.ALLOW_REGISTER === '1' + allowRegister: env.ALLOW_REGISTER === '1', + mcpSeedToken: env.MCP_AUTH_TOKEN || '' }); } diff --git a/sw.js b/sw.js index 5ab9a3e..d69376c 100644 --- a/sw.js +++ b/sw.js @@ -8,14 +8,14 @@ * 离线时用预缓存的骨架(本地记账照常,数据在 localStorage,与 SW 无关)。 * 3. 静态资源 network-first:在线优先最新(no-cache 本来就要求「用前先问」), * 网络失败才回缓存 —— SW 不改变现有缓存策略,只补「彻底断网」这一层。 - * 4. /api/* 与 /mcp/sse 一律不碰:同步引擎自己有超时与 offline 处理, + * 4. /api/* 与 /mcp* 一律不碰:同步引擎自己有超时与 offline 处理, * SW 绝不缓存 API 响应,避免「读到旧数据以为同步成功」的假象。 * * 版本升级:静态资源 no-cache+ETag,导航每次都回源,新 index.html 引用的 * vendor ?v= 指纹变化会触发重下,PRECACHE 键值同步更新即可。旧缓存条目 * activate 时统一清理,不做遗留兜底。 */ -const VERSION = 'v2'; +const VERSION = 'v3'; const PRECACHE = `wht-precache-${VERSION}`; const RUNTIME = `wht-runtime-${VERSION}`; @@ -84,7 +84,7 @@ self.addEventListener('fetch', (event) => { // 只管同源 GET;跨域与 /api/*、/mcp/* 直接放行给网络 if (url.origin !== self.location.origin) return; if (event.request.method !== 'GET') return; - if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/mcp/')) return; + if (url.pathname.startsWith('/api/') || url.pathname === '/mcp' || url.pathname.startsWith('/mcp/')) return; if (event.request.mode === 'navigate') { event.respondWith(networkFirst(event.request, RUNTIME));