@@ -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 ) ;
}