关于 MCP,最常见的解释是“AI 世界的 USB-C”。这个比喻适合做开场,却经不起继续追问:插头两边分别是谁?模型是否直接连接 MCP Server?工具是谁挑的?授权又在哪里发生?

不把这些问题拆开,最后很容易得到一种错误印象:只要接入 MCP,模型就能自行访问外部系统。真实情况要克制得多。MCP 规定的是消息怎么表达、能力怎样被发现、调用结果如何返回。至于模型要不要调用、用户有没有权限、一次写操作是否需要确认,仍由承载模型的应用负责。

下面用一个工单系统贯穿全文。我们准备给 AI 应用接入两个能力:查询线上故障,以及读取某条故障的详情。后面再加写操作时,权限问题也会自然浮出来。

先把边界钉住:MCP 连接的不是模型和 API

MCP 的全称是 Model Context Protocol。按最新规范,它是一套建立在 JSON-RPC 2.0 之上的开放协议,用于 AI 应用与外部服务交换工具、资源和提示模板。这个定义里最重要的不是 “Model”,而是 “Protocol”。

一套 MCP 连接里有三个角色:

  • Host 是用户正在使用的 AI 应用,例如桌面客户端、IDE 或 Agent 运行环境。它持有对话,调用模型,管理权限和用户确认。
  • Client 是 Host 内部的一段协议实现。一个 Client 只连接一个 Server;一个 Host 可以同时管理多个 Client。
  • Server 暴露某一组外部能力。它可能包装工单 API、数据库、文件系统,也可能只调用本机程序。
图 1:MCP 所在的位置。模型与 Server 之间隔着 Host;安全边界也主要落在 Host 和后端系统。
AI Host对话、模型调用、Agent 循环、审批、上下文
→
MCP Client发现能力、编码请求、选择传输、解析结果
→
MCP Server
ToolsResourcesPrompts
再去访问工单 API 或数据库

这里有个经常被省略的事实:模型通常不直接发送 MCP 报文。模型看到的是 Host 整理过的工具定义;它返回一次工具调用意图,Host 再通过 MCP Client 把这次意图翻译成 tools/call。Server 也不应默认得到整段对话,它只收到完成当前请求所需的参数和元数据。

因此,MCP 与 Function Call 并不是竞争方案。Function Call 发生在模型接口这一侧,表达“模型想调用哪个函数”;MCP 发生在应用集成这一侧,负责“应用怎样找到并调用外部提供的能力”。二者经常首尾相接。

一句话版本MCP 把外部能力接到 Host;Host 再决定哪些能力、以什么形式交给模型。Server 不会因为使用了 MCP 就自动变成 Agent。

Server 到底提供什么

Server 侧最常见的三类原语是 Tools、Resources 和 Prompts。它们不只是三种数据格式,还暗含了不同的控制方式。

Tools

可以执行的操作,例如 search_incidents、add_comment。通常由模型根据任务选择,但 Host 可以在执行前拦截和确认。

Resources

可读取的内容,以 URI 标识,例如 incident://1842。更像一层标准化的数据读取接口。

Prompts

Server 提供的参数化提示模板,通常由用户或产品界面选择。它表达的是一种可复用入口,不是隐藏的系统指令。

拿工单系统来说,搜索适合做 Tool,因为模型需要传入关键词和状态,并根据返回结果继续判断;单条工单详情也可以作为 Resource 暴露,方便客户端按 URI 读取;“生成事故复盘”则可以做成 Prompt,让用户明确选择模板并补充事故编号。

边界不是由名词决定的,而是由使用方式决定的。同一份工单详情既能作为 Tool 的结果返回,也能成为 Resource。真正要问的是:它是一次带副作用的动作,还是一份可以定位、缓存和重复读取的数据?如果只是为了“把三种原语都用上”而重复暴露接口,客户端只会更难理解。

跟完一次调用,交互关系就清楚了

假设用户问:“最近和支付超时有关的线上故障有哪些?” Host 已经连接工单 Server。下面这条链路里,只有第二步和第七步需要模型参与;协议通讯、参数校验和实际 API 请求都由普通程序完成。

图 2:从自然语言到一次真实查询。箭头没有直接从模型指向 MCP Server。
1MCP Client向 Server 请求 tools/list,取得工具名、说明和 JSON Schema。
2Host → 模型把当前问题和经过筛选的工具定义交给模型。模型返回 search_incidents({query:"支付超时", status:"open"})。
3Host检查工具是否允许使用、参数是否合规、是否需要用户确认。模型的决定到这里仍只是一项请求。
4MCP Client发送 JSON-RPC tools/call。远程连接走 HTTP,本地进程可以走 stdio。
5MCP Server再次校验身份和参数,调用真正的工单 API,将后端错误转换为稳定的工具结果。
6Host接收 content 或 structuredContent,记录 trace,并控制返回模型的数据量。
7模型依据结果回答,或者提出下一次调用,例如读取其中一条故障详情。

