读完这篇你能:知道接一台 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,公开名字绝不发回远端
第三层是面对不可信远端时的交换姿态:要么完整一代,要么没有。远端可能迟到、截断、自相矛盾,工具列表因此按代整体交换。获取失败就保留上一代工具继续可用;新列表里同一工具出现两次、或与已有名字冲突,整次更新回滚,绝不部分接受一份可疑列表。部分接受等于让模型的能力集落进一个谁也说不清的中间态。同一条姿态延伸到失败呈现:服务器报告错误,调用就明确失败,模型绝不会看到虚假的成功。
远端不可信、本地要稳定——桥接层的每条规则,都可以看成这条边界上的一次具体取舍。
一条配置项接一台服务器
每台服务器一条配置项,这就是全部设置。 给它一个简短的唯一名称和一种传输方式,它的工具就会出现在模型的工具列表里。这些行是新增的,所以要放进 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)。
凭据一律用 !!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 的变化被处理得很严格。有一条总原则:宁可保持上一组工具可用,也不接受一份可疑的新列表。 具体表现:
- 服务器在工具列表里两次列出同一工具 → 该列表被作为无效列表拒绝,上一组保持可用。
- 发现分页由官方 SDK 负责,页数也有上限;发现失败会保留上一组工具,格式错误的游标链按 SDK 的行为处理——这一层没有插件自己的判定。
- 工具更新与已有工具名冲突 → 该更新被整体拒绝——绝不会得到该服务器的部分工具集。
最后一条是重点:它不做”能加的加上、冲突的跳过”这种部分接受。一份工具列表要么整体生效,要么整体不生效。
调用与结果
模型调用 MCP 工具时,调用以每次调用的超时(默认 60 秒)发往远端,并且像其他工具调用一样可以取消——这两条合起来才是完整的:光有超时不够,用户打断时得真能停下来。
结果的呈现方式:
- 按块顺序以普通文本返回;资源链接以文本形式显示名称与 URI。
- 服务器报告错误时,调用明确失败——模型不会看到虚假的成功。
这一条是整个接入里最要紧的纪律。工具调用如果能把失败伪装成成功,模型就会基于假信息继续往下推理,错误会滚雪球。
图片的处理也有条件:当前模型接受图片输入、并且 harness 启用了附件功能时才支持。条件不满足时——以及服务器返回音频或嵌入资源时——模型会看到一条清晰的诊断消息,而不是什么都没有。
“而不是什么都没有”是刻意的:静默丢弃会让模型完全不知道自己漏看了东西。
连不上与断开
两个阶段,两种默认行为。
启动时连不上:默认 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;别把它当成可以到处写的模板语言。