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 一种传输。

— 感谢阅读 —

一起交流

分享你的思考,让讨论更进一步。