一文看懂Spring MCP 的请求链路

1、项目简介

使用Spring MCP开发MCP服务器代码非常简单,下面是示例代码

kotlin 复制代码
@Service
public class WeatherMcp{
    @Tool(description = "根据城市名获取该城市的天气信息实况")
    public String getWeatherByCityName(@ToolParam(description = "城市名称") String cityName) {
        System.out.println("获取城市天气信息:" + cityName);
        String nowUrl = "https://api.seniverse.com/v3/weather/now.json?key=" + apiKey + "&location=";
        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(nowUrl + cityName))
                .build();
        try {
            var response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
            System.out.println("获取城市天气信息成功:" + response.body());
            return response.body();
        } catch (Exception e) {
            System.out.println(e.getMessage());
            return "获取天气信息失败";
        }
    }

}

一个@Tool注解搞定,然后就是一个配置类,注册工具

kotlin 复制代码
@Configuration
public class McpConfig {
    @Bean
    public ToolCallbackProvider WeatherTools(WeatherMcp weatherMcp) {
        return MethodToolCallbackProvider.builder()
                .toolObjects(weatherMcp)
                .build();
    }

}

然后是ymal配置及依赖

yaml 复制代码
 spring:
     ai:
      mcp:
        server:
          name: my-mcp-server
          version: 1.0.0
          type: SYNC
          sse-message-endpoint: /mcp/messages
 server:
  port: 8989
  servlet:
    context-path: /
xml 复制代码
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
    <version>1.0.0</version>
</dependency>

在Cherry Studio(自带mcp客户端)连接看下

这就成功写了一个mcp服务器,并且能够让大模型调用了。

2、问题提出

代码实现起来很简单,但是当大模型调用我们的MCP服务器的工具时发生了什么呢?先说结论,请看下面的图

总共请求了5次,第一次请求的path为/see,后续都为/mcp/messages,这些路径其实就是上面yml文件配置的。

在第一次http请求中, 本次请求会变成一个sse连接,同时创建一个McpSeesion,同时把sessionId以endpoint的方式推送给客户端。这个连接是1对1的,即一个客户端对应一个连接,后续请求的结果都是通过这个sse连接推送到对应的客户端

第二和第三次的http请求,用以初始化,具体干了啥没有具体研究

第四次http请求中,method是tools/list,用于获取mcp服务器的工具列表

第五次http请求中,method是tools/call,用于调用具体的mcp工具

3、/sse的请求链路

我们直接从DispatcherServlet.doDispatch方法开始看,在getHandler方法会获取HandlerExecutionChain,在HandlerMappings中有一个RouterFuncitionMapping,在这个对象中维护着RouterFunction,这里面有着所有的路由到function的映射。如下所示:

(GET && /sse) -> io.modelcontextprotocol.server.transport.WebMvcSseServerTransportProvider$$Lambda$1305/0x00000004015d2710@3d8d970e 和

(POST && /mcp/messages) -> io.modelcontextprotocol.server.transport.WebMvcSseServerTransportProvider$$Lambda$1307/0x00000004015d43f8@2fac80a8

这就对应着mcp的两个请求路径 也就是说,如果GET方法且是/sse路径,则会路由到WebMvcSseServerTransportProvider的一个Lambda表达式,POST方法且路径为 /mcp/messages路由到WebMvcSseServerTransportProvider的另一个Lambda表达式。 (这里提一嘴,我们使用@RequestMapping注解的controller中的方法是RequestMappingHandlerMapping中处理的)

再看WebMvcSseServerTransportProvider类

在初始化对象时添加了路由,也就是/sse映射到handleSseConnection方法 /mcp/messages 映射到handleMessage方法

思考一个问题,这个映射关系是什么时候,又是如何添加到HandlerMappings中的呢?

答案是在在DispatcherServlet的initStrategies方法中

在这里初始化了WebMvc的九大组件,其中就包括HandlerMappings的初始化。

看initHandlerMappings方法,就是把spring容器中初始化的HandlerMapping对象放到HandlerMappings中。

