🤖
AI审核中

MCP协议大改之后:Java与Spring AI如何跨越兼容性断层

Java 20分钟 119浏览 0评论

很多 Java 开发者第一次接触 MCP 时,关注的往往是如何使用 Spring AI 编写一个 @McpTool

但到了 2026 年,真正容易让系统出问题的,已经不是“工具怎么注册”,而是:

客户端和服务端使用的,可能根本不是同一个时代的 MCP 协议。

2026 年 7 月 28 日发布的新版 MCP 规范,彻底移除了协议级会话和初始化握手,开始转向真正的无状态协议。

问题在于,截至 2026 年 8 月 15 日,Java MCP SDK 和 Spring AI 还没有完整跟上这次协议升级。

这意味着,一个在本地运行正常的 Spring AI MCP Server,接入新版托管客户端时,可能在第一次请求到达工具之前就直接失败。

本文将从协议变化、Java 生态现状、兼容性测试、状态管理、安全设计和迁移方案几个方面,完整分析这次 MCP 协议断层。

一、MCP 2026-07-28 到底改变了什么

早期 MCP 更接近一种“先建立连接,再进行交互”的协议。

客户端需要先调用 initialize,服务端根据初始化请求返回协议版本和能力。部分 Streamable HTTP 服务还会通过 Mcp-Session-Id 维护会话状态。

2026-07-28 版本改变了这套模型:

  • 删除 initializenotifications/initialized
  • 删除协议级 Session 和 Mcp-Session-Id
  • 每个请求都携带协议版本、客户端身份和能力;
  • 新增强制实现的 server/discover
  • 每个 JSON-RPC 请求都使用独立的 HTTP POST;
  • 服务端需要跨请求保存状态时,必须显式返回状态句柄;
  • 服务端到客户端的多轮交互改为 MRTR;
  • 长连接通知改为 subscriptions/listen

官方将 2026-07-28 及之后的协议称为现代协议,将 2025-11-25 及之前依赖初始化握手的协议称为旧版协议。(Model Context Protocol)

两种协议的差异可以概括为:

对比项 2025-11-25 及之前 2026-07-28 及之后
协议生命周期 先初始化,再调用工具 每个请求独立完成
版本传递 在初始化阶段协商 每个请求都携带版本
客户端能力 初始化时声明 每个请求通过 _meta 声明
协议会话 可以依赖 Session ID 不再存在协议级会话
服务端发现 依赖初始化结果 使用 server/discover
跨请求状态 可以绑定连接或会话 使用显式状态句柄
HTTP 交互 POST、GET、SSE 混合 每个请求独立 POST
服务端反向请求 基于双向会话 使用 MRTR 嵌入结果

这并不是一次简单的字段调整,而是 MCP 生命周期模型的一次重新设计。

二、“无状态”其实有三种不同含义

MCP 升级后,最容易产生的误解是:

只要 Spring AI 配置了 protocol: STATELESS,就已经支持 MCP 2026-07-28。

这个结论并不成立。

在实际工程中,“无状态”至少包含三个不同层面的概念。

1. 部署层无状态

应用实例本身不保存本地会话,可以在 Kubernetes、Docker Swarm 或普通负载均衡集群中横向扩容。

例如,请求可以随机分发到任意实例。

2. 传输层无状态

Spring AI 的 Stateless MCP Server 不依赖某个固定 HTTP 连接,也不要求客户端一直连接同一个实例。

这主要解决的是服务部署与连接管理问题。

3. 协议层无状态

MCP 2026-07-28 不再存在初始化握手和协议级 Session。

每个请求必须自己携带:

  • 协议版本;
  • 客户端信息;
  • 客户端能力;
  • 请求方法相关信息。

Spring AI 文档目前已经提供 STATELESS 服务模式和默认 /mcp 端点,但 Spring AI 主分支仍然引用 MCP Java SDK 2.0.0。与此同时,Java SDK 官方问题记录明确说明,2.0.0 支持的是 2025-11-25 协议,并未实现 server/discover。(Home)

因此,工程上应当这样理解:

Spring AI 的 STATELESS 代表当前实现采用无状态服务端模式,并不等于它已经完整实现 MCP 2026-07-28 现代协议。

三、Java MCP 生态目前处于什么状态

截至 2026 年 8 月 15 日,MCP Java SDK 最新正式标签仍然是 v2.0.0,发布时间为 2026 年 6 月 11 日。官方 Java SDK 的 3.x 规划将实现 2026-07-28 规范列为主要目标,但当前里程碑仍处于开发阶段。(GitHub)

