本文对应项目版本:
v0.0.11
接入 MCP 之后,下一步是不是应该马上做 Agent?
这是我在 AI Mind 本版本里反复提醒自己的问题。
先简单介绍一下项目背景。AI Mind 是一个按版本持续演进的 AI Native Runtime Skeleton,不是一次性做完的 AI 产品。它从本地聊天闭环开始,逐步长出结构化流式协议、Tool Calling、Multi-Tool Runtime、Skill Runtime、MCP 接入,以及后续会继续推进的 Agent / 数据层能力。
到前一版本为止,项目里已经有了一条比较稳定的聊天主链:请求从 /api/chat 进入,经过 chat-service(聊天服务 facade,负责对外暴露稳定入口)和 runtime(聊天主链编排层,负责规划、工具执行和最终回答生成),再通过 @ai-mind/stream-core(流式协议、生命周期和 writer 内核)返回前端可消费的流式 chunk。
前面版本已经完成基础 MCP 接入,天气走 MCP Tool,文件读取升级成 MCP Resource。照这个节奏继续往下,很容易产生一个冲动:既然外部能力已经接进来了,那是不是该让模型自己决定下一步调用什么?
我最后把重心放在了更靠前的一层:先让能力面变得清楚、稳定、可消费。
本版本的主题不是 Agent,也不是 Remote MCP 教程,而是补了一层更基础的东西:Capability Surface(能力表面,用来描述能力、约束 Skill 承接范围,并把能力消费事实带进 Runtime)。
为了让第一次看到这个项目的读者也能顺着看下去,这里先把几个词放在当前项目语境里:MCP 负责把外部能力接进来,Tool 是可执行动作,Resource 是可读取上下文,Prompt 是可注入模型的任务消息,Skill 则负责承接某类稳定任务模式。
Capability Surface 可以先理解成一层“能力名片”:它先说清楚一个能力是谁、来自哪里、在本地还是远端、当前能不能用、哪个 Skill 可以承接。至于这个能力最终怎么执行,仍然交给 Tool / Resource / Prompt 各自的运行链路。
我更想复盘的是:在一个真实 AI Runtime 项目里,接入 MCP 之后、走向更完整的计划与执行之前,为什么需要先把能力表面收清楚。
它解决的问题是:当系统里同时存在 Tool、Resource、Prompt、本地 MCP、远端 MCP、Skill 路由和前端执行事实时,Skill 到底应该用什么方式稳定理解这些能力,并让 Runtime 安全地消费它们?
先看结论
MCP 接入解决的是“能力怎么接进来”,Capability Surface 解决的是“这些能力如何被描述、被 Skill 承接、被 Runtime 消费、被前端承接”。
本版本我先把 Tool / Resource / Prompt 收成统一的能力描述层,再让
reader-skill(文档读取、项目上下文和外部信息查询类 Skill)消费本版本固定的 local / remote MCP capability。
如果把这件事压成一条链路,大概是这样:
用户问题
-> Skill(reader-skill)
-> capabilitySelectors(能力选择范围)
-> Tool / Resource / Prompt(三类能力)
-> Runtime 消费
-> 前端消息 part(执行事实卡片)
1. 为什么接入 MCP 后,我先补能力表面
接入 MCP 之后继续往上做调度,看起来很自然。
因为这时候系统已经有了外部能力来源:MCP server 可以暴露 Tool,可以暴露 Resource,也可以暴露 Prompt。再往上一层,好像就该让运行时自己规划:先读哪个资源,再拿哪个 Prompt,再调用哪个 Tool。
但在我这个阶段,这一步太早了。
问题不在于更高层调度不重要,而是它之前还有一个更基础的问题没有解决:
- 系统里的能力是否能被统一描述?
- Skill 是否知道自己可以承接哪些能力?
- Prompt 是否已经被当成一等能力,而不是塞进工具链里的附属品?
- 前端消息模型是否能承接能力执行事实,而不只是最终答案?
- Runtime 是否真的消费了 capability metadata,而不是只把它当展示文案?
如果这些问题没有先收住,上层调度很容易变成一个过早的总入口:它看起来什么都能管,但底下的能力边界、错误语义、前端表达都还没清楚。
所以本版本我选择先补 Capability Surface。
这个名字听起来有点抽象,但在项目里它很具体:它是一层让 Skill 和 Runtime 能稳定理解能力对象的表面。它不替代 Tool、不替代 Resource、不替代 Prompt,也不替代 MCP 接入层。它只是先把“系统里有什么能力、来自哪里、在本地还是远端、属于哪类能力、当前是否可用”这些信息讲清楚。
对我来说,这比马上做更高层调度更值得先做。
因为后续计划与执行要做得稳,前提是能力面已经稳。
2. Capability Model 统一的是描述层,不是执行链
本版本最重要的一个判断是:Capability Model 只统一能力描述,不统一执行链。
这句话如果没有提前说清楚,很容易把事情做重。
Tool、Resource、Prompt 虽然都可以叫 capability,但它们的运行时语义完全不同:
- Tool 通常是一次可执行动作,可能由模型 tool call 触发,也可能被 runtime 主动调用。
- Resource 更像外部上下文读取,重点是把内容拿回来进入后续回答。
- Prompt 是模板或消息注入,重点是生成一组可进入模型上下文的消息。
所以我没有把它们硬抽成一个统一的 executeCapability() 大协议。
统一的是这层描述对象:
export const capabilityTypes = ['prompt', 'resource', 'tool'] as const
export type CapabilityType = (typeof capabilityTypes)[number]
export const capabilityProviderKinds = ['internal', 'mcp'] as const
export type CapabilityProviderKind = (typeof capabilityProviderKinds)[number]
export const capabilityLocations = ['local', 'remote'] as const
export type CapabilityLocation = (typeof capabilityLocations)[number]
export interface CapabilityIdentity {
name: string
capabilityType: CapabilityType
providerKind: CapabilityProviderKind
location: CapabilityLocation
serverId?: string
}
export interface CapabilityDefinition extends CapabilityIdentity {
capabilityId: string
title: string
description: string
availability: CapabilityAvailability
}
这段代码解决的是“能力身份如何被稳定描述”的问题。
这里有几个字段很关键:
capabilityType:能力是tool、resource还是promptproviderKind:能力来自internal还是mcplocation:能力在local还是remoteserverId:如果来自 MCP,它属于哪个 serveravailability:能力当前是否可用,不只是一句 true / false
capabilityId 也不是直接用 name,而是按 providerKind:location:capabilityType:serverId?:name 组合出来。这样做是为了避免重名。
一个本地 MCP server 里可以有 summary,一个远端 MCP server 里也可以有 summary。如果只靠 name,后面 Skill、Runtime、前端都会开始猜。capabilityId 把能力身份收成稳定规则,后面再扩 remote server 或 discovery,才不会一开始就欠债。
这一层的克制点也很重要:它只描述能力,不接管能力执行。
Tool / Resource / Prompt 仍然保持各自的执行语义。Capability Model 只是让它们能被同一套语言描述出来。
3. Skill Metadata 是 Skill 的表面,不是 Workflow
在之前的版本里,Skill 已经存在,但它更多像一组运行时规则:命中哪个 Skill、允许用哪些 Tool、拼什么 system prompt。
到了本版本,我想把 Skill 的“表面”讲清楚。
这里的表面不是 UI,而是 Skill 对外声明自己的方式:
- 我是谁?
- 我主要处理什么任务?
- 我可以承接哪些能力来源?
- 我允许消费哪些 capability?
- 如果能力不可用,我怎么回退?
对应到类型上,就是 SkillDefinition(Skill 的统一定义对象,承载系统提示词、工具范围和 capability 选择范围)。这里最关键的新增字段是 capabilitySelectors,它用结构化条件描述 Skill 可承接的能力范围。
我刻意没有给 Skill 再包一层复杂的 metadata 对象,也没有把它扩成 workflow 定义。因为本版本的 Skill Metadata 只承担几件事:
- 自描述
- routing 辅助
- 前端轻展示
- capability 承接范围声明
- fallback 策略声明
它不承担:
- 多步 workflow
- 通用 capability 调度
- 通用 planner
- 模型自主继续决策
reader-skill(阅读类 Skill,负责文件读取、文档总结、项目上下文和 MCP 文档能力)就是本版本最关键的例子:
export const readerSkillDefinition: SkillDefinition = {
skillId: 'reader-skill',
name: '阅读技能',
allowedTools: ['city-weather', 'local-text-read'],
sourceKinds: ['mcp'],
capabilitySelectors: [
{ providerKind: 'mcp', location: 'local', capabilityType: 'tool', names: ['city-weather'] },
{ providerKind: 'mcp', location: 'local', capabilityType: 'resource', names: ['local-text-read'] },
{ providerKind: 'mcp', location: 'local', capabilityType: 'prompt', names: ['local-file-summary'] },
{ providerKind: 'mcp', location: 'remote', serverId: 'project-assistant-service', capabilityType: 'resource' },
{ providerKind: 'mcp', location: 'remote', serverId: 'project-assistant-service', capabilityType: 'prompt' },
{ providerKind: 'mcp', location: 'remote', serverId: 'project-assistant-service', capabilityType: 'tool' },
],
fallbackPolicy: 'direct-answer',
}
这段配置的意义不在于“列了一堆能力”,而在于它把 Skill 的边界声明出来了。
reader-skill 可以承接本地 MCP Tool、Resource、Prompt,也可以承接来自 project-assistant-service(本版本新增的远端 MCP 服务)的三类 remote capability。但这不代表它拥有通用调度权。它只是声明:这些能力属于我的可承接范围。
真正是否执行,仍然由 Runtime 在具体上下文里决定。
这样 Skill 就没有偷偷长成上层调度器。
4. Prompt 为什么要成为一等 Capability
我在本版本很想强调 Prompt。
在很多 AI 应用里,Prompt 容易被当成“内部模板文件”,或者被塞进 Tool 调用前后的某段字符串里。短期看没问题,长期看会让能力面变得不完整。
如果 Tool 是“做一个动作”,Resource 是“取一段上下文”,那 Prompt 就应该是“生成一组可注入模型上下文的任务消息”。
它不是 Tool。
因为 Prompt 本身不执行外部动作,也不应该伪装成一次 tool call。
它也不是 Resource。
因为模板文件只是 Prompt 的存储介质,不等于 Prompt capability 本身。真正被消费的是“带参数注入后的 Prompt 消息”。
所以本版本补了两个 Prompt:
local-file-summary(本地文件总结 Prompt,通过project-files-server暴露)tasklist-draft(远端 tasklist 草稿 Prompt,通过project-assistant-service暴露)
本地 Prompt 的运行时消费落在 prompt-context.ts(本地 Prompt 上下文注入模块,负责判断是否需要读取 prompt、注入参数并转换成模型消息):
export function resolvePromptContextInvocation(
request: ChatRequest,
executedToolResults: ExecutedToolResult[]
): PromptContextInvocation | null {
const userGoal = getLastUserMessageText(request)
if (!shouldUseLocalFileSummaryPrompt(userGoal)) {
return null
}
const localTextReadResult = getLatestSuccessfulLocalTextReadResult(executedToolResults)
if (!localTextReadResult) {
return null
}
const filename = getLocalTextReadFilename(localTextReadResult.toolCall)
if (!filename) {
return null
}
return {
promptName: LOCAL_FILE_SUMMARY_PROMPT_NAME,
source: 'mcp',
location: 'local',
serverId: LOCAL_FILE_SUMMARY_SERVER_ID,
input: formatPromptInvocationInput(filename, userGoal),
execute: () => buildLocalSummaryPromptContextMessages(filename, userGoal),
}
}
这段代码解决的是“Prompt 如何进入最终回答上下文”的问题。
它的执行链是:
- 用户先触发文件读取,例如读取
README.md - Runtime 拿到最近一次成功的
local-text-read结果 - 判断当前用户目标是否需要总结、摘要、提炼
- 获取
local-file-summaryPrompt - 注入
filename / content / userGoal - 把 Prompt 消息转成模型上下文
这里我只把 Prompt 当成一类执行事实展示出来。前端需要稳定知道的是:
- 哪个 Prompt 被使用了
- 来自哪里
- 属于 local 还是 remote
- 注入了几条上下文消息
- 是否失败
至于内部 Prompt 模板正文,则继续留在 Runtime 和模型上下文里,不作为前端展示重点。
5. Remote MCP 只验证最小闭环,不做远程业务平台
本版本确实新增了一个 remote MCP server。
这里的 remote 很朴素:它不在 apps/webapp 进程内,而是一个独立服务,通过 Streamable HTTP 被 Webapp 消费。
但它的定位非常克制:只验证 remote capability 最小闭环。
新增服务是 apps/project-assistant-service(独立 NestJS 服务,当前只承载 remote MCP mock capability)。它通过官方 MCP SDK 暴露三类能力:
- Resource:
project://latest-context - Prompt:
tasklist-draft - Tool:
check_doc_consistency
服务侧注册能力的代码在 mcp-capability.service.ts(远端 MCP 能力注册服务,负责创建 MCP server 并注册 Resource / Prompt / Tool)。这里我只保留了“每类 capability 一个最小 mock”的形态,用来证明远端能力面可以成立。
我没有在这里接数据库,没有接第三方 API,也没有做远程文件系统。三个 capability 都是 mock 数据。这样做不是因为远端能力不重要,而是因为本版本要验证的是另一件事:
Webapp 作为 MCP 消费端,能不能通过 remote Streamable HTTP 稳定消费 Resource / Prompt / Tool 三类能力,并把执行事实并入当前聊天主链?
Webapp 侧的 server definition 也保持很明确:transport=streamable-http、location=remote、serverId=project-assistant-service,并声明它同时具备 prompts / resources / tools 三类 capability。
这层配置解决的是“远端 MCP server 如何进入 Webapp 能力注册表”的问题。
这里我只做了 mock Bearer Token:
- 无 token:
unauthorized - 错 token:
forbidden - 正确 token:正常连接
没有做用户态登录透传,也没有做 OAuth。
这也是本版本的边界:remote MCP 是为了验证 remote capability surface 和 runtime 消费链路,不是为了提前做一个生产级远程业务平台。
6. Capability Metadata 不能只用于展示,必须进入 Runtime
如果 capability 只停留在 catalog 和前端展示,本版本其实还不完整。
真正让我觉得本版本站住了的,是 Capability Metadata 进入了 Runtime 消费闭环。
先用一个真实请求把链路放具体一点。
当我在前端输入:
帮我检查一下当前文档之间有没有明显不一致的地方
我希望它不是直接让模型凭空回答,而是先命中 reader-skill,再确认这个 Skill 是否声明过可承接 check_doc_consistency 这类 remote tool capability。确认通过后,Runtime 才去调用 project-assistant-service 暴露的 remote Tool,并把结果注入最终回答。
也就是说,reader-skill 已经声明的 capabilitySelectors,不能只是文档信息。Runtime 必须真正基于这层声明决定本轮能不能消费某个 capability。
对应模块是 capability-context.ts(最小 remote capability 消费层,只处理 reader-skill 下本版本固定远端能力,不做通用 planner)。
先看入口:
export function resolveCapabilityContextInvocations(request: ChatRequest, skillDefinition?: SkillDefinition): RemoteCapabilityInvocation[] {
if (skillDefinition?.skillId !== 'reader-skill') {
return []
}
const userGoal = getLastUserMessageText(request)
const invocations: RemoteCapabilityInvocation[] = []
const candidates: Array<[RemoteCapabilityName, CapabilityType, boolean]> = [
[LATEST_CONTEXT_RESOURCE_NAME, 'resource', matchesAny(userGoal, PROJECT_CONTEXT_PATTERNS)],
[TASKLIST_DRAFT_PROMPT_NAME, 'prompt', matchesAny(userGoal, TASKLIST_DRAFT_PATTERNS)],
[DOC_CONSISTENCY_TOOL_NAME, 'tool', matchesAny(userGoal, DOC_CONSISTENCY_PATTERNS)],
]
for (const [name, capabilityType, matched] of candidates) {
const identity = createRemoteCapabilityIdentity(name, capabilityType)
if (matched && isRemoteCapabilityAllowed(skillDefinition, identity)) {
invocations.push(createRemoteCapabilityInvocation(name, capabilityType, userGoal))
}
}
return invocations
}
这段代码解决的是“Skill metadata 如何进入 runtime 判断”的问题。
这里有两个约束很重要:
- 只有命中
reader-skill才会进入这层 remote capability 消费。 - 即使用户输入命中了高置信规则,也必须通过
isRemoteCapabilityAllowed()检查capabilitySelectors。
也就是说,Runtime 不会绕过 Skill 声明去随便调远端能力。
执行阶段也没有把三类 capability 强行揉成一种协议,而是保留各自语义:
function createRemoteCapabilityInvocation(
name: RemoteCapabilityName,
capabilityType: CapabilityType,
userGoal: string
): RemoteCapabilityInvocation {
const invocation: RemoteCapabilityInvocation = {
capabilityType,
execute: async options => {
if (capabilityType === 'resource') {
return executeRemoteResourceInvocation(invocation, options)
}
if (capabilityType === 'prompt') {
return executeRemotePromptInvocation(invocation, options)
}
return executeRemoteToolInvocation(invocation, options)
},
input: capabilityType === 'resource' ? LATEST_CONTEXT_RESOURCE_URI : `goal=${userGoal}`,
location: 'remote',
name,
serverId: PROJECT_ASSISTANT_SERVER_ID,
source: 'mcp',
}
return invocation
}
这段代码体现了本版本的核心取舍:
- invocation 形态是统一的
- 但 Resource / Prompt / Tool 的执行语义不是统一的
Resource 会 readResource(),并把完整内容作为模型上下文。
Prompt 会 getPrompt(),并把返回 messages 转成模型上下文。
Tool 会 callTool(),并把执行结果作为最终回答依据。
如果某个 capability 失败,也不会直接打断整轮对话。Runtime 会写出统一错误 chunk,再注入一条“能力不可用”的上下文,让最终回答不要编造结果。
这个点很小,但非常关键。
因为这意味着 capability metadata 不再只是“给人看”的资料,而是真的进入了 Runtime 决策。
7. 前端为什么要承接执行事实
AI 应用的前端如果只承接最终答案,很多运行时事实会被藏起来。
这在普通聊天里问题不大,但一旦系统开始接 Tool、Resource、Prompt、MCP、Skill,前端就需要承接更多运行时事实。它不只是服务用户感知,也是在帮整个系统保持技术完整性:Runtime 写出了什么,前端就能稳定接住什么。
本版本扩展了流式协议:
export interface SkillSelectedChunk {
type: 'skill-selected'
skillId: string
name: string
description?: string
}
export interface PromptStartChunk {
type: 'prompt-start'
partId: string
promptName: string
source?: 'internal' | 'mcp'
location?: 'local' | 'remote'
serverId?: string
input?: string
}
export interface PromptEndChunk {
type: 'prompt-end'
partId: string
promptName: string
source?: 'internal' | 'mcp'
location?: 'local' | 'remote'
serverId?: string
status: 'completed' | 'failed'
messageCount?: number
}
这段协议解决的是“前端消息模型如何稳定承接 Skill 命中和 Prompt 执行事实”的问题。
同时,原来的 Tool / Resource chunk 也补上了 source / location / serverId。这样前端就不只是知道“调用了一个工具”,还知道:
- capability 类型是什么
- 来源是
internal还是mcp - 位置是
local还是remote - 属于哪个
serverId - 当前状态是 called、completed 还是 failed
前端消费逻辑落在 use-chat-stream.ts(聊天流消费 hook,负责把 NDJSON chunk 合并成前端消息 part):
case 'skill-selected': {
const messageId = activeStreamRef.current.messageId
if (!messageId) {
return
}
updateMessages(current => appendPart(current, messageId, createSkillPart(chunk.skillId, chunk.name, chunk.description)))
return
}
case 'prompt-start': {
const messageId = activeStreamRef.current.messageId
if (!messageId) {
return
}
updateMessages(current =>
appendPart(
current,
messageId,
createPromptPart(chunk.partId, chunk.promptName, 'called', chunk.source, chunk.location, chunk.serverId, chunk.input)
)
)
return
}
case 'prompt-end': {
const messageId = activeStreamRef.current.messageId
if (!messageId) {
return
}
updateMessages(current =>
updatePromptPart(current, messageId, chunk.partId, part => ({
...part,
promptName: chunk.promptName,
source: chunk.source ?? part.source,
location: chunk.location ?? part.location,
serverId: chunk.serverId ?? part.serverId,
status: chunk.status,
messageCount: chunk.messageCount,
}))
)
return
}
这段代码解决的是“流式协议如何落到前端消息结构”的问题。
最终前端不只是渲染答案,而是会把这些运行时事实沉淀成消息 part:
Skill 命中:阅读技能Prompt 注入:tasklist-draftResource 读取:latest-contextTool 执行:check_doc_consistency来源:MCP位置:remote服务:project-assistant-service
我很喜欢这一步,因为它让 Runtime 不再像一个黑盒。
在调试、观察或复盘一轮回答时,我们能知道这轮回答依赖了哪个 Skill,消费了哪类 capability,来自本地还是远端。
这不是 UI 小修,而是协议层、运行时和产品表达一起往前走了一步。
8. 本版本刻意没做什么
这篇文章的主角是 Capability Surface,所以边界也要说清楚。
本版本刻意没有做这些事:
- 不做 Agent
- 不做 workflow
- 不做多 remote server 编排
- 不做模型自由规划任意 capability 调用
- 不做复杂 OAuth 或账号体系
- 不接数据库
- 不接第三方 API
- 不做 remote 文件系统
- 不把 Tool / Resource / Prompt 强行抽成同一条执行链
这些不是“以后都不做”,而是本版本先不做。
因为当前更需要验证的是:
- Capability 能不能先被统一描述?
- Skill 能不能先声明自己可承接的能力范围?
- Prompt 能不能成为 Tool / Resource 之外的一等 capability?
- Remote MCP 能不能用一个 server 跑通最小闭环?
- Metadata 能不能真正进入 Runtime,而不是停留在展示?
- 前端消息模型能不能把执行事实承接下来?
这几个问题没有先收住,继续往更高层计划与执行走,复杂度会涨得很快。
9. 回到这次实践,我得到的几个判断
做完这一轮后,我对 AI Runtime 里的能力层有了一个更明确的判断:
做更完整的 Agent Runtime 之前,最好先有 Capability Surface。
更具体一点,我收获了 4 个判断。
第一,Capability Model 应该先是一层描述模型。
它不需要一开始就包办执行链。先把 Tool / Resource / Prompt 的身份、来源、位置、可用性描述清楚,就已经很有价值。
第二,Skill Metadata 是 Skill 的表面,不是 planner。
capabilitySelectors 用来表达 Skill 可承接什么能力,而不是让 Skill 变成 workflow 引擎。
第三,Prompt 应该是一等 capability。
Prompt 不应该长期伪装成 Tool,也不应该只作为 Resource 背后的模板文件存在。它有自己的生命周期、参数注入方式和前端执行事实。
第四,Remote MCP 可以先做最小闭环。
一个 remote server,三类 mock capability,一套 Streamable HTTP transport,一个 mock token,已经足够验证 MCP 接入层、Runtime、Skill、Protocol、Frontend 是否能跑通。
对我来说,本版本最有价值的地方,不是项目多了一个 project-assistant-service,也不是前端多了几张卡片。
真正的价值是:Capability 从“能被列出来”走到了“能被 Skill 声明、能被 Runtime 消费、能被前端消息模型承接”。
这才是从接入 MCP 继续往上层运行时走之前,我认为应该补上的一层。
10. 后续我会怎么继续往前推
短期内,我不会急着把这层扩成通用 Agent Runtime。
更合理的节奏是:
- 先继续观察
Capability Model是否足够承载更多本地 / 远端能力 - 再考虑 remote MCP discovery 或 server 配置化
- 再让更多 Skill 基于
capabilitySelectors消费稳定能力 - 最后再谈 Agent Runtime 如何基于这些能力做计划、执行和继续决策
换句话说,这条线更像从接入 MCP 走到 Capability Surface,再让 Skill Runtime 稳定消费更多 MCP 能力来源,最后再进入 Agent Runtime。
我现在更愿意先把中间这层做扎实。
项目地址
GitHub: github.com/HWYD/ai-min…
如果这篇文章刚好对正在做 AI Runtime、MCP 接入、Tool Calling 或 Skill 分层的同路人有一点参考价值,欢迎来仓库里看看。
如果大家也对这种按版本持续演进的 AI Runtime Skeleton 感兴趣,顺手点个 Star,也能让我知道这条路线确实对外部读者有帮助。后面我会继续沿着 Capability Surface、MCP 能力治理、Skill Runtime 和 Agent Runtime,把这套骨架一点点往前推。