之前在Apifox共享了MCP服务调试过程,这里彻底完善解析了一下。本文的前置知识是jsonrpc2.0规范

MCP,模型上下文协议,赋予了大模型调用第三方接口的能力,统一了客户端调用。如果说大语言模型(LLM)提供了大脑的话,MCP协议就是为这大脑连接上了可以被大脑操控的四肢。本文将通过Java案例来了解MCP Server是如何工作的,以及了解整个协议的运作流程。

协议基础

MCP是一套非常简单的协议规范。它分为本地服务stdio和远程服务http两种类型:

stdio用作本地服务的实现

比如读取本地的文件,调用本地的显卡,启动本地应用等等本地操作。在用户的角度,只要用户配置好了mcp Server,就可以在对话过程中自动使用这个mcp Server提供的能力。stdio mcp server本质是一个通过命令行调用的本地程序。

http用作远程服务的实现

比如调用高德地图的能力了解当前位置有哪些厕所,调用阿里云能力监控服务器状态,调用github能力管理仓库等等。正是因为远程mcp Server,使得大语言模型的可以调用整个互联网的能力,大大扩展了LLM的能力边界。在我们的企业应用上,基本上也是开发远程mcp Server来接入LLM大脑。

整个协议时构建在jsonrpc 2.0上的。整个协议中,一直存在四个角色,MCP Server(mcp服务)、MCP client(mcp客户端)、user(用户)和LLM(大语言模型),协议解决了他们之间的交互问题。由于MCP通过MCP client和MCP server之间的交互来实现功能,所以可以大致区分为客户端能力与服务端能力。

客户端能力

服务端要使用客户端能力首先需要客户端与服务端建立连接初始化时带上可用能力信息:

{
  "capabilities": {
    "roots": {
      "listChanged": true
    },
    "sampling": {}
  }
}
  • roots根目录

根目录定义了服务器在文件系统中的操作边界,使其能够明确可访问的目录和文件范围。支持该协议的客户端会向服务器提供根目录列表,并在列表变更时发送通知。

它解决了服务访问用户工作目录资源的问题。服务端通过发送roots/list请求来获取根目录,客户端通过natifications/roots/list_changed通知服务端,用户的根目录已经变更。

server                                     client
  |          roots/list                      |
  |             -->                          |
  |             <--                          |
  |                                          |
  |   natifications/roots/list_changed       |
  |           <----                          |
  |          roots/list                      |
  |             -->                          |
  |             <--

一个使用场景是将用户工作目录暴露到服务端。

  • sampling采样

采样解决了服务端访问LLM能力的问题,服务端通过客户端向语言模型请求 LLM 采样("补全"或"生成")。这种流程使得客户端能够保持对模型访问、选择和权限的控制,同时让服务器无需 API 密钥即可利用 AI 能力。服务器可以请求基于文本或图像的交互,并选择性地在提示中包含来自 MCP 服务器的上下文。

服务端通过sampling/createMessage请求客户端代理调用LLM能力。

server                     client             user          llm
  |  sampling/createMessage   |  获取用户许可   |             |
  |  --->                     |  -->          |             |
  |                           |  <--          |             |
  |                           |        调用LLM               |
  |                           |   -------------------->     |
  |请求服务端(如果需要将信息带回)|   <-------------------      | 
  |   <---                    |                             |              

一个使用场景是服务端给出的结果是一个人类无法识别的结果,需要使用LLM将其翻译成人话呈现给用户。服务端可以使用采样方式来达到这一目的。

服务端能力

客户端要使用服务端能力的前提是在初始化的时候声明了服务端有这个能力。

{
  "capabilities": {
    "prompts": {
      "listChanged": true
    },
    "resources": { 
      "subscribe": true, 
      "listChanged": true 
    },
    "tools": {
      "listChanged": true
    }
  }
}
  • prompts提示词

服务端为客户端提供的一系列快速提示词模板。提示功能允许服务器提供结构化消息和指令,用于与语言模型进行交互。客户端可以发现可用提示、检索其内容,并提供参数进行自定义。

客户端通过prompts/list 列出服务端可用的提示词,通过prompts/get获取提示词内容。服务端通过notifications/prompts/list_changed 通知客户端服务端的提示词已经变更。

client                              server
  |    prompts/list                   |
  |    ------->                       |
  |    <------                        |
  |                                   |
  |    prompts/get                    |
  |    ------->                       |
  |    <------                        |
  |                                   |
  |notifications/prompts/list_changed |
  |    ------->                       |
  |    <------                        |
  |   prompts/list                    |
  |    ------->                       |
  |    <------                        |

一个使用场景是为用户提供更加高质量的提示词。

  • resources资源

资源是定义在服务端的可被客户端访问的资源。这些资源使服务器能够共享为语言模型提供上下文的数据,例如文件、数据库模式或特定应用信息。每个资源通过 URI 进行唯一标识。

它解决了客户端访问服务端资源的问题。他又两个特性:subscribe和listChanged,分别应用于单个资源和资源列表发生变化时通知给客户端。

客户端通过resources/list列出服务端可用资源,通过resources/read读取资源,通过resources/templates/list获取动态资源模板(比如服务端图片通过日期放到不同的目录里面,那么服务端可以将整个资源路径做成模板资源,使用占位符精简资源路径)。服务端通过notifications/resources/list_changed通知客户端,服务端的资源列表已经变更。

客户端可以通过resources/subscribe订阅某个资源,通过resources/unsubscribe取消订阅某个资源。服务端通过notifications/resources/updated通知客户端某个资源已经变更。

