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

622 lines
27 KiB
JavaScript
Raw 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 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';
import { hashToken } from './auth.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 }。
* 兼容旧数据与种子:如果 user_mcp_tokens 查无记录,但 app_settings/seedToken 中存在旧 token,
* 则自动为 id=1 的老用户迁入,保证向后无感兼容。
*/
export async function readUserMcpConfig(store, userId, seedToken = '') {
let cfg = await store.getUserMcpConfig(userId);
if (cfg && cfg.configured) return cfg;
// 兼容老单人自用历史数据迁移
if (userId === 1) {
let legacyToken = await store.getSetting(MCP_KEYS.token);
if (!legacyToken && seedToken) {
legacyToken = seedToken;
await store.setSetting(MCP_KEYS.token, legacyToken);
}
if (legacyToken) {
const legacyEnabled = (await store.getSetting(MCP_KEYS.enabled)) !== '0';
const tokenHash = await hashToken(legacyToken);
await store.saveUserMcpToken(1, legacyToken, tokenHash);
if (!legacyEnabled) {
await store.setUserMcpEnabled(1, false);
}
return {
configured: true,
enabled: legacyEnabled,
token: legacyToken
};
}
}
return { configured: false, enabled: false, token: null };
}
/** 生成/轮换用户专有令牌并自动启用 */
export async function saveUserMcpToken(store, userId, token) {
const tokenHash = await hashToken(token);
await store.saveUserMcpToken(userId, token, tokenHash);
}
export async function setUserMcpEnabled(store, userId, enabled) {
await store.setUserMcpEnabled(userId, enabled);
}
// ---------- 参数校验 ----------
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 请求由 Bearer Token 鉴权,根据 tokenHash 检索绑定的 user。
* 完全解绑单人 id=1 硬编码,不同用户的 AI 客户端独立查各自打卡与薪酬。
* @param {Request} request
* @param {object} store SqliteStore / D1Store 实例
* @param {{seedToken?:string}} opts MCP_AUTH_TOKEN 环境变量值(仅首次种子用)
*/
export async function handleMcp(request, store, opts = {}) {
// 全局若未配置过任何可用 token,端点按设计直接关闭(404 隐藏端点)
const activeCount = await store.countActiveMcpTokens();
let legacyCfg1 = null;
if (activeCount === 0) {
legacyCfg1 = await readUserMcpConfig(store, 1, opts.seedToken);
if (!legacyCfg1.configured) {
return new Response('MCP 未启用:尚未配置接入令牌(页面「设置 → AI 接入 (MCP)」可一键生成)', { status: 404 });
}
}
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) {
return json({ error: 'unauthorized', message: '缺少 Authorization Bearer 令牌' }, 401);
}
const tokenHash = await hashToken(provided);
let hit = await store.getUserByMcpTokenHash(tokenHash);
// 兜底兼容迁移:若尚未迁入 user_mcp_tokens,检查 user 1 是否有遗留配置
if (!hit) {
const cfg1 = legacyCfg1 || await readUserMcpConfig(store, 1, opts.seedToken);
if (cfg1.configured && cfg1.token && tokenEquals(provided, cfg1.token)) {
const u1 = await store.getUserById(1);
if (u1) hit = { user: u1, enabled: cfg1.enabled };
}
}
if (!hit) {
return json({ error: 'unauthorized', message: '无效的 MCP 访问令牌' }, 401);
}
if (!hit.enabled) {
return new Response('MCP 已在后台关闭:可在页面「设置 → AI 接入 (MCP)」重新开启', { status: 503 });
}
let body;
try {
body = JSON.parse(await request.text());
} catch {
return json(rpcError(null, -32700, 'JSON 解析失败'), 400);
}
const user = hit.user;
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);
}