Spring AI 实战——应用 Model Context Protocol

123 阅读27分钟

本章内容

  • 使用预置的 MCP Server 的工具
  • 创建自定义 MCP Server
  • 启用 MCP 的 STDIO 与 HTTP+SSE 传输
  • 暴露工具、提示词与资源

在电影《无敌破坏王》中,角色 Fix-It Felix Jr. 手里拿着一把神奇的锤子。靠着这件工具,他能把被破坏的一切一锤修好:墙裂了?敲一下。路灯弯了?敲一下。窗户碎了?——完全无视玻璃与锤子的常识——敲一下,也能修好。要说“神锤”,恐怕只有雷神的妙尔尼尔能与之比肩。

但在现实世界里,锤子并不能解决一切。它钉钉子很拿手,可修窗户就不太行。现实里我们有很多工具,每种都有其最佳用例。与其像 Felix 那样永远握着同一把锤子,不如把多种工具收在一个便携的工具箱里,需要时带着走。

上一章里,你看到如何在 Spring AI 应用中把“工具”以内嵌代码的方式实现。本章将看到如何应用 Model Context Protocol(MCP,模型上下文协议) ,把一组相关的工具打包成可共享的“工具箱”,供任何 AI 应用复用。

7.1 认识 Model Context Protocol

MCP 是 Anthropic(Claude 系列模型的开发方)在 2024 年末提出的一份规范。虽然由 Anthropic 定义,但凡是支持“工具调用”的 LLM 与 API(包括我们一直使用的 OpenAI 模型)都可配合 MCP 使用。

MCP 的两个核心组件是 MCP ServerMCP Client

  • MCP Server 掌握某些资源(例如数据库、文件系统或外部 API),并通过一个或多个“工具”对外暴露访问能力。
  • MCP Client 运行在你的应用里,与 MCP Server 通讯,获取其工具列表,把这些工具提供给提示词上下文,并在 LLM 需要时代为调用工具。图 7.1 展示了规范中 MCP Server 与 MCP Client 的关系。 image.png

图 7.1 MCP Server 与 MCP Client 的交互

MCP Server 可通过三种传输协议对外提供工具:STDIOHTTP + Server-Sent Events(HTTP+SSE)Streamable HTTP。按规范,Server 应尽可能提供 STDIO 传输,同时可选择性实现 HTTP+SSE 或 Streamable HTTP。

注:Spring AI 1.0.0 支持 MCP 的 STDIOHTTP+SSE 传输,但不支持 Streamable HTTP,因此本章不涉及后者。Spring AI 1.1.0(撰写时尚处于早期里程碑版)将加入对 Streamable HTTP 的支持。

尽管 MCP 在 AIGC 领域还算新,但影响已经很大。开源之初,Anthropic 就给出了 19 个参考 MCP Servergithub.com/modelcontex…),涵盖 PostgreSQL、GitHub、Google Maps 等服务的集成。之后社区又涌现出数百个 MCP Server,覆盖更广的工具集合;可在 www.pulsemcp.com/ 查询不断扩充的注册目录。

许多 MCP Server 的 README 都包含在 Claude Desktopclaude.ai/download)里配置的说明。安装 Claude Desktop 后,打开设置 → Developer → Edit Config,将 MCP Server 的配置粘贴进去并重启应用即可。

比如你想试试 Google Maps MCP Servermng.bz/Dw0A)。README 提供了用 Docker 或 NPX 运行的方案。把其中任一配置(并加上你的 Google Maps API Key)粘进 Claude Desktop 配置后重启,就能在对话中提地理相关的问题。

举例:你想知道新墨西哥州小镇 Jal 里有多少家便利店。图 7.2 展示了在 Claude Desktop 中使用该 MCP Server 的效果。

image.png

图 7.2 在 Claude Desktop 中使用 Google Maps MCP Server

把 MCP 加进 Claude Desktop 很有趣也很实用,但 MCP 的真正威力在于把它“嵌”进你自己的应用里。为此,Spring AI 同时支持:

  1. 作为客户端使用已有的 MCP Server;
  2. 自己编写 MCP Server。下面先看如何用 MCP Client 集成外部 MCP Server 的工具。

7.2 使用 MCP Client

入门 MCP 最容易的方式,是先接入一个现成的 MCP Server。在 19 个参考实现里,Filesystem MCP Servermng.bz/lZQd)是绝佳的起点:它简单但能直观展示 MCP 的潜力。

我们创建一个新的 Spring Boot 项目:依赖 Spring WebSpring AI OpenAI,再加上 MCP Client 依赖(如图 7.3)。

image.png

图 7.3 在 Spring 中初始化 MCP 项目

项目初始化后,接下来配置 MCP Client 连接 Filesystem MCP Server。配置有两种方式:

  • 使用 Spring 配置application.propertiesapplication.yml
  • 使用 Claude Desktop 兼容的 JSON 配置

选择哪种主要看你习惯。很多 MCP Server 的 README 都提供了 Claude Desktop 用的 JSON,复制粘贴即可;而 Spring 风格对 Spring Boot 用户更自然。需要注意的是,在 7.4 节启用 HTTP+SSE 传输时,只能使用 Spring 风格配置,不能用 Claude 的 JSON。

先看 Spring 风格。在 application.yml 中配置 Filesystem MCP Server,如下:

清单 7.1 在 application.yml 中配置 MCP Server 引用

spring:
  ai:
    mcp:
      client:
        stdio:
          connections:
            filesystem:   #1
              command: 'npx'  #2
              args:           #3
                - '-y'
                - '@modelcontextprotocol/server-filesystem'
                - '/Users/habuma/mcp-playground'

        toolcallback:
          enabled: true

上述配置以 spring.ai.mcp.client 为根,表明通过 STDIO 传输连接 MCP Server(stdio)。在其下,可配置一个或多个连接。本例配置了名为 filesystem 的连接:

  • command 指定启动 Server 的命令;
  • args 指定命令行参数。

