StackTraces
Spring AI从零跑通

从零跑通 Spring AI 工具调用与 MCP:6 步,外加 5 个容易卡住的地方

@debug_master2026-09-29

跟着这篇走完,你会得到一个能跑的最小工程:模型先调用你自己写的 @Tool 方法,再调用一个跑在本地进程里的 MCP Server 提供的工具。全程 6 步,每步末尾都有一条命令告诉你这一步成了没有。

文中的 gpt-4o-mini、OPENAI_API_KEY、8080 都是示例值,照抄之前先换成你自己的。

Spring AI 工具调用与 MCP:从零跑通的六个步骤

前置条件

项 要求 说明
JDK 17 或更高 Spring AI 1.1.x 的编译基线就是 17,低于它连依赖都解析不了
Spring Boot 3.5.x 本文按 3.5.15 写。Spring AI 1.1.x 对应的就是这一档,配到 3.4 或 4.0 都会在自动配置阶段出问题
Spring AI 1.1.x 本文按 1.1.8 写。版本统一交给 BOM 管,别给单个依赖写版本号
Node.js 18 或更高 只在第三步启动 stdio 的 MCP Server 时用到,自带 npm 8+
API Key 任意 OpenAI 兼容服务的 Key 换成别的兼容 OpenAI 协议的服务也行,改 base-url 就够
网络 能访问模型服务与 npm 源 首次要拉 Maven 依赖和 npm 包;跑通之后可以换成 Ollama 完全离线

先确认 Java 和 Maven 的版本。

java -version
mvn -version

两条命令都要正常输出,而且 mvn -version 里显示的 JDK 要和上一条一致。Maven 用的是另一个 JDK(比如 JAVA_HOME 指向 8)时,编译期报的是类版本不支持,这事和 Spring AI 没关系,但很容易被当成依赖冲突去查。

第三步之前再确认一次 Node 工具链。

node -v
npx -v

有个版本差异要先说清楚:Spring AI 1.0.x 的 MCP 客户端只有 stdio 和 SSE 两种传输,Streamable HTTP 客户端是 1.1.x 才加的。停在 1.0.x 上,那组配置写了不会报错,但会被静默忽略。没有对应的自动配置去读它。

第一步:建工程,引入 Spring AI 依赖

用 start.spring.io 生成一个 Spring Boot 3.5.15 工程,或者在已有工程上改。核心是三个依赖:Web(后面要用 Controller 验证)、一个模型 starter、MCP 客户端 starter。

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

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-openai</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-mcp-client</artifactId>
  </dependency>
</dependencies>

版本交给 BOM 管,三个依赖都不写 <version>。逐个指定版本号是后面内部模块版本对不上、报 ClassNotFoundException 的常见起点。

模型 Key 写进配置,别硬编码到代码里。

spring:
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}
      chat:
        options:
          model: gpt-4o-mini

验证。

mvn -q dependency:tree -Dincludes=org.springframework.ai

输出里同时出现 spring-ai-starter-model-openai 和 spring-ai-starter-mcp-client 两行就是对的。少了前者,第二步注入 ChatModel 会失败;少了后者,第三步开始的 MCP 配置项会全部被忽略。配置写了不报错,但没有自动配置去读它。

第二步:写一个 @Tool 方法,先用 ChatClient 调通

先把工具调用这条链路单独跑通,别一上来就接 MCP。两个概念要先分清:@Tool 注解的方法跑在你的 JVM 里,MCP 工具跑在另一个进程里,但注册进 ChatClient 之后,对模型来说没差别。

工具类:

package com.example.demo.tools;

import org.springframework.ai.tool.annotation.Tool;

public class WeatherTools {

    @Tool(description = "查询指定城市的实时天气,返回天气状况和温度")
    public String getWeather(String city) {
        // 真实项目里换成 HTTP 调用,这里返回固定值便于验证
        return city + ":晴,26℃";
    }
}

挂进 ChatClient,再暴露一个接口用于验证:

package com.example.demo;

import com.example.demo.tools.WeatherTools;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class ChatController {

    private final ChatClient chatClient;

    public ChatController(ChatClient.Builder builder) {
        this.chatClient = builder
                .defaultTools(new WeatherTools())
                .build();
    }

    @GetMapping("/chat/tool")
    public String chat(@RequestParam String msg) {
        return chatClient.prompt(msg).call().content();
    }
}

defaultTools(...) 收的是方法级工具对象,框架会扫描里面的 @Tool 方法、生成工具描述。方法注释里的 description 不是装饰,模型就是靠它判断什么时候该调这个工具。

