芥末
发布于 2025-12-08 / 0 阅读
0
0

从零实现一个 Spring Boot MCP Server:协议原理、SSE 通道与工具调用

MCP(Model Context Protocol,模型上下文协议)是一套让 Agent 获取外部上下文、调用外部工具的开放协议。它解决的不是“大语言模型怎么推理”,而是“大语言模型所在的应用,怎么用统一方式发现工具、理解工具参数、调用工具并拿到结果”。

如果没有 MCP,每个 Agent 平台都可能有自己的工具接入方式:接口描述格式不同、参数结构不同、鉴权方式不同、结果解析方式也不同。工具开发者要为不同平台适配多套接入逻辑,Agent 开发者也要关心“怎么选工具、怎么拼参数、怎么调接口、怎么把结果喂回模型”。

MCP 把这条链路标准化了。

一个工具只要按 MCP Server 的规范暴露出去,支持 MCP 的 Agent 平台就能通过 MCP Client 与它通信。平台负责把工具描述转换成模型可理解的 Function Calling(函数调用)能力,工具服务只需要稳定地提供“有哪些工具”和“如何调用工具”。

MCP 的三个核心角色

MCP 通信里有三个角色:MCP Host、MCP Client、MCP Server。

flowchart LR
    U[用户] --> H[MCP Host<br/>Agent 应用或 AI 客户端]
    H --> C[MCP Client<br/>协议客户端]
    C <--> S[MCP Server<br/>工具与上下文服务]
    S --> D[(业务系统 / 数据库 / 外部 API)]
    D --> S
    S --> C
    C --> H
    H --> U
角色作用常见形态
MCP Host执行 Agent 逻辑,接收用户问题,决定是否调用工具Agent 平台、AI IDE、桌面 AI 客户端
MCP Client按 MCP 协议连接 MCP Server,发送请求并接收响应通常内置在 MCP Host 中
MCP Server暴露工具、资源或上下文数据,处理调用请求独立服务、本地进程、业务系统适配层

MCP Host 往往不会把 MCP Client 单独暴露给使用者。使用 Agent 平台时,看到的是“添加 MCP Server”“连接工具服务”“调用工具”等入口,真正的协议请求由平台内部的 MCP Client 完成。

MCP Server 才是工具开发者主要实现的部分。它需要告诉客户端:

  • 支持哪个 MCP 协议版本;
  • 服务器有哪些能力;
  • 有哪些工具可以调用;
  • 每个工具需要什么参数;
  • 调用工具后返回什么结果。

MCP 解耦的到底是什么

MCP 的价值可以类比成 Type-C 接口,但要注意这个类比的边界。Type-C 统一的是设备连接方式,MCP 统一的是 Agent 与工具之间的通信方式。

在 Agent 场景里,模型并不是直接去请求业务接口。典型流程是:

flowchart TD
    A[用户提出问题] --> B[Agent 判断是否需要工具]
    B --> C[模型选择工具并生成参数]
    C --> D[MCP Host 通过 MCP Client 调用 MCP Server]
    D --> E[MCP Server 执行业务逻辑]
    E --> F[返回结构化结果]
    F --> G[Agent 把结果交给模型继续生成回答]

没有 MCP 时,工具接入经常会变成平台绑定开发:

对比项自定义工具接入MCP 工具接入
工具描述每个平台一套格式统一通过 tools/list 暴露
工具调用每个平台一套调用协议统一通过 tools/call 调用
参数结构由平台自定义使用 JSON Schema 描述
复用成本平台越多,适配越多支持 MCP 的平台可复用
Agent 开发关注点要关心接口调用细节更关注任务规划与结果使用
工具开发关注点要适配不同 Agent 平台按 MCP Server 规范发布能力

所以,MCP 并不是替代大模型的 Function Calling,而是把外部工具以统一协议暴露给 MCP Host。MCP Host 再根据模型厂商的差异,把这些工具描述转换成模型可用的函数调用格式。

基于 HTTP + SSE 的 MCP 通信模型

MCP 支持不同传输方式。这里实现的是基于 HTTP + SSE 的 MCP Server,也是理解 MCP Server 最直观的一种形态。

在这种传输方式里,MCP Client 和 MCP Server 之间有两条通道:

