- 教程
- 文档
- 人工智能
【免费下载链接】mcp-for-beginners
This open-source curriculum introduces the fundamentals of Model Context Protocol (MCP) through real-world, cross-language examples in .NET, Java, TypeScript, JavaScript, Rust and Python. Designed for developers, it focuses on practical techniques for building modular, scalable, and secure AI workflows from session setup to service orchestration.
导读
本文围绕 mcp-for-beginners 课程第 15 课(MCP Apps)的作业展开,完整讲解如何基于 Model Context Protocol 的 MCP Apps 新范式,构建一个"数据 + 用户界面"自包含的交互式组件。你将掌握 MCP App 的两段式注册(registerAppTool+registerAppResource)、resourceUri关联机制、IFrame 内事件绑定与callServerTool通信方式,并最终交付一个可运行的石头剪刀布游戏及其在 Visual Studio Code 中的测试方法。
什么是 MCP Apps:让工具结果自带 UI
MCP Apps 是 MCP 生态中的一种新范式。传统模式下,MCP Server 只负责在工具调用后返回数据,消费这些数据还需要开发者自建前端来展示,这部分代码需要单独编写与维护。MCP Apps 的核心想法是:工具的结果不仅包含数据,还包含这些数据应该如何被交互的信息——工具结果可以直接携带 UI 信息,形成从数据到用户界面完全自包含的代码片段。
这带来的直接价值是:当你想为某个 MCP Server 快速提供一个带界面的入口时,不再需要维护一套独立前端,而是让服务端直接"发布"一个小型、自包含的 UI 组件(MCP App)。正如课程 15-mcp-apps 章节 所总结的:MCP Apps 是 MCP 标准中非常新的一项补充,适用于同时交付数据与 UI 功能的场景。
MCP App 的组成与工作原理
一个 MCP App 在服务端由"两半"组成,并通过一个resourceUri把它们连接起来:
- 工具(Tool):提供业务能力,接受输入并返回数据;
- 应用资源(Application Resource):提供可被渲染的组件(HTML/JavaScript),即 UI。
从仓库的目录结构可以直观看到这种分工(见 code/typescript/README.md):
server.ts -- 负责注册工具,并把组件注册为 UI 资源 src/ mcp-app.ts -- 事件处理器装配(event wire up) mcp-app.html -- 用户界面课程文档用一张流程图描述了完整的运行时架构:后端 MCP Server 中registerAppTool()注册工具、registerAppResource()注册组件资源,二者通过resourceUri关联;前端方面,宿主应用(Parent Web Page)把 MCP App 的 UI 注入到 IFrame 容器中,IFrame 内的mcp-app.html负责渲染界面,src/mcp-app.ts负责事件处理;当用户在界面中点击按钮时,事件处理器调用服务端工具,工具结果数据回传给事件处理器,再由事件处理器向父页面发送消息(参见 15-mcp-apps/README.md 中的架构说明)。
关键的安全设计是:这些 MCP Apps 出于安全原因运行在 IFrame 中,与宿主的通信需要通过向父 Web 应用发送消息来完成。这也是课程反复强调的要点。
后端实现:两段式注册
后端需要完成两件事:注册要交互的工具,以及定义组件(应用资源)。
注册工具:registerAppTool
先看一个最简单的工具get-time,它不需要输入参数,直接返回当前服务器时间:
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 }] }; }, );这段代码与普通 MCP 工具注册的最大区别在于_meta: { ui: { resourceUri } }——正是这一行把工具链接到它的 UI 资源。宿主调用该工具时,会读取_meta.ui.resourceUri来决定去获取并渲染哪个资源作为交互式 UI(该机制在 code 目录的 server.ts 中同样有明确注释说明)。
注册组件:registerAppResource
在同一个文件中,还需要注册组件资源。资源 URI 与工具通过同一个resourceUri关联:
const resourceUri = "ui://get-time/mcp-app.html"; // Register the resource, which returns the bundled HTML/JavaScript for the UI. registerAppResource( server, resourceUri, resourceUri, { mimeType: RESOURCE_MIME_TYPE }, async () => { const html = await fs.readFile(path.join(DIST_DIR, "mcp-app.html"), "utf-8"); return { contents: [ { uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }, ], }; }, );值得注意的实现细节是回调函数:它通过fs.readFile从DIST_DIR(即 Vite 构建产物dist目录,见 server.ts 中的DIST_DIR定义)读取打包后的mcp-app.html,再以资源内容的形式返回给宿主。也就是说,前端代码是在服务端通过资源回调"下发"给宿主的,这与传统前后端分离的开发模式完全不同。
前端组件:纯 HTML 界面 + 事件装配
与后端对应,前端也分两部分:纯 HTML 的用户界面,以及负责事件处理、工具调用、向父窗口发消息的脚本。
用户界面:mcp-app.html
<!-- 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>界面本身是再普通不过的 DOM 结构,通过<script type="module">引入打包后的mcp-app.ts。
事件装配:src/mcp-app.ts
事件装配负责"识别 UI 中哪些元素需要事件处理器,以及事件触发时做什么":
// 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) => { 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();这里的核心 API 有两个:
app.ontoolresult:处理来自服务端的工具结果。代码注释特别提示它必须在app.connect()之前设置,以免错过初始的工具结果;app.callServerTool():让 UI 主动向服务端请求新数据。它内部会向父窗口发送消息,由父窗口最终调用 MCP Server——这正好印证了前面"IFrame 内通过消息与宿主通信"的架构描述。
App实例来自@modelcontextprotocol/ext-apps包,该依赖在 code/typescript/my-app/package.json 中声明("@modelcontextprotocol/ext-apps": "^1.1.1")。
处理用户输入:FAQ 搜索示例
前面的get-time组件只有按钮、没有输入。课程随后演示了如何加入输入框并把参数传给工具,以实现 FAQ 搜索功能。
后端:带 zod inputSchema 的工具
后端先定义 FAQ 数据,再注册get-faq工具:
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 }] }; }, );与get-time的关键差异在inputSchema。这里使用 zod 声明了一个名为query的输入参数,它是可选的,且带有默认值"shipping":
inputSchema: zod.object({ query: zod.string().default("shipping"), })工具处理器内通过faq[query.toLowerCase()]做不区分大小写的检索,未命中时返回兜底文案。可以看到_meta.ui.resourceUri指向了另一个 FAQ 专用的资源 URIfaqResourceUri。
前端:输入框 + 按钮 + 事件
对应的 UI 需要加入输入元素和按钮:
<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中补充:
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]"; });这里的模式与get-time完全一致,只是app.callServerTool()的arguments中携带了从输入框读取的query值。整个交互链路是:UI 事件 → callServerTool 发消息给父窗口 → 父窗口调用 MCP Server → 工具结果回传 → ontoolresult/异步返回值更新界面。
输入 "warranty" 后,服务端命中 FAQ 数据并返回保修政策答案:
作业实战:构建石头剪刀布游戏
作业需求
课程在 15-mcp-apps/README.md 的 Assignment 一节 中布置了作业:创建一个石头剪刀布游戏,包含:
- UI:一个下拉列表(选项)、一个提交选择的按钮、一个显示"谁选了什么、谁赢了"的标签;
- Server:一个名为 rock-paper-scissors 的工具,接收
choice作为输入,渲染电脑的选择并判定胜负。
作业解决方案结构
assignment/typescript/README.md 说明了解决方案的组织方式——只保留最核心的三部分代码:
my-app server.ts -- 服务端功能 src mcp-app.ts -- UI 事件装配 mcp-app.html -- UI 标记运行方式很简单:参考 code/typescript/README.md 搭建工程,再把 assignment 目录 中对应文件的内容分别填入各自文件即可。
服务端:play-rps 工具与资源注册
先看服务端的核心实现(完整源码见 assignment/typescript/my-app/server.ts):
registerAppTool( server, "play-rps", { title: "Play Rock-Paper-Scissors", description: "Play a game of rock-paper-scissors with the server.", inputSchema: zod.object({ choice: zod.enum(["rock", "paper", "scissors"]), }), _meta: { ui: { resourceUri } }, // Links this tool to its UI resource }, async ({ choice }) => { const options = ["rock", "paper", "scissors"] as const; const serverChoice = options[Math.floor(Math.random() * options.length)]; let result: string; if (choice === serverChoice) { result = `It's a tie! We both chose ${choice}.`; } else if ( (choice === "rock" && serverChoice === "scissors") || (choice === "paper" && serverChoice === "rock") || (choice === "scissors" && serverChoice === "paper") ) { result = `You win! You chose ${choice} and I chose ${serverChoice}.`; } else { result = `I win! You chose ${choice} and I chose ${serverChoice}.`; } return { content: [ { type: "text", text: result }, ], }; }, );要点分析:
- 输入约束:
inputSchema用zod.enum(["rock", "paper", "scissors"])限定choice只能是三种合法取值之一,非法输入在类型层就被拦截; - 电脑选择:通过
Math.floor(Math.random() * options.length)在三个选项中随机生成serverChoice,实现"渲染电脑的选择"; - 胜负判定:先判断平局,再用三条规则判断玩家胜(剪刀克布、布克石头、石头克剪刀),其余情况为服务端胜;
- 返回格式:返回标准的
content: [{ type: "text", text: result }],结果字符串中同时包含双方选择和胜负信息,满足"显示谁选了什么、谁赢了"的需求。
随后照例注册配套的 UI 资源(与工具共用同一个resourceUri):
registerAppResource( server, resourceUri, resourceUri, { mimeType: RESOURCE_MIME_TYPE }, async () => { const html = await fs.readFile(path.join(DIST_DIR, "mcp-app.html"), "utf-8"); return { contents: [ { uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html, _meta: { ui: {} }, }, ], }; }, );前端:下拉框、按钮与结果标签
UI 标记(完整文件见 assignment/typescript/my-app/mcp-app.html):
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <title>Rock paper scissor</title> </head> <body> <div class="rock-paper-scissors"> <h1>Rock Paper Scissors</h1> <select id="rps-options" value="rock"> <option value="rock">Rock</option> <option value="paper">Paper</option> <option value="scissors">Scissors</option> </select> <button class="select" id="rps-button">Select</button> <p>Result: <code id="rps-result">...</code></p> </div> <script type="module" src="/src/mcp-app.ts"></script> </body> </html>事件装配(完整文件见 assignment/typescript/my-app/src/mcp-app.ts):
import { App } from "@modelcontextprotocol/ext-apps"; // Get element references const serverTimeEl = document.getElementById("server-time")!; // rps const getRpsBtn = document.getElementById("rps-button")!; const rpsResponseEl = document.getElementById("rps-result")!; const rpsOptions = document.getElementById("rps-options") as HTMLSelectElement; // 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) => { const time = result.content?.find((c) => c.type === "text")?.text; serverTimeEl.textContent = time ?? "[ERROR]"; }; getRpsBtn.addEventListener("click", async () => { const userChoice = rpsOptions.value; const result = await app.callServerTool({ name: "play-rps", arguments: { choice: userChoice } }); const rpsResult = result.content?.find((c) => c.type === "text")?.text; rpsResponseEl.textContent = rpsResult ?? "[ERROR]"; }); // Connect to host app.connect();可以看到,它与课程中get-time/get-faq的模式完全同构:先获取 DOM 元素引用,再创建App实例、设置ontoolresult,最后在按钮点击时通过app.callServerTool()携带用户在下拉框中选择的choice调用服务端工具,并把返回文本写入rps-result标签。整份作业的核心价值在于把"下拉列表 + 按钮 + 结果标签"的 UI 需求、"带枚举校验的工具 + 随机电脑选择 + 胜负判定"的服务端需求完整落地。
运行与测试
安装与启动后端
在 code/typescript/my-app 目录下(参考 code/typescript/README.md):
- 运行
npm install,安装前端与后端依赖; - 验证后端能通过编译:
npx tsc --noEmit(一切正常时应无输出); - 启动服务:
npm start,后端将运行在http://localhost:3001/mcp。
package.json(见 code/typescript/my-app/package.json)中的启动脚本使用了concurrently同时跑 Vite 构建(vite build --watch)与后端(tsx watch main.ts)。课程特别提示:Windows 机器上可能需要为concurrently找替代方案,该脚本中同时使用cross-env NODE_ENV=development INPUT=mcp-app.html注入构建入口;另外,如果在 Codespace 中运行,可能需要把端口可见性设为 public。
方式一:在 Visual Studio Code 中测试
Visual Studio Code 对 MCP Apps 有很好的支持,是测试 MCP App 最省事的方式之一。在.vscode/mcp.json(或项目mcp.json)中添加服务端条目(参见 15-mcp-apps/README.md 的 Testing 一节):
"my-mcp-server-7178eca7": { "url": "http://localhost:3001/mcp", "type": "http" }完整的mcp.json结构在 code/typescript/README.md 中有示例(外层包"servers"与"inputs"字段)。接着点击mcp.json中的 start 按钮启动服务,在安装有 GitHub Copilot 的聊天窗口中即可与你的 MCP App 交互。可以通过 prompt 触发,例如输入#get-faq:
与在浏览器中运行一样,它会在聊天界面中以同样的方式渲染 UI:
方式二:使用宿主(Host)应用测试
课程还提供了第二种测试路径:使用独立的宿主应用。@modelcontextprotocol/ext-apps仓库提供了多种可用于测试 MCP Apps 的宿主。在本地机器的做法是:
- 克隆该仓库后进入
ext-apps目录,运行npm install; - 另开一个终端,进入
ext-apps/examples/basic-host(Codespace 环境下需要把serve.ts第 27 行的http://localhost:3001/mcp替换为你的 Codespace 后端地址); - 运行
npm start启动宿主,宿主会连接后端并展示运行中的 App。
在宿主界面中点击 "Call Tool" 按钮即可看到工具调用结果(见 code/typescript/README.md)。
小结与关键要点
通过本课作业的完整实现,可以得出 MCP Apps 的几条核心结论:
- MCP Apps 是 MCP 标准中一个非常新的补充,适用于希望同时交付数据与 UI 功能的场景——服务端不仅对"数据是什么"有发言权,还对"数据如何被呈现"有发言权;
- 两段式注册:一个 MCP App = 一个工具(
registerAppTool)+ 一个应用资源(registerAppResource),二者通过_meta.ui.resourceUri与资源 URI 关联; - IFrame 隔离:出于安全原因,MCP Apps 运行在 IFrame 中,前端与 MCP Server 的通信必须通过向父 Web 应用发送消息(
app.callServerTool()即封装了这一过程)来完成,社区已提供纯 JavaScript、React 等多种库来简化这种通信; - 复用同一套模式:从
get-time(无输入)到get-faq(带 zod 默认值参数),再到石头剪刀布(枚举参数 + 服务端随机逻辑),交互组件的开发模式高度一致,掌握了事件装配与callServerTool之后即可快速扩展任意带 UI 的工具。
接下来可以继续深入第 4 章实践实现(04-PracticalImplementation/README.md),进一步探索分页等真实场景下的 MCP 落地技巧。
- 教程
- 文档
- 人工智能
【免费下载链接】mcp-for-beginners
This open-source curriculum introduces the fundamentals of Model Context Protocol (MCP) through real-world, cross-language examples in .NET, Java, TypeScript, JavaScript, Rust and Python. Designed for developers, it focuses on practical techniques for building modular, scalable, and secure AI workflows from session setup to service orchestration.
相关推荐
在 TypeScript 中构建剪刀石头布 MCP App:registerAppTool 与 registerAppResource 实战
在 TypeScript 中构建剪刀石头布 MCP App:registerAppTool 与 registerAppResource 实战 MCP Apps
教程文档人工智能MCP Apps 实战指南:基于 TypeScript 构建带交互 UI 的 MCP Server 与 Host 测试全流程
MCP Apps 实战指南:基于 TypeScript 构建带交互 UI 的 MCP Server 与 Host 测试全流程 本篇指南以 mcp for beg
教程文档人工智能在 MCP Apps 中集成 A2UI:构建基于 Python MCP Server 的交互式 UI 应用服务
在 MCP Apps 中集成 A2UI:构建基于 Python MCP Server 的交互式 UI 应用服务 导读 本文基于 a2ui 仓库中的 sample
人工智能AI AgentAI 应用前端UI组件
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考