client                                   server
  |    resources/list                       |
  |       ---->                             |
  |      <-----                             |  
  |   resources/read                        |
  |    ------>                              |
  |    <-----                               |
  |    resources/templates/list             |
  |   ---------------------->               |
  |   <---------------------                |
  | notifications/resources/list_changed    |
  |  <----------------------                |
  |    resources/list                       |
  |       ---->                             |
  |      <-----                             |
  |    resources/subscribe                  |
  |       ---->                             |
  |       <------                           |
  | notifications/resources/updated         |
  |  <----------------------                |
  |    resources/unsubscribe                |
  |       ---->                             |
  |      <-----                             |
  |                                         |

一个使用场景是提供一个数据库scheme供LLM参考。

  • tools工具

服务端向语言模型公开可调用的工具。这些工具使模型能够与外部系统交互,例如查询数据库、调用 API 或执行计算。每个工具通过唯一名称进行标识,并包含描述其架构的元数据。

它解决了LLM与服务端的交互问题。MCP服务端能力的开发集中在此,常规情况下大语言模型调用其他服务需要编程实现function calling,MCP提供tools,通过客户端中继实现了更加广泛的功能调用能力。客户端通过tools/list列出服务端提供的能力工具,通过tools/call调用服务端工具。服务端通过notifications/tools/list_changed通知客户端,服务端的工具已变更。

LLM     client                                server
 |          |    tools/list                       |
 |          |       ---->                         |
 |选择工具 |      <-----                         |  
 |------> |   tools/call                        |
 |处理结果 |    ------>                          |
 |<----   |    <-----                           |
 |          |    notifications/tools/list_changed |
 |          |   <---------------------            |
 |          |    tools/list                       |
 |          |       ---->                         |
 |          |      <-----                         |

能力工具(由MCP服务自行决定是否实现)

为了简化用户使用服务端资源和提示词的,MCP设计了补全方法用来补全用户输入,毕竟用户不可能记住所有的服务端提供的所有资源和提示词,更加不可能知道动态资源里面有哪些内容。客户端通过completion/complete方法来获取用户当前输入的提示(提示有两种类型:ref/prompt所引用的提示词模板,ref/resource所引用的资源uri),比如用户正在使用一个生成代码的提示词模板,但是没有指定语言,当用户输入ja的时候调用补全提示接口,就可以列出java和JavaScript供用户选择。再比如用户正在使用某个资源模板,但是不知道该资源代表具体额哪些文件,那么就可以返回资源列表给用户。

user     client                    server
| 用户输入  |  completion/complete    |
|  ----->  |      ---->              |
|          |      <----              |
| 用户输入  |  completion/complete    |
|  ----->  |      ---->              |
|          |      <----              |

MCP设计了一种客户端获取端日志的方法。服务端需要在初始化过程中声明该能力。

{
  "capabilities": {
    "logging": {}
  }
}

客户端通过logging/setLevel方法来设置接受什么级别的日志。服务端通过notifications/message方法将日志通知到客户端。

client                   server
|  logging/setLevel       |
|      ---->              |
|      <----              |
|  notifications/message  |
|      <----              |
|      <----              |

协议流程

总的来说它分为三个流程:建立连接,处理业务,关闭连接。

服务开发,在老版本中,使用http server sent events (http sse)能力连接,新版本中使用streamable http。这里使用spring ai提供的库,使用sse。

sse有两个端点,sse endpoint和sse message endpoint,前者由服务端发送数据到客户端,后者由客户端发送消息到服务端。

环境准备

首先我们要开发一个MCP服务器。这里使用SpringAI做的封装完成开发。注意,目前SpringAI还是Snapshot版本,功能不太完善,不建议在生产环境使用。

  1. 引入POM文件
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
   <modelVersion>4.0.0</modelVersion>
   <parent>
       <groupId>org.springframework.boot</groupId>
       <artifactId>spring-boot-starter-parent</artifactId>
       <version>3.4.6-SNAPSHOT</version>
       <relativePath/>
       <!-- lookup parent from repository -->
   </parent>
   <groupId>cn.lishiyuan</groupId>
   <artifactId>mcp-test</artifactId>
   <version>0.0.1-SNAPSHOT</version>
   <name>mcp-test</name>
   <description>mcp-test</description>
   <properties>
       <java.version>17</java.version>
       <spring-ai.version>1.0.0-SNAPSHOT</spring-ai.version>
   </properties>
   <dependencies>
       <dependency>
           <groupId>org.springframework.boot</groupId>
           <artifactId>spring-boot-starter-web</artifactId>
       </dependency>
       <dependency>
           <groupId>org.springframework.ai</groupId>
           <artifactId>spring-ai-starter-mcp-server</artifactId>
       </dependency>
       <dependency>
           <groupId>org.springframework.ai</groupId>
           <artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
       </dependency>
       <dependency>
           <groupId>org.springframework.boot</groupId>
           <artifactId>spring-boot-configuration-processor</artifactId>
       </dependency>
       <dependency>
           <groupId>org.projectlombok</groupId>
           <artifactId>lombok</artifactId>
           <scope>provided</scope>
       </dependency>
       <dependency>
           <groupId>org.springframework.boot</groupId>
           <artifactId>spring-boot-starter-test</artifactId>
           <scope>test</scope>
       </dependency>
   </dependencies>
   <dependencyManagement>
       <dependencies>
           <dependency>
               <groupId>org.springframework.ai</groupId>
               <artifactId>spring-ai-bom</artifactId>
               <version>${spring-ai.version}</version>
               <type>pom</type>
               <scope>import</scope>
           </dependency>
       </dependencies>
   </dependencyManagement>
   <build>
       <plugins>
           <plugin>
               <groupId>org.apache.maven.plugins</groupId>
               <artifactId>maven-compiler-plugin</artifactId>
               <configuration>
                   <annotationProcessorPaths>
                       <path>
                           <groupId>org.projectlombok</groupId>
                           <artifactId>lombok</artifactId>
                       </path>
                   </annotationProcessorPaths>
               </configuration>
           </plugin>
           <plugin>
               <groupId>org.springframework.boot</groupId>
               <artifactId>spring-boot-maven-plugin</artifactId>
               <configuration>
                   <excludes>
                       <exclude>
                           <groupId>org.projectlombok</groupId>
                           <artifactId>lombok</artifactId>
                       </exclude>
                   </excludes>
               </configuration>
           </plugin>
       </plugins>
   </build>
   <repositories>
       <repository>
           <id>spring-snapshots</id>
           <name>Spring Snapshots</name>
           <url>https://repo.spring.io/snapshot</url>
           <releases>
               <enabled>false</enabled>
           </releases>
       </repository>
   </repositories>
   <pluginRepositories>
       <pluginRepository>
           <id>spring-snapshots</id>
           <name>Spring Snapshots</name>
           <url>https://repo.spring.io/snapshot</url>
           <releases>
               <enabled>false</enabled>
           </releases>
       </pluginRepository>
   </pluginRepositories>
