Dify MCP 集成实验(02):工具进阶与协议原语——MCP 三原语如何落地?
1. 业务场景
先讲一个我们实际遇到的场景。
一家做客服工单 SaaS 的公司,支持团队每天处理大量工单查询:「退款相关的工单有哪些?」「T1002 现在什么状态?」这些查询如果能直接做成 MCP 工具,客服门户的 AI 助手就能自己查。同时还有排障手册(可读资源)和工单分析模板(提示词)——在 MCP 协议里,工具、资源、提示词是三种原语,一个 server 都能表达。
我们第一次接这类需求时,第一反应是「把查询做成工具就完事了」。真正动手才发现——客户要的不只是工具:排障手册、分析模板也是交付的一部分,三原语都得能表达;而且工具返回裸 dict 看着能用,下游解析一碰就碎。协议能表达什么是上限,平台消费什么是边界,两头都要摸清。
这不是个例。任何「外部系统数据进 Dify」的集成都是这个模式:先搞清楚协议能表达什么(tools / resources / prompts),才知道哪些能力 Dify 用得上、哪些要换种方式包装——「Dify 只消费 tools」的源码结论,要靠本实验的 server + 107-03 接入实证。
2. 场景痛点
这个流程的痛点,在协议落地时体现得最直接:
- 只会写工具不够:客户要的不只是查询工具,还有排障手册、分析模板——三原语都得能表达,少一个交付就缺一块。
- 结构化输出难:工具返回裸
dict,下游解析脆弱——字段错一个就崩,
structured_output=True时返回类型不对直接报InvalidSignature。 - 参数校验缺失:非法状态、不存在的工单号,返回什么?静默空结果最坑——下游把「没查到」误判成「查询失败」。
- 协议能力边界不清:不知道 Dify 只消费 tools——客户要「资源读取」时不知道怎么包装,方案当场卡壳。
本质上,协议能表达什么是上限,平台消费什么是边界——两头都清楚,交付才不会返工。
3. 方案:为什么是 MCP 三原语完整实现
在 107-01 地基上,把 MCP 协议三原语(tools / resources / prompts)在 server 侧完整实现——多工具、结构化输出、参数校验、资源与提示词模板。
选它的理由:
- 协议原生,一套 server
全实现:
@server.tool()重复装饰即可注册多工具(1:N 关系实证),@server.resource()/@server.prompt()补齐资源与提示词——三原语同 server 共存; - 结构化输出强制:
structured_output=True+ Pydantic 模型——返回类型编译器级兜底,裸 dict 直接报错,不留给运行期; - 契约一致性:mock 工单字段(ticket_id/status/updated_at)与 105 工单系统一致(迁移纪律)——本实验产出的 server 是 107-03 的对照基准。
这篇文章我们就用它扩展 107-01 的 server,把三原语完整落地,为客户「资源读取」类诉求的包装方式提供依据。
4. 整体架构
链路很清晰:本地 server(tools + resources + prompts 三原语)→ HTTP → Dify 服务器(107-03 接入)。关键设计是三原语同 server 共存,为「Dify 只见 tools」的对照结论提供运行级实证基础。
5. 模块设计
5.1 结构化输出工具(返回类型必须 Pydantic 模型)
from pydantic import BaseModel
class TicketStatus(BaseModel):
ticket_id: str
status: str
updated_at: str
title: str = ""
@server.tool(structured_output=True)
def get_ticket_status(ticket_id: str) -> TicketStatus:
"""按工单号查状态;格式错/不存在 → raise ValueError("not_found: ...")"""
...坑点预埋:structured_output=True
时返回类型必须是 Pydantic BaseModel,裸 dict 报
InvalidSignature。
5.2 资源与提示词(三原语补齐)
# 资源:静态 + 模板(模板可读但不进 list,SDK 2.0 观察点)
@server.resource("support://troubleshooting")
@server.resource("support://troubleshooting/{topic}")
def troubleshooting(topic: str | None = None) -> str: ...
# 提示词:SDK 2.0 PromptMessage 只认 user/assistant,无 system 角色
@server.prompt()
def ticket_analysis(ticket_id: str) -> list[dict]:
return [{"role": "user", "content": f"请分析工单 {ticket_id} 的处理情况…"}]5.3 多工具注册
一个 server 暴露多个工具:@server.tool()
重复装饰即可(1:N 关系实证);工具名冲突时 SDK
自动告警(warn_on_duplicate_tools)。
6. 运行验证
| 输入 | 预期 | 结果 |
|---|---|---|
| search_tickets(退款+pending) | 返回 T1003 | 通过 |
| search_tickets(登录+open) | 空列表
structured={'result': []}(空结果 ≠ 错误) |
通过 |
| search_tickets(非法状态 BAD) | isError=True + 中文错误 | 通过 |
| get_ticket_status(T1002) | structured_content
完整返回 |
通过 |
| get_ticket_status(t1004 小写) | 归一化 T1004 正常返回 | 通过 |
| get_ticket_status(T9999 不存在) | status: not_found(显式空结果,非静默) |
通过 |
| resources/list + read | 列出并读取
support://troubleshooting 条目 |
通过 |
| prompts/list + get | 返回 ticket_analysis 模板(user 消息) | 通过 |
7. 实战坑
| 坑 | 现象 | 修复 |
|---|---|---|
| 结构化输出要求 Pydantic 模型 | structured_output=True 返回裸
dict 报
InvalidSignature: return type dict is not serializable for structured output |
返回类型声明为 BaseModel 子类(实测) |
| prompt 无 system 角色 | 写 role: system 报
ValidationError |
SDK 2.0 PromptMessage 只接受 user/assistant(实测) |
| 模板资源不进 list | resources/list 只列静态
Resource,{topic} 模板可读但不在列表 |
静态 + 模板双装饰;read(login) 成功证明注册有效(实测) |
| 模板资源错误 | read 未知主题 → server 端 raise | 客户端收到 "Error creating resource from template"(错误透传,server 打堆栈日志)(实测) |
| 空结果语义 | 空列表返回
structured={'result': []} |
按「空结果 ≠ 错误」纪律处理,下游不误判失败(实测) |
| 多工具命名冲突 | 工具重名注册不报错 | SDK 自动告警(warn_on_duplicate_tools),命名规范避免(实测) |
8. 实验文档及源码获取
- 实验文档(完整操作步骤):DIFY-107-02:工具进阶与协议原语.md
- Server 源码:dify107_02_support_server 目录
- 交付验证记录(三原语对照清单 + 四类调用验证):验证记录-02-工具进阶与协议原语.md
- 全部目录:dify-107/experiments | dify-107/dsl | dify-107/servers | dify-107/delivery
文章聚焦核心配置与采坑点;实验的完整分步操作(节点搭建/参数表/调试指引)见实验文档原文。