目前可以得出以下结论:

组件 当前状态
MCP 规范 已发布 2026-07-28 现代无状态规范
Java MCP SDK 正式版 最新为 2.0.0
Java SDK 2.0.0 协议基线 2025-11-25
server/discover Java SDK 2.0.0 尚未完整支持
Spring AI MCP 依赖 当前仍引用 Java SDK 2.0.0
Java 现代协议支持 正在 3.x 规划中推进

这并不代表 Spring AI 的 MCP 功能不能使用。

它意味着:

当前 Java 服务端和旧时代客户端可以正常协作,但不能默认认为它兼容所有已经升级到 2026-07-28 的客户端。

四、一个 server/discover 为什么会让整个请求失败

新版 MCP 规定,服务端必须实现 server/discover

客户端可以在调用 tools/list 之前先发送:

{
  "jsonrpc": "2.0",
  "id": "discover-1",
  "method": "server/discover",
  "params": {
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientInfo": {
        "name": "enterprise-agent",
        "version": "1.0.0"
      },
      "io.modelcontextprotocol/clientCapabilities": {}
    }
  }
}

服务端应当返回自己支持的协议版本、能力和身份信息。(Model Context Protocol)

理想的兼容流程如下:

flowchart LR
    A[客户端访问 MCP 服务] --> B[发送 server/discover]
    B --> C{服务端响应}
    C -->|200| D[读取 supportedVersions]
    D --> E[选择共同协议版本]
    C -->|404 与 -32601| F[确认服务端不支持该方法]
    F --> G[双时代客户端回退旧版 initialize]
    C -->|400 与 -32022| H[读取服务端支持版本]
    H --> E
    C -->|500| I[协议探测中断]
    I --> J[连接器或整次模型请求失败]

官方规范要求:

  • 协议版本不支持时,返回 HTTP 400 和 UnsupportedProtocolVersionError
  • RPC 方法不支持时,返回 HTTP 404 和 JSON-RPC 错误码 -32601
  • 不应该因为未知方法直接返回 HTTP 500。(Model Context Protocol)

但 Java SDK 2.0.0 的一个公开问题显示,新版客户端发送 server/discover 后,当前无状态服务端可能返回 HTTP 500,并提示缺少对应处理器。

更严重的是,某些托管客户端会将这个 500 视为 MCP 连接失败,从而终止整次模型响应,即使用户的问题根本不需要调用工具。(GitHub)

所以这不是一个普通的“工具调用失败”,而是:

MCP 协议探测阶段已经失败,模型甚至还没有机会决定是否调用工具。

五、新旧协议的兼容关系

官方将实现分为三类:

  • Legacy:只支持初始化握手协议;
  • Modern:只支持每请求元数据协议;
  • Dual-era:同时支持新旧两套协议。

常见组合如下:

客户端 服务端 结果
Legacy 客户端 Legacy 服务端 协议时代一致,通常可正常使用
Modern 客户端 Modern 服务端 正常使用新版协议
Dual-era 客户端 Legacy 服务端 服务端正确返回不支持后,可以回退
Modern 客户端 Legacy 服务端 通常无法直接兼容
Legacy 客户端 Modern-only 服务端 无法完成旧版初始化
Modern 客户端 当前 Java SDK 2.0.0 服务端 存在 server/discover 兼容风险

这也是为什么单纯查看 /mcp 接口是否能够访问,并不能证明服务端兼容某个 MCP 客户端。(Model Context Protocol)

六、当前 Spring AI 项目的可用基线

在 Java SDK 正式完成新版协议实现之前,Spring AI 2.0 仍然可以作为旧版协议时代的无状态 MCP Server 使用。

Maven 依赖可以这样配置:

<properties>
    <java.version>21</java.version>
    <spring-ai.version>2.0.0</spring-ai.version>