</project>
  1. application.properties
spring.application.name=mcp-test  
spring.ai.mcp.server.enabled=true  
spring.ai.mcp.server.stdio=false  
debug=true
  1. 开发Tools,这里以获取服务器信息为例子
@Service  
public class SystemService {  
  
    @Tool(name = "服务器信息",description = "获取当前服务器信息")  
    public Map<String,String> getOsInfo(@ToolParam(required = true,description = "name") String name) {  
        return Map.of("user",name,"os", System.getProperty("os.name"),"version" , System.getProperty("os.version") , "arch" , System.getProperty("os.arch"));  
    }  
}
  1. 配置服务端要暴露的资源、提示词和工具等等信息
package cn.lishiyuan.mcptest.config;  
  
import cn.lishiyuan.mcptest.service.SystemService;  
import io.modelcontextprotocol.server.McpServerFeatures;  
import io.modelcontextprotocol.server.McpSyncServerExchange;  
import io.modelcontextprotocol.spec.McpSchema;  
import org.slf4j.Logger;  
import org.slf4j.LoggerFactory;  
import org.springframework.ai.tool.ToolCallbackProvider;  
import org.springframework.ai.tool.method.MethodToolCallbackProvider;  
import org.springframework.context.annotation.Bean;  
import org.springframework.context.annotation.Configuration;  
import org.springframework.util.MimeTypeUtils;  
  
import java.nio.file.Files;  
import java.nio.file.Path;  
import java.util.List;  
import java.util.function.BiConsumer;  
  
@Configuration  
public class McpConfig {  
  
    private static final Logger log = LoggerFactory.getLogger(McpConfig.class);  
  
    @Bean  
    public ToolCallbackProvider myTools(SystemService systemService) {  
        return MethodToolCallbackProvider.builder().toolObjects(systemService).build();  
    }  
  
    @Bean  
    public List<McpServerFeatures.SyncResourceSpecification> myResources() {  
        var systemInfoResource = new McpSchema.Resource("data://poi.json","POI数据","获取POI数据", MimeTypeUtils.APPLICATION_JSON_VALUE,new McpSchema.Annotations(List.of(McpSchema.Role.ASSISTANT),0.6));  
  
        var resourceSpecification = new McpServerFeatures.SyncResourceSpecification(systemInfoResource, (exchange, request) -> {  
            try {  
                System.out.println("read ==== resource");  
                String jsonContent = Files.readString(Path.of("C:\\Users\\lee\\Desktop\\data.json"));  
                return new McpSchema.ReadResourceResult(  
                        List.of(new McpSchema.TextResourceContents(request.uri(), "application/json", jsonContent)));  
            }  
            catch (Exception e) {  
                throw new RuntimeException("Failed to generate system info", e);  
            }  
        });  
        return List.of(resourceSpecification);  
    }  
  
    @Bean  
    public List<McpServerFeatures.SyncPromptSpecification> myPrompts() {  
        var prompt = new McpSchema.Prompt("greeting", "A friendly greeting prompt",  
                List.of(new McpSchema.PromptArgument("name", "The name to greet", true)));  
  
        var promptSpecification = new McpServerFeatures.SyncPromptSpecification(prompt, (exchange, getPromptRequest) -> {  
            String nameArgument = (String) getPromptRequest.arguments().get("name");  
            if (nameArgument == null) { nameArgument = "friend"; }  
            var userMessage = new McpSchema.PromptMessage(McpSchema.Role.USER, new McpSchema.TextContent("Hello " + nameArgument + "! How can I assist you today?"));  
            return new McpSchema.GetPromptResult("A personalized greeting message", List.of(userMessage));  
        });  
  
        return List.of(promptSpecification);  
    }  
  
    @Bean  
    public BiConsumer<McpSyncServerExchange, List<McpSchema.Root>> rootsChangeHandler() {  
        return (exchange, roots) -> {  
            log.info("Registering root resources: {}", roots);  
        };  
    }  
}

启动服务之后我们建立我们需要在我们客户端配置mcp server信息才能使用。我这里直接配置到一个支持MCP的插件(Cline,一个vscode插件)上直接使用。包括两个部分,大模型配置和MCP服务配置,大模型可以使用使用deepseek的token。我这里只给出mcp服务的配置。