应用启动时,Spring AI 会按该命令启动 MCP Server,并通过标准输入/输出与之通讯。

该配置直接来源于 Filesystem MCP Server README 中“在 Claude Desktop 使用”的示例(只是路径不同)。如果你更偏好 Claude Desktop 风格 JSON,Spring AI 也支持——可用一个属性指向 JSON 文件:

spring:
  ai:
    mcp:
      client:
        stdio:
          servers-configuration: classpath:mcp-servers.json

spring.ai.mcp.client.stdio.servers-configuration 接受一个 URI,指向 Claude 的配置 JSON。比如 classpath:mcp-servers.json 放在类路径根目录,内容如下(与 README 相仿,只是路径不同):

{
  "mcpServers": {
    "filesystem": {
      "command": "npx",
      "args": [
        "-y",
        "@modelcontextprotocol/server-filesystem",
        "/Users/habuma/mcp-playground"
      ]
    }
  }
}

如果你已经在 Claude Desktop 里配置过多个 MCP Server,也可以让 Spring AI 直接读取 Claude 的配置。macOS 示例:

spring:
  ai:
    mcp:
      client:
        stdio:
          servers-configuration: "file://${HOME}/Library/Application Support/Claude/claude_desktop_config.json"

无论用哪种方式,Spring AI 都会在应用启动时自动拉起对应的 MCP Server。你只需让 ChatClient 知道这些“外部工具”即可:在构建 ChatClient 时调用 defaultToolCallbacks(),或在发起 prompt 时调用 tools()

下面创建本项目的主控制器,类似 Board Game Buddy 的 AskController,但避免混淆,我们命名为 McpAskController(清单 7.2)。

清单 7.2 支持 MCP 的问答控制器

package com.example.mcpfilesystemclient;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class McpAskController {

    private final ChatClient chatClient;

    public McpAskController(ChatClient.Builder chatClientBuilder,
                            ToolCallbackProvider tools) {
        this.chatClient = chatClientBuilder
                .defaultToolCallbacks(tools)
                .build();
    }

    @PostMapping("/ask")
    public Answer ask(@RequestBody Question question) {
        return chatClient.prompt()
                .user(question.question())
                .call()
                .entity(Answer.class);
    }

    public record Question(String question) { }

    public record Answer(String answer) { }

}

McpAskController 与第一章的控制器类似,区别在于构造函数里把 ToolCallbackProvider 传给了 defaultToolCallbacks(),让 ChatClient 知道 MCP Server 提供的所有工具。于是,LLM 在需要时就能像调用你自定义的 gameComplexityFunction 那样调用这些外部工具。

到此,MCP Client 的配置就绪。我们试跑一下:

  • 启动应用;
  • 在你为 Filesystem MCP Server 指定的工作目录里放些文件;
  • 用 HTTPie 询问目录中文件列表:
$ http :8080/ask question="List all files" -b
{
    "answer": "[FILE] testfile.txt\n[FILE] someOtherFile.txt"
}

很棒!它看到了两个文件(前缀 [FILE])。更酷的是它不仅能“读”,还能“写”:

$ http :8080/ask question="Create a file named penguins.txt and write \
a joke about penguins as its content" -b
{
    "answer": "File penguins.txt created with a joke about penguins."
}

它声称已写入文件。你可以用编辑器打开验证,或者继续让应用读给你听:

$ http :8080/ask question="Read the file named penguins.txt" -b
{
    "answer": "Why don't you ever see penguins in the UK?\n\nBecause they're
               afraid of Wales!"
}

通过 Filesystem MCP Server,我们直观体验了如何在 Spring AI 中集成外部 MCP Server 的能力。别忘了,这只是 Anthropic 提供的 19 个参考 Server 之一;社区里还有数百个 MCP Server 等你发掘,功能“宝库”可直接接入你的 Spring AI 应用。

你也许会问:能否自建一个 MCP Server?答案是:当然可以!Spring AI 同样支持用 Java 编写自己的 MCP Server。下一节我们就动手实现一个具备自定义能力的 MCP Server。

7.3 创建你自己的 MCP Server

虽然已经有不少 MCP Server 可以用于集成常见的 API 与功能,但总还有新的空间。Spring AI 的 MCP Server 支持让你能够创建并共享几乎任何你能想象、且可以用 Java 完成的工具。为了演示如何用 Spring AI 构建一个 MCP Server,我们来实现一个能在回答桌游问题时提供帮助的 MCP Server。

第 4 章中你使用的 RAG 技术非常适合基于某一款游戏的规则书来提问。但当问题跨越整批游戏、且比较基础时,RAG 就不那么合适了。比如用户问:“有什么适合 10 人玩的游戏?”或者“能推荐一款 30 分钟内就能玩完的游戏吗?”RAG 顶多能找出几段似乎相关的文档片段并尝试作答。但这类问题更适合直接查询数据库或 API。

想象一下:我们有一个数据库,里面保存了若干桌游的基础信息。这个数据库中的一张表可能包含:

  • 游戏标题
  • 游戏的简要描述
  • 该游戏可参与的最少与最多玩家数
  • 预计的游戏时长

如果你构建一个 MCP Server,把返回这些数据的工具暴露出来,那么客户端应用就能回答“某个玩家人数最合适的游戏”或“在某个时间内可以完成的游戏”等问题。下面我们就来构建这样一个 MCP Server。

7.3.1 构建服务器

要创建自定义 MCP Server,先新建一个 Spring Boot 应用。在 Initializr 里选择依赖时,需要勾选 Model Context Protocol Server。由于服务器要操作数据库,还需要一些额外依赖,具体包括:

  • Spring Data JDBC —— 用来创建查询数据库的仓库(Repository)
  • PostgreSQL Driver —— 连接 PostgreSQL 数据库
  • Docker Compose Support —— 应用启动时自动通过 Docker 拉起 PostgreSQL