flowchart LR
    C[MCP Client] -- "GET /sse<br/>建立 SSE 长连接" --> S[MCP Server]
    S -- "event: endpoint<br/>event: message" --> C
    C -- "POST /message/{sessionId}<br/>发送 JSON-RPC 请求" --> S

两条通道分工很明确:

通道方向协议用途
SSE 通道Server → ClientHTTP GET + text/event-stream服务端推送 endpoint、响应结果、通知、心跳
POST 通道Client → ServerHTTP POST + JSON客户端发送 initializetools/listtools/call 等请求

也就是说,MCP Client 不会把 JSON-RPC 请求直接发到 /sse。它会先连接 /sse,拿到服务端通过 SSE 推送的 POST 地址,然后把后续请求发到这个 POST 地址。服务端处理完请求后,再通过 SSE 通道把结果推回客户端。

SSE:负责服务端如何把消息推回客户端

SSE(Server-Sent Events,服务器发送事件)是一种基于 HTTP 的单向事件流机制。它看起来像服务端主动推送,本质上仍然是客户端先发起一个 HTTP 请求,服务端一直不结束响应,而是不断往响应体里追加事件数据。

SSE 响应头通常长这样:

Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

响应体由一条条事件组成,每条事件用两个换行分隔:

event: message
data: {"hello":"world"}

event: ping
data: {"type":"ping"}

SSE 常用字段只有几个:

字段含义
data事件负载,可以是普通文本,也可以是 JSON 字符串
event事件名称,客户端可按名称监听不同事件
id事件编号,可用于断线续传
retry客户端断线后重连间隔,单位是毫秒

在 MCP 的 SSE 传输里,常见事件有两类。

第一类是 endpoint,用于告诉客户端后续 POST 请求应该发到哪里:

event: endpoint
data: http://localhost:8080/message/client-1710000000000

第二类是 message,用于推送 JSON-RPC 响应:

event: message
data: {"jsonrpc":"2.0","id":"1","result":{"tools":[{"name":"get_time","description":"Returns current server time"}]}}

SSE 只负责“怎么推”。它不规定 MCP 的方法名、参数、工具格式,这些内容由 JSON-RPC 2.0 和 MCP 协议层定义。

JSON-RPC 2.0:负责消息长什么样

JSON-RPC 2.0 是一种轻量级远程过程调用协议。MCP 使用它来包装 Client 与 Server 之间的请求、响应和通知。

JSON-RPC 请求一般包含这些字段:

字段是否必需含义
jsonrpc固定为 "2.0"
method要调用的方法,例如 initializetools/list
params方法参数
id请求必需,通知不需要请求编号,响应必须带回同一个 id

一次普通请求长这样:

{
  "jsonrpc": "2.0",
  "method": "tools/list",
  "params": {},
  "id": "1"
}

成功响应长这样:

{
  "jsonrpc": "2.0",
  "id": "1",
  "result": {
    "tools": []
  }
}

失败响应长这样:

{
  "jsonrpc": "2.0",
  "id": "1",
  "error": {
    "code": -32601,
    "message": "Method not found"
  }
}

通知和请求很像,但没有 id,也不需要服务端响应。例如初始化完成通知:

{
  "jsonrpc": "2.0",
  "method": "notifications/initialized",
  "params": {}
}

常见 JSON-RPC 错误码可以按这个规则处理:

错误码含义典型场景
-32700Parse errorJSON 解析失败
-32600Invalid Request请求结构不符合 JSON-RPC
-32601Method not found不支持的方法
-32602Invalid params参数错误
-32603Internal error服务端内部异常

MCP 的完整调用生命周期

基于 SSE Transport 的 MCP 通信可以拆成五个动作:连接、取端点、初始化、使用工具、断开连接。

sequenceDiagram
    participant Client as MCP Client
    participant Server as MCP Server

    Client->>Server: GET /sse
    Server-->>Client: event: endpoint /message/{sessionId}

    Client->>Server: POST /message/{sessionId}<br/>initialize
    Server-->>Client: event: message<br/>initialize result

    Client->>Server: POST /message/{sessionId}<br/>notifications/initialized
    Note over Server: 记录客户端初始化完成<br/>通知不返回响应

    Client->>Server: POST /message/{sessionId}<br/>tools/list
    Server-->>Client: event: message<br/>工具列表

    Client->>Server: POST /message/{sessionId}<br/>tools/call
    Server-->>Client: event: message<br/>工具调用结果

    Client--xServer: 关闭 SSE 连接

