029、MCP Resources 与 Prompts 开发实战
上周五凌晨两点,我在调试一个Claude Code的MCP Server时,发现Resources接口返回的数据总是被截断。日志里看到response body长度只有4096字节,但我的数据库查询结果明明有2万行。翻遍MCP协议文档,才发现Resources的uri参数有个隐藏的size字段控制分页——这个坑让我折腾了三个小时。今天就把Resources和Prompts这两个MCP核心原型的实战经验掰开揉碎讲清楚。
Resources:不只是文件系统
很多人把MCP Resources理解成“读取文件”,这格局小了。Resources本质是结构化数据源的抽象层,可以是数据库表、API响应、甚至是实时计算的中间结果。Claude Code通过resources/read和resources/list两个方法交互,但真正决定开发效率的是URI设计。
URI命名规范(踩过坑才懂)
别学官方示例那样用file:///logs/app.log这种扁平URI。生产环境我推荐三级命名法:
mcp://{provider}/{resource_type}/{identifier}
比如我负责的监控系统:
mcp://prometheus/metrics/cpu_usage
mcp://prometheus/alerts/high_memory
mcp://postgres/tables/orders
这样设计的好处是:resources/list返回的树形结构天然支持按provider和type过滤。Claude Code的自动补全会根据前缀提示可用资源,用户体验直接拉满。
分页与增量更新(血泪教训)
Resources的read方法返回contents数组,但每个content有大小限制。我踩过的坑是:默认不启用分页。必须显式在read请求的params里传size和cursor。
// 别这样写——直接返回全部数据
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const data = await fetchAllFromDB(); // 2万行数据直接炸
return { contents: [{ uri: request.params.uri, text: JSON.stringify(data) }] };
});
// 正确姿势:支持游标分页
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const { uri, size = 1000, cursor } = request.params;
const { rows, nextCursor } = await fetchPaginated(uri, size, cursor);
return {
contents: [{ uri, text: JSON.stringify(rows) }],
nextCursor // 告诉Claude Code还有更多数据
};
});
这里有个细节:nextCursor必须是字符串,不能是数字。我一开始传了数字索引,结果Claude Code解析报错,排查半天才发现协议要求string类型。
订阅机制:实时数据的关键
Resources支持subscribe和unsubscribe,但很多开发者忽略了这个能力。如果你的数据源是实时变化的(比如日志流、监控指标),一定要实现订阅。
// 订阅处理逻辑
server.setRequestHandler(SubscribeRequestSchema, async (request) => {
const { uri } = request.params;
// 这里踩过坑:必须返回空对象,不能返回undefined
// 否则Claude Code会认为订阅失败
startPolling(uri, (newData) => {
// 通过notification通知客户端
server.sendNotification({
method: "notifications/resources/updated",
params: { uri }
});
});
return {}; // 必须显式返回空对象
});
注意:notifications/resources/updated通知只传URI,不传数据。Claude Code收到通知后会主动调用read获取最新数据。这个设计是为了避免通知体过大,但代价是增加了一次网络往返。
Prompts:让Claude Code学会提问
Prompts是MCP里最容易被低估的能力。它定义了Claude Code可以“主动问”用户什么信息。比如一个代码审查工具,需要用户选择审查级别(严格/宽松),或者一个部署工具,需要用户确认环境。
Prompt模板设计原则
Prompts的核心是prompts/get方法,返回一个messages数组。每个message可以是user、assistant或system角色。但实战中我发现:不要把所有逻辑塞进一个prompt。
// 反面教材:一个prompt干所有事
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
return {
messages: [
{ role: "system", content: { type: "text", text: "你是代码审查助手..." } },
{ role: "user", content: { type: "text", text: "请审查以下代码..." } }
]
};
});
// 正确做法:拆分成多个prompt,每个专注一个场景
const prompts = {
"review-code": {
name: "审查代码",
description: "对指定代码文件进行审查",
arguments: [
{ name: "filePath", description: "文件路径", required: true },
{ name: "severity", description: "审查严格程度", required: false }
]
},
"deploy-service": {
name: "部署服务",
description: "部署服务到指定环境",
arguments: [
{ name: "env", description: "环境名称", required: true },
{ name: "version", description: "版本号", required: true }
]
}
};
每个prompt的arguments字段是关键。Claude Code会根据这些参数自动生成交互式表单,用户填写后触发prompts/get请求。参数类型目前只支持string,但可以通过description暗示格式(比如“格式:YYYY-MM-DD”)。
动态prompt:根据上下文调整
静态prompt模板不够灵活。我常用的模式是:在prompts/get处理函数里根据参数动态构建prompt内容。
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === "review-code") {
const fileContent = await readFile(args.filePath);
const severity = args.severity || "normal";
// 根据文件类型调整审查重点
const language = detectLanguage(args.filePath);
const focusAreas = getFocusAreas(language, severity);
return {
messages: [
{
role: "system",
content: {
type: "text",
text: `你正在审查${language}代码。重点关注:${focusAreas.join("、")}。严格程度:${severity}`
}
},
{
role: "user",
content: {
type: "text",
text: `请审查以下代码:\n\`\`\`${language}\n${fileContent}\n\`\`\``
}
}
]
};
}
});
这里有个坑:arguments参数名必须和prompts/list里定义的一致,大小写敏感。我因为把filePath写成filepath,导致Claude Code传参时一直报错。
Prompt与Resources的联动
高级玩法是把Resources和Prompts结合起来。比如一个prompt需要读取某个资源的数据作为上下文:
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
if (request.params.name === "analyze-metrics") {
// 先读取资源数据
const metricsData = await server.handleRequest({
method: "resources/read",
params: { uri: "mcp://prometheus/metrics/cpu_usage", size: 100 }
});
return {
messages: [
{
role: "system",
content: { type: "text", text: "你是一个性能分析专家" }
},
{
role: "user",
content: { type: "text", text: `分析以下CPU指标:\n${metricsData.contents[0].text}` }
}
]
};
}
});
但注意:不要在prompt处理函数里直接调用resources/read,这会导致循环依赖。正确做法是通过server实例的handleRequest方法,或者把资源读取逻辑抽成独立函数。
实战经验总结
-
Resources的URI设计决定可扩展性:用三级命名法,别偷懒。我见过一个项目把所有资源都叫
data,后来维护成本爆炸。 -
分页是必选项而非可选项:即使你的数据现在很小,也要实现分页。Claude Code的上下文窗口有限,一次性返回太多数据会导致对话卡死。
-
Prompts的arguments要精简:每个prompt的参数不要超过3个,否则用户填写体验很差。复杂场景拆成多个prompt串联。
-
错误处理要细致:Resources和Prompts的请求都可能失败。返回错误时用MCP定义的错误码,比如
-32602表示参数无效,-32603表示内部错误。别自己发明错误码。 -
日志记录要包含URI:调试时最痛苦的是不知道哪个资源出了问题。每个请求都打日志,带上
uri和name字段。 -
测试要覆盖边界情况:空数据、超大响应、参数缺失、并发请求——这些场景都要写测试。我吃过亏:一个prompt在参数为空时返回了undefined,导致Claude Code整个会话崩溃。
最后说个个人习惯:我会在MCP Server启动时打印所有注册的Resources和Prompts列表,方便快速验证。这个简单的做法帮我省了无数排查时间。

393

被折叠的 条评论
为什么被折叠?



