帮你快速理解、总结文档立即下载

MCP Apps接入指南

最近更新时间:2026-10-23 15:25:41
我的收藏
让 MCP 工具不再只回纯文本,而是直接在对话里渲染一个交互式界面——按钮、表单、图表、地图,都能内嵌在对话中。
本文档面向 MCP Server 开发者。如果您已有一个 MCP Server(已作为插件接入应用),想为某个 tool 添加交互式界面,本文说明协议契约、接入步骤、Host 能力边界与链路对接方式。
对话端遵循 MCP 官方 io.modelcontextprotocol/ui 协议扩展(ext-apps,specification 2026-01-26)渲染交互式界面。

一、协议核心概念

1.1 UI Resource

MCP Apps 的交互式界面是一份 HTML,作为 MCP Resource 暴露:
URI:建议使用 ui:// 前缀(与官方规范一致),例如 ui://my-server/dashboard;该 URI 由平台侧从 tool 的 _meta.ui.resourceUri 取到并下发到对话端,用于拉取 HTML。
MIME 必须 是 text/html;profile=mcp-app(对话端按 mime_type 以 text/html 开头选取 HTML 资源,避免误取同响应里的非 HTML 资源)。
资源的 _meta.ui.csp 用于声明 CSP 域名白名单;平台侧会映射为 ReadMCPResource 响应的 resource_meta,对话端据此生成内层 iframe 的 CSP。

1.2 App Tool

让 Host 知道某个 tool 关联了交互式界面,需要在 tool 定义里加 _meta.ui.resourceUri 指向 UI Resource。模型调用这个 tool 时,Host 自动渲染对应交互式界面。

1.3 Sandbox iframe

交互式界面运行在隔离的 iframe 中:界面拿不到主页面的数据,主页面也不会被界面影响。
代理页与业务域天然跨域,配合 iframe sandbox 与 postMessage 的 targetOrigin 锁定,实现 UI 隔离与权限边界;
代理页只转发消息、不理解语义,真正的资源拉取与工具调用由业务页 Host 完成后端调用;
iframe sandbox 属性:allow-scripts allow-same-origin allow-forms allow-popups;
CSP 由资源 resource_meta.ui.csp 生成并注入内层 iframe;
HTML 含 <script type="module"> 时改用 srcdoc 注入(与代理页同源,module 可正常执行),普通 script 走 document.write 注入。

1.4 消息通信

界面 HTML 通过 ext-apps 库拿到一个 App 实例,与 Host 之间走 JSON-RPC over postMessage。Host 与界面之间的通信都是异步消息,没有任何 DOM/全局变量泄漏到主页面。

二、接入步骤

1. Server 端注册 tool:tool 定义里加 _meta.ui.resourceUri = 'ui:///'。
2. Server 端注册 resource handler:返回 mimeType: 'text/html;profile=mcp-app',text 字段塞 HTML。
3. Server 端 tool 实现:在 CallToolResult 里也带 _meta.ui.resourceUri,并把交互式界面需要的数据放在 structuredContent。
4. HTML 模板:head 用 color-scheme: light dark + light-dark() 兜底;script 里 new App({ autoResize: true }).connect(),挂 app.ontoolresult 处理工具结果、app.ontoolinput 处理数据未就绪场景。
5. 联调:在支持的入口触发该 tool,看交互式界面渲染与反向调用效果。

三、完整的加载 & 交互流程

以"用户输入 查看有哪些 todo → 模型调用 show_todos → 用户在交互式界面里点添加"为例:



要点:
1. HTML 由 Host 预取:拿到 ResourceUri 后 Host 主动拉取 HTML(与 iframe 装载并发),两者都就绪时才把 HTML 下发给代理页注入,交互式界面启动后无需再回拉资源。
2. toolResult 由 Host 推送、不是交互式界面拉取:握手完成后 Host 主动重放工具结果,保证交互式界面状态与对话上下文同步。
3. 数据可能分步到达:structuredContent 未就绪时先推 tool-input,补齐后再补推 tool-result,交互式界面需幂等处理。
4. 反向工具调用不经过模型:由 Host 调后端接口,可在原对话结束之后的历史会话里继续发生;ThreadId / PluginId 由 Host 从会话上下文补齐,交互式界面无需感知。
5. isError: true 是业务失败(接口层仍为成功响应),界面需自行判断并给出提示。