初始化请求用于协商协议版本和能力:

{
  "jsonrpc": "2.0",
  "method": "initialize",
  "params": {
    "protocolVersion": "2024-11-05",
    "capabilities": {
      "sampling": {},
      "roots": {
        "listChanged": true
      }
    },
    "clientInfo": {
      "name": "mcp-inspector",
      "version": "0.9.0"
    }
  },
  "id": "0"
}

服务端返回自身能力:

{
  "jsonrpc": "2.0",
  "id": "0",
  "result": {
    "protocolVersion": "2024-11-05",
    "capabilities": {
      "tools": {
        "listChanged": true
      }
    },
    "serverInfo": {
      "name": "SpringBoot MCP Server",
      "version": "1.0.0"
    }
  }
}

初始化完成后,客户端才会进入工具发现和工具调用阶段。

用 Spring Boot WebFlux 实现一个 MCP Server

一个最小可用的 MCP Server 至少需要三部分:

  1. SSE 端点:接收客户端连接,并向客户端推送事件;
  2. POST 端点:接收客户端发来的 JSON-RPC 请求;
  3. 协议处理器:处理 initializetools/listtools/call 等方法。

项目结构可以这样组织:

springboot-mcp-server/
├── pom.xml
└── src/
    └── main/
        ├── java/
        │   └── com/example/mcp/
        │       ├── McpApplication.java
        │       ├── protocol/
        │       │   ├── McpRequest.java
        │       │   └── McpResponse.java
        │       ├── server/
        │       │   └── McpServerHandler.java
        │       ├── tools/
        │       │   └── McpToolRegistry.java
        │       └── transport/
        │           └── SseTransport.java
        └── resources/
            └── application.properties

1. 准备依赖

使用 Spring Boot WebFlux 实现 SSE 会比较自然,因为它可以直接返回 Flux<ServerSentEvent<?>>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webflux</artifactId>
    </dependency>

    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
    </dependency>

    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

配置端口:

server.port=8080

启动类:

package com.example.mcp;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class McpApplication {
    public static void main(String[] args) {
        SpringApplication.run(McpApplication.class, args);
    }
}

2. 定义 JSON-RPC 请求和响应

McpRequest 用来接收客户端请求:

package com.example.mcp.protocol;

import lombok.Data;

import java.util.Map;

@Data
public class McpRequest {
    private String jsonrpc;
    private String id;
    private String method;
    private Map<String, Object> params;
}

McpResponse 用来返回成功结果或错误结果。响应里 resulterror 只能出现一个。

package com.example.mcp.protocol;

import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class McpResponse {
    private String jsonrpc = "2.0";
    private String id;
    private Object result;
    private McpError error;

    public static McpResponse success(String id, Object result) {
        McpResponse response = new McpResponse();
        response.setId(id);
        response.setResult(result);
        return response;
    }

    public static McpResponse error(String id, int code, String message) {
        McpResponse response = new McpResponse();
        response.setId(id);
        response.setError(new McpError(code, message));
        return response;
    }

    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    public static class McpError {
        private int code;
        private String message;
        private Object data;

        public McpError(int code, String message) {
            this.code = code;
            this.message = message;
        }
    }
}

3. 实现工具注册表

MCP Server 需要能回答两个问题:

  • 有哪些工具?
  • 某个工具被调用时怎么执行?

这里实现三个简单工具:

工具名作用参数
hello_world返回问候语name,可选
get_time返回当前服务器时间
echo原样返回输入消息message
package com.example.mcp.tools;

import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;

@Component
public class McpToolRegistry {

    public List<Map<String, Object>> listTools() {
        return List.of(
            Map.of(
                "name", "hello_world",
                "description", "Returns a Hello World message",
                "inputSchema", Map.of(
                    "type", "object",
                    "properties", Map.of(
                        "name", Map.of(
                            "type", "string",
                            "description", "Name to greet"
                        )
                    )
                )
            ),
            Map.of(
                "name", "get_time",
                "description", "Returns current server time",
                "inputSchema", Map.of(
                    "type", "object",
                    "properties", Map.of()
                )
            ),
            Map.of(
                "name", "echo",
                "description", "Echoes back the provided message",
                "inputSchema", Map.of(
                    "type", "object",
                    "properties", Map.of(
                        "message", Map.of(
                            "type", "string",
                            "description", "Message to echo back"
                        )
                    )
                )
            )
        );
    }