你不需要 OpenAI 或其他 LLM 相关依赖,因为此服务器并不会直接与 LLM 交互。图 7.4 展示了该项目的初始化方式。

image.png

图 7.4 初始化用于创建自定义 MCP Server 的项目

项目初始化完成后,就可以从创建 JDBC 仓库与准备数据库(模式与示例数据)开始搭建 MCP Server。

7.3.2 配置数据库

为简化演示、并避免你在 MCP Server 项目之外额外准备数据库,我们使用 Spring Boot 的 Docker Compose 支持,在应用启动时拉起一个 PostgreSQL 数据库。Spring Boot Initializr 应当已在项目根目录生成 compose.yaml,里头包含 PostgreSQL 服务;若没有,请创建该文件并确保内容如下:

services:
  postgres:
    image: 'postgres:latest'
    environment:
      - 'POSTGRES_DB=boardgamedb'
      - 'POSTGRES_PASSWORD=secret'
      - 'POSTGRES_USER=myuser'
    ports:
      - '5432:5432'

接着,确保数据库按预期被初始化(模式与测试数据)。在 src/main/resources 下放置如下 schema.sql,创建 Game 表:

drop table if exists game cascade;
create table game (
    id bigserial not null primary key,
    title varchar(255) not null,
    description varchar(1024),
    min_players int not null,
    max_players int not null,
    min_playing_time int not null,
    max_playing_time int not null
);

再用 data.sql 填充一些示例游戏数据:

insert into game (title, description, min_players, max_players,
                  min_playing_time, max_playing_time) values
('Sagrada', 'A dice-drafting game where players create beautiful ' ||
 'stained glass windows using colored dice.', 1, 4, 30, 45),
('Catan', 'A strategy board game where players collect resources and ' ||
 'build settlements.', 3, 4, 60, 120),
('Ticket to Ride', 'A railway-themed board game where players collect ' ||
 'cards to claim railway routes.', 2, 5, 30, 120),
('Carcassonne', 'A tile-placement game where players build cities, roads, ' ||
 'and fields.', 2, 5, 35, 45),
('7 Wonders', 'A card drafting game where players develop civilizations ' ||
 'through three ages.', 3, 7, 30, 45),
('Azul', 'A tile-laying game where players decorate a palace with ' ||
 'beautiful tiles.', 2, 4, 30, 45),
('Splendor', 'A card-based game where players collect gems and develop a ' ||
 'trading empire.', 2, 4, 30, 45),
('Azul Duel', 'A two-player version of Azul where players compete to ' ||
 'create the most beautiful tile mosaic.', 2, 2, 30, 45),
('Wingspan', 'A card-driven engine-building game where players attract ' ||
 'birds to their wildlife preserves.', 1, 5, 40, 70),
('Flip 7', 'A fast-paced card game where players try to flip cards to ' ||
 'get the highest score, but avoiding flipping a duplicate card',
 3, 13, 15, 30);

如果你用的是“嵌入式数据库”,schema.sqldata.sql 会在应用启动后由 Spring Boot 自动应用。但运行在 Docker 的 PostgreSQL 不算嵌入式,因此需要在 application.properties 中配置以下属性,指示 Spring Boot 总是初始化数据库:

spring.sql.init.mode=always

接下来,需要让 MCP Server 能查询数据库。使用 Spring Data JDBC,可定义一个接口,Spring 会在运行时自动生成实现。下面的 GameRepository 提供了 MCP Server 所需的方法:

package com.example.mcpserver;

import org.springframework.data.jdbc.repository.query.Query;
import org.springframework.data.repository.CrudRepository;

import java.util.List;

public interface GameRepository extends CrudRepository<Game, Long> {

  @Query("""
      SELECT id, title, description, min_players, max_players,
             min_playing_time, max_playing_time
      FROM game
      WHERE min_players <= :numPlayers AND max_players >= :numPlayers
      """)
  List<Game> findGamesForPlayerCount(int numPlayers);

  @Query("""
      SELECT id, title, description, min_players, max_players,
             min_playing_time, max_playing_time
      FROM game
      WHERE min_playing_time <= :minutes AND max_playing_time >= :minutes
      """)
  List<Game> findGamesForPlayingTime(int minutes);

}

由于继承了 CrudRepositoryGameRepository 自带了一些通用方法,包括可用于统计数据库中游戏数量的 count()。此外,它还定义了两个自定义查询方法:

  • 按“玩家人数”检索可玩的游戏
  • 按“分钟数”检索在指定时间内可玩的游戏

两个方法通过 @Query 标注具体 SQL。

这两个方法的返回值类型是 List<Game>Game 是一个 Java record,承载从数据库取出的各字段:

package com.example.mcpserver;

import org.springframework.data.annotation.Id;

public record Game(
    @Id
    Long id,
    String title,
    String description,
    Integer minPlayers,
    Integer maxPlayers,
    Integer minPlayingTime,
    Integer maxPlayingTime) {}

这里唯一需要注意的是 id 属性使用了 @Id 注解,用于告知 Spring Data JDBC 哪个字段是对象的主键。

数据库底座准备好之后,下一步就是在 MCP Server 暴露的工具里使用 GameRepository

7.3.3 创建 MCP Server 的工具

就像上一章所学,给类中的方法添加 @Tool 注解,就能把该方法暴露为“工具”。清单 7.3 中的方法都加了 @Tool,并通过仓库查询游戏数据库。它们就是我们 MCP Server 要暴露的工具的底层实现。

清单 7.3 Tools 类:提供查询游戏数据的方法

package com.example.mcpserver;