</properties>

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>${spring-ai.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
    </dependency>
</dependencies>

服务端配置如下:

spring:
  ai:
    mcp:
      server:
        name: enterprise-tool-server
        version: 1.0.0
        type: SYNC
        protocol: STATELESS
        request-timeout: 20s
        stateless:
          mcp-endpoint: /mcp

Spring AI 文档显示,Stateless MCP Server 支持工具、资源和提示词能力,默认端点为 /mcp。不过 Stateless 模式不适合依赖连接上下文的设计,Tool Context 在该模式下也不适用。(Home)

需要强调的是:

这是一套当前可用的 Java 无状态部署基线,不应该对外宣称已经完整支持 2026-07-28 协议。

七、在 CI 中增加 MCP 协议兼容性测试

MCP 服务上线前,不能只测试 tools/listtools/call

至少还应该测试:

  1. 新版 server/discover 请求是否导致 500;
  2. 不支持的方法是否返回正确的协议错误;
  3. 请求头和 _meta 中的协议版本是否一致;
  4. 服务端到底声明支持哪些协议版本。

下面是一段基于 Java 21 HttpClient 和 JUnit 5 的协议探测测试:

import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;

import org.junit.jupiter.api.Test;

class McpProtocolCompatibilityTest {

    private static final URI MCP_ENDPOINT = URI.create(
            System.getProperty(
                    "mcp.endpoint",
                    "http://localhost:8080/mcp"
            )
    );

    private final HttpClient httpClient = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(3))
            .build();

    @Test
    void discoverShouldReturnControlledProtocolResponse() throws Exception {
        String requestBody = """
                {
                  "jsonrpc": "2.0",
                  "id": "discover-1",
                  "method": "server/discover",
                  "params": {
                    "_meta": {
                      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
                      "io.modelcontextprotocol/clientInfo": {
                        "name": "mcp-contract-test",
                        "version": "1.0.0"
                      },
                      "io.modelcontextprotocol/clientCapabilities": {}
                    }
                  }
                }
                """;

        HttpRequest request = HttpRequest.newBuilder()
                .uri(MCP_ENDPOINT)
                .timeout(Duration.ofSeconds(10))
                .header("Content-Type", "application/json")
                .header("Accept", "application/json, text/event-stream")
                .header("MCP-Protocol-Version", "2026-07-28")
                .header("Mcp-Method", "server/discover")
                .POST(HttpRequest.BodyPublishers.ofString(requestBody))
                .build();

        HttpResponse<String> response = httpClient.send(
                request,
                HttpResponse.BodyHandlers.ofString()
        );

        int status = response.statusCode();
        String body = response.body();

        switch (status) {
            case 200 -> assertTrue(
                    body.contains("\"supportedVersions\""),
                    "200 响应必须包含 supportedVersions"
            );

            case 400 -> assertTrue(
                    body.contains("-32022"),
                    "400 响应应当包含 UnsupportedProtocolVersionError"
            );

            case 404 -> assertTrue(
                    body.contains("-32601"),
                    "404 响应应当包含 Method not found"
            );

            default -> fail(
                    "MCP 协议探测返回了不可控响应,status="
                            + status
                            + ", body="
                            + body
            );
        }
    }
}

对于当前存在兼容问题的 Java MCP Server,这个测试可能直接失败。

这正是测试存在的意义:在生产客户端替你发现问题之前,先在 CI 阶段阻断不兼容版本。

八、没有 Session,不代表业务不能保存状态

新版 MCP 删除的是协议级 Session,并不是禁止业务保存状态。

例如,一个报表生成流程可能需要:

  1. 创建报表任务;
  2. 返回任务 ID;
  3. 后续查询执行状态;
  4. 下载最终结果。

以前开发者可能把任务状态绑定在当前连接或 Session 中。

新版协议推荐服务端生成一个显式状态句柄,并要求客户端在后续工具调用时继续传递。(Model Context Protocol)

flowchart LR
    A[调用 create_export_job] --> B[服务端创建任务]
    B --> C[(Redis 或 PostgreSQL)]
    B --> D[返回 jobId 与 expiresAt]
    D --> E[模型保留 jobId]
    E --> F[调用 get_export_job]
    F --> G[携带 jobId]
    G --> C
    C --> H[校验用户 权限 有效期]
    H --> I[返回任务状态]

Java 工具可以这样设计:

import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import java.util.UUID;

import org.springframework.ai.mcp.annotation.McpTool;
import org.springframework.ai.mcp.annotation.McpToolParam;
import org.springframework.stereotype.Component;

record ExportHandle(
        String jobId,
        Instant expiresAt
) {
}

record ExportStatus(
        String jobId,
        String status,
        Instant expiresAt
) {
}

record ExportJob(
        String jobId,
        String ownerId,
        String reportType,
        String status,
        Instant expiresAt
) {
}

interface ExportJobRepository {

    void save(ExportJob job);

    Optional<ExportJob> findById(String jobId);
}

interface CurrentCaller {

    String requireUserId();
}

@Component
public class ExportTools {

    private final ExportJobRepository repository;
    private final CurrentCaller currentCaller;

    public ExportTools(
            ExportJobRepository repository,
            CurrentCaller currentCaller
    ) {
        this.repository = repository;
        this.currentCaller = currentCaller;
    }

