dsh 插件开发学习

29-接入 MCP 服务

读完这篇你能:知道接一台 MCP 服务器其实不用写代码;搞清工具名是怎么拼出来的、为什么它一改就会让会话历史与权限规则失效;以及连不上、断线、工具列表冲突这几种情况各自会怎样呈现。

前置:28-定时任务与目标推进。约 25 分钟。

示例代码:dsh-mcp-lab

DSH 版本:0.2.0-rc.2

从这一篇开始走出本机。第一个要接的东西是 MCP——模型上下文协议,它做的事情很朴素:让模型把别的程序提供的工具当成自己的工具来调。

公司内部的日志查询、工单系统、知识库,只要那边愿意提供一个 MCP 服务器,这边就不用为它写任何插件代码。

目标

  • 会写一台 MCP 服务器的最小配置,并用环境变量注入凭据。
  • 理解工具名的拼法,以及为什么服务器名是个要慎重的字段。
  • 预判几种失败:连不上、断线、工具列表冲突。

DSH 设计

MCP(Model Context Protocol,模型上下文协议)是一套行业通行的协议:一个外部程序按它暴露自己的工具,模型客户端按它去发现并调用。DSH 接一台 MCP 服务器,靠的是三层现成的设计,一层一层看。

第一层在模型面前:那里只有一个工具注册表,外部工具没有第二条通道。DSH 进程里有一个统一的工具注册表,每个工具的完整定义都注册在这里,对话循环按这里分派每一次调用,模型每轮看到的工具清单也由这里按显式允许列表生成。接入层是一个叫 dsh-mcp-client 的插件,起桥接作用,它做的事只是把远端服务器上发现的工具,按同一种定义形态注册进这张表。注册之后,超时、取消、执行管线、会话历史对它们和本机原生工具一视同仁。所以「接服务器」不需要写任何插件代码:要声明的只有「去哪发现工具」——服务器名字加传输方式,一条配置就是全部。

第二层是命名,原则是:工具名的前缀是本地承诺,不是远端自报。每个 MCP 工具在模型面前的名字形如「mcp__ 加服务器名加工具原名」,其中服务器名取自本地配置,绝不采用远端自报的名字。为什么信不过远端?它是另一个进程,报的名字不可信、部署之间不唯一、升级时可能变化,任何一条成立都会让模型面前的工具被悄悄改名。而 DSH 的会话历史与权限规则都按工具名记账,名字一变,过去的调用记录和授权规则就全部对不上。一个具体的样子:

本地配置 serverName: github,远端工具原名 create_issue
→ 模型面前的名字:mcp__github__create_issue
→ 调回服务器时只带原名 create_issue,公开名字绝不发回远端

第三层是面对不可信远端时的交换姿态:要么完整一代,要么没有。远端可能迟到、截断、自相矛盾,工具列表因此按代整体交换。获取失败就保留上一代工具继续可用;新列表里同一工具出现两次、或与已有名字冲突,整次更新回滚,绝不部分接受一份可疑列表。部分接受等于让模型的能力集落进一个谁也说不清的中间态。同一条姿态延伸到失败呈现:服务器报告错误,调用就明确失败,模型绝不会看到虚假的成功。

mermaid 图表

远端不可信、本地要稳定——桥接层的每条规则,都可以看成这条边界上的一次具体取舍。

一条配置项接一台服务器

每台服务器一条配置项,这就是全部设置。 给它一个简短的唯一名称和一种传输方式,它的工具就会出现在模型的工具列表里。这些行是新增的,所以要放进 patch 的 insert 列表里(03-用 patch 覆盖 DSH 的默认配置讲过:没有 insert 的行是「按 id 覆盖已有行」,id 不存在会被跳过):

- insert:
    - id: mcp-github
      name: '@deepseek-ai/dsh-mcp-client'
      config:
        serverName: github
        transport: stdio
        command: npx
        args: ['-y', '@modelcontextprotocol/server-github']
        env:
          GITHUB_TOKEN: !!js process.env.GITHUB_TOKEN

    - id: mcp-web
      name: '@deepseek-ai/dsh-mcp-client'
      config:
        serverName: web
        transport: streamable-http
        url: http://localhost:3000/mcp
        headers:
          Authorization: !!js '`Bearer ${process.env.MCP_TOKEN}`'