import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class GameTools {

  private final GameRepository gameRepository;

  public GameTools(GameRepository gameRepository) {
    this.gameRepository = gameRepository;
  }

  @Tool(name = "gameCount", 
  description = "Returns the count of games in the repository.")
  public long gameCount() {
    return gameRepository.count();
  }

  @Tool(name = "findGamesForPlayerCount",
        description = 
        "Finds a games suitable for the specified number of players.")
  public List<Game> findGamesForPlayerCount(
      @ToolParam(description = 
      "The number of players to find games for.") int numPlayers) {
    return gameRepository.findGamesForPlayerCount(numPlayers);
  }

  @Tool(name = "findGamesForPlayingTime",
        description = "Finds games suitable for the specified playing time.")
  public List<Game> findGamesForPlayingTime(
      @ToolParam(description = "The time for playing the game.") int time) {
    return gameRepository.findGamesForPlayingTime(time);
  }

}

GameTools 的构造器接收 GameRepository,随后 gameCount()findGamesForPlayerCount()findGamesForPlayingTime() 会使用该仓库从数据库读取游戏数据。

到目前为止,@Tool 的用法与第 6 章并无太大不同。但若要在 MCP Server 中暴露这些工具,你需要在 Spring 应用上下文里配置一个 Bean,把这些方法“转换”为 MCP 工具。下一段清单展示了如何配置 MCP Server,将 GameTools 的方法作为工具对外提供。

清单 7.4 配置 MCP Server:把 GameTools 的方法暴露为工具

package com.example.mcpserver;

import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.tool.method.MethodToolCallbackProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class McpConfig {

  @Bean
  ToolCallbackProvider toolCallbackProvider(GameTools tools) {
    return MethodToolCallbackProvider.builder()
        .toolObjects(tools)
        .build();
  }

}

toolCallbackProvider() 方法创建了一个 ToolCallbackProvider Bean。之后由 Spring AI 的自动配置接管,创建让 MCP Server 正常工作的其他组件。至此,你的 MCP Server 基本就绪,但还有两点小细节需要处理。

禁用非 MCP 输出

当 MCP Server 通过 STDIO 传输与客户端通信时,它依赖标准输入/输出。任何写到 标准输出 的内容,都会被视为服务端发给客户端的 MCP 协议数据。若把日志或其它与 MCP 协议无关的输出写到标准输出,就会干扰客户端并导致通信失败。因此需要关闭写往标准输出的日志以及 Spring Boot 的 ASCII 启动横幅:

spring.main.banner-mode=off
logging.level.root=ERROR

以上配置相对“狠”,直接禁用了所有日志。你也可以选择把日志重定向到文件或其他日志汇聚系统。就本章演示而言,禁用全部日志已足够。


你的 MCP Server 现在可以构建了!在命令行用 Gradle 打包:

$ ./gradlew build

若一切顺利,build/libs 目录下会生成一个可执行 JAR(例如 mcp-server.jar)。不过你不需要自己运行这个 JAR,它会由 Spring AI 应用中的 MCP Client 来启动。我们会在 7.3.5 节开发那个客户端应用。在此之前,先用 MCP Inspector 来“验车”。

7.3.4 检查 MCP Server

MCP Inspectormng.bz/V9aN)是个无需搭完整 MCP Client 就能测试 MCP Server 的小工具。它能快速验证你的 Server 是否按预期暴露了工具。

机器上装有 npx 的话,直接运行:

$ npx @modelcontextprotocol/inspector

启动后,它应自动在默认浏览器打开一个 URL。如果没有,它会在控制台打印一个带 MCP_PROXY_AUTH_TOKEN 参数的 URL。用浏览器打开该地址(注意包含该参数),你会看到类似图 7.5 的界面。 image.png

图 7.5 启动 MCP Inspector

测试 MCP Server 时,在 Command 输入框里填入 java(因为要用 java 命令运行可执行 JAR)。在 Arguments 输入框里填入 -jar,后接一个空格与 JAR 的绝对路径。例如在 macOS、用户名为 habuma 的情况下,可能是:
/Users/habuma/mcp-server/build/libs/mcp-server-0.0.1-SNAPSHOT.jar(按你的路径实际调整)。

在点击 Connect 之前,还有一件事:由于 MCP Server 使用 Spring Boot 的 Docker Compose 支持,而 compose.yaml 在打包时不会进 JAR,你需要告诉 Spring 该文件在哪里。展开 Environment Variables,点击 Add Environment Variable,新增环境变量 SPRING_DOCKER_COMPOSE_FILE,其值为项目中 compose.yaml 的路径。比如项目根目录在 /Users/habuma/mcp-server,则设为:
/Users/habuma/mcp-server/compose.yaml

现在可以点击 Connect 连接 MCP Server。连接成功后,页面右侧会显示 Server 细节信息。由于本 Server 没有资源或模板,界面会落在 Tools 标签页。点击 List Tools 按钮,你应能看到类似图 7.6 的工具列表。

image.png

图 7.6 在 MCP Inspector 中查看 MCP Server 的工具

可以看到,MCP Server 暴露了三个工具,分别对应 GameTools 中的三个方法(可能需要滚动列表才能全部看到)。点进某个工具,在最右侧的表单里填入所需参数,然后点击 Run Tool,就能看到该工具的响应。

用 Inspector 戳一戳 MCP Server 能帮助确认它按预期对外暴露了工具。但 MCP Server 的真正用途,是在 LLM 请求 的上下文中被应用调用。接下来,我们将在一个 AI 应用中实际使用这个 MCP Server,看看它在真实场景中的工作方式。

7.3.5 在客户端应用中使用该服务端

本章稍早你已创建过一个 Spring AI 应用,并在其中配置了 MCP Client 来体验 Filesystem MCP Server。现在要对接 Board Game DB MCP Server,你可以新建一个类似的 Spring AI 应用;或者直接在原项目基础上做少量修改。

首先,更新 MCP Client 的配置。如果使用 Spring 的配置属性,你的 application.yaml 大致如下:

spring:
  ai:
    mcp:
      client:
        stdio:
          connections:
            boardgamedb:
              command: ${JAVA_HOME}/bin/java
              args:
              - '-jar'
              - '${MCP_SERVER_PATH}/build/libs/mcp-server-0.0.1-SNAPSHOT.jar'