    @McpTool(
            name = "create_export_job",
            description = "创建异步报表任务,任务句柄有效期为两小时"
    )
    public ExportHandle createExportJob(
            @McpToolParam(
                    description = "报表类型",
                    required = true
            )
            String reportType
    ) {
        String jobId = "job_" + UUID.randomUUID();
        String ownerId = currentCaller.requireUserId();
        Instant expiresAt = Instant.now().plus(Duration.ofHours(2));

        ExportJob job = new ExportJob(
                jobId,
                ownerId,
                reportType,
                "PENDING",
                expiresAt
        );

        repository.save(job);

        return new ExportHandle(jobId, expiresAt);
    }

    @McpTool(
            name = "get_export_job",
            description = "根据任务句柄查询报表生成状态"
    )
    public ExportStatus getExportJob(
            @McpToolParam(
                    description = "create_export_job 返回的任务句柄",
                    required = true
            )
            String jobId
    ) {
        String ownerId = currentCaller.requireUserId();

        ExportJob job = repository.findById(jobId)
                .filter(item -> item.ownerId().equals(ownerId))
                .orElseThrow(() ->
                        new IllegalArgumentException(
                                "任务不存在或当前用户无权访问"
                        )
                );

        if (job.expiresAt().isBefore(Instant.now())) {
            throw new IllegalStateException(
                    "任务句柄已经过期,请重新创建任务"
            );
        }

        return new ExportStatus(
                job.jobId(),
                job.status(),
                job.expiresAt()
        );
    }
}

实际生产环境中,ExportJobRepository 应由 Redis、PostgreSQL 或其他共享存储实现,而不是使用本机 ConcurrentHashMap

因为只要服务部署了多个实例,本地内存状态就会产生以下问题:

  • 创建任务的实例和查询任务的实例可能不同;
  • 应用重启后任务状态丢失;
  • 扩容、缩容时无法迁移状态;
  • 无法统一设置过期时间;
  • 无法进行跨实例幂等控制。

状态句柄还必须满足几个条件:

要求 说明
不透明 不在 ID 中暴露用户、数据库主键等内部结构
不可猜测 使用 UUID 或足够随机的标识
有有效期 明确 TTL,并在过期后返回可恢复错误
每次鉴权 不能拿到句柄就拥有数据访问权限
支持幂等 创建、支付、提交等操作需要业务幂等键
共享存储 不能绑定某一个应用实例

官方规范特别强调:对于认证服务,Handle 只是一个名称,不是权限凭证,服务端必须在每次调用时重新校验调用者是否有权访问该 Handle。(Model Context Protocol)

九、Java 项目应该怎样迁移

面对当前的协议断层,不建议直接将所有 MCP Server 一次性升级。

更稳妥的方案是分阶段迁移。

第一阶段:建立协议资产清单

先记录当前系统中:

  • MCP 客户端名称和版本;
  • MCP 服务端使用的 SDK;
  • 使用的协议版本;
  • Transport 类型;
  • 是否依赖 initialize
  • 是否依赖 Mcp-Session-Id
  • 是否发送 server/discover
  • 是否支持协议降级。

不要使用“客户端看起来能连上”作为兼容性依据。

第二阶段:固定当前生产基线

如果现有客户端和 Java SDK 2.0.0 工作正常,可以暂时固定在 2025-11-25 协议时代。

不要在没有完成验证的情况下,对外声明支持 2026-07-28。

协议版本应该像数据库版本一样被明确记录,而不是交给客户端和服务端自动猜测。

第三阶段:加入兼容性网关

企业内部拥有多个 MCP Server 时,可以增加一层协议兼容网关:

flowchart LR
    A[AI Host 或 Agent 平台] --> B[MCP 兼容网关]
    B --> C{协议版本}
    C -->|2025-11-25| D[Spring AI 2.0 服务集群]
    C -->|2026-07-28| E[新版 MCP 服务集群]
    D --> F[统一工具服务层]
    E --> F
    F --> G[(Redis 或 PostgreSQL)]
    F --> H[企业 API 与业务系统]

兼容网关可以负责:

  • 记录客户端发送的协议版本;
  • 校验 Header 和 _meta 是否一致;
  • 将未知方法转换为正确的 JSON-RPC 错误;
  • 根据版本路由到不同服务集群;
  • 统计新旧协议使用比例;
  • 对异常客户端进行限流和隔离。

但网关不应该伪造能力。

例如,后端并不支持 2026-07-28 时,网关不能返回一个假的 server/discover,声称服务端已经支持新版协议。

