MCP /mcp/status Lifecycle 技术方案
会议讨论稿。说明 DeepWorks Server 在
GET /workspace/:id/mcp/status中新增lifecycle字段的背景、契约、状态机与前后端消费约定。产品侧名称统一为 DeepWorks;文中
OpenCode/ Engine 仅作运行时兼容标识。
1. 结论摘要(会上先对齐)
- 问题 :引擎原始
items只描述进程 runtime,无法表达 OAuth「授权中 / 成功 / 失败 / 用户拒绝」。Plaud 桥接会提前 handshake,未授权也可能变成connected。 - 做法 :
items原样透传 ;新增lifecycle字段,按 MCP 名汇总「连接维 + 授权维 + 主状态」。 - 已接入判定 :新产品面(设备页 / Plaud)认
lifecycle.auth === auth_success,不认items.*.status === connected。 - 配套接口 :
GET /workspace/:id/mcp/authorized只返回授权终态(auth_success/auth_failed/auth_denied),供设备列表静默探测。 - 兼容策略 :扩展页连接 store 暂继续读引擎
items,避免被新主状态打乱;后续可增量迁读lifecycle。 - Token 落盘 :接入时动态探测可写凭据根,经 MCP
environment.MCP_REMOTE_CONFIG_DIR注入;proxy-custom.js在{根}/mcp-remote-v1/{hash}_tokens.json原子写入。
2. 背景与要解决的问题
2.1 旧接口能力边界
引擎 items[name].status |
实际含义 | 产品缺口 |
|---|---|---|
connected |
进程握手成功 | Plaud 桥接提前 initialize → 未授权也会 connected |
failed |
通道/进程失败 | 用户拒绝与网络超时无法区分 |
needs_auth |
需要登录 | 授权中 / 拒绝后 / token 已落盘但未连上混在一起 |
disconnected / 空 |
未连 | 磁盘已有 token 时仍像「未接入」 |
2.2 典型误判
| 现象 | 根因 |
|---|---|
| 点「接入账号」立刻显示已接入 | 桥接提前 connected,UI 直接认引擎态 |
| 浏览器授权成功后状态不同步 | token 已落盘,引擎短暂报 Connection closed |
| 拒绝授权后当成普通失败 / 下一轮误判拒绝 | 缺少独立 auth_denied,或残留 *_auth_denied |
2.3 约束
- 不得改写引擎
items结构(旧客户端 / 扩展页仍在用)。 - status 探测必须短超时(授权窗口可能很长,不能卡 10s)。
- 凭据探测只暴露布尔产物,不回传 token 明文。
- 凭据目录可能落在隔离 HOME,也可能残留在宿主 HOME;必须拒绝演示目录污染。
3. 方案对比(待会上确认是否维持)
| 方案 | 做法 | 优点 | 代价 | 建议 |
|---|---|---|---|---|
A. items 透传 + lifecycle 新字段 |
当前实现 | 零破坏;连接/授权可分维 | 调用方要选对字段 | 推荐,已落地 |
B. 直接改写 items.status |
把引擎值改成 authorizing 等 |
前端只读一处 | 扩展页 / SDK / 旧桌面全破坏 | 否决 |
C. 只加 /mcp/authorized |
status 不动 | status 完全兼容 | 授权中无法轮询 | 否决(authorized 作配套保留) |
D. 前端自扫 .mcp-auth |
渲染进程读目录 | 不改 server | 越权、路径不一致、隔离 HOME 会错 | 否决 |
4. 总体架构
#mermaid-svg-PrcCUU7qNQ0HLJal{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-PrcCUU7qNQ0HLJal .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-PrcCUU7qNQ0HLJal .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-PrcCUU7qNQ0HLJal .error-icon{fill:#552222;}#mermaid-svg-PrcCUU7qNQ0HLJal .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-PrcCUU7qNQ0HLJal .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-PrcCUU7qNQ0HLJal .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-PrcCUU7qNQ0HLJal .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-PrcCUU7qNQ0HLJal .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-PrcCUU7qNQ0HLJal .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-PrcCUU7qNQ0HLJal .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-PrcCUU7qNQ0HLJal .marker{fill:#333333;stroke:#333333;}#mermaid-svg-PrcCUU7qNQ0HLJal .marker.cross{stroke:#333333;}#mermaid-svg-PrcCUU7qNQ0HLJal svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-PrcCUU7qNQ0HLJal p{margin:0;}#mermaid-svg-PrcCUU7qNQ0HLJal .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-PrcCUU7qNQ0HLJal .cluster-label text{fill:#333;}#mermaid-svg-PrcCUU7qNQ0HLJal .cluster-label span{color:#333;}#mermaid-svg-PrcCUU7qNQ0HLJal .cluster-label span p{background-color:transparent;}#mermaid-svg-PrcCUU7qNQ0HLJal .label text,#mermaid-svg-PrcCUU7qNQ0HLJal span{fill:#333;color:#333;}#mermaid-svg-PrcCUU7qNQ0HLJal .node rect,#mermaid-svg-PrcCUU7qNQ0HLJal .node circle,#mermaid-svg-PrcCUU7qNQ0HLJal .node ellipse,#mermaid-svg-PrcCUU7qNQ0HLJal .node polygon,#mermaid-svg-PrcCUU7qNQ0HLJal .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-PrcCUU7qNQ0HLJal .rough-node .label text,#mermaid-svg-PrcCUU7qNQ0HLJal .node .label text,#mermaid-svg-PrcCUU7qNQ0HLJal .image-shape .label,#mermaid-svg-PrcCUU7qNQ0HLJal .icon-shape .label{text-anchor:middle;}#mermaid-svg-PrcCUU7qNQ0HLJal .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-PrcCUU7qNQ0HLJal .rough-node .label,#mermaid-svg-PrcCUU7qNQ0HLJal .node .label,#mermaid-svg-PrcCUU7qNQ0HLJal .image-shape .label,#mermaid-svg-PrcCUU7qNQ0HLJal .icon-shape .label{text-align:center;}#mermaid-svg-PrcCUU7qNQ0HLJal .node.clickable{cursor:pointer;}#mermaid-svg-PrcCUU7qNQ0HLJal .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-PrcCUU7qNQ0HLJal .arrowheadPath{fill:#333333;}#mermaid-svg-PrcCUU7qNQ0HLJal .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-PrcCUU7qNQ0HLJal .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-PrcCUU7qNQ0HLJal .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-PrcCUU7qNQ0HLJal .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-PrcCUU7qNQ0HLJal .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-PrcCUU7qNQ0HLJal .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-PrcCUU7qNQ0HLJal .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-PrcCUU7qNQ0HLJal .cluster text{fill:#333;}#mermaid-svg-PrcCUU7qNQ0HLJal .cluster span{color:#333;}#mermaid-svg-PrcCUU7qNQ0HLJal div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-PrcCUU7qNQ0HLJal .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-PrcCUU7qNQ0HLJal rect.text{fill:none;stroke-width:0;}#mermaid-svg-PrcCUU7qNQ0HLJal .icon-shape,#mermaid-svg-PrcCUU7qNQ0HLJal .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-PrcCUU7qNQ0HLJal .icon-shape p,#mermaid-svg-PrcCUU7qNQ0HLJal .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-PrcCUU7qNQ0HLJal .icon-shape .label rect,#mermaid-svg-PrcCUU7qNQ0HLJal .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-PrcCUU7qNQ0HLJal .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-PrcCUU7qNQ0HLJal .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-PrcCUU7qNQ0HLJal :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 调用方
DeepWorks Server
三路输入
items 原样
lifecycle.plaud
终态列表
引擎 mcp.status
runtime + error
mcp-auth.json
pending / authenticated
mcp-remote 凭据目录
tokens / client_info / verifier / auth_denied
resolveMcpLifecycleStatus
GET /mcp/status
GET /mcp/authorized
Plaud 详情 / 设备列表
连接 store / 扩展页
原则:
- 连接看引擎 runtime。
- 授权看磁盘产物 + 引擎拒绝错误。
- 主状态
status给新产品面做总览;细节分别看connection/auth。 - 产品「账号已接入」只认
auth === auth_success。
5. 接口契约
5.1 GET /workspace/:id/mcp/status
- 鉴权:client
- 引擎探测超时:2.5s ;超时则
items = {},lifecycle仍按磁盘计算
响应示例
json
{
"items": {
"plaud": { "status": "connected", "error": null }
},
"lifecycle": {
"plaud": {
"name": "plaud",
"status": "connected",
"connection": "connected",
"auth": "auth_success",
"runtimeStatus": "connected",
"storedAuthState": null,
"remoteArtifacts": {
"hasTokens": true,
"hasClientInfo": true,
"hasCodeVerifier": false,
"hasAuthDenied": false
}
}
}
}
| 字段 | 含义 | 兼容约定 |
|---|---|---|
items |
引擎原始 map | 禁止改写 |
lifecycle[name].status |
汇总主状态 | 新产品面总览 |
lifecycle[name].connection |
连接维 | disconnected / connecting / connected / connect_failed |
lifecycle[name].auth |
授权维 | none / authorizing / auth_success / auth_failed / auth_denied |
remoteArtifacts |
磁盘布尔探测 | 不含密钥内容 |
5.2 GET /workspace/:id/mcp/authorized
从同一套 lifecycle 筛出:
auth_successauth_failedauth_denied
按 name 排序返回 { items: DeepworksMcpLifecycleStatus[] }。
设备列表进入「我的设备」时静默调用;是否展示已接入 仍以 Plaud 轮询到的 auth_success 为准。
5.3 客户端封装
| 方法 | 路径 | 用途 |
|---|---|---|
listMcpStatus(workspaceId) |
/mcp/status |
Plaud 轮询 lifecycle.plaud |
listAuthorizedMcp(workspaceId) |
/mcp/authorized |
设备页静默探测终态 |
6. 状态机
6.1 授权维 auth(判定顺序)
#mermaid-svg-KCbeBcEdBJPxbL5A{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-KCbeBcEdBJPxbL5A .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-KCbeBcEdBJPxbL5A .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-KCbeBcEdBJPxbL5A .error-icon{fill:#552222;}#mermaid-svg-KCbeBcEdBJPxbL5A .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-KCbeBcEdBJPxbL5A .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-KCbeBcEdBJPxbL5A .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-KCbeBcEdBJPxbL5A .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-KCbeBcEdBJPxbL5A .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-KCbeBcEdBJPxbL5A .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-KCbeBcEdBJPxbL5A .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-KCbeBcEdBJPxbL5A .marker{fill:#333333;stroke:#333333;}#mermaid-svg-KCbeBcEdBJPxbL5A .marker.cross{stroke:#333333;}#mermaid-svg-KCbeBcEdBJPxbL5A svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-KCbeBcEdBJPxbL5A p{margin:0;}#mermaid-svg-KCbeBcEdBJPxbL5A .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-KCbeBcEdBJPxbL5A .cluster-label text{fill:#333;}#mermaid-svg-KCbeBcEdBJPxbL5A .cluster-label span{color:#333;}#mermaid-svg-KCbeBcEdBJPxbL5A .cluster-label span p{background-color:transparent;}#mermaid-svg-KCbeBcEdBJPxbL5A .label text,#mermaid-svg-KCbeBcEdBJPxbL5A span{fill:#333;color:#333;}#mermaid-svg-KCbeBcEdBJPxbL5A .node rect,#mermaid-svg-KCbeBcEdBJPxbL5A .node circle,#mermaid-svg-KCbeBcEdBJPxbL5A .node ellipse,#mermaid-svg-KCbeBcEdBJPxbL5A .node polygon,#mermaid-svg-KCbeBcEdBJPxbL5A .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-KCbeBcEdBJPxbL5A .rough-node .label text,#mermaid-svg-KCbeBcEdBJPxbL5A .node .label text,#mermaid-svg-KCbeBcEdBJPxbL5A .image-shape .label,#mermaid-svg-KCbeBcEdBJPxbL5A .icon-shape .label{text-anchor:middle;}#mermaid-svg-KCbeBcEdBJPxbL5A .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-KCbeBcEdBJPxbL5A .rough-node .label,#mermaid-svg-KCbeBcEdBJPxbL5A .node .label,#mermaid-svg-KCbeBcEdBJPxbL5A .image-shape .label,#mermaid-svg-KCbeBcEdBJPxbL5A .icon-shape .label{text-align:center;}#mermaid-svg-KCbeBcEdBJPxbL5A .node.clickable{cursor:pointer;}#mermaid-svg-KCbeBcEdBJPxbL5A .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-KCbeBcEdBJPxbL5A .arrowheadPath{fill:#333333;}#mermaid-svg-KCbeBcEdBJPxbL5A .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-KCbeBcEdBJPxbL5A .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-KCbeBcEdBJPxbL5A .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-KCbeBcEdBJPxbL5A .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-KCbeBcEdBJPxbL5A .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-KCbeBcEdBJPxbL5A .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-KCbeBcEdBJPxbL5A .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-KCbeBcEdBJPxbL5A .cluster text{fill:#333;}#mermaid-svg-KCbeBcEdBJPxbL5A .cluster span{color:#333;}#mermaid-svg-KCbeBcEdBJPxbL5A div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-KCbeBcEdBJPxbL5A .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-KCbeBcEdBJPxbL5A rect.text{fill:none;stroke-width:0;}#mermaid-svg-KCbeBcEdBJPxbL5A .icon-shape,#mermaid-svg-KCbeBcEdBJPxbL5A .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-KCbeBcEdBJPxbL5A .icon-shape p,#mermaid-svg-KCbeBcEdBJPxbL5A .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-KCbeBcEdBJPxbL5A .icon-shape .label rect,#mermaid-svg-KCbeBcEdBJPxbL5A .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-KCbeBcEdBJPxbL5A .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-KCbeBcEdBJPxbL5A .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-KCbeBcEdBJPxbL5A :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
是
否
是
否
是
否
是
否
是
否
是
否
需要 OAuth?
none
runtime = needs_client_registration?
auth_failed
磁盘已有 token?
auth_success
hasAuthDenied?
auth_denied
引擎 error 是用户拒绝?
failed 且已开始授权
且非 Connection closed?
needs_auth 或已开始授权?
authorizing
「已开始授权」= mcp-auth.json 为 pending,或存在 *_client_info.json / code_verifier。
6.2 连接维 connection
| 条件 | connection |
|---|---|
配置禁用或 runtime disabled |
disconnected |
runtime connected |
connected |
runtime failed |
connect_failed |
needs_auth / needs_client_registration / auth=authorizing |
connecting |
| 其它非空 runtime | connecting |
| 无 runtime | disconnected |
6.3 汇总主状态 status(优先级高 → 低)
auth_deniedauth_failedauthorizingconnected(引擎已连上)connect_failed(引擎失败;即使 token 在盘也不用auth_success作主状态)auth_success(token 已落盘、引擎尚未连上)connectingdisconnected
会上强调 :
status=connect_failed且auth=auth_success时,产品仍应显示「已接入」。已接入看auth,不看status。
6.4 场景对照表
| 场景 | 引擎 items | 磁盘 | auth | status | Plaud UI |
|---|---|---|---|---|---|
| 未点接入 | 空 / disconnected | 无产物 | none |
disconnected |
未接入 |
| 已点接入、浏览器未关 | 提前 connected 或 Connection closed |
client_info + verifier | authorizing |
authorizing |
正在接入 |
| 用户点拒绝 | access_denied 或 *_auth_denied |
无 tokens | auth_denied |
auth_denied |
失败,可重试 |
| 授权成功、引擎未稳 | Connection closed |
*_tokens.json |
auth_success |
connect_failed |
已接入 |
| 授权成功且引擎已连 | connected |
tokens | auth_success |
connected |
已接入 |
| 新一轮配置未写完 | 任意 | 旧 tokens | 可能 auth_success |
--- | 轮询 isAuthWatchReady=false 时忽略 |
| 授权中、宿主有旧 denied | authorizing 产物 | 主目录无 denied | authorizing |
authorizing |
继续等 |
7. 数据源与探测规则
7.1 三路输入
| 来源 | 位置 | 用途 |
|---|---|---|
| 引擎 runtime | opencode.mcp.status |
runtimeStatus、error 文本 |
| 引擎 auth 文件 | mcp-auth.json |
remote MCP 的 pending / authenticated |
| mcp-remote 目录 | {MCP_REMOTE_CONFIG_DIR}/mcp-remote-v1/{hash}_* |
local Plaud/proxy 的 token 与拒绝标记 |
Plaud 服务 URL https://mcp.plaud.cn/mcp 对应 hash:68dcbb84de7e44a5910d32034143d806。
7.2 凭据目录优先级
- 配置 env 中可信 的
MCP_REMOTE_CONFIG_DIR - 服务启动快照中可信的 runtime 根
- 托管 Engine HOME 下
.mcp-auth - 宿主 HOME(仅主目录尚无 token、且不处于 authorizing 中间态时补检)
合并规则:
- 已有 tokens →
hasAuthDenied强制false(成功优先) - 主目录已有 client_info / verifier 时,不吃宿主旧
*_auth_denied - 接入 / 解除时清理权威根 + 配置原路径 +
DEEPWORKS_HOST_HOME,避免下次误判
7.3 用户拒绝如何进入 auth_denied
任一即可:
- 引擎 error 匹配
access_denied/user declined/Authorization error等 - 桌面桥接在用户拒绝时写入
{hash}_auth_denied
Connection closed、空 error 不算拒绝 ,授权窗口内保持 authorizing。
7.4 Token 文件落盘路径(Mac / Windows)
路径公式
text
{MCP_REMOTE_CONFIG_DIR}/mcp-remote-v1/{serverUrlHash}_{filename}
| 文件名 | 何时出现 | lifecycle 含义 |
|---|---|---|
{hash}_client_info.json |
动态客户端注册完成 | 授权已开始 |
{hash}_code_verifier_*.txt |
打开授权页前 | 授权已开始 |
{hash}_tokens.json |
换票成功 | auth_success |
{hash}_auth_denied |
用户拒绝(桌面桥接写入) | auth_denied |
Plaud:serverUrl = https://mcp.plaud.cn/mcp →
serverUrlHash = 68dcbb84de7e44a5910d32034143d806
完整 token 文件名:68dcbb84de7e44a5910d32034143d806_tokens.json。
凭据根 {MCP_REMOTE_CONFIG_DIR} 如何选定
接入 Plaud 时,桌面端 ensureWritableMcpAuthDir 按顺序探测第一个可写目录 ,再写入 MCP 配置的 environment.MCP_REMOTE_CONFIG_DIR:
| 优先级 | 候选根(相对 app.getPath("userData") / 宿主 HOME) |
说明 |
|---|---|---|
| 1 | 外部已设且可信 的 MCP_REMOTE_CONFIG_DIR |
演示目录(如 Desktop/test)会被拒绝 |
| 2 | userData/deepworks-dev-data/home/.mcp-auth |
DEEPWORKS_DEV_MODE=1(pnpm dev) |
| 2′ | userData/runtime-home/.mcp-auth |
打包正式/测试包常用隔离 HOME |
| 3 | {宿主 HOME}/.mcp-auth |
隔离目录不可写时的回退 |
| 4 | userData/mcp-auth |
最后回退 |
appId 随发行身份变化,常见值:com.deepexi.deepworks(正式)、com.deepexi.deepworks.test(测试)、带 .dev 的开发标识。
Mac 示例路径
假设用户家目录 /Users/alice,userData 为
/Users/alice/Library/Application Support/com.deepexi.deepworks.test:
| 场景 | token 绝对路径 |
|---|---|
pnpm dev(首选可写) |
/Users/alice/Library/Application Support/com.deepexi.deepworks.test/deepworks-dev-data/home/.mcp-auth/mcp-remote-v1/68dcbb84de7e44a5910d32034143d806_tokens.json |
| 打包 Test 包(隔离 runtime-home) | /Users/alice/Library/Application Support/com.deepexi.deepworks.test/runtime-home/.mcp-auth/mcp-remote-v1/68dcbb84de7e44a5910d32034143d806_tokens.json |
回退到 userData/mcp-auth |
/Users/alice/Library/Application Support/com.deepexi.deepworks.test/mcp-auth/mcp-remote-v1/68dcbb84de7e44a5910d32034143d806_tokens.json |
| 回退到宿主 HOME | /Users/alice/.mcp-auth/mcp-remote-v1/68dcbb84de7e44a5910d32034143d806_tokens.json |
正式包把 com.deepexi.deepworks.test 换成 com.deepexi.deepworks 即可。
Windows 示例路径
假设用户 C:\Users\alice,userData 为
C:\Users\alice\AppData\Roaming\com.deepexi.deepworks.test:
| 场景 | token 绝对路径 |
|---|---|
pnpm dev(首选可写) |
C:\Users\alice\AppData\Roaming\com.deepexi.deepworks.test\deepworks-dev-data\home\.mcp-auth\mcp-remote-v1\68dcbb84de7e44a5910d32034143d806_tokens.json |
| 打包 Test 包(隔离 runtime-home) | C:\Users\alice\AppData\Roaming\com.deepexi.deepworks.test\runtime-home\.mcp-auth\mcp-remote-v1\68dcbb84de7e44a5910d32034143d806_tokens.json |
回退到 userData\mcp-auth |
C:\Users\alice\AppData\Roaming\com.deepexi.deepworks.test\mcp-auth\mcp-remote-v1\68dcbb84de7e44a5910d32034143d806_tokens.json |
| 回退到宿主 HOME | C:\Users\alice\.mcp-auth\mcp-remote-v1\68dcbb84de7e44a5910d32034143d806_tokens.json |
实际以运行时探测到的第一个可写根 为准;排查时先看 MCP 配置里的
environment.MCP_REMOTE_CONFIG_DIR,再进其下的mcp-remote-v1/。
7.5 proxy-custom.js 如何动态写入 token
Plaud 当前走应用内 proxy-custom.js(历史兼容名 proxy.js),不是裸 npx mcp-remote 直连。写入逻辑与 mcp-remote 同源:读 MCP_REMOTE_CONFIG_DIR,按 URL hash 拼文件名。
mcp-remote-v1 目录 proxy-custom.js plaud MCP 配置 桌面 ensureWritableMcpAuthDir Plaud 详情 mcp-remote-v1 目录 proxy-custom.js plaud MCP 配置 桌面 ensureWritableMcpAuthDir Plaud 详情 #mermaid-svg-3CUBGd41wcobi52o{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-3CUBGd41wcobi52o .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-3CUBGd41wcobi52o .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-3CUBGd41wcobi52o .error-icon{fill:#552222;}#mermaid-svg-3CUBGd41wcobi52o .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-3CUBGd41wcobi52o .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-3CUBGd41wcobi52o .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-3CUBGd41wcobi52o .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-3CUBGd41wcobi52o .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-3CUBGd41wcobi52o .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-3CUBGd41wcobi52o .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-3CUBGd41wcobi52o .marker{fill:#333333;stroke:#333333;}#mermaid-svg-3CUBGd41wcobi52o .marker.cross{stroke:#333333;}#mermaid-svg-3CUBGd41wcobi52o svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-3CUBGd41wcobi52o p{margin:0;}#mermaid-svg-3CUBGd41wcobi52o .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3CUBGd41wcobi52o text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-3CUBGd41wcobi52o .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-3CUBGd41wcobi52o .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-3CUBGd41wcobi52o .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-3CUBGd41wcobi52o .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-3CUBGd41wcobi52o #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-3CUBGd41wcobi52o .sequenceNumber{fill:white;}#mermaid-svg-3CUBGd41wcobi52o #sequencenumber{fill:#333;}#mermaid-svg-3CUBGd41wcobi52o #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-3CUBGd41wcobi52o .messageText{fill:#333;stroke:none;}#mermaid-svg-3CUBGd41wcobi52o .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3CUBGd41wcobi52o .labelText,#mermaid-svg-3CUBGd41wcobi52o .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-3CUBGd41wcobi52o .loopText,#mermaid-svg-3CUBGd41wcobi52o .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-3CUBGd41wcobi52o .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-3CUBGd41wcobi52o .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-3CUBGd41wcobi52o .noteText,#mermaid-svg-3CUBGd41wcobi52o .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-3CUBGd41wcobi52o .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3CUBGd41wcobi52o .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3CUBGd41wcobi52o .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3CUBGd41wcobi52o .actorPopupMenu{position:absolute;}#mermaid-svg-3CUBGd41wcobi52o .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-3CUBGd41wcobi52o .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3CUBGd41wcobi52o .actor-man circle,#mermaid-svg-3CUBGd41wcobi52o line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-3CUBGd41wcobi52o :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} process.env.MCP_REMOTE_CONFIG_DIR lifecycle 探测 hasTokens=true → auth_success 探测可写凭据根configDir(动态路径)environment.MCP_REMOTE_CONFIG_DIR = configDirnode proxy-custom.js https://mcp.plaud.cn/mcp 38080getConfigDir() = 根/mcp-remote-v1serverUrlHash = md5(serverUrl|...)写 {hash}_client_info.json写 {hash}code_verifier*.txt浏览器 OAuth 回调换票saveTokens → 原子写 {hash}_tokens.json
1)接入侧:动态注入凭据根
plaudMcpEntry()(详情接入)流程:
ensureWritableMcpAuthDir()按 7.4 优先级找可写根。- 组装 command:
node+ 应用内proxy-custom.js+https://mcp.plaud.cn/mcp+38080。 - 写入 MCP:
json
{
"type": "local",
"command": ["node", "<app>/.../proxy-custom.js", "https://mcp.plaud.cn/mcp", "38080"],
"environment": {
"MCP_REMOTE_CONFIG_DIR": "<探测到的可写根>"
}
}
路径不写死盘符或包名;换用户、换正式/测试包、Mac/Windows 都走同一套探测。
2)proxy 侧:目录与文件名
proxy-custom.js 核心逻辑(与源码一致):
js
// 凭据根:优先环境变量,否则 os.homedir()/.mcp-auth
function getConfigDir() {
const baseConfigDir = process.env.MCP_REMOTE_CONFIG_DIR || path.join(os.homedir(), ".mcp-auth");
return path.join(baseConfigDir, "mcp-remote-v1");
}
// 单文件:{hash}_{filename}
function getConfigFilePath(serverUrlHash, filename) {
return path.join(getConfigDir(), `${serverUrlHash}_${filename}`);
}
// hash:md5(serverUrl | resource | authorizeParams | headers | ...)
function getServerUrlHash(serverUrl, ...) { /* md5 hex */ }
因此只要 MCP 配置注入了正确的 MCP_REMOTE_CONFIG_DIR,token 就会落到 DeepWorks 托管目录,而不是误写到演示路径。
3)换票成功后:saveTokens 原子落盘
OAuth 回调拿到 access_token / refresh_token 后:
saveTokens(tokens)补上expires_at、requested_scope。writeJsonFile(serverUrlHash, "tokens.json", tokensToSave):mkdir确保mcp-remote-v1存在;- 先写临时文件
{tokens.json}.{pid}.{ts}.tmp(mode0600); rename成{hash}_tokens.json(原子替换);- Windows 上 rename 失败时回退为直接
writeFile目标路径。
- 删除本次
code_verifier,并清理过期 verifier 残留。
lifecycle 侧只要探测到该 {hash}_tokens.json 存在(且内容可视为可用),即置 remoteArtifacts.hasTokens = true → auth = auth_success。
接口不回传 token 明文,只回布尔标记。
4)与 /mcp/status 的关系
| 时刻 | 磁盘 | lifecycle.auth |
|---|---|---|
| 已写 client_info / verifier,尚无 tokens | 中间产物 | authorizing |
saveTokens 完成 |
{hash}_tokens.json |
auth_success |
| 用户拒绝且无 tokens | {hash}_auth_denied |
auth_denied |
| 解除账号 / 重新接入前 | clearMcpRemoteAuth 删除同 hash 前缀文件 |
回到 none,避免旧 token 误报成功 |
8. 前端消费约定
GET /mcp/authorized GET /mcp/status 设备列表 Plaud 详情 用户 GET /mcp/authorized GET /mcp/status 设备列表 Plaud 详情 用户 #mermaid-svg-sFffqgYOXIGg8Fya{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-sFffqgYOXIGg8Fya .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-sFffqgYOXIGg8Fya .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-sFffqgYOXIGg8Fya .error-icon{fill:#552222;}#mermaid-svg-sFffqgYOXIGg8Fya .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-sFffqgYOXIGg8Fya .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-sFffqgYOXIGg8Fya .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-sFffqgYOXIGg8Fya .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-sFffqgYOXIGg8Fya .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-sFffqgYOXIGg8Fya .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-sFffqgYOXIGg8Fya .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-sFffqgYOXIGg8Fya .marker{fill:#333333;stroke:#333333;}#mermaid-svg-sFffqgYOXIGg8Fya .marker.cross{stroke:#333333;}#mermaid-svg-sFffqgYOXIGg8Fya svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-sFffqgYOXIGg8Fya p{margin:0;}#mermaid-svg-sFffqgYOXIGg8Fya .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-sFffqgYOXIGg8Fya text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-sFffqgYOXIGg8Fya .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-sFffqgYOXIGg8Fya .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-sFffqgYOXIGg8Fya .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-sFffqgYOXIGg8Fya .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-sFffqgYOXIGg8Fya #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-sFffqgYOXIGg8Fya .sequenceNumber{fill:white;}#mermaid-svg-sFffqgYOXIGg8Fya #sequencenumber{fill:#333;}#mermaid-svg-sFffqgYOXIGg8Fya #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-sFffqgYOXIGg8Fya .messageText{fill:#333;stroke:none;}#mermaid-svg-sFffqgYOXIGg8Fya .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-sFffqgYOXIGg8Fya .labelText,#mermaid-svg-sFffqgYOXIGg8Fya .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-sFffqgYOXIGg8Fya .loopText,#mermaid-svg-sFffqgYOXIGg8Fya .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-sFffqgYOXIGg8Fya .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-sFffqgYOXIGg8Fya .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-sFffqgYOXIGg8Fya .noteText,#mermaid-svg-sFffqgYOXIGg8Fya .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-sFffqgYOXIGg8Fya .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-sFffqgYOXIGg8Fya .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-sFffqgYOXIGg8Fya .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-sFffqgYOXIGg8Fya .actorPopupMenu{position:absolute;}#mermaid-svg-sFffqgYOXIGg8Fya .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-sFffqgYOXIGg8Fya .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-sFffqgYOXIGg8Fya .actor-man circle,#mermaid-svg-sFffqgYOXIGg8Fya line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-sFffqgYOXIGg8Fya :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} loop每 3s 回跳落在设备列表首页,不进详情 点击接入账号清旧 token / 清 auth_denied写 plaud MCP 并 connectlifecycle.plaudauthorizing / auth_success / auth_denied浏览器授权后深链回跳接管轮询auth_success标记已接入 + toast进入列表时静默拉终态
调用约定:
- 新产品面只读
lifecycle。 - 「账号是否已授权」看
auth,不看status,也不看items。 - 「进程是否可用」看
connection或items。 - 不要把
lifecycle.status映射回引擎items再写入连接 store。 - 新一轮 OAuth 前必须清 token 和
*_auth_denied。
9. 影响范围
| 层 | 路径 | 职责 |
|---|---|---|
| 汇总算法 | apps/server/src/mcp.ts |
resolveMcpLifecycleStatus / listAuthorizedMcpLifecycleStatuses |
| 磁盘探测 | apps/server/src/mcp-remote-auth.ts |
hasAuthDenied、可信根、token 优先 |
| HTTP | apps/server/src/server.ts |
/mcp/status、/mcp/authorized |
| 客户端类型 | apps/app/src/app/lib/openwork-server.ts |
DeepworksMcpLifecycleStatus、listMcpStatus |
| Plaud 轮询 | plaud-auth-lifecycle-poll-custom.ts |
3s 拉 lifecycle,终态停表 |
| Plaud UI | plaud-device-detail-custom.tsx、device-discovery-page.tsx |
已接入认 auth_success |
| 拒绝落盘 | plaud-mcp-bridge.mjs、main.mjs |
写 {hash}_auth_denied |
| 测试 | mcp-lifecycle-status.test.ts、mcp-remote-auth.test.ts |
状态机与探测优先级 |
明确不改 :引擎 mcp.status 协议;连接 store 的 items 刷新路径;connect / disconnect 返回体仍是引擎 { items }。
10. 风险与处理
| 风险 | 表现 | 处理 |
|---|---|---|
桥接提前 connected |
未授权显示已接入 | UI 认 auth |
| 引擎探测超时 | 轮询 10s 失败 | status 2.5s 超时,磁盘仍可算态 |
Connection closed |
授权中被标失败 | 短暂断连保持 authorizing |
旧 *_auth_denied |
下一轮立刻失败 | 接入时清标记;token 优先;中间态忽略宿主 denied |
| 演示目录污染 | 永远 authorizing | 只读可信凭据根 |
status=connect_failed + auth=auth_success |
误用 status 当已接入 | 文档与代码统一:已接入看 auth |
| 扩展页混用 lifecycle | 未知状态文案 | 连接 store 暂不迁移 |
11. 验证清单
自动化
bash
cd apps/server && bun test src/mcp-lifecycle-status.test.ts src/mcp-remote-auth.test.ts
必过断言:
needs_auth→authorizing- tokens 在盘、引擎 disconnected →
auth=auth_success - tokens 在盘、引擎 connected →
status=connected,auth=auth_success - tokens 在盘、引擎
Connection closed→auth=auth_success,status=connect_failed access_denied/hasAuthDenied→auth_denied- 授权中
Connection closed→ 仍为authorizing - 主目录 authorizing 时忽略宿主
*_auth_denied /mcp/authorized只留三种终态
手工
- 未接入:
auth=none,设备列表无 Plaud - 点接入未授权:
auth=authorizing,不出现「账号已接入」 - 浏览器拒绝:
auth=auth_denied,可重试 - 浏览器同意回跳:列表首页已接入;详情不误开
- 解除后再接入:不被旧 denied / token 误判
- 扩展页其它 MCP:仍走
items,不受影响
12. 会上待讨论点
- 已接入标准 :是否统一锁定为
auth === auth_success(建议是)。 - 扩展页是否迁读
lifecycle:当前不迁;若迁,是否只对 OAuth MCP 显示授权维。 status=connect_failed+auth=auth_success:产品文案是「已接入(同步中)」还是直接「已接入」。/mcp/authorized是否写入 UI:当前静默;是否后续用于设备列表冷启动恢复已接入态。- 是否把本契约提升为所有 OAuth MCP 的公共规范(Figma remote 等),还是先限定 Plaud / local mcp-remote。
13. 代码指针
| 文件 | 说明 |
|---|---|
apps/server/src/mcp.ts |
lifecycle 状态机 |
apps/server/src/mcp-remote-auth.ts |
凭据目录探测(含 tokens / auth_denied) |
apps/server/src/server.ts |
/mcp/status、/mcp/authorized 路由 |
apps/app/src/app/lib/openwork-server.ts |
客户端类型与 API |
apps/app/src/react-app/domains/plaud-device/plaud-device-detail-custom.tsx |
plaudMcpEntry 注入 MCP_REMOTE_CONFIG_DIR |
apps/desktop/electron/mcp-auth-dir.mjs |
可写凭据根探测与回退 |
apps/desktop/electron/main.mjs |
ensureWritableMcpAuthDir 候选根顺序 |
apps/app/public/deepworks-device-discovery/js/proxy-custom.js |
getConfigDir / saveTokens / 原子写 tokens |
apps/app/src/react-app/domains/plaud-device/plaud-auth-lifecycle-poll-custom.ts |
前端轮询 |
docs/sophon-device/plaud.md |
Plaud 设备行为文档(需与本契约对齐「已接入」定义) |