注意:该片段假设你设置了 JAVA_HOMEMCP_SERVER_PATH 环境变量,分别指向你机器上的 Java 安装位置与 MCP Server 工程目录。否则需要填写完整的绝对路径。

可选地,如果你用 Claude Desktop 的配置方式来设置 MCP Client,可以使用如下 JSON:

{
  "mcpServers": {
    "filesystem": {
      "command": "/Users/habuma/.sdkman/candidates/java/current/bin/java",
      "args": [
        "-jar",
        "/Users/habuma/Projects/BookProjects/walls10/code/ch07/    \
         boardgamedb-mcp-stdio/mcp-server/build/libs/             \
         mcp-server-0.0.1-SNAPSHOT.jar"
      ]
    }
  }
}

在 Claude Desktop 的配置里,不能引用环境变量,必须写入完整路径。根据你的本机路径自行调整。

配置好服务端后,启动应用并尝试提问。例如,你想找一款适合 5 名玩家的游戏。通过 /ask 端点发起请求,可能会得到如下结果:

$ http -b :8080/ask \
  question="I'm having 4 friends over to play games. What should we play?"
{
  "answer": "You should consider playing 'Ticket to Ride', which is a
  railway-themed board game where players collect cards to claim railway
  routes; or 'Carcassonne', a tile-placement game where players build cities,
  roads, and fields; or '7 Wonders', a card drafting game where players
  develop civilizations through three ages; or 'Wingspan', a card-driven
  engine-building game where players attract birds to their wildlife
  preserves; or 'Flip 7', a fast-paced card game where players try to flip
  cards to get the highest score while avoiding flipping a duplicate card."
}

再比如,找一款不太费时间的游戏:

$ http -b :8080/ask \
  question="We only have about 30 minutes to play a game. What would \
  you suggest?"
{
    "answer": "I suggest playing 'Sagrada', which is a dice-drafting game
    where players create beautiful stained glass windows using colored dice,
    suitable for 1 to 4 players and has a playing time of around 30 to 45
    minutes."
}

很棒!自定义 MCP Server 正常工作,你的应用也能根据玩家数量和可用时长给出合适的游戏建议。

由于该 MCP Server 实现了 STDIO 传输,客户端需要负责运行它,并通过标准输入/输出通信(这和早期互联网的 CGI 模式有点类似)。像前文清单 7.2 里折腾的 Filesystem MCP Server,为了访问客户端机器的文件系统,确实需要“本地”运行在客户端旁边。但对于 Board Game DB MCP Server 来说没有这种限制,把它独立运行会更方便。下面我们看看如何在 MCP Server 中启用 HTTP+SSE 传输。

7.4 使用 HTTP+SSE 传输

STDIO 是集成 MCP Server 的常见方式。它相对简单,不需要单独部署服务端进程:客户端像“边车”一样拉起 MCP Server,并通过标准输入/输出通信,就好像客户端在“键入”请求给服务端。

不过,使用 STDIO 时,客户端与服务端在部署上是耦合的。尽管它们以不同进程并行运行,但服务端的启动由客户端负责。并且由于所有通信都占用了标准输出,服务端的日志输出就很受限;更糟糕的是,被客户端拉起的服务端调试起来也会非常困难。

与之相对,使用 HTTP+SSE 传输就是更常规的“客户端—服务端”模式:双方可以独立部署、独立运维、独立扩缩容。通信使用 HTTP,服务端可以跑在任何客户端可达的位置,甚至在另一台主机上。服务端也可以按需输出日志,而不必担心干扰 MCP 协议通信。

Streamable HTTP vs. HTTP+SSE

HTTP+SSE 传输出现在 2024 年 11 月版的 MCP 规范中。此后它被标记为弃用,并在后续版本中基本移除,取而代之的是新的 Streamable HTTP 传输。

在撰写本章节时,Spring AI 与 Java MCP SDK 的稳定版本尚未支持 Streamable HTTP。因此,出于当前版本 Spring AI 的限制,本节仅讲解 HTTP+SSE,不再讨论 Streamable HTTP。

Spring AI 1.1.0 的首个里程碑版已包含对 Streamable HTTP 的支持。看起来在 MCP Server 中启用 Streamable HTTP 只需设置一个配置属性;无论使用 Streamable HTTP 还是 HTTP+SSE,服务端代码实现本身是一致的

在 Spring AI 中使用 HTTP+SSE 与使用 STDIO 的方式非常相似。实际上,你几乎无需改动业务代码,只需调整少量配置并替换一个构建依赖即可。下面从在 MCP Server 侧启用 HTTP+SSE 开始讲起。

7.4.1 在 MCP Server 中配置 HTTP+SSE

选择用 HTTP+SSE 传输方式部署 MCP Server,其实只是换一个起步依赖而已。回顾一下:最初在 Initializr 勾选 Model Context Protocol Server 时,构建里加入的是:

implementation 'org.springframework.ai:spring-ai-autoconfigure-mcp-server'

若要改为通过 HTTP+SSE 通信,需要把该依赖替换为以下两种之一。若你偏好非响应式(或无所谓),可使用基于 Spring MVC 的依赖:

implementation 'org.springframework.ai:spring-ai-starter-mcp-server-webmvc'

该实现基于 Spring MVC,在内嵌 Tomcat 上提供服务。

也可以在 Spring Initializr 中选择 Model Context Protocol Server 依赖;是否加入 MVC 版或 WebFlux 版,取决于你是否同时勾选了 Spring WebSpring Reactive Web

如果你希望在响应式基础上提供 MCP Server 的工具能力,可选择 WebFlux 实现:

implementation 'org.springframework.ai:spring-ai-starter-mcp-server-webflux'

选择 WebFlux 依赖后,MCP Server 将在 Netty 上提供请求处理。其优势是以更少的请求处理线程实现更高效的吞吐,相比于从线程池拉取阻塞线程更节省资源。