这也是 MCP 与 Agent 的关系。MCP 给 Agent 增加了可发现的外部能力,却不提供 Agent 的决策循环。循环何时继续、最多调用多少次、失败后是否换工具,由 Host 或外层 Harness 决定。同一个 MCP Server 可以被聊天应用调用,也可以被固定 Workflow 调用,Server 无需知道对面是不是 Agent。

线上真正传输的报文

省略每个请求都要携带的元数据后,一次工具调用的核心很朴素:

// Client → Server
{
  "jsonrpc": "2.0",
  "id": 12,
  "method": "tools/call",
  "params": {
    "name": "search_incidents",
    "arguments": { "query": "支付超时", "status": "open" }
  }
}

// Server → Client
{
  "jsonrpc": "2.0",
  "id": 12,
  "result": {
    "content": [{ "type": "text", "text": "找到 2 条故障" }],
    "structuredContent": { "items": [/* ... */] },
    "isError": false
  }
}

content 适合直接进入对话上下文,structuredContent 适合客户端校验、渲染或继续处理。两者并不冲突。只返回一大段“看起来像 JSON 的文本”虽然能跑,却让客户端失去了可靠解析的机会;对稳定接口,最好同时定义输出 Schema。

传输层:stdio 与 Streamable HTTP 解决的是部署问题

stdioStreamable HTTP
Server 在哪里通常由 Host 在本机拉起一个子进程独立部署的远程服务
消息怎么走stdin/stdout 上的 JSON-RPC向单一 MCP 端点发送 HTTP POST;响应可以是 JSON 或请求范围内的 SSE 流
认证常从环境变量或本机配置取得凭据需要按远程服务设计 OAuth、访问令牌和作用域
适合个人工具、IDE、本地文件和开发环境团队服务、SaaS、多实例部署和统一治理
最容易踩的坑把日志写入 stdout,污染协议报文Origin 校验、令牌受众、跨用户缓存和超时处理做得不完整

stdio 不是“简化版 MCP”。它与 HTTP 承载的是同一类协议消息,只是进程管理和安全条件不同。stdio Server 的 stdout 是协议通道,调试日志必须写到 stderr;一行随手的 console.log 就可能让客户端收到无法解析的 JSON。

远程 Server 也不是把 stdio 程序套一层 Web 路由就结束了。它面对多用户、并发、网关、认证和数据隔离。最新 Streamable HTTP 要求校验 Origin,本地监听应绑定 127.0.0.1,而不是无条件监听 0.0.0.0。这些要求与模型无关,却决定了服务能否安全上线。

远程鉴权应该放在哪里

MCP Server 在 OAuth 体系中是 Resource Server:它验证访问令牌,但不应该顺手承担账号登录和令牌签发。下面只展示 HTTP 入口的鉴权落点,verifyAccessToken 应交给成熟的身份系统实现,并严格校验签名、issuer、audience、有效期和 scope。

npm install @modelcontextprotocol/express @modelcontextprotocol/node express
import type { OAuthTokenVerifier } from "@modelcontextprotocol/express";
import {
  createMcpExpressApp,
  getOAuthProtectedResourceMetadataUrl,
  requireBearerAuth
} from "@modelcontextprotocol/express";
import { toNodeHandler } from "@modelcontextprotocol/node";
import { createMcpHandler } from "@modelcontextprotocol/server";

const endpoint = new URL("https://mcp.example.com/mcp");
const verifier: OAuthTokenVerifier = { verifyAccessToken };

const auth = requireBearerAuth({
  verifier,
  requiredScopes: ["incidents:read"],
  resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(endpoint)
});

const app = createMcpExpressApp({
  host: "0.0.0.0",
  allowedHosts: ["mcp.example.com"],
  allowedOrigins: ["https://agent.example.com"]
});

const handler = toNodeHandler(createMcpHandler(createServer));
app.all("/mcp", auth, (req, res) => void handler(req, res, req.body));

这一层验证的是“请求能不能进入 MCP Server”。某个 Tool 是否允许执行,还要在处理函数里检查更细的 scope 和对象权限。入口通过,不等于调用者可以访问所有工单。

版本变化:为什么旧教程和新规范看起来不像同一种协议

本文以 2026-07-28 规范为准。这个版本对协议核心做了较大调整。如果你读过早期文章,熟悉的流程多半是:Client 先发送 initialize,Server 返回版本和能力,Client 再发 initialized;Streamable HTTP 还可能维持 Mcp-Session-Id。

2026-07-28 删除了这套握手和协议级 Session。每个请求都自带协议版本、Client 信息和能力元数据。Client 如果想事先了解 Server,可以调用 server/discover,但这不是每次业务调用的必经步骤。一个请求因此可以落到负载均衡器后面的任意实例。