{
  "mcpServers": {
    "mcp-server": {
      "url": "http://localhost:8080/sse",
      "disabled": false
    }
  }
}

注意:默认端点路径就是/sse,在我们的服务没有做额外配置的时候直接使用这个路径。

此时我们输入提示词:给出mcp-server服务器的服务器信息。客户端会将列出的tools提供给大模型参考。大模型分析出需要调用我们写好的工具需要一个参数:

1. The user is asking for server information from the mcp-server.
2. From the MCP SERVERS section, I can see mcp-server has a tool called "服务器信息" that can get server information.
3. The tool requires a "name" parameter in its input schema. Since the user didn't specify a name, I'll need to ask for it.
4. I'll use the ask_followup_question tool to request the server name parameter.

Cline has a question:

Please provide the server name you want to get information for

此时我们需要在此输入参数name,我这里提供的是AAAA。然后大模型会尝试提供客户端(Cline)调用mcp-server:

1. The user provided the server name "AAAA" for which they want information.
2. I'll use the use_mcp_tool with the "服务器信息" tool from mcp-server to get the server information.
3. The required parameter "name" will be set to "AAAA" as provided by the user.

Cline wants to use a tool on the `mcp-server` MCP server:

服务器信息,获取当前服务器信息

Arguments

{
  "name": "AAAA"
}

我们许可之后大模型就会通过客户端调用我们写好的工具,获取到服务器信息:

{"version":"10.0","user":"AAAA","os":"Windows 11","arch":"amd64"}

然后大模型会将结果总结成用户能看懂的内容:

1. The MCP server returned the requested information for server "AAAA".
2. The information includes version, user, OS and architecture details.
3. I'll present this information to the user using attempt_completion since the task is complete.

Task Completed

Server information for AAAA:

- Version: 10.0
- User: AAAA
- OS: Windows 11
- Architecture: amd64

注意:由于我们给的提示词是给出mcp-server服务器的服务器信息,大模型有时候会将mcp-server直接解析为name,此时我们直接许可调用就可以直接调用服务端工具了。

建立连接

常规情况下我们需要使用inspector调试我们的MCP服务器。为了展示完整流程,这里使用ApiFox(和Postman一样)。

首先通过sse endpoint建立连接。在spring ai下面默认是/sse路径,服务端的数据与通知由该端点发送给客户端。

http://lcoalhost:8080/sse

在连接后会建立会话,服务端会自动发送sse message endpoint端点信息到用户这边。在spring ai下面默认是/mcp/message?sesssionId={id},用户通过该端点发送消息到服务端。

/mcp/message?sessionId=4a9cfce7-f58c-4d4c-aba5-8c72166ef5b2

注意:http://lcoalhost:8080/sse是个长连接,开启后直到关闭连接才会断开。

此时双方通信建立了,需要握手初始化才能正式连接。 握手过程如下:

  1. 客户端通过/mcp/message?sessionId=4a9cfce7-f58c-4d4c-aba5-8c72166ef5b2发生初始化信息到服务端:
{
    "jsonrpc": "2.0",
    "method": "initialize",
    "id": "9c535818-0",
    "params": {
        "protocolVersion": "2024-11-05",
        "capabilities": {
            "roots": {
                "listChanged": true
            }
        },
        "clientInfo": {
            "name": "Java SDK MCP Client",
            "version": "1.0.0"
        }
    }
}

注意:此时需要携带客户端能力到服务端,协议版本protocolVersion要要一致。

  1. 服务端通过/sse端点将服务端初始化信息返回给客户端
{

    "jsonrpc": "2.0",
    "id": "9c535818-0",
    "result": {
        "protocolVersion": "2024-11-05",
        "capabilities": {
            "completions": {},
            "logging": {},
            "prompts": {
                "listChanged": true
            },
            "resources": {
                "subscribe": false,
                "listChanged": true
            },
            "tools": {
                "listChanged": true
            }
        },
        "serverInfo": {
            "name": "mcp-server",
            "version": "1.0.0"
        }
    }

}

注意:客户端发送数据给服务器数据的端点和服务端给客户端发数据的端点是不同的,需要通过Id来标识同一个请求。服务端会携带服务端数据给到客户端。

  1. 客户端通过端点/mcp/message?sessionId=4a9cfce7-f58c-4d4c-aba5-8c72166ef5b2发送初始化完成消息到服务端
{
    "jsonrpc": "2.0",
    "method": "notifications/initialized"
}

此时我们就已经建立连接了,可以开始我我们的操作了。

处理业务

这里已几个常见的操作演示

  • ping消息,上报心跳。

端点还是同一个:/mcp/message?sessionId=4a9cfce7-f58c-4d4c-aba5-8c72166ef5b2

{
  "jsonrpc": "2.0",
  "method": "ping",
  "id": "16783616132122"
}

服务端会通过/sse返回响应:

{
    "jsonrpc": "2.0",
    "id": "16783616132122",
    "result": {}
}
  • 列出工具
{
    "jsonrpc": "2.0",
    "id": "53000020191023873",
    "method": "tools/list",
    "params":{}
}

服务端会返回:

{
    "jsonrpc": "2.0",
    "id": "53000020191023873",
    "result": {
        "tools": [
            {
                "name": "服务器信息",
                "description": "获取当前服务器信息",
                "inputSchema": {
                    "type": "object",
                    "properties": {
                        "name": {
                            "type": "string",
                            "description": "name"
                        }
                    },
                    "required": [
                        "name"
                    ],
                    "additionalProperties": false
                }
            }
        ]
    }
}
  • 调用工具
{

    "jsonrpc": "2.0",
    "id": "53000020191023",
    "method": "tools/call",
    "params":{
        "name":"服务器信息",
        "arguments":{
            "name":"AAAA"
        }
    }
}

