这篇适合谁、读完能做到什么
如果你的 MCP 服务器已经能返回数据,但每次都要再写一套前端去展示,这篇适合你。读完你能说清 MCP Apps 的工作方式,知道它由哪几个文件组成,写出一个带按钮和输入框的小应用,并在 VS Code 里让它跑起来。[1]
MCP Apps 是什么
MCP Apps 是 MCP 里的一种新做法。工具的返回结果不仅可以包含数据,还可以包含界面信息,告诉宿主应用这些数据应该怎样被展示和操作。换句话说,服务器返回的是一个可以渲染的组件,组件里既有视觉元素,也有按钮点击、用户输入等交互。[1]
这样做的动机是:平时使用 MCP 服务器,往往需要在它前面放一个前端,那是你必须编写和维护的代码。有时这是必要的,但如果能带上一小段自包含的内容,同时包含数据和界面,维护成本就会低很多。[1] 需要说明的是,MCP Apps 是 MCP 标准里非常新的一部分。[1]
一个 MCP App 由哪几部分组成
创建一个 MCP App 组件,需要一个工具,加上一个应用资源(resource)。两者通过 resourceUri 连接起来。[1]
示例项目分三个文件:server.ts 负责注册工具,以及把组件注册为 UI 资源;src/mcp-app.ts 负责绑定事件处理函数;mcp-app.html 是用户界面:[1] 按文件去读时,先看 server.ts 里的注册,再看 mcp-app.ts 里的事件绑定,最后看 mcp-app.html 里的界面,这样最容易理清每一段代码的职责。[1]
server.ts -- responsible for registering tools and the component as a UI component
src/
mcp-app.ts -- wiring up event handlers
mcp-app.html -- the user interface运行时,组件被放在宿主应用的 IFrame 里。它要调用 MCP 服务器时,并不直接连接服务器,而是向父页面发送消息,由父页面去调用服务器。这是出于安全考虑,所以这类应用运行在 IFrame 中。[1]
这种分层意味着界面代码通过 app 对象间接调用服务器,而不是直接访问它。对初学者来说,最容易混淆的是:按钮点击的处理函数在界面里,工具本身却在服务器上。[1]
后端:注册工具和界面资源
后端要做两件事:注册你想让界面调用的工具,以及定义组件本身。[1]
先看工具的注册。下面这段代码注册了一个叫 get-time 的工具,它不接收任何输入,返回当前服务器时间。_meta: { ui: { resourceUri } } 这一行把这个工具和它的界面资源连接起来:[1]
registerAppTool(
server,
"get-time",
{
title: "Get Time",
description: "Returns the current server time.",
inputSchema: {},
_meta: { ui: { resourceUri } }, // Links this tool to its UI resource
},
async () => {
const time = new Date().toISOString();
return { content: [{ type: "text", text: time }] };
},
);
get-time 的处理函数用 new Date().toISOString() 生成时间字符串,并以文本内容返回。如果工具需要用户输入,可以定义 inputSchema,这一点在后面的 FAQ 例子里会用到。[1]
再看组件的注册。registerAppResource 的回调会读取 mcp-app.html,把打包好的 HTML 和 JavaScript 作为内容返回。这个资源的 URI 是 ui://get-time/mcp-app.html:[1]
flowchart LR
subgraph Backend[Backend: MCP Server]
T["Register tools: registerAppTool()"]
C["Register component resource: registerAppResource()"]
U[resourceUri]
T --- U
C --- U
end
subgraph Parent[Parent Web Page]
H[Host application]
IFRAME[IFrame container]
H -->|Inject MCP App UI| IFRAME
end
subgraph Frontend[Frontend: MCP App inside IFrame]
UI["User interface: mcp-app.html"]
EH["Event handlers: src/mcp-app.ts"]
UI --> EH
end
IFRAME --> UI
EH -->|Click triggers server tool call| T
T -->|Tool result data| EH
EH -->|Send message to parent page| H注意这里的 resourceUri,它就是工具和组件之间的纽带。[1]
前端:HTML 界面和事件绑定
前端由两部分组成:用纯 HTML 写的界面,以及处理事件、调用工具或给父窗口发消息的代码。[1]
界面很简单,只有一个显示服务器时间的位置、一个按钮,以及加载脚本的标签:[1]
<!-- mcp-app.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Get Time App</title>
</head>
<body>
<p>
<strong>Server Time:</strong> <code id="server-time">Loading...</code>
</p>
<button id="get-time-btn">Get Server Time</button>
<script type="module" src="/src/mcp-app.ts"></script>
</body>
</html>事件绑定写在 mcp-app.ts 里。它从 @modelcontextprotocol/ext-apps 导入 App,创建一个名为 Get Time App、版本为 1.0.0 的应用实例:[1]
// mcp-app.ts
import { App } from "@modelcontextprotocol/ext-apps";
// Get element references
const serverTimeEl = document.getElementById("server-time")!;
const getTimeBtn = document.getElementById("get-time-btn")!;
// Create app instance
const app = new App({ name: "Get Time App", version: "1.0.0" });
// Handle tool results from the server. Set before `app.connect()` to avoid
// missing the initial tool result.
app.ontoolresult = (result) => {接着,代码设置 app.ontoolresult 来处理工具返回的结果,给按钮加点击事件,最后调用 app.connect():[1]
const time = result.content?.find((c) => c.type === "text")?.text;
serverTimeEl.textContent = time ?? "[ERROR]";
};
// Wire up button click
getTimeBtn.addEventListener("click", async () => {
// `app.callServerTool()` lets the UI request fresh data from the server
const result = await app.callServerTool({ name: "get-time", arguments: {} });
const time = result.content?.find((c) => c.type === "text")?.text;
serverTimeEl.textContent = time ?? "[ERROR]";
});
// Connect to host
app.connect();这里有一个顺序上的细节:app.ontoolresult 必须在 app.connect() 之前设置,否则可能漏掉初始的工具结果。[1]
按钮点击时,代码调用 app.callServerTool(),让界面向服务器请求新的数据。它接收 name 和 arguments 两个参数。调用时,它实际上是向父窗口发送消息,再由父窗口去调用 MCP 服务器。[1]
用户输入:做一个 FAQ 查询
上面的例子只有一个按钮。下面加一个输入框,让用户输入关键词,比如「shipping」,然后调用后端的 FAQ 搜索工具。[1] 这个例子的重点是数据的一个完整来回:用户输入,界面调用工具,拿到结果,再更新页面。把这个来回看懂以后,就可以把输入框换成下拉列表,或者把按钮换成表单,逻辑是一样的。[1]
后端先注册 get-faq 工具。它的输入参数 query 是可选的,默认值为 shipping,这是用 zod 声明的:[1]
const faq: { [key: string]: string } = {
"shipping": "Our standard shipping time is 3-5 business days.",
"return policy": "You can return any item within 30 days of purchase.",
"warranty": "All products come with a 1-year warranty covering manufacturing defects.",
}
registerAppTool(
server,
"get-faq",
{
title: "Search FAQ",
description: "Searches the FAQ for relevant answers.",
inputSchema: zod.object({
query: zod.string().default("shipping"),
}),
_meta: { ui: { resourceUri: faqResourceUri } }, // Links this tool to its UI resource
},
async ({ query }) => {
const answer: string = faq[query.toLowerCase()] || "Sorry, I don't have an answer for that.";
return { content: [{ type: "text", text: answer }] };
},
);下面是完整的 FAQ 数据和工具处理函数。工具把输入转成小写后去 FAQ 表里查找,找不到时返回一句道歉的话:[1]
const faq: { [key: string]: string } = {
"shipping": "Our standard shipping time is 3-5 business days.",
"return policy": "You can return any item within 30 days of purchase.",
"warranty": "All products come with a 1-year warranty covering manufacturing defects.",
}
registerAppTool(
server,
"get-faq",
{
title: "Search FAQ",
description: "Searches the FAQ for relevant answers.",
inputSchema: zod.object({
query: zod.string().default("shipping"),
}),
_meta: { ui: { resourceUri: faqResourceUri } }, // Links this tool to its UI resource
},
async ({ query }) => {
const answer: string = faq[query.toLowerCase()] || "Sorry, I don't have an answer for that.";
return { content: [{ type: "text", text: answer }] };
},
);前端界面增加了一个文本输入框(faq-query)和一个按钮(get-faq-btn):[1]
<div class="faq">
<h1>FAQ response</h1>
<p>FAQ Response: <code id="faq-response">Loading...</code></p>
<input type="text" id="faq-query" placeholder="Enter FAQ query" />
<button id="get-faq-btn">Get FAQ Response</button>
</div>最后在 mcp-app.ts 里绑定按钮:读取输入框的值,把它作为 query 参数传给 get-faq,再把返回的文本显示出来:[1]
inputSchema 里的 query 有默认值 shipping,所以即使界面没有传参,工具也能返回一个答复。界面的代码则要注意取值的兜底:找不到文本内容时,页面显示 [ERROR]。[1]
const getFaqBtn = document.getElementById("get-faq-btn")!;
const faqQueryInput = document.getElementById("faq-query") as HTMLInputElement;
getFaqBtn.addEventListener("click", async () => {
const query = faqQueryInput.value;
const result = await app.callServerTool({ name: "get-faq", arguments: { query } });
const faq = result.content?.find((c) => c.type === "text")?.text;
faqResponseEl.textContent = faq ?? "[ERROR]";
});在 VS Code 里测试
VS Code 对 MCP Apps 的支持很好,是最省事的测试方法之一。在 mcp.json 里添加一个服务器条目,用 url 指向服务器的 /mcp 端点,类型设为 http:[1]
"my-mcp-server-7178eca7": {
"url": "http://localhost:3001/mcp",
"type": "http"
}启动服务器,并确保已经安装 GitHub Copilot。然后在 Chat 窗口里输入 #get-faq 之类的提示,界面就会以和浏览器里相同的方式渲染出来。[1]
把这个流程归纳起来,VS Code 的测试分三步:在 mcp.json 里添加服务器条目,启动服务器,然后在 Chat 窗口里用提示词调用它。[1]
练习:做一个石头剪刀布
练习要求你做一个石头剪刀布游戏。界面部分包括:一个下拉列表用来选择选项,一个按钮用来提交选择,一个标签显示双方各出了什么,以及谁赢了。服务器部分要有一个石头剪刀布工具,接收 choice 作为输入,生成电脑的选择,并判断胜负。[1]
要记住的几点
MCP Apps 的核心是让服务器不只决定数据,也参与决定数据如何呈现。它运行在 IFrame 里,通过父页面与服务器通信。JavaScript 和 React 都有一些库,可以简化这种通信。[1]
本文改编自 Microsoft 开源课程《MCP for Beginners》第 3 课中关于 MCP Apps 的一节[1],原文以 MIT 许可证发布。
来源
- 1MCP AppsMicrosoft mcp-for-beginners (MIT)
引用本页: MCP Apps:让工具返回交互界面. TellWorld, https://www.tellworld.net/zh/practice/practice-mcp-apps. 更新于 2026-11-14