    @SuppressWarnings("unchecked")
    public Map<String, Object> callTool(String name, Map<String, Object> arguments) {
        String text = switch (name) {
            case "hello_world" -> {
                String target = String.valueOf(arguments.getOrDefault("name", "World"));
                yield "Hello, " + target + "!";
            }
            case "get_time" -> "Current server time: " + LocalDateTime.now();
            case "echo" -> String.valueOf(arguments.getOrDefault("message", ""));
            default -> throw new IllegalArgumentException("Unknown tool: " + name);
        };

        return Map.of(
            "content", List.of(
                Map.of(
                    "type", "text",
                    "text", text
                )
            )
        );
    }
}

工具列表里的 inputSchema 很关键。MCP Host 会根据它理解工具参数,进而让模型生成符合结构的调用参数。

4. 实现 MCP 协议处理器

McpServerHandler 负责处理 JSON-RPC 方法。最小实现需要覆盖:

  • initialize
  • notifications/initialized
  • tools/list
  • tools/call
package com.example.mcp.server;

import com.example.mcp.protocol.McpRequest;
import com.example.mcp.protocol.McpResponse;
import com.example.mcp.tools.McpToolRegistry;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import java.util.Map;

@Component
@RequiredArgsConstructor
public class McpServerHandler {

    private final McpToolRegistry toolRegistry;
    private volatile boolean initialized = false;

    @SuppressWarnings("unchecked")
    public McpResponse handleRequest(McpRequest request) {
        if (request.getMethod() == null || request.getMethod().isBlank()) {
            return McpResponse.error(request.getId(), -32600, "Invalid request: method is required");
        }

        try {
            return switch (request.getMethod()) {
                case "initialize" -> handleInitialize(request.getId());
                case "notifications/initialized" -> {
                    initialized = true;
                    yield null;
                }
                case "tools/list" -> handleListTools(request.getId());
                case "tools/call" -> {
                    Map<String, Object> params = request.getParams() == null
                        ? Map.of()
                        : request.getParams();

                    String toolName = String.valueOf(params.get("name"));
                    Map<String, Object> arguments = params.get("arguments") instanceof Map<?, ?> map
                        ? (Map<String, Object>) map
                        : Map.of();

                    yield handleCallTool(request.getId(), toolName, arguments);
                }
                default -> McpResponse.error(
                    request.getId(),
                    -32601,
                    "Method not found: " + request.getMethod()
                );
            };
        } catch (IllegalArgumentException e) {
            return McpResponse.error(request.getId(), -32602, e.getMessage());
        } catch (Exception e) {
            return McpResponse.error(request.getId(), -32603, "Internal error: " + e.getMessage());
        }
    }

    private McpResponse handleInitialize(String id) {
        return McpResponse.success(
            id,
            Map.of(
                "protocolVersion", "2024-11-05",
                "capabilities", Map.of(
                    "tools", Map.of(
                        "listChanged", true
                    )
                ),
                "serverInfo", Map.of(
                    "name", "SpringBoot MCP Server",
                    "version", "1.0.0"
                )
            )
        );
    }

    private McpResponse handleListTools(String id) {
        return McpResponse.success(
            id,
            Map.of("tools", toolRegistry.listTools())
        );
    }

    private McpResponse handleCallTool(
        String id,
        String toolName,
        Map<String, Object> arguments
    ) {
        if (!initialized) {
            return McpResponse.error(id, -32600, "Client has not been initialized");
        }

        Object result = toolRegistry.callTool(toolName, arguments);
        return McpResponse.success(id, result);
    }
}

notifications/initialized 不返回响应,这是 JSON-RPC 通知的基本语义。客户端发通知只是告诉服务端“初始化流程已经完成”,服务端记录状态即可。

5. 实现 SSE 和 POST 两个端点

SseTransport 是整个 MCP Server 最关键的部分。它需要维护每个客户端的 SSE 连接,并把 POST 请求的处理结果推回对应客户端。

package com.example.mcp.transport;

import com.example.mcp.protocol.McpRequest;
import com.example.mcp.protocol.McpResponse;
import com.example.mcp.server.McpServerHandler;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.publisher.Sinks;

import java.time.Duration;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

@RestController
@RequiredArgsConstructor
public class SseTransport {

    private final ObjectMapper objectMapper;
    private final McpServerHandler serverHandler;