引入新 starter 依赖后,底层自动配置会完成让 MCP Server 通过 HTTP+SSE 传输所需的一切,基本无需额外代码。

不过有个小点需要注意:之前使用 STDIO 传输时,你禁用了 Spring Boot 的 Banner 并关闭了日志;而现在换成 HTTP+SSE,应恢复这些输出——删除对应的配置即可。另一个可选项是设置端口为 3001:

server.port=3001

之所以选 3001,是因为 MCP Inspector 在调试 HTTP+SSE 服务器时默认就用这个端口。

接下来,启动 MCP Server,并用 MCP Inspector 试一试。

7.4.2 检查(Inspect)MCP Server

现在客户端应用不再负责帮你启动 MCP Server;你需要自己运行它。借助 Gradle 的 Spring Boot 插件:

$ ./gradle bootRun

稍等片刻,应用会启动并监听 3001 端口(若你已恢复日志输出,可以在日志里确认)。

此时,任何配置为 HTTP+SSE 客户端传输且指向该地址的客户端,都可以使用你的 MCP Server。我们先用 MCP Inspector 验证服务可用性。

假设 MCP Inspector 仍在运行,浏览器打开 http://localhost:5173。在左侧选择 SSE 作为传输方式。你会看到 URL 已预填为 http://localhost:3001/sse。如果上一节把端口设为 3001,这个地址正合适。点击 Connect 之前,对照图 7.7 检查你的选择无误。

image.png

连接成功后,右侧面板会激活。点击 List Tools 查看 MCP Server 暴露的全部工具,然后任选一个工具,填入所需参数,点击 Run Tool 调用它。图 7.8 展示了以玩家人数 5 调用 findGamesForPlayerCount 的示例。

image.png

一切顺利:Inspector 能连上 MCP Server,也能顺利调用工具。接下来,把它接入客户端应用。

7.4.3 将客户端配置为使用 HTTP+SSE Server

把 MCP Client 切到 HTTP+SSE 几乎和在 Server 侧从 STDIO 切到 HTTP+SSE 一样简单。事实上,如果你用的是标准客户端实现,不必更换 MCP Client 的 starter 依赖;若你想使用响应式客户端,可以可选替换为:

implementation 'org.springframework.ai:spring-ai-starter-mcp-server-webflux'

无论哪种,你都需要修改 MCP Client 的Spring 配置属性。与 STDIO 不同,HTTP+SSE 下不能使用 Claude Desktop 的 JSON 配置;必须用 Spring 配置。不过 HTTP+SSE 的配置更精简,只需要指明 MCP Server 的 URL。

例如,若 Server 跑在本机 3001 端口,application.yml 可这样写:

spring:
  ai:
    mcp:
      client:
        sse:
          connections:
            boardgamedb:
              url: http://localhost:3001

这里根配置为 spring.ai.mcp.client.sse.connectionsboardgamedb 是该连接的名字(可随意命名);url 指向 MCP Server 的地址。

现在就可以启动客户端应用并测试 HTTP+SSE 传输了。确保服务端已在运行,然后启动客户端。比如,询问一款适合 10 人的游戏:

$ http :8080/ask \
  question="What would be a good game for 10 players?" -b
{
  "answer": "A good game for 10 players is Flip 7. It is a fast-paced card
  game where players try to flip cards to get the highest score while avoiding
  flipping a duplicate card. The game accommodates between 3 to 13 players
  and has a playing time of 15 to 30 minutes."
}

很好!一切按预期工作——你不仅能按玩家人数提问,还能将 MCP Server 独立于客户端进行开发、演进、部署与运维。

虽然“工具(tools)”是 MCP 最常被提及的能力,但 MCP 不止于此。接下来看看如何通过 MCP Server 暴露 prompts 与 resources

7.5 暴露 Prompts 与 Resources

在 Board Game Buddy 的 API 中,用户可以用自由文本来填写大部分提示词(prompt)。能够随心所欲地输入、随心所欲地发问,是聊天体验的核心。但设想你要定义一个更传统的界面:列表、按钮、复选框等控件在台前,台后自动组装出要发给 LLM 处理的提示词。这样的 UI 能让用户以更确定的方式与应用交互,同时仍能驱动强大的生成式 AI 能力。

这正是 MCP Server 暴露 promptsresources 的用武之地。与其把 prompt 的构造逻辑硬编码在应用里,不如由 MCP Server 提供一组预定义的 prompts,应用在发送给 LLM 之前,用 UI 中的填写项去补齐这些 prompts 的细节。而 resources 则可以向应用提供与该 MCP Server 领域相关的文本或二进制数据。

Spring AI 的 MCP 模块允许你在带有 @Bean 的 Spring 配置方法中声明 prompts 与 resources。下面看看如何通过这些 bean 为 MCP Server 增加 prompts 与 resources。

7.5.1 声明用于暴露 prompts 与 resources 的 Bean

在基于 Spring AI 的 MCP Server 中暴露 prompts 的关键,是定义一个提示规格列表(prompt specifications)的 bean。更具体地说,这个 bean 的类型可以是两者之一:

  • List<McpServerFeatures.SyncPromptSpecification>
  • List<McpServerFeatures.AsyncPromptSpecification>

如何选择取决于你是否使用 Project Reactor 的响应式类型;若使用,则选后者,否则前者同样可用。下面的清单展示了如何向 MCP Server 增加两个 prompt。

清单 7.5 使用 Spring 配置暴露 prompts

