MCP 协议实战:用 Python + Vue 3 搭建 AI 工具调用面板
MCP 协议实战:用 Python + Vue 3 搭建 AI 工具调用面板
2026 年,AI Agent 的爆发让一个协议悄然成为基础设施——MCP(Model Context Protocol)。Alibaba 的 page-agent(18k stars)和 ByteDance 的 deer-flow(69k stars)都在用它。但市面上几乎没有中文实战教程。这篇文章补上这个缺口。
为什么你需要了解 MCP?
想象一下这个场景:你写了一个 Python 脚本能查数据库、一个 Node.js 脚本能调 API、一个 Bash 脚本能管理文件。现在你想让 AI 调用这些能力——以前你得为每个能力写一套 API 适配,现在只需要一个 MCP Server。
MCP 的本质是一个标准化协议,让 AI 模型(Claude、GPT、本地 LLM)能发现并调用外部工具,就像 USB-C 统一了充电口一样,MCP 统一了 AI 与外部世界的接口。
MCP 的核心架构
┌─────────────┐ MCP 协议 ┌──────────────┐│ MCP Host │ ◄──────────────► │ MCP Server ││ (AI 应用) │ JSON-RPC 2.0 │ (工具提供者) ││ Claude / │ │ Python/Node ││ GPT / 本地 │ │ /Rust/任意 │└─────────────┘ └──────────────┘ │ ┌──────┴──────┐ │ 工具/资源 │ │ 数据库/API │ │ 文件系统 │ └─────────────┘MCP 定义了三种核心能力:
| 能力 | 作用 | 类比 |
|---|---|---|
| Tools(工具) | 可执行的函数,有输入输出 | API 端点 |
| Resources(资源) | 只读数据源,支持订阅更新 | REST 资源 |
| Prompts(提示) | 预定义的对话模板 | 宏/快捷指令 |
关键设计哲学:MCP 是 Client-Server 模型,不是 Server-Client。AI 应用是 Client(Host),你的代码是 Server。这意味着你的工具代码不需要知道 AI 的存在——它只是暴露能力,让 AI 来发现。
从零构建 Python MCP Server
环境准备
# 创建项目mkdir mcp-tool-server && cd mcp-tool-serverpython -m venv venvsource venv/bin/activate
# 安装 MCP SDKpip install mcp httpx aiosqlitemcp 是官方 Python SDK,httpx 用于异步 HTTP 调用,aiosqlite 提供轻量数据库支持。
第一个 MCP Server:系统监控工具
我们从一个实用场景开始——让 AI 能查询服务器状态。
import platformimport psutilfrom mcp.server import Serverfrom mcp.server.stdio import stdio_serverfrom mcp.types import Tool, TextContent
# 创建 MCP Server 实例server = Server("system-monitor")
@server.tool()async def get_system_info() -> list[TextContent]: """获取系统基本信息:OS、CPU 核心数、内存总量""" info = { "os": f"{platform.system()} {platform.release()}", "cpu_cores": psutil.cpu_count(logical=True), "memory_total_gb": round(psutil.virtual_memory().total / (1024**3), 2), "python_version": platform.python_version(), } return [TextContent(type="text", text=str(info))]
@server.tool()async def get_disk_usage(path: str = "/") -> list[TextContent]: """查询指定路径的磁盘使用情况
Args: path: 要查询的路径,默认为根目录 """ usage = psutil.disk_usage(path) result = { "path": path, "total_gb": round(usage.total / (1024**3), 2), "used_gb": round(usage.used / (1024**3), 2), "free_gb": round(usage.free / (1024**3), 2), "percent": usage.percent, } return [TextContent(type="text", text=str(result))]
@server.tool()async def get_process_list(limit: int = 10) -> list[TextContent]: """列出占用 CPU 最多的进程
Args: limit: 返回的进程数量,默认 10 个 """ processes = [] for proc in psutil.process_iter(["pid", "name", "cpu_percent", "memory_percent"]): try: processes.append(proc.info) except (psutil.NoSuchProcess, psutil.AccessDenied): continue
# 按 CPU 使用率排序 processes.sort(key=lambda p: p.get("cpu_percent", 0) or 0, reverse=True) return [TextContent( type="text", text=str(processes[:limit]) )]
async def main(): """启动 MCP Server,通过 stdio 通信""" async with stdio_server() as (read_stream, write_stream): await server.run( read_stream, write_stream, server.create_initialization_options(), )
if __name__ == "__main__": import asyncio asyncio.run(main())为什么用 @server.tool() 装饰器? 它自动完成三件事:
- 注册工具:把函数注册到 MCP 协议的工具列表中
- 提取文档:从 docstring 中提取工具描述,AI 靠这个理解工具用途
- 参数推断:从函数签名推断参数类型和默认值,生成 JSON Schema
这是 MCP SDK 最优雅的设计——你写 Python 函数,SDK 处理协议细节。
启动 Server
# 安装 psutilpip install psutil
# 通过 stdio 启动(MCP 标准通信方式)python server.py现在你的 Python 脚本已经是一个 MCP Server 了。Claude Desktop、Cursor、或者任何 MCP Client 都能发现并调用这三个工具。
用 SSE 让 Web 前端也能调用
stdio 适合桌面应用,但我们的目标是 Web 前端。MCP 支持 SSE(Server-Sent Events)传输层,让浏览器也能通信。
import asynciofrom mcp.server import Serverfrom mcp.server.sse import SseServerTransportfrom starlette.applications import Starlettefrom starlette.routing import Route, Mountimport psutil
server = Server("system-monitor-sse")sse = SseServerTransport("/messages/")
# 复用之前的工具定义@server.tool()async def get_system_info() -> list[TextContent]: info = { "os": f"{platform.system()} {platform.release()}", "cpu_cores": psutil.cpu_count(logical=True), "memory_total_gb": round(psutil.virtual_memory().total / (1024**3), 2), } return [TextContent(type="text", text=str(info))]
@server.tool()async def get_disk_usage(path: str = "/") -> list[TextContent]: usage = psutil.disk_usage(path) result = { "path": path, "total_gb": round(usage.total / (1024**3), 2), "used_gb": round(usage.used / (1024**3), 2), "free_gb": round(usage.free / (1024**3), 2), "percent": usage.percent, } return [TextContent(type="text", text=str(result))]
async def handle_sse(request): """SSE 连接端点""" async with sse.connect_sse( request.scope, request.receive, request._send ) as streams: await server.run( streams[0], streams[1], server.create_initialization_options() )
async def handle_messages(request): """MCP 消息处理端点""" await sse.handle_post_message( request.scope, request.receive, request._send )
app = Starlette( debug=True, routes=[ Route("/sse", endpoint=handle_sse), Mount("/messages/", app=sse.handle_post_message), ],)
if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8765)SSE vs stdio 的选择逻辑:
- stdio:本地 CLI 工具、桌面应用、单客户端场景
- SSE:Web 应用、多客户端场景、需要跨网络通信
SSE 的优势是浏览器原生支持 EventSource API,不需要 WebSocket 的复杂握手。
构建 Vue 3 可视化面板
有了 MCP Server,我们搭建一个 Vue 3 前端来可视化调用这些工具。
项目初始化
npm create vite@latest mcp-dashboard -- --template vue-tscd mcp-dashboardnpm installnpm install mcp-client # MCP 官方客户端库npm run dev核心组件:工具调用面板
<script setup lang="ts">import { ref, onMounted, computed } from 'vue'
interface Tool { name: string description: string inputSchema: Record<string, any>}
interface ToolResult { tool: string args: Record<string, any> output: string timestamp: Date error?: string}
const tools = ref<Tool[]>([])const results = ref<ToolResult[]>([])const loading = ref(false)const mcpClient = ref<any>(null)
// 参数输入表单const paramForm = ref<Record<string, string>>({})
// 当前选中的工具const selectedTool = ref<string>('')
// 连接 MCP Serverasync function connect() { try { // 使用 SSE 传输连接到 MCP Server const { SSEClientTransport } = await import('mcp-client') const { Client } = await import('mcp-client')
const transport = new SSEClientTransport( new URL('http://localhost:8765/sse') ) const client = new Client({ name: 'mcp-dashboard', version: '1.0.0', })
await client.connect(transport) mcpClient.value = client
// 获取工具列表 const { tools: toolList } = await client.listTools() tools.value = toolList } catch (err) { console.error('MCP 连接失败:', err) }}
// 调用工具async function callTool(toolName: string) { if (!mcpClient.value) return
loading.value = true const tool = tools.value.find(t => t.name === toolName)
try { const args = { ...paramForm.value } const result = await mcpClient.value.callTool({ name: toolName, arguments: args, })
const output = result.content .filter((c: any) => c.type === 'text') .map((c: any) => c.text) .join('\n')
results.value.unshift({ tool: toolName, args, output, timestamp: new Date(), }) } catch (err: any) { results.value.unshift({ tool: toolName, args: paramForm.value, output: '', timestamp: new Date(), error: err.message, }) } finally { loading.value = false }}
// 动态生成参数表单const currentParams = computed(() => { const tool = tools.value.find(t => t.name === selectedTool.value) if (!tool) return []
const props = tool.inputSchema.properties || {} return Object.entries(props).map(([key, schema]: [string, any]) => ({ name: key, type: schema.type || 'string', description: schema.description || '', required: tool.inputSchema.required?.includes(key) || false, }))})
onMounted(() => { connect()})</script>
<template> <div class="tool-panel"> <header class="panel-header"> <h1>🔧 MCP 工具面板</h1> <span class="connection-status" :class="{ connected: mcpClient }"> {{ mcpClient ? '● 已连接' : '○ 未连接' }} </span> </header>
<!-- 工具选择 --> <div class="tool-selector"> <select v-model="selectedTool" :disabled="!mcpClient"> <option value="" disabled>选择工具...</option> <option v-for="tool in tools" :key="tool.name" :value="tool.name"> {{ tool.name }} </option> </select>
<p v-if="selectedTool" class="tool-desc"> {{ tools.find(t => t.name === selectedTool)?.description }} </p> </div>
<!-- 参数表单 --> <div v-if="currentParams.length" class="param-form"> <div v-for="param in currentParams" :key="param.name" class="param-field"> <label> {{ param.name }} <span v-if="param.required" class="required">*</span> <span class="param-type">({{ param.type }})</span> </label> <input v-model="paramForm[param.name]" :placeholder="param.description" /> </div> </div>
<!-- 执行按钮 --> <button class="execute-btn" :disabled="!selectedTool || loading" @click="callTool(selectedTool)" > {{ loading ? '执行中...' : '执行工具' }} </button>
<!-- 结果展示 --> <div class="results"> <div v-for="(result, index) in results" :key="index" class="result-card" :class="{ error: result.error }" > <div class="result-header"> <span class="tool-name">{{ result.tool }}</span> <span class="timestamp"> {{ result.timestamp.toLocaleTimeString() }} </span> </div> <pre v-if="result.output" class="result-output">{{ result.output }}</pre> <div v-if="result.error" class="error-msg"> ❌ {{ result.error }} </div> </div> </div> </div></template>
<style scoped>.tool-panel { max-width: 800px; margin: 0 auto; padding: 24px; font-family: 'Inter', system-ui, sans-serif;}
.panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px;}
.panel-header h1 { font-size: 1.5rem; margin: 0;}
.connection-status { font-size: 0.875rem; color: #999;}
.connection-status.connected { color: #22c55e;}
.tool-selector { margin-bottom: 16px;}
.tool-selector select { width: 100%; padding: 10px 14px; border: 1px solid #e2e8f0; border-radius: 8px; font-size: 1rem; background: white;}
.tool-desc { margin-top: 8px; color: #64748b; font-size: 0.875rem;}
.param-form { display: flex; flex-direction: column; gap: 12px; margin-bottom: 16px;}
.param-field label { display: block; font-size: 0.875rem; font-weight: 500; margin-bottom: 4px;}
.required { color: #ef4444;}
.param-type { color: #94a3b8; font-weight: 400;}
.param-field input { width: 100%; padding: 8px 12px; border: 1px solid #e2e8f0; border-radius: 6px; font-size: 0.875rem;}
.execute-btn { width: 100%; padding: 12px; background: #3b82f6; color: white; border: none; border-radius: 8px; font-size: 1rem; font-weight: 500; cursor: pointer; transition: background 0.2s;}
.execute-btn:disabled { background: #94a3b8; cursor: not-allowed;}
.execute-btn:not(:disabled):hover { background: #2563eb;}
.results { margin-top: 24px; display: flex; flex-direction: column; gap: 12px;}
.result-card { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px;}
.result-card.error { border-color: #fecaca; background: #fef2f2;}
.result-header { display: flex; justify-content: space-between; margin-bottom: 8px; font-size: 0.875rem;}
.tool-name { font-weight: 600; color: #1e40af;}
.timestamp { color: #94a3b8;}
.result-output { background: #1e293b; color: #e2e8f0; padding: 12px; border-radius: 6px; font-size: 0.8125rem; overflow-x: auto; white-space: pre-wrap; word-break: break-word;}
.error-msg { color: #dc2626; font-size: 0.875rem;}</style>这个面板的核心价值:它不需要知道任何具体工具的实现细节。新增一个 MCP 工具,面板自动发现、自动渲染参数表单、自动执行。这就是 MCP 协议的力量——工具发现和调用是标准化的。
进阶:构建数据库查询工具
系统监控只是热身,让我们构建一个更实用的场景——让 AI 查询 SQLite 数据库。
import aiosqlitefrom mcp.server import Serverfrom mcp.types import Tool, TextContent
server = Server("database-query")
DB_PATH = "app.db"
@server.tool()async def list_tables() -> list[TextContent]: """列出数据库中所有用户表""" async with aiosqlite.connect(DB_PATH) as db: cursor = await db.execute( "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'" ) tables = [row[0] for row in await cursor.fetchall()] return [TextContent(type="text", text=f"表列表: {', '.join(tables)}")]
@server.tool()async def describe_table(table_name: str) -> list[TextContent]: """查看表结构
Args: table_name: 表名 """ async with aiosqlite.connect(DB_PATH) as db: cursor = await db.execute(f"PRAGMA table_info({table_name})") columns = await cursor.fetchall()
schema = [] for col in columns: schema.append({ "cid": col[0], "name": col[1], "type": col[2], "not_null": bool(col[3]), "default": col[4], })
return [TextContent( type="text", text=f"表 {table_name} 的结构:\n" + "\n".join( f" {c['name']} ({c['type']})" + (" NOT NULL" if c["not_null"] else "") + (f" DEFAULT {c['default']}" if c["default"] else "") for c in schema ) )]
@server.tool()async def query_table( table_name: str, limit: int = 20, where: str = "", order_by: str = "",) -> list[TextContent]: """查询表数据(只读)
Args: table_name: 表名 limit: 返回行数,默认 20 where: WHERE 条件(不含 WHERE 关键字) order_by: ORDER BY 子句(不含 ORDER BY 关键字) """ # ⚠️ 安全:只允许 SELECT,拒绝危险操作 # 实际生产环境应该用参数化查询或白名单机制
sql = f"SELECT * FROM {table_name}" params = []
if where: sql += f" WHERE {where}" if order_by: sql += f" ORDER BY {order_by}" sql += f" LIMIT {limit}"
async with aiosqlite.connect(DB_PATH) as db: cursor = await db.execute(sql) columns = [desc[0] for desc in cursor.description] rows = await cursor.fetchall()
# 格式化为表格 lines = [" | ".join(columns)] lines.append("-" * len(lines[0])) for row in rows: lines.append(" | ".join(str(v) for v in row))
return [TextContent(type="text", text="\n".join(lines))]
# 初始化数据库(示例数据)async def init_db(): async with aiosqlite.connect(DB_PATH) as db: await db.execute(""" CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT, role TEXT DEFAULT 'user', created_at TEXT DEFAULT CURRENT_TIMESTAMP ) """) await db.execute(""" INSERT OR IGNORE INTO users (id, name, email, role) VALUES (1, '张三', 'zhangsan@example.com', 'admin'), (2, '李四', 'lisi@example.com', 'user'), (3, '王五', 'wangwu@example.com', 'user') """) await db.commit()安全设计要点:
- 只读查询:这个工具只暴露
SELECT,不暴露INSERT/UPDATE/DELETE - LIMIT 限制:防止全表扫描导致性能问题
- 生产环境建议:用参数化查询代替字符串拼接,或者实现 SQL 白名单机制
Docker 部署:让一切可复现
开发环境搞定了,接下来用 Docker 把整个系统打包。
MCP Server 的 Dockerfile
FROM python:3.12-slim
WORKDIR /app
# 安装依赖COPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txt
# 复制代码COPY server.py sse_server.py db_tool.py ./COPY app.db ./
# 暴露 SSE 端口EXPOSE 8765
# 启动CMD ["python", "sse_server.py"]Vue 前端的 Dockerfile
FROM node:20-alpine AS builder
WORKDIR /appCOPY package*.json ./RUN npm ciCOPY . .RUN npm run build
# 生产镜像用 Nginx 托管FROM nginx:alpineCOPY --from=builder /app/dist /usr/share/nginx/htmlCOPY nginx.conf /etc/nginx/conf.d/default.confEXPOSE 80docker-compose 编排
version: '3.8'
services: mcp-server: build: context: ./mcp-server dockerfile: Dockerfile.mcp-server ports: - "8765:8765" volumes: - db-data:/app/data environment: - DB_PATH=/app/data/app.db restart: unless-stopped
frontend: build: context: ./mcp-dashboard dockerfile: Dockerfile.frontend ports: - "3000:80" depends_on: - mcp-server environment: - VITE_MCP_URL=http://mcp-server:8765 restart: unless-stopped
volumes: db-data:多阶段构建的价值:前端镜像最终只有 Nginx + 静态文件,不包含 Node.js、npm、源码,镜像体积从几百 MB 降到 20MB 左右。
Nginx 反向代理配置
server { listen 80; server_name _;
# 前端静态文件 location / { root /usr/share/nginx/html; try_files $uri $uri/ /index.html; }
# 反向代理 MCP Server 的 SSE 端点 location /mcp/ { proxy_pass http://mcp-server:8765/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; # SSE 需要禁用缓冲 proxy_buffering off; proxy_cache off; }}为什么需要反向代理? 浏览器前端和 MCP Server 在不同端口(甚至不同域名),直接跨域调用会被浏览器拦截。Nginx 把 /mcp/ 路径代理到 MCP Server,前端只需要同源调用即可。
实战经验:MCP 开发的常见坑
坑 1:工具描述决定 AI 调用准确率
AI 模型靠工具的 description 字段决定何时调用哪个工具。描述模糊会导致 AI 选错工具。
# ❌ 不好的描述@server.tool()async def get_info(): """获取信息""" # AI 不知道什么时候该调用这个
# ✅ 好的描述@server.tool()async def get_system_info(): """获取服务器系统信息:操作系统、CPU 核心数、内存总量。 当用户询问服务器状态、系统配置、硬件信息时调用。"""经验法则:描述中应该包含”当用户 XXX 时调用”的触发条件。
坑 2:SSE 连接的超时处理
SSE 连接长时间空闲可能被代理服务器断开。需要实现心跳机制。
// 前端 SSE 重连逻辑const eventSource = new EventSource('/mcp/sse')
eventSource.onmessage = (event) => { // 处理 MCP 消息 handleMessage(JSON.parse(event.data))}
eventSource.onerror = () => { // SSE 断开,延迟重连 setTimeout(() => { eventSource.close() reconnect() }, 3000)}坑 3:参数类型不匹配
MCP 协议用 JSON Schema 定义参数类型。Python 的 int 对应 JSON 的 integer,但前端传过来的是字符串。
# SDK 会自动做类型转换,但你要确保:# 1. 函数签名有类型注解# 2. 有默认值(让参数可选)@server.tool()async def query_table( table_name: str, # 必填 limit: int = 20, # 可选,有默认值 where: str = "", # 可选,空字符串默认值) -> list[TextContent]:坑 4:错误处理要返回结构化信息
不要直接抛异常,MCP 协议有标准的错误格式。
@server.tool()async def query_table(table_name: str, limit: int = 20) -> list[TextContent]: try: # 查询逻辑 ... except aiosqlite.Error as e: return [TextContent( type="text", text=f"数据库错误: {str(e)}", # 可以标记为错误状态 )]MCP vs 传统 API:什么时候用哪个?
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 给 AI 模型提供工具调用 | MCP | 工具发现、参数 Schema 自动生成 |
| 给其他服务提供数据接口 | REST/GraphQL | 人类开发者更熟悉 |
| 内部微服务通信 | gRPC | 性能更好,类型安全 |
| 浏览器端实时推送 | SSE/WebSocket | MCP 的 SSE 传输层也是基于这个 |
| CLI 工具 | MCP stdio | 零配置,管道通信 |
核心判断标准:如果消费者是 AI 模型,用 MCP。如果消费者是人类开发者或其他服务,用传统 API。
总结
MCP 不是另一个 API 框架,它是 AI 时代的”设备驱动层”。就像 USB 让鼠标、键盘、摄像头都能插到电脑上一样,MCP 让任何工具都能被 AI 调用。
这篇文章覆盖了:
- ✅ MCP 协议的核心概念和架构
- ✅ Python MCP Server 的完整实现(系统监控 + 数据库查询)
- ✅ SSE 传输层让 Web 前端能调用
- ✅ Vue 3 可视化面板的完整代码
- ✅ Docker 多容器编排部署
- ✅ 4 个实战踩坑经验
下一步:把你手头的 Python 脚本包装成 MCP Server,你的 AI 助手就能直接调用它们了。不需要改脚本逻辑,只需要加一个装饰器。
如果你对 MCP 协议有更多疑问,或者想讨论 AI 工具链的最佳实践,欢迎在评论区交流。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!