MCP 协议实战:让 AI Agent 真正连上你的业务系统

MCP 正在成为 AI Agent 接入外部工具的"HTTP 时刻"。但读文档和实际落地之间,隔着一个真实的业务系统。

背景

上个月给出海电商团队搭一个运营数据 Agent,需求很朴素:自然语言查订单、分析 SKU 周转、对比各站点 GMV。

传统做法是写个 RAG + SQL Generator,Prompt 里拼一堆 Schema 让 LLM 猜怎么查。但有两个根本问题:

  1. Schema 膨胀:出海业务跨多国多站点,表结构随站点定制,Prompt 塞不下
  2. 安全风险:让 LLM 直接拼 SQL,白名单纯靠 Prompt 约束 = 防君子不防小人

我需要一种方式,让 Agent 像调用 HTTP API 一样调内部服务 —— MCP (Model Context Protocol) 恰好是这个抽象。

插一句:这让我想起早期门户时代的「频道模板系统」。CMS 后台给编辑一个结构化表单,编辑填参数不用碰 HTML。MCP 对 Agent 做的事本质上一样 —— 给 LLM 一个结构化的工具契约,让它不用碰底层实现。

技术方案

MCP 是什么(两句话说清楚)

MCP 是 Anthropic 提出的开放协议,定义了两个角色:

  • MCP Server:暴露 Tools / Resources / Prompts 的服务端
  • MCP Client:调用这些能力的一方(通常是 AI Agent Host,如 Claude Desktop、Cursor、自建 Agent)

通信走 JSON-RPC 2.0,支持 stdio 和 Server-Sent Events (SSE) 两种传输。对业务系统集成来说,SSE 是最实用的方案 —— 不需要 Agent 和 Server 在同一台机器上。

架构设计

┌──────────────┐     SSE(HTTP)     ┌──────────────┐     gRPC     ┌──────────────┐
│  AI Agent    │ ◄──────────────► │  MCP Server   │ ◄──────────► │  业务服务     │
│ (Claude/自建) │                  │  (Node.js)    │              │  (订单/BI等)  │
└──────────────┘                   └──────────────┘              └──────────────┘

MCP Server 充当"翻译层":把 Agent 的工具调用请求翻译成内部 RPC,把返回结果格式化成 Tool Result。

选型理由:

  • 不用改现有服务:MCP Server 作为独立 Sidecar 部署,业务服务零侵入
  • SSE 模式天然支持跨机器:Agent 在云端,MCP Server 在内网,过一层 API Gateway 就行
  • TypeScript 生态@modelcontextprotocol/sdk 官方 SDK,和现有 Node 技术栈匹配

实施步骤

Step 1:创建 MCP Server 项目

mkdir order-mcp-server && cd order-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk express cors
npm install -D typescript @types/node tsx

Step 2:定义 Tool 清单

按业务需求定义了 3 个 Tool:

// tools/schema.ts
export const TOOLS: ToolDefinition[] = [
  {
    name: "query_orders",
    description: "按站点、日期范围、订单状态查询订单列表",
    parameters: {
      type: "object",
      properties: {
        site: { type: "string", enum: ["US", "SEA", "ME", "LATAM"], description: "站点代码" },
        startDate: { type: "string", description: "开始日期,格式 YYYY-MM-DD" },
        endDate: { type: "string", description: "结束日期,格式 YYYY-MM-DD" },
        status: { type: "string", enum: ["PENDING", "SHIPPED", "DELIVERED", "CANCELED"] },
        limit: { type: "number", default: 20 }
      },
      required: ["site", "startDate", "endDate"]
    }
  },
  {
    name: "get_sku_metrics",
    description: "查询 SKU 的周转天数、库存深度、近 30 天销量",
    parameters: {
      type: "object",
      properties: {
        skuCode: { type: "string", description: "SKU 编码" },
        site: { type: "string", enum: ["US", "SEA", "ME", "LATAM"] }
      },
      required: ["skuCode", "site"]
    }
  },
  {
    name: "compare_gmv",
    description: "对比多个站点的 GMV,支持同比/环比",
    parameters: {
      type: "object",
      properties: {
        sites: { type: "array", items: { type: "string" }, description: "站点列表" },
        compareType: { type: "string", enum: ["yoy", "mom"], description: "同比/环比" }
      },
      required: ["sites"]
    }
  }
];

注意:Tool 的 description 就是 Agent 的"API 文档"。写得越精确,Agent 调用越准确。这里踩过一个坑 —— 最早 compare_gmv 没限制 sites 数组长度,Agent 一次传 12 个站点,后端 SQL 直接超时。

Step 3:实现 SSE MCP Server

// server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import express from "express";
import { TOOLS } from "./tools/schema";
import { OrderService } from "./services/order";

const app = express();
const transportMap = new Map<string, SSEServerTransport>();

// SSE endpoint — 建立长连接
app.get("/sse", async (req, res) => {
  const transport = new SSEServerTransport("/messages", res);
  const mcpServer = new McpServer({
    name: "order-mcp-server",
    version: "1.0.0"
  });

  // 注册 Tools
  for (const tool of TOOLS) {
    mcpServer.tool(
      tool.name,
      tool.description,
      tool.parameters,
      async (args: any) => {
        return await OrderService.handleToolCall(tool.name, args);
      }
    );
  }

  transportMap.set(transport.sessionId, transport);
  await mcpServer.connect(transport);

  res.on("close", () => transportMap.delete(transport.sessionId));
});