@Bean
public List<McpServerFeatures.SyncPromptSpecification> gamePrompts() {
  var playerCountPrompt = new McpSchema.Prompt(
      "gamesForPlayerCount",
      "A prompt to find games for a specific number of players",
      List.of(new McpSchema.PromptArgument(
          "playerCount", "The number of players", true)));

  var playerCountPromptSpec = new McpServerFeatures.SyncPromptSpecification(
      playerCountPrompt, (exchange, getPromptRequest) -> {
    String playerCount =
        (String) getPromptRequest.arguments().get("playerCount");
    var userMessage = new McpSchema.PromptMessage(
        McpSchema.Role.USER,
        new McpSchema.TextContent(
            String.format("Find games for %s players", playerCount)
        ));
    return new McpSchema.GetPromptResult(
        String.format("A prompt to find games for %s players", playerCount),
        List.of(userMessage));
  });

  var playingTimePrompt = new McpSchema.Prompt(
      "gamesForPlayingTime",
      "A prompt to find games for given amount of time",
      List.of(new McpSchema.PromptArgument(
          "timeInMinutes", "The time in minutes", true)));

  var playingTimePromptSpec = new McpServerFeatures.SyncPromptSpecification(
      playingTimePrompt, (exchange, getPromptRequest) -> {
    String timeInMinutes =
        (String) getPromptRequest.arguments().get("timeInMinutes");
    var userMessage = new McpSchema.PromptMessage(
        McpSchema.Role.USER,
        new McpSchema.TextContent(
            String.format("Find games to play in %s minutes", timeInMinutes)
        ));
    return new McpSchema.GetPromptResult(
        String.format("A prompt to find games to play in %s minutes",
            timeInMinutes),
        List.of(userMessage));
  });

  return List.of(playerCountPromptSpec, playingTimePromptSpec);
}

这段代码看起来内容不少,但理解起来可以分层看:定义第一个 prompt 规格的代码块,几乎被重复了一次来定义第二个 prompt 规格。每个代码块又可分为两步:

  1. 定义一个 prompt(元信息)
  2. 定义一个 prompt 规格(如何用入参生成实际提示内容)

方法最后返回这两个 prompt 规格组成的列表。

前几行先定义了一个“按玩家人数找游戏”的 prompt。prompt 定义主要是元信息:名称、描述,以及一组可用于填充 prompt 占位符的参数。

随后构造 prompt 规格:以刚才定义的 prompt 为基础,再给出一个用于拼装提示内容的 lambda。它先从请求参数中取出玩家人数,生成“为 N 名玩家寻找游戏”的用户消息(user message),最后返回一个 GetPromptResult,其中包含生成后的 prompt 描述与这条用户消息。

接着重复同样的过程,定义一个“按可用时长找游戏”的 prompt 与规格。除了具体文本、描述、参数名称不同,其余相同。方法返回这两个 prompt 规格。

要查看运行效果,打开 MCP Inspector,连接 MCP Server,切到 Prompts 页签,点击 List Prompts 就能看到你定义的两条 prompt。点开任意一个,填好参数,在 Get Prompt 下方能看到生成出的提示文本。图 7.9 展示了可能的样子。

image.png

现在,看看如何给 MCP Server 增加一个 resource。举个例子:向客户端返回仓库中所有游戏标题的资源。你可以在先前定义的 GameRepository 上新增一个方法:

@Query("SELECT title FROM game ORDER BY title")
List<String> findAllTitles();

有了它,就可以定义 resource 了。声明 resource 与声明 prompt 类似,但 bean 方法需要返回资源规格列表。如下所示:

清单 7.6 使用 Spring 配置暴露 resource

@Bean
public List<McpServerFeatures.SyncResourceSpecification>
    gameResources(GameRepository gameRepository) {
  List<McpSchema.Role> audience = List.of(McpSchema.Role.USER);
  McpSchema.Annotations annotations =
      new McpSchema.Annotations(audience, 1.0);

  var gameListResource = new McpSchema.Resource(    // #1
      "games://game-list",
      "Game List",
      "A list of games available in the repository",
      "text/plain",
      annotations
  );

  var gameTitles = gameRepository.findAllTitles();  // #2
  var gameListText = new StringBuilder();
  for (String title : gameTitles) {
    gameListText.append("- ").append(title).append("\n");
  }

  var gameListResourceSpec =
    new McpServerFeatures.SyncResourceSpecification( // #3
      gameListResource, (exchange, request) -> {
    return new McpSchema.ReadResourceResult(
        List.of(new McpSchema.TextResourceContents(
            request.uri(),
            "text/plain",
            gameListText.toString())));
  });

  return List.of(gameListResourceSpec);             // #4
}

这里不是定义 prompt/规格,而是定义 resource/资源规格。与 prompt 不同的是,在创建规格之前,先从仓库拉取游戏标题,并拼成一个文本字符串,作为资源的内容。

查看效果:打开 MCP Inspector,连接 MCP Server,切到 Resources 页签,点 List Resources,即可看到 “Game List” 资源。点开后,在最右侧文本框能看到拼接出的标题清单。图 7.10 展示了可能的界面。

image.png

至此,你已经在 MCP Server 中暴露了两条 prompts 和一个 resource。不可否认,这套 API 的样板代码略多。好消息是,社区已有项目支持基于注解来声明 prompts 与 resources,能显著简化样板化工作。接下来就来看看它。

7.5.2 使用注解式 Prompts 与 Resources

鉴于 Spring AI 本身未必能长期维护社区所需的全部功能,Spring AI 团队专门创建了一个收纳与 Spring AI 相关社区项目的仓库(github.com/spring-ai-c…)。这些社区项目在不断演进、成熟后,未来可能会“毕业”并入主项目。但在此之前,你已经可以在自己的 Spring AI 项目中直接使用它们——只是不属于 Spring AI 的官方支持范围。

其中一个项目是 mcp-annotations。它用注解的方式简化了 prompts 与 resources 的定义。该项目目前被标注为“孵化中”(incubating),这意味着将来某个时间(甚至在你读到本书时)它可能会成为 Spring AI 的一部分。在此之前,要使用它,需要在 MCP Server 的构建中加入下面的依赖:

implementation 'com.logaritex.mcp:spring-ai-mcp-annotations:0.1.0'

有了它,你无需再在 @Bean 方法里声明 prompts 和 resources,而是把它们写成服务类中的方法。例如,清单 7.7 中的 PromptProvider 展示了如何用注解定义与前文相同的两个 prompts。