四、Server 端接入示例

以下示例用于说明字段契约。服务端用官方 @modelcontextprotocol/sdk,_meta.ui 字段定义见 MCP Apps spec types。

const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
const {
CallToolRequestSchema,
ListToolsRequestSchema,
ListResourcesRequestSchema,
ReadResourceRequestSchema,
} = require('@modelcontextprotocol/sdk/types.js');

const UI_MIME = 'text/html;profile=mcp-app';
const TODO_URI = 'ui://my-todo/list';

const server = new Server(
{ name: 'my-todo', version: '0.1.0' },
{ capabilities: { tools: {}, resources: {} } },
);

const todos = [];

server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'show_todos',
description: '展示待办列表交互式界面',
inputSchema: { type: 'object', properties: {} },
// 关键:声明 UI Resource,Host 见到此字段就渲染交互式界面
_meta: { ui: { resourceUri: TODO_URI } },
},
{
name: 'add_todo',
description: 'Add a new todo',
inputSchema: {
type: 'object',
properties: { title: { type: 'string' } },
required: ['title'],
},
},
],
}));

server.setRequestHandler(CallToolRequestSchema, async req => {
if (req.params.name === 'show_todos') {
return {
content: [{ type: 'text', text: `${todos.length} todos` }],
// structuredContent 会作为 toolResult 推送给交互式界面
structuredContent: { items: todos },
// 关键:tool result 也带 _meta.ui,让 Host 关联到交互式界面
_meta: { ui: { resourceUri: TODO_URI } },
};
}
if (req.params.name === 'add_todo') {
todos.push({ id: todos.length + 1, title: req.params.arguments.title });
return {
content: [{ type: 'text', text: 'Added' }],
structuredContent: { items: todos },
};
}
});

server.setRequestHandler(ListResourcesRequestSchema, async () => ({
resources: [{ uri: TODO_URI, name: 'todo-list', mimeType: UI_MIME }],
}));

server.setRequestHandler(ReadResourceRequestSchema, async req => {
if (req.params.uri === TODO_URI) {
return {
contents: [{
uri: TODO_URI,
text: HTML, // 见「五、App(HTML)开发」
mimeType: UI_MIME,
_meta: {
ui: {
// CSP:声明交互式界面运行时需要的外部域名。平台侧会把它映射为
// ReadMCPResource 响应的 resource_meta,对话端据此生成内层 iframe 的 CSP。
// **未列出的域名将被浏览器拦截。**
csp: {
// 允许通过 ESM 远端引入 ext-apps(script-src / connect-src 都需要 esm.sh);
// 离线/内网改为自托管 SDK 时,把 esm.sh 换成自托管域名
resourceDomains: ['https://esm.sh'],
// 如交互式界面还会调外部 API(fetch / WebSocket),把对应域名加这里
connectDomains: ['https://esm.sh'],
},
},
},
}],
};
}
});
structuredContent 是交互式界面的数据来源,Host 在握手完成后把完整 CallToolResult 推给交互式界面。
注意:
structuredContent 为空、或只含 viewUUID / _meta 等标识字段时,被视为数据残缺:交互式界面先加载外壳,等 ToolResult 补齐后再补推一次 tool-result。因此交互式界面必须同时挂载 ontoolinput 与 ontoolresult,并按 _meta.viewUUID 做幂等去重。

五、App(HTML)开发

