AI 技术
#MCP#URI Template#RFC 6570#资源模板#百分号编码

MCP 资源模板的 URI 展开:客户端拼出的路径为何在服务端解析失败

远程 MCP Server 用 RFC 6570 模板声明文件与数据库资源时,客户端填充变量、服务端匹配模板这两步经常对不上。文章拆解模板展开、百分号编码、路径段转义与匹配歧义,说明解析失败发生在哪一环,并给出校验点与可观测指标。

一个具体的失败场景

某团队把内部知识库接进 MCP。服务端是远程部署的,通过 resources/templates/list 声明了两类资源:一类是对象存储里的文档,模板写成 file:///{path};另一类是数据库表,模板写成 db://{database}/{table}。客户端在界面上让用户从目录树里选文件,选中的路径是 季度报告/2025 Q1 财务.pdf,客户端按模板拼出 file:///季度报告/2025 Q1 财务.pdf 并发出 resources/read。

服务端返回 -32602 Invalid params,日志里只有一句“no matching resource template”。同一时刻,另一个用户选中的是 docs/readme.md,读取成功。差别只在路径里出现了非 ASCII 字符、空格和斜杠层级。这类问题在联调阶段很难复现,因为开发机上用的都是英文短路径,上线后真实文件名才把问题暴露出来。

常见的直觉是把模板当成字符串替换:把 {path} 换成用户给的路径就行。MCP 规范确实把资源模板定义为 URI 模板,而 URI 模板遵循 RFC 6570 的展开规则。RFC 6570 不是简单的占位符替换,它规定了不同表达式类型对保留字符、百分号编码和路径分隔符的处理方式。客户端和服务端只要在“用哪种表达式、编不编码、编哪些字符”上理解不一致,拼出的 URI 就落不进服务端的匹配范围。

下面沿着这条失败链路走一遍:模板怎么声明,客户端怎么展开,服务端怎么匹配,以及在哪几个点上最容易断。

模板声明与展开是两套代码

MCP 的 resources/templates/list 返回 resourceTemplates 数组,每一项至少包含 uriTemplate、name,可选 title、description、mimeType。规范给出的示例模板是 file:///{path},配套说明里提到参数可以通过 completion API 自动补全。这里要区分两个动作:

  • 声明:服务端写下一个模板字符串,表示“我支持这一类 URI”。
  • 展开:客户端拿到模板后,把变量填进去,生成一个具体 URI,再发给 resources/read。

声明和展开由不同进程、不同语言、不同库完成。服务端可能用 Go 写,客户端可能用 TypeScript SDK,两边各自依赖一个 URI 模板实现。RFC 6570 定义了 4 个级别和多种表达式类型,实现覆盖程度不一。一个库只支持 {var} 和 {?var},另一个库支持 {+var}、{/var}、{;var},双方对同一个模板的展开结果就可能不同。

更隐蔽的问题是:服务端声明模板时,往往按“我希望收到的 URI 长什么样”来写;客户端展开时,按“我手里这个字符串直接塞进去”来做。两个意图之间没有共享的编码约定,失败就发生在中间。

变量编码决定了哪些字符会被改写

RFC 6570 的核心是表达式。表达式写在花括号里,第一个字符决定展开方式。工程上最常遇到的是这几种:

  • {var}:简单字符串展开。变量值里的保留字符会被百分号编码,空格变成 %20,斜杠变成 %2F。
  • {+var}:保留展开。保留字符原样保留,斜杠不会被编码,适合把一整段路径塞进去。
  • {/var}:路径段展开。每个变量值作为一个独立路径段,前面补一个 /,段内的斜杠会被编码。
  • {?var} 和 {&var}:查询参数展开,生成 ?key=value 或 &key=value。

回到知识库场景。服务端声明 file:///{path},用的是简单展开。客户端把 季度报告/2025 Q1 财务.pdf 填进去,按 RFC 6570 应该得到 file:///%E5%AD%A3%E5%BA%A6%E6%8A%A5%E5%91%8A%2F2025%20Q1%20%E8%B4%A2%E5%8A%A1.pdf。注意斜杠被编码成了 %2F,整个路径变成一个路径段。

如果服务端期望的是“收到一个带层级结构的路径”,它应该声明 file://{/path} 或者 file:///{+path}。前者让每个变量值成为独立路径段,后者保留斜杠。两种声明对应两种完全不同的展开结果,服务端匹配逻辑也必须跟着变。