    private final Map<String, Sinks.Many<ServerSentEvent<String>>> clientSinks =
        new ConcurrentHashMap<>();

    @GetMapping(value = "/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<ServerSentEvent<String>> sseEndpoint(
        @RequestParam(required = false) String clientId,
        ServerWebExchange exchange
    ) {
        String origin = exchange.getRequest().getHeaders().getFirst("Origin");
        if (origin != null && !isValidOrigin(origin)) {
            return Flux.error(new SecurityException("Invalid origin: " + origin));
        }

        String sessionId = clientId != null ? clientId : "client-" + UUID.randomUUID();
        Sinks.Many<ServerSentEvent<String>> sink =
            Sinks.many().multicast().onBackpressureBuffer();

        clientSinks.put(sessionId, sink);

        String endpointUri = getBaseUrl(exchange) + "/message/" + sessionId;

        ServerSentEvent<String> endpointEvent = ServerSentEvent.<String>builder()
            .event("endpoint")
            .data(endpointUri)
            .build();

        Flux<ServerSentEvent<String>> heartbeat = Flux.interval(Duration.ofSeconds(30))
            .map(tick -> ServerSentEvent.<String>builder()
                .event("ping")
                .data("{\"type\":\"ping\"}")
                .build()
            );

        return Flux.concat(
                Flux.just(endpointEvent),
                Flux.merge(sink.asFlux(), heartbeat)
            )
            .doFinally(signalType -> {
                clientSinks.remove(sessionId);
                sink.tryEmitComplete();
            });
    }

    @PostMapping(
        value = "/message/{sessionId}",
        consumes = MediaType.APPLICATION_JSON_VALUE
    )
    public Mono<ResponseEntity<Void>> handleMessage(
        @PathVariable String sessionId,
        @RequestBody String messageJson,
        ServerWebExchange exchange
    ) {
        return Mono.fromRunnable(() -> {
            String origin = exchange.getRequest().getHeaders().getFirst("Origin");
            if (origin != null && !isValidOrigin(origin)) {
                sendMessageToClient(
                    sessionId,
                    McpResponse.error(null, -32600, "Invalid origin: " + origin)
                );
                return;
            }

            try {
                McpRequest request = objectMapper.readValue(messageJson, McpRequest.class);
                McpResponse response = serverHandler.handleRequest(request);

                if (response != null) {
                    sendMessageToClient(sessionId, response);
                }
            } catch (Exception e) {
                sendMessageToClient(
                    sessionId,
                    McpResponse.error(null, -32700, "Parse error: " + e.getMessage())
                );
            }
        }).thenReturn(ResponseEntity.accepted().build());
    }

    private void sendMessageToClient(String sessionId, Object message) {
        Sinks.Many<ServerSentEvent<String>> sink = clientSinks.get(sessionId);
        if (sink == null) {
            return;
        }

        try {
            String json = objectMapper.writeValueAsString(message);

            ServerSentEvent<String> event = ServerSentEvent.<String>builder()
                .event("message")
                .data(json)
                .build();

            sink.tryEmitNext(event);
        } catch (Exception e) {
            sink.tryEmitNext(ServerSentEvent.<String>builder()
                .event("message")
                .data("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"Serialization error\"}}")
                .build()
            );
        }
    }

    private String getBaseUrl(ServerWebExchange exchange) {
        String scheme = exchange.getRequest().getURI().getScheme();
        String host = exchange.getRequest().getHeaders().getHost().toString();
        return scheme + "://" + host;
    }

    private boolean isValidOrigin(String origin) {
        return origin.startsWith("http://localhost")
            || origin.startsWith("http://127.0.0.1");
    }
}

这段代码里有几个关键点:

代码位置作用
GET /sse建立 SSE 长连接
clientSinkssessionId 保存客户端连接
event: endpoint告诉客户端 POST 请求地址
POST /message/{sessionId}接收客户端 JSON-RPC 请求
sendMessageToClient把 JSON-RPC 响应通过 SSE 推回客户端
ping 事件防止长连接长时间无数据被中间代理断开
Origin 校验降低 DNS Rebinding 等攻击风险

POST 接口返回的是 HTTP 层面的接收状态,例如 202 Accepted。真正的 MCP 响应不从 POST 返回体返回,而是通过 SSE 的 event: message 推给客户端。

用 MCP Inspector 测试服务