图 3:变化的是协议状态,不是业务状态。

旧版

initialize 建立协议上下文,后续请求依赖已经协商好的连接状态。

新版

请求自描述。协议版本与 Client 能力随请求携带,server/discover 用于按需发现。

没有消失的东西

购物车、浏览器页签、长任务仍需要状态,只是要用显式 ID 或 handle 放进业务数据,而不是藏在连接 Session 里。

“协议无状态”不等于“应用不能有状态”。假设 Tool 创建了一次数据导出,应返回 job_id;后续查询进度时,Client 把这个 ID 作为参数再次传入。状态归业务系统管理,调用关系也变得可追踪。兼容旧客户端时则应依赖 SDK 的版本协商能力,不要在业务处理函数里到处判断协议年代。

动手写一个 MCP Server

下面实现一个只读的故障查询 Server。代码使用官方 TypeScript Server SDK 的当前接口和 stdio 传输。为了让示例可以独立运行,后端数据暂时放在数组里;换成真实系统时,只需替换 searchIncidents,协议层不用改。

1. 建项目

mkdir incident-mcp
cd incident-mcp
npm init -y
npm install @modelcontextprotocol/server zod
npm install -D typescript @types/node
mkdir src

package.json 至少补上 ESM 类型和构建命令:

{
  "type": "module",
  "scripts": {
    "build": "tsc"
  }
}

2. 注册工具

import { McpServer, ResourceTemplate } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { z } from "zod";

type Incident = {
  id: string;
  title: string;
  status: "open" | "resolved";
  severity: "P1" | "P2" | "P3";
};

const incidents: Incident[] = [
  { id: "1842", title: "支付回调持续超时", status: "open", severity: "P1" },
  { id: "1837", title: "订单页偶发超时", status: "resolved", severity: "P2" }
];

function createServer() {
  const server = new McpServer({
    name: "incident-desk",
    version: "1.0.0"
  });

  server.registerTool(
    "search_incidents",
    {
      description: "按关键词和状态查询故障工单。只读取,不修改工单。",
      inputSchema: z.object({
        query: z.string().min(1).max(100)
          .describe("标题关键词,不要传入整段用户问题"),
        status: z.enum(["open", "resolved", "all"]).default("open"),
        limit: z.number().int().min(1).max(20).default(10)
      })
    },
    async ({ query, status, limit }) => {
      const keyword = query.toLowerCase();
      const items = incidents
        .filter(item => item.title.toLowerCase().includes(keyword))
        .filter(item => status === "all" || item.status === status)
        .slice(0, limit);

      return {
        content: [{
          type: "text",
          text: items.length
            ? `找到 ${items.length} 条故障:${items.map(x => `${x.id} ${x.title}`).join(";")}`
            : "没有找到匹配的故障"
        }],
        structuredContent: { items }
      };
    }
  );

  return server;
}

void serveStdio(createServer);
console.error("incident-desk MCP server is running");

这段代码不长,真正值得注意的是接口收口:

  • Server 没有暴露“执行任意工单 API”,而是给出一个语义明确的只读操作。
  • status 用枚举,limit 有上限,避免模型制造无限结果集。
  • 工具说明明确写出“不修改工单”,这有助于模型选择,但它不是安全边界。后端凭据仍应只有读取权限。
  • 返回内容同时照顾模型和程序:自然语言摘要放在 content,原始条目放在 structuredContent。

3. 再补一份 Resource 和一个 Prompt

查询是一次动作;工单详情则有稳定 URI,更适合做 Resource。下面的模板让 Client 可以读取任意 incident://{id}。Prompt 只提供复盘提纲,不读取工单,也不会自行调用 Tool。

// 放在 createServer() 的 return server 之前
server.registerResource(
  "incident-detail",
  new ResourceTemplate("incident://{id}", { list: undefined }),
  {
    title: "故障工单详情",
    description: "按编号读取一条故障工单",
    mimeType: "application/json"
  },
  async (uri, { id }) => {
    const incident = incidents.find(item => item.id === String(id));
    return {
      contents: [{
        uri: uri.href,
        mimeType: "application/json",
        text: JSON.stringify(incident ?? { error: "incident_not_found" })
      }]
    };
  }
);

server.registerPrompt(
  "incident-review",
  {
    title: "故障复盘",
    description: "根据一条故障工单整理复盘提纲",
    argsSchema: z.object({
      incidentId: z.string().describe("故障工单编号")
    })
  },
  ({ incidentId }) => ({
    messages: [{
      role: "user",
      content: {
        type: "text",
        text: `请读取 incident://${incidentId},按时间线、根因、影响、修复和后续行动整理复盘。缺少证据的部分不要猜。`
      }
    }]
  })
);

