feat: 设置页 MCP 管理卡片(方案B)— DB 真开关 + 即时生效 + env 仅首次种子

- 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 章节)
This commit is contained in:
OpenClaw Agent
2026-09-13 10:24:33 +08:00
parent 9702d1949a
commit 26306eee52
14 changed files with 1006 additions and 12 deletions
+118 -1
View File
@@ -809,6 +809,31 @@
</div>
</div>
<!-- AI 接入 (MCP):让对话式 AI 只读查询打卡与薪酬;开关与令牌存服务器 DB,改动即时生效 -->
<div class="card overflow-hidden">
<div class="px-5 py-3 bg-slate-50 border-b border-slate-100 flex items-center justify-between gap-2">
<p class="text-xs font-bold text-slate-700 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-lightbulb" /></svg>AI 接入 (MCP)</p>
<span class="text-[10px] font-semibold px-2 py-0.5 rounded-full" :class="mcpBadge.cls">{{ mcpBadge.text }}</span>
</div>
<div class="divide-y divide-slate-100">
<div class="flex items-center justify-between px-5 py-3">
<span class="text-xs text-slate-600">接入开关</span>
<button @click="toggleMcp" :disabled="mcp.busy || !mcp.configured" class="relative w-11 h-6 rounded-full transition shrink-0 disabled:opacity-40" :class="mcp.enabled ? 'bg-emerald-500' : 'bg-slate-300'">
<span class="absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-all" :class="mcp.enabled ? 'left-[22px]' : 'left-0.5'"></span>
</button>
</div>
<div v-if="mcp.configured" class="flex items-center justify-between gap-2 px-5 py-3">
<span class="text-xs text-slate-600 shrink-0">访问令牌</span>
<span class="text-[11px] font-bold text-slate-700 font-mono-num">{{ mcp.masked }}</span>
</div>
<button v-if="mcp.configured" @click="copyMcpToken" :disabled="mcp.busy" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-indigo-600 active:bg-slate-50 transition disabled:opacity-40"><svg class="ico text-[15px]"><use :href="mcpCopied ? '#i-check' : '#i-copy'" /></svg>{{ mcpCopied ? '已复制,去 AI 客户端粘贴' : '复制访问令牌(明文)' }}</button>
<button @click="newMcpToken" :disabled="mcp.busy" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-indigo-600 active:bg-slate-50 transition disabled:opacity-40"><svg class="ico text-[15px]" :class="{ 'animate-spin': mcp.busy }"><use href="#i-refresh-cw" /></svg>{{ mcp.configured ? '重置令牌(旧令牌立即失效)' : '生成访问令牌并开启' }}</button>
</div>
<p class="px-5 py-3 text-[10px] text-slate-400 bg-slate-50/70 leading-relaxed">
端点 POST /mcp(Bearer 令牌鉴权),只读:AI 只能查询出勤、工时与薪酬,改不了任何数据。开关与令牌改动即时生效,无需重启;重置后旧客户端立即失效。
</p>
</div>
<div class="card overflow-hidden">
<p class="px-5 py-3 text-xs font-bold text-slate-700 bg-slate-50 border-b border-slate-100 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-database" /></svg>数据</p>
<div class="divide-y divide-slate-100">
@@ -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
};