验证。

curl -s "http://localhost:8080/chat/tool?msg=调用 getWeather 工具查一下北京的天气"

返回内容里出现 26℃ 就说明工具真的被执行了。这个字符串只存在于工具方法里,模型编不出来。如果只看到模型在客套,先回去改 description,再换一个支持工具调用的模型试。

第三步:接一个 stdio 的 MCP Server

MCP Server 独立运行,客户端通过 stdio 或 HTTP 连它。先用 stdio 接官方的 filesystem server,它把本地目录暴露成一组可读写的工具。

先确认这个 server 自己能跑起来。

npx -y @modelcontextprotocol/server-filesystem /tmp/mcp-demo

进程起来后不输出内容、停在等待状态就是对的(stdout 是协议通道,正常运行时本来就不该有东西打出来)。Ctrl+C 退出,然后写配置。

spring:
  ai:
    mcp:
      client:
        stdio:
          connections:
            filesystem:
              command: npx
              args:
                - -y
                - "@modelcontextprotocol/server-filesystem"
                - /tmp/mcp-demo

/tmp/mcp-demo 是这个 server 的目录边界,先手工建出来,别直接授权整个家目录。

加一个只用于自检的接口,把当前注册进来的工具名吐出来:

package com.example.demo;

import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Arrays;
import java.util.List;

@RestController
public class ToolDebugController {

    private final ToolCallbackProvider toolCallbackProvider;

    public ToolDebugController(ToolCallbackProvider toolCallbackProvider) {
        this.toolCallbackProvider = toolCallbackProvider;
    }

    @GetMapping("/debug/tools")
    public List<String> tools() {
        return Arrays.stream(toolCallbackProvider.getToolCallbacks())
                .map(cb -> cb.getToolDefinition().name())
                .toList();
    }
}

验证。

curl -s http://localhost:8080/debug/tools

返回的数组不为空,说明 MCP 客户端的自动配置生效、连接建立成功、远端工具已经被发现,里面能看到 read_file、list_directory 这类名字。数组是空的但应用正常启动,通常就两个方向:连接根本没建起来(服务端进程启动失败),或者 spring.ai.mcp.client.toolcallback.enabled 被关掉了(默认是 true)。

第四步:把 MCP 工具注册进 ChatClient

到这一步,MCP 工具已经进了 Spring 容器,但还没进 ChatClient。这两件事是分开的,最常见的错误就是配好了 MCP 却漏了这一步,然后看着模型完全不理远端工具。

改一下构造方法,把 ToolCallbackProvider 里的工具一起挂上去。本地 @Tool 和 MCP 工具可以共存:

public ChatController(ChatClient.Builder builder, ToolCallbackProvider toolCallbackProvider) {
    this.chatClient = builder
            .defaultTools(new WeatherTools())
            .defaultToolCallbacks(toolCallbackProvider.getToolCallbacks())
            .build();
}

验证。先在授权目录里放一个带标记的文件:

mkdir -p /tmp/mcp-demo
echo "云栈科技技术验证标记" > /tmp/mcp-demo/note.txt
curl -s "http://localhost:8080/chat/tool?msg=用 read_file 工具读取 /tmp/mcp-demo/note.txt,把内容原样告诉我"

回答里出现 云栈科技,说明模型调用的是 MCP Server 那个进程里的工具,不是 JVM 里的方法。如果它回你「我没有文件读取权限」之类的话,就是工具没注册进 ChatClient,回到这一步检查代码。

第五步:让模型在一次对话里自己选工具

前两步都是你点名让模型用某个工具。真实场景里,该由模型自己判断用哪个。这一步不加新代码,只把本地工具和远端工具放进同一个问题,看它会不会分派。

两个工具的返回值特意做得不一样:getWeather 返回 26℃,MCP 的 read_file 返回 云栈科技技术验证标记。一次问它两件事:

curl -s "http://localhost:8080/chat/tool?msg=北京今天天气怎么样?另外读一下 /tmp/mcp-demo/note.txt 告诉我里面写了什么"

验证。

回答里同时出现 26℃ 和 云栈科技,就说明模型在一轮里调用了两个工具,一个来自 JVM、一个来自 MCP 进程,而且把两边的结果都拼进了回答。只出现其中一个,通常不是「不支持多工具」,而是某一边的 description 太模糊,模型没意识到那个工具和问题相关。

第六步:换成 Streamable HTTP 接远程 Server(可选)