服务端会响应:

{
    "jsonrpc": "2.0",
    "id": "53000020191023",
    "result": {
        "content": [
            {
                "type": "text",
                "text": "{\"version\":\"10.0\",\"user\":\"AAAA\",\"os\":\"Windows 11\",\"arch\":\"amd64\"}"

            }
        ],
        "isError": false
    }
}
  • 读取资源
{
    "jsonrpc": "2.0",
    "id": "53000020191",
    "method": "resources/read",
    "params":{
       "uri": "data://poi.json"
    }
}

服务端返回:

{

    "jsonrpc": "2.0",
    "id": "53000020191",
    "result": {
        "contents": [
            {
                "uri": "data://poi.json",
                "mimeType": "application/json",
                "text": "{\r\n    \"suggestion\": {\r\n        \"keywords\": [],\r\n        \"cities\": []\r\n    },\r\n    \"count\": \"37\",\r\n    \"infocode\": \"10000\",\r\n    \"pois\": [\r\n        {\r\n            \"parent\": \"B0016121E8\",\r\n            \"distance\": [],\r\n            \"pcode\": \"120000\",\r\n            \"importance\": [],\r\n            \"biz_ext\": {\r\n                \"cost\": \"70.00\",\r\n                \"rating\": \"4.4\",\r\n                \"meal_ordering\": \"0\"\r\n            },\r\n            \"recommend\": \"0\",\r\n            \"type\": \"餐饮服务;中餐厅;综合酒楼\",\r\n            \"photos\": [\r\n                {\r\n                    \"title\": [],\r\n                    \"url\": \"http://store.is.autonavi.com/showpic/3dab10d3ca24f1686d965812e1850ea1\"\r\n                },\r\n                {\r\n                    \"title\": [],\r\n                    \"url\": \"http://store.is.autonavi.com/showpic/bafa40eea088a87e8b37312e6d321472\"\r\n                },\r\n                {\r\n                    \"title\": [],\r\n                    \"url\": \"http://store.is.autonavi.com/showpic/3d16a234d4c6c7ebd12d6396a15ff0e7\"\r\n                }\r\n            ],\r\n            \"discount_num\": \"0\",\r\n            \"gridcode\": \"5817512920\",\r\n            \"typecode\": \"050101\",\r\n            \"shopinfo\": \"1\",\r\n            \"poiweight\": [],\r\n            \"citycode\": \"022\",\r\n            \"adname\": \"河东区\",\r\n            \"children\": [],\r\n            \"alias\": [],\r\n            \"tel\": \"022\",\r\n            \"id\": \"B0GRR5L15E\",\r\n            \"tag\": [],\r\n            \"event\": [],\r\n            \"entr_location\": [],\r\n            \"indoor_map\": \"0\",\r\n            \"email\": [],\r\n            \"timestamp\": \"2025-04-21 07:43:36\",\r\n            \"website\": [],\r\n            \"address\": \"大直沽街六纬路万达公馆底商1716号\",\r\n            \"adcode\": \"120102\",\r\n            \"pname\": \"天津市\",\r\n            \"biz_type\": \"diner\",\r\n            \"cityname\": \"天津市\",\r\n            \"postcode\": [],\r\n            \"match\": \"0\",\r\n            \"business_area\": \"大直沽\",\r\n            \"indoor_data\": {\r\n                \"cmsid\": [],\r\n                \"truefloor\": [],\r\n                \"cpid\": [],\r\n                \"floor\": []\r\n            },\r\n            \"childtype\": \"320\",\r\n            \"exit_location\": [],\r\n            \"name\": \"姐弟烧烤(六纬路店)\",\r\n            \"location\": \"117.239013,39.105635\",\r\n            \"shopid\": [],\r\n            \"navi_poiid\": [],\r\n            \"groupbuy_num\": \"0\"\r\n        }\r\n    ],\r\n    \"status\": \"1\",\r\n    \"info\": \"OK\"\r\n}"
            }
        ]
    }
}

总之,所有的操作都是遵照这么一个模式,客户端使用端点/mcp/message?sessionId={sessionId}向服务端发送请求,服务端通过sse端点响应结果。使用jsonrpc格式调用,通过method区分不同操作。

源码

modelcontextprotocol组织提供了Java类库。而springai对其进行了封装,使其可以自动配置。

和其他应用一样。它也是包含一个配置过程和一个执行过程。

配置过程

将服务器能力(tools工具,resources资源,prompts提示词)放入容器(bean放入容器):上面McpConfig这个Configuration类的配置就是在做这个。

  
@Configuration  
public class McpConfig {  
  
    private static final Logger log = LoggerFactory.getLogger(McpConfig.class);  
  
    @Bean  
    public ToolCallbackProvider myTools(SystemService systemService) {  
        ...
    }  
  
    @Bean  
    public List<McpServerFeatures.SyncResourceSpecification> myResources() {  
       ...
    }  
  
    @Bean  
    public List<McpServerFeatures.SyncPromptSpecification> myPrompts() {  
      ...
    }  
  
}

自动配置入口在McpServerAutoConfiguration,这个自动配置在本案例中要求McpWebMvcServerAutoConfiguration先完成。内部实际上就是两步操作:

  • 将WebMvcSseServerTransportProvider实例放入容器,该类也是mcp官方提供的。
  • 将两个已绑定处理器的端点放入容器以加入spring mvc路径。默认是/sse和/mcp/message,就是前文用来通信的端点,可以通过properties修改
  