这样可能绕过连接检查,却会在后续 tools/list、MRTR 或订阅流程中产生更隐蔽的问题。

第四阶段:双栈灰度

当 Java SDK 正式提供完整的 2026-07-28 支持后,可以同时部署两组实例:

  • Legacy 实例继续服务旧客户端;
  • Modern 实例服务新版客户端;
  • 网关根据协议版本进行路由;
  • 逐步观察错误率和回退率;
  • 最后再下线旧版协议。

不要在同一时间同时升级客户端、SDK、网关和业务工具。

否则出现问题时,很难判断是协议、传输、鉴权还是工具实现造成的。

十、MCP 无状态化之后,安全边界更重要

无状态协议意味着每个请求都必须独立完成身份识别和权限校验。

不能因为前一个请求已经认证,就默认后续请求仍然可信。

新版 MCP 的安全设计要求包括:

  • Streamable HTTP 请求需要校验 Origin
  • 本地服务应尽量只绑定 127.0.0.1
  • 远程服务应使用可靠的身份认证;
  • Token 必须校验 Audience;
  • 禁止将上游 Token 直接透传给下游服务;
  • 应优先使用短生命周期访问令牌;
  • 工具参数必须进行完整校验;
  • 工具调用应进行限流;
  • 工具输出需要进行脱敏和清理;
  • 敏感操作应要求用户确认;
  • 工具调用需要留下审计记录。(Model Context Protocol)

对于 Java 服务来说,至少应该记录以下审计字段:

{
  "requestId": "req_9cb71f",
  "protocolVersion": "2026-07-28",
  "clientName": "enterprise-agent",
  "clientVersion": "1.0.0",
  "method": "tools/call",
  "toolName": "create_export_job",
  "userId": "user_1024",
  "tenantId": "tenant_001",
  "status": "SUCCESS",
  "durationMs": 126,
  "timestamp": "2026-08-15T10:30:00Z"
}

需要注意,日志里不要直接记录:

  • Access Token;
  • Cookie;
  • 身份证号;
  • 手机号;
  • 工具调用中的完整敏感参数;
  • 数据库查询结果全文;
  • 状态句柄对应的内部数据。

十一、建议增加的监控指标

除了普通接口监控,还应增加 MCP 协议维度的指标。

指标 作用
mcp_requests_total 按协议版本、方法、状态码统计请求
mcp_discover_total 统计 server/discover 调用结果
mcp_protocol_error_total 统计协议不匹配和 Header 不一致
mcp_fallback_total 统计客户端回退旧协议次数
mcp_tool_duration 统计工具调用耗时
mcp_tool_error_total 区分协议错误和业务执行错误
mcp_handle_lookup_total 统计 Handle 查询、过期和不存在
mcp_auth_denied_total 统计身份和权限拒绝
mcp_active_subscriptions 统计长连接订阅数量

尤其需要关注以下告警:

  • server/discover 出现 HTTP 500;
  • 某个客户端突然开始发送新协议版本;
  • 协议降级率突然升高;
  • 未知 RPC 方法数量异常增长;
  • Handle 越权访问数量增加;
  • 单个工具调用量突然暴涨;
  • SSE 请求长期不释放。

十二、结语

MCP 2026-07-28 的核心变化,不是增加了几个新方法,而是将协议从“连接和会话驱动”改造成了“每请求自描述”。

这使 MCP Server 更容易横向扩容,更适合云原生部署,也减少了客户端连接状态与服务实例之间的隐式绑定。

但协议升级也带来了现实问题:

规范已经进入现代无状态时代,Java SDK 和 Spring AI 仍处于新旧协议交界处。

因此,当前 Java 团队最重要的工作不是盲目追新,而是先建立清晰的协议边界:

  • 明确客户端和服务端支持的协议版本;
  • 不把 STATELESS 配置误认为完整支持新版协议;
  • 在 CI 中增加 server/discover 兼容性测试;
  • 未知方法必须返回可控错误,不能直接 500;
  • 将跨请求状态迁移为显式 Handle;
  • 在每次调用中重新完成身份和权限校验;
  • 通过双栈和灰度方式完成迁移。

MCP 最终会成为 AI 应用连接工具、数据和企业系统的重要协议。

而对于 Java 开发者来说,真正决定系统能否稳定运行的,往往不是能不能写出一个工具,而是能不能正确处理协议版本、状态边界和兼容性。

0 条评论
如果你觉得文章对你有帮助,那就请作者喝杯咖啡吧☕
微信
支付宝
  0 条评论