// POST endpoint — 接收 JSON-RPC 消息
app.post("/messages", express.json(), async (req, res) => {
  const sessionId = req.query.sessionId as string;
  const transport = transportMap.get(sessionId);
  if (!transport) { res.status(404).end(); return; }
  await transport.handlePostMessage(req, res);
});

app.listen(3001, () => console.log("MCP Server running on :3001"));

关键点:SSE 模式下需要维护 sessionId → transport 映射。每次请求带 sessionId query param 来路由到正确的长连接。

Step 4:在 Agent 侧配置 MCP Client

以 Claude Desktop 为例,编辑 claude_desktop_config.json

{
  "mcpServers": {
    "order-service": {
      "url": "https://internal-api.your-company.com/mcp/sse",
      "headers": {
        "Authorization": "Bearer <your-api-token>"
      }
    }
  }
}

自建 Agent(WorkBuddy 等)则需要在 Agent 侧实现 MCP Client 的 SSE transport。SDK 提供了标准客户端,接入代码如下:

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";

const transport = new SSEClientTransport(
  new URL("https://internal-api/mcp/sse")
);

const client = new Client({ name: "my-agent", version: "1.0.0" });
await client.connect(transport);

// 获取可用工具列表
const tools = await client.listTools();

// 调工具
const result = await client.callTool({
  name: "compare_gmv",
  arguments: { sites: ["US", "SEA"], compareType: "mom" }
});

踩坑记录

坑 1:SSE 重连风暴

现象:Agent 掉线后重连,短时间内创建了 200+ 个 SSE 连接,服务 OOM。

根因:Claude Desktop 的重连退避算法默认最大值太小(30s),突发重连时瞬间打满连接池。

解决

  • Server 侧加 maxConnectionsPerClient 限制(我们设 5)
  • 旧连接加 60s TTL,超时主动断
  • Agent 侧 reconnect.backoff 调到最大 120s
// 连接数限制
const MAX_PER_CLIENT = 5;
const clientConnectionCount = new Map<string, number>();

app.get("/sse", (req, res) => {
  const clientId = req.headers["x-client-id"] as string || req.ip;
  const count = clientConnectionCount.get(clientId) || 0;
  if (count >= MAX_PER_CLIENT) {
    res.status(429).json({ error: "Too many connections" });
    return;
  }
  clientConnectionCount.set(clientId, count + 1);
  res.on("close", () => {
    const c = clientConnectionCount.get(clientId) || 1;
    clientConnectionCount.set(clientId, Math.max(0, c - 1));
  });
  // ... rest of handler
});

坑 2:Tool Result 太大导致上下文爆炸

现象query_orders 一次返回 500 条订单,每条含完整地址、物流轨迹、备注。Agent 收到后 Token 直接飙到上限,后续对话全丢。

解决

  • Tool 返回做摘要化:只返回关键字段,列表类结果限制行数
  • 大量数据走 Resource 模式而非 Tool Result。Agent 拿到 Resource URI 后按需读取
// Tool 返回精简版,附带 Resource URI
async function queryOrders(args: QueryOrdersArgs) {
  const orders = await OrderService.query(args);
  return {
    content: [{
      type: "text",
      text: JSON.stringify({
        total: orders.total,
        summary: orders.items.slice(0, 20).map(o => ({
          id: o.id, site: o.site, status: o.status,
          amount: o.amount, createdAt: o.createdAt
        })),
        _more: `mcp-resource://orders/detail?ids=${orders.itemIds.join(',')}`
      })
    }]
  };
}

坑 3:认证 token 泄露到 LLM

现象:有一天翻 Agent 日志,发现一次 Tool 调用的错误信息里包含了服务端返回的 Authorization header expired: Bearer sk-xxx...。这个信息构成了 LLM 的上下文,如果后续对话持续,token 有概率被泄露。

解决:MCP Server 层的错误信息做脱敏处理,不透露任何 credential/token/secrets:

async function handleToolCall(toolName: string, args: any) {
  try {
    return await callInternalService(toolName, args);
  } catch (err: any) {
    // 永远不要让原始错误信息进入 Agent 上下文
    return {
      isError: true,
      content: [{ type: "text", text: "Service temporarily unavailable, please retry" }]
    };
  }
}

实际内网的错误信息打全量日志到 ELK,Agent 只看脱敏后的消息。

总结

MCP 的意义不在于技术本身有多复杂(它就是个 JSON-RPC over SSE),而在于标准化了 Agent ↔ 工具之间的契约

这跟当年互联网从各种自定义二进制协议收敛到 HTTP 的逻辑一样:标准化降低集成成本、催生生态。

几个关键收获:

  1. Tool description 是最重要的"文档",写得不精确 = Agent 调不对
  2. SSE 连接的生命周期管理是线上最大的坑,重连策略、连接池、TTL 都要提前设计
  3. Tool Result 的粒度决定了 Token 消耗,宁可多分几个小 Tool,也别用一个巨型 Tool 塞所有数据
  4. 安全审计要覆盖 Tool→Agent 的返回路径,任何错误信息都可能成为 LLM 的上下文

目前在出海业务侧,MCP Server 已经接了订单、库存、物流三个域。下一步打算把多站点的 BI 指标也通过 MCP 暴露,让运营能把「东南亚站上月 GMV 环比为什么掉了 15%」这种问题直接问 Agent。


作者:lotusxyhf,互联网老兵转型出海 + AI Agent 赛道。 从门户到 AI,踩过的坑比写过的代码还多。欢迎评论区交流。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