@AutoConfiguration  
@ConditionalOnClass({WebMvcSseServerTransportProvider.class})  
@ConditionalOnMissingBean({McpServerTransportProvider.class})  
@Conditional({McpServerStdioDisabledCondition.class})  
public class McpWebMvcServerAutoConfiguration {  
    public McpWebMvcServerAutoConfiguration() {  
    }  
  
    @Bean  
    @ConditionalOnMissingBean    public WebMvcSseServerTransportProvider webMvcSseServerTransportProvider(ObjectProvider<ObjectMapper> objectMapperProvider, McpServerProperties serverProperties) {  
        ObjectMapper objectMapper = (ObjectMapper)objectMapperProvider.getIfAvailable(ObjectMapper::new);  
        return new WebMvcSseServerTransportProvider(objectMapper, serverProperties.getBaseUrl(), serverProperties.getSseMessageEndpoint(), serverProperties.getSseEndpoint());  
    }  
  
    @Bean  
    public RouterFunction<ServerResponse> mvcMcpRouterFunction(WebMvcSseServerTransportProvider transportProvider) {  
        return transportProvider.getRouterFunction();  
    }  
}

路径和处理器绑定发生在构造WebMvcSseServerTransportProvider过程中。


public WebMvcSseServerTransportProvider(ObjectMapper objectMapper, String baseUrl, String messageEndpoint, String sseEndpoint) {  
    this.sessions = new ConcurrentHashMap();  
    this.isClosing = false;  
    Assert.notNull(objectMapper, "ObjectMapper must not be null");  
    Assert.notNull(baseUrl, "Message base URL must not be null");  
    Assert.notNull(messageEndpoint, "Message endpoint must not be null");  
    Assert.notNull(sseEndpoint, "SSE endpoint must not be null");  
    this.objectMapper = objectMapper;  
    this.baseUrl = baseUrl;  
    this.messageEndpoint = messageEndpoint;  
    this.sseEndpoint = sseEndpoint;  
    // 绑定处理器
    this.routerFunction = RouterFunctions.route().GET(this.sseEndpoint, this::handleSseConnection).POST(this.messageEndpoint, this::handleMessage).build();  
}

其实整个过程就是通过配置构建和初始化McpAsyncServer或者McpSyncServer实例的过程。McpAsyncServer、McpSyncServer都是mcp官方类库提供的类。本案例中,是McpSyncServer实例。

前面我们已经构造了WebMvcSseServerTransportProvider实例,它是用来通信的。现在我们要将本服务支持的能力,工具、资源、提示词等等组合成规范,最终构建出McpSyncServer。

  
@AutoConfiguration(  
    after = {McpWebMvcServerAutoConfiguration.class, McpWebFluxServerAutoConfiguration.class}  
)  
@ConditionalOnClass({McpSchema.class, McpSyncServer.class})  
@EnableConfigurationProperties({McpServerProperties.class})  
@ConditionalOnProperty(  
    prefix = "spring.ai.mcp.server",  
    name = {"enabled"},  
    havingValue = "true",  
    matchIfMissing = true  
)  
public class McpServerAutoConfiguration {
    @Bean  
@ConditionalOnProperty(  
    prefix = "spring.ai.mcp.server",  
    name = {"type"},  
    havingValue = "SYNC",  
    matchIfMissing = true  
)  
    public McpSyncServer mcpSyncServer(McpServerTransportProvider transportProvider, McpSchema.ServerCapabilities.Builder capabilitiesBuilder, McpServerProperties serverProperties, ObjectProvider<List<McpServerFeatures.SyncToolSpecification>> tools, ObjectProvider<List<McpServerFeatures.SyncResourceSpecification>> resources, ObjectProvider<List<McpServerFeatures.SyncPromptSpecification>> prompts, ObjectProvider<List<McpServerFeatures.SyncCompletionSpecification>> completions, ObjectProvider<BiConsumer<McpSyncServerExchange, List<McpSchema.Root>>> rootsChangeConsumers, List<ToolCallbackProvider> toolCallbackProvider) {  
        McpSchema.Implementation serverInfo = new McpSchema.Implementation(serverProperties.getName(), serverProperties.getVersion());  
        McpServer.SyncSpecification serverBuilder = McpServer.sync(transportProvider).serverInfo(serverInfo);  
        if (serverProperties.getCapabilities().isTool()) {  
            logger.info("Enable tools capabilities, notification: " + serverProperties.isToolChangeNotification());  
            capabilitiesBuilder.tools(serverProperties.isToolChangeNotification());  
            List<McpServerFeatures.SyncToolSpecification> toolSpecifications = new ArrayList(tools.stream().flatMap(Collection::stream).toList());  
            List<ToolCallback> providerToolCallbacks = toolCallbackProvider.stream().map((pr) -> {  
                return List.of(pr.getToolCallbacks());  
            }).flatMap(Collection::stream).filter((fc) -> {  
                return fc instanceof ToolCallback;  
            }).map((fc) -> {  
                return fc;  
            }).toList();  
            toolSpecifications.addAll(this.toSyncToolSpecifications(providerToolCallbacks, serverProperties));  
            if (!CollectionUtils.isEmpty(toolSpecifications)) {  
                serverBuilder.tools(toolSpecifications);  
                logger.info("Registered tools: " + toolSpecifications.size());  
            }  
        }  
      
        List completionSpecifications;  
        if (serverProperties.getCapabilities().isResource()) {  
            logger.info("Enable resources capabilities, notification: " + serverProperties.isResourceChangeNotification());  
            capabilitiesBuilder.resources(false, serverProperties.isResourceChangeNotification());  
            completionSpecifications = resources.stream().flatMap(Collection::stream).toList();  
            if (!CollectionUtils.isEmpty(completionSpecifications)) {  
                serverBuilder.resources(completionSpecifications);  
                logger.info("Registered resources: " + completionSpecifications.size());  
            }  
        }  
      
        if (serverProperties.getCapabilities().isPrompt()) {  
            logger.info("Enable prompts capabilities, notification: " + serverProperties.isPromptChangeNotification());  
            capabilitiesBuilder.prompts(serverProperties.isPromptChangeNotification());  
            completionSpecifications = prompts.stream().flatMap(Collection::stream).toList();  
            if (!CollectionUtils.isEmpty(completionSpecifications)) {  
                serverBuilder.prompts(completionSpecifications);  
                logger.info("Registered prompts: " + completionSpecifications.size());  
            }  
        }  
      
        if (serverProperties.getCapabilities().isCompletion()) {  
            logger.info("Enable completions capabilities");  
            capabilitiesBuilder.completions();  
            completionSpecifications = completions.stream().flatMap(Collection::stream).toList();  
            if (!CollectionUtils.isEmpty(completionSpecifications)) {  
                serverBuilder.completions(completionSpecifications);  
                logger.info("Registered completions: " + completionSpecifications.size());  
            }  
        }  
      
        rootsChangeConsumers.ifAvailable((consumer) -> {  
            serverBuilder.rootsChangeHandler((exchange, roots) -> {  
                consumer.accept(exchange, roots);  
            });  
            logger.info("Registered roots change consumer");  
        });  
        serverBuilder.capabilities(capabilitiesBuilder.build());  
        serverBuilder.instructions(serverProperties.getInstructions());  
        serverBuilder.requestTimeout(serverProperties.getRequestTimeout());  
        // 添加完能力和配置完成后就构建服务类
        return serverBuilder.build();  
    }
}

