接入 MCP,不一定要先平台化:一次 AI Runtime 的实战取舍
本文对应项目版本:
v0.0.9
这半年只要聊到 MCP,讨论几乎都会很快滑向同一个方向:
- 怎么做平台
- 怎么做编排
- 怎么管多个 server
- 怎么继续接 Agent
这条路线当然成立,但它有个很容易被跳过的前置问题:
你现在的系统,真的已经准备好接住 MCP 了吗?
如果答案还是模糊的,那“先平台化”很多时候只是把问题往后推。
你会先做出一套看起来很完整的抽象,然后再回头发现,真正难的不是平台长什么样,而是更基础的三件事:
- 现有 Runtime 能不能稳定消费 MCP Tool
- 文件读取这种能力到底该算 Tool 还是 Resource
- 前端能不能把这两类能力真实区分开
我这次做的,就是先不跳到“大而全”的那一步。
我没有单独起一套 MCP Runtime,也没有直接继续做 Agent,而是先做了一件更小、但我觉得更值的事:
把 MCP 当成“能力来源层”,接进现有 Skill Runtime,先证明它能在真实主链里工作。
这篇文章不会把重点放在“我接了几个 server”上,而会重点讲清楚 3 个更实际的问题:
- 为什么我没有先做平台化
- 为什么天气先走 MCP Tool,而文件读取必须升级成 MCP Resource
- 为什么前端从这一步开始必须正式区分 Tool card 和 Resource card
如果你也是下面这些情况,这篇会比较对路:
- 你已经有 Tool Calling / Skill Runtime,正在想 MCP 该怎么进来
- 你刚开始接触 MCP,想先理解它在工程里到底怎么落地
- 你不想先看一堆概念图,而是想看一个真实项目是怎么接进来的
![]()
说明:天气查询仍然展示为 Tool card,读取 README.md 已经展示为 Resource card,前端能直接看出两类能力的区别。
先说结论:MCP 最先改变的,通常不是架构层数,而是能力来源
如果只用一句话概括这次实践,我会写成:
接入 MCP,最先该改变的,不一定是 Runtime 形态,而往往是“能力从哪里来”。
比如在我这个项目里,本来就已经有两类能力:
city-weatherlocal-text-read
从模型视角看,它们只是两个可调用能力。
但从工程视角看,这两个能力其实属于完全不同的两类来源:
- 天气更像“调用一个外部动作”
- 文件读取更像“读取一个受控资源”
一旦开始用 MCP 接这两类能力,你很快就会遇到两个非常真实的问题:
- 模型看到的能力名,要不要跟着 MCP 一起改?
- 文件读取这种能力,到底还该不该继续伪装成 Tool?
这两个问题,远比“要不要先做平台化”更应该优先回答。
如果你第一次接触 MCP,只要先搞清 4 个词就够了
网上关于 MCP 的资料很多,但如果你现在是第一次真正在项目里接它,我建议先不要把自己扔进一整套协议细节里。
先搞懂下面 4 个词,已经足够把这篇读明白。
Host
Host 可以简单理解成:
你自己的应用里,负责连接和消费 MCP server 的那一层。
它不一定是一个巨大的平台,也不一定是一个可视化控制台。
在这个项目里,Host 做的事情很朴素:
- 知道有哪些 MCP server
- 什么时候拉起它们
- 什么时候调用 Tool
- 什么时候读取 Resource
Tool
Tool 是动作型能力。
你给它参数,它帮你执行一次动作,然后返回结果。
比如天气查询就很适合 Tool 语义:
- 输入:城市名
- 输出:当前天气文本
Resource
Resource 是读取型能力。
它不像 Tool 那样强调“执行一次动作”,更像是:
在一个受控边界里,读取一段已经存在的内容。
比如:
- 读取
README.md - 读取
package.json - 读取某个受控 URI 对应的内容
stdio
stdio 可以理解成最小闭环方案。
也就是你的应用直接通过本地进程和 MCP server 通信,而不是一上来就做远程 HTTP、鉴权、编排这些更重的东西。
这次我故意先只做本地 stdio,因为它最适合先验证一件事:
MCP 这套能力来源,能不能被现有主链稳定吃进去。
而且当前这个 Host 也不是额外再造的一层平台,而是基于官方 SDK 接起来的最小消费层。
这版的 MCP SDK 接入方式
很多文章讲 MCP,会把重点放在协议概念上。
但真到接入时,更实际的问题其实是:
你准备用什么方式把 server、client 和 transport 这几层真正落下来?
我这版没有把 SDK 当成一个“顺手一提的依赖”,而是把它放在了 Host 基础层最合适的位置上。
具体来说,当前这条链路是很清楚的:
- server 侧用官方 SDK 的
McpServer和StdioServerTransport暴露能力 - client 侧用官方 SDK 的
Client和StdioClientTransport发起连接 - 中间再用一个
MCPClientManager按serverId复用 client
也就是说,SDK 在这里不是直接跑到业务层里到处被调用,而是被收在一条比较干净的基础层里:
- server 负责声明 MCP 能力
- client 负责连接、初始化、调用和错误收束
- manager 负责复用 client
- adapter 再往上把 MCP 结果翻译成当前 Runtime 认识的 Tool / Resource 结构
这段代码解决的问题是:把这版 MCP 接入里最关键的 server / client / transport 落位方式讲清楚。
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
const client = new Client(MCP_CLIENT_INFO, {
capabilities: MCP_CLIENT_CAPABILITIES,
})
const server = new McpServer({
name: 'weather-server',
version: '0.0.9',
})
这段代码本身不复杂,但它背后有几个很实际的工程判断:
- client、transport、server 都直接走官方公开子路径,后续升级时更容易对照官方文档
-
MCPClient只处理单个 server 的连接、调用和超时,不顺手掺业务语义 -
MCPClientManager只负责按serverId复用 client,避免请求一多就重复拉进程 - 真正跟业务相关的参数映射、错误翻译和结果规整,继续留在 adapter 层
这样做的好处,不是“更标准”这么抽象,而是非常实际:
- 你能很清楚地知道 server 写在哪一层
- 你能很清楚地知道 client 负责什么、不负责什么
- 你后面要接第二个、第三个 MCP server 时,不需要再把主链扒开重写
同时我也把 MCP 代码明确收在服务端 / Node runtime,不往浏览器侧扩。
对这版来说,SDK 这一块真正想解决的不是“怎么秀一套接入技巧”,而是:
先把 server、client、transport 这条基础层收稳,再往上谈平台化、编排和 Agent。
为什么我没有先单独起一套 MCP Runtime
这是这篇最核心的取舍。
很多人一看到 MCP,第一反应是对的:
“这个东西以后肯定会越来越多,那是不是应该先抽一层独立 Runtime?”
问题是,这个判断太早了。
因为如果你现在的项目里,连下面这些问题都还没证明:
- 现有 Tool Runtime 能不能稳定承接 MCP Tool
- 现有消息协议能不能承接 MCP Resource
- 现有前端能不能真实表达 Tool / Resource 差异
- 现有 Skill 边界会不会被 MCP 污染
那你先做出一层新 Runtime,本质上还是在“想象未来需求”。
而我这次更想先证明“今天真实会发生什么”。
所以我有意压住了这几个方向:
- 不做远程 HTTP MCP
- 不做多 server 编排
- 不做 Resource Picker
- 不做 server 状态面板
- 不把
utility-skill一起迁进去 - 不提前进入 Agent
这不是说这些不重要,而是因为这一版更值得验证的,是一个更朴素的问题:
现有系统能不能在不推翻主链的前提下,先把 MCP Tool 和 MCP Resource 接进来。
如果答案是可以,那后面的平台化才是顺势而为。
如果答案是还不行,那先做平台化反而会把问题藏起来。
这次最重要的策略:能力名不变,只替换底层来源
这是我这次最想强调的一点。
接 MCP 的时候,我没有让模型开始学习一堆新名字。
我没有把:
city-weatherlocal-text-read
改成:
get_weatherproject-resource-readresources/read
相反,我刻意让模型继续看到原来的能力名。
也就是说,从模型视角看,一切几乎没变:
- 它还是在调用
city-weather - 它还是在调用
local-text-read
但运行时已经知道,底层来源变了:
-
city-weather实际走weather-server.get_weather -
local-text-read实际走project-files-server.resources/read
为什么这个取舍重要?
因为它能把变化压缩在最合适的一层。
这样一来:
- 模型心智没被打乱
- Skill 边界没被打乱
- 真正发生变化的,是能力来源
这会让你更容易验证问题到底出在哪里。
否则你同一版里同时改:
- 模型看到的能力名
- Skill 分层
- 底层能力来源
- 前端展示方式
最后哪怕效果不好,你也很难判断到底是哪一层出了问题。
天气为什么先走 MCP Tool
如果你想先验证 MCP Tool 主链,天气是一个特别好的切入点。
原因不是它业务价值有多高,而是它特别“像 Tool”:
- 输入参数简单
- 调用动作明确
- 返回结果直观
- 成功失败都容易观察
所以我先做了一个很小的 weather-server,只暴露一个 Tool:
get_weather
然后在项目里继续保留模型熟悉的名字:
city-weather
中间用一层 adapter 做映射。
这段代码解决的问题是:
模型侧保留原有能力名,运行时把它稳定映射到底层 MCP Tool,同时把结果整理成当前主链已经认识的结构。
const WEATHER_SERVER_ID = 'weather-server'
const WEATHER_TOOL_NAME = 'get_weather'
export const weatherToolAdapter: MCPToolAdapter<WeatherToolAdapterInput> = {
async call(input): Promise<MCPToolAdapterResult> {
const response = await mcpClientManager.callTool(WEATHER_SERVER_ID, {
arguments: { city: input.city },
name: WEATHER_TOOL_NAME,
})
const outputText = extractToolText(response.result)
if (response.result.isError) {
throw new MCPHostError('REQUEST_FAILED', outputText || '天气 MCP Tool 调用失败。')
}
return {
action: 'current',
inputText: `city=${input.city}`,
outputText,
serverId: WEATHER_SERVER_ID,
source: 'mcp',
title: 'city-weather',
toolName: 'city-weather',
}
},
}
这里的重点不是“代码能调用成功”,而是 adapter 做了 4 件很关键的事:
- 参数映射
- 错误翻译
- 结果标准化
- 来源信息补齐
这意味着 MCP 原始结构并没有直接漏进主运行时。
主链只知道:
- 这是一次
city-weather - 来源是 MCP
- 来自
weather-server - 已经有了标准化结果
这就是我说的“先改变能力来源,而不是先改 Runtime 形态”。
![]()
说明:展示 city-weather 在用户视角下仍然是原来的能力,但卡片上已经能看到 来源:MCP 和 weather-server。
文件读取为什么不能继续伪装成 Tool
天气这条线解决的是“Tool 怎么接进来”。
文件读取解决的是另一个更重要的问题:
有些能力本质上就不该再被当成 Tool。
以前很多项目做本地文件读取时,会顺手做一个 Tool:
- 输入文件名
- 返回文件内容
这当然能跑,但一旦你开始用 MCP 去理解它,就会发现它的语义其实不太像 Tool,而更像 Resource。
为什么?
因为文件读取更像是在做下面这些事情:
- 读取某个 URI 对应的内容
- 只读,不执行副作用
- 有明确边界
- 适合展示预览
这其实正是 Resource 的典型场景。
所以这次我没有继续让文件读取伪装成“另一个 Tool”。
我做的是:
- 模型侧继续保留
local-text-read - 底层把它转成
project://README.md这样的 Resource URI - 通过
project-files-server.resources/read去读取
而且原来的安全边界全部保留:
- 只允许根目录直接文本文件
- 不允许子目录
- 不允许绝对路径
- 不允许
../ - 非文本文件拒绝
这段代码解决的问题是:
把文件读取从“本地直接访问”升级成“受控 Resource 读取”,同时把预览信息整理成前端能直接消费的结构。
export const projectFileResourceAdapter: MCPResourceAdapter<ProjectFileResourceAdapterInput> = {
async read(input): Promise<MCPResourceAdapterResult> {
const safeFilename = assertSafeRootFilename(input.filename)
const uri = createProjectResourceUri(safeFilename)
const response = await mcpClientManager.readResource(PROJECT_FILES_SERVER_ID, { uri })
const textContent = extractTextContent(response.result)
if (!textContent) {
throw new MCPHostError('REQUEST_FAILED', '项目文件 MCP Resource 没有返回可用文本内容。')
}
return {
content: textContent.text,
contentPreview: createProjectResourcePreview(textContent.text),
previewChars: MAX_PROJECT_RESOURCE_PREVIEW_CHARS,
resourceName: safeFilename,
serverId: PROJECT_FILES_SERVER_ID,
status: 'completed',
uri,
}
},
}
这段代码带来的变化,不只是“读文件换了个通道”,而是整个系统开始正式承认:
- 天气是 Tool
- 文件读取是 Resource
这两类能力不该继续被混成一类。
这也是为什么我会说,文件读取这一步其实比天气更关键。
因为它逼着整个系统第一次认真区分:
什么是动作型能力,什么是读取型能力。
前端为什么必须开始区分 Tool card 和 Resource card
很多后端接入类文章,写到这里就结束了。
但我觉得 MCP 真正开始成立,恰恰是在前端。
因为如果前端还是把所有东西都塞回 Tool card,那 Resource 在产品层面其实根本没有被表达出来。
用户只会感觉:
“哦,又多了一个工具调用卡片。”
但他不会理解系统已经多了一种新的能力类型。
所以这次我没有只改后端,也同步改了流式协议。
这段代码解决的问题是:
给 Resource 一套独立的流式生命周期,而不是继续借 Tool 协议蹭展示。
export interface ResourceStartChunk {
type: 'resource-start'
partId: string
resourceName: string
uri: string
serverId: string
}
export interface ResourceEndChunk {
type: 'resource-end'
partId: string
resourceName: string
uri: string
serverId: string
contentPreview?: string
isTruncated?: boolean
previewChars?: number
}
export interface ResourceErrorChunk {
type: 'resource-error'
partId: string
resourceName: string
uri: string
serverId: string
message: string
}
这三个事件看起来只是多了几个类型,但它们的意义很大:
- Resource 有自己的开始、完成、失败
- 它不是 Tool 的一种特殊状态
- 它应该以另一种 part 进入消息模型
前端接着也按这个语义去消费它。
这段代码解决的问题是:
把 Resource 当成正式消息 part 处理,而不是继续硬塞进 Tool part。
case 'resource-start': {
const messageId = activeStreamRef.current.messageId
if (!messageId) {
return
}
updateMessages(current =>
appendPart(current, messageId, createResourcePart(chunk.partId, chunk.resourceName, chunk.uri, chunk.serverId))
)
return
}
case 'resource-end': {
const messageId = activeStreamRef.current.messageId
if (!messageId) {
return
}
updateMessages(current =>
updateResourcePart(current, messageId, chunk.partId, part => ({
...part,
status: 'completed',
contentPreview: chunk.contentPreview,
isTruncated: chunk.isTruncated,
previewChars: chunk.previewChars,
}))
)
return
}
这一步带来的直接效果是:
- 天气继续显示 Tool card
- 文件读取开始显示 Resource card
- 用户第一次能直观看到两类能力的边界
这不是简单的 UI 小修,而是协议层、消息模型层、产品表达层一起升级。
![]()
说明:展示资源名称、URI、serverId、状态、内容预览,以及它和 Tool card 的区别。
为什么是 reader-skill 在承接 MCP,而不是别的层
如果你已经有 Tool Runtime,又想开始接 MCP,很容易冒出一个问题:
“要不要新起一个 mcp-skill?”
我这次没有这么做。
原因很简单:
这版里需要接入 MCP 的两类能力,本来就属于 reader-skill 的边界:
- 天气查询
- 文件读取
它们的共同点不是“都是 MCP”,而是“都是外部上下文获取”。
也就是说,真正稳定的边界不是 MCP,而是 reader-skill 本身。
这也是我为什么一直觉得,Skill / MCP / Agent 这几层不要轻易混:
- Tool 是原子能力
- Skill 是能力模式
- MCP 是能力来源通道
- Agent 才是计划与继续决策
如果一接 MCP 就先做一个 mcp-skill,很容易把“能力来源”错误地提升成“能力模式”。
但实际上,在这次实践里更自然的做法是:
- 继续保留原有 Skill 边界
- 只替换它底层消耗的能力来源
这样好处很明显:
- 普通聊天主链不被污染
-
utility-skill不被连带改造 -
reader-skill反而变得更有解释力
因为从这一步开始,reader-skill 不再只是“一个能读文件、查天气的 Skill”,而是:
第一个正式承接 MCP 能力来源的 Skill。
这次实践真正证明了什么
如果把“项目支持 MCP 了”这种大而泛的说法放一边,这次实践真正证明的其实是下面几件更具体的事。
1. 现有 Runtime 不重做,也能接入 MCP
这很关键。
因为它说明现有主链不是必须推翻重来,MCP 可以先作为能力来源层进入现有系统,而且 SDK 的落位方式也可以先收稳。
2. 能力名不变,只换底层来源,是非常有效的过渡策略
这能最大程度保持模型心智稳定,也能更清楚地定位问题发生在哪一层。
3. 文件读取一旦升级成 Resource,整个系统的分层会明显变清楚
这一步不只是“换个 API”,而是在认真区分:
- 什么是 Tool
- 什么是 Resource
4. 前端是否区分 Tool / Resource,决定了这次接入是不是“真的成立”
如果前端不区分,MCP Resource 在产品层面就还是半成品。
5. 现在还没必要急着做 Agent
因为当前更值得先收稳的是:
- MCP Tool / Resource 的真实接入边界
-
reader-skill的承接方式 - 协议和前端表达是不是已经站住
如果这些问题都还没收住,就急着往 Agent 走,很容易把“能力接入问题”和“任务调度问题”混在一起。
最后
如果你现在也在做 MCP 接入,我会很推荐先问自己一个问题:
我现在真正缺的,是一套平台,还是一条能被主链真实验证的接入路径?
这两个答案,最后导向的实现方式会完全不同。
对这个项目来说,这次更合适的答案是后者。
所以我没有先做平台化,而是先把 MCP 放进一个真实会被用到的地方:
- 天气走 MCP Tool
- 文件读取走 MCP Resource
-
reader-skill成为第一层承载层 - 前端正式区分 Tool card 和 Resource card
这条路听起来不激进,但它非常扎实。
因为从这一版开始,MCP 不再只是“以后可能会接”的方向,而是已经进入当前系统主链、真正开始工作的能力来源层。
项目地址
GitHub: github.com/HWYD/ai-min…
如果这篇文章或这个项目对你有帮助,欢迎到仓库里看看,也欢迎顺手点个 Star。
后面我会继续沿着 Skill -> MCP -> Agent 这条线,把这个 Runtime Skeleton 一版一版往前推进。