两种传输对应两种部署形态:服务器作为本地程序跑就用 stdio(配 command / args / env / cwd),作为服务跑就用 streamable-http(配 url / headers)。

mermaid 图表

凭据一律用 !!js 从环境变量取,别写进文件——这也和 03-用 patch 覆盖 DSH 的默认配置讲的规则一致:只有行的 config 与 disabled 允许 !!js,其它元数据字段会保持静态。

几个值得先知道其默认值的字段:

字段 默认值 含义
toolCallTimeoutMs 60,000 每次 tools/call 或资源请求的超时
failOnStartupError false 初始连接或工具同步失败时,是否拒绝插件激活
reconnect.enabled true 连接丢失后是否自动重连
reconnect.initialDelayMs 500 首次重连延迟,每次连续失败尝试翻倍
reconnect.maxDelayMs 30,000 退避上限;同时是重置尝试预算所需的正常运行时长
reconnect.maxAttempts 10 每次中断内连续失败尝试上限,超出后放弃

failOnStartupError 默认是 false,这个默认值有讲究,下面会讲。

工具命名与共存

模型看到的每个工具都带着稳定的服务器限定名:

mcp__<serverName>__<rawName>

比如 mcp__github__create_issue。这个形态和 Claude Code、Codex 用的是同一套。

“稳定”这两个字很关键:只要服务器保持相同的工具名,这个名字就不变,所以会话历史与权限规则在重启和重载之后仍然有效。反过来说——serverName 改一个字,所有工具名都变了,历史里那些调用记录就对不上了。所以起名要慎重,它是个一次性的兼容性承诺。

因为这个前缀,两个服务器可以同时提供一个叫 search 的工具,分别以 mcp__github__search 和 mcp__web__search 共存,互不影响。

而 tools/list 的变化被处理得很严格。有一条总原则:宁可保持上一组工具可用,也不接受一份可疑的新列表。 具体表现:

mermaid 图表
  • 服务器在工具列表里两次列出同一工具 → 该列表被作为无效列表拒绝,上一组保持可用。
  • 发现分页由官方 SDK 负责,页数也有上限;发现失败会保留上一组工具,格式错误的游标链按 SDK 的行为处理——这一层没有插件自己的判定。
  • 工具更新与已有工具名冲突 → 该更新被整体拒绝——绝不会得到该服务器的部分工具集。

最后一条是重点:它不做”能加的加上、冲突的跳过”这种部分接受。一份工具列表要么整体生效,要么整体不生效。

调用与结果

模型调用 MCP 工具时,调用以每次调用的超时(默认 60 秒)发往远端,并且像其他工具调用一样可以取消——这两条合起来才是完整的:光有超时不够,用户打断时得真能停下来。

结果的呈现方式:

  • 按块顺序以普通文本返回;资源链接以文本形式显示名称与 URI。
  • 服务器报告错误时,调用明确失败——模型不会看到虚假的成功。

这一条是整个接入里最要紧的纪律。工具调用如果能把失败伪装成成功,模型就会基于假信息继续往下推理,错误会滚雪球。

图片的处理也有条件:当前模型接受图片输入、并且 harness 启用了附件功能时才支持。条件不满足时——以及服务器返回音频或嵌入资源时——模型会看到一条清晰的诊断消息,而不是什么都没有。

“而不是什么都没有”是刻意的:静默丢弃会让模型完全不知道自己漏看了东西。

连不上与断开

两个阶段,两种默认行为。

mermaid 图表

启动时连不上:默认 failOnStartupError: false,也就是 harness 照常启动,只是这台服务器的工具不会出现,并记录一条错误。想让”连不上”变成”别启动”,把它设成 true。

这个默认值是合理的:一台次要的服务器不应该把整个 harness 拖垮。但它也意味着工具静默消失是可能的——所以那条错误日志是你排查时的第一站。

运行中断开(比如本地服务器进程崩了):插件会以 500 ms 起步、逐次翻倍、上限 30 秒的延迟自动重连并刷新工具集,重连进度在日志里可见。中断期间有个反直觉的表现:

最后已知的工具仍会列出,但对它们的调用会失败,直到服务器恢复。

