跟著這篇走完,你會得到一個能跑的最小工程:模型先調用你自己寫的 @Tool 方法,再調用一個跑在本地進程裡的 MCP Server 提供的工具。全程 6 步,每步末尾都有一條命令告訴你這一步成了沒有。
文中的 gpt-4o-mini、OPENAI_API_KEY、8080 都是示例值,照抄之前先換成你自己的。

前置條件
| 項 | 要求 | 說明 |
|---|---|---|
| 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 都會讓客戶端解析失敗。
failed to start command: exec: "npx": executable file not found in $PATH
現象:終端裡手敲 npx 能跑,Spring Boot 一啟動就說找不到它。
原因:JVM 子進程繼承的 PATH 不等於你交互式 shell 的 PATH,IDE 和系統服務拿到的更精簡。
404 Not Found on GET request for "http://localhost:8080/sse"
現象:服務端明明活著,客戶端連過去直接 404。
原因:客戶端還按舊的 SSE 路徑連,服務端已經切到 Streamable HTTP 的 /mcp,協議和端點都沒對上。
工具註冊了,模型就是不調用
現象:編譯啟動都沒問題,模型完全無視你寫的 @Tool;偶爾會拋 No ToolCallback found for tool name: xxx。
原因:defaultOptions 用了不支持工具調用的 DefaultChatOptions;或者 .tools(...) 匹配到了會吞掉回調的重載;或者工具執行階段又新建了一次 options。
No qualifying bean of type 'ChatModel' available
現象:啟動直接失敗,說容器裡找不到 ChatModel。
原因:模型 starter 沒引,或者引了但自動設定被關掉,Spring 也就沒東西可以注入。