清单 7.7 用注解声明 prompts

package com.example.mcpserver;

import com.logaritex.mcp.annotation.McpArg;
import com.logaritex.mcp.annotation.McpPrompt;
import io.modelcontextprotocol.spec.McpSchema;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class PromptProvider {

  @McpPrompt(
      name = "gamesForPlayerCount",
      description =
      "A prompt to find games for a specific number of players")  // #1
  public McpSchema.GetPromptResult gamesForPlayerCount(
      @McpArg(name = "playerCount",
              description = "The number of players",
              required = true) Integer playerCount) {  // #2

    var userMessage = new McpSchema.PromptMessage(   // #3
        McpSchema.Role.USER,
        new McpSchema.TextContent(
            String.format("Find games for %s players", playerCount)
        ));

    return new McpSchema.GetPromptResult(
        String.format("A prompt to find games for %s players", playerCount),
        List.of(userMessage));   // #4
  }

  @McpPrompt(
      name = "gamesForPlayingTime",
      description =
      "A prompt to find games for a specific number of players")   // #1
  public McpSchema.GetPromptResult gamesForPlayingTime(
      @McpArg(name = "timeInMinutes",
          description = "The time in minutes",
          required = true) Integer timeInMinutes) {   // #5

    var userMessage = new McpSchema.PromptMessage(  // #6
        McpSchema.Role.USER,
        new McpSchema.TextContent(
            String.format("Find games to play in %s minutes", timeInMinutes)
        ));

    return new McpSchema.GetPromptResult(
        String.format("A prompt to find games to play in %s minutes",
                      timeInMinutes),
        List.of(userMessage));   // #7
  }
}

这段代码未必比清单 7.5 更短,但更直观、更好读。它的风格也很像 Spring MVC 用注解定义控制器的方式。

  • 每个方法上的 @McpPrompt 类似于 Spring MVC 的 @RequestMapping(以及各 HTTP 方法注解),用于定义 prompt 的元信息(名称、描述),并告诉 Spring 当客户端请求该 prompt 时要调用此方法。
  • 方法参数上的 @McpArg 类似于 @RequestParam@PathVariable,用于声明 prompt 的入参
  • 方法体中创建 PromptMessage 承载完整的提示文本,最后返回包含描述消息列表(这里是单条 user 消息)的 GetPromptResult

定义完这些带注解的方法后,还需要有一个 bean 将它们暴露为 MCP 接口中的 prompts:

@Bean
List<McpServerFeatures.SyncPromptSpecification> myPrompts(
        PromptProvider promptProvider) {
  return SpringAiMcpAnnotationProvider
      .createSyncPromptSpecifications(List.of(promptProvider));
}

这个方法返回 SyncPromptSpecificationAsyncPromptSpecification 列表(与清单 7.5 相同)。不同的是,这里无需写一堆模板化代码,只要调用 SpringAiMcpAnnotationProvider 的工具方法,把含有 @McpPrompt 方法的服务 bean 列出来即可。

Resources 也能用类似的注解方式定义。先写一个服务类,其中的方法用 @McpResource 注解。清单 7.8 的 ResourceProvider 定义了与前文清单 7.6 同样的资源:

清单 7.8 用注解声明 resources

package com.example.mcpserver;

import com.logaritex.mcp.annotation.McpResource;
import io.modelcontextprotocol.spec.McpSchema;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class ResourceProvider {

  private final GameRepository gameRepository;

  public ResourceProvider(GameRepository gameRepository) {   // #1
    this.gameRepository = gameRepository;
  }

  @McpResource(uri = "games://game-list",
               name = "Game List",
               description = "A list of games available in the repository")
  public McpSchema.ReadResourceResult gameListResource
      (McpSchema.ReadResourceRequest request) {
    var gameTitles = gameRepository.findAllTitles();     // #2
    var gameListText = new StringBuilder();
    for (String title : gameTitles) {
      gameListText.append("- ").append(title).append("\n");
    }

    return new McpSchema.ReadResourceResult(     // #3
        List.of(new McpSchema.TextResourceContents(
            request.uri(),
            "text/plain",
            gameListText.toString())));
  }

}
  • @McpResource 注解为 gameListResource() 指定资源元信息:名称、描述、URI;也表明当客户端请求该资源时应调用此方法。
  • 方法首先使用注入的 GameRepository 读取所有游戏标题,拼接为文本。
  • 最后返回资源内容列表。本例只有一项,为 TextResourceContents,其中包含资源 URI、MIME 类型与文本内容。

若要返回二进制资源(如图片、音频),可使用 BlobResourceContents,其参数与 TextResourceContents 相同,但第三个参数改为Base64 编码的二进制字符串。

同 prompts 一样,还需一个 @Bean 将带 @McpResource 的方法暴露为资源:

@Bean
public List<McpServerFeatures.SyncResourceSpecification> myResources(
        ResourceProvider resourceProvider) {
  return SpringAiMcpAnnotationProvider
      .createSyncResourceSpecifications(List.of(resourceProvider));
}

这比清单 7.6 简洁许多:直接调用工具方法把 ResourceProvider 中的 @McpResource 方法转换为资源规格列表即可。

启动 MCP Server,并用 MCP Inspector 连接后,你会看到与之前相同的 prompts 与 resources。二者在行为上完全一致,只不过这一次是通过注解来定义的

小结

  • MCP(Model Context Protocol) 是由 Anthropic 提出的规范,用于标准化可复用的工具(tools)、prompts 与 resources 的创建方式。
  • Spring AI 同时提供 MCP 的客户端与服务端支持。
  • 目前已有 1000+ 公共 MCP Servers 可用于 Spring AI 应用,以扩展超出 LLM 训练知识之外的能力。
  • Spring AI 的 MCP 支持 STDIOHTTP+SSE 两种传输方式,用于客户端与服务端之间的通信。