而McpSyncServer实际上是将功能委托给McpAsyncServer实现的,McpAsyncServer构造时会绑定各种method的处理器。

McpAsyncServer(McpServerTransportProvider mcpTransportProvider, ObjectMapper objectMapper,  
       McpServerFeatures.Async features, Duration requestTimeout,  
       McpUriTemplateManagerFactory uriTemplateManagerFactory) {  
    this.mcpTransportProvider = mcpTransportProvider;  
    this.objectMapper = objectMapper;  
    this.serverInfo = features.serverInfo();  
    this.serverCapabilities = features.serverCapabilities();  
    this.instructions = features.instructions();  
    this.tools.addAll(features.tools());  
    this.resources.putAll(features.resources());  
    this.resourceTemplates.addAll(features.resourceTemplates());  
    this.prompts.putAll(features.prompts());  
    this.completions.putAll(features.completions());  
    this.uriTemplateManagerFactory = uriTemplateManagerFactory;  
  
    Map<String, McpServerSession.RequestHandler<?>> requestHandlers = new HashMap<>();  
    // 工具处理器
    if (this.serverCapabilities.tools() != null) {  
       requestHandlers.put(McpSchema.METHOD_TOOLS_LIST, toolsListRequestHandler());  
       requestHandlers.put(McpSchema.METHOD_TOOLS_CALL, toolsCallRequestHandler());  
    }  
  
    // 资源处理器
    if (this.serverCapabilities.resources() != null) {  
       requestHandlers.put(McpSchema.METHOD_RESOURCES_LIST, resourcesListRequestHandler());  
       requestHandlers.put(McpSchema.METHOD_RESOURCES_READ, resourcesReadRequestHandler());  
       requestHandlers.put(McpSchema.METHOD_RESOURCES_TEMPLATES_LIST, resourceTemplateListRequestHandler());  
    }  
  
    // 提示词处理器
    if (this.serverCapabilities.prompts() != null) {  
       requestHandlers.put(McpSchema.METHOD_PROMPT_LIST, promptsListRequestHandler());  
       requestHandlers.put(McpSchema.METHOD_PROMPT_GET, promptsGetRequestHandler());  
    }  
  
    // 日志请求处理器
    if (this.serverCapabilities.logging() != null) {  
       requestHandlers.put(McpSchema.METHOD_LOGGING_SET_LEVEL, setLoggerRequestHandler());  
    }  
  
    // 补全
    if (this.serverCapabilities.completions() != null) {  
       requestHandlers.put(McpSchema.METHOD_COMPLETION_COMPLETE, completionCompleteRequestHandler());  
    }  

    Map<String, McpServerSession.NotificationHandler> notificationHandlers = new HashMap<>();  
    // 初始化完成不需要回应
    notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_INITIALIZED, (exchange, params) -> Mono.empty());  
  
    List<BiFunction<McpAsyncServerExchange, List<McpSchema.Root>, Mono<Void>>> rootsChangeConsumers = features  
       .rootsChangeConsumers();  
  
    if (Utils.isEmpty(rootsChangeConsumers)) {  
       rootsChangeConsumers = List.of((exchange, roots) -> Mono.fromRunnable(() -> logger  
          .warn("Roots list changed notification, but no consumers provided. Roots list changed: {}", roots)));  
    }  
    
    notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_ROOTS_LIST_CHANGED,  
          asyncRootsListChangedNotificationHandler(rootsChangeConsumers));  
  
    mcpTransportProvider.setSessionFactory(  
          transport -> new McpServerSession(UUID.randomUUID().toString(), requestTimeout, transport,  
                this::asyncInitializeRequestHandler, Mono::empty, requestHandlers, notificationHandlers));  
}

