服务端:createUIResource 怎么打包 UI
本章讲工具作者那一侧:一行
createUIResource(...)是怎么把一段 HTML 或一个外部网址,变成 MCP 能传输的标准资源对象的。三种语言(TS/Ruby/Python)做的是同一件事,这里以 TS 为主。
1. 它要解决的小问题
MCP 协议里,工具结果和资源都是结构化 JSON。要把「一块网页」塞进去,得先把它规范成一个固定形状的对象:有 uri(怎么引用它)、有 mimeType(它是什么)、有内容(text 或 blob)。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是空文件;字面定义只在 Pythontypes.py:11、Rubymcp_ui_server.rb:12)。
3. 两种内容类型
content 是一个可辨识联合(discriminated union),只有两种(sdks/typescript/server/src/types.ts:29-31):
content.type | 字段 | 含义 | 服务端做什么 |
|---|---|---|---|
rawHtml | htmlString | 自包含的一整段 HTML | 原样当内容字符串 |
externalUrl | iframeUrl | 一个外部网页地址 | 服务端去抓这个 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)拦了三层:
- 协议:只允许
http:/https:(挡掉file:、gopher:等)。 - 黑名单主机名:
localhost、127.0.0.1、0.0.0.0、[::1]、[::](BLOCKED_HOSTNAMES,第 11-17 行)。 - 私网 IPv4:
isPrivateIPv4(第 23-40 行)挡掉10/8、172.16-31、192.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 行):uiPrefixedMetadata → metadata → 已有 _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这类外层属性。