5.1 HTML 模板

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<style>
/* 主题跟随宿主:light-dark() 兜底 + data-theme 显式锁定 */
:root {
color-scheme: light dark;
--bg: light-dark(#fff, #1e1e1e);
--fg: light-dark(#1a1a1a, #e6e6e6);
}
html[data-theme="light"] { color-scheme: light }
html[data-theme="dark"] { color-scheme: dark }
body { margin: 0; padding: 12px; background: var(--bg); color: var(--fg); }
</style>
</head>
<body>
<ul id="list"></ul>
<button id="add">添加</button>

<script type="module">
// 远端 ESM 引入 ext-apps(自带依赖的 bundle,浏览器直接 import)
// 锁版本到 1.x;生产环境建议改成具体 patch 版(如 @1.7.4)
import { App } from 'https://esm.sh/@modelcontextprotocol/ext-apps@1/app-with-deps';

const app = new App({
name: 'todo-app',
version: '1.0.0',
autoResize: true, // 自动按内容上报 size-changed
});

function render(items) {
const list = document.getElementById('list');
list.textContent = '';
for (const t of items) {
const li = document.createElement('li');
li.textContent = t.title;
list.appendChild(li);
}
}

// 数据主通道:Host 在握手后推送完整 CallToolResult(可能延后到达,需幂等)
let lastUuid = '';
app.ontoolresult = (r) => {
const uuid = (r && r._meta && r._meta.viewUUID) || '';
if (uuid && uuid === lastUuid) return; // 去重
lastUuid = uuid;
if (r?.structuredContent?.items) render(r.structuredContent.items);
};

// 数据未就绪时先收到 tool-input(仅含上下文标识),渲染骨架屏即可
app.ontoolinput = (input) => {
console.log('[mcp-app] tool-input', input); // { toolInput: { thread_id, plugin_id, resource_uri } }
};

// 主题:从 initialize 结果取 hostContext.theme(当前版本后续不再推送变更)
function applyTheme(theme) {
if (!theme) return;
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.style.colorScheme = theme;
}

document.getElementById('add').onclick = async () => {
// 反向调用同一插件下的工具:服务端按 plugin 工具列表校验 + CAM 鉴权
const res = await app.callServerTool({
name: 'add_todo',
arguments: { title: `todo ${Date.now()}` },
});
if (res?.isError) { console.warn('调用失败', res); return; }
if (res?.structuredContent?.items) render(res.structuredContent.items);
};

await app.connect();
applyTheme(app.hostContext?.theme);
</script>
</body>
</html>

六、Host 侧 iframe 模板

以下为装载交互式界面的 iframe 实现模板。

6.1 外层 iframe(业务页)

<iframe
src="https://example.com/proxy"
sandbox="allow-scripts allow-forms"
referrerpolicy="no-referrer"
title="代理页"
style="width: 100%; height: 50px; border: 0"
></iframe>
src 指向自供的代理页,与业务页不同域(本地联调可用同源副本);
iframe 必须有正常高度:height: 0 / display: none 会导致代理页不加载 → 收不到 sandbox-proxy-ready → HTML 永不注入(死锁)。初始给 50px 占位,随后按交互式界面上报的 size-changed 撑开,上限 600px,超出后交互式界面内部滚动;
实例标识用 ResourceUri:同一条 mcp_app 会随 content.added / message.done / response.completed 多次更新,标识不稳定会导致 iframe 重建、重复拉取 HTML。
Host 需要下发与应答:向代理页下发 sandbox-resource-ready(HTML + sandbox + CSP),并应答交互式界面上抛的 ui/initialize、tools/call 等 JSON-RPC 请求(完整时序见第三章)。

6.2 代理页(sandbox 代理)

<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="color-scheme" content="light dark" />
<title>MCP-App Proxy</title>
<style>
html, body { margin: 0; height: 100%; width: 100%; }
body { display: flex; flex-direction: column; background: #fff; }
iframe { border: 0; padding: 0; width: 100%; height: 100%;
overflow: auto; /* 内容超过外层高度上限时内部滚动 */ }
</style>
</head>
<body>
<script>
var PROXY_READY_NOTIFICATION = 'ui/notifications/sandbox-proxy-ready';
var RESOURCE_READY_NOTIFICATION = 'ui/notifications/sandbox-resource-ready';

var OWN_ORIGIN = location.origin;
var hostOrigin = ''; // 握手后锁定的业务页 origin

// 允许的业务页 origin 白名单(新增域名在此追加)
var ALLOWED_ORIGINS = ['https://adp.cloud.tencent.com'];

var inner = document.createElement('iframe');
inner.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms');
document.body.appendChild(inner);

function isAllowedOrigin (origin) {
return !!origin && ALLOWED_ORIGINS.indexOf(origin) !== -1;
}

// 注入界面 HTML:module script 用 srcdoc(document.write 注入的 module 不执行)
function renderHtml (html) {
if (typeof html !== 'string') return;
var isModuleScript = /<script[^>]*type\\s*=\\s*["']module["']/i.test(html);
var doc = inner.contentDocument || (inner.contentWindow && inner.contentWindow.document);
if (isModuleScript || !doc) {
inner.srcdoc = html;
} else {
doc.open(); doc.write(html); doc.close();
}
}

// 承接 Host 下发的资源:设置权限 + CSP,再注入 HTML
function handleResourceReady (params) {
params = params || {};
if (typeof params.sandbox === 'string') inner.setAttribute('sandbox', params.sandbox);
if (typeof params.allow === 'string') inner.setAttribute('allow', params.allow);
if (typeof params.csp === 'string' && params.csp) inner.setAttribute('csp', params.csp);
renderHtml(params.html);
}

window.addEventListener('message', function (event) {
var data = event.data;

// 方向 A:Host(业务页)→ 本代理页
if (event.source === window.parent) {
if (!isAllowedOrigin(event.origin)) return; // 非白名单来源直接拒绝
hostOrigin = event.origin;

if (data && data.method === RESOURCE_READY_NOTIFICATION) {
handleResourceReady(data.params); // 本地消费,不下发给交互式界面
return;
}
// sandbox-* 前缀通知由代理页自身消费,不转发
if (data && typeof data.method === 'string' &&
data.method.indexOf('ui/notifications/sandbox-') === 0) return;

// 其余 Host → 交互式界面的 JSON-RPC 透明转发
if (inner && inner.contentWindow) inner.contentWindow.postMessage(data, OWN_ORIGIN);
return;
}

// 方向 B:交互式界面 → 本代理页 → Host(initialize / tools / call / size-changed 等原样上抛)
if (event.source === (inner && inner.contentWindow)) {
if (event.origin !== OWN_ORIGIN) return;
if (hostOrigin) window.parent.postMessage(data, hostOrigin); // 不用 '*'
}
});

// 通知 Host:代理页已就绪,可以下发交互式界面资源
window.parent.postMessage(
{ jsonrpc: '2.0', method: PROXY_READY_NOTIFICATION, params: {} }, '*'
);
</script>
</body>
</html>
要点:
1. 只转发不理解:代理页不解析业务语义,ui/initialize、tools/call 等均由业务页 Host 处理;sandbox-* 前缀通知由代理页本地消费。
2. origin 双向锁定:入向校验业务域白名单(本地联调时可额外放行 localhost / 127.0.0.1),出向用握手锁定的 hostOrigin,不用 *;内层交互式界面与代理页同源,但与业务域跨域,因此对业务域仍隔离。
3. module script 用 srcdoc:document.write 注入的内联 <script type="module"> 不会执行,检测到 module 时改用 srcdoc(同源,module 可正常执行)。

七、实时消息(WS)链路对接

Host 通过 WebSocket 接收实时消息,mcp_app 作为消息的一个 Content 下推。下面是 Host(对话端)侧的对接方式。

7.1 事件与消息结构

WS 下行事件字段为 PascalCase:
事件
payload
对话端处理
content.added
{ MessageId, ContentIndex, Content }
预置该 Content(预热,此时可能只有 ResourceUri,ToolResult 未到位)
message.added / message.done
{ Message: { MessageId, Type, Contents } }
按 MessageId 合并到对应消息;message.done 会补齐 ToolResult
response.completed
{ Response: { RecordId, Messages } }
全量重放本轮消息
mcp_app Content 的结构:
{
"Type": "mcp_app",
"McpApp": {
"ThreadId": "thread_xxx",
"PluginId": "plugin_xxx",
"ResourceUri": "ui://my-todo/list",
"ToolResult": "{\\"content\\":[{\\"type\\":\\"text\\",\\"text\\":\\"3 todos\\"}],\\"structuredContent\\":{\\"items\\":[{\\"id\\":1,\\"title\\":\\"写周报\\"}]},\\"_meta\\":{\\"viewUUID\\":\\"v1\\"}}"
}
}
ToolResult 是完整 CallToolResult 的 JSON 字符串,可随 message.done 才补齐;
ThreadId / PluginId / ResourceUri 是后续拉取 HTML 与调用工具的必需参数。

7.2 一个实时链路的事件序列

// 1) 预热:只有资源标识,无数据 → 交互式界面先加载外壳
{ "Type": "content.added", "MessageId": "rpl_001", "ContentIndex": 0,
"Content": { "Type": "mcp_app",
"McpApp": { "ThreadId": "thread_xxx", "PluginId": "plugin_xxx",
"ResourceUri": "ui://my-todo/list" } } }

// 2) 工具执行完:补 ToolResult → 对话端补推 ui/notifications/tool-result
{ "Type": "message.done", "Message": { "MessageId": "rpl_001", "Type": "tool_call",
"Contents": [ { "Type": "mcp_app", "McpApp": { "ThreadId": "thread_xxx",
"PluginId": "plugin_xxx", "ResourceUri": "ui://my-todo/list",
"ToolResult": "{...structuredContent...}" } } ] } }

// 3) 本轮结束:全量重放(同一条消息再次到达)
{ "Type": "response.completed", "Response": { "RecordId": "rec_001", "Messages": [ ... ] } }

7.3 对话端消费要点

1. 两种消息承载:mcp_app 既可挂在 tool_call 消息的 Contents 里,也可挂在 reply 消息里,两者渲染逻辑一致;只在开头列出的入口生效。
2. 实例标识用 ResourceUri:同一条 mcp_app 消息会经历 content.added → message.done → response.completed 多次更新,若标识依赖 MessageId,会导致 iframe 重建并重复拉取 HTML。
3. HTML 按 {ThreadId}:{PluginId}:{Uri} 缓存:Host 会缓存拉取结果,同一资源在多次重放中只请求一次。
4. 结果按 viewUUID 幂等:ToolResult 补齐后会再补推一次 tool-result,交互式界面侧需按 _meta.viewUUID 去重(见第四节注意事项)。

7.4 云 API(CAPI)接口对接

(1)ReadMCPResource —— 拉取界面 HTML

Host 拿到 ResourceUri 后立即请求;HTML 到手后等代理页就绪再下发,交互式界面启动后无需再回拉。
import json

req = models.ReadMCPResourceRequest()
req.ThreadId = thread_id # 来自 mcp_app 内容
req.PluginId = plugin_id
req.Uri = "ui://my-todo/list"
resp = client.ReadMCPResource(req)

# 取 HTML:只认 MimeType 以 text/html 开头的资源
html_content = next(c for c in resp.Contents if c.MimeType.startswith("text/html"))
html = html_content.Text
resource_meta = resp.ResourceMeta # 用于生成内层 iframe 的 CSP
{
"Contents": [
{ "Uri": "ui://my-todo/list",
"MimeType": "text/html;profile=mcp-app",
"Text": "<!DOCTYPE html>..." }
],
"ResourceMeta": "{\\"ui\\":{\\"csp\\":{\\"connectDomains\\":[\\"https://esm.sh\\"],\\"resourceDomains\\":[\\"https://esm.sh\\"]}}}"
}
ThreadId / PluginId 缺失时 Host 直接判失败,不发请求;
只取 MimeType 以 text/html 开头的资源,避免误取同响应里的非 HTML 资源;
交互式界面的 HTML 由 {ThreadId}:{PluginId}:{Uri} 唯一决定,可自行缓存,多次重放无需重复请求。

(2)CallMCPTool —— 调用 MCP 工具

用于交互式界面反向调用工具,也可由接入方在服务端主动发起(该调用不经过模型,可在原对话结束之后的历史会话里继续发生)。
req = models.CallMCPToolRequest()
req.ThreadId = thread_id
req.PluginId = plugin_id
req.ToolName = "add_todo"
req.Arguments = json.dumps({"title": "写周报"}) # Arguments 为 JSON 字符串
resp = client.CallMCPTool(req)

result = json.loads(resp.Result) # 完整 CallToolResult
{
"Result": "{\\"content\\":[{\\"type\\":\\"text\\",\\"text\\":\\"Added\\"}],\\"structuredContent\\":{\\"items\\":[]}}"
}
Host 把 Result 解析后按原 JSON-RPC id 回给交互式界面,结构原样透传(含 structuredContent / isError / _meta);
ThreadId / PluginId 缺失 → 回错误码 -32001;接口异常 → 回 -32000,server 不会被实际调用。

(3)DescribeConversationMessageList —— 历史消息与交互式界面重建

刷新页面、重新进入会话等场景走历史消息接口,mcp_app 内容与其 ToolResult 一并返回,用于重建交互式界面。
req = models.DescribeConversationMessageListRequest()
req.ConversationId = conversation_id
req.RecordId = record_id or "" # 翻页游标,空表示从最新开始
req.Limit = 50 # 单次最多 50
resp = client.DescribeConversationMessageList(req)
{
"MessageList": [
{
"MessageId": "rpl_001",
"Type": "tool_call",
"Contents": [
{ "Type": "mcp_app",
"McpApp": {
"ThreadId": "thread_xxx",
"PluginId": "plugin_xxx",
"ResourceUri": "ui://my-todo/list",
"ToolResult": "{\\"structuredContent\\":{\\"items\\":[{\\"id\\":1,\\"title\\":\\"写周报\\"}]},\\"_meta\\":{\\"viewUUID\\":\\"v1\\"}}"
} }
]
}
],
"FirstRecordId": "rec_001",
"LastRecordId": "rec_050",
"HasMoreBefore": true,
"HasMoreAfter": false
}
历史场景要点:
1. 数据已内联:历史消息里 ToolResult 是完整的 CallToolResult,握手完成后直接重放 ui/notifications/tool-result,交互式界面立即渲染,无需等实时补齐。
2. 仍需幂等:ToolResult 可能在翻页/重放中重复到达,交互式界面按 _meta.viewUUID 去重。
3. 与实时链路同构:历史消息与实时推送携带的 mcp_app 结构一致(均含 ThreadId / PluginId / ResourceUri / ToolResult),渲染逻辑复用一套。

八、安全模型

8.1 Sandbox 隔离

第三方 HTML 跑在隔离的 sandbox iframe 中:业务页 → 代理页 → 界面。
iframe sandbox 属性:allow-scripts allow-same-origin allow-forms allow-popups。
资源 _meta.ui.csp 生成 CSP 后注入内层 iframe,未声明的域名一律被浏览器拦截。
代理页只接受白名单业务域的消息;自建宿主或使用私有化 / 自定义域名时,需把业务域名加入代理页白名单。

8.2 授权机制

反向 tools/call 无前端授权弹窗,改为服务端校验:
校验
说明
能力边界
ToolName 必须属于当前 PluginId 的工具列表,不在列表内直接拒绝。
归属校验
后端按 ThreadId 校验会话归属,不一致直接拒绝。
鉴权
由服务端用对应 ToolId 装配 CAM 鉴权。
身份字段
应用 / 用户 / Agent 等身份信息由后端从会话态补齐,前端不接受也不提交。
ThreadId / PluginId 缺失时,Host 直接拒绝调用并回错误码 -32001,server 不会被实际调用。

8.3 外链与日志

ui/open-link:Host 用 window.open 打开,当前版本不做 scheme 校验,建议只传 http:// 或 https://。
notifications/message(sendLog):Host 只上抛给宿主页面,当前未做界面展示。

九、机制小结

Server 契约:UI Resource(text/html;profile=mcp-app) + tool _meta.ui.resourceUri
+ CallToolResult.structuredContent + resource_meta.ui.csp
↓
对话触发:模型调该 tool → 消息 Content.Type = 'mcp_app'
↓ (WS:content.added 预热 → message.done 补 ToolResult → response.completed 重放)

Host 渲染:ReadMCPResource → 双层沙箱 iframe → ui/initialize → 重放 tool-input / tool-result
↓
反向交互:交互式界面 tools/call → CallMCPTool → 结果回传交互式界面
环节
责任方
关键点
声明 UI Resource
MCP Server
MIME = text/html;profile=mcp-app
关联 tool 与交互式界面
MCP Server
_meta.ui.resourceUri
提供渲染数据
MCP Server
CallToolResult.structuredContent
声明 CSP 域名
MCP Server
resource_meta.ui.csp.{resourceDomains,connectDomains}
交互式界面开发
MCP Server
ext-apps SDK 1.x;同时处理 ontoolinput 与 ontoolresult 并按 viewUUID 幂等
渲染与鉴权
对话端
双层沙箱 iframe;tools/call 服务端按 plugin 工具列表校验 + CAM 鉴权

十、参考资源

协议与 SDK

MCP Apps spec & 参考实现:modelcontextprotocol/ext-apps(npm:@modelcontextprotocol/ext-apps)
MCP Apps protocol types:spec.types.ts
MCP 主协议规范:Model Context Protocol
官方示例 server:ext-apps/examples

Web 标准