LangChain4j Community 10 月 10 日发布的 1.22.0-beta32 带了 STDIO MCP Server starter,一个依赖加几行配置,就能把 Spring 容器里指定的 @Tool Bean 暴露成 MCP 工具。它默认关闭,要暴露哪些 Bean 得显式写名字,依赖树里也没有 Spring AI。我用 Spring Boot 4 的那个 starter 在 Spring Boot 4.0.5 + JDK 21 上搭了最小项目,手工发 JSON-RPC 走完了 initialize、tools/list、tools/call。
前几天写过 LangChain4j 1.22.0 的决策模型,这次的 starter 在社区仓库里,跟着同一轮版本出来。文中设计取舍的说法来自官方 README 和 PR #839。
发布了什么
1.22.0-beta32 Release Notes 中对应条目是 “Add opt-in Spring Boot 3 and 4 STDIO MCP server starters”(PR #839)。Boot 4 用的是 dev.langchain4j:langchain4j-community-mcp-server-spring-boot4-starter,也就是本文跑的这个。Boot 3 对应 dev.langchain4j:langchain4j-community-mcp-server-spring-boot-starter,这部分本文只依据 README:两者复用社区仓库里已有的 McpServer 和 StdioMcpServerTransport,配置属性和行为一致,版本由 langchain4j-community-bom 管理,只引入与应用 Boot 大版本匹配的那一个。
PR 描述里有两点限定范围的说明:模块通过 enforcer 规则禁止直接或传递依赖 Spring AI;这次只做 STDIO,不包含 HTTP、OAuth、模型配置和编排。也就是说它面向的是“由客户端拉起的本地进程”这种 MCP Server,比如给 IDE 或桌面 Agent 挂一个读内部系统的工具。
最小项目
pom.xml 的关键部分:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.5</version>
</parent>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-community-bom</artifactId>
<version>1.22.0-beta32</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-community-mcp-server-spring-boot4-starter</artifactId>
</dependency>
</dependencies>
解析出来的依赖里,langchain4j-core 是 1.22.0,langchain4j-mcp 和 langchain4j-community-mcp-server 是 1.22.0-beta32,没有任何 org.springframework.ai 构件。
业务服务和工具门面分成两个类。OrderService 是普通的 @Service:
@Service
public class OrderService {
private final Map<String, String> statuses = Map.of("A1001", "SHIPPED", "A1002", "PENDING");
public String statusOf(String orderId) {
return statuses.getOrDefault(orderId, "NOT_FOUND");
}
}
工具门面是一个不带任何 Spring 注解的类,通过 @Bean 注册:
public class OrderTools {
private final OrderService orderService;
public OrderTools(OrderService orderService) {
this.orderService = orderService;
}
@Tool("Look up the shipping status of an order")
public String orderStatus(@P(name = "orderId", value = "Order id, e.g. A1001") String orderId) {
return orderService.statusOf(orderId);
}
}
@Configuration(proxyBeanMethods = false)
class ToolConfiguration {
@Bean
OrderTools orderTools(OrderService orderService) {
return new OrderTools(orderService);
}
}
这样拆的原因在 README 里写得很清楚:starter 会显式拒绝 JDK 代理和 CGLIB 代理,因为当前核心 server 靠反射读取具体类上声明的方法,代理类上的方法注册不可靠;它也不会去解包代理,避免绕开事务和安全切面。推荐做法就是让一个没有代理的 @Tool 门面去调用被代理的服务。另外参数名要么用 -parameters 编译,要么用 @P(name = ...),否则 JSON Schema 里的参数名没有意义。
application.properties:
spring.main.web-application-type=none
spring.main.banner-mode=off
logging.config=classpath:logback-mcp.xml
langchain4j.community.mcp.server.enabled=true
langchain4j.community.mcp.server.tool-bean-names=orderTools
STDIO 模式下 stdout 只能输出 MCP JSON-RPC,所以 banner 要关掉,日志要改到 stderr。starter 不会替你改全局日志配置,logback-mcp.xml 需要自己放:
<configuration>
<appender name="STDERR" class="ch.qos.logback.core.ConsoleAppender">
<target>System.err</target>
<encoder>
<pattern>%level %logger - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="STDERR"/>
</root>
</configuration>
跑一遍 JSON-RPC
打包后用管道直接喂三条请求:
mvn -q -B package -DskipTests
( printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"cli","version":"0"}}}'
printf '%s\n' '{"jsonrpc":"2.0","method":"notifications/initialized"}'
printf '%s\n' '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
printf '%s\n' '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"orderStatus","arguments":{"orderId":"A1001"}}}'
sleep 3 ) | java -jar target/order-mcp-0.0.1.jar 2>stderr.log
stdout 上拿到的原样输出:
{"id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{}},"serverInfo":{"name":"LangChain4j","version":"dev"}},"jsonrpc":"2.0"}
{"id":2,"result":{"tools":[{"name":"orderStatus","description":"Look up the shipping status of an order","inputSchema":{"type":"object","properties":{"orderId":{"type":"string","description":"Order id, e.g. A1001"}},"required":["orderId"]}}]},"jsonrpc":"2.0"}
{"id":3,"result":{"content":[{"type":"text","text":"SHIPPED"}]},"jsonrpc":"2.0"}
Spring 的启动日志全部进了 stderr.log,stdout 上只有协议消息。管道里的 sleep 3 结束后 stdin 关闭,进程随之正常退出,退出码 0。这和 README 描述的生命周期一致:transport 在 ApplicationReadyEvent 之后启动,一个非守护线程负责在 main 返回后保持进程存活,stdin 读到 EOF 时关闭整个 ApplicationContext,全程不调用 System.exit。如果应用自己创建了非守护线程,需要自己负责停掉,否则进程不会退出。
配错的时候会怎样
README 说启用后 tool-bean-names 必须是非空、不重复的单例 Bean 名,找不到 Bean、名字为空、重复选择(含别名)、Bean 上没有 @Tool 方法都会让启动失败。我把配置改成指向没有 @Tool 方法的 orderService 试了一下:
echo | java -jar target/order-mcp-0.0.1.jar --langchain4j.community.mcp.server.tool-bean-names=orderService
启动直接失败,根因是:
java.lang.IllegalArgumentException: MCP tool bean must declare at least one @Tool method: orderService
异常来自 dev.langchain4j.community.mcp.server.spring.McpServerAutoConfiguration 里的 mcpServer 工厂方法。starter 不做类路径扫描,也不会自动把容器里所有带 @Tool 的 Bean 暴露出去,暴露面完全由这一行配置决定。
自定义和边界
需要接管时有三个层次可选,README 的说法是:自己定义 McpServer Bean,会替换默认的工具注册;自己定义 StdioMcpServerTransport,会替换默认 transport,但仍由 starter 的生命周期管理,建议配合 @Lazy 和 @Bean(destroyMethod = "");自己定义 McpServerLifecycle,则整个生命周期都由你负责。
README 里写明的限制有这几条:只支持选中 Bean 具体类上声明的工具方法,只从父类继承来的 @Tool 方法不会被识别;会话不可重启,EOF 即结束;协议层没有新增能力,HTTP 传输和 OAuth 不在这个 starter 的范围内。Spring AI 自己也有 MCP Server starter,这个新 starter 的区别在于不依赖 Spring AI,直接复用 LangChain4j 的 @Tool 写法,目前只有 STDIO 一种传输。
一起交流
分享你的思考,让讨论更进一步。