而上面的RouterFuncitionMapping应该是在springboot的自动装配类中自动装配到spring容器中的

再聚焦到HandlerMapping的getHandler方法,在这个方法会返回一个HandlerExecutionChain对象,这个对象里面有我们自定义和默认的拦截器和过滤器以及handler

在getHandler方法中,首先通过getHandlerInternal获取handler

在RouterFuncitionMapping中其实这个handler就是一个HandlerFunction(RequestMappingHandlerMapping则是一个HandlerMethod),而这个HandlerFunction就是WebMvcSseServerTransportProvider中添加的。

这个AuthInterceptor就是我自定义的拦截器,所以mcp的请求到这里还是跟普通的web请求一样,只不过使用的的是RouterFuncitionMapping,而不是平常使用的RequestMappingHandlerMapping,并且RouterFuncitionMapping的优先级高于RequestMappingHandlerMapping。

到这里通过url获取到了完整的HandlerExecutionChain,在HandlerExecutionChain中有handler(真正处理业务逻辑的方法或Lamda表达式)及拦截器

然后执行applyPreHandle,其实就是拦截器的前置处理方法,执行完后执行HandlerAdapter的handle方法。

然后进入handlerFunction的handle方法,即WebMvcSseServerTransportProvider类的handleSseConnection方法。在这里会创建一个sse连接

这里的sseBuilder.id(sessionId).event(MESSAGE_EVENT_TYPE).data();其实就是通过sse向客户端推送消息

到这里成功创建了一个McpServerSession,与客户端一对一连接。所谓的连接就是服务端维护这个sessionId,并把这个sessionId给到客户端,客户端下次调用mcp服务时带上这个sessionId。

其实这里忽略了许多细节,比如下面这个创建会话的代码,整个创建过程还是比较长的,挺多代码和回调啥的,就不细讲了。

ini 复制代码
McpServerSession session = sessionFactory.create(sessionTransport);

4、/mcp/messages的请求链路

从dispatch到HandlerFunctionAdapter的过程跟sse是一样的,只不过handler的方法对应的是WebMvcSseServerTransportProvider的handleMessage方法。在handleMessage方法中,根据请求的sessionId,找到之前的创建的McpServerSession

然后从请求体中拿到request body

然后调用session.handle方法,在这个方法中根据message类型走不同的逻辑,而本次走的是request

kotlin 复制代码
public Mono<Void> handle(McpSchema.JSONRPCMessage message) {
    return Mono.defer(() -> {
       // TODO handle errors for communication to without initialization happening
       // first
       if (message instanceof McpSchema.JSONRPCResponse response) {
          logger.debug("Received Response: {}", response);
          var sink = pendingResponses.remove(response.id());
          if (sink == null) {
             logger.warn("Unexpected response for unknown id {}", response.id());
          }
          else {
             sink.success(response);
          }
          return Mono.empty();
       }
       else if (message instanceof McpSchema.JSONRPCRequest request) {
          logger.debug("Received request: {}", request);
          return handleIncomingRequest(request).onErrorResume(error -> {
             var errorResponse = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), null,
                   new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INTERNAL_ERROR,
                         error.getMessage(), null));
             // TODO: Should the error go to SSE or back as POST return?
             return this.transport.sendMessage(errorResponse).then(Mono.empty());
          }).flatMap(this.transport::sendMessage);
       }
       else if (message instanceof McpSchema.JSONRPCNotification notification) {
          // TODO handle errors for communication to without initialization
          // happening first
          logger.debug("Received notification: {}", notification);
          // TODO: in case of error, should the POST request be signalled?
          return handleIncomingNotification(notification)
             .doOnError(error -> logger.error("Error handling notification: {}", error.getMessage()));
       }
       else {
          logger.warn("Received unknown message type: {}", message);
          return Mono.empty();
       }
    });
}

聚焦下面代码