list: undefined 表示这个 URI 模板可以读取,但 Server 不枚举全部实例。真实工单可能有几十万条,强行塞进 resources/list 没有意义。Client 已知编号时,直接构造 URI 即可。

4. 构建、配置与测试

npx tsc
npx @modelcontextprotocol/inspector node /绝对路径/incident-mcp/build/index.js

Inspector 可以查看工具列表、填写参数并直接调用。先在这里确认 Schema、空结果、异常和大结果集,再接入真实 Host。客户端配置的本质,是告诉 Host 用什么命令拉起 stdio Server:

{
  "mcpServers": {
    "incident-desk": {
      "command": "node",
      "args": ["/绝对路径/incident-mcp/build/index.js"]
    }
  }
}

配置文件的具体位置由 Host 决定,不属于 MCP 协议。Claude Desktop、IDE 和其他 Agent 产品可以采用不同界面,底下仍然是在创建一个 Client 并连接 Server。

从演示代码到可用服务,工作量主要在协议外

把一个 Tool 注册出来很快。决定它是否能长期使用的,反而是传统服务端工程。

工具边界按用户意图设计接口,不要把参数繁多的内部 API 原样暴露给模型。
结果预算分页、截断、摘要和 Resource Link 要提前设计,不能把几万行数据直接塞回上下文。
错误语义区分“工具执行失败”和“协议请求不合法”。超时也要说明操作是否可能已经完成。
幂等性创建、付款、发信等写操作要有 idempotency key,不能把模型重试当成一次新请求。
最小权限只读 Tool 使用只读凭据。删除、发布、付款不要靠工具描述里的警告来约束。
可观察性记录 tool、调用者、参数摘要、下游耗时和结果状态,同时清理令牌与敏感字段。

错误应该让调用方知道下一步还能做什么

“Something went wrong” 对 Agent 几乎没有价值。一个可恢复的错误至少要说明类别:参数不合法、权限不足、下游限流、服务暂时不可用,还是对象不存在。是否允许重试、建议等待多久,也应尽量结构化。否则模型往往会换个说法重复调用,表面上在探索,实际只是在撞同一堵墙。

对写操作尤其要谨慎。HTTP 超时只表示 Client 没拿到结果,不表示下游没有创建成功。没有幂等键和查询接口时,自动重试可能重复创建工单、重复发信,甚至重复付款。

工具描述是输入,不是可信代码

Host 会把工具名、说明和 Schema 放进模型上下文,因此 Server 返回的描述本身也是外部输入。客户端不能因为它长得像配置就完全信任。连接陌生 Server 等于允许对方影响模型看到的工具说明和资源内容,提示注入由此进入调用链。

实际部署时至少要分三道关:

  1. Host 只连接用户或管理员认可的 Server,并控制哪些工具可见。
  2. 高影响调用在 Host 侧显示明确的确认界面,不能只展示一个模糊的“允许”。
  3. Server 和后端系统按真实身份再次授权。即使 Host 漏拦,低权限令牌也不应完成越权操作。

远程 Server 接第三方 API 时,不要把 Client 给 MCP Server 的令牌原样转发给下游。规范明确禁止 token passthrough:给 MCP Server 的 token 与访问第三方 API 的 token 应有不同受众和独立授权,否则容易形成 confused deputy,也很难建立可靠审计。

MCP 解决了什么,又故意没解决什么

MCP 的价值不在于让单次 API 调用变短。只接一个模型、三个内部函数时,直接 Function Call 往往更省事。它真正有用的地方,是让能力提供方和 AI 应用之间形成可复用边界:Server 可以声明自己有什么,Host 可以用同一套协议接入不同来源,Agent 也不必为每个系统学习一套私有连接方式。

但协议没有替你完成下面这些工作:

  • 它不决定模型是否应该调用某个工具。
  • 它不提供完整的 Agent loop、计划器、记忆或任务验收。
  • 它不理解企业内部“关闭故障”和“解决故障”的业务差别。
  • 它不自动把工具变安全,也不替后端做权限、审计和幂等。
  • 它不保证不同 Host 会以完全相同的方式展示和审批调用。

理解这条边界后,选型会简单许多。如果能力只服务一个应用,普通函数已经稳定,不必为了追赶名词再加一层协议。如果同一组工具需要进入多个 AI 客户端,或者你正在做面向外部开发者的能力服务,MCP 才开始体现复用价值。

最后回到开头那条工单查询。模型负责从用户问题里判断“需要查故障”;Host 决定它能看到什么工具,并执行授权策略;MCP Client 把调用变成标准请求;Server 把请求落到真实工单系统;后端权限决定它究竟能读什么、改什么。五层责任都在,系统才完整。少画一个箭头不影响演示,少做一道边界检查,迟早会在生产环境补回来。

参考资料