stdio 适合本地工具,部署到服务器上更常见的是 HTTP 形态。MCP 的 HTTP 传输已经从 HTTP+SSE 迁到 Streamable HTTP,端点也从 /sse 变成了 /mcp。只有当你连的服务端是 HTTP 形态时才需要这一步,纯本地工具可以跳过。

spring:
  ai:
    mcp:
      client:
        streamable-http:
          connections:
            remote:
              url: http://localhost:8081
              endpoint: /mcp

url 只写协议、主机和端口,路径交给 endpoint。endpoint 的默认值就是 /mcp,服务端不是这个路径时才需要显式写。

先别急着启动应用,直接探一下服务端这个端点活没活。

curl -i -X POST http://localhost:8081/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

返回 200 且响应体里带 result,说明端点可用,可以回来把配置补上。返回 404 说明路径不对,先确认服务端开在 /mcp 还是旧的 /sse,再回来改 endpoint。

最后再提醒一次:这一节在 Spring AI 1.0.x 上不成立。那个版本的 MCP 客户端只支持 stdio 和 SSE,streamable-http 这组配置项没有对应的自动配置,写了会被静默忽略。

核心概念

概念 是什么 类比
ChatModel 某一家模型服务的客户端,负责发请求收响应 像 JDBC 驱动,换厂商就换一个实现
ChatClient 在 ChatModel 上包一层,管提示词、工具、会话这些横切逻辑 像 JdbcTemplate,用起来比裸驱动顺手
@Tool 标在方法上,框架据此生成工具描述发给模型 像给方法挂了一份接口文档,模型照着决定调不调
MCP 把外部工具和资源用统一协议暴露给 AI 应用的标准 像 USB-C,谁实现这个口都能插
Transport MCP 消息的传输方式,stdio 走子进程管道,Streamable HTTP 走 HTTP 像同一个协议跑在本地 socket 还是网络上
ToolCallbackProvider MCP 客户端把发现的远端工具统一交给 Spring 的出口 像适配器,一端是 MCP,一端是 Spring AI 的工具框架

常见问题

Unexpected token 'S', "Starting server..." is not valid JSON

现象:MCP Client 一连就报 JSON 解析失败,报错里还夹着服务端打印的那句日志。

原因:stdio 模式下 stdout 是 JSON-RPC 专用通道,服务端任何一句日志写进 stdout 都会让客户端解析失败。

相关文章:Spring AI MCP stdio:日志污染 stdout 导致解析失败

failed to start command: exec: "npx": executable file not found in $PATH

现象:终端里手敲 npx 能跑,Spring Boot 一启动就说找不到它。

原因:JVM 子进程继承的 PATH 不等于你交互式 shell 的 PATH,IDE 和系统服务拿到的更精简。

相关文章:Spring AI MCP stdio npx not found

404 Not Found on GET request for "http://localhost:8080/sse"

现象:服务端明明活着,客户端连过去直接 404。

原因:客户端还按旧的 SSE 路径连,服务端已经切到 Streamable HTTP 的 /mcp,协议和端点都没对上。

相关文章:Spring AI MCP 客户端 404:改用 Streamable HTTP

工具注册了,模型就是不调用

现象:编译启动都没问题,模型完全无视你写的 @Tool;偶尔会抛 No ToolCallback found for tool name: xxx。

原因:defaultOptions 用了不支持工具调用的 DefaultChatOptions;或者 .tools(...) 匹配到了会吞掉回调的重载;或者工具执行阶段又新建了一次 options。

相关文章:Spring AI Tool Calling 不生效的三种静默失效

No qualifying bean of type 'ChatModel' available

现象:启动直接失败,说容器里找不到 ChatModel。

原因:模型 starter 没引,或者引了但自动配置被关掉,Spring 也就没东西可以注入。

相关文章:Spring AI 报 No qualifying bean of type ChatModel

下一步

换成本地模型跑

把模型服务换成本机的 Ollama,就不用一直消耗 API 额度了;连不上通常只是服务没起或地址不对。

Spring AI 接 Ollama 报 Connection refused

改成流式输出

把回答一个字一个字推给前端,接进 Web 层之后还要处理 SSE 的连接生命周期和超时。

Spring AI 流式输出:stream 与 SseEmitter

管好长会话的上下文

多轮对话接上工具之后历史涨得很快,主动截断或摘要,别等报 400 才回头加。

Spring AI 报 400 context_length_exceeded

卡在哪一步了?

从零跑通的过程中撞到报错?把命令和输出记下来,帮助下一个刚起步的人。

贡献一条解法