javascript 复制代码
else if (message instanceof McpSchema.JSONRPCRequest request) {
    logger.debug("Received request: {}", request);
    return handleIncomingRequest(request).onErrorResume(error -> {
       var errorResponse = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), null,
             new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INTERNAL_ERROR,
                   error.getMessage(), null));
       // TODO: Should the error go to SSE or back as POST return?
       return this.transport.sendMessage(errorResponse).then(Mono.empty());
    }).flatMap(this.transport::sendMessage);
}

handleIncomingRequest方法处理完后发送消息,即transport::sendMessage,这个send message就是往客户端推送结果

在上面的代码中,sseBuilder.id(sessionId).event(MESSAGE_EVENT_TYPE).data(jsonText); 其实就是通过sse向客户端推送消息,具体流程如下:

再看handleIncomingRequest方法,如果是初始化请求,走if的逻辑,如果是其他请求,比如toolList, call等方法走else。再看else里面的逻辑。会从requestHandlers获取到对应的RequestHandler,然后执行handler.handle方法并把结果返回

我们再看这个requestHandlers,它是一个Map

swift 复制代码
private final Map<String, RequestHandler<?>> requestHandlers;

下面是requestHandlers的put方法,前面几个是预定义的一些方法,比如tools/list、tools/call等。

我们先看tools/list的requestHandler,如下, 这个方法返回了一个匿名内部类,其实就是requestHandler的实现类。

kotlin 复制代码
private McpServerSession.RequestHandler<McpSchema.ListToolsResult> toolsListRequestHandler() {
    return (exchange, params) -> {
       List<Tool> tools = this.tools.stream().map(McpServerFeatures.AsyncToolSpecification::tool).toList();

       return Mono.just(new McpSchema.ListToolsResult(tools, null));
    };
}

到这里可以发现CopyOnWriteArrayList<McpServerFeatures.AsyncToolSpecification> tools就是所有mcp工具的集合

scss 复制代码
public record AsyncToolSpecification(McpSchema.Tool tool,
       BiFunction<McpAsyncServerExchange, Map<String, Object>, Mono<McpSchema.CallToolResult>> call) {

    static AsyncToolSpecification fromSync(SyncToolSpecification tool) {
       // FIXME: This is temporary, proper validation should be implemented
       if (tool == null) {
          return null;
       }
       return new AsyncToolSpecification(tool.tool(),
             (exchange, map) -> Mono
                .fromCallable(() -> tool.call().apply(new McpSyncServerExchange(exchange), map))
                .subscribeOn(Schedulers.boundedElastic()));
    }
}

再看tools/call的requestHandler,也是匿名内部类,最终调用我们自定义代码的地方应该是tool.call().apply(exchange, callToolRequest.arguments())方法。

less 复制代码
private McpServerSession.RequestHandler<CallToolResult> toolsCallRequestHandler() {
    return (exchange, params) -> {
       McpSchema.CallToolRequest callToolRequest = objectMapper.convertValue(params,
             new TypeReference<McpSchema.CallToolRequest>() {
             });

       Optional<McpServerFeatures.AsyncToolSpecification> toolSpecification = this.tools.stream()
          .filter(tr -> callToolRequest.name().equals(tr.tool().name()))
          .findAny();

       if (toolSpecification.isEmpty()) {
          return Mono.error(new McpError("Tool not found: " + callToolRequest.name()));
       }

       return toolSpecification.map(tool -> tool.call().apply(exchange, callToolRequest.arguments()))
          .orElse(Mono.error(new McpError("Tool not found: " + callToolRequest.name())));
    };
}

最后要解决的问题是,spring mcp是如何把我们自己写的方法转成一个mcp tool的?

关键在于创建MethodToolCallback对象,这是mcp方法回调对象,在上面的tool.call().apply方法中,最终会进入下面代码

在这里可以看到有一个ToolCallback对象,然后使用toolCallback.call方法调用到MethodToolCallback.call, 如下图

这里的invoke方法就是我们自己写的带有@Tool的方法。

此时再看我们写的McpConfig

