接入 MCP,不一定要先平台化:一次 AI Runtime 的实战取舍

0 阅读16分钟

本文对应项目版本:v0.0.9

这半年只要聊到 MCP,讨论几乎都会很快滑向同一个方向:

  • 怎么做平台
  • 怎么做编排
  • 怎么管多个 server
  • 怎么继续接 Agent

这条路线当然成立,但它有个很容易被跳过的前置问题:

你现在的系统,真的已经准备好接住 MCP 了吗?

如果答案还是模糊的,那“先平台化”很多时候只是把问题往后推。

你会先做出一套看起来很完整的抽象,然后再回头发现,真正难的不是平台长什么样,而是更基础的三件事:

  • 现有 Runtime 能不能稳定消费 MCP Tool
  • 文件读取这种能力到底该算 Tool 还是 Resource
  • 前端能不能把这两类能力真实区分开

我这次做的,就是先不跳到“大而全”的那一步。

我没有单独起一套 MCP Runtime,也没有直接继续做 Agent,而是先做了一件更小、但我觉得更值的事:

把 MCP 当成“能力来源层”,接进现有 Skill Runtime,先证明它能在真实主链里工作。

这篇文章不会把重点放在“我接了几个 server”上,而会重点讲清楚 3 个更实际的问题:

  1. 为什么我没有先做平台化
  2. 为什么天气先走 MCP Tool,而文件读取必须升级成 MCP Resource
  3. 为什么前端从这一步开始必须正式区分 Tool card 和 Resource card

如果你也是下面这些情况,这篇会比较对路:

  • 你已经有 Tool Calling / Skill Runtime,正在想 MCP 该怎么进来
  • 你刚开始接触 MCP,想先理解它在工程里到底怎么落地
  • 你不想先看一堆概念图,而是想看一个真实项目是怎么接进来的

mcp-1.gif

说明:天气查询仍然展示为 Tool card,读取 README.md 已经展示为 Resource card,前端能直接看出两类能力的区别。

先说结论:MCP 最先改变的,通常不是架构层数,而是能力来源

如果只用一句话概括这次实践,我会写成:

接入 MCP,最先该改变的,不一定是 Runtime 形态,而往往是“能力从哪里来”。

比如在我这个项目里,本来就已经有两类能力:

  • city-weather
  • local-text-read

从模型视角看,它们只是两个可调用能力。

但从工程视角看,这两个能力其实属于完全不同的两类来源:

  • 天气更像“调用一个外部动作”
  • 文件读取更像“读取一个受控资源”

一旦开始用 MCP 接这两类能力,你很快就会遇到两个非常真实的问题:

  1. 模型看到的能力名,要不要跟着 MCP 一起改?
  2. 文件读取这种能力,到底还该不该继续伪装成 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 的 McpServerStdioServerTransport 暴露能力
  • client 侧用官方 SDK 的 ClientStdioClientTransport 发起连接
  • 中间再用一个 MCPClientManagerserverId 复用 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-weather
  • local-text-read

改成:

  • get_weather
  • project-resource-read
  • resources/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 形态”。

mcp-weather.png

说明:展示 city-weather 在用户视角下仍然是原来的能力,但卡片上已经能看到 来源:MCPweather-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 小修,而是协议层、消息模型层、产品表达层一起升级。

mcp-resource.png

说明:展示资源名称、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 一版一版往前推进。