跳到主要内容

服务端:createUIResource 怎么打包 UI

本章讲工具作者那一侧:一行 createUIResource(...) 是怎么把一段 HTML 或一个外部网址,变成 MCP 能传输的标准资源对象的。三种语言(TS/Ruby/Python)做的是同一件事,这里以 TS 为主。

1. 它要解决的小问题

MCP 协议里,工具结果和资源都是结构化 JSON。要把「一块网页」塞进去,得先把它规范成一个固定形状的对象:有 uri(怎么引用它)、有 mimeType(它是什么)、有内容(textblob)。createUIResource 就是这个「规范化打包机」。

2. 产出长什么样

打包结果是一个 UIResource,核心就是内层那个 resource:

// 示意,字段名取自真实类型
{
type: 'resource',
resource: {
uri: 'ui://my-server/widget', // ui:// 开头,唯一标识
mimeType: 'text/html;profile=mcp-app', // MCP Apps 标准 MIME
text: '<h1>Widget</h1>', // 或 blob: '<base64>'
_meta: { /* 可选元数据 */ },
},
}
  • uri 必须ui:// 开头,否则直接抛错:createUIResource 第 36-38 行 if (!options.uri.startsWith('ui://')) throw(sdks/typescript/server/src/index.ts:36-38)。
  • mimeType 是写死的常量 RESOURCE_MIME_TYPE = 'text/html;profile=mcp-app',这是 MCP Apps 标准 MIME。TS 侧没有任何 .ts 文件字面定义它——它在 sdks/typescript/server/src/types.ts:5 通过 export { ... RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps' 从 ext-apps 再导出(shared/src/index.ts 是空文件;字面定义只在 Python types.py:11、Ruby mcp_ui_server.rb:12)。

3. 两种内容类型

content 是一个可辨识联合(discriminated union),只有两种(sdks/typescript/server/src/types.ts:29-31):

content.type字段含义服务端做什么
rawHtmlhtmlString自包含的一整段 HTML原样当内容字符串
externalUrliframeUrl一个外部网页地址服务端去抓这个 URL 的 HTML,注入 <base> 后当内容

分发逻辑在 createUIResource 的 if/else 里(sdks/typescript/server/src/index.ts:42-62),末尾还用 const exhaustiveCheckContent: never = options.content 做穷尽检查——加新类型忘了处理会编译报错。

4. 两种编码:text vs blob

打包完内容字符串后,按 options.encoding 决定放进 text 还是 blob(sdks/typescript/server/src/index.ts:80-101):

  • text:直接放进 resource.text
  • blob:utf8ToBase64(...) 编码后放进 resource.blob

utf8ToBase64 值得一看(sdks/typescript/server/src/utils.ts:213-241):它优先用 Node 的 Buffer,否则退回 TextEncoder + btoa,并且分 8192 字节一块喂给 String.fromCharCode——直接 String.fromCharCode(...整个大数组) 会爆栈,这是处理大 HTML 的实打实的坑。

5. externalUrl 模式:服务端抓取 + <base> 注入 + SSRF 防护

这是服务端唯一「有副作用」的路径,也是工程含量最高的一支。

5.1 为什么要服务端抓、还要注 <base>

外部网页里全是相对路径(./style.css/img/logo.png)。如果只把 URL 丢给客户端 iframe,相对路径会相对沙箱代理页解析,全部 404。所以服务端先把 HTML 抓下来,在 <head> 后注入一个 <base href="原始URL">,让相对路径回到原站解析。

injectBaseTag(sdks/typescript/server/src/utils.ts:148-165)的细节:

  • 已有 <base 就不动(/<base\s/i.test(html))。
  • <head> 就插在它后面;没有就整段前置。
  • href 经过 escapeHtmlAttr 转义 &",防属性注入。

5.2 SSRF 防护(这段是安全要点)

「服务端去抓一个调用方给的 URL」是经典的 SSRF(服务端请求伪造) 风险——攻击者可能让你的服务器去访问内网或云元数据地址。validateExternalUrl(sdks/typescript/server/src/utils.ts:48-77)拦了三层:

  1. 协议:只允许 http: / https:(挡掉 file:gopher: 等)。
  2. 黑名单主机名:localhost127.0.0.10.0.0.0[::1][::](BLOCKED_HOSTNAMES,第 11-17 行)。
  3. 私网 IPv4:isPrivateIPv4(第 23-40 行)挡掉 10/8172.16-31192.168/16,以及 169.254/16 链路本地(云元数据 169.254.169.254 就在这段)。

抓取本身(fetchExternalUrl,sdks/typescript/server/src/utils.ts:89-142)还加了两道闸:

  • 超时:AbortController + 30 秒(FETCH_TIMEOUT_MS)。
  • 响应体大小上限 10 MB:先看 content-length,再边读边数字节(没有 content-length 时也能拦),超了就 reader.cancel()

诚实补一句:isPrivateIPv4 只对字面量 IPv4 生效。如果调用方传的是一个域名,DNS 解析到内网地址(DNS rebinding),这层不会拦——代码里只校验了 URL 字符串本身,没做解析后复查 (inferred,基于 validateExternalUrl 仅操作 parsed.hostname 字符串)。

5.3 抓完自动补 CSP 白名单

抓 externalUrl 后,createUIResource 把该 URL 的 origin 自动塞进 _meta.csp.baseUriDomains(sdks/typescript/server/src/index.ts:66-76)。原因:客户端沙箱会下发 CSP 限制 base-uri,而我们刚注入了一个指向外站的 <base>——不把这个 origin 列入白名单,注入的 <base> 会被 CSP 拦掉。这是 server 与 client 安全策略的一处隐性握手。

6. 元数据前缀:mcpui.dev/ui-

用户可以传 uiMetadata(MCP-UI 专用,如首选窗口尺寸)和 metadata(任意)。getAdditionalResourceProps(sdks/typescript/server/src/utils.ts:183-205)把 uiMetadata 的每个 key 加前缀 mcpui.dev/ui- 再放进 _meta,这样客户端能用前缀把「MCP-UI 的元数据」从一堆 _meta 里挑出来(对应客户端 getUIResourceMetadata,见第 02 章)。

合并顺序也讲究(第 197-201 行):uiPrefixedMetadatametadata → 已有 _meta,后者覆盖前者,所以用户显式写的 _meta 优先级最高。

已知的两个 UI 元数据 key(sdks/typescript/server/src/types.ts:50-53):

key(加前缀后)作用
preferred-frame-size[宽, 高] CSS 字符串建议的 iframe 尺寸
initial-render-data任意对象首屏初始数据

7. 三语言一致性

Ruby(sdks/ruby/lib/mcp_ui_server.rb,create_ui_resource)和 Python(sdks/python/server/src/mcp_ui_server/core.py:69,create_ui_resource)做的是同一套打包:校验 ui:// 前缀、按 rawHtml/externalUrl 取内容、按 text/blob 编码。

一个关键差异要诚实指出:Ruby/Python 的 externalUrl 不抓取、不注入 <base>、没有 SSRF 防护——它们直接把 iframeUrl 当内容字符串塞进资源(Python core.py:131 actual_content_string = iframe_url;Ruby process_external_url_content 只取 iframeUrl)。只有 TS server 实现了抓取那一套。所以 externalUrl 的「服务端预抓」目前是 TS 独有能力。

8. 关键细节 / 坑

  • ui:// 前缀校验在三种语言里都有,是协议第一道闸。
  • blob 编码的 8192 分块是为大 HTML 防爆栈(utils.ts:222-227)。
  • externalUrl 的 CSP 白名单是自动补的,用户一般不用管。
  • 字段 embeddedResourceProps 会被展开到顶层 UIResource(index.ts:106),用来放 annotations 这类外层属性。