启动服务:

mvn spring-boot:run

安装并启动 MCP Inspector:

npx "@modelcontextprotocol/inspector@0.9"

在 Inspector 中选择 SSE Transport,连接地址填:

http://localhost:8080/sse

连接建立后,服务端会先推送 endpoint:

event: endpoint
data: http://localhost:8080/message/client-xxxx

再按 MCP 流程执行:

  1. Initialize
  2. List Tools
  3. Call Tools

也可以直接构造工具调用请求。调用 hello_world 的 JSON-RPC 请求格式如下:

{
  "jsonrpc": "2.0",
  "method": "tools/call",
  "params": {
    "name": "hello_world",
    "arguments": {
      "name": "MCP"
    }
  },
  "id": "2"
}

服务端通过 SSE 返回:

{
  "jsonrpc": "2.0",
  "id": "2",
  "result": {
    "content": [
      {
        "type": "text",
        "text": "Hello, MCP!"
      }
    ]
  }
}

调用 tools/list 时,返回结果会包含三个工具的描述和参数结构:

{
  "jsonrpc": "2.0",
  "id": "1",
  "result": {
    "tools": [
      {
        "name": "hello_world",
        "description": "Returns a Hello World message",
        "inputSchema": {
          "type": "object",
          "properties": {
            "name": {
              "type": "string",
              "description": "Name to greet"
            }
          }
        }
      }
    ]
  }
}

实现 MCP Server 时容易踩的坑

1. SSE 通道和 POST 通道不要混在一起

/sse 只负责服务端推送事件,客户端请求应该发到 endpoint 事件给出的 POST 地址。把 initialize 直接 POST 到 /sse,通常不会得到预期结果。

2. JSON-RPC 响应必须带回相同的 id

客户端用 id 匹配请求和响应。服务端响应里的 id 如果丢了或变了,客户端就不知道这条结果对应哪次请求。

{
  "jsonrpc": "2.0",
  "id": "request-id-from-client",
  "result": {}
}

3. 通知不需要响应

notifications/initialized 属于通知,不应该返回 JSON-RPC 响应。返回响应可能会让部分客户端状态机出现异常。

4. 工具参数要用 JSON Schema 描述清楚

inputSchema 不是装饰字段。Agent 平台会依赖它告诉模型应该生成什么参数。字段名、类型、必填项、描述不准确,模型就更容易生成错误参数。

可以给必填参数加上 required

{
  "type": "object",
  "properties": {
    "message": {
      "type": "string",
      "description": "Message to echo back"
    }
  },
  "required": ["message"]
}

5. 长连接要处理心跳和断开清理

SSE 连接可能因为浏览器、代理、网关、负载均衡超时而断开。服务端最好定期发送心跳,并在连接断开时清理 sessionId 对应的资源。

6. 生产环境要认真处理安全边界

本地 Demo 只做了简单的 Origin 校验。生产环境还需要考虑:

风险处理方式
未授权调用工具加鉴权,例如 Token、OAuth、网关认证
DNS Rebinding校验 OriginHost,限制内网访问
参数注入对工具参数做白名单校验
工具越权每个工具单独做权限判断
敏感数据泄露控制工具返回内容,不把内部异常栈直接返回给客户端

掌握 MCP Server 的关键判断标准

一个 MCP Server 能稳定工作,核心不在代码量,而在协议流程是否完整:

flowchart TD
    A[暴露 GET /sse] --> B[返回 event: endpoint]
    B --> C[接收 POST JSON-RPC]
    C --> D{method}
    D -->|initialize| E[返回协议版本和能力]
    D -->|notifications/initialized| F[记录初始化完成]
    D -->|tools/list| G[返回工具列表和参数 Schema]
    D -->|tools/call| H[执行工具并返回 content]
    D -->|未知方法| I[返回 JSON-RPC error]
    E --> J[通过 SSE event: message 推回客户端]
    G --> J
    H --> J
    I --> J

只要理解这条链路,MCP 就不再是一个抽象概念。它的底层并不神秘:SSE 解决“服务端怎么推消息”,JSON-RPC 2.0 解决“消息格式怎么统一”,MCP 协议解决“连接、握手、工具发现、工具调用的生命周期怎么固定”。

把这三层合在一起,一个 Agent 就能用统一方式连接外部工具,一个工具服务也能被多个支持 MCP 的平台复用。


评论