注意:McpSyncServer和McpAsyncServer都有addResource、addTool等等修改服务状态的方法,我们可以运行时动态增删,会通过协议约定的natifications通知到客户端(如果开启了能力的话)。

此时就配置完成了,可以提供服务了。

处理过程

前文提到,WebMvcSseServerTransportProvider已经绑定了端点和处理器。

那么对于sse端点首先需要接受客户端连接请求,生成会话:

  private ServerResponse handleSseConnection(ServerRequest request) {  
    if (this.isClosing) {  
       return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).body("Server is shutting down");  
    }  
    // 生成sessionId
    String sessionId = UUID.randomUUID().toString();  
    logger.debug("Creating new SSE connection for session: {}", sessionId);  
  
    // Send initial endpoint event  
    try {  
      // sse返回
       return ServerResponse.sse(sseBuilder -> {  
          // 已完成和超时都移除session会话
          sseBuilder.onComplete(() -> {  
             logger.debug("SSE connection completed for session: {}", sessionId);  
             sessions.remove(sessionId);  
          });
          sseBuilder.onTimeout(() -> {  
             logger.debug("SSE connection timed out for session: {}", sessionId);  
             sessions.remove(sessionId);  
          });  
         // 会话对象
          WebMvcMcpSessionTransport sessionTransport = new WebMvcMcpSessionTransport(sessionId, sseBuilder);  
          McpServerSession session = sessionFactory.create(sessionTransport); 
          // 加入待完成握手的map 
          this.sessions.put(sessionId, session);  
  
          try {  
              // 返回会话端点
             sseBuilder.id(sessionId)  
                .event(ENDPOINT_EVENT_TYPE)  
                .data(this.baseUrl + this.messageEndpoint + "?sessionId=" + sessionId);  
          }  
          catch (Exception e) {  
             logger.error("Failed to send initial endpoint event: {}", e.getMessage());  
             sseBuilder.error(e);  
          }  
       }, Duration.ZERO);  
    }  
    catch (Exception e) {  
       logger.error("Failed to send initial endpoint event to session {}: {}", sessionId, e.getMessage());  
       sessions.remove(sessionId);  
       return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR).build();  
    }  
}

对于/mcp/message(它是一个正常的http请求),它需要接受客户端请求,将请求分发给不同对应的会话对象处理:

handleMessage(ServerRequest request) {  
    if (this.isClosing) {  
       return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).body("Server is shutting down");  
    }  
  
    if (request.param("sessionId").isEmpty()) {  
       return ServerResponse.badRequest().body(new McpError("Session ID missing in message endpoint"));  
    }  
    // 获取会话
    String sessionId = request.param("sessionId").get();  
    McpServerSession session = sessions.get(sessionId);  
  
    if (session == null) {  
       return ServerResponse.status(HttpStatus.NOT_FOUND).body(new McpError("Session not found: " + sessionId));  
    }  
  
    try {  
       String body = request.body(String.class);  
       McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(objectMapper, body);  
  
       // 处理请求,如果是Asyn
       session.handle(message).block(); // Block for WebMVC compatibility  
  
       return ServerResponse.ok().build();  
    }  
    catch (IllegalArgumentException | IOException e) {  
       logger.error("Failed to deserialize message: {}", e.getMessage());  
       return ServerResponse.badRequest().body(new McpError("Invalid message format"));  
    }  
    catch (Exception e) {  
       logger.error("Error handling message: {}", e.getMessage());  
       return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new McpError(e.getMessage()));  
    }  
}

会话对象会根据请求method不同而分发给不同的handle处理:

private final InitRequestHandler initRequestHandler;  
  
private final InitNotificationHandler initNotificationHandler;  
  
private final Map<String, RequestHandler<?>> requestHandlers;  
  
private final Map<String, NotificationHandler> notificationHandlers;


private Mono<McpSchema.JSONRPCResponse> handleIncomingRequest(McpSchema.JSONRPCRequest request) {  
    return Mono.defer(() -> {  
       Mono<?> resultMono;  
       if (McpSchema.METHOD_INITIALIZE.equals(request.method())) {  
          // 处理初始化请求(握手请求)
          McpSchema.InitializeRequest initializeRequest = transport.unmarshalFrom(request.params(),  
                new TypeReference<McpSchema.InitializeRequest>() {  
                });  
  
          this.state.lazySet(STATE_INITIALIZING);  
          this.init(initializeRequest.capabilities(), initializeRequest.clientInfo());  
          resultMono = this.initRequestHandler.handle(initializeRequest);  
       }  
       else {  
          // 获取处理器处理
           var handler = this.requestHandlers.get(request.method());  
          if (handler == null) {  
             MethodNotFoundError error = getMethodNotFoundError(request.method());  
             return Mono.just(new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), null,  
                   new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.METHOD_NOT_FOUND,  
                         error.message(), error.data())));  
          }  
  
          resultMono = this.exchangeSink.asMono().flatMap(exchange -> handler.handle(exchange, request.params()));  
       }  
       return resultMono  
          .map(result -> new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), result, null))  
          .onErrorResume(error -> Mono.just(new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(),  
                null, new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INTERNAL_ERROR,  
                      error.getMessage(), null)))); // TODO: add error message  
                                              // through the data field    });  
}

总的来看mcp是一个简单的协议。

参考:

  1. Specification - Model Context Protocol

标签: MCP

评论已关闭