MCP 协议实战:用 Python + Vue 3 搭建 AI 工具调用面板

3709 字
19 分钟
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#

环境准备#

Terminal window
# 创建项目
mkdir mcp-tool-server && cd mcp-tool-server
python -m venv venv
source venv/bin/activate
# 安装 MCP SDK
pip install mcp httpx aiosqlite

mcp 是官方 Python SDK,httpx 用于异步 HTTP 调用,aiosqlite 提供轻量数据库支持。

第一个 MCP Server:系统监控工具#

我们从一个实用场景开始——让 AI 能查询服务器状态。

server.py
import platform
import psutil
from mcp.server import Server
from mcp.server.stdio import stdio_server
from 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() 装饰器? 它自动完成三件事:

  1. 注册工具:把函数注册到 MCP 协议的工具列表中
  2. 提取文档:从 docstring 中提取工具描述,AI 靠这个理解工具用途
  3. 参数推断:从函数签名推断参数类型和默认值,生成 JSON Schema

这是 MCP SDK 最优雅的设计——你写 Python 函数,SDK 处理协议细节。

启动 Server#

Terminal window
# 安装 psutil
pip install psutil
# 通过 stdio 启动(MCP 标准通信方式)
python server.py

现在你的 Python 脚本已经是一个 MCP Server 了。Claude Desktop、Cursor、或者任何 MCP Client 都能发现并调用这三个工具。

用 SSE 让 Web 前端也能调用#

stdio 适合桌面应用,但我们的目标是 Web 前端。MCP 支持 SSE(Server-Sent Events)传输层,让浏览器也能通信。

sse_server.py
import asyncio
from mcp.server import Server
from mcp.server.sse import SseServerTransport
from starlette.applications import Starlette
from starlette.routing import Route, Mount
import 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 前端来可视化调用这些工具。

项目初始化#

Terminal window
npm create vite@latest mcp-dashboard -- --template vue-ts
cd mcp-dashboard
npm install
npm install mcp-client # MCP 官方客户端库
npm run dev

核心组件:工具调用面板#

src/components/ToolPanel.vue
<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 Server
async 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 数据库。

db_tool.py
import aiosqlite
from mcp.server import Server
from 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()

安全设计要点

  1. 只读查询:这个工具只暴露 SELECT,不暴露 INSERT/UPDATE/DELETE
  2. LIMIT 限制:防止全表扫描导致性能问题
  3. 生产环境建议:用参数化查询代替字符串拼接,或者实现 SQL 白名单机制

Docker 部署:让一切可复现#

开发环境搞定了,接下来用 Docker 把整个系统打包。

MCP Server 的 Dockerfile#

Dockerfile.mcp-server
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#

Dockerfile.frontend
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# 生产镜像用 Nginx 托管
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

docker-compose 编排#

docker-compose.yml
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 反向代理配置#

nginx.conf
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/WebSocketMCP 的 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 工具链的最佳实践,欢迎在评论区交流。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

MCP 协议实战:用 Python + Vue 3 搭建 AI 工具调用面板
https://boke.hackerdream.xyz/posts/mcp-protocol-vue3-python-ai-tool-panel/
作者
晴天
发布于
2026-05-21
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
晴天
Hello, I'm 晴天.
公告
欢迎来到我的博客!这是一则示例公告。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
125
分类
17
标签
287
总字数
257,955
运行时长
0
最后活动
0 天前

目录