这里有一个容易被忽略的细节:RFC 6570 的百分号编码作用于变量值,不作用于模板里的字面量。模板里的 file:/// 是字面量,原样输出。所以 file:///{path} 展开后一定以 file:/// 开头,后面跟编码后的变量值。如果客户端错误地对整个结果再做一次编码,file:/// 里的冒号和斜杠也会被改写,服务端按前缀匹配时直接落空。

服务端匹配模板时的歧义

服务端收到 resources/read 的 uri 后,需要判断它属于哪个模板,再从中提取变量。这一步通常用正则或前缀树实现,而模板到正则的转换并不唯一。

以两个模板为例:

  • file:///{path}
  • file:///{dir}/{file}

一个 URI file:///a/b/c.txt 同时能被两者匹配。按第一个模板,path 是 a/b/c.txt(如果斜杠未被编码);按第二个模板,dir 是 a,file 是 b/c.txt。服务端如果按声明顺序取第一个匹配,结果取决于模板返回顺序;如果按最长匹配,结果又不同。客户端无法预知服务端用哪种策略,于是同一个 URI 在不同实现下解析成不同资源。

RFC 6570 本身不规定模板之间的优先级,它只规定单个模板如何展开。匹配歧义是服务端实现问题,但会直接表现为客户端的读取失败或读错资源。

另一类歧义来自变量值里本来就含有的保留字符。用户文件名里带 ? 或 #,简单展开会把它编码成 %3F、%23,服务端解码后能还原。但如果服务端用的是保留展开 {+path},? 会原样进入 URI,被 URI 解析器当成查询串起点,path 变量只截取到 ? 之前。服务端拿到的路径比用户选的短,读取自然失败。

一次读取的完整数据流

把上面的环节串起来,一次成功的读取要经过模板声明、变量填充、URI 编码、传输、服务端匹配、变量解码、资源定位七个步骤。任何一步的编码约定不一致,都会在服务端表现为“找不到资源”。

