MCP `/mcp/status` Lifecycle 技术方案

MCP /mcp/status Lifecycle 技术方案

会议讨论稿。说明 DeepWorks Server 在 GET /workspace/:id/mcp/status 中新增 lifecycle 字段的背景、契约、状态机与前后端消费约定。

产品侧名称统一为 DeepWorks;文中 OpenCode / Engine 仅作运行时兼容标识。

1. 结论摘要(会上先对齐)

  1. 问题 :引擎原始 items 只描述进程 runtime,无法表达 OAuth「授权中 / 成功 / 失败 / 用户拒绝」。Plaud 桥接会提前 handshake,未授权也可能变成 connected。
  2. 做法 :items 原样透传 ;新增 lifecycle 字段,按 MCP 名汇总「连接维 + 授权维 + 主状态」。
  3. 已接入判定 :新产品面(设备页 / Plaud)认 lifecycle.auth === auth_success,不认 items.*.status === connected。
  4. 配套接口 :GET /workspace/:id/mcp/authorized 只返回授权终态(auth_success / auth_failed / auth_denied),供设备列表静默探测。
  5. 兼容策略 :扩展页连接 store 暂继续读引擎 items,避免被新主状态打乱;后续可增量迁读 lifecycle。
  6. 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_success
  • auth_failed
  • auth_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(优先级高 → 低)

  1. auth_denied
  2. auth_failed
  3. authorizing
  4. connected(引擎已连上)
  5. connect_failed(引擎失败;即使 token 在盘也不用 auth_success 作主状态)
  6. auth_success(token 已落盘、引擎尚未连上)
  7. connecting
  8. disconnected

会上强调 :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 凭据目录优先级

  1. 配置 env 中可信 的 MCP_REMOTE_CONFIG_DIR
  2. 服务启动快照中可信的 runtime 根
  3. 托管 Engine HOME 下 .mcp-auth
  4. 宿主 HOME(仅主目录尚无 token、且不处于 authorizing 中间态时补检)

合并规则:

  • 已有 tokens → hasAuthDenied 强制 false(成功优先)
  • 主目录已有 client_info / verifier 时,不吃宿主旧 *_auth_denied
  • 接入 / 解除时清理权威根 + 配置原路径 + DEEPWORKS_HOST_HOME,避免下次误判

7.3 用户拒绝如何进入 auth_denied

任一即可:

  1. 引擎 error 匹配 access_denied / user declined / Authorization error 等
  2. 桌面桥接在用户拒绝时写入 {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()(详情接入)流程:

  1. ensureWritableMcpAuthDir() 按 7.4 优先级找可写根。
  2. 组装 command:node + 应用内 proxy-custom.js + https://mcp.plaud.cn/mcp + 38080。
  3. 写入 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 后:

  1. saveTokens(tokens) 补上 expires_at、requested_scope。
  2. writeJsonFile(serverUrlHash, "tokens.json", tokensToSave):
    • mkdir 确保 mcp-remote-v1 存在;
    • 先写临时文件 {tokens.json}.{pid}.{ts}.tmp(mode 0600);
    • rename 成 {hash}_tokens.json(原子替换);
    • Windows 上 rename 失败时回退为直接 writeFile 目标路径。
  3. 删除本次 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进入列表时静默拉终态

调用约定:

  1. 新产品面只读 lifecycle。
  2. 「账号是否已授权」看 auth,不看 status,也不看 items。
  3. 「进程是否可用」看 connection 或 items。
  4. 不要把 lifecycle.status 映射回引擎 items 再写入连接 store。
  5. 新一轮 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 只留三种终态

手工

  1. 未接入:auth=none,设备列表无 Plaud
  2. 点接入未授权:auth=authorizing,不出现「账号已接入」
  3. 浏览器拒绝:auth=auth_denied,可重试
  4. 浏览器同意回跳:列表首页已接入;详情不误开
  5. 解除后再接入:不被旧 denied / token 误判
  6. 扩展页其它 MCP:仍走 items,不受影响

12. 会上待讨论点

  1. 已接入标准 :是否统一锁定为 auth === auth_success(建议是)。
  2. 扩展页是否迁读 lifecycle:当前不迁;若迁,是否只对 OAuth MCP 显示授权维。
  3. status=connect_failed + auth=auth_success:产品文案是「已接入(同步中)」还是直接「已接入」。
  4. /mcp/authorized 是否写入 UI:当前静默;是否后续用于设备列表冷启动恢复已接入态。
  5. 是否把本契约提升为所有 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 设备行为文档(需与本契约对齐「已接入」定义)
相关推荐
杨运交1 小时前
086-对象存储模块-基于Spring Boot的文件存储模块监控设计与实现
java·spring boot·后端
Umi·1 小时前
MySQL之DQL使用实例
数据库·mysql
曦夜日长2 小时前
主从 Reactor 高并发服务器实战 (三):服务器套接字Socket模块详解与实现
运维·服务器·网络
Wang's Blog2 小时前
Java 项目实战:交友项目 - 我的访客:需求说明
java·开发语言·交友
牧竹子2 小时前
当前支持向量的主流数据库情况对比
数据库
云析赢指标公式网42 小时前
文华财经指标源码笔记:WINNER筹码函数、控盘变量与MACD变体的调试记录
java·笔记·算法
老约家的可汗3 小时前
Linux开发三件套:make、git、gdb 入门指南
linux·运维·服务器
从琳开始9893 小时前
从Socket到TCP回显服务器:Java网络编程完整指南
java·服务器·网络·tcp/ip·java-ee
养军博客3 小时前
Spring Security 4.x / 5.x / 6.x 配置全对比:前后端不分离与前后端分离
java·后端·spring·springsecurity