为什么留着这些工具而不是立刻摘掉?因为服务器往往是短暂重启——留着能让模型看到”这能力本来存在”,调用失败的具体报错也能说明问题;立刻摘掉反而让模型以为”这台服务器从来没接过”。

连续失败十次后,该服务器的工具会被移除、重连停止,直到你重载配置或重启 harness。而连接持续超过 maxDelayMs(默认 30 秒)之后,这个计数会重置——所以偶发的抖动不会累积成”永久放弃”。

另外,reconnect.enabled: false 可以关掉自动重连,此时工具在断开后仍然列出、调用失败,直到你重载。

最后一条很实用:编辑配置项会在原地重载服务器连接,未变的名称保持不变。也就是说你可以改 args、改凭据、换 URL,而不必重启整个 harness。

接上公司内部系统

demo dsh-mcp-lab 是一个配置包——注意它没有 src/,因为这件事真的不需要写代码。包里的两行配置刻意用了两种传输:

- insert:
    - id: mcp-github
      name: '@deepseek-ai/dsh-mcp-client'
      config:
        # 工具会以 mcp__github__<工具名> 出现,所以这个名字要短、要稳。
        # 它同时是工具名前缀,改名等于改工具名,会话历史与权限规则会跟着失效。
        serverName: github
        transport: stdio
        command: npx
        args: ['-y', '@modelcontextprotocol/server-github']
        env:
          GITHUB_TOKEN: !!js process.env.GITHUB_TOKEN
        failOnStartupError: false

换成你自己的服务器,要动的就是这几个字段:serverName(工具名前缀,短且稳)、transport(本地程序 stdio / 远端服务 streamable-http)、以及对应的 command+args+env 或 url+headers。

如果是公司内部系统的日志或工单接口,通常的做法是:那边先包一层 MCP 服务器(本地跑就 stdio,部署成服务就 streamable-http),这边只加一条配置行。这一层包装的收益是双向的——你这边零代码,那边也可以用别的 MCP 客户端复用同一个服务器。

验证

cd code/dsh_plugin/dsh-mcp-lab && ./install.sh

三步:

# 1. 配置对不对
dsh --profile test --dump-config | grep -A6 'id: mcp-github'

# 2. 看工具列表里有没有这台服务器的工具
dsh --profile test "列一下你现在能用哪些工具,只看 mcp__ 开头的"

# 3. 让模型真的调一个
dsh --profile test "用 mcp__github__ 开头的工具查一下某条 issue"

然后亲手试一下那个默认值:把 command 故意改成一个不存在的程序。你会看到 harness 照常启动,只是这台服务器的工具不出现,日志里有一条错误。再把 failOnStartupError 改成 true,启动就直接中止了——两行配置,两种态度。

注意事项

  • serverName 是兼容性承诺。 它决定了所有工具名的前缀,改名等于改工具名,历史记录与权限规则会对不上。起名要短、要稳。
  • 一个 serverName 只能有一条配置项。 两条用同一个名字会在加载时以明确错误失败——这不是”后盖前”,是直接拒绝。
  • 工具列表要么整体生效,要么整体不生效。 冲突、重复工具、重复游标都导致整次更新被拒,上一组保持可用。别指望拿到”部分可用”的中间态。
  • 工具会在首个轮次之前出现。 服务器工具不是”用到才加载”,harness 开始对话前它就已经在列表里了。
  • 调用失败不会被伪装成成功。 服务器报错就是明确失败。这条对模型的行为影响很大,别去绕过它。
  • 图片支持是有条件的。 模型得接受图片输入、harness 得启用附件;条件不满足时给的是诊断消息。音频与嵌入资源同理——有提示,不会静默丢弃。
  • 断开后工具还列着,但调用会失败。 这是”服务器通常在短暂重启”的场景优化,不是 bug。连续失败十次才摘掉,且连接稳定超过 maxDelayMs 后计数重置。
  • failOnStartupError: false 意味着工具可能静默消失。 只有一条错误日志。定期看一眼日志,比事后奇怪”怎么没有那个工具”划算。
  • 改配置项是原地重载。 换凭据、换 URL、调 args 都不必重启 harness,未变的工具名保持不变。
  • 凭据走 !!js 从环境变量取。 只有 config 与行的 disabled 允许 !!js;别把它当成可以到处写的模板语言。