kotlin 复制代码
@Configuration
public class McpConfig {
    @Bean
    public ToolCallbackProvider WeatherTools(WeatherMcp weatherMcp) {
        return MethodToolCallbackProvider.builder()
                .toolObjects(weatherMcp)
                .build();
    }

}
typescript 复制代码
public Builder toolObjects(Object... toolObjects) {
    Assert.notNull(toolObjects, "toolObjects cannot be null");
    this.toolObjects = Arrays.asList(toolObjects);
    return this;
}

public MethodToolCallbackProvider build() {
    return new MethodToolCallbackProvider(this.toolObjects);
}
scss 复制代码
private MethodToolCallbackProvider(List<Object> toolObjects) {
    Assert.notNull(toolObjects, "toolObjects cannot be null");
    Assert.noNullElements(toolObjects, "toolObjects cannot contain null elements");
    assertToolAnnotatedMethodsPresent(toolObjects);
    this.toolObjects = toolObjects;
    validateToolCallbacks(getToolCallbacks());
}
less 复制代码
@Override
public ToolCallback[] getToolCallbacks() {
    var toolCallbacks = this.toolObjects.stream()
       .map(toolObject -> Stream
          .of(ReflectionUtils.getDeclaredMethods(
                AopUtils.isAopProxy(toolObject) ? AopUtils.getTargetClass(toolObject) : toolObject.getClass()))
          .filter(toolMethod -> toolMethod.isAnnotationPresent(Tool.class))
          .filter(toolMethod -> !isFunctionalType(toolMethod))
          .map(toolMethod -> MethodToolCallback.builder()// 在这里创建MethodToolCallback对象
             .toolDefinition(ToolDefinitions.from(toolMethod))
             .toolMetadata(ToolMetadata.from(toolMethod))
             .toolMethod(toolMethod)
             .toolObject(toolObject)
             .toolCallResultConverter(ToolUtils.getToolCallResultConverter(toolMethod))
             .build())
          .toArray(ToolCallback[]::new))
       .flatMap(Stream::of)
       .toArray(ToolCallback[]::new);

    validateToolCallbacks(toolCallbacks);

    return toolCallbacks;
}

5、总结

其实最关键的地方就两个映射。

一个是dispatcherServlet中把web请求映射到WebMvcSseServerTransportProvider的handleSseConnection和handleMessage方法。handleSseConnection方法用以创建sse连接及创建McpSession,并把sessionId推送给客户端,handleMessage用以处理客户端发送的消息,比如tools/list获取所有工具列表,tools/call用以调用具体的工具;

第二个关键点在于,在WebMvcSseServerTransportProvider的handleMessage方法中,如何根据客户端传过来的的方法名映射到我们自己写的带有@Tool的方法,其实就是在McpConfig配置类中,我们手动把工具注册成MethodToolCallback,MethodToolCallback有工具方法的元信息,其中包含方法名,回调Method,参数类型,返回值类型等。通过客户端传过来的方法名就很容易匹配到MethodToolCallback,然后使用反射回调即可。回调结束后会把回调结果以sendMessage的方式通过sse推送给客户端,而不是以web请求的response返回给客户端。

相关推荐
2401_8315017310 分钟前
Linux之Docker虚拟化技术(一)
java·linux·docker
TPBoreas19 分钟前
架构设计模式七大原则
java·开发语言
自由的疯30 分钟前
Java 实现TXT文件导入功能
java·后端·架构
开开心心就好30 分钟前
PDF转长图工具,一键多页转图片
java·服务器·前端·数据库·人工智能·pdf·推荐算法
现在没有牛仔了33 分钟前
SpringBoot实现操作日志记录完整指南
java·spring boot·后端
小蒜学长37 分钟前
基于django的梧桐山水智慧旅游平台设计与开发(代码+数据库+LW)
java·spring boot·后端·python·django·旅游
浮游本尊43 分钟前
Java学习第16天 - 分布式事务与数据一致性
java
浮游本尊1 小时前
Java学习第15天 - 服务网关与API管理
java
熙客2 小时前
Java:LinkedList的使用
java·开发语言
blueblood2 小时前
🗄️ JFinal 项目在 IntelliJ IDEA 中的 Modules 配置指南
java·后端