flowchart TD
    A[服务端声明模板 file:///{path}] --> B[客户端获取 resourceTemplates]
    B --> C[用户选择文件 季度报告/2025 Q1 财务.pdf]
    C --> D[按 RFC 6570 展开变量]
    D --> E{表达式类型}
    E -->|简单展开| F[斜杠编码为 %2F 空格编码为 %20]
    E -->|保留展开| G[斜杠与空格原样保留]
    F --> H[发送 resources/read]
    G --> H
    H --> I[服务端按模板匹配 URI]
    I --> J{匹配到哪个模板}
    J -->|唯一匹配| K[提取变量并解码]
    J -->|多个模板命中| L[按实现策略选一个 可能选错]
    J -->|无匹配| M[返回 Invalid params]
    K --> N[定位真实文件]
    N --> O{文件存在}
    O -->|是| P[返回 contents]
    O -->|否| Q[返回资源不存在]

图中最关键的分叉在 E 和 J。E 由客户端决定,取决于它把模板当成哪种表达式;J 由服务端决定,取决于匹配策略。两个分叉的组合有四种,只有“客户端展开方式与服务端匹配方式一致”这一种能稳定工作。

与静态资源枚举的对比

MCP 同时提供 resources/list 和 resources/templates/list。前者枚举具体资源,每个 URI 都是完整的、已经确定的值;后者只给模板,具体 URI 由客户端生成。两种方案在工程上的取舍不同。

维度静态枚举 resources/list模板 resources/templates/list
URI 生成方服务端,URI 已确定客户端,按模板展开
编码责任服务端一次完成客户端与服务端各做一半
匹配歧义不存在,逐个比对存在,多模板可能命中同一 URI
资源规模适应性资源多时需要分页适合大范围参数化资源
失败表现资源不存在,语义明确模板不匹配或变量提取错误
客户端实现复杂度低,直接使用返回的 URI高,需完整实现 RFC 6570
服务端实现复杂度低,维护资源清单高,需模板匹配与变量提取
适用场景数量有限、位置固定的资源文件树、数据库表等参数化空间

这张表解释了一个常见现象:小规模资源用静态枚举几乎不会出问题,一旦换成模板,联调成本立刻上升。模板把编码和匹配的责任分摊到两端,任何一端实现不完整都会暴露。

工程上通常的做法是混合使用。对数量可控、位置固定的资源用 resources/list 直接枚举;对文件树、数据库表这类参数化空间用模板,同时把模板设计得尽量无歧义。

把模板设计得不容易出错

模板声明阶段能消除大部分后续问题。以下几条是工程上反复验证过的做法。

(1)优先用路径段展开而不是简单展开

要表达“一个多级路径”,用 file://{/path} 比 file:///{path} 更贴合意图。前者让斜杠保持为路径分隔符,后者会把斜杠编码掉。如果确实需要保留原始斜杠,用 file:///{+path},但要清楚保留展开不会编码 ? 和 #,变量值里出现这两个字符会破坏 URI 结构。

(2)避免多个模板共享前缀

file:///{path} 和 file:///{dir}/{file} 放在同一份列表里,必然产生匹配歧义。如果业务上确实需要两种粒度,把它们放到不同的 URI scheme 下,例如 file:///{path} 和 tree:///{dir}/{file},让匹配在 scheme 层面就唯一。

(3)在服务端做解码后的规范化

服务端提取变量后,先做百分号解码,再对路径做规范化:合并重复斜杠、解析 . 和 ..、统一大小写策略。规范化之后再做资源定位,可以避免 a//b 和 a/b 被当成两个资源。

(4)把模板匹配结果写进日志

服务端每次匹配模板时,记录命中的模板名、提取出的原始变量值和解码后的值。出现“no matching resource template”时,这三个字段能直接区分是没匹配上,还是匹配上了但变量提取错了。

可观测指标与排查顺序

生产环境需要几个信号来判断问题出在哪一环。

  • 模板匹配失败率:resources/read 中因无匹配模板而返回 Invalid params 的比例。这个指标上升通常意味着客户端展开方式变了,或者服务端新增模板引入了歧义。
  • 模板命中分布:每个模板被命中的次数。如果某个模板从未命中,可能是客户端根本不支持它的表达式类型。
  • 变量解码失败率:提取出的变量无法百分号解码的比例。这通常指向客户端重复编码或漏编码。
  • 资源定位失败率:模板匹配成功但文件或表不存在的比例。这一层与编码无关,属于业务数据问题。

排查时按这个顺序走:先看请求 URI 原文,确认客户端发出的字符串长什么样;再对照服务端日志里的命中模板和提取变量,确认匹配是否落在预期模板上;最后对比解码后的路径和用户实际选择的路径。三步之内基本能定位到是展开、匹配还是定位环节的问题。

前提不成立时会怎样

这套机制依赖几个前提。客户端完整实现 RFC 6570,服务端模板之间无歧义,两端对保留字符的处理一致。任何一条不成立,方案就会退化。

客户端只支持简单展开时,{+var} 和 {/var} 会被当成 {var} 处理,路径里的斜杠被编码,服务端如果按路径段匹配就找不到。服务端模板存在前缀重叠时,匹配结果依赖实现细节,同一 URI 在不同版本的服务端上可能解析成不同资源。两端对空格的处理不一致时,%20 和 + 会被混用,服务端解码后得到不同的路径。

还有一个边界:URI 模板不负责表达“这个变量允许哪些值”。{path} 可以匹配任意字符串,服务端必须在提取变量后自己做路径穿越检查,拒绝 .. 和绝对路径。RFC 6570 的安全考虑章节也提醒,模板展开的结果需要在使用前验证。

如果这些前提无法在两端同时保证,退回到静态枚举是更稳的选择。资源数量增长带来的分页成本,通常低于跨端编码不一致带来的排查成本。

尚未统一的部分

MCP 规范把资源模板定义为 URI 模板,但没有规定服务端必须实现 RFC 6570 的哪些级别,也没有规定多模板匹配时的优先级。这意味着“客户端按模板拼出的路径在服务端解析失败”这个问题,短期内仍会以不同形式出现。

对实现者来说,可行的做法是在服务端声明模板时就把表达式类型写清楚,在客户端展开后把最终 URI 记进日志,在服务端匹配后把命中的模板和变量记进日志。三处日志对齐,问题就能从“服务端说解析失败”缩小到具体的编码或匹配环节。这比在两端反复猜测对方的实现要快得多。

资料来源

  1. MCP Specification: Resources
  2. RFC 6570: URI Template
  3. MCP TypeScript SDK: resource templates
  4. 工良出品 | 长文讲解 MCP 和案例实战 - 痴者工良 - 博客园