MCP Apps 让模型调用工具的结果不再局限于纯文本。通过交互式 HTML widget,同一个 MCP Server 可以把表单、筛选器、图表或审批控件交给 AI Host 渲染,让用户直接在对话界面中完成操作。
这套能力的关键在于:MCP Apps 是与 Host 解耦的标准扩展。只要 ChatGPT、Claude 或其他 AI Host 支持对应扩展,服务端就可以复用同一份工具和 UI 逻辑。Amazon Bedrock AgentCore 则可以作为部署和运行 MCP Server 的托管基础设施。
MCP App 的组成
一个可交互的 MCP App 通常包含三层:
- MCP 工具:接收模型或用户传入的参数,执行查询、计算或业务操作。
- 结构化结果:除了给模型阅读的文本,还返回 widget 所需的数据。
- HTML widget:在 AI Host 中展示结果,并通过事件再次调用 MCP 工具。
例如,模型可以调用 search_orders 查询订单。服务端返回订单列表后,widget 显示筛选器和“加载更多”按钮。用户点击按钮时,widget 发起新的工具调用,而不需要让模型重新猜测用户意图。
这会带来两个工程上的变化。工具接口需要同时考虑模型可读性和 UI 可读性;widget 也必须假设宿主环境不同,不能依赖某个特定聊天产品的私有 DOM 或浏览器 API。
先定义稳定的工具契约
建议让 MCP 工具返回明确的结构化数据,并把展示层需要的字段与内部数据库对象分开。下面是一个可以改造的 Python 示例。它使用标准库模拟 MCP 工具处理逻辑,便于在接入具体 MCP SDK 或 AgentCore Runtime 时复用数据模型。
# app.py
from http.server import BaseHTTPRequestHandler, HTTPServer
import json
ORDERS = [
{"id": "A-1001", "customer": "Acme", "status": "shipped", "total": 128.50},
{"id": "A-1002", "customer": "Globex", "status": "pending", "total": 86.00},
]
def search_orders(status=None):
rows = [o for o in ORDERS if not status or o["status"] == status]
return {
"items": rows,
"count": len(rows),
"filters": {"status": status or "all"},
}
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
if self.path != "/tools/search_orders":
self.send_error(404)
return
length = int(self.headers.get("Content-Length", "0"))
args = json.loads(self.rfile.read(length) or b"{}")
result = search_orders(args.get("status"))
body = json.dumps(result).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
if __name__ == "__main__":
print("Listening on http://127.0.0.1:8080")
HTTPServer(("127.0.0.1", 8080), Handler).serve_forever()
运行并测试:
python app.py
curl -s http://127.0.0.1:8080/tools/search_orders \
-H 'Content-Type: application/json' \
-d '{"status":"pending"}'
实际接入 MCP SDK 时,可以把 search_orders 注册为 MCP tool,把返回的 items、count 和 filters 放入工具结果,并额外声明关联的 HTML 资源或 widget。具体注册函数和资源 URI 取决于所使用的 SDK 版本,因此不要把示例中的 HTTP 路径直接当成 AgentCore 的固定协议。
编写可移植的 HTML Widget
Widget 应该把宿主通信封装在一个很小的适配层里。业务 UI 只处理数据和用户动作,不直接依赖 ChatGPT 或 Claude 的私有对象。
下面的 HTML 可以独立运行。它默认通过普通 HTTP 请求访问上面的示例服务;接入 MCP Host 时,把 callTool 替换为 Host 提供的 MCP 调用桥接即可。
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>订单查询</title>
<style>
body { font: 16px system-ui, sans-serif; max-width: 720px; margin: 32px auto; padding: 0 16px; }
select, button { font: inherit; padding: 8px 10px; margin-right: 8px; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { border-bottom: 1px solid #ddd; padding: 10px 6px; text-align: left; }
#message { color: #666; margin-top: 16px; }
</style>
</head>
<body>
<h1>订单查询</h1>
<label>
状态
<select id="status">
<option value="">全部</option>
<option value="pending">待处理</option>
<option value="shipped">已发货</option>
</select>
</label>
<button id="refresh">刷新</button>
<div id="message"></div>
<table>
<thead><tr><th>订单号</th><th>客户</th><th>状态</th><th>金额</th></tr></thead>
<tbody id="orders"></tbody>
</table>
<script>
async function callTool(name, arguments_) {
// 在 MCP Host 中替换成宿主提供的工具调用桥接。
const response = await fetch("http://127.0.0.1:8080/tools/" + name, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(arguments_)
});
if (!response.ok) throw new Error("工具调用失败: " + response.status);
return response.json();
}
async function loadOrders() {
const message = document.querySelector("#message");
const tbody = document.querySelector("#orders");
message.textContent = "加载中...";
tbody.replaceChildren();
try {
const status = document.querySelector("#status").value;
const result = await callTool("search_orders", { status });
for (const order of result.items) {
const row = document.createElement("tr");
for (const value of [order.id, order.customer, order.status, `$${order.total.toFixed(2)}`]) {
const cell = document.createElement("td");
cell.textContent = value;
row.appendChild(cell);
}
tbody.appendChild(row);
}
message.textContent = `共 ${result.count} 条订单`;
} catch (error) {
message.textContent = error.message;
}
}
document.querySelector("#refresh").addEventListener("click", loadOrders);
loadOrders();
</script>
</body>
</html>
保存为 widget.html 后,可以启动静态服务器进行本地验证:
python -m http.server 8000
然后打开 http://127.0.0.1:8000/widget.html。浏览器直接打开本地文件时,跨域策略可能阻止请求,因此使用静态服务器更接近实际部署环境。
部署到 AgentCore 时要关注什么
可以把 MCP Server 打包成 AgentCore Runtime 支持的部署单元,并通过环境变量注入数据库连接、下游 API 地址和认证配置。部署流程中的具体命令应以当前 AWS SDK、CLI 和 AgentCore 文档为准,但工程结构可以保持稳定:
mcp-app/
├── server.py # MCP tool 和资源注册
├── widgets/
│ └── orders.html # 交互式 HTML widget
├── requirements.txt
└── Dockerfile
一个最小的容器配置示例如下,适合作为改造起点:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV PORT=8080
EXPOSE 8080
CMD ["python", "server.py"]
部署前应验证以下边界:
- 认证与授权:每次工具调用都要基于当前用户或会话做权限检查,不能只在 widget 中隐藏按钮。
- 输入校验:模型生成的参数仍然是不可信输入,状态值、分页参数和资源 ID 都需要服务端校验。
- 跨 Host 行为:不同 Host 可能有不同的 iframe 沙箱、尺寸限制、网络策略和事件支持。widget 应减少外部依赖,并为加载失败提供可读错误。
- 结果大小:不要把大表格或敏感字段一次性返回。使用分页、摘要和按需查询,降低模型上下文与 UI 渲染压力。
- 幂等性:涉及创建、退款、删除等写操作时,增加确认步骤、幂等键和审计日志。
一份落地检查清单
开始接入时,可以按这个顺序推进:
- 先用纯 MCP tool 跑通数据查询和错误处理。
- 为工具结果定义稳定的 JSON schema,区分模型摘要字段和 widget 展示字段。
- 单独在浏览器中测试 HTML widget 的空数据、加载中、失败和超长数据状态。
- 在 AgentCore Runtime 中部署后,再分别验证支持 MCP Apps 的 AI Host。
- 对工具调用延迟、权限失败、重复提交和 Host 不支持 widget 的情况添加日志与降级文本。
MCP Apps 的价值不只是“把 HTML 放进聊天窗口”。真正需要设计的是一份跨 Host 的交互契约:MCP 工具负责可靠的业务能力,结构化结果负责稳定的数据边界,widget 负责高效的人机操作。将这三部分分开后,同一套服务才更容易在 Amazon Bedrock AgentCore、ChatGPT、Claude 以及其他兼容 Host 之间持续复用。