1. 为什么 Java 后端第一次接 MCP 容易卡住MCP 全称 Model Context Protocol你可以把它理解成 AI 客户端和外部工具之间的一份“接口约定”。Codex 这类客户端负责理解你的自然语言真正去读文件、查数据库、调接口的动作则交给一个个 MCP Server 来完成。对 Java 后端来说这件事的吸引力在于你不需要重写业务只要把已有的 SpringBoot 能力包装成 MCP 工具就能让 Codex 直接调用。但第一次落地时卡点往往不在协议本身而在工程细节。比如 SpringBoot 项目引入哪个 starter、STDIO 模式下日志为什么不能打到控制台、工具方法怎么注册、Codex 里 command 和 args 到底填什么、远程模式又该怎么暴露。这些问题搜出来的答案经常是 Node 或 Python 版本Java 侧能直接抄的配置不多。这篇就按 Java 后端开发者的视角从零搭一个可被 Codex 调用的 MCP Server。我会先给一份能跑通的 STDIO 本地服务再补一个 Streamable HTTP 远程服务最后用 curl 和 Codex 客户端各验证一次工具调用。过程中涉及统一 Key/API 通道的地方用 TaoToken 来承接避免在多个客户端里反复配置密钥。2. 前置准备TaoToken 统一 Key 与工程骨架在写代码之前先把两件事准备好一个是模型调用的统一入口一个是 SpringBoot 工程骨架。TaoToken 在这里的角色是统一 Key/API 通道。你可以在官网注册后拿到 API Key后续 Codex 或其他客户端需要调用模型时都走同一个通道不用每个工具单独配一套凭证。对 MCP 联调来说这能减少“工具能跑但模型调不通”的干扰。官网入口https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentAPI 地址https://taotoken.net/api获取 Keyhttps://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite接入文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite工程骨架用 Spring Initializr 生成即可Java 17、Maven 构建artifactId 叫 McpDemo。下面这份 pom 是本地 STDIO 版本的核心依赖关键点是spring-ai-starter-mcp-server和spring-ai-bom的版本管理。?xml version1.0 encodingUTF-8? project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version4.0.4/version relativePath/ /parent groupIdcom.example/groupId artifactIdMcpDemo/artifactId version0.0.1-SNAPSHOT/version properties java.version17/java.version spring-ai.version2.0.0-M3/spring-ai.version /properties dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-mcp-server/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency /dependencies dependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version${spring-ai.version}/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement build plugins plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId /plugin /plugins /build /project注意spring-ai-starter-mcp-server是 STDIO 本地服务用的如果你要做远程 HTTP 服务要换成spring-ai-starter-mcp-server-webmvc后面第 4 节会单独给。3. 可复制配置STDIO 本地 MCP Server3.1 application.yml 的关键三行STDIO 模式下MCP 服务是通过标准输入输出和客户端通信的。这意味着控制台不能随便打日志否则会污染协议数据。所以配置里最容易被忽略、也最容易导致“服务启动但 Codex 连不上”的就是日志重定向。spring: application: name: McpDemo main: banner-mode: off web-application-type: none ai: mcp: server: name: local-file-server version: 1.0.0 stdio: true type: SYNC instructions: | This server can list files, read text files, and search text in files under the configured base directory only. annotation-scanner: enabled: true logging: pattern: console: file: name: ./log/application.logbanner-mode: off关掉启动 bannerweb-application-type: none不启 Web 容器logging.pattern.console留空把控制台日志压掉日志统一写到./log/application.log。这三处配合STDIO 通道才干净。3.2 工具注册用注解暴露本地文件能力Spring AI 的 MCP 注解扫描开启后只要在 Bean 方法上标McpTool启动时就会自动注册成 MCP 工具。下面这个LocalFileTools提供四个工具查根目录、列文件、读文件、搜关键词。所有路径都经过safeResolve校验防止越出根目录。package com.example; import org.springframework.ai.mcp.annotation.McpTool; import org.springframework.ai.mcp.annotation.McpToolParam; import org.springframework.stereotype.Component; import java.io.IOException; import java.nio.charset.MalformedInputException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.*; import java.util.stream.Stream; Component public class LocalFileTools { private static final long MAX_FILE_SIZE 1024 * 1024L; private static final SetString ALLOWED_EXTENSIONS Set.of( .txt, .md, .java, .xml, .yml, .yaml, .json, .properties, .js, .ts, .html, .css, .sql, .sh, .bat, .ps1, .log, .csv ); McpTool(name get_base_dir, description 返回当前 MCP 服务允许访问的根目录) public String getBaseDir() { return getBaseDirPath().toString(); } McpTool(name list_files, description 列出指定目录下的文件) public String listFiles( McpToolParam(description 相对根目录的子目录, required false) String relativeDir, McpToolParam(description 是否递归列出, required false) Boolean recursive, McpToolParam(description 最多返回多少条, required false) Integer maxResults ) throws IOException { String dirArg isBlank(relativeDir) ? . : relativeDir; boolean recursiveFlag recursive null || recursive; int limit maxResults null ? 200 : Math.max(1, maxResults); Path dir safeResolve(dirArg); if (!Files.exists(dir)) return 目录不存在: dirArg; if (!Files.isDirectory(dir)) return 不是目录: dirArg; ListString files; try (StreamPath stream recursiveFlag ? Files.walk(dir) : Files.list(dir)) { files stream .filter(Files::isRegularFile) .map(this::toRelativePath) .sorted() .limit(limit) .toList(); } return files.isEmpty() ? 未找到文件 : String.join(\n, files); } McpTool(name read_file, description 读取单个文本文件的指定行范围) public String readFile( McpToolParam(description 相对根目录的文件路径) String relativePath, McpToolParam(description 起始行号从1开始, required false) Integer startLine, McpToolParam(description 结束行号, required false) Integer endLine ) throws IOException { if (isBlank(relativePath)) return relativePath 不能为空; int start startLine null ? 1 : Math.max(1, startLine); int end endLine null ? 200 : Math.max(start, endLine); Path file safeResolve(relativePath); if (!Files.exists(file)) return 文件不存在: relativePath; if (!Files.isRegularFile(file)) return 不是文件: relativePath; if (!isAllowedTextFile(file)) return 不允许读取该类型文件: file.getFileName(); if (Files.size(file) MAX_FILE_SIZE) return 文件过大拒绝读取; ListString lines; try { lines Files.readAllLines(file, StandardCharsets.UTF_8); } catch (MalformedInputException e) { return 文件不是 UTF-8 文本暂不支持读取; } int actualEnd Math.min(end, lines.size()); StringBuilder sb new StringBuilder(); sb.append(# File: ).append(relativePath).append(\n); sb.append(# Lines: ).append(start).append(-).append(actualEnd).append(\n\n); for (int i start; i actualEnd; i) { sb.append(i).append(: ).append(lines.get(i - 1)).append(\n); } return sb.toString(); } McpTool(name search_in_files, description 在目录内搜索关键词返回 文件路径:行号:内容) public String searchInFiles( McpToolParam(description 要搜索的关键词) String keyword, McpToolParam(description 相对根目录的子目录, required false) String relativeDir, McpToolParam(description 是否区分大小写, required false) Boolean caseSensitive, McpToolParam(description 最多返回多少条命中, required false) Integer maxHits ) throws IOException { if (isBlank(keyword)) return keyword 不能为空; String dirArg isBlank(relativeDir) ? . : relativeDir; boolean caseFlag caseSensitive ! null caseSensitive; int limit maxHits null ? 100 : Math.max(1, maxHits); Path dir safeResolve(dirArg); if (!Files.exists(dir) || !Files.isDirectory(dir)) return 目录不存在或非法: dirArg; String needle caseFlag ? keyword : keyword.toLowerCase(Locale.ROOT); ListString hits new ArrayList(); try (StreamPath stream Files.walk(dir)) { IteratorPath iterator stream .filter(Files::isRegularFile) .filter(this::isAllowedTextFile) .iterator(); while (iterator.hasNext() hits.size() limit) { Path file iterator.next(); if (Files.size(file) MAX_FILE_SIZE) continue; ListString lines; try { lines Files.readAllLines(file, StandardCharsets.UTF_8); } catch (MalformedInputException e) { continue; } for (int i 0; i lines.size() hits.size() limit; i) { String line lines.get(i); String haystack caseFlag ? line : line.toLowerCase(Locale.ROOT); if (haystack.contains(needle)) { hits.add(toRelativePath(file) : (i 1) : line.trim()); } } } } return hits.isEmpty() ? 未找到匹配内容 : String.join(\n, hits); } private Path getBaseDirPath() { String baseDir System.getenv(LOCAL_FILE_MCP_BASE_DIR); if (isBlank(baseDir)) baseDir .; return Paths.get(baseDir).toAbsolutePath().normalize(); } private Path safeResolve(String userPath) { Path baseDir getBaseDirPath(); Path resolved baseDir.resolve(userPath).normalize().toAbsolutePath(); if (!resolved.startsWith(baseDir)) { throw new IllegalArgumentException(禁止访问根目录之外的路径: userPath); } return resolved; } private boolean isAllowedTextFile(Path path) { String fileName path.getFileName().toString().toLowerCase(Locale.ROOT); return ALLOWED_EXTENSIONS.stream().anyMatch(fileName::endsWith); } private String toRelativePath(Path path) { return getBaseDirPath().relativize(path.toAbsolutePath().normalize()) .toString().replace(\\, /); } private boolean isBlank(String s) { return s null || s.isBlank(); } }根目录通过环境变量LOCAL_FILE_MCP_BASE_DIR传入不写死路径这样同一份 jar 可以在不同机器上复用。4. 验证请求curl 与 Codex 各调一次4.1 先打包再用 Java 客户端自测执行mvn clean package得到target/McpDemo-0.0.1-SNAPSHOT.jar。在写 Codex 配置之前先用一个 Java 客户端确认工具确实注册成功了能省掉很多“到底是服务问题还是客户端问题”的排查。package com.example; import io.modelcontextprotocol.client.McpClient; import io.modelcontextprotocol.client.transport.ServerParameters; import io.modelcontextprotocol.client.transport.StdioClientTransport; import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.spec.McpSchema.ListToolsResult; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class ClientStdio { private static final Logger log LoggerFactory.getLogger(ClientStdio.class); public static void main(String[] args) { var stdioParams ServerParameters.builder(java) .args(-jar, D:\\IdeaProjects\\McpDemo\\target\\McpDemo-0.0.1-SNAPSHOT.jar) .build(); var transport new StdioClientTransport(stdioParams, McpJsonDefaults.getMapper()); var client McpClient.sync(transport).build(); client.initialize(); ListToolsResult toolsList client.listTools(); log.info(Available Tools toolsList); client.closeGracefully(); } }运行后控制台会打印出get_base_dir、list_files、read_file、search_in_files四个工具。看到这个列表说明服务端注册没问题。4.2 Codex 客户端配置在 Codex Desktop 的“设置 - MCP 服务器”里新增一个 STDIO 服务核心参数如下参数是否必填说明command必填启动命令这里填javaargs可选启动参数填-jar和 jar 的绝对路径env可选服务需要的环境变量如LOCAL_FILE_MCP_BASE_DIRenv_vars可选把系统环境变量透传给服务cwd可选工作目录限制服务在该目录下运行配置完成后在 Codex 里发一句“列出当前目录下的文件”如果返回的是你设定根目录下的文件列表说明整条链路通了。4.3 远程 Streamable HTTP 版本如果想让服务被远程调用把 starter 换成spring-ai-starter-mcp-server-webmvc配置改成下面这样server: port: 8080 spring: application: name: McpDemo ai: mcp: server: name: remote-mcp-server version: 1.0.0 type: SYNC instructions: 这是一个可以和你打招呼的远程MCP服务。 annotation-scanner: enabled: true protocol: streamable工具类可以极简先放两个方法验证连通性package com.example; import org.springframework.ai.mcp.annotation.McpTool; import org.springframework.ai.mcp.annotation.McpToolParam; import org.springframework.stereotype.Component; Component public class McpTools { McpTool(name ping, description 检查远程 MCP 服务是否可用) public String ping() { return pong; } McpTool(name hello, description 打个招呼) public String hello( McpToolParam(description 你的名字) String name ) { return 你好 name ,我是 远程MCP服务。; } }启动后用 curl 验证工具列表curl -X POST http://localhost:8080/mcp \ -H Content-Type: application/json \ -H Accept: application/json, text/event-stream \ -d {jsonrpc:2.0,id:1,method:tools/list,params:{}}返回体里能看到ping和hello两个工具名就说明远程服务正常。Codex 侧配置远程 MCP 时主要参数是url必填、bearer_token_env_var可选用于认证、http_headers和env_http_headers可选附加请求头。5. 本篇常见错排查5.1 STDIO 服务启动后 Codex 连不上九成是日志污染了标准输出。检查logging.pattern.console是否留空、banner-mode是否关闭。只要控制台还有一行非协议内容客户端解析就会失败。把日志全部重定向到文件是最稳的做法。5.2 工具列表为空先确认annotation-scanner.enabled是true再确认工具类上有Component。如果用了McpTool但方法所在类没被 Spring 扫描到注解不会生效。另外type: SYNC和stdio: true要同时存在缺一个都可能注册不上。5.3 路径越界异常safeResolve会拒绝根目录之外的路径。如果你在 Codex 里让它读../开头的文件会直接抛IllegalArgumentException。这是有意为之的安全边界不是 bug。需要访问更大范围时调整LOCAL_FILE_MCP_BASE_DIR即可。5.4 远程模式 curl 返回 406Streamable HTTP 要求Accept头同时包含application/json和text/event-stream。只写application/json会被拒。上面那条 curl 命令里的 Accept 头别省。5.5 模型调用报鉴权失败如果 Codex 侧模型请求走的是统一通道检查 API Key 是否配置在正确的环境变量里。TaoToken 的 Key 可以在控制台生成接入文档里有各客户端的配置示例。工具本身能跑、但模型调不通时优先看这一层。6. 把 MCP 接进日常编码流工具跑通之后下一步是让它真正进入你的编码流程。本地文件服务适合让 Codex 读项目结构、定位代码片段远程服务适合把团队内部的查询接口包装成工具。两者可以同时挂在 Codex 上按场景切换。如果你打算长期用 Codex 做编码和 Agent 任务可以了解下 Coding Plan它把模型调用和工具链的额度统一管理省得每个服务单独配 KeyCoding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite模型对话https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentchatutm_campaignrewrite控制台https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite我自己的习惯是本地文件类工具走 STDIO不占端口、随 Codex 启停需要跨机器或团队共享的查询能力走 Streamable HTTP配好 bearer token 再暴露。先把ping和list_files这两个最小工具调通后面加业务工具就是复制注解、改参数的事。