《AI MCP Gateway》第1-1节:网关需求分析

大家好,我是技术UP主小傅哥。

今天是我们 《AIMCPGateway网关服务系统》 项目学习的第1节课程,小傅哥会带着大家,以互联网公司正规的承接产品需求到开发部署上线的流程,带着大家以第一主人公视角的方式进行学习。这样既可以保证你学习到项目内容,也能了解到公司里正规的开发模式,以后进入到公司也可以很好的融入团队,承接项目需求。加油💪🏻!让我们开启新项目之旅!

一、本章诉求
站在产品视角,分析 AI MCP Gateway 网关服务系统,这样一个产品功能需求的背景、诉求和目的。

需求是怎么来的呢?

作为互联网公司里的研发,我们通常都是从业务产品经理那承接需求,评审 PRD 文档,之后研发进行详细设计,给出设计文档在评审,评审通过后开始进入编码开发,以及推进后续的测试和上线。

那么,还有一类需求是不需要业务产品经理的,往往是纯技术产品经理或者研发和技术一起驱动的,这类的需求包括,系统的重构、组件的设计、框架的升级等。他们虽然也是需求,但不直接影响到产品经理的业务需求,所以往往由技术产品或者研发推动。

所以,像是 AI MCP Gateway 网关服务系统,是附属于 AI 应用开发部门的项目,一般是由 AI 技术产品经理推动,这类产品经理,往往比纯业务产品经理要多一些技术储备的。虽然他们编码不熟练,但对技术是有不错的储备,可以很好的规划和推进技术产品。

二、项目背景
MCP 协议的定义和发布,改变了我们使用 AI 的形态。

LLM 大模型第一次以 chatgpt 问世时,就足以震撼到我们。它具备各行各业的知识储备,可以识别人类语言,可以完成逻辑关系推理。在很长一段时间里,我们都是把问题抛给大模型,大模型回答后,我们在根据它的回复,复制粘贴到具体的场景进行处理。

但大模型不能无中生有,不能根据你的提问,直接帮你完成最终的处理。如;帮我查询最近一天内数据库账户表写入的数据量,这是做不到的,因为大模型并不能直接连接到我们的数据库或者其他任何服务。

在 2023 年前,如果想让大模型和程序代码互动,我们经常做的方式是写提示词,告诉大模型我的程序具备什么样的能力,有什么样的接口格式,你需要分析我的问题,并按照最终的执行给出结构化参数。json 格式为 {“function”:”query_user_account”,”arguments”:{“sc”:”渠道值”}} 之后再根据大模型的结果调用对应的程序方法。不过以上方式执行起来的误差较大,经常是需要慢慢微调提示词。

到了 2023年6月,OpenAI 发布 gpt-3.5-turbo-0613 模型,给 API 的调用提供了 Function Calling 的能力,只需要在请求 API 的时候传入 functions 参数,告知大模型本地有哪些函数方法可以被调用使用即可。到了 2023年11月,gpt-3.5-turbo-1106 发布,这回开始支持 tools 函数,我们可以在提问的时候,进行网络检索,天气对接。但是这些功能代码, 都被嵌入到大模型调用中,编写起来耦合在一块,维护起来很麻烦。

直至到2024年11月,Anthropic 发布了 MCP 协议,将 tools 的封装单独抽离到独立的服务,这种服务称之为 MCP 服务,然后通过远程协议的模式提供给大模型调用。而 MCP 协议的主要作用是将服务转换为可以被大模型识别的格式结构(后面章节会细分析 MCP 协议)。

MCP为何重要?

MCP 在构建或与 AI 应用程序或代理集成时减少了开发时间和复杂性。

MCP 提供对数据源、工具和应用程序生态系统的访问,这将增强功能并改善最终用户体验。

MCP 可产生功能更强大的 AI 应用程序或代理,它们可以在必要时访问您的数据并代表您采取行动。

img.png

自从有了 MCP 协议以后,市面上开始出现各类的 AI Agent 智能体服务,如被大家熟知的 Dify、Corz、Trae.ai/Cursor 等。这些都是通用的智能体服务,可以解决市面上大部分通用场景问题。

到这以后,各个公司开启了自己的 AI Agent 智能体实现,对公司里自身业务场景进行提效。如账户服务、交易订单、计息计罚、还款计划等,做成智能客户和AI运营工具,都可以为企业提效。那么这里就有一个问题,这些接口想被大模型识别,就要开发为 MCP 协议服务,只有 MCP 协议才能被大模型识别的协议,公司里的各项服务要想被大模型调用,就要编写为一个个的 MCP Server 服务端,这个工作量是非常大的。

img_1.png

所以,为了解决需要把大量接口开发成 MCP 协议服务的工作,我们可以设计一个 MCP 网关服务系统。它的核心工作原理在于,设计实现一套 MCP 协议的统一服务入口,管理和使用动态化注册的 HTTP/RPC 服务接口。MCP 协议的接口会被接入 MCP 服务的大模型,进行调用,核心过程包括;建立sse连接创建会话ID、初始化服务、获取 tools 工具列表、响应工具调用。这些内容,小傅哥会在后续的协议分析中带着大家学习。

综上,当我们有了一套 MCP 网关以后,就可以把我们各项所需的接口,快速🔜转换为 MCP 协议,配置给 AI Agent 智能体系统进行使用啦。这也是我们本次课程的最终目的。

三、产品方案

  1. 产品概述
    AI MCP Gateway 是一个基于 Model Context Protocol (MCP) 的智能网关服务系统,专为 AI 智能体应用提供高性能、可扩展的 MCP 实时通信基础设施。解决 AI 应用与各种数据源、工具和服务之间的连接复杂性,提供统一的接口和协议标准。

  2. 技术架构
    img_2.png

首先,核心目的为,为 AI 客户端的 MCP 服务协议使用诉求,提供便利性。后端服务接口可以在 MCP 网关进行注册,提供 MCP 协议类型接口。

之后,整个实现过程是以 MCP 协议 JSON-RPC2 通信结构,设计实现流式传输 Streamable HTTP 框架,完成 MCP 网关接口协议解析和消息处理。在消息处理过程中,则对注册进来的 http/rpc/… 接口进行调用。这部分会包括接口的描述性信息获取以及会话执行调用。更多细节会在后续的设计和编码中陆续体现

  1. 功能流程
    img_3.png

首先,是网关服务的注册,这部分提供管理端由用户进行接口配置注册操作,注册时把接口 http/rpc 进行详细拆解,录入到数据库中。在服务启动时,可以把接口进行服务加载,放到请求池中,等待调用。

之后,是 mcp 协议的2个调用过程,一个 {gatewayId}/mcp/sse 建立连接,创建出 sessionId 进行服务关联。首次建立连接后,会返回一个 /gd10090/mcp/message?sessionId=c2db-f64 之后在请求返回的地址,进行调用。

最后,在请求 {gatewayId}/mcp/message 进行消息处理,完成 initialze、tools/list、tools/call、resources/list,步骤调用。这些步骤需要从网关服务注册进来的接口中获取,而 tools/call 则是发起 http/rpc 接口调用。

《AI MCP Gateway》第1-2节:系统建模设计

一、本章诉求
按照 AI MCP Gateway 网关功能实现诉求,对系统服务进行建模设计。包括;用例图、四色建模拆解、工程模型分析。

二、架构选型
此项目会选择 DDD 领域驱动设计的方式,进行系统建模和功能设计。那为啥选择 DDD 架构呢?

因为 DDD 架构的四色建模方法可以更好的分析场景需求模型,同时它对应的六边形架构设计,非常合理的划分了微服务的各项单元功能。如;http、redis、mysql等都有自己的分层规划,同时又为领域服务与基础设施层的设计做了依赖倒置(这样的思想在Spring源码中很多),当我们在领域模块中实现服务时,就可以专心于各个模块的内聚服务了。

三、建模方法
在MVC的系统开发中,是很少做建模的事情的。因为它都是面向过程开发,过程就是一个流程需要什么就编写什么,但随着系统的复杂,会逐步发现,A流程、B流程、C流程,越来越多的流程加入,但这些流程中又存在了交叉、复用。也就是大家看到的 MVC 里的代码,总是一个功能,被复制了好多次,好多地方编写。所以使用 DDD 思想的情况,会对这些内容提前思考,进行建模设计,划分出领域。让一个个单元变得容易管理。

MVC 有点像睡大车店子(大通铺的一个大炕),DDD 划分了每家每户,每个床🛏睡着该睡的人。

DDD 的建模过程,是以一个用户为起点,通过行为命令,发起行为动作,串联整个业务。而这个用户的起点最初来自于用例图的分析。用例图是用户与系统交互的最简表示形式,展现了用户和与他相关的用例之间的关系。通过用例图,我们可以分析出所有的行为动作。

在 DDD 中用于完成用户的行为命令和动作分析的过程,是一个四色建模的过程,也称作风暴模型。在使用 DDD 的标准对系统建模前,一堆人要先了解 DDD 的操作手段,这样才能让产品、研发、测试、运营等了解业务的伙伴,都能在同一个语言下完成系统建模。

img_4.png

此图是整个四色建模的指导图,通过寻找领域事件,发起事件命令,完成领域事件的过程,完成 DDD 工程建模。

蓝色 - 决策命令,是用户发起的行为动作,如;开始签到、开始抽奖、查看额度等。

黄色 - 领域事件,过去时态描述。如;签到完成、抽奖完成、奖品发放完成。它所阐述的都是这个领域要完成的终态。

粉色 - 外部系统,如你的系统需要调用外部的接口完成流程。

红色 - 业务流程,用于串联决策命令到领域事件,所实现的业务流程。一些简单的场景则直接有决策命令到领域事件就可以了。

绿色 - 只读模型,做一些读取数据的动作,没有写库的操作。

棕色 - 领域对象,每个决策命令的发起,都是含有一个对应的领域对象。

敲黑板 综上,左下角的示意图。是一个用户,通过一个策略命令,使用领域对象,通过业务流程,完成2个领域事件,调用1次外部接口个过程。我们在整个 DDD 建模过程中,就是在寻找这些节点。

四、系统设计

  1. 用例图
    用例图(英语:use case diagram)是用户与系统交互的最简表示形式,展现了用户和与他相关的用例之间的关系。通过用例图,人们可以获知系统不同种类的用户和用例。用例图也经常和其他图表配合使用。

img_5.png

客户端视角;与 AI MCP Gateway 的交互,配置 MCP 服务后,会进行会话的发起与会话通信的建立。以及在建立会话过程,查看资源、获取资源、调用资源服务。

运营端视角;维护客户端的运营人员,需要完成网关资源的维护,注册服务资源,网关配置,鉴权配置,审核服务等。

这样的用例图,它有点项目目标结果驱动,让你知道最终的产品形态要提供什么功能。所以有这样的一个指导是可以很好的完成后续的建模设计的。

  1. 领域划分
    四色建模,指通过四种角色,循环领域事件的过程。四种角色分为;用户(命令)、对象、业务、事件。也就是一个人通过一个命令,携带一个对象,完成一个事件。完成事件的过程会需要使用到对应的聚合的业务。

这部分与用例图的衔接就是,用例图里找到的最终的事件的端点(xxx完成态),通过四色建模完成整个对象的产生过程。

img_6.png

蓝色,决策命令。这里我们找到协议注册、鉴权核验(ApiKey)、协议会话、发起会话、应答会话。

浅黄,领域事件。通过每一个命令完成最终的一个结果态。协议的存储、网关资源的确认、会话的简历、会话的应答。

四色建模完成的故事,是一种软件设计指引,也是思想。这样的思想落地的工程结构甚至可以是三层结构,不过我们在整个实践中发现,六边形架构或者整洁结构,才可以更好的承接 DDD 领域驱动设计完成的建模落地。

  1. 工程模型
    对于整个领域,在工程里的划分,我们放到一个模型图里体现下;
    img_7.png

trigger 是触发器层,我们把所有外部(http、rpc)调用我们,job调用,接收mq调用,都称之为触发动作。

case 是功能编排层,它的出现是为了减轻 trigger 层的压力。原本 trigger 要调用 domain 的领域层的各个方法完成逻辑串联,这部分拆分到 case 层来做处理,让 trigger 的 http 实现,只关心结果的封装和异常的打印。而这里的 case 层会有 mcp 的创建会话、消息处理的操作。后续还会随着功能开发增加其他 case 逻辑。

domain 是领域层,实现各个模块功能,包括;协议、鉴权、会话的处理。会话会有创建会话、查询会话和对会话的应答处理。

infrastructure 是基础设施层,所有资源和外部服务的链接,都会放到这里处理。这里还会实现 domain 领域层在 adapter 适配器内定义的接口,通过依赖倒置的方式实现。

基于以上分析,后续我们将开启系统工程的开发。

《AI MCP Gateway》第1-3节:网关协议表

一、本章诉求
设计 AI MCP Gateway 网关服务,所需的核心必备的数据库表。以用于存储,mcp 到 http 的协议转换处理,如,用户通过 sse 请求指定的网关 ID 对应的服务,则可以调用到对应的 http 服务接口。

二、功能设计
如图,库表驱动下的业务;

img_8.png

首先,以用户为入口,进行网关配置,他关心的是权限,以及配置的 http 怎么映射到 mcp 服务。

之后,mcp 服务对应的 http 接口能力和出入参字段,都需要给出对应的描述,这样 ai 调用 mcp 服务,才能拿到工具列表说明以及进行调用。

三、对照测试
为了让大家更好的理解,要做的 mcp 2 http 协议转换,我们做一个 mcp 服务,同时同出入参功能的设计一套 http 接口。之后来对比看,http 的出入参,在 mcp 协议是怎么映射的。

  1. 工程结构

在 ai-mcp-gateway-demo-mcp-server-test 下,提供一个 http 接口,它的出入参和 TestService 这个 mcp 服务是一致的。

你可以进入到 api.curl.sh、api.mcp.json 对比查看数据,来感受下他们的差异。

  1. 功能测试
    在咱们 ai mcp gateway 的学习中,我们通过 ai-mcp-gateway-demo-mcp-client-proxy 来分析协议。这里可以打开测试工程,以代理的方式,调用 ai-mcp-gateway-demo-mcp-server-test mcp 服务,拿到协议结构。

这里打开了2个工程,按照步骤启动服务,之后调试代码,来获取 mcp 通信协议结构。这个结构就是我放到 api.mcp.json 里面的数据。

有了这个结构的对比,可以思考到,要怎么设计mcp网关的库表,才能把一个 http 的接口,按照 mcp 的格式结构给组装出来。其实,设计库表的方式也很多,不非得局限到课程里设计。

  1. 库表设计
    3.1 网关配置表

img_9.png

这个表负责一个网关的总注册表,在这里可以维护网关的关闭或者开启。

3.2 网关鉴权表

img_10.png

这个表的目的是给 mcp 网关通信协议增加 api_key 进行验证的处理,也可以限制访问频次。

3.3 协议注册表

img_11.png

mcp_2_http 表示从 mcp 到 http 协议的注册,这样的设计是为了以后如果还想扩展其他的,如 mcp_2_rpc、mcp_2_mq 也都非常方便的添加。http、rpc、mq,他们的差异比较大

表名称调整了下,mcp_protocol_registry

3.4 协议映射表

img_12.png

这一个表的作用,是为了映射每一个 http 出入参字段,到 mcp 协议的时候,这些字段的名称描述。

表名称调整了下,mcp_protocol_mapping

《AI MCP Gateway》第1-4节:升级网关库表

一、本章诉求
增强网关库表设计,拆分出工具(tool)、工具协议类型(http),让网关配置可以支持一个网关下多个工具,工具可以绑定和切换到不同的协议上(1:n)。细节上会在 tool 上设计协议类型,以便于扩展支持不同的协议对接。

二、升级设计
如图,从旧版库表升级到新版库表的结构;

旧版的设计中,是有一个 mcp_protocol_registry 协议注册,里面包含了工具描述和 http 接口协议信息。功能理解和编码实现上会比较直观,适合我们最开始让大家上手学习。

新版的设计中,拆分了 tool 工具表,也就是一个网关(mcp_gateway)可以对应多个 tool 表,tool 表可以单独配置对应的协议信息,可以是 http,也可以是其他的。后续扩展的时候增加新的表即可。

三、新表设计
img_13.png

首先,新增设计 mcp_gateway_tool、mcp_protocol_http 表,去掉原有的 mcp_protocol_registry 表。使用 protocol_id 进行衔接,protocol_type 区分协议类型,如果是 http 的,则可以指定到 mcp_protocol_type 表查询,如果将来还有其他的类型,则可以查询其他表数据。

之后,mcp_protocol_mapping 去掉了 http_path、http_location,这部分不需要使用。

最后,整个结构来看,mcp_gateway 就可以 1:n 多个 tool 了,每个 tool 也可以动态的配置到 protocol 上(http 或者其他的)。

四、读者作业
简单作业:理解本节库表的设计的目标和拆分的方式,这个部分内容在以后工作中也会经常出现,在原有的库表上添加字段或者新增加表来解耦流程。

复杂作业:因为原有的功能和新的功能对比,只是库表做了变更,查询方式会有以一些调整。你可以尝试在工程里进行编码。鉴于这部分库表对应的代码实现的内容都是一致的,下一节将以代码评审的方式进行讲解,了解代码变更信息。这样你就可以对照变更信息,来修改你的代码了。

《AI MCP Gateway》第2-1节:MCP服务实现(用于后续协议分析)

进入到第二部分开始,小傅哥会带着大家通过各种手段对 MCP 协议进行细致的实践验证的方式进行分析。这部分包括了实现一个 MCP 服务,完成 MCP 服务的对接,通过代理的方式调试对接中 MCP 接口协议的数据,以及通过开发网页测试工具对接协议等。最后在进行 json-rpc2 定义的协议标准讲解。通过这样一套内容串联,你会对 MCP 有非常强的理解,也能为后面做 MCP 网关实现的学习打下良好的基础。

一、本章诉求
基于 Spring AI 框架,实现一个简单的 MCP 服务,为后续做协议的分析和验证进行使用。

因为协议分析,主要包括了通信的格式结构,如通信的入参,所以实现这样的一个 MCP 服务,会多增加一些入参类型,便于以后做网关设计时使用。

通常来讲,这部分的操作,也可以理解为是技术调研验证阶段。当我们要实现一个大的功能服务时,就要先想办法把复杂的逻辑拆分为独立的细小单元。也就是软件第一设计原则康威定律提到的,问题越小,越容易被理解和处理。所以,当你想在此项目拓展功能,或则自己在公司承接需求的时候,也可以使用这样的方式进行辅助完成系统的详细设计。

二、协议说明(MCP)
文档:https://docs.spring.io/spring-ai/reference/api/mcp/mcp-server-boot-starter-docs.html - 可以阅读官网文档,这里就包含了如何实现 MCP 服务,并提供了小案例。

org.springframework.ai spring-ai-starter-mcp-server

Spring AI MCP(模型上下文协议)服务启动器为在 Spring Boot 应用程序中设置 MCP 服务器提供了自动配置功能。它实现了 MCP 服务器功能与 Spring Boot 自动配置系统的无缝集成。

MCP 服务器启动器提供:

MCP 服务器组件的自动配置

支持同步和异步操作模式

多种传输层选项

灵活的工具、资源和提示规范

更改通知功能

Spring AI MCP 框架是对 MCP 协议的实现,可以把我们实现的服务功能,以 MCP 格式进行转换处理。与我们要实现的 AI MCP Gateway 不同的是,Spring AI MCP 固定的框架,每一个 MCP 都要独立完成开发,而 AI MCP Gateway 是一个通用协议转换的服务,只需要配置就可以完成从接口(http/rpc)到 MCP 协议的转换。

但为了更好的理解 MCP 协议,我们可以先基于 Spring AI MCP 框架,来实现一个简单的 MCP 并陆续完成对接使用,再到协议分析和设计 AI MCP Gateway 网关服务。

三、服务开发(MCP)
jdk 17

Maven 3.8.x

SpringBoot 3.4.3

星球课程入口,编程环境,jdk、maven等软件下载有提供。

  1. 工程结构

MCP 协议的实现,可以有2种方式,一种是提供 jar 包的(stdio 模式),本地引入使用。另外一种就是工程案例这种,sse 格式。其实 stdio 模式,只是不用自己启动 SpringBoot 服务,提供响应式接口了。把 jar 给提供出去,别人本地配置引入即可使用。但这样的方式不太适合做统一网关服务,所以我们把重点放到 sse 通信协议形式上。

Spring AI MCP 框架,提供的 MCP 开发方式非常简单,基本就是把自己平常开发的接口,配置上 @Tool 工具注解,之后再通过 Bean 对象,把服务注册进去。这样就可以把 Spring AI MCP 框架中的 webflux 进行管理和使用了。

  1. 功能实现
    2.1 对象描述
    @Data
    @JsonInclude(JsonInclude.Include.NON_NULL)
    public class XxxRequest01 {

    @JsonProperty(required = true, value = “city”)
    @JsonPropertyDescription(“城市名称,如果是中文汉字请先转换为汉语拼音,例如北京:beijing”)
    private String city;

    @JsonProperty(required = true, value = “company”)
    @JsonPropertyDescription(“公司信息,如果是中文汉字请先转换为汉语拼音,例如北京:jd/alibaba”)
    private Company company;

    @Data
    @JsonInclude(JsonInclude.Include.NON_NULL)
    public static class Company {

     @JsonProperty(required = true, value = "name")
     @JsonPropertyDescription("公司名称")
     private String name;
    
     @JsonProperty(required = true, value = "type")
     @JsonPropertyDescription("公司类型")
     private String type;
    

    }

}

在 MCP 服务的实现中,你会看到方法的出入参对象,会加入大量的注解描述。因为 MCP 服务最终给 LLM 大模型调用时,会需要获取出调用 MCP 协议服务时候的所需的接口出入参描述。这样 LLM 才能更准确的把这些对象组装出来进行调用。

后面我们在 AI MCP Gateway 网关实现中,配置 MCP 协议接口时,也需要把准确的描述出接口出入参信息。

2.2 定义服务
@Slf4j
@Service
public class TestService {

@Tool(description = "获取公司雇员信息")
public XxxResponse getCompanyEmployee(XxxRequest01 xxxRequest01, XxxRequest02 xxxRequest02) {
    log.info("根据公司和雇员,查询工资和工作工时。{} {}",xxxRequest01.getCompany(), xxxRequest02.getEmployeeName());

    // 这部分可以实际调用你需要的接口,比如调用http接口获取个数据或者做一些操作等。

    XxxResponse xxxResponse = new XxxResponse();
    xxxResponse.setSalary(new Random().longs(10000).toString());
    xxxResponse.setDayManHour(String.valueOf(new Random().nextInt(24)));

    return xxxResponse;
}

}

MCP 服务的实现,其实本身也就是 Spring 中 Service 的实现方式,唯独有差异的地方,就是多增加了一个 @Tool 注解,这个注解可以写入 name - 名称、description - 描述,这些信息页是为了 LLM 大模型调用 MCP 时,先拿到对应 MCP 服务的能力,之后根据用户提问匹配到 MCP Tool 工具的能力。

所以,在我们实现 MCP 或则通过 AI MCP GateWay 网关配置 MCP 的时候,也都要准确的描述服务的能力。这样才能更准确的进行匹配。

2.3 服务配置
@SpringBootApplication
public class Application {

public static void main(String[] args) {
    SpringApplication.run(Application.class, args);
}

@Bean
public ToolCallbackProvider testTools(TestService testService) {
    return MethodToolCallbackProvider.builder().toolObjects(testService).build();
}

}

服务开发实现完成后,还需要把服务注入到 Tool 工具回调提供者中,这个提供的方式在 Spring AI 框架结构的源码中,就可以被自身的响应式接口调用到了。

其实我们实现一个 MCP 网关,和 Spring AI MCP 框架的设计形式也是类似的,不过我们做的是可以动态配置,而不是编码固定写死的方式。

2.4 文件配置(yml)
server:
port: 8701
servlet:
encoding:
charset: UTF-8
force: true
enabled: true

spring:
application:
name: ai-mcp-gateway-demo-mcp-server-test
main:
banner-mode: off

stdio 模式打开,sse 模式,注释掉。

web-application-type: none

ai:
mcp:
server:
name: ${spring.application.name}
version: 1.0.0
type: ASYNC
instructions: “This server provides weather information tools and resources”
sse-message-endpoint: /mcp/messages
capabilities:
tool: true
resource: true
prompt: true
completion: true

logging:

stdio 模式打开,sse 模式,注释掉。

pattern:

console:

file:
name: data/log/${spring.application.name}.log

整个 MCP 服务是按照 sse 的方式进行发布,也就是一个 MCP 的响应是接口。所以这里的配置,都是打开 sse 方式进行发布。

在实现后,可以直接以 SpringBoot 服务的方式进行启动,之后就可以被访问使用了。

四、启动测试

  1. 启动服务

  2. 访问接口

地址:http://localhost:8701/sse - 访问后可以看到响应信息在浏览器上就可以了

说明:MCP 服务启动后,可以访问 sse 验证响应,它所提供的 data 数据,就是用于另外的一个访问,http://localhost:8701/sse/mcp/messages?sessionId=18ecda95-23c6-407b-ae7b-df26b027b35a 你只需要知道有这么个东西就可以,后续会带着你来做访问的处理。

《AI MCP Gateway》第2-2节:MCP代理调用

一、本章诉求
实现一个 MCP 客户端,用于对接 MCP 服务端,并通过代理 AI 接口的方式完成调用。该方案旨在调试 AI 调用 MCP 过程中的通信的请求接口协议,便于查看和分析相关数据。

二、流程设计
img_14.png

如图,是整个 Ai Client 以 Tools 工具,对接 MCP 的流程结构图。也是 Ai Agent 智能体最基础配置。如果感兴趣 Ai Agent 项目,也可以在星球里学习。

之后,我们要在整个实现过程中,为 Ai 接口,通过 SpringBoot HTTP 方式做一层代理。这样在调用 MCP 的过程中,我们就可以清楚的知道这个过程的协议数据结构了。代理的方式可以用在很多场景,还有一种是浏览器代理的主动安全扫描技术,甚至你服务器的应用暴漏了数据库密码都可以被扫描出来。 扩展知识:安全漏洞扫描,他怎么拿到了我的数据库密码?

三、功能实现

  1. 工程结构

proxy-api 定义代理接口,这个接口会被 app 层 config 进行配置实例化。

proxy-trigger 实现 proxy-api 代理接口,这一层要被 app 层的 pom 引入,这样才可以被 Spring 扫描到,实例化到容器。

proxy-app 程序启动入口,之后 ApiTest 层是测试调用 MCP 服务。

  1. 代理接口
    2.1 接口定义
    public interface IOpenAiApiProxy {

    @POST(“v1/chat/completions”)
    Single completions(@Body Object request);

    }

    2.2 实现接口
    @Configuration
    public class Retrofit2Config {

    @Bean
    public IOpenAiApiProxy openAiApi(@Value("${spring.ai.agent.base-url}") String baseUrl,
                                     @Value("${spring.ai.agent.api-key}") String apiKey) {
        OkHttpClient okHttpClient = new OkHttpClient.Builder()
                .addInterceptor(chain -> {
                    Request request = chain.request()
                            .newBuilder()
                            .addHeader("Content-Type", "application/json")
                            .addHeader("Authorization", "Bearer " + apiKey)
                            .build();
                    return chain.proceed(request);
                })
                .build();
    
        return new Retrofit.Builder()
                .baseUrl(baseUrl)
                .client(okHttpClient)
                .addCallAdapterFactory(RxJava2CallAdapterFactory.create())
                .addConverterFactory(JacksonConverterFactory.create())
                .build().create(IOpenAiApiProxy.class);
    }
    

    }

    这部分实现 AI 接口调用,这部分只是完成对 AI 接口的封装。

    2.3 代理调用
    @Slf4j
    @RestController()
    @CrossOrigin(“*”)
    @RequestMapping(“/v1/“)
    public class OpenAiApiProxyController {

    @Resource
    private IOpenAiApiProxy openAiApiProxy;
    
    /**
     * curl http://127.0.0.1:8771/v1/chat/completions
     */
    @RequestMapping(value = "chat/completions", method = RequestMethod.POST)
    public Object completions(@RequestBody Object request) {
        log.info("请求入参:{}", JSON.toJSONString(request));
        return openAiApiProxy.completions(request).blockingGet();
    }
    

    }

    在 proxy-trigger 层,实现对 AI 接口的调用。而这里的 http 接口,则和 chatgpt ai 接口保持一样的结构,这样我们在调试的时候就可以看到 MCP 协议信息了。

    1. 代理配置
      代理接口做完以后,可以把它实例化到基于 Spring AI 框架的 ChatClient 中。

    3.1 yml 配置
    server:
    port: 8771

    spring:
    ai:
    openai:
    base-url: http://127.0.0.1:8771
    api-key: sk-hpWgs2M83327R5qc9b517f07781b4dE483F260Fb20DaCe18
    chat:
    options:
    model: gpt-4.1-mini
    embedding-model: text-embedding-ada-002
    embedding:
    options:
    num-batch: 1536

    个人配置,通过 http://127.0.0.1:8090 代理出去

    agent:
    base-url: https://apis.itedus.cn
    api-key: sk-hpWgs2M83327R5qc9b517f07781b4dE483F260F**** *联系小傅哥获取可用开发渠道(仅用于编程学习使用)

    spring.ai.openai 是正式要使用的来实例化 ChatClient 的配置接口。但这个接口走的是,spring.ai.agent 下我们实例化的接口。

    3.2 实例化客户端
    @Configuration
    public class ChatClientConfig {

    @Bean
    public ChatClient.Builder chatClientBuilder(OpenAiChatModel chatModel) {
        return new DefaultChatClientBuilder(chatModel, ObservationRegistry.NOOP, (ChatClientObservationConvention) null);
    }
    

    }

    固定配置实例化客户端,注意 OpenAiChatModel 是 Spring AI 框架,基于 yml 配置,自己完成实例化的对象。

    四、功能测试

    1. 测试方法
      @Slf4j
      @RunWith(SpringRunner.class)
      @SpringBootTest
      public class ApiTest {

      @Resource
      private ChatClient.Builder chatClientBuilder;

      @Test
      public void test() {
      ChatClient chatClient = chatClientBuilder.defaultOptions(
      OpenAiChatOptions.builder()
      .model(“gpt-4.1-mini”)
      .toolCallbacks(new SyncMcpToolCallbackProvider(sseMcpClient()).getToolCallbacks())
      .build())
      .build();

       log.info("测试结果:{}", chatClient.prompt("有哪些工具可以使用").call().content());
      

      }

      public McpSyncClient sseMcpClient() {
      HttpClientSseClientTransport sseClientTransport = HttpClientSseClientTransport
      .builder(“http://localhost:8701/sse“)
      .build();

       McpSyncClient mcpSyncClient = McpClient.sync(sseClientTransport).requestTimeout(Duration.ofMinutes(360)).build();
       var init_sse = mcpSyncClient.initialize();
       log.info("Tool SSE MCP Initialized {}", init_sse);
      
       return mcpSyncClient;
      

      }

    }

    首先,在测试前要确保 ai-mcp-gateway-demo-mcp-server-test 服务已经启动,并把对应的服务地址:port 配置到上面 sseMcpClient 里。替换 builder(“http://localhost:8701/sse“)

    之后,不是直接运行 test,而是要以debug方式启动 ai-mcp-gateway-demo-mcp-client-proxy 服务,这样代理接口才能生效。

    然后,在 OpenAiApiProxyController.completions 打断点,之后在 debug 方式执行 ApiTest.test 这样你就可以看到 mcp 协议的相关数据了。

    1. 测试结果

    {“messages”:[{“content”:”有哪些工具可以使用”,”role”:”user”}],”model”:”gpt-4.1-mini”,”stream”:false,”temperature”:0.7,”tools”:[{“type”:”function”,”function”:{“description”:”获取公司雇员信息”,”name”:”JavaSDKMCPClient_getCompanyEmployee”,”parameters”:{“additionalProperties”:false,”type”:”object”,”properties”:{“xxxRequest01”:{“type”:”object”,”properties”:{“city”:{“type”:”string”,”description”:”城市名称,如果是中文汉字请先转换为汉语拼音,例如北京:beijing”},”company”:{“type”:”object”,”properties”:{“name”:{“type”:”string”,”description”:”公司名称”},”type”:{“type”:”string”,”description”:”公司类型”}},”required”:[“name”,”type”],”description”:”公司信息,如果是中文汉字请先转换为汉语拼音,例如北京:jd/alibaba”}},”required”:[“city”,”company”]},”xxxRequest02”:{“type”:”object”,”properties”:{“employeeCount”:{“type”:”string”,”description”:”雇员姓名”}},”required”:[“employeeCount”]}},”required”:[“xxxRequest01”,”xxxRequest02”]}}}]}

    这个调试拿到的 MCP 请求入参结构,协议结构很重要,用于后续做 AI MCP Gateway 网关实现使用。

    具体的 MCP 数据结构,小傅哥会带着大家在后续开发时候使用。

    《AI MCP Gateway》第2-3节:MCP通信协议(json-rpc2) - 调试 Spring AI 源码

    一、本章诉求
    学习了解 JSON-RPC 2.0 消息协议定义,并通过工程实践调试(debug)验证的方式,分析 MCP 通信协议过程。

    二、通信协议
    MCP等同于为AI安装上了手和脚,使其这个AI大脑具备了行为能力的执行。所以,25年以来,AIAgent智能体才得以落地。

    MCP 定义了一种标准化的通信协议,使客户端和服务器能够以一致且可预测的方式交换消息。这种标准化的定义对整个AI和服务的交互性至关重要。

    而通信就要有数据的交互格式,MCP 采用的是 JSON-RPC 2.0 协议,作为客户端和服务端之间素有的通信消息格式。JSON-RPC 是一种轻量级的远程过程调用协议,采用 JSON 编码,易于阅读和调试、与编程语言无关,支持在任何编程环境中实现(Java、Python、Go、JS…),且成熟完善,规范明确,适合广泛使用。

    在本节代码工程下,docs/pdf -> JSON-RPC 2.0 Specification.pdf 详细介绍了 json-rpc 2.0 通信协议,可以查阅。也可以阅读它的官网,但打开会卡一些。https://www.jsonrpc.org/specification

    1. 调用协议

    首先,很多通信协议,也包括业务工程的流程处理,往往第一步是建立一个验证关系,拿到整个后续链路请求的会话ID,之后以会话ID作为全流程的串联关系进行通信。这和图中的 MCP 协议调用过程是一样的。

    之后,MCP 客户端和服务端的交互,分为4个步骤;初始化(连接)、发现工具列表(能力)、执行工具调用、断开连接。也就是说 AI 要拿到你配置的工具的 MCP 的能力,这样才能根据你的请求决定调用哪个 MCP 服务,以及处理 MCP 服务返回的结果。

    1. 消息协议
      2.1 请求
      {
      “jsonrpc”: “2.0”,
      “id”: 1,
      “method”: “tools/call”,
      “params”: {
      “name”: “weather”,
      “arguments”: {
      “location”: “请给出北京的天气信息”
      }
      }
      }

    由客户端发送到服务器,包括;唯一标识(id)、要执行的方法信息(tools/call)、方法的参数。

    2.2 应答
    成功

    {
    “jsonrpc”: “2.0”,
    “id”: 1,
    “result”: {
    “temperature”: 62,
    “conditions”: “北京16°~19°,天气温和,不适合上班。”
    }
    }

    失败

    {
    “jsonrpc”: “2.0”,
    “id”: 1,
    “error”: {
    “code”: -32602,
    “message”: “Invalid location parameter”
    }
    }

    2.3 通知
    {
    “jsonrpc”: “2.0”,
    “method”: “progress”,
    “params”: {
    “message”: “Processing data…”,
    “percent”: 50
    }
    }

    单向消息,无需响应。通常由服务器发送到客户端,用于提供有关事件的更新或通知。

    三、协议验证
    接下来,我们通过调试 Spring AI MCP 框架,客户端与服务端的对接使用,来了解 MCP 通信协议过程和数据。

    1. 工程结构

    如图,这是一个测试 MCP 协议的案例工程,提供了相关的测试对接方式,以及核心源码。

    CorsConfig 配置了全局的跨域,避免做验证的时候 mcp-api.html 访问不了接口。另外一个 LoggingWebFilter 是拦截打印请求日志,方便观察。这两部分代码可以查看源码。

    1. 功能说明
      2.1 MCP 服务端
      @SpringBootApplication
      public class Application {

      public static void main(String[] args) {
      SpringApplication.run(Application.class, args);
      }

      @Bean
      public ToolCallbackProvider tools(AuthService authService) {
      return MethodToolCallbackProvider.builder()
      .toolObjects(authService)
      .build();
      }

      @Slf4j
      @Service
      public static class AuthService {

       @Tool(description = "提问")
       public AuthFunctionResponse ask(AuthFunctionRequest request) {
           return new AuthFunctionResponse("你的提问," + (request != null ? request.toString() : "null"));
       }
      

      }

      @Data
      @JsonInclude(JsonInclude.Include.NON_NULL)
      public static class AuthFunctionRequest {

       @JsonProperty(required = true, value = "question")
       @JsonPropertyDescription("提问")
       private String question;
      

      }

      @Data
      @NoArgsConstructor
      @JsonInclude(JsonInclude.Include.NON_NULL)
      public static class AuthFunctionResponse {

       @JsonProperty(required = true, value = "content")
       @JsonPropertyDescription("content")
       private String content;
      
       public AuthFunctionResponse(String content) {
           this.content = content;
       }
      

      }

      public record TextInput(String input) {
      }

      /**

      • 可以执行注册mcp服务
        */
        @Bean
        public ToolCallback toUpperCase() {
        return FunctionToolCallback.builder(“toUpperCase”, (TextInput input) -> input.input().toUpperCase())
        .inputType(TextInput.class)
        .description(“Put the text to upper case”)
        .build();
        }

    }

    做了一个简单的 MCP 服务端,配置到了 Application 启动入口下。

    一个是提问的 MCP 服务,一个是工具大小写转换。toUpperCase 时关键字。

    2.2 MCP 客户端
    @Slf4j
    @RunWith(SpringRunner.class)
    @SpringBootTest
    public class ApiTest {

    @Resource
    private ChatClient.Builder chatClientBuilder;
    
    @Test
    public void test() {
        ChatClient chatClient = chatClientBuilder.defaultOptions(
                        OpenAiChatOptions.builder()
                                .model("gpt-4.1-mini")
                                .toolCallbacks(new SyncMcpToolCallbackProvider(sseMcpClient()).getToolCallbacks())
                                .build())
                .build();
    
        // 有哪些工具可以使用
        log.info("测试结果:{}", chatClient.prompt("toUpperCase aaa").call().content());
    }
    
    public McpSyncClient sseMcpClient() {
        HttpClientSseClientTransport sseClientTransport = HttpClientSseClientTransport
                .builder("http://localhost:8777/sse")
                .build();
    
        McpSyncClient mcpSyncClient = McpClient.sync(sseClientTransport).requestTimeout(Duration.ofMinutes(360)).build();
        var init_sse = mcpSyncClient.initialize();
        log.info("Tool SSE MCP Initialized {}", init_sse);
    
        return mcpSyncClient;
    }
    

    }

    这里是一段 Spring AI 客户端使用的代码,通过 SSE 方式对接了 MCP 服务,这个 MCP 服务就是本身的这套工程,测试的时候要先启动工程。

    之后我们会对服务进行提问,toUpperCase aaa 调用工具服务,把消息的 aaa 转换为大写。

    四、功能测试

    1. 基础配置
      server:
      port: 8777

    ai 配置

    spring:
    ai:
    openai:
    base-url: https://apis.itedus.cn
    api-key: sk-dgLKpeuUTzXRxiou6026611a60D2456f88Ae0c86B81eBd65
    chat:
    options:
    model: gpt-4.1-mini

    注意,服务端口 8777,ai 需要配置 base-url、api-key

    1. 源码断点
      源码:WebFluxSseServerTransportProvider

    可以两下 Shift 输入 WebFluxSseServerTransportProvider 进入源码,在这2个地方打上断点,这样可以在有请求的时候查看数据。

    1. 运行验证 - mcp api

    启动服务,通过网页调用服务端接口,了解请求过程。分为,建立连接,之后是消息发送的4个过程。

    这部分因为是异步响应的,页面没有做那么复杂,后面还有一个通过客户端请求进行调试的,来看具体的异步响应结果。

    这部分调用过程的内容,有演示视频,可以通过课程视频来学习。

    1. 调试验证 - client debug
      4.1 请求参数

    4.2 应答结果

    4.3 协议策略

    调试可以查看,出入参结果。根据结果可以看到,整个 MCP 的请求过程。

    可以看到对应的协议处理的策略实现。

    《AI MCP Gateway》第2-4节:streamable协议应用案例

    一、本章诉求
    前面章节我们学习了 sse 协议的分析,设计,实现和使用,从这一节开始,我们进入 streamable 协议的扩展,这也是目前最新的 mcp 协议使用方式。我们通过网关的统一管理,可以让一套服务,有不同的协议调用。

    这一节先升级 Spring AI 框架(1.1.4),增加 mcp 服务 streamable 协议方式的对接案例,对比 sse 与 streamable 的差异。

    二、协议对比
    如图,mcp 通信,sse、streamable 对比;

    img_15.png

    Streamable HTTP 是一种基于普通 HTTP 请求、服务器可按需升级为 SSE 流式响应、无需强制长连接、统一通过 /message 协议层通信并支持 stateless 模式的传输机制。

    Streamable HTTP 支持无状态服务器与纯 HTTP 实现,无需强制长连接和 SSE 依赖,与现有基础设施良好兼容,是对 HTTP + SSE 的渐进式改进,并可灵活选择是否使用 SSE 流式响应。

    这两种实现都是在协议的出入方式有变化,但底层的通信仍然是json-rpc2。后续章节会从源码视角来看 sse、streamable 的实现差异,之后在去做设计和编码实现。

    三、功能实现

    1. 工程结构

    在 ai-mcp-gateway-demo-mcp-server-test 工程里拉一个测试 mcp 的服务,这一节要升级下 spring ai 框架到 v1.1.4 版本。

    pom 文件,是对3种不同,可以按需选择进行调试。这一节主要是 mcp server(streamable http)的对接使用。

    resources 文件下,有一个 -sse、-stdio、-streamable,三个配置文件,需要哪一个,就在 application.yml 配置对应的名称,这样方便我们来测试验证。

    spring-ai-mcp,这里小傅哥把 spring ai 框架种,streamable http 的代码先拿出来了,感兴趣的小伙伴可以先看,我们后续也会分析的。

    1. 测试案例
      2.1 引入pom org.springframework.ai spring-ai-bom ${spring-ai.version} pom import

    spring-ai.version 版本号,调整为 1.1.4 版本,方便我们使用 streamable http 协议。

        
    
        
    
        
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
        </dependency>
    

    这部分配置,sse、stdio、streamable,都放在一起了,需要啥就使用啥。

    2.2 yml 配置
    application.yml

    spring:
    application:
    name: ai-mcp-gateway-demo-mcp-server-test
    config:
    name: ai-mcp-gateway-demo-mcp-server-test
    profiles:

    active: stdio

    active: sse

    active: streamable
    

    ai:
    mcp:
    server:
    name: ai-mcp-gateway-mcp-server-demo # 服务名
    version: 0.0.1 # 版本号
    type: SYNC
    instructions: |
    This server provides demo tools for Java MCP development.
    Use getCityTime for timezone lookup, sumNumbers for calculation,
    and projectSummary for quick project scaffolding summaries.

    profiles 用于切换 active,切换 stdio、sse、streamable 方式。

    application-sse.yml

    server:
    port: 8701
    servlet:
    encoding:
    charset: UTF-8
    force: true
    enabled: true

    http://localhost:8701/sse

    spring:
    main:
    banner-mode: off
    ai:
    mcp:
    server:
    name: ${spring.application.name}
    version: 1.0.0
    type: ASYNC
    instructions: “This server provides weather information tools and resources”
    sse-message-endpoint: /mcp/messages
    capabilities:
    tool: true
    resource: true
    prompt: true
    completion: true

    logging:
    file:
    name: data/log/${spring.application.name}.log

    application-streamable.yml

    server:
    port: 8701

    http://localhost:8701/api/mcp

    spring:
    config:
    activate:
    on-profile: streamable
    main:
    web-application-type: servlet # 启用Web
    ai:
    mcp:
    server:
    protocol: STREAMABLE
    streamable-http:
    mcp-endpoint: /api/mcp
    keep-alive-interval: 30s

    logging:
    file:
    name: data/log/${spring.application.name}.log

    streamable http,必须在 spring.ai 写的协议 protocol,指定 STREAMABLE

    mcp-endpoint,是端点地址,/api/mcp 这个按需配置即可。

    2.4 代码开发
    @SpringBootApplication
    public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
    
    @Bean
    public ToolCallbackProvider testTools(TestService testService) {
        return MethodToolCallbackProvider.builder().toolObjects(testService).build();
    }
    

    }

    这部分工具 ToolCallbackProvider,sse、streamable 都是一样的。其实这也能看出,我们在以后实现网关的时候,其实就是对外的协议提供方式,底层是不变的。

    其他的接口实现等也是不变的,都是共用的。

    四、测试验证

    1. 启动服务端

    选择 streamable 配置后启动。

    1. 测试客户端
      public class StreamableApiTest {

      public static void main(String[] args) throws Exception {
      OpenAiApi openAiApi = OpenAiApi.builder()
      .baseUrl(“https://apis.itedus.cn“)
      .apiKey(“sk-MEhn88b6iV7THIHp00724b306cAc41C0A17可以联系小傅哥申请”)
      .completionsPath(“v1/chat/completions”)
      .embeddingsPath(“v1/embeddings”)
      .build();

       ChatModel chatModel = OpenAiChatModel.builder()
               .openAiApi(openAiApi)
               .defaultOptions(OpenAiChatOptions.builder()
                       .model("gpt-4o")
                       .toolCallbacks(new SyncMcpToolCallbackProvider(sseMcpClient_streamable()).getToolCallbacks())
                       .build())
               .build();
      
       log.info("测试结果:{}", chatModel.call("""
               获取公司雇员信息,信息如下;
               城市;北京
               公司;谷歌
               雇员;小傅哥
               """));
      

      }

      public static McpSyncClient sseMcpClient_streamable() {

       McpClientTransport mcpClientTransport = HttpClientStreamableHttpTransport
               .builder("http://localhost:8701")
               .endpoint("/api/mcp")
               .build();
      
       McpSyncClient mcpSyncClient = McpClient.sync(mcpClientTransport).requestTimeout(Duration.ofMinutes(36000)).build();
       var init_sse = mcpSyncClient.initialize();
       log.info("Tool SSE MCP Initialized {}", init_sse);
      
       return mcpSyncClient;
      

      }

    }

    StreamableApiTest,测试的时候,使用 HttpClientStreamableHttpTransport 方式链接,注意 .endpoint(“/api/mcp”) 配置。

    测试验证,可以访问服务和得到结果即可。

    《AI MCP Gateway》第2-5节:streamable协议应用分析
    一、本章诉求
    通过分析(+ debug 调试) spring ai mcp streamable 协议源码,深入理解实现原理。

    在前面章节 mcp sse 的分析和实现的基础上,本章节往后对 mcp streamable http 协议的理解和实现会轻松不少,因为底层 mcp 模型上下文协议,这部分内容是不变的,更多是调整了协议的执行流程。小伙伴们也可以结合本节内容,多一些源码的调试验证,以及各类资料的检索补充自己的知识积累。

    每个伙伴的学习储备不同,可能有些伙伴理解起来容易,有些伙伴会感觉稍微吃力。这时候可以使用 walicode.xiaofuge.cn 这样的 AI IDE 工具打开这部分代码,让继续分析(把代码加入对话框,之后告诉他你是编程小白,对哪些内容不理解,需要帮忙分析)。

    二、通信协议(streamable http)
    如图,streamable http 通信协议过程;

    img_16.png
    首先,乍一看 mcp streamable http 流程交互过程蛮多,不好理解。但其实抓住本质,一下就好理解了。streamable http 的协议定义采用的是单端点通信,同一个接口方法(如 /mcp),分为 GET、POST 两个方法类型,各自承担不同职责。

    之后,这种通信方式在微信公众号的对接中有类似方式,通过 post 验证签名,通过后再通过 get 方法通信,也是一个接口名称,两个通信方法。之后,streamable http 也是这样的通信结构。

    步骤1(POST-初始化) ;通过 post 请求,传递 method 标识为 initialize 初始化标识,创建 sessionId(Mcp-Session-Id)写入到 header 头信息中。sessionId 的用途是用于做通信标记的,你日常在互联网中做一些如流程比较长业务的,信贷审批、协议签约,都会有一个 sessionId 记录的你的生命周期行为。

    步骤2(GET-建立监听) ;建立在步骤1的基础上,拿了 sessionId 以同一个接口的不同请求方式(post -> get),用 get 请求建立 SSE 连接。建立这个东西的目的在于数据传输。就像,post 你是拿到了”批文”,get 帮你建了一条通信监听管道。后续服务端有任何需要主动推送的消息(如通知、采样请求),都可以通过这条 SSE 管道推送给客户端。

    步骤3(POST-业务请求) ;管道建立完毕后,接下来要操作;做初始化完成、资源、工具列表查询和工具调用,这一部分的操作都会通过 POST 请求发送到服务端,但响应结果会通过步骤2建立的 SSE 连接,使用 sessionTransport.sendMessage(message) 把数据发送给客户端。

    三、源码分析
    Java

    org.springframework.ai spring-ai-starter-mcp-server-webmvc streamable http 的源码部分主要是引入的 spring-ai-starter-mcp-server-webmvc 包,核心类是 WebMvcStreamableServerTransportProvider,可以在该类中打断点进行调试。

    4.1 路由注册
    首先看路由注册部分,同一个端点(默认 /mcp)注册了三种 HTTP 方法:

    Java
    this.routerFunction = RouterFunctions.route()
    .GET(this.mcpEndpoint, this::handleGet) // SSE 监听
    .POST(this.mcpEndpoint, this::handlePost) // 消息收发
    .DELETE(this.mcpEndpoint, this::handleDelete) // 关闭会话
    .build();
    4.2 POST 请求 - 初始化会话
    在 ai 客户端对接 mcp 服务启动后,最先收到一个 POST 请求,传递的是 McpSchema.JSONRPCRequest 消息类型,method 为 initialize。服务端创建 McpStreamableServerSession 会话对象,生成 sessionId 存储到 sessions Map 中,并写入响应头 Mcp-Session-Id 返回给客户端。

    Java
    // handlePost 中的初始化逻辑
    if (jsonrpcRequest.method().equals(“initialize”)) {
    // 创建会话
    McpStreamableServerSessionInit init = this.sessionFactory.startSession(initializeRequest);
    // 存储会话
    this.sessions.put(init.session().getId(), init.session());
    // 返回结果,Header 中携带 Mcp-Session-Id
    return ServerResponse.ok()
    .header(“Mcp-Session-Id”, init.session().getId())
    .body(new McpSchema.JSONRPCResponse(“2.0”, jsonrpcRequest.id(), initResult, null));
    }
    4.3 GET 请求 - 建立 SSE 监听
    初始化完成后,客户端拿着 sessionId 发起 GET 请求,建立 SSE 长连接。这里需要校验 Accept: text/event-stream 请求头和 Mcp-Session-Id。

    根据是否有 Last-Event-ID,分为两种处理模式:

    Java
    // handleGet 核心逻辑
    if (!request.headers().header(“Last-Event-ID”).isEmpty()) {
    // 模式B:断线重连,补发错过的消息
    String lastId = request.headers().asHttpHeaders().getFirst(“Last-Event-ID”);
    session.replay(lastId).toIterable().forEach(message -> {
    sessionTransport.sendMessage(message).block();
    });
    } else {
    // 模式A:正常监听,注册监听流
    McpStreamableServerSessionStream listeningStream = session.listeningStream(sessionTransport);
    // 连接关闭时,自动清理监听流
    sseBuilder.onComplete(() -> listeningStream.close());
    }
    这个 GET 建立的 SSE 连接就是”数据通信管道”,后续 POST 请求的响应、服务端主动推送的通知,都会通过 sessionTransport.sendMessage(message) 在这条管道上传输。

    4.4 POST 请求 - 业务数据处理
    SSE 管道建立后,客户端的业务请求(tools/list、tools/call、通知等)都通过 POST 发送。服务端根据消息类型分别处理:

    4.4.1 请求类型(JSONRPCRequest)— tools/list、tools/call 等
    请求类消息通过 session.responseStream() 处理,响应结果通过流式方式返回给客户端。结合当前课程代码理解,更准确的说法是:POST 发起的请求结果由 POST 自身的响应承载,而不是统一进入 GET 的 SSE 监听通道。

    Java
    if (message instanceof McpSchema.JSONRPCRequest) {
    McpSchema.JSONRPCRequest jsonrpcRequest = (McpSchema.JSONRPCRequest) message;
    return ServerResponse.sse(sseBuilder -> {
    WebMvcStreamableMcpSessionTransport sessionTransport =
    new WebMvcStreamableMcpSessionTransport(sessionId, sseBuilder);
    // 处理请求,结果通过 SSE 流返回
    session.responseStream(jsonrpcRequest, sessionTransport).block();
    }, Duration.ZERO);
    }
    4.4.2 通知类型(JSONRPCNotification)— 初始化完成通知等
    通知是单向消息,不需要响应,直接接受即可:

    Java
    if (message instanceof McpSchema.JSONRPCNotification) {
    McpSchema.JSONRPCNotification jsonrpcNotification = (McpSchema.JSONRPCNotification) message;
    session.accept(jsonrpcNotification).block();
    return ServerResponse.accepted().build();
    }
    4.4.3 响应类型(JSONRPCResponse)— 客户端回复服务端的请求
    当服务端通过 SSE 推送了采样请求(sampling)时,客户端通过 POST 回复处理结果:

    Java
    if (message instanceof McpSchema.JSONRPCResponse) {
    McpSchema.JSONRPCResponse jsonrpcResponse = (McpSchema.JSONRPCResponse) message;
    session.accept(jsonrpcResponse).block();
    return ServerResponse.accepted().build();
    }
    4.5 完整消息流转图
    把 POST 和 GET 的配合关系画出来,就是下面这张图:
    img_17.png
    这是 Streamable HTTP 协议的核心设计思想。两种 SSE 流解决不同的问题:

    可能有小伙伴会困惑:POST 发的 tools/call 请求,响应到底是通过 POST 自身返回,还是通过 GET 的 SSE 管道返回?

    答案:通过POST请求自身创建的临时SSE流返回,不是通过GET的SSE管道。 (可能被面试官当做八股,如果不问,你也可以学会后,喂到面试官嘴里)

    看 handlePost 中处理 JSONRPCRequest 的源码:

    Java
    // handlePost 中处理 tools/list、tools/call 等请求
    } else if (message instanceof McpSchema.JSONRPCRequest) {
    McpSchema.JSONRPCRequest jsonrpcRequest = (McpSchema.JSONRPCRequest) message;

    // 关键:这里创建了一个新的、临时的 SSE 流!
    return ServerResponse.sse((sseBuilder) -> {
        // 新建 sessionTransport,绑定的是 POST 请求自己的 sseBuilder
        WebMvcStreamableMcpSessionTransport sessionTransport = 
            new WebMvcStreamableMcpSessionTransport(sessionId, sseBuilder);
        
        // 处理请求,结果通过这个临时 SSE 流返回
        session.responseStream(jsonrpcRequest, sessionTransport).block();
    }, Duration.ZERO);
    

    }
    这段代码做了什么:

    ServerResponse.sse(sseBuilder -> …) — 在 POST 的响应中创建了一个 SSE 流

    new WebMvcStreamableMcpSessionTransport(sessionId, sseBuilder) — 用这个 SSE 流创建一个临时的传输器

    session.responseStream(jsonrpcRequest, sessionTransport) — 处理请求,结果通过这个临时传输器返回

    .block() — 阻塞等待处理完成,完成后这个 SSE 流就关闭了

    对比 handleGet 中创建长期 SSE 管道的代码:

    Java
    // handleGet 中创建长期 SSE 管道
    return ServerResponse.sse((sseBuilder) -> {
    WebMvcStreamableMcpSessionTransport sessionTransport =
    new WebMvcStreamableMcpSessionTransport(sessionId, sseBuilder);

    // 注册为长期监听流,不会 block
    McpStreamableServerSessionStream listeningStream = session.listeningStream(sessionTransport);
    
    // 连接关闭时才清理
    sseBuilder.onComplete(() -> listeningStream.close());
    

    }, Duration.ZERO);
    两者的关键区别:

    GETSSE管道(长期) :解决”服务端主动推送”的问题。HTTP 协议是请求-响应模式,客户端无法主动接收服务器消息,所以需要建立一个长期 SSE 管道,让服务端随时可以推送。

    POST临时SSE流(临时) :解决”流式响应”的问题。对于耗时较长的 tools/call 调用,直接返回 JSON 要等到处理完才能响应,用 SSE 流式返回可以先推状态再推结果。

    DELETE请求用于客户端主动关闭会话 :服务端收到后会清理 sessions Map 中的会话记录,释放资源。这是优雅关闭的方式,比让连接超时断开更可靠。

    三、内容总结

    1. 核心分工:GET 和 POST 各司其职
      在理解源码之前,先要搞清楚 GET 和 POST 各自的职责分工:

    SSE 流类型

    创建时机

    生命周期

    用途

    GETSSE管道

    客户端发起 GET /mcp

    长期存在,保持打开

    接收服务端主动推送的通知、请求

    POST临时SSE流

    每次 POST tools/call 等请求时

    临时的,请求处理完就关闭

    返回该 POST 请求的处理结果

    一句话总结 :POST 的响应走 POST 自己的临时 SSE 流,GET 的 SSE 管道只管服务端主动推送。

    1. GET SSE 的两种模式
      GET 请求建立的 SSE 连接有两种工作模式 ,通过 Last-Event-ID 请求头来区分:

    Java
    GET /mcp 请求

    ├── 有 Last-Event-ID → 模式B:断线重连,补发错过的消息

    └── 无 Last-Event-ID → 模式A:正常监听,等待服务端推送
    2.1 模型类型
    2.1.1 模式A:正常监听(listeningStream)
    客户端首次建立 SSE 连接时,没有 Last-Event-ID,进入正常监听模式。服务端调用 session.listeningStream(sessionTransport) 注册一个监听流,之后服务端可以随时通过这条 SSE 连接推送消息。

    2.1.2 模式B:断线重连(replay)
    客户端断线后重新连接时,会自动带上 Last-Event-ID 请求头(这是 SSE 协议的规范机制:服务端每发一条消息都会带上 id 字段,客户端自动记住最后收到的 ID,重连时自动带上)。服务端收到后调用 session.replay(lastId) 补发错过的消息,之后进入正常监听模式。

    2.2 场景举例
    2.2.1 场景A:服务端主动推送通知
    当服务端有事件需要通知客户端时(如资源变更),通过 GET SSE 管道推送:

    Java
    Client Server
    │ │
    │── GET /mcp ──────────────────────→│ 建立长期 SSE 管道
    │ │
    │ … 客户端正常工作 … │
    │ │
    │ 某个资源被修改了 │
    │ ↓ │
    │ notifyClients() │
    │ ↓ │
    │←── SSE: 通知”资源已更新” ────────│ 通过 GET SSE 管道推送
    2.2.2 场景B:服务端向客户端发起请求(如采样请求)
    这个场景需要特别理解为什么必须用GETSSE管道 。

    核心区别 :tools/call 是客户端主动发起(客户端知道自己要调用工具),所以用 POST。但采样请求是服务端主动发起 (LLM 处理过程中突然需要客户端帮忙),客户端不知道什么时候会收到这个请求,无法主动发 POST,所以只能通过 GET SSE 管道由服务端推送。

    打个比方:

    tools/call 就像你去餐厅点菜(你主动),服务员把菜端给你(POST 临时 SSE 流)

    采样请求就像餐厅突然打电话给你说”您点的菜需要确认口味”(服务端主动),你接了电话再回复(POST 回复结果)

    LLM 在处理过程中需要客户端协助时(如生成文本),通过 GET SSE 管道推送采样请求:

    Java
    Client Server
    │ │
    │── GET /mcp ──────────────────────→│ 建立长期 SSE 管道
    │ │
    │ … LLM 正在处理 … │
    │ │
    │ LLM 需要客户端 │
    │ 帮忙生成内容 │
    │ ↓ │
    │←── GET SSE管道: 采样请求 ────────│ 服务端主动推送(客户端无法预期)
    │ │
    │── POST /mcp(回复采样结果)───────→│ 客户端收到后通过 POST 回复
    为什么不通过POST临时SSE流? 因为客户端不知道什么时候需要收到采样请求,无法主动发起 POST 请求。服务端只能通过已建立的 GET SSE 管道主动推送。

    2.2.3 场景C:断线重连时补发消息
    客户端网络断开后重新连接,通过 Last-Event-ID 机制补发错过的消息:

    Java
    Client Server
    │ │
    │←── SSE: id=msg1 ────────────────│
    │←── SSE: id=msg2 ────────────────│
    │ │
    │ ╳ 网络断开 │
    │ │
    │── GET /mcp ──────────────────────→│ 重连
    │ Header: Last-Event-ID: msg2 │
    │ │
    │←── SSE: 重放msg2之后的消息 ──────│ 补发错过的消息
    3. 关键设计
    3.1 为什么 POST 的响应要通过 SSE 返回?
    这是 Streamable HTTP 协议的核心设计思想。如果 POST 请求直接返回 JSON 结果,对于同步的 tools/list 查询没问题,但对于耗时较长的 tools/call 调用,客户端就无法实时获取进度。

    通过 SSE 流式返回,服务端可以在处理过程中持续推送中间状态,客户端可以实时感知进度。同时,这也保持了响应通道的统一 ——不管是 POST 请求的响应,还是服务端主动推送的通知,都通过 GET 建立的 SSE 管道传输,客户端只需要监听一个通道即可。

    3.2 DELETE 的作用
    DELETE 请求用于客户端主动关闭会话。服务端收到后会清理 sessions Map 中的会话记录,释放资源。这是优雅关闭的方式,比让连接超时断开更可靠。

    3.3 GET SSE 推送的触发源是什么?
    这是理解 SSE 的关键问题。GET请求本身不会触发任何推送 ,GET 只是建立了一条”管道”,建立完之后连接保持打开,管道一直通着。

    推送的触发源是”其他事件”,不是 GET 请求本身。从源码中可以看到推送的几种触发场景:

    场景1:广播通知(notifyClients)

    当服务端需要通知所有客户端时(如资源变更),调用 notifyClients 方法:

    Java
    public Mono notifyClients(String method, Object params) {
    // 向所有活跃会话广播消息
    this.sessions.values().parallelStream().forEach((session) -> {
    session.sendNotification(method, params).block();
    });
    }
    触发时机举例:

    某个资源被修改,通知所有订阅了该资源的客户端

    系统级广播(如维护通知)

    场景2:单个会话通知(sendNotification)

    当需要通知单个客户端时(如该客户端订阅的事件发生):

    Java
    session.sendNotification(method, params)
    触发时机举例:

    该客户端订阅的资源发生了变化

    服务端需要向该客户端推送采样请求(sampling)

    场景3:断线重连补发(replay)

    客户端断线重连时,补发错过的消息:

    Java
    session.replay(lastId)
    触发时机:客户端网络恢复后重新连接

    完整推送流程示意 :

    一句话总结 :GET 建立的是”收听通道”,服务端有事件发生时才通过这个通道推送。就像你打开了收音机(GET),电台有节目播出时(业务事件)你才能听到(SSE推送)。

    四、与 SSE 协议的对比
    对比项

    MCP SSE 协议

    MCP Streamable HTTP 协议

    端点数量

    2个(/sse + /message)

    1个(/mcp)

    GET 职责

    建立 SSE 连接 + 推送

    建立长期 SSE 监听管道

    POST 职责

    发送业务消息

    初始化 + 发送业务消息 + 临时 SSE 响应

    响应方式

    POST 无响应(通过 GET SSE 推送)

    POST 创建临时 SSE 流返回响应

    会话管理

    无状态,SSE 断开即丢失

    有状态,sessionId 贯穿生命周期

    断线重连

    不支持,断开后需要重新连接

    支持,通过 Last-Event-ID 补发消息

    服务端主动推送

    通过 GET SSE 推送

    通过 GET SSE 管道推送

    《AI MCP Gateway》第3-1节:工程初始化创建

    《AI MCP Gateway》第3-2节:会话管理服务实现

    一、本章诉求
    从本章节开始,我们会以第1-2节:系统建模设计 为标准,进行各个模块的逻辑编码实现。包括;协议、鉴权、会话等。这部分内容完成后,在定义 api 接口,以及在 trigger 的 http 下实现接口,以及调用 case 层,处理 domain 领域的编排。整个实现过程是逐步有骨架,之后陆续填充完善的过程,你只要跟住理解清楚了,就可以很容易的完成全部编码。

    温馨提示 :尽量不要完全对照视频写代码,可以是先看视频,之后对照工程中分支代码变化(前面一节教程有告诉大家怎么对比),之后来实现功能。这样你会注意到更多的细节。

    二、功能设计
    如图,会话领域服务功能;

    img_18.png

    首先,我们是把一次连接请求作为一次会话来看。比如数据库查询会话、MyBatis 操作会话、信贷交易会话,会话的作用在于以统一的标识记录用户的操作行为,根据你的会话ID,可以找到你再次过程中所有的行为记录。

    那么,在 AI MCP Gateway 中,也是按照 MCP 的协议方式,进行会话请求和消息处理。会话的作用是拿到后续处理消息的请求地址,这个地址是在会话请求阶段进行分配的。在前面章节进行协议分析的时候,有讲解过 /mcp/message?sessionId= 的获取和使用

    三、编码实现

    1. 工程结构
    • domain 领域,用于实现各个核心服务,其实相对于 mvc 的 service 一个个原子方法,在 ddd 中会被抽取到独立的包下,也就称之为领域。就像家里,你的衣服,裤子,袜子等,给你一个独立的衣帽间。所以,这也是为啥大厂更喜欢推 ddd 的原因。

    • service 是实现具体领域服务的,model 下是实现这些服务所所需要的对象。这里我们暂时只需要一个值读写,用于描述会话属性信息。因为它不需要做落库,也不没有任何事务,也不具备唯一ID。就像一个人,会话类似人的手套,帽子的感觉,是人的一个装饰。如果是一个独立的人,一般会定义为实体(entity),而多个实体要一起落库的时候,会考虑做 aggregate 聚合。其实 mvc 编码里只不过是多一些入参,一些去操作。那么在 ddd 里是把这些东西定义了标准。让大家的编码尽量在同一一个设计下落地。

    • adapter 是适配器,它的用途在于 domain 领域服务,要与外部接口、数据库、缓存、mq等对接时候,则通过 adapter 定义接口,让基础设施层做实现。这样的设计是依赖倒置,在 spring 的源码中使用的非常多。所以,其实好东西,好的设计都是非常有用的。做编码,做程序员,也是要不断积累自己的。

    1. 功能实现
      2.1 会话对象
      @Getter
      @Builder
      @AllArgsConstructor
      @NoArgsConstructor
      public class SessionConfigVO {

      /**

      • 会话唯一标识符
        */
        private String sessionId;

      /**

      • 流式响应
        */
        private Sinks.Many<ServerSentEvent> sink;

      /**

      • 会话时间
        */
        private Instant createTime;

      /**

      • 最后访问时间戳,volatile 确保多线程下可见性
        */
        private volatile Instant lastAccessedTime;

      /**

      • 会话活跃状态标识
        */
        private volatile boolean active;

      public SessionConfigVO(String sessionId, Sinks.Many<ServerSentEvent> sink) {
      this.sessionId = sessionId;
      this.sink = sink;
      this.createTime = Instant.now();
      this.lastAccessedTime = Instant.now();
      this.active = true;
      }

      /**

      • 标记会话为非活跃状态
        */
        public void markInactive() {
        this.active = false;
        }

      /**

      • 更新最后访问时间
        */
        public void updateLastAccessed() {
        this.lastAccessedTime = Instant.now();
        }

      /**

      • 过期时间判断
        */
        public boolean isExpired(long timeoutMinutes) {
        return lastAccessedTime.isBefore(Instant.now().minus(timeoutMinutes, ChronoUnit.MINUTES));
        }

    }

    sessionId,是会话唯一ID标识。

    sink 作为会话的流式响应对象。

    createTime、lastAccessedTime、active,时间和会话标识。

    markInactive、updateLastAccessed、isExpired,这几个是充血模型方法,把他们聚合到值对象内。在 ddd 思想下,会把和对象相关联的这些操作,放到对象中设计实现,这样每个使用对象里方法的service,就都不需要每次使用的时候,就做一次处理了。

    2.2 会话管理
    @Slf4j
    @Service
    public class SessionManagementService implements ISessionManagementService {

    /**
     * 会话超时时间(分钟)- 也可以把配置抽取到yml里
     */
    private static final long SESSION_TIMEOUT_MINUTES = 30;
    
    /**
     * 定时任务调度
     */
    private final ScheduledExecutorService cleanupScheduler = Executors.newSingleThreadScheduledExecutor();
    
    /**
     * 活跃回话存储器,key->sessionId,ConcurrentHashMap 确保线程安全
     */
    private final Map<String, SessionConfigVO> activeSessions = new ConcurrentHashMap<>();
    
    public SessionManagementService() {
        cleanupScheduler.scheduleAtFixedRate(this::cleanupExpiredSessions, 5, 5, TimeUnit.MINUTES);
        log.info("会话管理服务已启动,会话超时时间: {} 分钟", SESSION_TIMEOUT_MINUTES);
    }
    
    @Override
    public SessionConfigVO createSession(String gatewayId) {
        log.info("创建会话 gatewayId:{}", gatewayId);
    
        String sessionId = UUID.randomUUID().toString();
    
        Sinks.Many<ServerSentEvent<String>> sink = Sinks.many().multicast().onBackpressureBuffer();
    
        // 发送端点消息 - 告知客户端消息请求地址(客户端第二次会使用 messageEndpoint 进行请求会话)
        String messageEndpoint = "/" + gatewayId + "/mcp/message?sessionId=" + sessionId;
        sink.tryEmitNext(ServerSentEvent.<String>builder()
                .event("endpoint")
                .data(messageEndpoint)
                .build());
    
        SessionConfigVO sessionConfigVO = new SessionConfigVO(sessionId, sink);
    
        activeSessions.put(sessionId, sessionConfigVO);
    
        log.info("创建会话 gatewayId:{} sessionId:{},当前活跃会话数:{}", gatewayId, sessionId, activeSessions.size());
    
        return sessionConfigVO;
    }
    
    @Override
    public void removeSession(String sessionId) {
        log.info("删除会话配置 sessionId:{}", sessionId);
        SessionConfigVO sessionConfigVO = activeSessions.remove(sessionId);
        if (null == sessionConfigVO) return;
    
        sessionConfigVO.markInactive();
    
        try {
            sessionConfigVO.getSink().tryEmitComplete();
        } catch (Exception e) {
            log.warn("关闭会话Sink时出错:{}", e.getMessage());
        }
    
        log.info("移除会话:{},剩余活跃会话数:{}", sessionId, activeSessions.size());
    }
    
    @Override
    public SessionConfigVO getSession(String sessionId) {
        if (null == sessionId || sessionId.isEmpty()) {
            return null;
        }
    
        SessionConfigVO sessionConfigVO = activeSessions.get(sessionId);
        if (null != sessionConfigVO && sessionConfigVO.isActive()) {
            sessionConfigVO.updateLastAccessed();
            return sessionConfigVO;
        }
    
        return null;
    }
    
    public void cleanupExpiredSessions() {
        int cleanedCount = 0;
    
        for (Map.Entry<String, SessionConfigVO> entry : activeSessions.entrySet()) {
            SessionConfigVO sessionConfigVO = entry.getValue();
    
            if (!sessionConfigVO.isActive() || sessionConfigVO.isExpired(SESSION_TIMEOUT_MINUTES)) {
                removeSession(sessionConfigVO.getSessionId());
                cleanedCount++;
            }
    
        }
    
        // 记录清理日志
        if (cleanedCount > 0) {
            log.info("清理了 {} 个过期会话,剩余活跃会话数: {}", cleanedCount, activeSessions.size());
        }
    }
    
    @Override
    public void shutdown() {
        log.info("关闭会话管理服务...");
    
        for (String sessionId : activeSessions.keySet()) {
            removeSession(sessionId);
        }
    
        // 关闭清理调度器
        cleanupScheduler.shutdown();
    
        try {
            // 等待5秒让正在执行的任务完成
            if (!cleanupScheduler.awaitTermination(5, TimeUnit.SECONDS)) {
                // 超时强制关闭
                cleanupScheduler.shutdown();
            }
        } catch (InterruptedException e) {
            // 异常强制关闭
            cleanupScheduler.shutdown();
            Thread.currentThread().interrupt();
        }
    
        log.info("关闭会话管理服务完成");
    }
    

    }

    生命周期与流程

    创建会话

    生成 sessionId : SessionManagementService.java:51

    初始化 sink (多播+回压缓冲): SessionManagementService.java:53

    推送 endpoint 事件(告知客户端消息入口): SessionManagementService.java:57-60

    写入 activeSessions : SessionManagementService.java:64

    会话访问

    查询并校验活跃性: SessionManagementService.java:94-99

    刷新最后访问时间: SessionManagementService.java:97

    会话清理

    定时清理任务启动: SessionManagementService.java:42-45

    过期/非活跃会话移除: SessionManagementService.java:106-113

    优雅关闭

    遍历移除所有会话并完成 sink: SessionManagementService.java:126-138

    调度器关闭与等待终止: SessionManagementService.java:131-143

    核心方法与行为

    createSession(String gatewayId) : SessionManagementService.java:48-69

    生成唯一 sessionId 并创建多播 sink

    向客户端发送 endpoint 事件, data 为消息接口地址: “/“ + gatewayId + “/mcp/message?sessionId=” + sessionId ( SessionManagementService.java:56-60 )

    返回 SessionConfigVO 并记录到活跃表

    getSession(String sessionId) : SessionManagementService.java:89-101

    判空与存取,从活跃表取出后校验 isActive()

    每次访问刷新 lastAccessedTime ,用于过期判定

    removeSession(String sessionId) : SessionManagementService.java:72-86

    从活跃表移除,并 markInactive() ;尝试 sink.tryEmitComplete() 完成事件流

    捕获异常(例如重复完成或下游错误)并记录告警

    cleanupExpiredSessions() : SessionManagementService.java:103-120

    依据 SESSION_TIMEOUT_MINUTES 超时阈值判断过期: SessionManagementService.java:109-111 与 SessionConfigVO.java:70

    统一通过 removeSession 做清理,保持关闭逻辑一致

    shutdown() : SessionManagementService.java:123-146

    移除所有活跃会话并完成所有 sink

    关闭清理调度器并等待终止,处理中断与超时分支

    接口定义: ISessionManagementService.java:14, 22, 29, 36, 41

    createSession / removeSession / getSession / cleanupExpiredSessions / shutdown

    SSE通信机制

    事件模型: ServerSentEvent ,事件名为 endpoint , data 携带消息接口地址: SessionManagementService.java:57-60

    连接步骤(典型):

    客户端通过 SSE 建立会话创建流,收到 endpoint 事件

    客户端使用 data 指定的 messageEndpoint 发起后续消息交互(会话上下文通过 sessionId 绑定)

    Sink 特性:

    Sinks.many().multicast().onBackpressureBuffer() 支持多订阅者与回压缓冲: SessionManagementService.java:53

    完成流使用 tryEmitComplete() ,避免异常抛出导致服务中断: SessionManagementService.java:80-83

    这部分代码,本节视频会带着编写和讲解。如果不理解,也可以多看几次视频。

    《AI MCP Gateway》第3-3节:会话接口编排

    一、本章诉求
    定义 mcp 通信协议会话服务接口,并在 case 层做领域服务的编排,为接口提供服务能力。

    二、功能设计
    如图,会话服务编排接口;

    img_19.png

    首先,这一节我们的主要定义并实现 mcp 服务,sse 请求接口,创建会话信息。地址案例:http://localhost:8777/api-gateway/test10001/mcp/sse?api_key=xxx

    test10001 - 是每个MCP网关,申请获得的唯一标识ID

    mcp/sse - 是固定的接口格式,让使用方知道这是 mcp 服务,sse 通信协议。

    api_key - 是一个服务网路请求时候的安全校验,确保是你申请了唯一的key,才可以访问的。这部分鉴权的操作,会放到后续实现。

    之后,这里我们会看到 trigger 层是实现 MCP 服务接口,接口的实现,需要调用 domain 领域层(domain 是每一个内聚的服务方法)。而为了减轻 trigger 层的编码压力,这里我们引入下 case 层,来处理 trigger 层调用 domain 领域层所做的编排动作。让 trigger 层只负责一个接口的封装操作,如;日志打印、参数校验、对象转换、异常处理、结果封装,而具体的逻辑则有 case 层承接。

    注意,case 层的编排操作,会采用星球中的扳手工程项目下的设计模式框架中的规则树,这个规则树非常适合节点的编排,对于流程的解耦非常有效。

    在互联网大厂中,会有很多这样的解耦设计,架构也不是一成不变的。这些东西都会随着各种业务的迭代,不断的进行演进。所以,做为编码工程师,未来的架构师,都要大量的吸收这些编码设计经验。

    三、编码实现

    1. 工程结构

    api 层定义接口,之后由 trigger 层统一实现。别小看 api 层的定义,感觉它放到 trigger 层也可以实现,这里之所有抽取出来,一方面是为了统一标准,以后想知道实现了哪些接口也非常容易。另外如果是做 rpc 接口服务,api 层是可以打一个 jar 包对外,让别人引入使用的。关于 RPC 教程:https://bugstack.cn/md/road-map/dubbo.html

    case 层编排 domain 领域服务,也就是说,我们通过抽象设计出来的一个个领域服务方法,在通过 case 层,调用这些领域服务方法,进行编排串联起来,让他们具备一个完整的业务功能。这样做的好处是可以更好的解耦和扩展。

    1. 功能实现
      2.1 引入框架
    cn.bugstack.wrench xfg-wrench-starter-design-framework 3.0.0

    引入扳手工程的设计模式框架,使用通用设计模式。

    教程:《通用技术组件 - 🔧扳手工程》第2节:责任链和规则树通用模型框架

    2.2 服务编排
    2.2.1 模型结构

    一个编排服务的标准结构;

    AbstractMcpMessageSupport 抽象通用方法,以及继承编排服务。

    node 下是各个编排的节点,这些节点可以通过代码方式顺序组装,从 RootNode -> VerifyNode -> SessionNode -> EndNode

    DefaultMcpSessionFactory 工厂,包装服务,给 McpMessageService 使用。

    2.2.2 会话节点(举例)
    @Slf4j
    @Service
    public class SessionNode extends AbstractMcpMessageSupport {

    @Resource
    private EndNode endNode;
    
    @Override
    protected Flux<ServerSentEvent<String>> doApply(String requestParameter, DefaultMcpSessionFactory.DynamicContext dynamicContext) throws Exception {
        log.info("创建会话-SessionNode:{}", requestParameter);
    
        // 创建会话服务
        SessionConfigVO sessionConfigVO = sessionManagementService.createSession(requestParameter);
    
        // 写入上下文中
        dynamicContext.setSessionConfigVO(sessionConfigVO);
    
        return router(requestParameter, dynamicContext);
    }
    
    @Override
    public StrategyHandler<String, DefaultMcpSessionFactory.DynamicContext, Flux<ServerSentEvent<String>>> get(String requestParameter, DefaultMcpSessionFactory.DynamicContext dynamicContext) throws Exception {
        return endNode;
    }
    

    }

    每个节点都会继承 AbstractMcpMessageSupport 抽象支撑类(Spring 中也有很多 XxxSupport 的设计,把一些逻辑提取出来,供给子类使用)

    之后 doApply 受理服务的操作,可以调用领域层方法,实现逻辑。这里目前看着代码并不多,但这样抽取后是非常方便扩展的,如果有一些升级操作,也可以在实现一个节点,通过 get 方法的路由操作,进行切量。所以,它是非常灵活的。

    2.2.3 服务调用
    @Service
    public class McpMessageService implements IMcpSessionService {

    @Resource
    private DefaultMcpSessionFactory defaultMcpSessionFactory;
    
    @Override
    public Flux<ServerSentEvent<String>> createMcpSession(String gatewayId) throws Exception {
    
        StrategyHandler<String, DefaultMcpSessionFactory.DynamicContext, Flux<ServerSentEvent<String>>> strategyHandler = defaultMcpSessionFactory.strategyHandler();
    
        return strategyHandler.apply(gatewayId, new DefaultMcpSessionFactory.DynamicContext());
    }
    

    }

    这一部分是调用会话工厂的节点编排服务,完成会话创建操作。

    2.3 接口实现
    2.3.1 定义接口
    public interface IMcpGatewayService {

    /**
     * 建立 SSE 连接
     * @param gatewayId 网关ID
     * @return 流式响应
     */
    Flux<ServerSentEvent<String>> establishSSEConnection(String gatewayId) throws Exception;
    

    }

    2.3.2 实现接口
    @Slf4j
    @RestController
    @CrossOrigin(origins = ““, allowedHeaders = ““, methods = {RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE, RequestMethod.OPTIONS})
    @RequestMapping(“/“)
    public class McpGatewayController implements IMcpGatewayService {

    @Resource
    private IMcpSessionService mcpSessionService;
    
    public McpGatewayController() {
        System.out.println("xxxx");
    }
    
    /**
     * 建立sse连接,创建会话
     * <br/>
     * <a href="http://localhost:8777/api-gateway/test10001/mcp/sse">http://localhost:8777/api-gateway/test10001/mcp/sse</a>
     *
     * @param gatewayId 网关ID
     */
    @GetMapping(value = "{gatewayId}/mcp/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    @Override
    public Flux<ServerSentEvent<String>> establishSSEConnection(@PathVariable("gatewayId") String gatewayId) throws Exception {
        try {
            log.info("建立 MCP SSE 连接,gatewayId:{}", gatewayId);
            if (StringUtils.isBlank(gatewayId)) {
                log.info("非法参数,gateway is null");
                throw new AppException(ResponseCode.ILLEGAL_PARAMETER.getCode(), ResponseCode.ILLEGAL_PARAMETER.getInfo());
            }
    
            return mcpSessionService.createMcpSession(gatewayId);
        } catch (Exception e) {
            log.error("建立 MCP SSE 连接失败,gatewayId: {}", gatewayId, e);
            throw e;
        }
    }
    

    }

    通过调用 case 层方法,IMcpSessionService mcpSessionService 返回建立sse连接,创建会话 的操作。

    这里还有一个 McpGatewayController 构造函数,打印了 System.out.println(“xxxx”); 方法。这个是告诉大家,如果你的启动后,访问不到服务,那么可以在 System.out.println(“xxxx”); 一行打断点,看看有没有被 Spring 容器扫描到并管理,如果没有,那么你的 app 层的 pom 引入了 trigger 不,此外,spring 默认的扫描路径覆盖到了 trigger 层。如果仍然遇到问题,可以使用 ai idea 打开代码让排查,如;trae.ai

    2.4 访问地址
    server:
    port: 8777
    servlet:
    context-path: /api-gateway

    注意,dev yml 文件,配置的 port 端口和 context-path 默认起始访问路径。

    这样才能访问 http://localhost:8777/api-gateway/test10001/mcp/sse

    四、测试验证
    启动服务,并通过浏览器访问接口。

    . ____ _ __ _ _
    /\ / _ __ _ ()_ __ __ _ \ \ \
    ( ( )_
    _ | ‘_ | ‘| | ‘ / ` | \ \ \
    \/ _)| |)| | | | | || (_| | ) ) ) )
    ‘ |
    | .|| ||| |_, | / / / /
    =========|
    |==============|_/=//_//

    :: Spring Boot :: (v3.4.3)

    25-12-13.14:57:01.875 [main ] INFO Application - Starting Application using Java 17.0.15 with PID 32176 (/Users/fuzhengwei/coding/gitcode/KnowledgePlanet/ai-mcp-gateway/ai-mcp-gateway/ai-mcp-gateway-app/target/classes started by fuzhengwei in /Users/fuzhengwei/coding/gitcode/KnowledgePlanet/ai-mcp-gateway/ai-mcp-gateway)
    25-12-13.14:57:01.876 [main ] INFO Application - The following 1 profile is active: “dev”
    25-12-13.14:57:02.122 [main ] WARN ClassPathMapperScanner - No MyBatis mapper was found in ‘[cn.bugstack.ai]’ package. Please check your configuration.
    25-12-13.14:57:02.299 [main ] INFO TomcatWebServer - Tomcat initialized with port 8777 (http)
    25-12-13.14:57:02.304 [main ] INFO Http11NioProtocol - Initializing ProtocolHandler [“http-nio-8777”]
    25-12-13.14:57:02.304 [main ] INFO StandardService - Starting service [Tomcat]
    25-12-13.14:57:02.304 [main ] INFO StandardEngine - Starting Servlet engine: [Apache Tomcat/10.1.36]
    25-12-13.14:57:02.322 [main ] INFO [/api-gateway] - Initializing Spring embedded WebApplicationContext
    25-12-13.14:57:02.322 [main ] INFO ServletWebServerApplicationContext - Root WebApplicationContext: initialization completed in 433 ms
    xxxx
    25-12-13.14:57:02.349 [main ] INFO SessionManagementService - 会话管理服务已启动,会话超时时间: 30 分钟
    25-12-13.14:57:02.390 [main ] INFO OptionalValidatorFactoryBean - Failed to set up a Bean Validation provider: jakarta.validation.NoProviderFoundException: Unable to create a Configuration, because no Jakarta Bean Validation provider could be found. Add a provider like Hibernate Validator (RI) to your classpath.
    25-12-13.14:57:02.525 [main ] INFO Http11NioProtocol - Starting ProtocolHandler [“http-nio-8777”]
    25-12-13.14:57:02.530 [main ] INFO TomcatWebServer - Tomcat started on port 8777 (http) with context path ‘/api-gateway’
    25-12-13.14:57:02.534 [main ] INFO Application - Started Application in 0.866 seconds (process running for 1.015)

    请求地址后,可以看到这样一个输出结果,证明你目前编写的代码是通过的。

    《AI MCP Gateway》第3-4节:会话消息结构设计

    一、本章诉求
    增加 MCP 会话通信,处理请求消息的 HTTP 服务入口方法,完成简单的消息请求接收验证。并根据消息信息,设计会话领域层中消息处理策略。

    二、功能设计
    如图,会话消息响应设计;

    img_20.png

    首先,这里要设计一个同名接口的不同类型服务,get 用于创建会话服务,建立 sse 连接。而 post 则是处理端点消息,完成会话服务应答。

    之后,对于会话消息,我们在前面已经分析过,主要包括;InitializeHandler - 协议握手、ResourcesListHandler - 返回可用资源列表、ToolsCallHandler - 执行指定的工具调用、ToolsListHandler - 返回服务器支持的工具列表。本节我们先把这些策略结构设计出来,方便后续实现具体功能。

    重点,本节会先来实现接口和定义整个处理消息的结构,后续再做具体的功能实现,以及服务的编排动作。

    此外,本节还会引入 jdk16+ 定义的新语法关键字,record 、sealed 、permits 来定义对象。

    三、编码实现

    1. 工程结构

    trigger -> http,增加一个处理消息请求的接口服务。之后这里先调用 domain 领域层的 message 服务。

    这里要重点理解关于 MCP 服务的使用,创建会话和处理消息。所以本节还会引入 Spring AI 测试 MCP 服务,让大家理解 MCP 服务的使用过程。

    1. 前置条件
      2.1 申请 MCP 服务

    2.2 引入框架

    org.springframework.ai
    spring-ai-bom
    ${spring-ai.version}
    pom
    import

    在工程根 POM 配置 Spring AI 框架,spring-ai.version 1.0.0 也可以是其他更新的版本。

    之后在 ai-mcp-gateway-app 模块下,引入 spring-boot-starter-webflux、spring-ai-starter-model-openai、spring-ai-starter-mcp-server-webflux 组件,便于我们进行测试验证。

    2.3 YML 配置
    spring:
    ai:
    openai:
    base-url: https://apis.itedus.cn
    api-key: sk-2nQF8Y9ZxC8nL0Gp7f7bB43aDc5743F1A**** 可以联系小傅哥的渠道申请开发测试
    chat:
    options:
    model: gpt-4.1-mini

    在 application-dev.yml 配置 ai 请求地址和 apikey

    1. 定义接口(消息处理)
      @Slf4j
      @RestController
      @CrossOrigin(origins = ““, allowedHeaders = ““, methods = {RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE, RequestMethod.OPTIONS})
      @RequestMapping(“/“)
      public class McpGatewayController implements IMcpGatewayService {

      @Resource
      private IMcpSessionService mcpSessionService;

      /**

      • 处理 sse 连接,创建会话


      • http://localhost:8777/api-gateway/test10001/mcp/sse

      • @param gatewayId 网关ID
        */
        @GetMapping(value = “{gatewayId}/mcp/sse”, produces = MediaType.TEXT_EVENT_STREAM_VALUE)
        @Override
        public Flux<ServerSentEvent> handleSseConnection(@PathVariable(“gatewayId”) String gatewayId) throws Exception {
        try {
        log.info(“建立 MCP SSE 连接,gatewayId:{}”, gatewayId);
        if (StringUtils.isBlank(gatewayId)) {
        log.info(“非法参数,gateway is null”);
        throw new AppException(ResponseCode.ILLEGAL_PARAMETER.getCode(), ResponseCode.ILLEGAL_PARAMETER.getInfo());
        }

         return mcpSessionService.createMcpSession(gatewayId);
        

        } catch (Exception e) {
        log.error(“建立 MCP SSE 连接失败,gatewayId: {}”, gatewayId, e);
        throw e;
        }
        }

      /**

      • 处理 sse 消息,响应会话

      • @param gatewayId 网关ID

      • @param sessionId 会话ID

      • @param messageBody 请求消息

      • @return 响应结果


      • {

      • "jsonrpc": "2.0",
        
      • "method": "initialize",
        
      • "id": "95835f74-0",
        
      • "params": {
        
      •     "protocolVersion": "2024-11-05",
        
      •     "capabilities": {},
        
      •     "clientInfo": {
        
      •         "name": "Java SDK MCP Client",
        
      •         "version": "1.0.0"
        
      •     }
        
      • }
        
      • }
        */
        @PostMapping(value = “{gatewayId}/mcp/sse”, consumes = MediaType.APPLICATION_JSON_VALUE)
        public Mono<ResponseEntity> handleMessage(@PathVariable(“gatewayId”) String gatewayId,
        @RequestParam String sessionId,
        @RequestBody String messageBody) {
        try {
        log.info(“处理 MCP SSE 消息,gatewayId:{} sessionId:{} messageBody:{}”, gatewayId, sessionId, messageBody);

         return Mono.just(ResponseEntity.ok(Map.of("status", "sent via SSE")));
        

        } catch (Exception e) {
        log.info(“处理 MCP SSE 消息失败,gatewayId:{} sessionId:{} messageBody:{}”, gatewayId, sessionId, messageBody, e);
        return Mono.empty();
        }

        }

        }

        定义 handleMessage 接口,这是一个消息处理的接口,接口的 value 地址与 handleSseConnection 是同名的。这样我们可以打印下关于接受到的日志信息。在 mcp 协议包中 io.modelcontextprotocol.server.transport.WebFluxSseServerTransportProvider 也有类似这样的配置,只不过使用的是 RouterFunctions.route().GET(this.sseEndpoint, this::handleSseConnection).POST(this.messageEndpoint, this::handleMessage).build(); 方法。

        注意,SessionManagementService.createSession 创建会话服务,要补充下 String messageEndpoint = “/api-gateway/“ + gatewayId + “/mcp/sse?sessionId=” + sessionId; 原来没有加 api-gateway 否则下次消息处理会找不到请求地址。

        1. 会话领域(消息处理)
          4.1 定义对象
          public final class McpSchemaVO {

          /**

          • JSON-RPC 2.0 Message Types
            */
            public sealed interface JSONRPCMessage permits JSONRPCRequest, JSONRPCResponse {

            String jsonrpc();

          }

          /**

          • 请求对象
          • @param jsonrpc 协议版本 2.0
          • @param method 请求方法;initialize、tools/list、tools/call、resources/list
          • @param id 请求ID
          • @param params 请求参数
            */
            @JsonInclude(JsonInclude.Include.NON_ABSENT)
            @JsonIgnoreProperties(ignoreUnknown = true)
            public record JSONRPCRequest(@JsonProperty(“jsonrpc”) String jsonrpc,
            @JsonProperty(“method”) String method,
            @JsonProperty(“id”) Object id,
            @JsonProperty(“params”) Object params
            ) implements JSONRPCMessage {
            }

          /**

          • 响应对象
          • @param jsonrpc 协议版本 2.0
          • @param id 请求ID
          • @param result 响应结果
          • @param error 异常结果
            */
            @JsonInclude(JsonInclude.Include.NON_ABSENT)
            @JsonIgnoreProperties(ignoreUnknown = true)
            public record JSONRPCResponse(
            @JsonProperty(“jsonrpc”) String jsonrpc,
            @JsonProperty(“id”) Object id,
            @JsonProperty(“result”) Object result,
            @JsonProperty(“error”) JSONRPCError error
            ) implements JSONRPCMessage {
            @JsonInclude(JsonInclude.Include.NON_ABSENT)
            @JsonIgnoreProperties(ignoreUnknown = true)
            public record JSONRPCError(
            @JsonProperty(“code”) int code,
            @JsonProperty(“message”) String message,
            @JsonProperty(“data”) Object data) {
            }
            }

        }

        优点类别

        具体好处

        类型安全

        限制实现类,编译器检查,避免错误

        代码简洁

        record 自动生成方法,减少样板代码

        不可变性

        record 字段 final,线程安全,状态稳定

        JSON 兼容性

        注解控制序列化行为,忽略未知字段,减少数据

        设计清晰

        sealed 明确设计意图,便于理解和维护

        这段代码是 jdk 16+ 以后的新特性,用一种新的方式创建对象。和我们以前使用 class + get、set 是一样的诉求。

        record 简化了不可变数据类的定义,自动生成常用方法。

        sealedinterface 限制了接口的实现范围,增强类型安全。

        permits 关键字后面列出了允许实现该接口的具体类或接口。

        还可以再检索些关于这样设计的意图,多一些积累,会让你在编码思维上有更多的提升。

        4.2 消息策略
        domain -> service 下,增加了 ISessionMessageService 与 ISessionManagementService 是2个单一职责接口。每个类负责自己的逻辑,做到功能隔离。

        接口定义

        public interface IRequestHandler {

        McpSchemaVO.JSONRPCResponse handle(McpSchemaVO.JSONRPCRequest message);
        

        }

        方法实现

        @Slf4j
        @Service(“initializeHandler”)
        public class InitializeHandler implements IRequestHandler {

        @Override
        public McpSchemaVO.JSONRPCResponse handle(McpSchemaVO.JSONRPCRequest message) {
        
            log.info("模拟处理初始化请求");
        
            return new McpSchemaVO.JSONRPCResponse("2.0", message.id(), Map.of(
                    "protocolVersion", "2024-11-05",
                    "capabilities", Map.of(
                            "tools", Map.of(),
                            "resources", Map.of()
                    ),
                    "serverInfo", Map.of(
                            "name", "MCP Weather Proxy Server",
                            "version", "1.0.0"
                    )
            ), null);
        
        }
        

        }

        举例一个实现 InitializeHandler,其他几个 ResourcesListHandler、ToolsCallHandler、ToolsListHandler,也只是空实现。

        我们先把结构框架定义出来,后续小傅哥会带着大家完善这里的细节功能。有时候在公司架构师也会做这样的事情,做定义标准,之后再分配活,由各个小伙伴实现。

        4.3 服务调用
        @Slf4j
        @Service
        public class SessionMessageService implements ISessionMessageService {

        @Resource
        private Map<String, IRequestHandler> requestHandlerMap;
        
        @Override
        public McpSchemaVO.JSONRPCResponse processHandlerMessage(McpSchemaVO.JSONRPCRequest request) {
            String method = request.method();
            log.info("开始处理请求,方法: {}", method);
        
            SessionMessageHandlerMethodEnum sessionMessageHandlerMethodEnum = SessionMessageHandlerMethodEnum.getByMethod(method);
            if (null == sessionMessageHandlerMethodEnum) {
                throw new AppException(METHOD_NOT_FOUND.getCode(), METHOD_NOT_FOUND.getInfo());
            }
        
            String handlerName = sessionMessageHandlerMethodEnum.getHandlerName();
            IRequestHandler requestHandler = requestHandlerMap.get(handlerName);
        
            if (null == requestHandler) {
                throw new AppException(METHOD_NOT_FOUND.getCode(), METHOD_NOT_FOUND.getInfo());
            }
        
            // 使用枚举策略模式处理请求
            return requestHandler.handle(request);
        }
        

        }

        Map<String, IRequestHandler> requestHandlerMap 是一种策略注入手段,所有实现了 IRequestHandler 接口的方法都会被注入到 Map 中,而 key 则是 Bean 的名称。关于 Spring 的注入特性,可以阅读《Spring Dependency Injection - 依赖注入使用技巧》

        这里对于 requestHandlerMap 的策略注入外,还定义了一个枚举类,通过枚举衔接入参方法到调用的处理。

        枚举调用

        @Slf4j
        @Getter
        @AllArgsConstructor
        public enum SessionMessageHandlerMethodEnum {

        // 根据实际业务需求定义方法类型
        INITIALIZE("initialize", "initializeHandler", "初始化请求"),
        TOOLS_LIST("tools/list", "toolsListHandler", "工具列表请求"),
        TOOLS_CALL("tools/call", "toolsCallHandler", "工具调用请求"),
        RESOURCES_LIST("resources/list", "resourcesListHandler", "资源列表请求"),
        
        ;
        
        private final String method;
        private final String handlerName;
        private final String description;
        
        /**
         * 根据方法名获取枚举
         *
         * @param method 方法名
         * @return 对应的枚举,如果找不到返回UNKNOWN
         */
        public static SessionMessageHandlerMethodEnum getByMethod(String method) {
            for (SessionMessageHandlerMethodEnum value : values()) {
                if (value.getMethod().equals(method)) {
                    return value;
                }
            }
            return null;
        }
        

        }

        mcp handler 入参中获取的 method 方法名称是 initialize 之后要关联上策略方法,initializeHandler。

        initializeHandler、toolsListHandler、toolsCallHandler、resourcesListHandler,都是对应的 IRequestHandler 接口实现类。通过枚举方式关联。

        1. 服务调用
          @PostMapping(value = “{gatewayId}/mcp/sse”, consumes = MediaType.APPLICATION_JSON_VALUE)
          public Mono<ResponseEntity> handleMessage(@PathVariable(“gatewayId”) String gatewayId,
          @RequestParam String sessionId,
          @RequestBody String messageBody) {
          try {
          log.info(“处理 MCP SSE 消息,gatewayId:{} sessionId:{} messageBody:{}”, gatewayId, sessionId, messageBody);
          McpSchemaVO.JSONRPCMessage jsonrpcMessage = McpSchemaVO.deserializeJsonRpcMessage(messageBody);
          log.info(“序列化消息:{}”, jsonrpcMessage.jsonrpc());

           // 暂时直接调用 domain,后续调整
           McpSchemaVO.JSONRPCResponse jsonrpcResponse = serviceMessageService.processHandlerMessage((McpSchemaVO.JSONRPCRequest) jsonrpcMessage);
           log.info("调用结果:{}", JSON.toJSONString(jsonrpcResponse));
           return Mono.just(ResponseEntity.ok(Map.of("status", "sent via SSE")));
          

          } catch (Exception e) {
          log.info(“处理 MCP SSE 消息失败,gatewayId:{} sessionId:{} messageBody:{}”, gatewayId, sessionId, messageBody, e);
          return Mono.empty();
          }
          }

          这里先把 serviceMessageService 的 domain 领域服务注入到 trigger 了,先做一个暂时的调用,方便验证服务流程。

          四、功能测试

          1. 单测方法
            @Slf4j
            @RunWith(SpringRunner.class)
            @SpringBootTest
            public class ApiTest {

            @Resource
            private ChatClient.Builder chatClientBuilder;

            @Test
            public void test_mcp() {
            ChatClient chatClient = chatClientBuilder.defaultOptions(
            OpenAiChatOptions.builder()
            .model(“gpt-4.1-mini”)
            .toolCallbacks(new SyncMcpToolCallbackProvider(sseMcpClient02()).getToolCallbacks())
            .build())
            .build();

             // 有哪些工具可以使用
             log.info("测试结果:{}", chatClient.prompt("有哪些工具可以使用").call().content());
            

            }

            public McpSyncClient sseMcpClient02() {
            HttpClientSseClientTransport sseClientTransport = HttpClientSseClientTransport
            .builder(“http://127.0.0.1:8777“)
            .sseEndpoint(“/api-gateway/test10001/mcp/sse”)
            .build();

             McpSyncClient mcpSyncClient = McpClient.sync(sseClientTransport).requestTimeout(Duration.ofMinutes(36000)).build();
             var init_sse = mcpSyncClient.initialize();
             log.info("Tool SSE MCP02 Initialized {}", init_sse);
            
             return mcpSyncClient;
            

            }

            /**

          }

          test_mcp 是 Spring AI 代码,简单验证 MCP 服务。

          这里有2个 MCP 服务。sseMcpClient01、sseMcpClient02。一个是百度的,一个是我们自己的网关服务。主要是验证 AI 在调用 MCP 服务的时候,是一个怎样的过程和数据。

          1. 功能验证
            测试的时候,要先 debug 方式启动网关服务,之后在执行测试 test_mcp 方法。

          调试运行,他会先访问到 handleSseConnection 创建会话服务,之后在进入到 handleMessage 处理消息。

          AI Client 就是这样的调用过程,先和 MCP 构建一个会话,之后在这个会话下完成消息的处理过程。

          注意,这部分还不是完整通信协议的全流程。整个运行会有报错提示(后续才会逐步开发这些内容);java.lang.NullPointerException: Cannot invoke “java.lang.Throwable.getMessage()” because the return value of “org.springframework.core.NestedExceptionUtils.getMostSpecificCause(java.lang.Throwable)” is null

          《AI MCP Gateway》第3-5节:消息协议处理案例

          一、本章诉求
          整个 MCP 协议的通信调用过程,需要先创建 session 会话,之后通过 handler 接收下发的指令方法,完成整个 MCP 的处理。那么,为了让大家更清楚我们所开发的东西,这里我们要先做一些流程案例,完成整个 MCP 服务的通信过程。后续在把案例的固定的代码,拆分到数据库配置实现。

          二、功能设计
          如图,会话消息响应设计;

          img_21.png

          首先,MCP 的通信过程,分为了初始化,后去工具列表,调用工具,以及资源和通知等。这里我们要硬编码返参实现这些方法。

          之后,我们这里做一个单词小写转换大写的方法,让 AI 通过 MCP 调用到网关服务。做完这部分,你就能联想到,这里既然也可以硬编码操作,那么也可以调用 http、rpc,甚至是 mq,以及还可以是 rs232 串口通信,控制硬件设备。

          注意,这一节的实现,还是从 trigger 触发器的 http 层,直接调用到 domain 领域方法,后续在从 case 层进行串联流程。

          三、编码实现

          1. 工程结构

          首先,domain 领域层的 message 消息处理中,ToolsListHandler - 返回工具列表、ToolsCallHandler - 负责完成工具调用。

          然后,ToolsListHandler MCP 协议告诉 AI 你可以调用一个单词的小写转换,入参是什么名称,什么类型。把这些都要告诉 AI 客户端。

          之后,ToolsCallHandler 是接收 AI 客户端下达的调用方法以及入参信息,之后 MCP 来接收这个数据并完成工具的调用。

          1. 功能实现
            2.1 消息处理
            2.1.1 InitializeHandler 初始化
            @Slf4j
            @Service(“initializeHandler”)
            public class InitializeHandler implements IRequestHandler {

            @Override
            public McpSchemaVO.JSONRPCResponse handle(McpSchemaVO.JSONRPCRequest message) {

             log.info("模拟处理初始化请求");
            
             return new McpSchemaVO.JSONRPCResponse("2.0", message.id(), Map.of(
                     "protocolVersion", "2025-12-27",
                     "capabilities", Map.of(
                             "tools", Map.of(),
                             "resources", Map.of()
                     ),
                     "serverInfo", Map.of(
                             "name", "MCP Word Util Proxy Server",
                             "version", "1.0.0"
                     )
             ), null);
            

            }

          }

          这部分结构不用调整,写一个 MCP Word Util Proxy Server 返回就可以了。

          2.1.2 ResourcesListHandler 资源列表
          @Slf4j
          @Service(“resourcesListHandler”)
          public class ResourcesListHandler implements IRequestHandler {

          @Override
          public McpSchemaVO.JSONRPCResponse handle(McpSchemaVO.JSONRPCRequest message) {
              return new McpSchemaVO.JSONRPCResponse("2.0", message.id(), Map.of(
                      "resources", Map.of(
                              "resources", new Object[]{}
                      )
              ), null);
          }
          

          }

          这部分返回一个空的资源信息就可以。

          2.1.3 ToolsListHandler 工具列表
          @Slf4j
          @Service(“toolsListHandler”)
          public class ToolsListHandler implements IRequestHandler {

          @Override
          public McpSchemaVO.JSONRPCResponse handle(McpSchemaVO.JSONRPCRequest message) {
              return new McpSchemaVO.JSONRPCResponse("2.0", message.id(), Map.of(
                      "tools", new Object[]{
                              Map.of(
                                      "name", "toUpperCase",
                                      "description", "小写转大写",
                                      "inputSchema", Map.of(
                                              "type", "object",
                                              "properties", Map.of(
                                                      "word", Map.of(
                                                              "type", "string",
                                                              "description", "单词,字符串"
                                                      )
                                              ),
                                              "required", new String[]{"word"}
                                      )
                              )
                      }
              ), null);
          
          }
          

          }

          这里返回的就是一个工具方法的描述信息,包括它的入参名称,类型,描述。以及说明 required 必传字段 word

          2.1.4 ToolsCallHandler 方法调用
          @Slf4j
          @Service(“toolsCallHandler”)
          public class ToolsCallHandler implements IRequestHandler {

          @Override
          public McpSchemaVO.JSONRPCResponse handle(McpSchemaVO.JSONRPCRequest message) {
          
              Object id = message.id();
              Object params = message.params();
          
              if (!(params instanceof Map)) {
          
                  new McpSchemaVO.JSONRPCResponse.JSONRPCError(McpErrorCodes.INVALID_PARAMS, "Invalid arguments format", null);
          
                  return new McpSchemaVO.JSONRPCResponse("2.0",
                          message.id(),
                          null,
                          new McpSchemaVO.JSONRPCResponse.JSONRPCError(McpErrorCodes.INVALID_PARAMS, "无效参数 - 无效的方法参数", null));
              }
          
              Map<String, Object> paramsMap = (Map<String, Object>) params;
              String toolName = (String) paramsMap.get("name");
              Object argumentsObj = paramsMap.get("arguments");
          
              Map<String, Object> arguments = (Map<String, Object>) argumentsObj;
          
              if ("toUpperCase".equals(toolName)) {
                  String word = arguments.get("word").toString();
          
                  return new McpSchemaVO.JSONRPCResponse("2.0", message.id(), Map.of(
                          "content", new Object[]{
                                  Map.of(
                                          "type", "text",
                                          "text", word.toUpperCase()
                                  )
                          }
                  ), null);
              }
          
              return new McpSchemaVO.JSONRPCResponse("2.0",
                      message.id(),
                      null,
                      new McpSchemaVO.JSONRPCResponse.JSONRPCError(McpErrorCodes.METHOD_NOT_FOUND, "方法未找到 - 方法不存在或不可用", null));
          }
          

          }

          从 McpSchemaVO.JSONRPCRequest message 获取 AI 客户端,通过 MCP 协议,调用到方法的入参信息。

          从入参里可以获取到要调用的 MCP 方法,toolName 之后在拿到参数信息。这里使用 word.toUpperCase() 进行字符串的小写转换为大写,并以 MCP 协议格式返回结果。

          后面,这部分会调整为 从 HTTP 接口的方式进行调用。

          2.2 消息转换
          @Service
          public class SessionMessageService implements ISessionMessageService {

          @Resource
          private Map<String, IRequestHandler> requestHandlerMap;
          
          @Override
          public McpSchemaVO.JSONRPCResponse processHandlerMessage(McpSchemaVO.JSONRPCMessage message) {
          
              if (message instanceof McpSchemaVO.JSONRPCResponse response) {
                  log.info("收到结果消息");
              }
          
              if (message instanceof McpSchemaVO.JSONRPCRequest request) {
                  String method = request.method();
                  log.info("开始处理请求,方法: {}", method);
          
                  SessionMessageHandlerMethodEnum sessionMessageHandlerMethodEnum = SessionMessageHandlerMethodEnum.getByMethod(method);
                  if (null == sessionMessageHandlerMethodEnum) {
                      throw new AppException(METHOD_NOT_FOUND.getCode(), METHOD_NOT_FOUND.getInfo());
                  }
          
                  String handlerName = sessionMessageHandlerMethodEnum.getHandlerName();
                  IRequestHandler requestHandler = requestHandlerMap.get(handlerName);
          
                  if (null == requestHandler) {
                      throw new AppException(METHOD_NOT_FOUND.getCode(), METHOD_NOT_FOUND.getInfo());
                  }
          
                  // 使用枚举策略模式处理请求
                  return requestHandler.handle(request);
              }
          
              if (message instanceof McpSchemaVO.JSONRPCNotification notification) {
                  log.info("收到即将处理的通知 {} {}", notification.method(), JSON.toJSONString(notification.params()));
              }
          
              return null;
          
          }
          

          }

          在处理 AI 客户端,调用 MCP 服务的时候,会有多种类型参数,JSONRPCResponse、JSONRPCRequest、JSONRPCNotification,所以 processHandlerMessage 方法的入参也要做对应的调整。并在方法内,根据 instanceof 判断类的类型分别进行使用。

          这里 JSONRPCResponse、JSONRPCNotification 暂时不需要做什么,只是打印个日志即可。

          在上一节开发中,我们调试不同的接收的消息类型,你也可以在开发这部分代码的时候进行调试验证。

          2.3 消息响应
          @PostMapping(value = “{gatewayId}/mcp/sse”, consumes = MediaType.APPLICATION_JSON_VALUE)
          public Mono<ResponseEntity> handleMessage(@PathVariable(“gatewayId”) String gatewayId,
          @RequestParam String sessionId,
          @RequestBody String messageBody) {
          try {
          log.info(“处理 MCP SSE 消息,gatewayId:{} sessionId:{} messageBody:{}”, gatewayId, sessionId, messageBody);

              SessionConfigVO session = sessionManagementService.getSession(sessionId);
              if (null == session) {
                  log.warn("会话不存在或已过期,gatewayId:{} sessionId:{}", gatewayId, sessionId);
                  return Mono.just(ResponseEntity.notFound().build());
              }
              
              McpSchemaVO.JSONRPCMessage jsonrpcMessage = McpSchemaVO.deserializeJsonRpcMessage(messageBody);
              log.info("序列化消息:{}", jsonrpcMessage.jsonrpc());
             
              // 暂时直接调用 domain,后续调整
              McpSchemaVO.JSONRPCResponse jsonrpcResponse = serviceMessageService.processHandlerMessage(jsonrpcMessage);
            
              if (null != jsonrpcResponse) {
                  String responseJson = objectMapper.writeValueAsString(jsonrpcResponse);
                  session.getSink().tryEmitNext(ServerSentEvent.<String>builder()
                          .event("message")
                          .data(responseJson)
                          .build());
              }
              return Mono.just(ResponseEntity.accepted().build());
          } catch (Exception e) {
              log.error("处理 MCP SSE 消息失败,gatewayId:{} sessionId:{} messageBody:{}", gatewayId, sessionId, messageBody, e);
              return Mono.just(ResponseEntity.internalServerError().build());
          }
          

          }

          handleMessage 做一些扩展,通过 sessionId 获取 SessionConfigVO 对象,用于 getSink 发送消息。

          McpSchemaVO.deserializeJsonRpcMessage 转换消息类型这个方法也要扩展下,支持 JSONRPCNotification 类型。并进行 serviceMessageService.processHandlerMessage 处理,以及把结果通过会话中的 sink 返回结果。

          注意 Mono.just(ResponseEntity.accepted().build());、Mono.just(ResponseEntity.internalServerError().build()); 包装影响结果。

          本节还引入了 McpErrorCodes.java 类,这样的固定内容,可以直接在课程代码查看。

          四、测试验证

          1. 单测方式
            @Slf4j
            @RunWith(SpringRunner.class)
            @SpringBootTest
            public class ApiTest {

            @Resource
            private ChatClient.Builder chatClientBuilder;

            @Test
            public void test_mcp() {
            ChatClient chatClient = chatClientBuilder.defaultOptions(
            OpenAiChatOptions.builder()
            .model(“gpt-4.1-mini”)
            .toolCallbacks(new SyncMcpToolCallbackProvider(sseMcpClient02()).getToolCallbacks())
            .build())
            .build();

             // 有哪些工具可以使用
             log.info("测试结果:{}", chatClient.prompt("把xiaofuge转换为大写").call().content());
            

            }

            public McpSyncClient sseMcpClient02() {
            HttpClientSseClientTransport sseClientTransport = HttpClientSseClientTransport
            .builder(“http://127.0.0.1:8777“)
            .sseEndpoint(“/api-gateway/test10001/mcp/sse”)
            .build();

             McpSyncClient mcpSyncClient = McpClient.sync(sseClientTransport).requestTimeout(Duration.ofMinutes(36000)).build();
             var init_sse = mcpSyncClient.initialize();
             log.info("Tool SSE MCP02 Initialized {}", init_sse);
            
             return mcpSyncClient;
            

            }

          }

          chatClient.prompt(“把xiaofuge转换为大写”) 的描述,会调用到 ai mcp 的大小写转换操作。

          1. 测试日志

          可以在你需要验证的地方打断点,但要注意,大断点会影响到调用,可能会客户端会报错。不过没啥关系,后面可以不打断点,直接让调用测试。

          2.1 网关日志
          25-12-27.18:22:51.113 [http-nio-8777-exec-1] INFO McpGatewayController - 建立 MCP SSE 连接,gatewayId:test10001
          25-12-27.18:22:51.114 [http-nio-8777-exec-1] INFO RootNode - 创建会话 mcp session RootNode:test10001
          25-12-27.18:22:51.114 [http-nio-8777-exec-1] INFO SessionNode - 创建会话-SessionNode:test10001
          25-12-27.18:22:51.114 [http-nio-8777-exec-1] INFO SessionManagementService - 创建会话 gatewayId:test10001
          25-12-27.18:22:51.118 [http-nio-8777-exec-1] INFO SessionManagementService - 创建会话 gatewayId:test10001 sessionId:5c41f6ac-ab1d-401a-b456-8c6b24fa70be,当前活跃会话数:1
          25-12-27.18:22:51.118 [http-nio-8777-exec-1] INFO EndNode - 创建会话-EndNode:test10001
          25-12-27.18:22:51.150 [http-nio-8777-exec-2] INFO McpGatewayController - 处理 MCP SSE 消息,gatewayId:test10001 sessionId:5c41f6ac-ab1d-401a-b456-8c6b24fa70be messageBody:{“jsonrpc”:”2.0”,”method”:”initialize”,”id”:”37fab8e3-0”,”params”:{“protocolVersion”:”2024-11-05”,”capabilities”:{},”clientInfo”:{“name”:”Java SDK MCP Client”,”version”:”1.0.0”}}}
          25-12-27.18:22:51.172 [http-nio-8777-exec-2] INFO McpGatewayController - 序列化消息:2.0
          25-12-27.18:22:51.172 [http-nio-8777-exec-2] INFO SessionMessageService - 开始处理请求,方法: initialize
          25-12-27.18:22:51.172 [http-nio-8777-exec-2] INFO InitializeHandler - 模拟处理初始化请求
          25-12-27.18:22:51.201 [http-nio-8777-exec-3] INFO McpGatewayController - 处理 MCP SSE 消息,gatewayId:test10001 sessionId:5c41f6ac-ab1d-401a-b456-8c6b24fa70be messageBody:{“jsonrpc”:”2.0”,”method”:”notifications/initialized”}
          25-12-27.18:22:51.202 [http-nio-8777-exec-3] INFO McpGatewayController - 序列化消息:2.0
          25-12-27.18:22:51.239 [http-nio-8777-exec-3] INFO SessionMessageService - 收到即将处理的通知 notifications/initialized null
          25-12-27.18:22:51.245 [http-nio-8777-exec-4] INFO McpGatewayController - 处理 MCP SSE 消息,gatewayId:test10001 sessionId:5c41f6ac-ab1d-401a-b456-8c6b24fa70be messageBody:{“jsonrpc”:”2.0”,”method”:”tools/list”,”id”:”37fab8e3-1”,”params”:{}}
          25-12-27.18:22:51.245 [http-nio-8777-exec-4] INFO McpGatewayController - 序列化消息:2.0
          25-12-27.18:22:51.245 [http-nio-8777-exec-4] INFO SessionMessageService - 开始处理请求,方法: tools/list
          25-12-27.18:22:53.155 [http-nio-8777-exec-5] INFO McpGatewayController - 处理 MCP SSE 消息,gatewayId:test10001 sessionId:5c41f6ac-ab1d-401a-b456-8c6b24fa70be messageBody:{“jsonrpc”:”2.0”,”method”:”tools/call”,”id”:”37fab8e3-2”,”params”:{“name”:”toUpperCase”,”arguments”:{“word”:”xiaofuge”}}}
          25-12-27.18:22:53.155 [http-nio-8777-exec-5] INFO McpGatewayController - 序列化消息:2.0
          25-12-27.18:22:53.155 [http-nio-8777-exec-5] INFO SessionMessageService - 开始处理请求,方法: tools/call

          2.2 调用日志
          25-12-27.18:22:51.196 [HttpClient-1-Worker-0] INFO McpAsyncClient - Server response with Protocol: 2024-11-05, Capabilities: ServerCapabilities[completions=null, experimental=null, logging=null, prompts=null, resources=ResourceCapabilities[subscribe=null, listChanged=null], tools=ToolCapabilities[listChanged=null]], Info: Implementation[name=MCP Word Util Proxy Server, version=1.0.0] and Instructions null
          25-12-27.18:22:51.241 [main ] INFO ApiTest - Tool SSE MCP02 Initialized InitializeResult[protocolVersion=2024-11-05, capabilities=ServerCapabilities[completions=null, experimental=null, logging=null, prompts=null, resources=ResourceCapabilities[subscribe=null, listChanged=null], tools=ToolCapabilities[listChanged=null]], serverInfo=Implementation[name=MCP Word Util Proxy Server, version=1.0.0], instructions=null]
          25-12-27.18:22:54.065 [main ] INFO ApiTest - 测试结果:xiaofuge 转换为大写是 XIAOFUGE。

          如打印结果 xiaofuge 转换成了 XIAOFUGE。

          《AI MCP Gateway》第3-6节:基础层数据处理(Dao)

          一、本章诉求
          将 AI MCP Gateway 库表设计,编写到工程中,映射成 PO、DAO、Mapper 文件,以便于后续章节的使用。

          这一节,小傅哥会演示如何使用 AI IDE 工具,通过 Prompt 描述,来完成这些文件的生成。

          二、结构介绍
          如图,从领域层到基础设施层(mysql、redis…)的使用方式;

          img_22.png

          首先,这里有个设计的思想的体现,这类思想也是 Spring、MyBatis 等框架源码中常用的思想,叫做【依赖倒置】。它的设计目标是,让数据使用方,不过渡依赖于提供方。提供方在升级、替换、迭代时候,都不影响使用方。

          之后,图里是依赖倒置的具体编码体现,从 domain 领域层,每个会话、鉴权、协议的功能编写时,所需的数据,是通过在 domain 领域层定义接口,之后由infrastructure 基础设施层做具体的功能实现。也就是说,每个功能区需要啥数据,就定义好接口,确定好入参和返回结果的,基础设施层引入 domain 层定义的这个标准接口,做具体的数据封装使用。也类似于公司的领导,要这,要那,他不关心具体是谁做,最后做好就可以了。

          三、功能实现

          1. 工程结构

          以数据库表为标准,定义 po、dao,以及 mapper 映射。

          这部分固定的东西,可以 ai 生成,也可以手动编写。如果你是安装课程 ai 编写的,那么一定要熟悉下这些内容,后续做具体的功能实现,还要深入的使用。

          1. 功能实现
            用于编码的 AI IDE 是蛮多的,trae.ai、claude code、joycode 等都是可以的,本节课程选择的是 trae.ai 进行课程演示。在使用的时候,IntelliJ IDEA 也打开项目负责运行,AI IDE 主要负责 AI 操作编码。

          《AI MCP Gateway》第3-7节:协议消息处理-Initialize

          一、本章诉求
          从本章开始,我们将进入消息协议的处理过程,把原本的硬编码的案例操作,通过网关ID(gatewayId)与数据库配置数据进行关联。

          今天我们来处理第一个协议消息的处理场景,Initialize 初始化部分。这部分学习时,会附带调试 Spring AI 框架中 modelcontextprotocol 关于协议处理部分的源码,让大家有更多的积累。

          https://github.com/modelcontextprotocol/java-sdk

          https://modelcontextprotocol.io/docs/getting-started/intro

          二、功能设计
          如图,Initialize 初始化协议处理;

          img_23.png
          首先,在 session 会话层的 adapter 下,创建调用基础设施层仓储服务的接口,并由基础设施层做功能实现。再通过依赖倒置的方式用于领域层使用。(这部分对于初次接触 DDD,又没学习过一些源码或者设计知识的伙伴,可能感觉有点绕,不过没关系,在看到课程源码以后,会逐步清晰)

          然后,从数据库获取的数据,要进行协议转换。也就是把之前固定编码的部分,用数据库获取的数据来动态填充。这部分内容只是初次拿到数据并使用的小试牛刀,并不复杂。不过,我们还会增加对 Spring AI 源码的调试,扩展知识学习。

          之后,在我们陆续完成这些 handler 消息的处理后,则会在 case 层陆续做编排,以及验证网关ID和token等。

          三、源码调试(Spring AI)
          知其然,知其所以然。

          类似于 Spring AI 的各类框架,本身都有提供关于 modelcontextprotocol 模型上下文协议的 MCP 客户端和服务端,而我们要做的事情,说白了就是把原本仅限于当前工程的 MCP 服务实现,转换为动态可配置化的 MCP 网关实现。

          所以,我们调试源码,以及使用源码的过程,是一种技术迁移的能力储备。这既是你未来做其他场景方案设计的一种可复刻模式,也是用于述职答辩或公司面试的能力举证。

          1. 工程源码
            如图,此部分调试范围工程源码;

          测试工程;ai-mcp-gateway-demo-mcp-server-test 这个工程本身做了 mcp 的实现,我们可以直接做调试验证。

          源码位置;工程下,External Libraries 包下,会有 Maven: io.modelcontextprotocol.sdk:mcp:0.10.0 你可以进入后到任何一个类里,点击下载源码(右上角 DownSource)

          1. 断点位置
            2.1 McpServerSession.handleIncomingRequest

          在 McpServerSession 的 handleIncomingRequest 方法中,找到(红点)位置,打上断点。

          这部分代码,处理的是消息的请求处理,初始化方法和其他方法。打上断点在启动程序运行后,是可以看到各个方法的实现类的。你也可以通过 ai 工具,询问这些类都是什么用途,多增强一些理解。

          2.2 McpAsyncServer.asyncInitializeRequestHandler

          在 McpSAsyncServer 的 asyncInitializeRequestHandler 方法中,找到(红点)位置,打上断点。

          这部分代码,是具体的这些消息处理中的一个。处理初始化方法,读它的代码,你会看到它是从请求信息,工程启动信息,服务端信息,来构建这个初始化信息。告诉 AI 客户端,我们可以做什么。

          1. 调试代码
            3.1 单元测试
            工程:ai-mcp-gateway-demo-mcp-server-test

          @Slf4j
          public class ApiTest {

          public static void main(String[] args) throws Exception {
              OpenAiApi openAiApi = OpenAiApi.builder()
                      .baseUrl("https://apis.itedus.cn")
                      .apiKey("sk-cXnvKM3u2LzivNOK3b2e15E5321a4a06969280C6E588CaDe")
                      .completionsPath("v1/chat/completions")
                      .embeddingsPath("v1/embeddings")
                      .build();
          
              ChatModel chatModel = OpenAiChatModel.builder()
                      .openAiApi(openAiApi)
                      .defaultOptions(OpenAiChatOptions.builder()
                              .model("gpt-4o")
                              .toolCallbacks(new SyncMcpToolCallbackProvider(sseMcpClient01()).getToolCallbacks())
                              .build())
                      .build();
          
          
              log.info("测试结果:{}", chatModel.call("你有哪些工具能力"));
          }
          
          public static McpSyncClient sseMcpClient01() {
              HttpClientSseClientTransport sseClientTransport = HttpClientSseClientTransport
                      .builder("http://localhost:8701/sse")
                      .build();
          
              McpSyncClient mcpSyncClient = McpClient.sync(sseClientTransport).requestTimeout(Duration.ofMinutes(360)).build();
              var init_sse = mcpSyncClient.initialize();
              log.info("Tool SSE MCP Initialized {}", init_sse);
          
              return mcpSyncClient;
          }
          

          }

          这是一段测试 mcp 服务的代码,主要验证它的执行流程。测试时,要先以debug模式,启动服务端(ai-mcp-gateway-demo-mcp-server-test)之后在执行单元测试。

          3.2 调试结果

          JSON.toJSONString(new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), result, null))

          关于调试; F7(进入方法里)、F8(一步步执行)、F9(跳到下一个断点位置)

          你可以 F9 顺序执行这些断点,也可以 F8 一步步执行,到了如图位置,可以在控制台输入 JSON 序列化,看下最终的执行结果。这个结果和整个过程,其实也就类似于本节 MCP 网关要做的初始化动作。

          四、编码实现

          1. 工程结构

          以 domain 领域层为入口,session 会话中消息处理所需的内容进行思考。InitializeHandler 的处理诉求,就是基于 gatewayId 从数据库里获取数据填充。那么为了满足这一功能,则需要定义一个用于和数据库交互的接口,也就是 adapter 下的 repository 下的 ISessionRepository。与此同时,为了明确出返回的结果,则定义了 McpGatewayConfigVO 值对象。这就是整个获取数据的过程。

          在获取数据之后,还需要把来自于数据库表查询的数据,转换为 MCP 协议结构,这个协议则需要补充 McpSchemaVO 对象。这部分是一个固定的标准结构,直接参考就可以。

          另外,因为 MCP 还要返回一个功能版本,本节的数据库表中补充了一个 tool_version 的字段。

          1. 核心功能
            2.1 补充字段

          mcp_protocol_registry 表,添加一个 tool_version 字段,可以直接使用课程下,本节分支的 SQL 语句,导入最新的表。

          此外,在项目工程里的基础设施层下,dao、po、mapper 也要做相应的补充调整。

          2.2 仓储接口(适配器)
          2.2.1 接口定义
          public interface ISessionRepository {

          McpGatewayConfigVO queryMcpGatewayConfigByGatewayId(String gatewayId);
          

          }

          所有从领域层到,dao、redis、rpc/http,以及其他的数据处理,都要从 adapter 定义接口开始。而不是以前开发代码,直接把 dao 的接口,注入到了 service 的实现里,因为这样的直接注入,在将来的调整,升级,迭代中,都有非常大的问题,一点数据库的调整,就能让上层几十个甚至上百个 service 跟着一起动。为了避免这一现象,设计了 adapter 适配器层,也就是所提到的 ACL 防腐设计。

          2.2.2 接口实现
          @Repository
          public class SessionRepository implements ISessionRepository {

          @Resource
          private IMcpGatewayDao mcpGatewayDao;
          
          @Resource
          private IMcpProtocolRegistryDao mcpProtocolRegistryDao;
          
          @Override
          public McpGatewayConfigVO queryMcpGatewayConfigByGatewayId(String gatewayId) {
              // 1. 查询网关配置(这里只判空,返回null就可以)
              McpGatewayPO mcpGatewayPO = mcpGatewayDao.queryMcpGatewayByGatewayId(gatewayId);
              if (null == mcpGatewayPO) return null;
          
              // 2. 查询协议注册(1:1 -> gatewayId:toolId)
              McpProtocolRegistryPO mcpProtocolRegistryPO = mcpProtocolRegistryDao.queryMcpProtocolRegistryByGatewayId(gatewayId);
              if (null == mcpProtocolRegistryPO) return null;
          
              return McpGatewayConfigVO.builder()
                      .gatewayId(mcpGatewayPO.getGatewayId())
                      .gatewayName(mcpGatewayPO.getGatewayName())
                      .toolId(mcpProtocolRegistryPO.getToolId())
                      .toolName(mcpProtocolRegistryPO.getToolName())
                      .toolDesc(mcpProtocolRegistryPO.getToolDescription())
                      .toolVersion(mcpProtocolRegistryPO.getToolVersion())
                      .build();
          }
          

          }

          在会话仓储实现中,注入相应的 dao 操作,之后在 queryMcpGatewayConfigByGatewayId 方法里,根据 gatewayId 网关ID查询数据配置,最后在组装数据返回结果即可。

          2.3 协议封装
          @Service(“initializeHandler”)
          public class InitializeHandler implements IRequestHandler {

          @Resource
          private ISessionRepository repository;
          
          /**
           * 对照 io.modelcontextprotocol.spec.McpServerSession
           * <br/>
           * McpServerSession.handle -> McpSchema.JSONRPCRequest -> handleIncomingRequest
           * -> McpSchema.METHOD_INITIALIZE -> McpAsyncServer.asyncInitializeRequestHandler
           * -> result -> new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), result, null)
           * <br/>
           * {
           * "id": "a355a5f7-0",
           * "jsonrpc": "2.0",
           * "result": {
           * "capabilities": {
           * "completions": {},
           * "logging": {},
           * "prompts": {
           * "listChanged": true
           * },
           * "resources": {
           * "listChanged": true,
           * "subscribe": false
           * },
           * "tools": {
           * "listChanged": true
           * }
           * },
           * "instructions": "This server provides weather information tools and resources",
           * "protocolVersion": "2024-11-05",
           * "serverInfo": {
           * "name": "ai-mcp-gateway-demo-mcp-server-test",
           * "version": "1.0.0"
           * }
           * }
           * }
           */
          @Override
          public McpSchemaVO.JSONRPCResponse handle(String gatewayId, McpSchemaVO.JSONRPCRequest message) {
              log.info("消息处理服务-initialize gatewayId:{} request.params:{}", gatewayId, JSON.toJSONString(message.params()));
          
              // 1. 转换参数
              McpSchemaVO.InitializeRequest initializeRequest = McpSchemaVO.unmarshalFrom(message.params(), new TypeReference<>() {
              });
          
              // 2. 查询配置
              McpGatewayConfigVO mcpGatewayConfigVO = repository.queryMcpGatewayConfigByGatewayId(gatewayId);
          
              // 3. 组装信息
              McpSchemaVO.InitializeResult initializeResult = new McpSchemaVO.InitializeResult(initializeRequest.protocolVersion(),
                      new McpSchemaVO.ServerCapabilities(new McpSchemaVO.ServerCapabilities.CompletionCapabilities(),
                              new HashMap<>(),
                              new McpSchemaVO.ServerCapabilities.LoggingCapabilities(),
                              new McpSchemaVO.ServerCapabilities.PromptCapabilities(true),
                              new McpSchemaVO.ServerCapabilities.ResourceCapabilities(false, true),
                              new McpSchemaVO.ServerCapabilities.ToolCapabilities(true)),
                      new McpSchemaVO.Implementation(mcpGatewayConfigVO.getToolName(), mcpGatewayConfigVO.getToolVersion()),
                      mcpGatewayConfigVO.getToolDesc()
              );
          
              // 4. 返回结果
              return new McpSchemaVO.JSONRPCResponse(McpSchemaVO.JSONRPC_VERSION, message.id(), initializeResult, null);
          }
          

          }

          关于 McpSchemaVO 协议对象,你可以作为一个工具对象理解使用即可,他所要返回的数据,都依赖于 mcp 协议本身定义的对象。也就是这部分内容 https://github.com/modelcontextprotocol/java-sdk

          在 InitializeHandler 的 handle 实现里,为的就是把数据库的数据,获取出来后,封装成 MCP 协议返回的结果。其中 McpSchemaVO.InitializeRequest initializeRequest 和我们在调试 Spring AI 的代码时候是一样的,解析了请求入参的 Request 对象,之后使用它的协议信息。之后就是根据 gatewayId 获取 McpGatewayConfigVO 配置对象,在转化为 McpSchemaVO.InitializeResult 对象。

          最后,返回 McpSchemaVO.JSONRPCResponse 结果即可。整个操作过程,就是把以前固定编码的东西,写了从数据库表获取。

          五、测试验证

          1. 单测方法
            工程:ai-mcp-gateway-demo-mcp-server-test

          public class ApiTest {

          public static void main(String[] args) throws Exception {
              OpenAiApi openAiApi = OpenAiApi.builder()
                      .baseUrl("https://apis.itedus.cn")
                      .apiKey("sk-cXnvKM3u2LzivNOK3b2e15E5321a4a06969280C6E588CaDe")
                      .completionsPath("v1/chat/completions")
                      .embeddingsPath("v1/embeddings")
                      .build();
          
              ChatModel chatModel = OpenAiChatModel.builder()
                      .openAiApi(openAiApi)
                      .defaultOptions(OpenAiChatOptions.builder()
                              .model("gpt-4o")
                              .toolCallbacks(new SyncMcpToolCallbackProvider(sseMcpClient02()).getToolCallbacks())
                              .build())
                      .build();
          
          
              log.info("测试结果:{}", chatModel.call("你有哪些工具能力"));
          }
          
          public static McpSyncClient sseMcpClient01() {
              HttpClientSseClientTransport sseClientTransport = HttpClientSseClientTransport
                      .builder("http://localhost:8701/sse")
                      .build();
          
              McpSyncClient mcpSyncClient = McpClient.sync(sseClientTransport).requestTimeout(Duration.ofMinutes(360)).build();
              var init_sse = mcpSyncClient.initialize();
              log.info("Tool SSE MCP Initialized {}", init_sse);
          
              return mcpSyncClient;
          }
          
          public static McpSyncClient sseMcpClient02() {
              HttpClientSseClientTransport sseClientTransport = HttpClientSseClientTransport
                      .builder("http://localhost:8777")
                      .sseEndpoint("/api-gateway/gateway_001/mcp/sse")
                      .build();
          
              McpSyncClient mcpSyncClient = McpClient.sync(sseClientTransport).requestTimeout(Duration.ofMinutes(360)).build();
              var init_sse = mcpSyncClient.initialize();
              log.info("Tool SSE MCP Initialized {}", init_sse);
          
              return mcpSyncClient;
          }
          

          }

          增加一个 sseMcpClient02,之后注意 sseEndpoint(“/api-gateway/gateway_001/mcp/sse”) 配置的网关ID: gateway_001

          1. 测试结果
            在ai-mcp-gateway 工程的 InitializeHandler.handle 下,打上断点,方便验证结果。 之后,就可以运行 ai-mcp-gateway-demo-mcp-server-test ApiTest 方法了,来看下结果。 - 这一部分的结果,更多的是跑通流程,来验证可以调试到这个步骤,当所有的消息处理,都完成后,就可以处理接口的调用了。

          《AI MCP Gateway》第3-8节:协议消息处理
          一、本章诉求
          从 HTTP 接口到 MCP 协议的映射,这里要考虑的是怎么把一个完整请求 http 的接口描述录入到数据库,之后在通过数据库的配置,转换为 MCP 协议结构告诉 AI 客户端 tool/list,也就是这套 HTTP 接口到 MCP 以后所提供工具能力。

          所以,小傅哥在带着大家实现的过程中,先做了基于 HTTP 接口所需存储的信息,做了库表的设计。之后到这一节,我们要把库表的数据转换为 MCP 协议结构数据。

          拓展,像是 HTTP 可以做,那么 RPC、MQ、数据库等各类资源,你也可以转换为 MCP 服务协议进行使用。

          二、流程设计
          如图,Tool/List 工具列表协议处理;

          img_24.png

          首先,我们要根据网关ID(gatewayId)从数据库中,获取网关配置和http工具字段配置列表,这部分数据相当于是把 HTTP 请求结构体,拆解喽放到数据库表中,之后再查询出来按照 MCP 协议结构组装使用。

          然后,是对 buildTools 工具细节的处理,这部分是对元素的拆分和组装。这部分还有一些细节在下面。

          注意,目前库表里设计的是1网关对应一个接口,一个接口1个tool能力。不过代码里先支持多个,后续可以扩展库表配置。

          img_25.png

          之后,映射数据库表 mcp_protocol_mapping 拆解字段的父子关系,一个字段以下的另外一个字段,如;xxxRequest01 -> xxxRequest01.city 的映射。所以在 buildProperty 的处理过程中要,要做递归循环,一层一层的找到这些内容,并拆解组装使用。

          三、源码调试(Spring AI)
          在3-7节,已经介绍了如何调试,本节继续打新的断点,调试即可。

          public static void main(String[] args) throws Exception {
          OpenAiApi openAiApi = OpenAiApi.builder()
          .baseUrl(“https://apis.itedus.cn“)
          .apiKey(“sk-j9brA5gtId49CpRG1d97E8F75dAb4066B7Cd**** *联系小傅哥申请”)
          .completionsPath(“v1/chat/completions”)
          .embeddingsPath(“v1/embeddings”)
          .build();
          ChatModel chatModel = OpenAiChatModel.builder()
          .openAiApi(openAiApi)
          .defaultOptions(OpenAiChatOptions.builder()
          .model(“gpt-4o”)
          .toolCallbacks(new SyncMcpToolCallbackProvider(sseMcpClient01()).getToolCallbacks())
          .build())
          .build();
          log.info(“测试结果:{}”, chatModel.call(“你有哪些工具能力”));
          }
          public static McpSyncClient sseMcpClient01() {
          HttpClientSseClientTransport sseClientTransport = HttpClientSseClientTransport
          .builder(“http://localhost:8701/sse“)
          .build();
          McpSyncClient mcpSyncClient = McpClient.sync(sseClientTransport).requestTimeout(Duration.ofMinutes(360)).build();
          var init_sse = mcpSyncClient.initialize();
          log.info(“Tool SSE MCP Initialized {}”, init_sse);
          return mcpSyncClient;
          }

          img_26.png
          接下来 AI 客户端,会调用 MCP 的 tool/list 拿到工具列表,在这部分调试的时候,可以看到 tools 的返回结果。这部分结果,也是你要对照实现的内容。把数据库里的 HTTP 协议映射表,转换为 MCP 协议结构数据。

          这部分 MCP 协议数据,放到工程 docs/prompt 下,这部分你也可以思考,如何把数据封装成这样的结构。

          四、功能实现

          1. 工程结构

          本节主要在 ToolsListHandler 通过 adapter 适配器层,从 dao 层获取数据,之后在 ToolsListHandler 进行封装。 这一节的封装代码会稍微有点难度,使用到了嵌套循环,层层递归父子节点,找到是否最后一个,之后依次的填充到结果里。当然也不非得使用这样的方式,也可以换其他的“算法”思路实现。

          1. 解析封装
            @Slf4j
            @Service(“toolsListHandler”)
            public class ToolsListHandler implements IRequestHandler {

            @Resource
            private ISessionRepository repository;

            /**

            • {

            • “tools”: [

            • {

            • “description”: “获取公司雇员信息”,

            • “inputSchema”: {

            • “additionalProperties”: false,

            • “properties”: {

            • “xxxRequest01”: {

            • “type”: “object”,

            • “properties”: {

            • “city”: {

            • “type”: “string”,

            • “description”: “城市名称,如果是中文汉字请先转换为汉语拼音,例如北京:beijing”

            • },

            • “company”: {

            • “type”: “object”,

            • “properties”: {

            • “name”: {

            • “type”: “string”,

            • “description”: “公司名称”

            • },

            • “type”: {

            • “type”: “string”,

            • “description”: “公司类型”

            • }

            • },

            • “required”: [

            • “name”,

            • “type”

            • ],

            • “description”: “公司信息,如果是中文汉字请先转换为汉语拼音,例如北京:jd/alibaba”

            • }

            • },

            • “required”: [

            • “city”,

            • “company”

            • ]

            • },

            • “xxxRequest02”: {

            • “type”: “object”,

            • “properties”: {

            • “employeeCount”: {

            • “type”: “string”,

            • “description”: “雇员姓名”

            • }

            • },

            • “required”: [

            • “employeeCount”

            • ]

            • }

            • },

            • “required”: [

            • “xxxRequest01”,

            • “xxxRequest02”

            • ],

            • “type”: “object”

            • },

            • “name”: “getCompanyEmployee”

            • }

            • ]

            • }
              */
              @Override
              public McpSchemaVO.JSONRPCResponse handle(String gatewayId, McpSchemaVO.JSONRPCRequest message) {

              // 1. 网关配置
              McpGatewayConfigVO mcpGatewayConfigVO = repository.queryMcpGatewayConfigByGatewayId(gatewayId);

              // 2. 查询网关(gatewayId)下的工具列表配置
              List mcpGatewayToolConfigVOS = repository.queryMcpGatewayToolConfigListByGatewayId(gatewayId);

              // 3. 构建工具列表
              List<McpSchemaVO.Tool> tools = buildTools(mcpGatewayConfigVO, mcpGatewayToolConfigVOS);

              return new McpSchemaVO.JSONRPCResponse(“2.0”, message.id(), Map.of(
              “tools”, tools
              ), null);
              }

            private List<McpSchemaVO.Tool> buildTools(McpGatewayConfigVO gatewayConfig, List toolConfigs) {
            // 1. 通过 toolId 一组组转换为 Map 结构
            Map<Long, List> toolsMap = toolConfigs.stream()
            .collect(Collectors.groupingBy(McpGatewayToolConfigVO::getToolId));

             List<McpSchemaVO.Tool> tools = new ArrayList<>();
            
             for (Map.Entry<Long, List<McpGatewayToolConfigVO>> entry : toolsMap.entrySet()) {
                 Long toolId = entry.getKey();
                 List<McpGatewayToolConfigVO> configs = entry.getValue();
            
                 // 排序
                 configs.sort((o1, o2) -> {
                     int s1 = o1.getSortOrder() != null ? o1.getSortOrder() : 0;
                     int s2 = o2.getSortOrder() != null ? o2.getSortOrder() : 0;
                     return Integer.compare(s1, s2);
                 });
            
                 // 父子元素 Map parentPath -> List<Children>
                 Map<String, List<McpGatewayToolConfigVO>> childrenMap = new HashMap<>();
            
                 List<McpGatewayToolConfigVO> roots = new ArrayList<>();
            
                 for (McpGatewayToolConfigVO config : configs) {
                     if (config.getParentPath() == null) {
                         roots.add(config);
                     } else {
                         childrenMap.computeIfAbsent(config.getParentPath(), k -> new ArrayList<>()).add(config);
                     }
                 }
            
                 // 排序
                 roots.sort((o1, o2) -> {
                     int s1 = o1.getSortOrder() != null ? o1.getSortOrder() : 0;
                     int s2 = o2.getSortOrder() != null ? o2.getSortOrder() : 0;
                     return Integer.compare(s1, s2);
                 });
            
                 // 构建输入结构
                 Map<String, Object> properties = new HashMap<>();
                 List<String> required = new ArrayList<>();
            
                 for (McpGatewayToolConfigVO root : roots) {
                     properties.put(root.getFieldName(), buildProperty(root, childrenMap));
                     if (Integer.valueOf(1).equals(root.getIsRequired())) {
                         required.add(root.getFieldName());
                     }
                 }
            
                 // 获取类型
                 String type = roots.size() == 1 ? roots.get(0).getMcpType() : "object";
            
                 // 构造函数
                 McpSchemaVO.JsonSchema inputSchema = new McpSchemaVO.JsonSchema(
                         type,
                         properties,
                         required.isEmpty() ? null : required,
                         false,
                         null,
                         null
                 );
            
                 // 工具描述
                 String name = "unknown-tool-" + toolId;
                 String desc = "";
                 if (gatewayConfig != null && Objects.equals(gatewayConfig.getToolId(), toolId)) {
                     name = gatewayConfig.getToolName();
                     desc = gatewayConfig.getToolDesc();
                 }
            
                 tools.add(new McpSchemaVO.Tool(name, desc, inputSchema));
             }
             return tools;
            

            }

            private Map<String, Object> buildProperty(McpGatewayToolConfigVO current, Map<String, List> childrenMap) {
            Map<String, Object> property = new HashMap<>();
            property.put(“type”, current.getMcpType());
            if (current.getMcpDesc() != null) {
            property.put(“description”, current.getMcpDesc());
            }

             // 校验孩子元素
             List<McpGatewayToolConfigVO> children = childrenMap.get(current.getMcpPath());
             if (children != null && !children.isEmpty()) {
                 Map<String, Object> props = new HashMap<>();
                 List<String> reqs = new ArrayList<>();
            
                 // 排序
                 children.sort((o1, o2) -> {
                     int s1 = o1.getSortOrder() != null ? o1.getSortOrder() : 0;
                     int s2 = o2.getSortOrder() != null ? o2.getSortOrder() : 0;
                     return Integer.compare(s1, s2);
                 });
            
                 for (McpGatewayToolConfigVO child : children) {
                     // 注意,buildProperty 嵌套递归,一层层的寻找,是否还有孩子元素(children)
                     props.put(child.getFieldName(), buildProperty(child, childrenMap));
                     if (Integer.valueOf(1).equals(child.getIsRequired())) {
                         reqs.add(child.getFieldName());
                     }
                 }
            
                 property.put("properties", props);
            
                 if (!reqs.isEmpty()) {
                     property.put("required", reqs);
                 }
            
             }
            
             return property;
            

            }

          }

          handle 的目的是 buildTools 的工具的构建,把数据库里的数据查询出来,之后依次循环处理,最后在封装结果。

          有一点绕的地方在于 buildProperty 的处理,它是一个嵌套递归,一层层的把子元素找到,在一次次的递归填充,直至全部完成。这部分如果不好理解,可以一点点debug调试来看。

          五、测试验证

          1. 前置说明

          本节库表的数据有补充,gateway_id 添加了对应的值。你可以从课程docs/dev-ops/bak 下拿到最新的本节的 sql

          1. 单测方法
            ai-mcp-gateway-app->cn.bugstack.ai.test.domain.session.message

          @Slf4j
          @RunWith(SpringRunner.class)
          @SpringBootTest
          public class RequestHandlerTest {

          @Resource
          private IRequestHandler toolsListHandler;
          
          @Test
          public void test_handle() {
              McpSchemaVO.JSONRPCResponse handle = toolsListHandler.
                      handle("gateway_001",
                              new McpSchemaVO.JSONRPCRequest("2.0","tool/list","a355a5f7-0",""));
              log.info("测试结果:{}", JSON.toJSONString(handle.result()));
          }
          

          }

          测试结果就是最终的 mcp 协议数据。你可以运行后和在 Spring AI 下调试的数据进行对比来看。

          《AI MCP Gateway》第3-9节:协议消息处理-ToolsCall

          一、本章诉求
          从 AI 客户端,向 AI MCP 发起请求的过程中,小傅哥已经带着大家处理了;InitializeHandler - 初始化、ToolsListHandler - 获取工具列表,接下来再要处理的就是 ToolsCallHandler 的操作了,接收来自 AI 客户端,用户的请求,转换为对应的参数,发起接口请求(这部分我们会调用 http 接口)。

          二、流程设计
          如图,Tool/Call 工具接口调用协议处理;

          img_27.png

          首先,从 ToolsCallHandler 入口开始,会接收到 AI 请求接口过程中,发过来的格式化参数信息(根据我们提供的工具列表说明提供过来的)。

          之后,根据获取的请求信息,解析请求参数(像是 http 接口分为 post、get,如果以后扩展 rpc 接口,就直接泛化调用,这部分就可以根据你想对接哪些东西来看了)并做协议调用。

          注意,当前章节还是gateway -> tool 是 1:1 的,这部分后续在做细化处理。我们先用一个简单的结构,把整个流程跑通。有了基础后,在深入的理解拆分会更好理解。

          三、源码调试(Spring AI)
          测试工程;ai-mcp-gateway-demo-mcp-server-test

          测试分支;3-9-mcp-message-handler-toolcall

          测试目的;通过 spring ai 验证,工具调用时返回数据的格式化结构。按照结构标准,定义 ai mcp 网关返参。

          1. 调整接口
            1.1 service tool 接口
            @Slf4j
            @Service
            public class TestService {

            @Tool(description = “获取公司雇员信息”)
            public XxxResponse getCompanyEmployee(XxxRequest01 xxxRequest01) {
            log.info(“根据公司和雇员,查询工资和工作工时。{}”,xxxRequest01.getCompany());

             // 这部分可以实际调用你需要的接口,比如调用http接口获取个数据或者做一些操作等。
            
             XxxResponse xxxResponse = new XxxResponse();
             xxxResponse.setSalary("16.92");
             xxxResponse.setDayManHour(String.valueOf(new Random().nextInt(24)));
            
             XxxResponse.User user = new XxxResponse.User();
             user.setUserId("111");
             user.setUserName("小傅哥");
            
             xxxResponse.setUser(user);
            
             XxxResponse xxxResponse02 = new XxxResponse();
             xxxResponse02.setSalary("16.92");
             xxxResponse02.setDayManHour(String.valueOf(new Random().nextInt(24)));
            
             XxxResponse.User user02 = new XxxResponse.User();
             user02.setUserId("111");
             user02.setUserName("小傅哥");
            
             xxxResponse02.setUser(user);
            
             List<XxxResponse> xlist = new ArrayList<>();
             xlist.add(xxxResponse);
             xlist.add(xxxResponse02);
            
             return xxxResponse;
            

            }

          }

          在 TestService 中,我们写了一个 tool 工具,将两个入参 XxxRequest01 xxxRequest01、XxxRequest02 xxxRequest02,调整为一个 XxxRequest01 xxxRequest01,也好和 http 接口对应,有一个 @RequestBody 注解对象。前面之所以添加1个以上的入参,主要为了验证入参结构定义。

          注意,这里还添加了 list 返参的测试,主要为了验证单个对象和 list 对象,对返回结果协议结构是否有不同的处理方式。验证可以证明,无论什么样的数据,都是一个结构。

          1.2 trigger http 接口
          @Slf4j
          @RestController
          @CrossOrigin(“*”)
          @RequestMapping(“/api/v1/mcp/“)
          public class TestServiceController {

          @PostMapping("get_company_employee")
          public XxxResponse getCompanyEmployee(@RequestBody XxxRequest01 xxxRequest01) {
              log.info("查询公司员工信息 - 城市:{}, 公司:{}",
                      xxxRequest01.getCity(),
                      xxxRequest01.getCompany().getName());
          
              XxxResponse xxxResponse = new XxxResponse();
              xxxResponse.setSalary("16.92");
              xxxResponse.setDayManHour(String.valueOf(new Random().nextInt(24)));
          
              XxxResponse.User user = new XxxResponse.User();
              user.setUserId("111");
              user.setUserName("小傅哥");
          
              xxxResponse.setUser(user);
          
              XxxResponse xxxResponse02 = new XxxResponse();
              xxxResponse02.setSalary("16.92");
              xxxResponse02.setDayManHour(String.valueOf(new Random().nextInt(24)));
          
              XxxResponse.User user02 = new XxxResponse.User();
              user02.setUserId("111");
              user02.setUserName("小傅哥");
          
              xxxResponse02.setUser(user);
          
              List<XxxResponse> xlist = new ArrayList<>();
              xlist.add(xxxResponse);
              xlist.add(xxxResponse02);
          
              return xxxResponse;
          }
          
          @GetMapping("/query-by-id")
          public String queryAiClientById(@RequestParam("id") String id) {
              return "hi xiaofuge " + id;
          }
          

          }

          对应 TestService 服务接口,这里设计的 http 接口,也要与之对应。@RequestBody XxxRequest01 xxxRequest01 也要提供一个对应的入参信息。

          另外,分别提供了 @PostMapping(“get_company_employee”)、@GetMapping(“/query-by-id”) 两个参数信息。为的是验证不同的请求形式。这部分内容会让 ai mcp 网关配置调用过来。

          1. 断点调试
            2.1 工具处理

          在 McpAsyncServer#toolsCallRequestHandler() 是处理工具调用的,这里是调用了本地的 service tool 方法,我们对照这样的方式,是要通过远程 http 接口,或者其他通信协议。(思路打开,如;rpc、mq、db、redis、socket、rs232串口等,都可以调用)

          注意,callToolRequest.arguments 的入参信息,这个是无论那种处理,都要获取这个参数。

          2.2. 接口调用

          接下来调用会进入到 TestService#getCompanyEmployee 接口,在 ai mcp 网关里,也就是调用 http 协议。

          2.3 协议结构

          2.3.1 请求参数
          {“id”:”ae84e5a4-2”,”jsonrpc”:”2.0”,”method”:”tools/call”,”params”:{“name”:”getCompanyEmployee”,”arguments”:{“xxxRequest01”:{“city”:”beijing”,”company”:{“name”:”jd”,”type”:”未知”}}}}}

          2.3.2 响应参数
          {
          “id”: “a355a5f7-0”,
          “jsonrpc”: “2.0”,
          “result”: {
          “content”: [
          {
          “text”: “{"salary":"16.92","dayManHour":"1","user":{"userId":"111","userName":"小傅哥"}}”,
          “type”: “text”
          }
          ],
          “isError”: “false”
          }
          }

          McpServerSession 的 handleIncomingRequest 会集中处理消息的工具列表请求,工具调用请求,打断点的时候,关注 request.method() 方法。

          之后你可以在输入框,输入 result 观察输入信息。也就可以看到,输出的结果,都会在 text 里放入。也就是我们调用的 http 结果,直接放入进来就可以了。

          四、功能实现

          1. 工程结构

          首先,在 domain 领域层 ToolsCallHandler 要根据网关 ID 查询到工具的调用信息。当前章节还是gateway -> tool 是 1:1 的,这部分后续在做细化处理。我们先用一个简单的结构,把整个流程跑通。有了基础后,在深入的理解拆分会更好理解。

          之后,根据调用信息,去执行 http 调用处理,分为 get、post 处理请求。

          1. 功能实现
            2.1 接口调用(http)
            2.1.1 接口定义
            public interface GenericHttpGateway {

            @POST
            Call post(
            @Url String url,
            @HeaderMap Map<String, Object> headers,
            @Body RequestBody body
            );

            @GET
            Call get(
            @Url String url,
            @HeaderMap Map<String, Object> headers,
            @QueryMap Map<String, Object> queryParams
            );

          }

          在 ai-mcp-gateway-infrastructure 基础设施层,gateway 调用外部接口这部分。我们使用 okhttp3、retrofit2 框架(可以检索下,补充知识),封装一个通用的调用。调用的时候,传入 url、headers和参数信息即可。这是一种面向对象调用的方式,不需要编写那么多的请求代码。

          2.1.2 初始接口
          @Configuration
          public class HTTPClientConfig {

          @Bean
          public OkHttpClient okHttpClient() {
              return new OkHttpClient.Builder()
                      .connectionPool(new ConnectionPool(10, 5, TimeUnit.MINUTES))
                      .retryOnConnectionFailure(true)
                      .connectTimeout(100, TimeUnit.SECONDS)
                      .readTimeout(300, TimeUnit.SECONDS)
                      .writeTimeout(300, TimeUnit.SECONDS)
                      .build();
          }
          
          @Bean
          public GenericHttpGateway genericHttpGateway(OkHttpClient okHttpClient) {
              Retrofit retrofit = new Retrofit.Builder()
                      .baseUrl("http://127.0.0.1/")
                      .addConverterFactory(GsonConverterFactory.create())
                      .client(okHttpClient)
                      .build();
              return retrofit.create(GenericHttpGateway.class);
          }
          

          }

          GenericHttpGateway 框架需要进行初始化处理,实例化出一个对象。在 ai-mcp-gateway-app 层的 config 下进行处理。

          2.1.3 测试服务
          @Slf4j
          @RunWith(SpringRunner.class)
          @SpringBootTest
          @Transactional
          public class GenericHttpGatewayTest {

          @Resource
          private GenericHttpGateway gateway;
          
          @javax.annotation.Resource
          private IMcpProtocolRegistryDao mcpProtocolRegistryDao;
          
          @Test
          public void test_post() throws Exception {
              McpProtocolRegistryPO mcpProtocolRegistryPO = mcpProtocolRegistryDao.queryById(1L);
          
              String httpUrl = mcpProtocolRegistryPO.getHttpUrl();
              String httpHeaders = mcpProtocolRegistryPO.getHttpHeaders();
              Integer timeout = mcpProtocolRegistryPO.getTimeout();
          
              // 1. 请求参数
              Map<String, Object> params = new java.util.HashMap<>();
              params.put("city", "beijing");
          
              Map<String, Object> company = new java.util.HashMap<>();
              company.put("name", "alibaba");
              company.put("type", "internet");
              params.put("company", company);
          
              // 2. 构建请求头
              Map<String, Object> headers = new java.util.HashMap<>();
              headers.put("Content-Type", "application/json");
          
              // 3. 构建请求体
              RequestBody requestBody = RequestBody.create(JSON.toJSONString(params), MediaType.parse("application/json"));
          
              // 4. 执行请求
              retrofit2.Call<ResponseBody> call = gateway.post(httpUrl, headers, requestBody);
              ResponseBody responseBody = call.execute().body();
          
              log.info("测试结果:{}", responseBody != null ? responseBody.string() : null);
          }
          

          }

          26-01-31.11:10:54.554 [main ] INFO GenericHttpGatewayTest - 测试结果:{“salary”:”16.92”,”dayManHour”:”19”,”user”:{“userId”:”111”,”userName”:”小傅哥”}}

          这部分我们先来验证下这样的调用方式,是否可以调用到 http 服务接口。验证时候要先启动 ai-mcp-gateway-demo-mcp-server-test 工程。

          运行后,你会看到这样一份结果。如果你更熟悉其他 http 框架,也可以使用,主要能确保调用到另外的一个服务即可。而且你还可以思考,对于不同协议类型,是不是可以封装个组件,传输协议类型,协议参数等,它可以就可以统一调用了。

          2.2 协议处理
          2.2.1 数据查询
          @Slf4j
          @Repository
          public class SessionRepository implements ISessionRepository {

          @Resource
          private IMcpGatewayDao mcpGatewayDao;
          
          @Resource
          private IMcpProtocolRegistryDao mcpProtocolRegistryDao;
          
          @Resource
          private IMcpProtocolMappingDao mcpProtocolMappingDao;
          
          // ... 省略部分
          
          @Override
          public McpGatewayProtocolConfigVO queryMcpGatewayProtocolConfig(String gatewayId) {
          
              McpProtocolRegistryPO mcpProtocolRegistryPO = mcpProtocolRegistryDao.queryMcpProtocolRegistryByGatewayId(gatewayId);
              if (null == mcpProtocolRegistryPO) return null;
          
              McpGatewayProtocolConfigVO.HTTPConfig httpConfig = new McpGatewayProtocolConfigVO.HTTPConfig();
              httpConfig.setHttpUrl(mcpProtocolRegistryPO.getHttpUrl());
              httpConfig.setHttpHeaders(mcpProtocolRegistryPO.getHttpHeaders());
              httpConfig.setHttpMethod(mcpProtocolRegistryPO.getHttpMethod());
              httpConfig.setTimeout(mcpProtocolRegistryPO.getTimeout());
          
              return McpGatewayProtocolConfigVO.builder().httpConfig(httpConfig).build();
          }
          

          }

          需要在 ISessionRepository 仓储新增加一个 queryMcpGatewayProtocolConfig 的查询处理。你可以看到目前只是通过 gatewayId 网关ID,查询到工具的配置。但更细化的话,是通过 gatewayId 和工具配置的名称,这部分咱们在走完核心流程后,会在做细化。

          2.2.2 接口调用
          @Component
          public class SessionPort implements ISessionPort {

          @Resource
          private GenericHttpGateway gateway;
          
          private final ObjectMapper objectMapper = new ObjectMapper();
          
          @Override
          public Object toolCall(McpGatewayProtocolConfigVO.HTTPConfig httpConfig, Object params) throws IOException {
              // 1. 构建请求头
              String httpHeadersJson = httpConfig.getHttpHeaders();
          
              Map<String, Object> headers = objectMapper.readValue(httpHeadersJson, Map.class);
          
              // 2. 判断请求方法
              String httpMethod = httpConfig.getHttpMethod().toLowerCase();
          
              // 3. 参数校验
              if (!(params instanceof Map<?, ?> arguments)) {
                  throw new AppException(ResponseCode.ILLEGAL_PARAMETER.getCode(), ResponseCode.ILLEGAL_PARAMETER.getInfo());
              }
          
              switch (httpMethod) {
                  // 1. 构建请求体
                  case "post": {
                      RequestBody requestBody = RequestBody.create(JSON.toJSONString(arguments.values().toArray()[0]),
                              MediaType.parse("application/json"));
          
                      Call<ResponseBody> call = gateway.post(httpConfig.getHttpUrl(), headers, requestBody);
                      ResponseBody responseBody = call.execute().body();
          
                      assert responseBody != null;
          
                      return responseBody.string();
                  }
                  // 2. 执行get请求
                  case "get": {
                      Map<String, Object> objMapRequest = new java.util.HashMap<>((Map<String, Object>) arguments.values().toArray()[0]);
          
                      String url = httpConfig.getHttpUrl();
                      // 替换路径参数
                      Matcher matcher = Pattern.compile("\\{([^}]+)\\}").matcher(url);
                      while (matcher.find()) {
                          String name = matcher.group(1);
                          if (objMapRequest.containsKey(name)) {
                              url = url.replace("{" + name + "}", String.valueOf(objMapRequest.get(name)));
                              objMapRequest.remove(name);
                          }
                      }
          
                      Call<ResponseBody> call = gateway.get(url, headers, objMapRequest);
          
                      ResponseBody responseBody = call.execute().body();
          
                      assert responseBody != null;
          
                      return responseBody.string();
                  }
              }
          
              throw new AppException(ResponseCode.METHOD_NOT_FOUND.getCode(), ResponseCode.METHOD_NOT_FOUND.getInfo());
          }
          

          }

          定义接口调用接口和参数 McpGatewayProtocolConfigVO.HTTPConfig httpConfig 按照传入的方法,调用对应的协议。

          这里区分了 post、get 请求,如果你的场景还有其他方法也可以按照这样的结构扩展,如果更为复杂,可以设计为策略模式。

          2.2.3 协议调用
          @Slf4j
          @Service(“toolsCallHandler”)
          public class ToolsCallHandler implements IRequestHandler {

          @Resource
          private ISessionRepository repository;
          
          @Resource
          private ISessionPort port;
          
          @Override
          public McpSchemaVO.JSONRPCResponse handle(String gatewayId, McpSchemaVO.JSONRPCRequest message) {
              try {
                  McpGatewayProtocolConfigVO mcpGatewayProtocolConfigVO = repository.queryMcpGatewayProtocolConfig(gatewayId);
          
                  // 1. 转换参数
                  McpSchemaVO.CallToolRequest callToolRequest =
                          McpSchemaVO.unmarshalFrom(message.params(), new TypeReference<>() {
                          });
          
                  Object argumentsObj = callToolRequest.arguments();
          
                  // todo 暂时工具名称还没有使用,后续会调整。
                  String name = callToolRequest.name();
          
                  // 2. 调用接口
                  Object result = port.toolCall(mcpGatewayProtocolConfigVO.getHttpConfig(), argumentsObj);
          
                  return new McpSchemaVO.JSONRPCResponse(McpSchemaVO.JSONRPC_VERSION, message.id(), Map.of(
                          "content", new Object[]{
                                  Map.of(
                                          "type", "text",
                                          "text", result
                                  ),
          
                          },
                          "isError", "false"
                  ), null);
          
              } catch (Exception e) {
                  return new McpSchemaVO.JSONRPCResponse(McpSchemaVO.JSONRPC_VERSION,
                          message.id(),
                          null,
                          new McpSchemaVO.JSONRPCResponse.JSONRPCError(McpErrorCodes.INVALID_PARAMS, e.getMessage(), null));
          
              }
              
          }
          

          }

          在 ToolsCallHandler 下,负责查询我们的提供的接口,以及调用接口工具调用。

          拿到请求结果后,按照我们的一个格式结构,封装返回协议 McpSchemaVO.JSONRPCResponse 这样的结构。

          五、测试验证

          1. 注意事项

          本节有调整库表数据,配合本节测试,可以导入本节课程下(对应工程分支)SQL语句。

          先启动 ai-mcp-gateway-demo-mcp-server-test 它负责提供 http 接口。

          在启动 ai-mcp-gateway 它负责网关能力,协议转换,调用 http 接口。

          在 ai-mcp-gateway-demo-mcp-server-test 工程的 ApiTest 下,调用 mcp 服务,这样就可以串联起来了。

          1. 单测方法
            public class ApiTest {

            public static void main(String[] args) throws Exception {
            OpenAiApi openAiApi = OpenAiApi.builder()
            .baseUrl(“https://apis.itedus.cn“)
            .apiKey(“sk-JCwN4ZzqDBoh8mOFD77dF915E22**** 联系小傅哥申请测试key”)
            .completionsPath(“v1/chat/completions”)
            .embeddingsPath(“v1/embeddings”)
            .build();

             ChatModel chatModel = OpenAiChatModel.builder()
                     .openAiApi(openAiApi)
                     .defaultOptions(OpenAiChatOptions.builder()
                             .model("gpt-4o")
                             .toolCallbacks(new SyncMcpToolCallbackProvider(sseMcpClient02()).getToolCallbacks())
                             .build())
                     .build();
            
             log.info("测试结果:{}", chatModel.call("""
                     获取公司雇员信息,信息如下;
                     城市;北京
                     公司;谷歌
                     雇员;小傅哥
                     """));
            

            }

            public static McpSyncClient sseMcpClient02() {
            HttpClientSseClientTransport sseClientTransport = HttpClientSseClientTransport
            .builder(“http://localhost:8777“)
            .sseEndpoint(“/api-gateway/gateway_001/mcp/sse”)
            .build();

             McpSyncClient mcpSyncClient = McpClient.sync(sseClientTransport).requestTimeout(Duration.ofMinutes(360)).build();
             var init_sse = mcpSyncClient.initialize();
             log.info("Tool SSE MCP Initialized {}", init_sse);
            
             return mcpSyncClient;
            

            }

          }

          11:25:37.375 [main] INFO cn.bugstack.ai.mcp.server.test.ApiTest – 测试结果:根据提供的信息,以下是小傅哥在北京谷歌公司的雇员信息:

          • 姓名 :小傅哥
          • 工资 :16.92(未注明具体单位)
          • 日工作时长 :7小时

          Process finished with exit code 0

          如图,ai-mcp-gateway 工程,对于 ToolsCallHandler 的处理。可以看到它调用完接口后,拿到的结果并按照 mcp 协议结构进行封装返回。 - 最后,是可以看到 ai 测试拿到的 mcp 结果,也就是我们调用到 mcp 网关后得到的结果内容。

          《AI MCP Gateway》第3-10节:评审库表升级代码

          一、本章诉求
          针对升级的库表结构,调整工程代码基础设施层(dao、po、mapper),重新设计领域层值对象,附带调整InitializeHandler、ToolsListHandler、ToolsCallHandler的数据使用。

          本节是一个很好的练习篇,原有的功能、流程、结构都不变,只是把库表升级,之后针对这些数据的时候重新定义对象。所以,这一节我们采用互联网公司中的代码评审方式来讲解变更信息,你可以在这个过程中,对比代码变化,来编写你的代码。也可以在学习文档和视频后,自己来编写。可能在这个过程中会遇到错误,但这些错误会驱动你深入的debug调试,快速的积累核心知识。这节学习透彻了以后,后面的章节将会非常好学习。

          git 教程:https://bugstack.cn/md/road-map/git.html

          二、流程设计
          如图,库表升级对于领域功能的改造;

          img_28.png

          InitializeHandler 旧版是通过网关配置和工具两部分拿到基础信息,新版直接从网关配置拿到即可。

          ToolsListHandler 旧版从 McpGatewayToolConfigVO 定义的工具和映射,拿到 list 数据,之后做的拆分。新版定义了 McpToolConfigVO - 工具部分、McpToolProtocolConfigVO - 协议部分,有工具引入协议信息。

          ToolsCallHandler 这部分增强了查询,通过 gatewayId 网关ID、toolName 工具名称,来获取到当前当前要调用的协议信息。这里查询的是 http 协议。如果对接了更多的协议,这部分要做策略处理。

          本节开发实现的时候,要导入最新的数据库表。docs/dev-ops/bak/3-10-ai_mcp_gateway_v2.sql

          三、功能实现

          1. 工程结构
            1.1 基础层

          这一层要调整 PO 对象、DAO 对象,以及 adapter 下的适配对象。

          1.2 领域层

          gateway 下,调整值对象。网关配置对象、工具对象、工具协议对象。一个工具,对应着一个协议,一个协议有多个映射(入参对象的很多属性)。

          1.3 应用层

          应用层下,mapper 里调整了对应数据库表 mapper 信息。

          1. 代码评审(show diff)

          首先,我们每次开发需求,都会拉一个新的分支。从你拉的这个分支开始写代码,合并test测试,合并预发验证,合并master上线,以及代码评审的时候,把你所有本次需求所做的代码,要与 master 分支做差异对比评审。

          之后,评审的时候,从整个流程来讲解,之后每一步都能看到你的修改时候,有哪些代码变化。增加,减少,调整,通过这样的方式,一点点对比出代码实现的内容,以及是否有风险。关于这块的对比,可以看本节的视频

          注意,你需要和3-9对比差异,因为 main 分支,到你拉取的时候就是全量代码了。

          1. 核心功能
            3.1 InitializeHandler
            @Slf4j
            @Service(“initializeHandler”)
            public class InitializeHandler implements IRequestHandler {

            @Resource
            private ISessionRepository repository;

            @Override
            public McpSchemaVO.JSONRPCResponse handle(String gatewayId, McpSchemaVO.JSONRPCRequest message) {
            log.info(“消息处理服务-initialize gatewayId:{} request.params:{}”, gatewayId, JSON.toJSONString(message.params()));

             // 1. 转换参数
             McpSchemaVO.InitializeRequest initializeRequest = McpSchemaVO.unmarshalFrom(message.params(), new TypeReference<>() {
             });
            
             // 2. 查询配置
             McpGatewayConfigVO mcpGatewayConfigVO = repository.queryMcpGatewayConfigByGatewayId(gatewayId);
            
             // 3. 组装信息
             McpSchemaVO.InitializeResult initializeResult = new McpSchemaVO.InitializeResult(initializeRequest.protocolVersion(),
                     new McpSchemaVO.ServerCapabilities(new McpSchemaVO.ServerCapabilities.CompletionCapabilities(),
                             new HashMap<>(),
                             new McpSchemaVO.ServerCapabilities.LoggingCapabilities(),
                             new McpSchemaVO.ServerCapabilities.PromptCapabilities(true),
                             new McpSchemaVO.ServerCapabilities.ResourceCapabilities(false, true),
                             new McpSchemaVO.ServerCapabilities.ToolCapabilities(true)),
                     new McpSchemaVO.Implementation(mcpGatewayConfigVO.getGatewayName(), mcpGatewayConfigVO.getVersion()),
                     mcpGatewayConfigVO.getGatewayDesc()
             );
            
             // 4. 返回结果
             return new McpSchemaVO.JSONRPCResponse(McpSchemaVO.JSONRPC_VERSION, message.id(), initializeResult, null);
            

            }

          }

          InitializeResult 参数组装的时候,不需要在引入 tool 工具了,直接从 mcpGatewayConfigVO 结果对象里获取。

          3.2 ToolsListHandler
          @Slf4j
          @Service(“toolsListHandler”)
          public class ToolsListHandler implements IRequestHandler {

          @Resource
          private ISessionRepository repository;
          
          @Override
          public McpSchemaVO.JSONRPCResponse handle(String gatewayId, McpSchemaVO.JSONRPCRequest message) {
          
              // 1. 查询网关(gatewayId)下的工具列表配置
              List<McpToolConfigVO> mcpToolConfigVOS = repository.queryMcpGatewayToolConfigListByGatewayId(gatewayId);
          
              // 2. 构建工具列表
              List<McpSchemaVO.Tool> tools = buildTools(mcpToolConfigVOS);
          
              return new McpSchemaVO.JSONRPCResponse("2.0", message.id(), Map.of(
                      "tools", tools
              ), null);
          }
          
          private List<McpSchemaVO.Tool> buildTools(List<McpToolConfigVO> toolConfigs) {
              List<McpSchemaVO.Tool> tools = new ArrayList<>();
          
              for (McpToolConfigVO toolConfigVO : toolConfigs) {
                  McpToolProtocolConfigVO mcpToolProtocolConfigVO = toolConfigVO.getMcpToolProtocolConfigVO();
                  List<McpToolProtocolConfigVO.ProtocolMapping> configs = mcpToolProtocolConfigVO.getRequestProtocolMappings();
          
                  // 排序
                  configs.sort((o1, o2) -> {
                      int s1 = o1.getSortOrder() != null ? o1.getSortOrder() : 0;
                      int s2 = o2.getSortOrder() != null ? o2.getSortOrder() : 0;
                      return Integer.compare(s1, s2);
                  });
          
                  // 父子元素 Map parentPath -> List<Children>
                  Map<String, List<McpToolProtocolConfigVO.ProtocolMapping>> childrenMap = new HashMap<>();
          
                  List<McpToolProtocolConfigVO.ProtocolMapping> roots = new ArrayList<>();
          
                  for (McpToolProtocolConfigVO.ProtocolMapping config : configs) {
                      if (config.getParentPath() == null) {
                          roots.add(config);
                      } else {
                          childrenMap.computeIfAbsent(config.getParentPath(), k -> new ArrayList<>()).add(config);
                      }
                  }
          
                  // 排序
                  roots.sort((o1, o2) -> {
                      int s1 = o1.getSortOrder() != null ? o1.getSortOrder() : 0;
                      int s2 = o2.getSortOrder() != null ? o2.getSortOrder() : 0;
                      return Integer.compare(s1, s2);
                  });
          
                  // 构建输入结构
                  Map<String, Object> properties = new HashMap<>();
                  List<String> required = new ArrayList<>();
          
                  for (McpToolProtocolConfigVO.ProtocolMapping root : roots) {
                      properties.put(root.getFieldName(), buildProperty(root, childrenMap));
                      if (Integer.valueOf(1).equals(root.getIsRequired())) {
                          required.add(root.getFieldName());
                      }
                  }
          
                  // 获取类型
                  String type = roots.size() == 1 ? roots.get(0).getMcpType() : "object";
          
                  // 构造函数
                  McpSchemaVO.JsonSchema inputSchema = new McpSchemaVO.JsonSchema(
                          type,
                          properties,
                          required.isEmpty() ? null : required,
                          false,
                          null,
                          null
                  );
          
                  // 工具描述
                  tools.add(new McpSchemaVO.Tool(toolConfigVO.getToolName(), toolConfigVO.getToolDescription(), inputSchema));
              }
          
              return tools;
          }
          
          private Map<String, Object> buildProperty(McpToolProtocolConfigVO.ProtocolMapping current, Map<String, List<McpToolProtocolConfigVO.ProtocolMapping>> childrenMap) {
              Map<String, Object> property = new HashMap<>();
              property.put("type", current.getMcpType());
              if (current.getMcpDesc() != null) {
                  property.put("description", current.getMcpDesc());
              }
          
              // 校验孩子元素
              List<McpToolProtocolConfigVO.ProtocolMapping> children = childrenMap.get(current.getMcpPath());
              if (children != null && !children.isEmpty()) {
                  Map<String, Object> props = new HashMap<>();
                  List<String> reqs = new ArrayList<>();
          
                  // 排序
                  children.sort((o1, o2) -> {
                      int s1 = o1.getSortOrder() != null ? o1.getSortOrder() : 0;
                      int s2 = o2.getSortOrder() != null ? o2.getSortOrder() : 0;
                      return Integer.compare(s1, s2);
                  });
          
                  for (McpToolProtocolConfigVO.ProtocolMapping child : children) {
                      // 注意,buildProperty 嵌套递归,一层层的寻找,是否还有孩子元素(children)
                      props.put(child.getFieldName(), buildProperty(child, childrenMap));
                      if (Integer.valueOf(1).equals(child.getIsRequired())) {
                          reqs.add(child.getFieldName());
                      }
                  }
          
                  property.put("properties", props);
          
                  if (!reqs.isEmpty()) {
                      property.put("required", reqs);
                  }
          
              }
          
              return property;
          }
          

          }

          首先,List toolConfigs 查询出来的是一个网关下对应的多个 tool 工具,之后每个工具里(McpToolConfigVO)会映射协议配置对象(McpToolProtocolConfigVO),再往下,协议对象(McpToolProtocolConfigVO)里面映射着 list 协议信息(List requestProtocolMappings)。这部分要对照本节代码来看。

          之后,对比于旧版代码,buildTools 里的操作,是更换了获取工具和协议的方式。如之前的 McpGatewayToolConfigVO 更换为 McpToolProtocolConfigVO.ProtocolMapping,这样的设计也是解耦了工具和协议的硬绑定。

          3.3 ToolsCallHandler
          @Slf4j
          @Service(“toolsCallHandler”)
          public class ToolsCallHandler implements IRequestHandler {

          @Resource
          private ISessionRepository repository;
          
          @Resource
          private ISessionPort port;
          
          @Override
          public McpSchemaVO.JSONRPCResponse handle(String gatewayId, McpSchemaVO.JSONRPCRequest message) {
              try {
                  // 1. 转换参数
                  McpSchemaVO.CallToolRequest callToolRequest =
                          McpSchemaVO.unmarshalFrom(message.params(), new TypeReference<>() {
                          });
          
                  Object argumentsObj = callToolRequest.arguments();
                  String toolName = callToolRequest.name();
          
                  // 2. 查询协议信息
                  McpToolProtocolConfigVO mcpToolProtocolConfigVO = repository.queryMcpGatewayProtocolConfig(gatewayId, toolName);
                  if (null == mcpToolProtocolConfigVO) {
                      throw new AppException(ResponseCode.METHOD_NOT_FOUND.getCode(), ResponseCode.METHOD_NOT_FOUND.getInfo());
                  }
          
                  // 2. 调用接口
                  Object result = port.toolCall(mcpToolProtocolConfigVO.getHttpConfig(), argumentsObj);
          
                  return new McpSchemaVO.JSONRPCResponse(McpSchemaVO.JSONRPC_VERSION, message.id(), Map.of(
                          "content", new Object[]{
                                  Map.of(
                                          "type", "text",
                                          "text", result
                                  ),
          
                          },
                          "isError", "false"
                  ), null);
          
              } catch (Exception e) {
                  return new McpSchemaVO.JSONRPCResponse(McpSchemaVO.JSONRPC_VERSION,
                          message.id(),
                          null,
                          new McpSchemaVO.JSONRPCResponse.JSONRPCError(McpErrorCodes.INVALID_PARAMS, e.getMessage(), null));
          
              }
          
          }
          

          }

          工具调用的话,查询网关协议配置,queryMcpGatewayProtocolConfig 传入的是 gatewayId、toolName 两个参数信息。

          1. 接口处理
            @PostMapping(value = “{gatewayId}/mcp/sse”, consumes = MediaType.APPLICATION_JSON_VALUE)
            public Mono<ResponseEntity> handleMessage(@PathVariable(“gatewayId”) String gatewayId,
            @RequestParam(“sessionId”) String sessionId,
            @RequestBody String messageBody) {

          @RequestParam(“sessionId”) 增加了 sessionId

          四、测试验证

          1. 基础配置
            1.1 导入库表

          把课程的最新的库表导入到项目。

          1.2 配置链接
          application-dev.yml

          spring:
          datasource:
          username: root
          password: 12345678
          url: jdbc:mysql://127.0.0.1:3306/ai_mcp_gateway_v2?useUnicode=true&characterEncoding=utf8&autoReconnect=true&zeroDateTimeBehavior=convertToNull&serverTimezone=UTC&useSSL=true
          driver-class-name: com.mysql.cj.jdbc.Driver

          注意,ai_mcp_gateway_v2 要更换下名称。

          1. 启动顺序

          启动 ai-mcp-gateway-demo-mcp-server-test 工程,提供 http 服务接口

          启动 ai-mcp-gateway 工程,提供网关能力(可以 debug 启动,方便测试网关)

          1. 单测方法
            @Slf4j
            public class ApiTest {

            public static void main(String[] args) throws Exception {
            OpenAiApi openAiApi = OpenAiApi.builder()
            .baseUrl(“https://apis.itedus.cn“)
            .apiKey(“sk-7O3KXkQE9tTvzCcn71Cd8a242945447196FfE4CcF8D674A8”)
            .completionsPath(“v1/chat/completions”)
            .embeddingsPath(“v1/embeddings”)
            .build();

             ChatModel chatModel = OpenAiChatModel.builder()
                     .openAiApi(openAiApi)
                     .defaultOptions(OpenAiChatOptions.builder()
                             .model("gpt-4o")
                             .toolCallbacks(new SyncMcpToolCallbackProvider(sseMcpClient02()).getToolCallbacks())
                             .build())
                     .build();
            
             log.info("测试结果:{}", chatModel.call("""
                     获取公司雇员信息,信息如下;
                     城市;北京
                     公司;谷歌
                     雇员;小傅哥
                     """));
            

            }

            public static McpSyncClient sseMcpClient01() {
            HttpClientSseClientTransport sseClientTransport = HttpClientSseClientTransport
            .builder(“http://localhost:8701/sse“)
            .build();

             McpSyncClient mcpSyncClient = McpClient.sync(sseClientTransport).requestTimeout(Duration.ofMinutes(36000)).build();
             var init_sse = mcpSyncClient.initialize();
             log.info("Tool SSE MCP Initialized {}", init_sse);
            
             return mcpSyncClient;
            

            }

            /**

            • http://localhost:8777/api-gateway/gateway_001/mcp/sse
              */
              public static McpSyncClient sseMcpClient02() {
              HttpClientSseClientTransport sseClientTransport = HttpClientSseClientTransport
              .builder(“http://localhost:8777“)
              .sseEndpoint(“/api-gateway/gateway_001/mcp/sse”)
              .build();

              McpSyncClient mcpSyncClient = McpClient.sync(sseClientTransport).requestTimeout(Duration.ofMinutes(360)).build();
              var init_sse = mcpSyncClient.initialize();
              log.info(“Tool SSE MCP Initialized {}”, init_sse);

              return mcpSyncClient;
              }

          }

          resolutions on MacOS. Check whether you have a dependency on ‘io.netty:netty-resolver-dns-native-macos’. Use DEBUG level to see the full stack: java.lang.UnsatisfiedLinkError: failed to load the required native library
          07:54:37.329 [main] INFO cn.bugstack.ai.mcp.server.test.ApiTest – 测试结果:在北京谷歌(科技公司)中,雇员“小傅哥”的信息如下:

              - **用户名** : 小傅哥
          
          • 用户ID : 111
            • 每日工时 : 23 小时
          • 薪资 : 16.92单位(货币单位未标明)

          如上,测试通过。整个开发流程的核心逻辑不变,更多的就是调整数据对象的获取,让网关、工具、协议解耦。

          《AI MCP Gateway》第3-11节:会话内容编排处理

          一、本章诉求
          将目前在 MCP 网关服务接口管理(McpGatewayController)中的 handleMessage 下的逻辑代码,抽取到 case 进行编排处理,减轻 Controller 层的代码压力。

          img_29.png

          这里有一个设计思想,Controller 接口实现的控制器层,在处理复杂逻辑的时候,都会调用很多 Service 服务。无论这个服务是贫血模型的 mvc 架构,还是充血模型的 ddd 架构。那么为了减轻 Controller 的职责,不至于让一个 Controller 的代码逻辑过于繁重,因此引入了 case 编排层。上承接 Controller 层的出入参需求,下处理 domian 领域服务的编排处理。这一层甚至不需要额外的对象包,它可以承接 Controller 的 DTO 对象作为出入参,也可以使用领域层的对象。

          二、流程设计
          如图,会话消息处理流程设计;

          img_30.png

          首先,这部分的重点在于将原本的会话服务接口下的消息处理,直接调用 domain 领域层的部分,重构迁移到 case 层通过规则树的方式分摊 trigger 触发器下的 Controller 的压力。

          之后,这部分的 case 编排和会话 Session 处理的架构设计方案是一致的,使用的是星球「码农会锁」扳手工程下的通用设计模式组件。这部分的设计,只要具备编码式的规则树结构,可以划分职责的方式完成节点的拆分,就都可以作为编排设计工具使用。

          三、功能实现

          1. 工程结构

          首先,调整下 case 层,原有 session 编排服务类的名称为 McpSessionService 的服务。之后 node 节点因为会有相同的,这里要修改下注册的 bean 的名称。

          之后,在 case 模块的 message 包下,编写编排逻辑,调用领域层完成功能逻辑的串联。这部分完成后,就可以让 McpGatewayController 层来调用 case编排了。

          1. 功能实现
            这部分会列举一些核心代码,整体的代码可以参考本节课程对应的代码分支。视频里也会带着大家手把手完成。另外以下的编排动作,和之前的 case 模块 session 下的编排是一样的,设计模式也是一套。如果不是太熟悉可以进入到星球-课程入口,组件项目 《通用技术组件 - 🔧扳手工程》 通用设计模块框架查看。

          2.1 会话节点
          @Slf4j
          @Service(“mcpMessageSessionNode”)
          public class SessionNode extends AbstractMcpMessageServiceSupport {

          @Resource(name = "mcpMessageMessageHandlerNode")
          private MessageHandlerNode messageHandlerNode;
          
          @Override
          protected ResponseEntity<Void> doApply(HandleMessageCommandEntity requestParameter, DefaultMcpMessageFactory.DynamicContext dynamicContext) throws Exception {
              log.info("消息处理 mcp message SessionNode:{}", requestParameter);
          
              SessionConfigVO sessionConfigVO = sessionManagementService.getSession(requestParameter.getSessionId());
              if (null == sessionConfigVO) {
                  log.warn("会话不存在或已过期,gatewayId:{} sessionId:{}", requestParameter.getGatewayId(), requestParameter.getSessionId());
                  return ResponseEntity.notFound().build();
              }
          
              dynamicContext.setSessionConfigVO(sessionConfigVO);
          
              return router(requestParameter, dynamicContext);
          }
          
          @Override
          public StrategyHandler<HandleMessageCommandEntity, DefaultMcpMessageFactory.DynamicContext, ResponseEntity<Void>> get(HandleMessageCommandEntity requestParameter, DefaultMcpMessageFactory.DynamicContext dynamicContext) throws Exception {
              return messageHandlerNode;
          }
          

          }

          这部分就是 Controller 下,消息处理部分获取 session 对象的处理,拿到当前会话的 session 对象,才能对当前会话发送消息。

          获取完 session 对象后,把对象数据保存到上下文中,下一个节点要从上下文获取到这个对象数据,之后进行使用。

          2.2 消息节点
          @Slf4j
          @Service(“mcpMessageMessageHandlerNode”)
          public class MessageHandlerNode extends AbstractMcpMessageServiceSupport {

          @Autowired
          private ObjectMapper objectMapper;
          
          @Override
          protected ResponseEntity<Void> doApply(HandleMessageCommandEntity requestParameter, DefaultMcpMessageFactory.DynamicContext dynamicContext) throws Exception {
              log.info("消息处理 mcp message MessageHandlerNode:{}", requestParameter);
          
              McpSchemaVO.JSONRPCResponse jsonrpcResponse =
                      serviceMessageService.processHandlerMessage(requestParameter.getGatewayId(), requestParameter.getJsonrpcMessage());
          
              if (null != jsonrpcResponse) {
                  String responseJson = objectMapper.writeValueAsString(jsonrpcResponse);
          
                  SessionConfigVO sessionConfigVO = dynamicContext.getSessionConfigVO();
                  sessionConfigVO.getSink().tryEmitNext(ServerSentEvent.<String>builder()
                          .event("message")
                          .data(responseJson)
                          .build());
              }
          
              return ResponseEntity.accepted().build();
          }
          
          @Override
          public StrategyHandler<HandleMessageCommandEntity, DefaultMcpMessageFactory.DynamicContext, ResponseEntity<Void>> get(HandleMessageCommandEntity requestParameter, DefaultMcpMessageFactory.DynamicContext dynamicContext) throws Exception {
              return defaultStrategyHandler;
          }
          

          }

          首先,HandleMessageCommandEntity 里内聚了转换方法,把请求入参的 messageBody 字符串数据,转换为 McpSchemaVO.JSONRPCMessage 对象。

          之后,在 MessageHandlerNode 获取 jsonrpcResponse 对象,并使用 ServiceMessageService 的领域层方法,处理消息(工具列表、工具调用…)。

          最后,处理完成后,把消息使用上下文对象中 session 完成数据发送。最后返回一个 ResponseEntity.accepted().build() 即可。

          2.3 功能调用
          @PostMapping(value = “{gatewayId}/mcp/sse”, consumes = MediaType.APPLICATION_JSON_VALUE)
          public Mono<ResponseEntity> handleMessage(@PathVariable(“gatewayId”) String gatewayId,
          @RequestParam(“sessionId”) String sessionId,
          @RequestBody String messageBody) {
          try {
          log.info(“处理 MCP SSE 消息,gatewayId:{} sessionId:{} messageBody:{}”, gatewayId, sessionId, messageBody);
          if (StringUtils.isBlank(gatewayId) || StringUtils.isBlank(sessionId)) {
          log.info(“非法参数,gateway、sessionId is or null”);
          throw new AppException(ResponseCode.ILLEGAL_PARAMETER.getCode(), ResponseCode.ILLEGAL_PARAMETER.getInfo());
          }
          HandleMessageCommandEntity commandEntity = new HandleMessageCommandEntity(gatewayId, sessionId, messageBody);
          ResponseEntity responseEntity = mcpMessageService.handleMessage(commandEntity);
          return Mono.just(responseEntity);
          } catch (Exception e) {
          log.error(“处理 MCP SSE 消息失败,gatewayId:{} sessionId:{} messageBody:{}”, gatewayId, sessionId, messageBody, e);
          return Mono.just(ResponseEntity.internalServerError().build());
          }
          }

          McpGatewayController 处理 sse 响应消息的部分就可以通过 McpMessageService 的 case 消息进行处理了。

          最后在把响应结果,用 Mono.just(responseEntity); 封装返回。

          四、测试验证

          1. 注意事项

          本节没有sql调整,继续使用上一节的sql即可。

          先启动 ai-mcp-gateway-demo-mcp-server-test 它负责提供 http 接口。

          在启动 ai-mcp-gateway 它负责网关能力,协议转换,调用 http 接口。

          在 ai-mcp-gateway-demo-mcp-server-test 工程的 ApiTest 下,调用 mcp 服务,这样就可以串联起来了。

          1. 单测方法
            public class ApiTest {

            public static void main(String[] args) throws Exception {
            OpenAiApi openAiApi = OpenAiApi.builder()
            .baseUrl(“https://apis.itedus.cn“)
            .apiKey(“sk-JCwN4ZzqDBoh8mOFD77dF915E22**** 联系小傅哥申请测试key”)
            .completionsPath(“v1/chat/completions”)
            .embeddingsPath(“v1/embeddings”)
            .build();

             ChatModel chatModel = OpenAiChatModel.builder()
                     .openAiApi(openAiApi)
                     .defaultOptions(OpenAiChatOptions.builder()
                             .model("gpt-4o")
                             .toolCallbacks(new SyncMcpToolCallbackProvider(sseMcpClient02()).getToolCallbacks())
                             .build())
                     .build();
            
             log.info("测试结果:{}", chatModel.call("""
                     获取公司雇员信息,信息如下;
                     城市;北京
                     公司;谷歌
                     雇员;小傅哥
                     """));
            

            }

            public static McpSyncClient sseMcpClient02() {
            HttpClientSseClientTransport sseClientTransport = HttpClientSseClientTransport
            .builder(“http://localhost:8777“)
            .sseEndpoint(“/api-gateway/gateway_001/mcp/sse”)
            .build();

             McpSyncClient mcpSyncClient = McpClient.sync(sseClientTransport).requestTimeout(Duration.ofMinutes(360)).build();
             var init_sse = mcpSyncClient.initialize();
             log.info("Tool SSE MCP Initialized {}", init_sse);
            
             return mcpSyncClient;
            

            }

          }

          网关日志

          26-02-20.08:52:13.142 [http-nio-8777-exec-10] INFO SessionMessageService - 开始处理请求,方法: tools/list
          26-02-20.08:52:14.608 [http-nio-8777-exec-1] INFO McpGatewayController - 处理 MCP SSE 消息,gatewayId:gateway_001 sessionId:dea027bf-b355-4def-9c7d-be2ea4e12836 messageBody:{“jsonrpc”:”2.0”,”method”:”tools/call”,”id”:”d4707e30-2”,”params”:{“name”:”JavaSDKMCPClient_getCompanyEmployee”,”arguments”:{“xxxRequest01”:{“city”:”beijing”,”company”:{“name”:”google”,”type”:””}}}}}
          26-02-20.08:52:14.608 [http-nio-8777-exec-1] INFO RootNode - 消息处理 mcp message RootNode:HandleMessageCommandEntity(gatewayId=gateway_001, sessionId=dea027bf-b355-4def-9c7d-be2ea4e12836, jsonrpcMessage=JSONRPCRequest[jsonrpc=2.0, method=tools/call, id=d4707e30-2, params={name=JavaSDKMCPClient_getCompanyEmployee, arguments={xxxRequest01={city=beijing, company={name=google, type=}}}}])
          26-02-20.08:52:14.608 [http-nio-8777-exec-1] INFO SessionNode - 消息处理 mcp message SessionNode:HandleMessageCommandEntity(gatewayId=gateway_001, sessionId=dea027bf-b355-4def-9c7d-be2ea4e12836, jsonrpcMessage=JSONRPCRequest[jsonrpc=2.0, method=tools/call, id=d4707e30-2, params={name=JavaSDKMCPClient_getCompanyEmployee, arguments={xxxRequest01={city=beijing, company={name=google, type=}}}}])
          26-02-20.08:52:14.608 [http-nio-8777-exec-1] INFO MessageHandlerNode - 消息处理 mcp message MessageHandlerNode:HandleMessageCommandEntity(gatewayId=gateway_001, sessionId=dea027bf-b355-4def-9c7d-be2ea4e12836, jsonrpcMessage=JSONRPCRequest[jsonrpc=2.0, method=tools/call, id=d4707e30-2, params={name=JavaSDKMCPClient_getCompanyEmployee, arguments={xxxRequest01={city=beijing, company={name=google, type=}}}}])
          26-02-20.08:52:14.608 [http-nio-8777-exec-1] INFO SessionMessageService - 开始处理请求,方法: tools/call
          26-02-20.08:52:21.477 [SpringApplicationShutdownHook] INFO GracefulShutdown - Commencing graceful shutdown. Waiting for active requests to complete

          AI测试客户端日志

          08:52:16.412 [main] INFO cn.bugstack.ai.mcp.server.test.ApiTest – 测试结果:根据查询结果,在北京谷歌公司工作的雇员小傅哥的信息如下:

          • 名字 : 小傅哥
          • 薪资 : 16.92(单位可能未明确)
          • 每日工时 : 6 小时

          你是否需要进一步的细节或者其他帮助?

          《AI MCP Gateway》第3-12节:鉴权功能领域服务

          一、本章诉求
          设计 MCP 网关通信过程中的鉴权领域功能,包括;权限注册、请求限流、权限校验,这样3个主要的服务能力。领域层设计好后,就可以让 case 串联逻辑完成权限功能的使用了。

          二、流程设计
          如图,关于鉴权功能的领域处理;

          img_31.png

          首先,设计对鉴权领域的功能,校验阶段,判断当前用户传递 api_key 是否为配置的有效key,是否开启认证,是否在有效期。

          之后,是注册 api_key 的处理,以及 api_key 的使用限流。数据库表中设计了,速率限制(次/小时) 可以按需设计你的。这部分值在使用中会转换为多少秒一次,不过程序调用过程中,一般会达到毫秒。所以这个值可以适当放大

          三、编码实现

          1. 工程结构

          首先,在 domain 领域层增加 auth 鉴权领域服务,之后设计单一职责接口服务 IAuthLicenseService、IAuthRateLimitService、IAuthRegisterService。

          之后,在 domain 领域下的 model 模型对象中,设计了实体命令类,来驱动鉴权功能服务。包括,校验命令、限流命令、注册命令,实体对象。

          1. 库表调整
            img_32.png

          在 mcp_gateway 添加上是否强校验的字段配置。mcp_gateway_auth 表中,还有一些索引的调整和测试数据。你可以导入本节最新sql语句。

          1. 功能开发
            3.0 枚举设计
            public enum AuthStatusEnum {

            ;

            @Getter
            @AllArgsConstructor
            @NoArgsConstructor
            public enum GatewayConfig {

             NOT_VERIFIED(0, "不校验"),
            
             STRONG_VERIFIED(1, "强校验"),
             ;
            
             private Integer code;
             private String info;
            
             public static GatewayConfig get(Integer code) {
                 if (code == null) return null;
                 for (GatewayConfig val : values()) {
                     if (val.code.equals(code)) {
                         return val;
                     }
                 }
                 throw new AppException(ResponseCode.ENUM_NOT_FOUND.getCode(), ResponseCode.ENUM_NOT_FOUND.getInfo());
             }
            

            }

            @Getter
            @AllArgsConstructor
            @NoArgsConstructor
            public enum AuthConfig {
            DISABLE(0, “禁用”),
            ENABLE(1, “启用”),

             ;
            
             private Integer code;
             private String info;
            
             public static AuthConfig get(Integer code) {
                 if (code == null) return null;
                 for (AuthConfig val : values()) {
                     if (val.code.equals(code)) {
                         return val;
                     }
                 }
                 throw new AppException(ResponseCode.ENUM_NOT_FOUND.getCode(), ResponseCode.ENUM_NOT_FOUND.getInfo());
             }
            

            }

          }

          设计枚举值,他们都是 auth 相关的,所以都放到了一块。方便管理。

          3.1 权限校验
          接口设计

          public interface IAuthLicenseService {

          boolean checkLicense(LicenseCommandEntity commandEntity);
          

          }

          功能实现

          @Service
          public class AuthLicenseService implements IAuthLicenseService {

          @Resource
          private IAuthRepository repository;
          
          @Override
          public boolean checkLicense(LicenseCommandEntity commandEntity) {
              // 查询是否强校验(非强校验,直接返回校验结果 true)
              AuthStatusEnum.GatewayConfig gatewayAuthStatus = repository.queryGatewayAuthStatus(commandEntity.getGatewayId());
              if (AuthStatusEnum.GatewayConfig.NOT_VERIFIED.equals(gatewayAuthStatus)) return true;
          
              // 查询网关认证配置信息
              McpGatewayAuthVO mcpGatewayAuthVO = repository.queryEffectiveGatewayAuthInfo(commandEntity);
          
              // 没有匹配到权限返回 false
              if (null == mcpGatewayAuthVO) return false;
          
              // 检查是否开启了认证模式,未开启则为true
              if (AuthStatusEnum.AuthConfig.DISABLE.equals(mcpGatewayAuthVO.getStatus())) {
                  return true;
              }
          
              // 判断过期时间,未设置过期时间永久有效
              Date expireTime = mcpGatewayAuthVO.getExpireTime();
              if (null == expireTime) return true;
          
              boolean isBefore = new Date().before(expireTime);
          
              if (!isBefore) {
                  log.warn("apiKey 权限校验,expireTime 已过期。gatewayId:{} apiKey:{}", commandEntity.getGatewayId(), commandEntity.getApiKey());
              }
          
              return isBefore;
          }
          

          }

          这部分要判断当前网关下是否配置了强鉴权,如果没有有效配置,则可以直接返回 true 即可。之后继续按照网关ID + apiKey查询数据,再校验数据是否空,以及是否开启,之后在判断是够过期。

          另外,这部分查询数据,也可以优化到 redis 进行存储起来,提高查询效率。

          3.2 调用限流
          接口设计

          public interface IAuthRateLimitService {

          /**
           * 限流操作
           * true - 限流
           * false - 未限流
           */
          boolean rateLimit(RateLimitCommandEntity commandEntity);
          

          }

          功能实现

          @Service
          public class AuthRateLimitService implements IAuthRateLimitService {

          @Resource
          private IAuthRepository repository;
          
          private final Cache<String, RateLimiter> rateLimiterCache = CacheBuilder.newBuilder()
                  .expireAfterAccess(1, TimeUnit.HOURS)
                  .build();
          
          @Override
          public boolean rateLimit(RateLimitCommandEntity commandEntity) {
              String gatewayId = commandEntity.getGatewayId();
              String apiKey = commandEntity.getApiKey();
          
              if (null == apiKey || apiKey.isEmpty()) return false;
          
              try {
                  // 1. 获取限流组件
                  RateLimiter rateLimiter = rateLimiterCache.get(gatewayId + "_" + apiKey, () -> {
                      McpGatewayAuthVO mcpGatewayAuthVO = repository.queryEffectiveGatewayAuthInfo(new LicenseCommandEntity(gatewayId, apiKey));
                      if (null == mcpGatewayAuthVO || null == mcpGatewayAuthVO.getRateLimit()) {
                          throw new IllegalStateException("未配置限流");
                      }
          
                      // 速率限制(次/小时)转换为(次/秒)
                      double permitsPerSecond = (double) mcpGatewayAuthVO.getRateLimit() / 3600;
                      if (permitsPerSecond <= 0) {
                          throw new IllegalArgumentException("限流值不正确");
                      }
          
                      return RateLimiter.create(permitsPerSecond);
                  });
          
                  // 2. 尝试获取令牌
                  return !rateLimiter.tryAcquire();
          
              } catch (ExecutionException e) {
                  Throwable cause = e.getCause();
                  // 如果是无配置,按原逻辑返回 false (不限流)
                  if (cause instanceof IllegalStateException) {
                      return false;
                  }
                  // 如果是配置为 0/负数,按原逻辑返回 true (限流/禁止)
                  if (cause instanceof IllegalArgumentException) {
                      return true;
                  }
                  // 其他异常(如数据库错误),记录日志并放行
                  log.error("限流校验失败 gatewayId:{} apiKey:{}", gatewayId, apiKey, e);
                  return false;
              }
          }
          

          }

          使用 Google Guava 包下的 RateLimiter 进行限流设计。过滤当前 api_key 最大可支持的请求频次。

          这部分会首次会新建,并根据速率限制,放到 RateLimiter.create(permitsPerSecond) 中。之后二次会直接调用使用 !rateLimiter.tryAcquire()。如果被限流到了,就返回一个 true,表示限流成功。

          3.3 权限注册
          接口设计

          public interface IAuthRegisterService {

          String register(RegisterCommandEntity commandEntity);
          

          }

          功能实现

          @Service
          public class AuthRegisterService implements IAuthRegisterService {

          @Resource
          private IAuthRepository repository;
          
          @Override
          public String register(RegisterCommandEntity commandEntity) {
              // 1. 生成 API Key | gw 网关缩写,方便区分
              String apiKey = "gw-" + RandomStringUtils.randomAlphanumeric(48);
          
              // 2. 构建聚合对象
              McpGatewayAuthVO mcpGatewayAuthVO = McpGatewayAuthVO.builder()
                      .gatewayId(commandEntity.getGatewayId())
                      .apiKey(apiKey)
                      .rateLimit(commandEntity.getRateLimit())
                      .expireTime(commandEntity.getExpireTime())
                      .status(AuthStatusEnum.AuthConfig.ENABLE)
                      .build();
          
              // 3. 保存数据
              repository.insert(mcpGatewayAuthVO);
          
              // 4. 返回结果
              return apiKey;
          }
          

          }

          生成一个 gw- 开头的唯一字符串,如果生成失败数据库表也会有唯一索引失败,最后前端运营配置的时候重新生成即可。但这种概率是非常低的。

          构建完成后,插入到数据库表即可。

          四、测试验证

          1. 鉴权服务测试
            @SpringBootTest
            public class AuthLicenseServiceTest {

            @Resource
            private IAuthLicenseService authLicenseService;

            @Resource
            private IAuthRegisterService authRegisterService;

            @Test
            public void test_checkLicense() {
            // 1. 注册 可以走新注册 apiKey,或者找到数据表中现存的数据测试。
            /* RegisterCommandEntity registerCommandEntity = new RegisterCommandEntity();
            registerCommandEntity.setGatewayId(“api-gateway-g4”);
            registerCommandEntity.setRateLimit(10);
            registerCommandEntity.setExpireTime(new Date(System.currentTimeMillis() + 1000L * 60 * 60)); // 1小时后过期
            String apiKey = authRegisterService.register(registerCommandEntity);
            log.info(“注册结果 apiKey: {}”, apiKey);*/

             // 2. 鉴权
             LicenseCommandEntity commandEntity = new LicenseCommandEntity();
             commandEntity.setGatewayId("api-gateway-g4");
            

          // commandEntity.setApiKey(apiKey);
          commandEntity.setApiKey(“gw-lf3HFzlJCdnrYl20oHbd5lJQxE7GWz8wjsSgjDZfctJNV8s5”);

              boolean success = authLicenseService.checkLicense(commandEntity);
              log.info("鉴权结果 success: {}", success);
              assertTrue(success);
          }
          

          }

          26-02-23.08:10:22.771 [main ] INFO HikariPool - HikariPool-1 - Added connection com.mysql.cj.jdbc.ConnectionImpl@3af6c672
          26-02-23.08:10:22.772 [main ] INFO HikariDataSource - HikariPool-1 - Start completed.
          26-02-23.08:10:22.789 [main ] INFO AuthLicenseServiceTest - 鉴权结果 success: true

          可以调整 apiKey 值进行测试验证。

          1. 限流服务测试
            @SpringBootTest
            public class AuthRateLimitServiceTest {

            @Resource
            private IAuthRateLimitService authRateLimitService;

            @Resource
            private IAuthRegisterService authRegisterService;

            @Test
            public void test_rateLimit_success() throws InterruptedException {
            // 1. 注册 (36000次/小时 => 10次/秒)
            /RegisterCommandEntity registerCommandEntity = new RegisterCommandEntity();
            registerCommandEntity.setGatewayId(“api-gateway-g4”);
            registerCommandEntity.setRateLimit(36000);
            registerCommandEntity.setExpireTime(new Date(System.currentTimeMillis() + 1000L * 60 * 60));
            String apiKey = authRegisterService.register(registerCommandEntity);
            log.info(“注册结果 apiKey: {}”, apiKey);
            /

             // 1. 可以走新注册 apiKey,或者找到数据表中现存的数据测试。
             RateLimitCommandEntity commandEntity = new RateLimitCommandEntity();
             commandEntity.setGatewayId("api-gateway-g4");
             commandEntity.setApiKey("gw-lf3HFzlJCdnrYl20oHbd5lJQxE7GWz8wjsSgjDZfctJNV8s5");
            
             // 2.1 第一次调用
             boolean rateLimit = authRateLimitService.rateLimit(commandEntity);
             log.info("限流结果(第一次) rateLimit: {}", rateLimit);
             assertFalse(rateLimit); // false 表示未被限流
            
             // 休息 150ms,让令牌桶生成新的令牌(10次/秒 = 100ms/次)
             Thread.sleep(150);
             
             // 2.2 第二次调用
             rateLimit = authRateLimitService.rateLimit(commandEntity);
             log.info("限流结果(第二次) rateLimit: {}", rateLimit);
             assertFalse(rateLimit);
            

            }

          ai-mcp-gateway-3-12-04.png

          26-02-23.08:11:16.731 [main ] INFO AuthRateLimitServiceTest - 限流结果(第一次) rateLimit: false
          26-02-23.08:11:16.886 [main ] INFO AuthRateLimitServiceTest - 限流结果(第二次) rateLimit: true

          java.lang.AssertionError
          at org.junit.Assert.fail(Assert.java:87)
          at org.junit.Assert.assertTrue(Assert.java:42)
          at org.junit.Assert.assertFalse(Assert.java:65)
          at org.junit.Assert.assertFalse(Assert.java:75)
          at cn.bugstack.ai.test.domain.auth.AuthRateLimitServiceTest.test_rateLimit_success(AuthRateLimitServiceTest.java:62)
          at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
          at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77)
          at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
          at java.base/java.lang.reflect.Method.invoke(Method.java:569)

          这部分会依赖于你在数据中的配置,3600 次/小时,会被计算成多少次每秒,之后限流使用。比如你调整的大一些,第二次限流就是 false 了。

          1. 注册服务测试
            @SpringBootTest
            public class AuthRegisterServiceTest {

            @Resource
            private IAuthRegisterService authRegisterService;

            @Test
            public void test_register() {
            RegisterCommandEntity commandEntity = new RegisterCommandEntity();
            commandEntity.setGatewayId(“api-gateway-g4”);
            commandEntity.setRateLimit(10);
            // 过期时间:2天
            commandEntity.setExpireTime(new Date(System.currentTimeMillis() + 1000L * 60 * 60 * 24 * 2));

             String apiKey = authRegisterService.register(commandEntity);
             log.info("注册结果 apiKey: {}", apiKey);
             
             assertNotNull(apiKey);
             assertTrue(apiKey.startsWith("gw-"));
            

            }

          }

          注册服务,验证像数据库表里写数据即可。

          《AI MCP Gateway》第3-13节:鉴权功能编排处理

          一、本章诉求
          在 case 的 mcp 模块下,将 auth 鉴权功能,串联到 session 会话创建、message 消息处理中去。让会话创建的时候可以校验 apiKey 的可用性,以及在消息处理中使用限流控制请求频次。

          二、流程设计
          如图,将鉴权功能串联到case编排中;

          ai-mcp-gateway-3-13-01.png

          首先,是 api 入参这部分,都需要添加一个 apiKey 作为后缀的请求入参。会话请求阶段,是用户配置的请求连接传递进来的,而后续的消息处理部分,是我们在会话阶段把 apiKey 拼接到请求地址里去的。

          之后,消息处理阶段,根据请求的 apiKey 做响应的限流处理。基本你在各个官网申请的 mcp 服务,如百度搜索,都会让你创建一个 apiKey 针对你的这个 key 做一些列的流程处理。包括还会对你请求的数据做一个日志记录。

          三、编码实现
          1、工程结构
          img_33.png

          首先,以 trigger 层为入口调用 case,这部分 要添加 apiKey 的入参信息,之后 case 分别在2个编排的流程中处理各自的业务。

          之后,case 层,session 在 VerifyNode 进行核验,SessionNode 则负责透彻 apiKey 用于拼接到下一次消息处理的请求上去。另外一个 message 则是在 RootNode 根节点,做了一个限流的处理(也可以新增加一个单独的节点处理)。

          最后,是 domain 层来承接这部分逻辑的时候,Session 会话中,把 apiKey 写入进去。

          1. 功能开发
            2.1 领域层 - 拼接 apiKey
            2.1.1 对照参考
            这里你可以先申请一个百度的 apiKey 访问它的地址,看看他的返回结果,拼接的过程。

          百度搜索MCP服务(url);https://sai.baidu.com/zh/detail/e014c6ffd555697deabf00d058baf388

          百度搜索MCP服务(key - 可自行申请);https://console.bce.baidu.com/iam/?_=1753597622044#/iam/apikey/list

          当我们的请求带有 apiKey 的时候,mcp 服务,会把这个 apiKey 拼接到下一次数据请求的连接后面,让 ai 客户端拿到之后去调用处理。

          那么到了下一次数据请求处理中,就可以根据请求信息做相应的操作了,比如做限流动作(其实这里还可以做敏感词的过滤,风险数据的拦截等)。

          2.1.2 逻辑实现
          public SessionConfigVO createSession(String gatewayId, String apiKey) {
          log.info(“创建会话 gatewayId:{}”, gatewayId);
          String sessionId = UUID.randomUUID().toString();
          Sinks.Many<ServerSentEvent> sink = Sinks.many().multicast().onBackpressureBuffer();

          // 发送端点消息 - 告知客户端消息请求地址(客户端第二次会使用 messageEndpoint 进行请求会话)
          String messageEndpoint = "/api-gateway/" + gatewayId + "/mcp/sse?sessionId=" + sessionId;
          if (StringUtils.isNoneBlank(apiKey)) {
              messageEndpoint += "&api_key=" + apiKey;
          }
          
          sink.tryEmitNext(ServerSentEvent.<String>builder()
                  .event("endpoint")
                  .data(messageEndpoint)
                  .build());
          SessionConfigVO sessionConfigVO = new SessionConfigVO(sessionId, sink);
          activeSessions.put(sessionId, sessionConfigVO);
          log.info("创建会话 gatewayId:{} sessionId:{},当前活跃会话数:{}", gatewayId, sessionId, activeSessions.size());
          return sessionConfigVO;
          

          }

          在创建 Session 会话的时候,判断 apiKey 是否为空,如果存在值,则把这个值拼接到请求链接后。

          2.2 编排层 - 串联逻辑
          2.2.1 会话创建(Session)
          @Service(“mcpSessionVerifyNode”)
          public class VerifyNode extends AbstractMcpSessionSupport {

          @Resource(name = "mcpSessionSessionNode")
          private SessionNode sessionNode;
          
          @Resource
          private IAuthLicenseService authLicenseService;
          
          @Override
          protected Flux<ServerSentEvent<String>> doApply(String requestParameter, DefaultMcpSessionFactory.DynamicContext dynamicContext) throws Exception {
              log.info("创建会话-VerifyNode:{}", requestParameter);
          
              boolean isCheckSuccess
                      = authLicenseService.checkLicense(new LicenseCommandEntity(requestParameter, dynamicContext.getApiKey()));
          
              if (!isCheckSuccess) {
                  throw new AppException(McpErrorCodes.INSUFFICIENT_PERMISSIONS, "fail to auth apikey");
              }
          
              return router(requestParameter, dynamicContext);
          }
          
          @Override
          public StrategyHandler<String, DefaultMcpSessionFactory.DynamicContext, Flux<ServerSentEvent<String>>> get(String requestParameter, DefaultMcpSessionFactory.DynamicContext dynamicContext) throws Exception {
              return sessionNode;
          }
          

          }

          在 VerifyNode 节点,调用 authLicenseService.checkLicense 方法进行验证,如果为 false 则会抛出一个异常,反馈给调用端。

          @Service(“mcpSessionSessionNode”)
          public class SessionNode extends AbstractMcpSessionSupport {

          @Resource(name = "mcpSessionEndNode")
          private EndNode endNode;
          
          @Override
          protected Flux<ServerSentEvent<String>> doApply(String requestParameter, DefaultMcpSessionFactory.DynamicContext dynamicContext) throws Exception {
              log.info("创建会话-SessionNode:{}", requestParameter);
          
              // 创建会话服务
              SessionConfigVO sessionConfigVO = sessionManagementService.createSession(requestParameter, dynamicContext.getApiKey());
          
              // 写入上下文中
              dynamicContext.setSessionConfigVO(sessionConfigVO);
          
              return router(requestParameter, dynamicContext);
          }
          
          @Override
          public StrategyHandler<String, DefaultMcpSessionFactory.DynamicContext, Flux<ServerSentEvent<String>>> get(String requestParameter, DefaultMcpSessionFactory.DynamicContext dynamicContext) throws Exception {
              return endNode;
          }
          

          }

          另外还有一个 Session 节点,这个节点只是透传 apiKey 进去就可以,它的目的是为了拼接参数。

          2.2.2 消息处理(Message)
          @Service(“mcpMessageRootNode”)
          public class RootNode extends AbstractMcpMessageServiceSupport {

          @Resource(name = "mcpMessageSessionNode")
          private SessionNode sessionNode;
          
          @Resource
          private IAuthRateLimitService authRateLimitService;
          
          @Override
          protected ResponseEntity<Void> doApply(HandleMessageCommandEntity requestParameter, DefaultMcpMessageFactory.DynamicContext dynamicContext) throws Exception {
              try {
                  log.info("消息处理 mcp message RootNode:{}", requestParameter);
          
                  // 判断命中工具调用做限流处理
                  if (requestParameter.getJsonrpcMessage() instanceof McpSchemaVO.JSONRPCRequest request) {
                      String method = request.method();
          
                      SessionMessageHandlerMethodEnum sessionMessageHandlerMethodEnum = SessionMessageHandlerMethodEnum.getByMethod(method);
                      if (SessionMessageHandlerMethodEnum.TOOLS_CALL.equals(sessionMessageHandlerMethodEnum)){
                          // 是(true)否(false)命中限流
                          boolean isHit = authRateLimitService.rateLimit(new RateLimitCommandEntity(requestParameter.getGatewayId(), requestParameter.getApiKey()));
                          if (isHit) {
                              log.warn("消息处理 mcp message RootNode - 命中限流{} {}", requestParameter.getGatewayId(), requestParameter.getApiKey());
                              throw new AppException(McpErrorCodes.INSUFFICIENT_PERMISSIONS, "fail to auth apikey rateLimiter");
                          }
                      }
                  }
          
                  return router(requestParameter, dynamicContext);
              } catch (Exception e) {
                  log.error("消息处理 mcp message RootNode:{}", requestParameter, e);
                  throw e;
              }
          }
          
          @Override
          public StrategyHandler<HandleMessageCommandEntity, DefaultMcpMessageFactory.DynamicContext, ResponseEntity<Void>> get(HandleMessageCommandEntity requestParameter, DefaultMcpMessageFactory.DynamicContext dynamicContext) throws Exception {
              return sessionNode;
          }
          

          }

          一次 AI 请求会分为资源获取,列表加载,之后是工具调用,这里为了更准确的限流,需要先判断出工具调用类型在做限流处理。

          增加一个判断限流是否命中,如果命中限流也要抛一个异常。

          2.3 触发器 - 接口使用
          @Slf4j
          @RestController
          @CrossOrigin(origins = ““, allowedHeaders = ““, methods = {RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE, RequestMethod.OPTIONS})
          @RequestMapping(“/“)
          public class McpGatewayController implements IMcpGatewayService {

          @Resource
          private IMcpSessionService mcpSessionService;
          
          @Resource
          private IMcpMessageService mcpMessageService;
          
          /**
           * 处理 sse 连接,创建会话
           * <br/>
           * <a href="http://localhost:8777/api-gateway/gateway_001/mcp/sse">http://localhost:8777/api-gateway/gateway_001/mcp/sse</a>
           * <br/>
           * <a href="http://localhost:8777/api-gateway/gateway_001/mcp/sse?api_key=gw-lf3HFzlJCdnrYl20oHbd5lJQxE7GWz8wjsSgjDZfctJNV8s5">http://localhost:8777/api-gateway/gateway_001/mcp/sse?api_key=gw-lf3HFzlJCdnrYl20oHbd5lJQxE7GWz8wjsSgjDZfctJNV8s5</a>
           *
           * @param gatewayId 网关ID
           */
          @GetMapping(value = "{gatewayId}/mcp/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
          @Override
          public Flux<ServerSentEvent<String>> handleSseConnection(
                  @PathVariable("gatewayId") String gatewayId, @RequestParam("api_key") String apiKey) throws Exception {
               // ... 省略部分代码
              } catch (Exception e) {
                  log.error("建立 MCP SSE 连接失败,gatewayId: {}", gatewayId, e);
                  throw e;
              }
          }
          
          /**
           * 处理 sse 消息,响应会话
           *
           * @param gatewayId   网关ID
           * @param sessionId   会话ID
           * @param messageBody 请求消息
           * @return 响应结果
           * <br/>
           * {
           * "jsonrpc": "2.0",
           * "method": "initialize",
           * "id": "95835f74-0",
           * "params": {
           * "protocolVersion": "2024-11-05",
           * "capabilities": {},
           * "clientInfo": {
           * "name": "Java SDK MCP Client",
           * "version": "1.0.0"
           * }
           * }
           * }
           */
          @PostMapping(value = "{gatewayId}/mcp/sse", consumes = MediaType.APPLICATION_JSON_VALUE)
          public Mono<ResponseEntity<Void>> handleMessage(@PathVariable("gatewayId") String gatewayId,
                                                          @RequestParam("sessionId") String sessionId,
                                                          @RequestParam("api_key") String apiKey,
                                                          @RequestBody String messageBody) {
              try {
                  // ... 省略部分代码
                  return Mono.just(responseEntity);
              } catch (Exception e) {
                  log.error("处理 MCP SSE 消息失败,gatewayId:{} sessionId:{} messageBody:{}", gatewayId, sessionId, messageBody, e);
                  return Mono.just(ResponseEntity.internalServerError().build());
              }
          }
          

          }

          在 McpGatewayController 这一层,主要注意的是关于 @RequestParam(“api_key”) String apiKey 入参的添加。之后就是串联调用到 case 层即可。

          四、测试验证

          1. 注意事项

          本节没有sql调整,继续使用上一节的sql即可。

          先启动 ai-mcp-gateway-demo-mcp-server-test 它负责提供 http 接口。

          在启动 ai-mcp-gateway 它负责网关能力,协议转换,调用 http 接口。

          在 ai-mcp-gateway-demo-mcp-server-test 工程的 ApiTest 下,调用 mcp 服务,这样就可以串联起来了。

          1. 单测调用
            public class ApiTest {

            public static void main(String[] args) throws Exception {
            OpenAiApi openAiApi = OpenAiApi.builder()
            .baseUrl(“https://apis.itedus.cn“)
            .apiKey(“sk-JCwN4ZzqDBoh8mOFD77dF915E22**** 联系小傅哥申请测试key”)
            .completionsPath(“v1/chat/completions”)
            .embeddingsPath(“v1/embeddings”)
            .build();

             ChatModel chatModel = OpenAiChatModel.builder()
                     .openAiApi(openAiApi)
                     .defaultOptions(OpenAiChatOptions.builder()
                             .model("gpt-4o")
                             .toolCallbacks(new SyncMcpToolCallbackProvider(sseMcpClient02()).getToolCallbacks())
                             .build())
                     .build();
            
             log.info("测试结果:{}", chatModel.call("""
                     获取公司雇员信息,信息如下;
                     城市;北京
                     公司;谷歌
                     雇员;小傅哥
                     """));
            

            }

            public static McpSyncClient sseMcpClient02() {
            HttpClientSseClientTransport sseClientTransport = HttpClientSseClientTransport
            .builder(“http://localhost:8777“)
            .sseEndpoint(“/api-gateway/gateway_001/mcp/sse”)
            .build();

             McpSyncClient mcpSyncClient = McpClient.sync(sseClientTransport).requestTimeout(Duration.ofMinutes(360)).build();
             var init_sse = mcpSyncClient.initialize();
             log.info("Tool SSE MCP Initialized {}", init_sse);
            
             return mcpSyncClient;
            

            }

          }

          网关日志

          26-02-24.13:43:39.462 [http-nio-8777-exec-1] INFO SessionManagementService - 创建会话 gatewayId:gateway_001
          26-02-24.13:43:39.467 [http-nio-8777-exec-1] INFO SessionManagementService - 创建会话 gatewayId:gateway_001 sessionId:ef1ee1a7-4824-4e99-a8e0-c0d4b1cbef9b,当前活跃会话数:1
          26-02-24.13:43:39.467 [http-nio-8777-exec-1] INFO EndNode - 创建会话-EndNode:gateway_001
          26-02-24.13:44:02.231 [http-nio-8777-exec-2] INFO McpGatewayController - 建立 MCP SSE 连接,gatewayId:gateway_001
          26-02-24.13:44:02.231 [http-nio-8777-exec-2] INFO RootNode - 创建会话 mcp session RootNode:gateway_001
          26-02-24.13:44:02.232 [http-nio-8777-exec-2] INFO VerifyNode - 创建会话-VerifyNode:gateway_001
          26-02-24.13:44:02.237 [http-nio-8777-exec-2] ERROR RootNode - 创建会话 mcp session RootNode 异常:gateway_001

          AI测试客户端日志

          13:22:14.056 [Thread-0] INFO cn.bugstack.ai.mcp.server.test.ApiTest – 测试结果:根据提供的信息,我查询到北京谷歌公司的以下雇员相关信息:

          • 雇员名称 :小傅哥
          • 工时 :每天工作 10 小时
          • 薪资 :16.92(单位可能是千元或其他货币,可以根据具体情况确认)。

          《AI MCP Gateway》第3-14节:解析Swagger标准OpenAPI协议

          一、本章诉求
          在前面章节,我们实现了关于 AI MCP 网关,解决 HTTP 协议向 MCP 协议的转换处理。相关的协议(HTTP)数据存储在数据库表中。那么现在的另外一个问题就是,数据库表里的协议映射数据应该怎么录入进去。

          这个录入方案也比较多,比如,提供一个页面,让用户自己手动输入相关的协议映射信息。也可以提供一个 SDK 组件,让 HTTP 接口服务端引入,之后自动上报。之后这里还有一种方案,是通过 Swagger 导出标准 OpenAPI 协议接口 json 文件,以文件数据的方式,录入到数据库表中。

          本节,我们先来处理关于 Swagger 的使用到提供一个工具包把 OpenAPI 的 JSON 转换为我们的设计的库表对应对象的关系。本节操作转换的工具类包,不非得手动编码,能理解和使用即可。

          二、工具介绍(Swagger)

          如图,我们需要的就是这份标准的 OpenAPI 接口的 JSON 文件,使用它解析并转换为目前 ai mcp gateway 库表结构中设计的 http 协议和字段映射关系。你可以把这部分的解析,当做一个工具包使用。

          三、流程设计

          1. 设计目标

          从 Swagger 导出的 OpenAPI 标准的 JSON 结构对象,到 ai mcp gateway 库表结构的对象转换。需要解析结构,也就是在这个过程中,找到对象关系链,一层层的拿到相关的数据。

          1. 设计流程
            如图,Swagger OpenAPI json 文件转换流程设计;

          img_34.png
          首先,当你仔细查看 Swagger 导出的 json 文件,他会根据接口的结构,分别用 requestBody、properties 区分是对象还是属性,这些信息是有利于我们分析它如何进行解析结构的。官网资料 OpenAPI 规范(中文版) - 可以查看命名的对象结构。

          之后,整个解析过程过,转换 json 数据为对象,循环提取和嵌套解析,按照是对象类型还是属性类型做第一次解析,之后如果对象里嵌套了属性,则嵌套循环继续解析属性。

          最后,把所有解析填充的数据都填充到返回列表即可。这一章节只做解析,后续的章节在做落库的动作

          1. 功能开发
            3.1 测试工程(ai-mcp-gateway-demo-mcp-server-test)
            首先我们需要在测试工程引入 Swagger,并对接口以及出入参对象的属性使用 Swagger 注解增强描述,增强描述的信息主要为了可以让拿到的接口文档信息,与 mcp 服务配置更为匹配。

          如果是历史工程,接口信息不完善,那么则需要人工手动的在网关层维护(其实不如把历史工程维护好,这样以后每次不同场景都可以生成出需要的迭代文档了) —— Swagger 官网说:人工智能的未来取决于 API 质量

          3.1.1 pom 配置

          org.springdoc
          springdoc-openapi-starter-webmvc-ui
          2.5.0

          org.springdoc springdoc-openapi-starter-webmvc-api 2.5.0

          Swagger openapi pom 文件引入,引入后项目启动后可以自动获取 http 接口信息。

          3.1.2 增强描述
          3.1.2.1 类和属性 - schema
          @Data
          @JsonInclude(JsonInclude.Include.NON_NULL)
          @Schema(name = “XxxRequest01”, description = “公司员工信息查询请求”)
          public class XxxRequest01 {

          @JsonProperty(required = true, value = "city")
          @JsonPropertyDescription("城市名称,如果是中文汉字请先转换为汉语拼音,例如北京:beijing")
          @Schema(description = "城市名称(拼音),例如: beijing", requiredMode = Schema.RequiredMode.REQUIRED)
          private String city;
          
          // ... 省略部分代码
          

          }

          对类或者属性,通过 @Schema 注解,增强描述,这些内容可以有利于后续转换为 mcp 服务。

          3.1.2.2 服务 - tag
          @RestController
          @CrossOrigin(“*”)
          @RequestMapping(“/api/v1/mcp/“)
          @Tag(name = “测试服务”, description = “公司员工信息查询接口”)
          public class TestServiceController {
          // … 省略部分
          }

          通过 @Tag 标签增加接口服务描述。

          3.1.2.3 接口 - operation
          @PostMapping(“get_company_employee”)
          @Operation(
          summary = “获取公司员工信息”,
          description = “根据城市与公司信息返回员工工资与工时”
          )
          @ApiResponses({
          @ApiResponse(responseCode = “200”, description = “成功”,
          content = @Content(schema = @Schema(implementation = XxxResponse.class)))
          })
          public XxxResponse getCompanyEmployee(@RequestBody XxxRequest01 xxxRequest01) {
          log.info(“查询公司员工信息 - 城市:{}, 公司:{}”,
          xxxRequest01.getCity(),
          xxxRequest01.getCompany().getName());

          // … 省略部分代码

          }

          @GetMapping(“/query-by-id-01”)
          public String queryAiClientById01(@RequestParam(“id”) String id) {
          return “hi xiaofuge “ + id;
          }

          @GetMapping(“/query-by-id-02”)
          public String queryAiClientById02(@RequestParam(“id”) int id) {
          return “hi xiaofuge “ + id;
          }

          @GetMapping(“/query-by-id-03”)
          public String queryAiClientById03(@RequestParam(“id”) Integer id) {
          return “hi xiaofuge “ + id;
          }

          @GetMapping(“/query-test02”)
          public String test02(@RequestBody XxxRequest01.Company.Deep deep) {
          return “hi xiaofuge”;
          }

          @GetMapping(“/query-test03”)
          public String test03(@RequestBody XxxRequest01.Company company) {
          return “hi xiaofuge”;
          }

          接口方法通过 @Operation 注解增强描述。

          此外这部分还增加了很多的测试接口,分别验证不同类型的入参方式。

          在你要使用的接口,转换为 mcp 服务协议的,尽量都做一些这样的补充,这样可以更加准确的让 ai agent 使用 mcp 服务。

          3.2 网关工程(ai-mcp-gateway)
          为了更好的让大家理解从功能调研、验证到具体的服务实现,我们这一节先只是做对 Swagger OpenAPI 标准 json 文件的解析验证。后续在把解析的测试代码做成具体的服务功能。

          3.2.1 协议领域
          ai-mcp-gateway-3-14-04.png

          @Data
          public class HTTPProtocolVO {

          private String httpUrl;
          private String httpHeaders;
          private String httpMethod;
          private Integer timeout;
          
          private List<ProtocolMapping> mappings;
          
          @Data
          @Builder
          @AllArgsConstructor
          @NoArgsConstructor
          public static class ProtocolMapping {
              /**
               * 映射类型:request-请求参数映射,response-响应数据映射
               */
              private String mappingType;
              /**
               * 父级路径(如:xxxRequest01,用于构建嵌套结构,根节点为NULL)
               */
              private String parentPath;
              /**
               * 字段名称(如:city、company、name)
               */
              private String fieldName;
              /**
               * MCP完整路径(如:xxxRequest01.city、xxxRequest01.company.name)
               */
              private String mcpPath;
              /**
               * MCP数据类型:string/number/boolean/object/array
               */
              private String mcpType;
              /**
               * MCP字段描述
               */
              private String mcpDesc;
              /**
               * 是否必填:0-否,1-是(用于生成required数组)
               */
              private Integer isRequired;
              /**
               * 排序顺序(同级字段排序)
               */
              private Integer sortOrder;
          }
          

          }

          按照数据库表结构述求,定义了对应的协议值对象。

          可能有的伙伴会感觉,这部分定义的对象和其他领域对象类似。是的,但在领域设计中,我们一般为会不同的领域,都定义单独的属于当前领域所需的对象。因为在实际的场景开发中,刚开始看似一样的对象,但以后因为业务逻辑的不断迭代,差异性也会越来越大。此外,像是 mvc 中常定义一个 vo 对象,一个对象里有非常多的属性字段,这些属性信息隶属于不同的场景业务。所以随着时间的推移,需求的不断迭代,mvc 中的 vo 对象变得越来越难以维护了。

          3.2.2 协议解析
          @Slf4j
          @RunWith(SpringRunner.class)
          @SpringBootTest
          public class Swagger2McpProtocolHttpTest {

          @Value("classpath:swagger/api-docs-test03.json")
          private Resource apiDocs;
          
          @Test
          public void parseSwaggerAndBuildHTTPProtocolVO() throws Exception {
              String json = new String(FileCopyUtils.copyToByteArray(apiDocs.getInputStream()), StandardCharsets.UTF_8);
          

          // List endpoints = Arrays.asList(“/api/v1/mcp/get_company_employee”);
          // List endpoints = Arrays.asList(“/api/v1/mcp/query-test03”);
          // List endpoints = Arrays.asList(“/api/v1/mcp/query-test02”);
          List endpoints = Arrays.asList(“/api/v1/mcp/query-by-id-01”);
          // List endpoints = Arrays.asList(“/api/v1/mcp/query-by-id-02”);
          // List endpoints = Arrays.asList(“/api/v1/mcp/query-by-id-03”);
          List result = parse(json, endpoints);
          log.info(“测试结果:{}”, JSON.toJSONString(result));
          }

          private List<HTTPProtocolVO> parse(String json, List<String> endpoints) {
              JSONObject root = JSON.parseObject(json);
          
              String baseUrl = root.getJSONArray("servers").getJSONObject(0).getString("url");
          
              JSONObject paths = root.getJSONObject("paths");
              JSONObject schemas = root.getJSONObject("components").getJSONObject("schemas");
          
              List<HTTPProtocolVO> list = new ArrayList<>();
          
              for (String endpoint : endpoints) {
                  JSONObject pathItem = paths.getJSONObject(endpoint);
                  if (pathItem == null) continue;
          
                  String method = detectMethod(pathItem);
                  JSONObject operation = pathItem.getJSONObject(method);
          
                  HTTPProtocolVO vo = new HTTPProtocolVO();
                  vo.setHttpUrl(baseUrl + endpoint);
                  vo.setHttpMethod(method);
                  vo.setHttpHeaders(JSON.toJSONString(new HashMap<>() {{
                          put("Content-Type", "application/json");
                      }}));
                  vo.setTimeout(30000);
          
                  List<HTTPProtocolVO.ProtocolMapping> mappings = new ArrayList<>();
          
                  // 1. Parse requestBody - 对象入参
                  JSONObject requestBody = operation.getJSONObject("requestBody");
                  if (requestBody != null) {
                      JSONObject content = requestBody.getJSONObject("content");
                      JSONObject appJson = content.getJSONObject("application/json");
                      if (appJson != null) {
                          JSONObject schema = appJson.getJSONObject("schema");
                          String ref = schema.getString("$ref");
          
                          if (ref != null) {
                              String refName = ref.substring(ref.lastIndexOf('/') + 1);
                              JSONObject reqSchema = schemas.getJSONObject(refName);
                              String rootName = toLowerCamel(refName);
          
                              HTTPProtocolVO.ProtocolMapping rootMapping = HTTPProtocolVO.ProtocolMapping.builder()
                                      .mappingType("request")
                                      .parentPath(null)
                                      .fieldName(rootName)
                                      .mcpPath(rootName)
                                      .mcpType(convertType(reqSchema.getString("type")))
                                      .mcpDesc(reqSchema.getString("description"))
                                      .isRequired(1)
                                      .sortOrder(1)
                                      .build();
                              mappings.add(rootMapping);
          
                              parseProperties(rootName, reqSchema.getJSONObject("properties"), reqSchema.getJSONArray("required"), schemas, mappings);
                          }
                      }
                  }
          
                  // 2. Parse parameters - 属性入参
                  JSONArray parameters = operation.getJSONArray("parameters");
                  if (parameters != null) {
                      for (int i = 0; i < parameters.size(); i++) {
                          JSONObject param = parameters.getJSONObject(i);
                          String in = param.getString("in");
                          if (!"query".equals(in) && !"path".equals(in)) continue;
          
                          String name = param.getString("name");
                          boolean required = param.getBooleanValue("required");
                          String description = param.getString("description");
          
                          JSONObject schema = param.getJSONObject("schema");
                          String type = schema.getString("type");
                          String ref = schema.getString("$ref");
          
                          if (ref != null) {
                              String refName = ref.substring(ref.lastIndexOf('/') + 1);
                              JSONObject reqSchema = schemas.getJSONObject(refName);
                              
                              if (type == null) type = reqSchema.getString("type");
                              if (description == null) description = reqSchema.getString("description");
          
                              HTTPProtocolVO.ProtocolMapping rootMapping = HTTPProtocolVO.ProtocolMapping.builder()
                                      .mappingType("request")
                                      .parentPath(null)
                                      .fieldName(name)
                                      .mcpPath(name)
                                      .mcpType(convertType(type))
                                      .mcpDesc(description)
                                      .isRequired(required ? 1 : 0)
                                      .sortOrder(mappings.size() + 1)
                                      .build();
                              mappings.add(rootMapping);
          
                              parseProperties(name, reqSchema.getJSONObject("properties"), reqSchema.getJSONArray("required"), schemas, mappings);
                          } else {
                              HTTPProtocolVO.ProtocolMapping mapping = HTTPProtocolVO.ProtocolMapping.builder()
                                      .mappingType("request")
                                      .parentPath(null)
                                      .fieldName(name)
                                      .mcpPath(name)
                                      .mcpType(convertType(type))
                                      .mcpDesc(description)
                                      .isRequired(required ? 1 : 0)
                                      .sortOrder(mappings.size() + 1)
                                      .build();
                              mappings.add(mapping);
                          }
                      }
                  }
          
                  vo.setMappings(mappings);
                  list.add(vo);
              }
              return list;
          }
          
          private void parseProperties(String parentMcpPath, JSONObject properties, JSONArray requiredList, JSONObject definitions, List<HTTPProtocolVO.ProtocolMapping> mappings) {
              if (properties == null) return;
          
              int sortOrder = 1;
              for (String propName : properties.keySet()) {
                  JSONObject prop = properties.getJSONObject(propName);
          
                  // 拼接关系链 a.b.c
                  String currentMcpPath = parentMcpPath + "." + propName;
          
                  JSONObject effectiveSchema = prop;
                  String type = prop.getString("type");
                  String description = prop.getString("description");
          
                  // 获取应用关系对象
                  if (prop.containsKey("$ref")) {
                      String ref = prop.getString("$ref");
                      String refName = ref.substring(ref.lastIndexOf('/') + 1);
                      effectiveSchema = definitions.getJSONObject(refName);
                      if (type == null) type = effectiveSchema.getString("type");
                      if (description == null) description = effectiveSchema.getString("description");
                  }
          
                  HTTPProtocolVO.ProtocolMapping mapping = HTTPProtocolVO.ProtocolMapping.builder()
                          .mappingType("request")
                          .parentPath(parentMcpPath)
                          .fieldName(propName)
                          .mcpPath(currentMcpPath)
                          .mcpType(convertType(type))
                          .mcpDesc(description)
                          .isRequired(requiredList != null && requiredList.contains(propName) ? 1 : 0)
                          .sortOrder(sortOrder++)
                          .build();
                  mappings.add(mapping);
          
                  // 如果存在下一个引用对象,则嵌套循环继续寻找
                  if (effectiveSchema.containsKey("properties")) {
                      parseProperties(currentMcpPath, effectiveSchema.getJSONObject("properties"), effectiveSchema.getJSONArray("required"), definitions, mappings);
                  }
              }
          }
          
          // MCP数据类型:string/number/boolean/object/array
          private String convertType(String type) {
              if (type == null) return "string";
              return switch (type.toLowerCase()) {
                  case "string", "char", "date", "datetime" -> "string";
                  case "integer", "int", "long", "double", "float", "number" -> "number";
                  case "boolean", "bool" -> "boolean";
                  case "array", "list" -> "array";
                  default -> "object";
              };
          }
          
          // 检测方法
          private String detectMethod(JSONObject pathItem) {
              if (pathItem.containsKey("post")) return "post";
              if (pathItem.containsKey("get")) return "get";
              if (pathItem.containsKey("put")) return "put";
              if (pathItem.containsKey("delete")) return "delete";
              return "post";
          }
          
          private String toLowerCamel(String name) {
              if (name == null || name.isEmpty()) return name;
              char[] cs = name.toCharArray();
              cs[0] = java.lang.Character.toLowerCase(cs[0]);
              return new String(cs);
          }
          

          }

          步骤 1:初始化与基础信息提取,使用 JSON.parseObject 解析 Swagger 文档根节点,之后提取 Base URL :从 servers 节点获取服务的基础路径(例如 http://localhost:8091/api/v1/mcp/ )。获取 paths (所有 API 路径定义)和 components.schemas (所有对象模型定义)。

          步骤 2:遍历接口 (Endpoints),代码根据预定义的 endpoints 列表(如 /api/v1/mcp/query-by-id-01 )逐个处理:

          定位 PathItem :在 paths 对象中找到对应路径的定义。

          检测 HTTP 方法 :通过 detectMethod 方法扫描 post , get , put , delete 关键字,确定接口请求方式。

          构建 HTTPProtocolVO,拼接完整 URL ( baseUrl + endpoint )。预设一些值,设置默认 Header ( Content-Type: application/json )和超时时间。

          步骤 3:参数解析 (Mapping),

          RequestBody 解析(对象入参):

          定位 requestBody.content.application/json.schema 。

          处理引用 (如 #/components/schemas/UserReq ),解析出对象名称。

          根对象映射 :创建根节点的映射记录,标记为 request 类型。

          递归解析属性 :调用 parseProperties 方法,深入解析对象内部的所有字段。

          Parameters 解析(属性入参)

          遍历 parameters 数组。

          过滤类型 :仅处理 in 为 query 或 path 的参数。

          处理引用与基本类型 :

          如果参数定义包含 $ref ,则去 schemas 中查找对应的类型和描述。

          否则直接使用参数本身的 type 和 description 。

          生成映射 :为每个参数生成一个 ProtocolMapping 对象。

          步骤4:parseProperties (递归属性解析)

          遍历每个属性字段。

          构建路径 :生成 currentMcpPath (例如 user.address.city )。

          解析引用 :如果字段包含 $ref ,跳转到 definitions (schemas) 中获取实际定义。

          生成 Mapping :

          mcpPath : 完整的点分路径。

          isRequired : 检查字段名是否存在于 required 数组中。

          递归 :如果当前字段也是一个对象(包含 properties ),则递归调用自身,传入当前路径作为父路径。

          整个解析过程,你可以适当理解,也可以只是把它当做一个工具包使用。

          四、测试验证

          1. 注意事项

          先启动 ai-mcp-gateway-demo-mcp-server-test - 确保启动正常

          访问 http://localhost:8701/swagger-ui/index.html - 如果访问不了,可以更换为具体IP,以及检查端口是否为8701

          访问 http://localhost:8701/v3/api-docs - 也可以2里面的页面蓝色字**/v3/api-docs**,点击进来。进来后保存 json 文件。

          这部分在视频课里有演示,可以查看。

          1. 测试验证
            运行 Swagger2McpProtocolHttpTest#parseSwaggerAndBuildHTTPProtocolVO

          @Test
          public void parseSwaggerAndBuildHTTPProtocolVO() throws Exception {
          String json = new String(FileCopyUtils.copyToByteArray(apiDocs.getInputStream()), StandardCharsets.UTF_8);
          List endpoints = Arrays.asList(“/api/v1/mcp/get_company_employee”);
          List endpoints = Arrays.asList(“/api/v1/mcp/query-test03”);
          List endpoints = Arrays.asList(“/api/v1/mcp/query-test02”);
          List endpoints = Arrays.asList(“/api/v1/mcp/query-by-id-01”);
          List endpoints = Arrays.asList(“/api/v1/mcp/query-by-id-02”);
          List endpoints = Arrays.asList(“/api/v1/mcp/query-by-id-03”);
          List result = parse(json, endpoints);
          log.info(“测试结果:{}”, JSON.toJSONString(result));
          }

          26-02-28.07:03:47.937 [main ] INFO Swagger2McpProtocolHttpTest - 测试结果:[{“httpHeaders”:”{"Content-Type":"application/json"}”,”httpMethod”:”get”,”httpUrl”:”http://localhost:8701/api/v1/mcp/query-by-id-01","mappings":[{"fieldName":"id","isRequired":1,"mappingType":"request","mcpPath":"id","mcpType":"string","sortOrder":1}],"timeout":30000}]

          传入不同的接口,可以分别解析出对应的数据结构。

          《AI MCP Gateway》第3-15节:协议域-协议解析处理

          一、本章诉求
          设计协议域 ,定义分层结构,用于承接协议的解析、存储、使用等场景功能。在整个 DDD 架构下,我们会不断的思考这些内容的功能域设计,而不是一些单一的原则方法。

          这一节我们把前面做的解析协议的案例代码,按照协议域的分层结构,拆分编写对应的功能逻辑。

          二、流程设计
          如图,设计协议域,封装解析逻辑(其他流程后续处理);
          img_35.png

          首先,划分出【协议域】,增加协议解析、协议存储,对应的单一职责的接口定义。

          之后,把上一节的协议解析的案例代码,按照如图所示的结构,拆分设计。这里解析还增加了 rpc 包,是一个示意,如果后续大家做其他场景的接口对应的协议解析,可以都在这个结构下增加策略实现。

          三、编码实现

          1. 工程结构

          protocol 协议下编写服务接口,处理协议解析和协议存储,本节先来处理协议解析部分。

          领域接口的服务方法,定义对应的命令实体驱动,之后具体的解析分为对象入参和属性入参进行解析。

          1. 功能开发
            2.1 协议接口
            public interface IProtocolAnalysis {

            List doAnalysis(AnalysisCommandEntity commandEntity);

          }

          public interface IProtocolStorage {

          }

          定义协议解析和存储的接口,目前先处理第一个 IProtocolAnalysis 协议解析。

          doAnalysis 解析方法的入参 AnalysisCommandEntity 命令实体对象,解析后返回 List 数据。

          2.2 解析策略
          2.2.1 策略接口
          public interface IProtocolAnalysisStrategy {

          void doAnalysis(JSONObject operation, JSONObject definitions, List<HTTPProtocolVO.ProtocolMapping> mappings);
          

          }

          因为这部分的解析,会区分不同的入参类型(属性和对象),所以做了一个策略拆分,定义了相关的接口。

          2.2.2 公共方法
          public abstract class AbstractProtocolAnalysisStrategy implements IProtocolAnalysisStrategy {

          protected void parseProperties(String parentMcpPath, JSONObject properties, JSONArray requiredList, JSONObject definitions, List<HTTPProtocolVO.ProtocolMapping> mappings) {
              if (properties == null) return;
          
              int sortOrder = 1;
              for (String propName : properties.keySet()) {
                  JSONObject prop = properties.getJSONObject(propName);
          
                  // 拼接关系链 a.b.c
                  String currentMcpPath = parentMcpPath + "." + propName;
          
                  JSONObject effectiveSchema = prop;
                  String type = prop.getString("type");
                  String description = prop.getString("description");
          
                  // 获取应用关系对象
                  if (prop.containsKey("$ref")) {
                      String ref = prop.getString("$ref");
                      String refName = ref.substring(ref.lastIndexOf('/') + 1);
                      effectiveSchema = definitions.getJSONObject(refName);
                      if (type == null) type = effectiveSchema.getString("type");
                      if (description == null) description = effectiveSchema.getString("description");
                  }
          
                  HTTPProtocolVO.ProtocolMapping mapping = HTTPProtocolVO.ProtocolMapping.builder()
                          .mappingType("request")
                          .parentPath(parentMcpPath)
                          .fieldName(propName)
                          .mcpPath(currentMcpPath)
                          .mcpType(convertType(type))
                          .mcpDesc(description)
                          .isRequired(requiredList != null && requiredList.contains(propName) ? 1 : 0)
                          .sortOrder(sortOrder++)
                          .build();
                  mappings.add(mapping);
          
                  // 如果存在下一个引用对象,则嵌套循环继续寻找
                  if (effectiveSchema.containsKey("properties")) {
                      parseProperties(currentMcpPath, effectiveSchema.getJSONObject("properties"), effectiveSchema.getJSONArray("required"), definitions, mappings);
                  }
              }
          }
          
          protected String convertType(String type) {
              if (type == null) return "string";
              return switch (type.toLowerCase()) {
                  case "string", "char", "date", "datetime" -> "string";
                  case "integer", "int", "long", "double", "float", "number" -> "number";
                  case "boolean", "bool" -> "boolean";
                  case "array", "list" -> "array";
                  default -> "object";
              };
          }
          
          protected String toLowerCamel(String name) {
              if (name == null || name.isEmpty()) return name;
              char[] cs = name.toCharArray();
              cs[0] = java.lang.Character.toLowerCase(cs[0]);
              return new String(cs);
          }
          

          }

          parseProperties 是一个共用方法,所以这部分设计了公共的抽象类,把 parseProperties 放到 AbstractProtocolAnalysisStrategy 类。

          2.2.3 解析类型
          @Slf4j
          @Component(“parametersAnalysis”)
          @Order(2)
          public class ParametersAnalysisStrategy extends AbstractProtocolAnalysisStrategy {

          @Override
          public void doAnalysis(JSONObject operation, JSONObject definitions, List<HTTPProtocolVO.ProtocolMapping> mappings) {
              JSONArray parameters = operation.getJSONArray("parameters");
              if (parameters == null) return;
          
              for (int i = 0; i < parameters.size(); i++) {
                  JSONObject param = parameters.getJSONObject(i);
                  String in = param.getString("in");
                  if (!"query".equals(in) && !"path".equals(in)) continue;
          
                  String name = param.getString("name");
                  boolean required = param.getBooleanValue("required");
                  String description = param.getString("description");
          
                  JSONObject schema = param.getJSONObject("schema");
                  String type = schema.getString("type");
                  String ref = schema.getString("$ref");
          
                  if (ref != null) {
                      String refName = ref.substring(ref.lastIndexOf('/') + 1);
                      JSONObject reqSchema = definitions.getJSONObject(refName);
          
                      if (type == null) type = reqSchema.getString("type");
                      if (description == null) description = reqSchema.getString("description");
          
                      HTTPProtocolVO.ProtocolMapping rootMapping = HTTPProtocolVO.ProtocolMapping.builder()
                              .mappingType("request")
                              .parentPath(null)
                              .fieldName(name)
                              .mcpPath(name)
                              .mcpType(convertType(type))
                              .mcpDesc(description)
                              .isRequired(required ? 1 : 0)
                              .sortOrder(mappings.size() + 1)
                              .build();
          
                      mappings.add(rootMapping);
          
                      parseProperties(name, reqSchema.getJSONObject("properties"), reqSchema.getJSONArray("required"), definitions, mappings);
                  } else {
                      HTTPProtocolVO.ProtocolMapping mapping = HTTPProtocolVO.ProtocolMapping.builder()
                              .mappingType("request")
                              .parentPath(null)
                              .fieldName(name)
                              .mcpPath(name)
                              .mcpType(convertType(type))
                              .mcpDesc(description)
                              .isRequired(required ? 1 : 0)
                              .sortOrder(mappings.size() + 1)
                              .build();
                      mappings.add(mapping);
                  }
              }
          }
          

          }

          解析属性入参,和测试案例的操作方式是一样的。

          @Slf4j
          @Component(“requestBodyAnalysis”)
          @Order(1)
          public class RequestBodyAnalysisStrategy extends AbstractProtocolAnalysisStrategy {

          @Override
          public void doAnalysis(JSONObject operation, JSONObject definitions, List<HTTPProtocolVO.ProtocolMapping> mappings) {
              JSONObject requestBody = operation.getJSONObject("requestBody");
              if (requestBody == null) return;
          
              JSONObject content = requestBody.getJSONObject("content");
              JSONObject appJson = content.getJSONObject("application/json");
              if (appJson == null) return;
          
              JSONObject schema = appJson.getJSONObject("schema");
              String ref = schema.getString("$ref");
          
              if (ref != null) {
                  String refName = ref.substring(ref.lastIndexOf('/') + 1);
                  JSONObject reqSchema = definitions.getJSONObject(refName);
                  String rootName = toLowerCamel(refName);
          
                  HTTPProtocolVO.ProtocolMapping rootMapping = HTTPProtocolVO.ProtocolMapping.builder()
                          .mappingType("request")
                          .parentPath(null)
                          .fieldName(rootName)
                          .mcpPath(rootName)
                          .mcpType(convertType(reqSchema.getString("type")))
                          .mcpDesc(reqSchema.getString("description"))
                          .isRequired(1)
                          .sortOrder(1)
                          .build();
          
                  mappings.add(rootMapping);
          
                  parseProperties(rootName, reqSchema.getJSONObject("properties"), reqSchema.getJSONArray("required"), definitions, mappings);
              }
          }
          

          }

          解析对象入参,和测试案例的操作方式是一样的。

          2.3 解析调用
          @Slf4j
          @Service
          public class ProtocolAnalysis implements IProtocolAnalysis {

          private final Map<String, IProtocolAnalysisStrategy> protocolAnalysisStrategyMap;
          
          public ProtocolAnalysis(Map<String, IProtocolAnalysisStrategy> protocolAnalysisStrategyMap) {
              this.protocolAnalysisStrategyMap = protocolAnalysisStrategyMap;
          }
          
          @Override
          public List<HTTPProtocolVO> doAnalysis(AnalysisCommandEntity commandEntity) {
              log.info("协议解析请求 endpoints:{} openApiJson:{}", JSON.toJSONString(commandEntity.getEndpoints()), commandEntity.getOpenApiJson());
          
              List<HTTPProtocolVO> list = new ArrayList<>();
              try {
                  JSONObject root = JSON.parseObject(commandEntity.getOpenApiJson());
                  String baseUrl = root.getJSONArray("servers").getJSONObject(0).getString("url");
                  JSONObject paths = root.getJSONObject("paths");
                  JSONObject schemas = root.getJSONObject("components").getJSONObject("schemas");
          
                  List<String> endpoints = commandEntity.getEndpoints();
                  if (null == endpoints || endpoints.isEmpty()) return list;
          
                  for (String endpoint : endpoints) {
                      JSONObject pathItem = paths.getJSONObject(endpoint);
                      if (pathItem == null) continue;
          
                      String method = detectMethod(pathItem);
                      JSONObject operation = pathItem.getJSONObject(method);
          
                      HTTPProtocolVO vo = new HTTPProtocolVO();
                      vo.setHttpUrl(baseUrl + endpoint);
                      vo.setHttpMethod(method);
                      vo.setHttpHeaders(JSON.toJSONString(new HashMap<>() {{
                              put("Content-Type", "application/json");
                          }}));
                      vo.setTimeout(30000);
          
                      List<HTTPProtocolVO.ProtocolMapping> mappings = new ArrayList<>();
          
                      // 枚举策略动作处理
                      AnalysisTypeEnum.SwaggerAnalysisAction analysisAction = AnalysisTypeEnum.SwaggerAnalysisAction.get(operation);
                      IProtocolAnalysisStrategy strategy = protocolAnalysisStrategyMap.get(analysisAction.getCode());
                      strategy.doAnalysis(operation, schemas, mappings);
          
                      vo.setMappings(mappings);
                      list.add(vo);
                  }
          
              } catch (Exception e) {
                  log.error("协议解析失败 endpoints:{} openApiJson:{}", JSON.toJSONString(commandEntity.getEndpoints()), commandEntity.getOpenApiJson(), e);
              }
          
              return list;
          }
          
          private String detectMethod(JSONObject pathItem) {
              if (pathItem.containsKey("post")) return "post";
              if (pathItem.containsKey("get")) return "get";
              if (pathItem.containsKey("put")) return "put";
              if (pathItem.containsKey("delete")) return "delete";
              return "post";
          }
          

          }

          解析的时候除了基本的逻辑处理,到具体的属性和对象,就要调用策略服务了。

          这里的策略服务调用,是定义了一个枚举和 protocolAnalysisStrategyMap,通过 AnalysisTypeEnum.SwaggerAnalysisAction.get(operation) 来获取具体的调用方法。这部分的操作是把逻辑充血到枚举类中,等同于谁负责定义类型,谁负责提供判断方法。放在以前面向过程的编码,会直接在 Service 层编写,如果其他人的逻辑也需要,那么另外一个 Service 就还需要在编写一次判断方法。所以,要把这类内容聚合到具体的类里,让它可以共用。

          @Getter
          public enum AnalysisTypeEnum {

          swagger,
          
          ;
          
          @Getter
          @AllArgsConstructor
          @NoArgsConstructor
          public enum SwaggerAnalysisAction {
          
              requestBodyAnalysis("requestBodyAnalysis", "解析对象"),
              parametersAnalysis("parametersAnalysis", "解析属性"),
          
              ;
          
              private String code;
              private String info;
          
              public static SwaggerAnalysisAction get(JSONObject operation) {
          
                  JSONObject requestBody = operation.getJSONObject("requestBody");
                  JSONArray parameters = operation.getJSONArray("parameters");
          
                  if (null != requestBody) {
                      return requestBodyAnalysis;
                  }
          
                  if (null != parameters) {
                      return parametersAnalysis;
                  }
          
                  throw new AppException(ResponseCode.ENUM_NOT_FOUND.getCode(), ResponseCode.ENUM_NOT_FOUND.getInfo());
              }
          
          }
          

          }

          上面提到的,也就是这部分的判断操作。

          四、测试验证
          @Slf4j
          @RunWith(SpringRunner.class)
          @SpringBootTest
          public class ProtocolAnalysisTest {

          @Value("classpath:swagger/api-docs-test03.json")
          private Resource apiDocs;
          
          @Autowired
          private IProtocolAnalysis protocolAnalysis;
          
          @Test
          public void parseSwaggerAndBuildHTTPProtocolVO() throws Exception {
              String json = new String(FileCopyUtils.copyToByteArray(apiDocs.getInputStream()), StandardCharsets.UTF_8);
              List<String> endpoints = Arrays.asList("/api/v1/mcp/get_company_employee");
          

          // List endpoints = Arrays.asList(“/api/v1/mcp/query-test03”);
          // List endpoints = Arrays.asList(“/api/v1/mcp/query-test02”);
          // List endpoints = Arrays.asList(“/api/v1/mcp/query-by-id-01”);
          // List endpoints = Arrays.asList(“/api/v1/mcp/query-by-id-02”);
          // List endpoints = Arrays.asList(“/api/v1/mcp/query-by-id-03”);

              AnalysisCommandEntity commandEntity = AnalysisCommandEntity.builder()
                      .openApiJson(json)
                      .endpoints(endpoints)
                      .build();
          
              List<HTTPProtocolVO> result = protocolAnalysis.doAnalysis(commandEntity);
              log.info("测试结果:{}", JSON.toJSONString(result));
          }
          

          }

          26-03-05.06:37:12.852 [main ] INFO ProtocolAnalysisTest - 测试结果:[{“httpHeaders”:”{"Content-Type":"application/json"}”,”httpMethod”:”post”,”httpUrl”:”http://localhost:8701/api/v1/mcp/get_company_employee","mappings":[{"fieldName":"xxxRequest01","isRequired":1,"mappingType":"request","mcpDesc":"公司员工信息查询请求","mcpPath":"xxxRequest01","mcpType":"object","sortOrder":1},{"fieldName":"city","isRequired":1,"mappingType":"request","mcpDesc":"城市名称(拼音),例如: beijing”,”mcpPath”:”xxxRequest01.city”,”mcpType”:”string”,”parentPath”:”xxxRequest01”,”sortOrder”:1},{“fieldName”:”company”,”isRequired”:1,”mappingType”:”request”,”mcpDesc”:”公司信息”,”mcpPath”:”xxxRequest01.company”,”mcpType”:”object”,”parentPath”:”xxxRequest01”,”sortOrder”:2},{“fieldName”:”deep”,”isRequired”:1,”mappingType”:”request”,”mcpDesc”:”测试”,”mcpPath”:”xxxRequest01.company.deep”,”mcpType”:”object”,”parentPath”:”xxxRequest01.company”,”sortOrder”:1},{“fieldName”:”x01”,”isRequired”:1,”mappingType”:”request”,”mcpDesc”:”测试”,”mcpPath”:”xxxRequest01.company.deep.x01”,”mcpType”:”string”,”parentPath”:”xxxRequest01.company.deep”,”sortOrder”:1},{“fieldName”:”name”,”isRequired”:1,”mappingType”:”request”,”mcpDesc”:”公司名称”,”mcpPath”:”xxxRequest01.company.name”,”mcpType”:”string”,”parentPath”:”xxxRequest01.company”,”sortOrder”:2},{“fieldName”:”type”,”isRequired”:1,”mappingType”:”request”,”mcpDesc”:”公司类型”,”mcpPath”:”xxxRequest01.company.type”,”mcpType”:”string”,”parentPath”:”xxxRequest01.company”,”sortOrder”:3}],”timeout”:30000}]

          如上,拆分后解析结果正常。你还可以多验证一些场景,并打端点调试。

          《AI MCP Gateway》第3-16节:协议域-协议存储处理

          一、本章诉求
          在协议域 添加协议存储服务,以及验证存储后的数据,在 AI MCP 网关,整个流程中完成调用处理。其实这部分的内容,就是在串联流程。协议解析、协议存储,存储后的数据到数据库表中的结构,是否还可以按照约定的方式转换为可以被 MCP 协议识别的结构。之后再继续完成调用的处理。

          二、流程设计
          如图,从协议解析到协议存储流程设计;

          img_36.png

          首先,本节的重点是要验证,协议解析 -> 协议存储 -> 协议使用,是否可以完整整个链路调用处理。

          所以,在这一节完成协议存储后,我们就要做一个完整的案例,来验证整个数据解析存储和使用是否可以通过。

          三、编码实现

          1. 工程结构

          首先,在 domain 领域层,protocol 协议下,实现协议存储(IProtocolStorage)功能。这部分是 CRUD 操作,没有复杂代码。

          之后,在 app 模块下,protocol 里,增加一个测试部分,既解析协议,也要做存储,把这部分联系起来。

          最后,整个功能完成后,我们在一个流程的串联验证,启动测试端,提供服务接口,之后在启动网关,最后通过 ai 客户端完成验证。

          1. 功能开发
            2.1 接口定义
            public interface IProtocolStorage {

            List doStorage(StorageCommandEntity commandEntity);

          }

          添加 StorageCommandEntity 命令对象,里面含有协议的集合数据。目前接口返回的是协议ID,这个 ID 是在协议存储数据节点生成的唯一值(一般公司里会有这种组件)

          2.2 接口实现
          @Service
          public class ProtocolStorage implements IProtocolStorage {

          @Resource
          private IProtocolRepository repository;
          
          @Override
          public List<Long> doStorage(StorageCommandEntity commandEntity) {
              return repository.saveHttpProtocolAndMapping(commandEntity.getHttpProtocolVOS());
          }
          

          }

          这部分存储的逻辑,就是 CRUD 操作了,直接调用新增加的仓储接口,到基础设施层实现即可。

          2.3 仓储服务
          @Repository
          public class ProtocolRepository implements IProtocolRepository {

          @Resource
          private IMcpProtocolHttpDao protocolHttpDao;
          
          @Resource
          private IMcpProtocolMappingDao protocolMappingDao;
          
          @Transactional(rollbackFor = Exception.class)
          @Override
          public List<Long> saveHttpProtocolAndMapping(List<HTTPProtocolVO> httpProtocolVOS) {
              List<Long> protocolIdList = new ArrayList<>();
          
              for (HTTPProtocolVO httpProtocolVO : httpProtocolVOS) {
          
                  // 0. 生成协议ID,八位数字的。
                  long protocolId = Long.parseLong(RandomStringUtils.randomNumeric(8));
          
                  // 1. 保存 HTTP 协议配置
                  McpProtocolHttpPO mcpProtocolHttpPO = McpProtocolHttpPO.builder()
                          .protocolId(protocolId)
                          .httpUrl(httpProtocolVO.getHttpUrl())
                          .httpMethod(httpProtocolVO.getHttpMethod())
                          .httpHeaders(httpProtocolVO.getHttpHeaders())
                          .timeout(httpProtocolVO.getTimeout())
                          .retryTimes(3)
                          .status(ProtocolStatusEnum.ENABLE.getCode())
                          .build();
                  protocolHttpDao.insert(mcpProtocolHttpPO);
          
                  // 2. 保存协议映射配置
                  List<HTTPProtocolVO.ProtocolMapping> mappings = httpProtocolVO.getMappings();
                  if (null == mappings || mappings.isEmpty()) continue;
          
                  for (HTTPProtocolVO.ProtocolMapping mapping : mappings) {
                      McpProtocolMappingPO mcpProtocolMappingPO = McpProtocolMappingPO.builder()
                              .protocolId(protocolId)
                              .mappingType(mapping.getMappingType())
                              .parentPath(mapping.getParentPath())
                              .fieldName(mapping.getFieldName())
                              .mcpPath(mapping.getMcpPath())
                              .mcpType(mapping.getMcpType())
                              .mcpDesc(mapping.getMcpDesc())
                              .isRequired(mapping.getIsRequired())
                              .sortOrder(mapping.getSortOrder())
                              .build();
                      protocolMappingDao.insert(mcpProtocolMappingPO);
                  }
          
                  protocolIdList.add(protocolId);
              }
          
              return protocolIdList;
          }
          

          }

          将 List httpProtocolVOS 值对象,转换为数据库需要的 PO 对象,之后进行存储操作。注意这里有一个 @Transactional(rollbackFor = Exception.class) 事务注解。

          Long.parseLong(RandomStringUtils.randomNumeric(8)); 生成8位唯一协议值,一般公司里会有这样的通用组件。确保唯一,但如果不唯一也会有数据库表仿重字段。

          四、测试验证

          1. 注意事项

          先启动 ai-mcp-gateway-demo-mcp-server-test - 确保启动正常

          注意,下面的单测验证后,你会在数据库表里看到写入进去的协议。要把协议ID,更新到 mcp_gateway_tool 中。(这部分有视频演示)

          之后在测试 ai 客户端,调用 mcp 协议。

          1. 测试验证 - 数据存储 - ai mcp gateway
            @RunWith(SpringRunner.class)
            @SpringBootTest
            public class ProtocolStorageTest {

            @Value(“classpath:swagger/api-docs-test03.json”)
            private org.springframework.core.io.Resource apiDocs;

            @Autowired
            private IProtocolAnalysis protocolAnalysis;

            @Resource
            private IProtocolStorage protocolStorage;

            @Test
            public void test_storage() throws IOException {
            // 1. 协议解析
            String json = new String(FileCopyUtils.copyToByteArray(apiDocs.getInputStream()), StandardCharsets.UTF_8);
            // List endpoints = Arrays.asList(“/api/v1/mcp/get_company_employee”);
            List endpoints = Arrays.asList(“/api/v1/mcp/query-test03”);
            // List endpoints = Arrays.asList(“/api/v1/mcp/query-test02”);
            // List endpoints = Arrays.asList(“/api/v1/mcp/query-by-id-01”);
            // List endpoints = Arrays.asList(“/api/v1/mcp/query-by-id-02”);
            // List endpoints = Arrays.asList(“/api/v1/mcp/query-by-id-03”);

             AnalysisCommandEntity commandEntity = AnalysisCommandEntity.builder()
                     .openApiJson(json)
                     .endpoints(endpoints)
                     .build();
            
             List<HTTPProtocolVO> httpProtocolVOS = protocolAnalysis.doAnalysis(commandEntity);
             log.info("解析协议:{}", JSON.toJSONString(httpProtocolVOS));
            
             // 2. 协议存储
             List<Long> protocolIdList = protocolStorage.doStorage(
                     StorageCommandEntity.builder()
                             .httpProtocolVOS(httpProtocolVOS).build());
            
             log.info("存储协议:{}", JSON.toJSONString(protocolIdList));
            

            }

          }

          测试阶段,要做是数据的存储操作。

          1. 测试阶段 - 网关验证 - ai-mcp-gateway-demo-mcp-server-test
            public static void main(String[] args) throws Exception {
            OpenAiApi openAiApi = OpenAiApi.builder()
            .baseUrl(“https://apis.itedus.cn“)
            .apiKey(“sk-27hVt8exkK7Ati8s978403F629B6440e***可以联系我获取”)
            .completionsPath(“v1/chat/completions”)
            .embeddingsPath(“v1/embeddings”)
            .build();
            ChatModel chatModel = OpenAiChatModel.builder()
            .openAiApi(openAiApi)
            .defaultOptions(OpenAiChatOptions.builder()
            .model(“gpt-4o”)
            .toolCallbacks(new SyncMcpToolCallbackProvider(sseMcpClient02()).getToolCallbacks())
            .build())
            .build();
            log.info(“测试结果:{}”, chatModel.call(“””
            获取公司雇员信息,信息如下;
            城市;北京
            公司;谷歌
            雇员;小傅哥
            “””));
            }

          08:26:12.631 [main] INFO cn.bugstack.ai.mcp.server.test.ApiTest – 测试结果:通过查询得知,在北京谷歌公司,小傅哥的相关雇员信息如下:

          • 工资:16.92
          • 每日工时:20小时
          • 用户ID:111
          • 用户名:小傅哥

          验证通过,可以走完全流程。

          《AI MCP Gateway》第3-17节:网关配置域-配置数据存储(CRUD)

          一、本章诉求
          本节我们要新增加一个网关配置域,对网关、工具提供基本的配置操作,也就是 CRUD 的代码从领域层操作数据库的过程。这部分的内容实现,为的就是在后续提供的 AI MCP 网关运营服务上,可以在后台配置出整个网关服务。

          二、流程设计
          如图,网关配置域功能流程设计;

          img_37.png

          首先,我们知道的,一整套的 AI MCP 网关配置,包括了,协议的解析存储和使用,之后是鉴权,但最开始是有一个网关的配置的,也就是写入基本的网关配置信息。

          那么,为了能完整的串联全部流程,我们这里需要增加下网关配置域,处理网关配置、网关工具配置的内容,一个网关可以挂多个工具,一个工具映射了一个协议(http、rpc等)

          注意,当前我们先实现了最基本的保存和更新,后续随着运营后台的开发,在陆续补充需要的接口。

          三、编码实现

          1. 工程结构
            这部分 gateway 网关配置域,主要是维护网关和工具的配置操作。整部分内容的编写,都是为了以后在 case 层提供整个配置服务能力,串联各个流链路。

          整个 gateway 的调用顺序为,service 层定义接口,需要的对象放到 model 下,要操作数据,就定义 repository 对应的仓库服务接口。之后有 infrastructure 基础设施层来做具体实现。这部分就是依赖倒置的体现。

          1. 领域接口
            public interface IGatewayConfigService {

            void saveGatewayConfig(GatewayConfigCommandEntity commandEntity);

            void updateGatewayAuthStatus(GatewayConfigCommandEntity commandEntity);

          }

          public interface IGatewayToolConfigService {

          void saveGatewayToolConfig(GatewayToolConfigCommandEntity commandEntity);
          
          void updateGatewayToolProtocol(GatewayToolConfigCommandEntity commandEntity);
          

          }

          IGatewayConfigService 网关配置、IGatewayToolConfigService 网关工具配置。

          @Slf4j
          @Service
          public class GatewayConfigService implements IGatewayConfigService {

          @Resource
          private IGatewayRepository repository;
          
          @Override
          public void saveGatewayConfig(GatewayConfigCommandEntity commandEntity) {
              repository.saveGatewayConfig(commandEntity);
          }
          
          @Override
          public void updateGatewayAuthStatus(GatewayConfigCommandEntity commandEntity) {
              repository.updateGatewayAuthStatus(commandEntity);
          }
          

          }

          @Slf4j
          @Service
          public class GatewayToolConfigService implements IGatewayToolConfigService {

          @Resource
          private IGatewayRepository repository;
          
          @Override
          public void saveGatewayToolConfig(GatewayToolConfigCommandEntity commandEntity) {
              repository.saveGatewayToolConfig(commandEntity);
          }
          
          @Override
          public void updateGatewayToolProtocol(GatewayToolConfigCommandEntity commandEntity) {
              repository.updateGatewayToolProtocol(commandEntity);
          }
          

          }

          这部分接口实现,就是直接调用仓储接口,配和一些 crud 操作即可。

          1. 基础设施(CRUD)
            @Repository
            public class GatewayRepository implements IGatewayRepository {

            @Resource
            private IMcpGatewayDao mcpGatewayDao;

            @Resource
            private IMcpGatewayToolDao mcpGatewayToolDao;

            @Override
            public void saveGatewayConfig(GatewayConfigCommandEntity commandEntity) {
            GatewayConfigVO gatewayConfigVO = commandEntity.getGatewayConfigVO();

             McpGatewayPO mcpGatewayPO = new McpGatewayPO();
             mcpGatewayPO.setGatewayId(gatewayConfigVO.getGatewayId());
             mcpGatewayPO.setGatewayName(gatewayConfigVO.getGatewayName());
             mcpGatewayPO.setGatewayDesc(gatewayConfigVO.getGatewayDesc());
             mcpGatewayPO.setVersion(gatewayConfigVO.getVersion());
             mcpGatewayPO.setAuth(null != gatewayConfigVO.getAuth() ? gatewayConfigVO.getAuth().getCode() : GatewayEnum.GatewayAuthStatusEnum.ENABLE.getCode());
             mcpGatewayPO.setStatus(null != gatewayConfigVO.getStatus() ? gatewayConfigVO.getStatus().getCode() : GatewayEnum.GatewayStatus.NOT_VERIFIED.getCode());
             mcpGatewayDao.insert(mcpGatewayPO);
            

            }

            @Override
            public void updateGatewayAuthStatus(GatewayConfigCommandEntity commandEntity) {
            GatewayConfigVO gatewayConfigVO = commandEntity.getGatewayConfigVO();
            if (null == gatewayConfigVO.getAuth()) {
            return;
            }

             McpGatewayPO mcpGatewayPO = new McpGatewayPO();
             mcpGatewayPO.setGatewayId(gatewayConfigVO.getGatewayId());
             mcpGatewayPO.setAuth(null != gatewayConfigVO.getAuth() ? gatewayConfigVO.getAuth().getCode() : null);
             int count = mcpGatewayDao.updateAuthStatusByGatewayId(mcpGatewayPO);
             if (1 != count) {
                 throw new AppException(DB_UPDATE_FAIL.getCode(), DB_UPDATE_FAIL.getInfo());
             }
            

            }

            @Override
            public void saveGatewayToolConfig(GatewayToolConfigCommandEntity commandEntity) {
            GatewayToolConfigVO gatewayToolConfigVO = commandEntity.getGatewayToolConfigVO();

             McpGatewayToolPO mcpGatewayToolPO = new McpGatewayToolPO();
             mcpGatewayToolPO.setGatewayId(gatewayToolConfigVO.getGatewayId());
             mcpGatewayToolPO.setToolId(gatewayToolConfigVO.getToolId());
             mcpGatewayToolPO.setToolName(gatewayToolConfigVO.getToolName());
             mcpGatewayToolPO.setToolType(gatewayToolConfigVO.getToolType());
             mcpGatewayToolPO.setToolDescription(gatewayToolConfigVO.getToolDescription());
             mcpGatewayToolPO.setToolVersion(gatewayToolConfigVO.getToolVersion());
             mcpGatewayToolPO.setProtocolId(gatewayToolConfigVO.getProtocolId());
             mcpGatewayToolPO.setProtocolType(gatewayToolConfigVO.getProtocolType());
             mcpGatewayToolDao.insert(mcpGatewayToolPO);
            

            }

            @Override
            public void updateGatewayToolProtocol(GatewayToolConfigCommandEntity commandEntity) {
            GatewayToolConfigVO gatewayToolConfigVO = commandEntity.getGatewayToolConfigVO();

             McpGatewayToolPO mcpGatewayToolPO = new McpGatewayToolPO();
             mcpGatewayToolPO.setGatewayId(gatewayToolConfigVO.getGatewayId());
             mcpGatewayToolPO.setProtocolId(gatewayToolConfigVO.getProtocolId());
             mcpGatewayToolPO.setProtocolType(gatewayToolConfigVO.getProtocolType());
            
             int count = mcpGatewayToolDao.updateProtocolByGatewayId(mcpGatewayToolPO);
             if (1 != count) {
                 throw new AppException(DB_UPDATE_FAIL.getCode(), DB_UPDATE_FAIL.getInfo());
             }
            

            }

          }

          这部分就是基本的 crud 操作了,以及配套的要修改 mapper 文件。

          1. 充血设计
            举例一个简单的充血设计;

          @Data
          @Builder
          @NoArgsConstructor
          @AllArgsConstructor
          public class GatewayConfigCommandEntity {

          private GatewayConfigVO gatewayConfigVO;
          
          public static GatewayConfigCommandEntity buildUpdateGatewayAuthStatusVO(String gatewayId, GatewayEnum.GatewayAuthStatusEnum auth) {
              return GatewayConfigCommandEntity.builder()
                      .gatewayConfigVO(GatewayConfigVO.builder()
                              .gatewayId(gatewayId)
                              .auth(auth)
                              .build())
                      .build();
          }
          

          }

          当我们通过 void saveGatewayConfig(GatewayConfigCommandEntity commandEntity); void updateGatewayAuthStatus(GatewayConfigCommandEntity commandEntity);两个接口,都使用了同一个命令对象,但对于 updateGatewayAuthStatus 操作,是不需要那么对数据的,这个时候就可以在 GatewayConfigCommandEntity 内聚一个方法,方便使用。

          四、测试验证
          注意,sql 有更新;3-17-ai_mcp_gateway_v2.sql - 在课程 dev-ops 下。mcp_gateway_tool protocol_type 长度调整为8位。

          1. 网关配置(CRUD)
            @RunWith(SpringRunner.class)
            @SpringBootTest
            public class GatewayConfigServiceTest {

            @Resource
            private IGatewayConfigService gatewayConfigService;

            @Test
            public void test_saveGatewayConfig() {
            GatewayConfigCommandEntity commandEntity = new GatewayConfigCommandEntity();
            GatewayConfigVO gatewayConfigVO = GatewayConfigVO.builder()
            .gatewayId(“gateway_002”)
            .gatewayName(“员工信息查询网关”)
            .gatewayDesc(“用于查询公司员工信息的MCP网关”)
            .version(“1.0.0”)
            .auth(GatewayEnum.GatewayAuthStatusEnum.ENABLE)
            .status(GatewayEnum.GatewayStatus.STRONG_VERIFIED)
            .build();
            commandEntity.setGatewayConfigVO(gatewayConfigVO);

             gatewayConfigService.saveGatewayConfig(commandEntity);
             log.info("保存网关配置成功 gatewayId: {}", gatewayConfigVO.getGatewayId());
            

            }

            @Test
            public void test_updateGatewayAuthStatus() {
            GatewayConfigCommandEntity commandEntity = GatewayConfigCommandEntity.buildUpdateGatewayAuthStatusVO(“gateway_002”, GatewayEnum.GatewayAuthStatusEnum.DISABLE);
            gatewayConfigService.updateGatewayAuthStatus(commandEntity);
            log.info(“更新网关鉴权状态成功 gatewayId: {}”, commandEntity.getGatewayConfigVO().getGatewayId());
            }

          }

          网关配置,如果数据存储失败,可以修改 gatewayId。

          1. 工具配置
            @RunWith(SpringRunner.class)
            @SpringBootTest
            public class GatewayToolConfigServiceTest {

            @Resource
            private IGatewayToolConfigService gatewayToolConfigService;

            @Test
            public void test_saveGatewayToolConfig() {
            GatewayToolConfigCommandEntity commandEntity = new GatewayToolConfigCommandEntity();
            GatewayToolConfigVO gatewayToolConfigVO = GatewayToolConfigVO.builder()
            .gatewayId(“gateway_002”)
            .toolId(Long.valueOf(RandomStringUtils.randomNumeric(4)))
            .toolName(“JavaSDKMCPClient_getCompanyEmployee”)
            .toolType(“function”)
            .toolDescription(“获取公司雇员信息”)
            .toolVersion(“1.0.0”)
            .protocolId(83666188L)
            .protocolType(“http”)
            .build();
            commandEntity.setGatewayToolConfigVO(gatewayToolConfigVO);

             gatewayToolConfigService.saveGatewayToolConfig(commandEntity);
             log.info("保存网关工具配置成功 gatewayId: {} toolId: {}", gatewayToolConfigVO.getGatewayId(), gatewayToolConfigVO.getToolId());
            

            }

            @Test
            public void test_updateGatewayToolProtocol() {
            GatewayToolConfigCommandEntity commandEntity = GatewayToolConfigCommandEntity.buildUpdateGatewayProtocol(
            “gateway_002”,
            4904L,
            83666188L,
            “dubbo”);

             gatewayToolConfigService.updateGatewayToolProtocol(commandEntity);
             log.info("更新网关工具协议成功 gatewayId: {} protocolId: {}", commandEntity.getGatewayToolConfigVO().getGatewayId(), commandEntity.getGatewayToolConfigVO().getProtocolId());
            

            }

          }

          测试网关工具的配置和修改,把 http 协议修改为 dubbo

          《AI MCP Gateway》第3-18节:管理端-API功能编排串联

          一、本章诉求
          到本章节往下,我们要为 AI MCP Gateway(网关)提供一个管理端页面,维护网关的配置和验证。如配置网关和工具能力,为工具添加协议。协议来自于 Swagger 导出的 openapi json 文件。这些内容,我们陆续的添加。本节主要串联服务功能接口(看看这样的架构下怎么提供的页面功能),以及提供基础UI页面。

          注意,关于网关后台的管理页面是使用 AI IDE 工具生成的。建议每个人都生成一套自己的,你只要在对话框中,把服务端的 api 接口拖到对话中,并告诉它在什么位置编写 html 代码,以及对接服务端页面。它就可以帮你完成一套页面。

          二、流程设计
          如图,从管理端页面到服务端的流程设计;

          img_38.png

          首先,整个流程为;从前端页面(管理后台)通过 ajax 请求服务端 trigger 层的 http 接口开始,由 case 层分摊 trigger 层串联逻辑部分的流程。这部分流程,调用 domain 领域层处理,也就是完成 CRUD 操作。最终由基础设施层,依赖倒置于 domain 领域层,完成数据的增删改查操作。

          之后,本节我们的重点是完成从 trigger 往下的逻辑处理,对于页面端,先少量提供。后续陆续补充各个功能。

          三、功能实现

          1. 工程结构

          api - 定义 admin 接口,统一定义会更加方便维护。另外像是 rpc 这样的组件,他还会把 api 打包对外,让使用方使用接口协议。可阅读 《Dubbo 使用教程和原理分析》

          trigger - 实现 api 定义的接口,之后 trigger 触发器负责调用 case 逻辑,封装接口结果和异常的处理。

          case - 类似于中转站,分摊 trigger 的逻辑编写,如 trigger 要串联一些 domain 以及增加的判断,现在都拆分到 case 层处理,之后 trigger 就只是负责对 case 的调用,以及出入参结果的封装。

          domain - 负责功能逻辑充血方式的实现,这部分在前面章节,已经开发了一些功能,如 gateway、protocol 都有用于管理配置网关的服务。之后要增加 adminService 服务,处理其他的增删改查操作。

          infrastructure - 基础设施层,负责最后的数据处理。在 DDD 对应的六边形架构中,基础设施层以依赖倒置于 domain 领域层的,也就是 domain 定义标准,基础设施层负责实现。这样即使将来有什么调整,也不会影响领域层。

          1. 功能开发
            2.1 接口定义
            public interface IAdminService {

            Response saveGatewayConfig(GatewayConfigRequestDTO.GatewayConfig requestDTO);

            Response saveGatewayToolConfig(GatewayConfigRequestDTO.GatewayToolConfig requestDTO);

            Response saveGatewayProtocol(GatewayConfigRequestDTO.GatewayProtocol requestDTO);

            Response saveGatewayAuth(GatewayConfigRequestDTO.GatewayAuth requestDTO);

            Response<List> queryGatewayConfigList();

          }

          这里定义了一整套的 admin 管理后台所需的接口能力。基本是一些 CRUD 的操作。

          后续开发中,还要继续完善一些接口,便于管理端使用。当然你也可以自己有想法后,在完善一些。

          对于 trigger 层的接口实现,可以直接看代码。没有太复杂的逻辑,都是 crud 的处理。

          2.2 编排能力
          public interface IAdminAuthService {

          void saveGatewayAuth(RegisterCommandEntity commandEntity);
          

          }

          public interface IAdminGatewayService {

          void saveGatewayConfig(GatewayConfigCommandEntity commandEntity);
          
          void saveGatewayToolConfig(GatewayToolConfigCommandEntity commandEntity);
          

          }

          public interface IAdminManageService {

          List<GatewayConfigEntity> queryGatewayConfigList();
          

          }

          public interface IAdminProtocolService {

          void saveGatewayProtocol(StorageCommandEntity commandEntity);
          

          }

          在 domain 层,定义了认证、网关、管理、协议的服务,用于 crud 操作。

          像是 IAdminProtocolService#saveGatewayProtocol 会调用 domain 领域层的功能来处理逻辑实现。

          2.3 领域服务
          public class AdminService implements IAdminService {

          @Resource
          private IAdminRepository adminRepository;
          
          @Override
          public List<GatewayConfigEntity> queryGatewayConfigList() {
              return adminRepository.queryGatewayConfigList();
          }
          

          }

          其他的几个领域服务,都已经在 auth、gateway、protocol 实现了。

          AdminService 是新增加的功能,后续还有其他crud在这里添加。

          2.4 接口实现
          @RestController
          @CrossOrigin(origins = ““, allowedHeaders = ““, methods = {RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE, RequestMethod.OPTIONS})
          @RequestMapping(“/admin/“)
          public class AdminController implements IAdminService {

          @Resource
          private IAdminGatewayService adminGatewayService;
          @Resource
          private IAdminAuthService adminAuthService;
          @Resource
          private IAdminProtocolService adminProtocolService;
          @Resource
          private IAdminManageService adminManageService;
          
          // ... 省略部分代码,可以从项目中查看
          
          @RequestMapping(value = "query_gateway_config_list", method = RequestMethod.GET)
          @Override
          public Response<List<GatewayConfigDTO>> queryGatewayConfigList() {
              try {
                  log.info("查询网关配置列表开始");
                  List<GatewayConfigEntity> entities = adminManageService.queryGatewayConfigList();
                  List<GatewayConfigDTO> dtoList = entities.stream().map(e -> GatewayConfigDTO.builder()
                          .gatewayId(e.getGatewayId())
                          .gatewayName(e.getGatewayName())
                          .gatewayDesc(e.getGatewayDesc())
                          .version(e.getVersion())
                          .auth(e.getAuth())
                          .status(e.getStatus())
                          .build()).collect(Collectors.toList());
                  log.info("查询网关配置列表完成 count: {}", dtoList.size());
                  return Response.<List<GatewayConfigDTO>>builder()
                          .code(ResponseCode.SUCCESS.getCode())
                          .info(ResponseCode.SUCCESS.getInfo())
                          .data(dtoList)
                          .build();
              } catch (Exception e) {
                  log.error("查询网关配置列表失败", e);
                  return Response.<List<GatewayConfigDTO>>builder()
                          .code(ResponseCode.UN_ERROR.getCode())
                          .info(ResponseCode.UN_ERROR.getInfo())
                          .build();
              }
          }
          

          }

          AdminController 管理端包装 case 层,返回数据结果。

          其他的一些接口也的都是类似的操作,可以查看工程代码。

          2.5 管理页面

          在 docs/dev-ops/nginx 下,提供了管理端页面 html,这部分内容就是 ai ide(trae.ai/claude code)实现的了。建议你也做一套自己的,最好在有一些额外的功能。

          四、测试验证

          1. 前置说明

          测试前启动服务端工程。

          确保工程名称和工程的文件夹名称一致。如工程是 a 文件夹也是 a,别 a-master

          1. 验证功能

          如图,在 admin.html 下,点击浏览器,可以直接访问你的管理端页面。

          《AI MCP Gateway》第3-19节:管理端-API与UI对接

          一、本章诉求
          结合网关管理后台(UI)功能,在服务端提供相应的接口能力,与管理后台对接。这些功能主要是 CRUD 做页面管理的增删改查操作。

          此部分功能使用 AI IDE 工具就可以完成,也很建议学习的伙伴使用 AI IDE 工具(opencode、trae.ai、openclaw(QClaw)),做一套属于自己的管理后台,这样你可以在投递简历的时候发送,也可以在面试的时候演示。

          提示,可以在你的 AI IDE 安装技能 https://github.com/fuzhengwei/xfg-ddd-skills 这样处理起来更加准确。

          二、流程设计
          如图,管理端到服务端接口的设计;

          img_39.png

          整个这部分的设计实现都是 CRUD,管理端需要什么,就可以配置什么接口,查询、修改、删除即可。

          三、功能实现

          1. 工程结构

          首先,让 AI IDE 基于现在管理端需要的 UI 页面,拆分出了一些独立的页面,在 views 下,包括;auth、list、protocol、tool,如果你还有其他的诉求,也可以继续补充。

          之后,在 AdminController 下配套的给 html 页面提供 http 接口。调用链路 html -> trigger(http) -> case -> domain(串联),之后基础设施层依赖倒置的实现 domain 层的接口,对数据库进行 CRUD 操作。

          1. 功能开发
            2.1 前端开发
            2.1.1 接口配置
            dev/ops/nginx/html/js/config.js

          // js/config.js

          const API_BASE_URL = “http://127.0.0.1:8777/api-gateway“; // 替换为实际的服务端IP和端口

          const API_ENDPOINTS = {
          // 获取网关列表
          GET_GATEWAY_LIST: ${API_BASE_URL}/admin/query_gateway_config_list,
          GET_GATEWAY_PAGE: ${API_BASE_URL}/admin/query_gateway_config_page,
          // 保存网关配置
          SAVE_GATEWAY_CONFIG: ${API_BASE_URL}/admin/save_gateway_config,
          // 保存网关工具配置
          SAVE_GATEWAY_TOOL_CONFIG: ${API_BASE_URL}/admin/save_gateway_tool_config,
          // 获取网关协议列表
          GET_GATEWAY_PROTOCOL_LIST: ${API_BASE_URL}/admin/query_gateway_protocol_list,
          GET_GATEWAY_PROTOCOL_PAGE: ${API_BASE_URL}/admin/query_gateway_protocol_page,
          // 根据网关ID获取协议列表
          GET_GATEWAY_PROTOCOL_LIST_BY_ID: ${API_BASE_URL}/admin/query_gateway_protocol_list_by_gateway_id,
          // 保存网关协议配置
          SAVE_GATEWAY_PROTOCOL: ${API_BASE_URL}/admin/save_gateway_protocol,
          // 导入网关协议配置
          IMPORT_GATEWAY_PROTOCOL: ${API_BASE_URL}/admin/import_gateway_protocol,
          // 解析网关协议配置
          ANALYSIS_PROTOCOL: ${API_BASE_URL}/admin/analysis_protocol,
          // 删除网关协议配置
          DELETE_GATEWAY_PROTOCOL: ${API_BASE_URL}/admin/delete_gateway_protocol,
          // 获取网关认证列表
          GET_GATEWAY_AUTH_LIST: ${API_BASE_URL}/admin/query_gateway_auth_list,
          GET_GATEWAY_AUTH_PAGE: ${API_BASE_URL}/admin/query_gateway_auth_page,
          // 保存网关认证配置
          SAVE_GATEWAY_AUTH: ${API_BASE_URL}/admin/save_gateway_auth,
          // 删除网关认证配置
          DELETE_GATEWAY_AUTH: ${API_BASE_URL}/admin/delete_gateway_auth,
          // 获取网关工具列表
          GET_GATEWAY_TOOL_LIST: ${API_BASE_URL}/admin/query_gateway_tool_list,
          GET_GATEWAY_TOOL_PAGE: ${API_BASE_URL}/admin/query_gateway_tool_page,
          // 根据网关ID获取工具列表
          GET_GATEWAY_TOOL_LIST_BY_ID: ${API_BASE_URL}/admin/query_gateway_tool_list_by_gateway_id,
          // 删除网关工具配置
          DELETE_GATEWAY_TOOL: ${API_BASE_URL}/admin/delete_gateway_tool_config
          };

          // 模拟登录账号
          const MOCK_ACCOUNT = {
          username: “admin”,
          password: “password123”
          };

          在 config.js 页面配置了 API_BASE_URL 地址,如果你部署了云服务器或者其他环境,需要检查IP和端口需要配置。

          之后就是一系列的网关管理后台,对接所需的各个URL地址了,基本全是 CRUD 操作。

          2.1.2 接口调用
          // js/app.js
          $(document).ready(function() {
          // 检查登录状态
          if(localStorage.getItem(‘mcp_admin_logged_in’) !== ‘true’) {
          window.location.href = ‘index.html’;
          return;
          }

          // 退出登录
          $('#logoutBtn').on('click', function(e) {
              e.preventDefault();
              localStorage.removeItem('mcp_admin_logged_in');
              window.location.href = 'index.html';
          });
          
          // 侧边栏导航切换和动态加载页面
          $('.nav-link[data-target]').on('click', function(e) {
              e.preventDefault();
              
              // 更新激活状态
              $('.nav-link').removeClass('active');
              $(this).addClass('active');
              
              const targetId = $(this).data('target');
              loadView(targetId);
          });
          
          // 动态加载网关配置选项
          function loadGatewayOptions(selectedGatewayId = null) {
              const $select = $('#tool-gatewayId');
              const $helpText = $('#tool-gatewayId-help');
              
              // 保持现有选项,只更新"加载中"状态
              $select.html('<option value="">加载网关列表中...</option>');
              
              $.ajax({
                  url: API_ENDPOINTS.GET_GATEWAY_LIST,
                  type: 'GET',
                  success: function(response) {
                      if(response && response.code === '0000' && response.data) {
                          let optionsHtml = '<option value="">请选择网关...</option>';
                          response.data.forEach(function(gw) {
                              // 使用松散比较(==)或转换类型,因为从后端传来的类型可能和本地解析的不一致
                              const isSelected = selectedGatewayId == gw.gatewayId ? 'selected' : '';
                              optionsHtml += `<option value="${gw.gatewayId}" ${isSelected}>${gw.gatewayName}</option>`;
                          });
                          $select.html(optionsHtml);
                          
                          // 如果有选中值,触发 change 事件以更新小字提示
                          if (selectedGatewayId) {
                              $helpText.text(`网关ID: ${selectedGatewayId}`);
                          } else {
                              $helpText.text('网关ID: -');
                          }
                      } else {
                          $select.html('<option value="">加载失败,请重试</option>');
                      }
                  },
                  error: function() {
                      $select.html('<option value="">加载失败,请检查网络</option>');
                  }
              });
          }
          
          //... 省略部分
          

          }

          关于服务端接口的调用,来自于 app.js 这个文件,都是处理各个接口调用的。

          这部分东西让 AI 来帮助编写就可以,出页面UI和对接,ai 很擅长。

          2.2 后端开发
          2.2.1 后台接口
          @Slf4j
          @RestController
          @CrossOrigin(origins = ““, allowedHeaders = ““, methods = {RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE, RequestMethod.OPTIONS})
          @RequestMapping(“/admin/“)
          public class AdminController implements IAdminService {

          @Resource
          private IAdminGatewayService adminGatewayService;
          @Resource
          private IAdminAuthService adminAuthService;
          @Resource
          private IAdminProtocolService adminProtocolService;
          @Resource
          private IAdminManageService adminManageService;
          
          @RequestMapping(value = "save_gateway_config", method = RequestMethod.POST)
          @Override
          public Response<GatewayConfigResponseDTO> saveGatewayConfig(@RequestBody GatewayConfigRequestDTO.GatewayConfig requestDTO) {
              try {
                  log.info("保存网关配置开始 gatewayId: {}", requestDTO.getGatewayId());
                  GatewayConfigCommandEntity commandEntity = GatewayConfigCommandEntity.builder()
                          .gatewayConfigVO(GatewayConfigVO.builder()
                                  .gatewayId(requestDTO.getGatewayId())
                                  .gatewayName(requestDTO.getGatewayName())
                                  .gatewayDesc(requestDTO.getGatewayDesc())
                                  .version(requestDTO.getVersion())
                                  .auth(GatewayEnum.GatewayAuthStatusEnum.getByCode(requestDTO.getAuth()))
                                  .status(GatewayEnum.GatewayStatus.get(requestDTO.getStatus()))
                                  .build())
                          .build();
                  adminGatewayService.saveGatewayConfig(commandEntity);
                  log.info("保存网关配置完成 gatewayId: {}", requestDTO.getGatewayId());
                  return Response.<GatewayConfigResponseDTO>builder()
                          .code(ResponseCode.SUCCESS.getCode())
                          .info(ResponseCode.SUCCESS.getInfo())
                          .data(GatewayConfigResponseDTO.builder().success(true).build())
                          .build();
              } catch (Exception e) {
                  log.error("保存网关配置失败 gatewayId: {}", requestDTO.getGatewayId(), e);
                  return Response.<GatewayConfigResponseDTO>builder()
                          .code(ResponseCode.UN_ERROR.getCode())
                          .info(ResponseCode.UN_ERROR.getInfo())
                          .build();
              }
          }
          
          @RequestMapping(value = "delete_gateway_auth", method = RequestMethod.POST)
          public Response<GatewayConfigResponseDTO> deleteGatewayAuth(@RequestParam String gatewayId) {
              try {
                  log.info("删除网关认证配置开始 gatewayId: {}", gatewayId);
                  adminAuthService.deleteGatewayAuth(gatewayId);
                  log.info("删除网关认证配置完成 gatewayId: {}", gatewayId);
                  return Response.<GatewayConfigResponseDTO>builder()
                          .code(ResponseCode.SUCCESS.getCode())
                          .info(ResponseCode.SUCCESS.getInfo())
                          .data(GatewayConfigResponseDTO.builder().success(true).build())
                          .build();
              } catch (Exception e) {
                  log.error("删除网关认证配置失败 gatewayId: {}", gatewayId, e);
                  return Response.<GatewayConfigResponseDTO>builder()
                          .code(ResponseCode.UN_ERROR.getCode())
                          .info(ResponseCode.UN_ERROR.getInfo())
                          .build();
              }
          }
          
          // ... 省略部分接口
          

          }

          这部分接口就是配合 UI 页面出的,需要什么接口,编写就可以。

          2.2.2 编排逻辑
          ai-mcp-gateway-3-19-03.png

          case 层编排对于 crud 的一些操作,如果有解析和存储这样的,可以串联领域方法。

          不过这里的串联相对简单很多,不会像是 mcp 部分那么多东西。

          这部分相对简单,基本都是 curd 操作,可以直接看到代码。

          四、测试验证

          1. 启动访问

          2. 管理后台
            2.1 登录操作

          2.2 网关列表

          2.3 工具展示

          2.4 导入协议

          2.5 认证权限

          《AI MCP Gateway》第3-20节:验证服务,LLM对接测试MCP接口

          一、本章诉求
          为了在配置完 AI MCP 网关以后,方便的验证网关服务SSE接口是否正常,我们需要在服务端增加一个简单的 LLM 大模型能力,通过 AI MCP 网关,来调用 MCP 服务。这样就可以非常方便的验证网关能力了。

          本节我们先来做一下 LLM 的服务接口,把这部分能力在领域层设计实现出来,并包装一个接口服务。后续在结合这个接口服务,在管理后台配套的做一个页面出来。专门验证 MCP 服务能力。

          温馨提示,学习学到这了,就要多思考一些扩展。不只是完成课程的从0到1,也要思考从1-100还能做什么。

          二、功能设计
          如图,LLM 对接 MCP 服务,提供接口能力设计图;

          img_40.png

          首先,整个操作的核心就是 LLM 服务,这部分内容,咱们在 ai-mcp-gateway-demo-mcp-server-test 工程里,有做过测试验证。现在相当于要把这部分能力迁移过来,放到 domain 领域层,实现 LLM 服务。

          之后,这个 LLM 服务的目的,就是可以动态化的构建含有 MCP 服务的对话模型。MCP 的加载,来自于接口层传入的 gatewayId 网关 ID 做动态的加载处理。

          注意,因为网关的配置也是不断的动态调整的,所以流程设计上会有一个是否重新加载的操作。重新加载的目的就是把 MCP 动态的刷新配置。

          三、功能实现

          1. 工程结构

          首先,增加 LLMService 领域服务,封装大模型的创建和获取。

          之后,由 case 层,封装 domain 领域服务,再由 trigger 的接口层,使用 AdminController 来处理 case 层调用。

          另外,McpGatewayController 中接口中,api_key 的入参,需要配置不校验。因为有些场景可能不配置 auth 校验。

          1. 功能实现
            2.1 LLM 接口服务
            2.1.1 配置pom org.springframework.ai spring-ai-starter-model-openai org.springframework.ai spring-ai-starter-mcp-server-webflux

          因为需要使用 spring ai 能力开发 LLM 大模型对接 MCP,所以要在 domain 领域层引入包信息。

          注意,app 下也引入了,原本是 test 作用域配置的 spring ai 框架,需要去掉。

          2.1.2 领域服务(LLM)
          @Slf4j
          @Service
          public class LLMService implements ILLMService {

          private final Map<String, ChatModel> chatModelMap = new HashMap<>();
          
          @Resource
          private OpenAiApi openAiApi;
          
          @Value("${spring.ai.openai.options.model:gpt-4o}")
          private String model;
          
          @Override
          public void buildChatModel(BuildChatModelCommandEntity commandEntity) {
              log.info("构建对话模型 gatewayId:{} mcp:{}", commandEntity.getGatewayId(), JSON.toJSONString(commandEntity.getMcpConfigVO()));
          
              // mcp 配置
              McpConfigVO mcpConfigVO = commandEntity.getMcpConfigVO();
          
              // model 配置 + mcp 服务
              ChatModel chatModel = OpenAiChatModel.builder()
                      .openAiApi(openAiApi)
                      .defaultOptions(OpenAiChatOptions.builder()
                              .model(model)
                              .toolCallbacks(buildToolCallback(mcpConfigVO))
                              .build())
                      .build();
          
              // 写入缓存
              chatModelMap.put(commandEntity.getGatewayId(), chatModel);
          }
          
          public ToolCallback[] buildToolCallback(McpConfigVO mcpConfigVO) {
              String sseEndPoint = mcpConfigVO.getSseEndpoint();
              if (StringUtils.isNotBlank(mcpConfigVO.getAuthApiKey())) {
                  sseEndPoint += "?api_key=" + mcpConfigVO.getAuthApiKey();
              }
          
              HttpClientSseClientTransport sseClientTransport = HttpClientSseClientTransport
                      .builder(mcpConfigVO.getBaseUri())
                      .sseEndpoint(sseEndPoint)
                      .build();
          
              McpSyncClient mcpSyncClient = McpClient
                      .sync(sseClientTransport)
                      .requestTimeout(Duration.ofMillis(mcpConfigVO.getTimeout())).build();
              var initialize = mcpSyncClient.initialize();
          
              log.info("tool sse mcp initialize {}", initialize);
          
              return new SyncMcpToolCallbackProvider(mcpSyncClient).getToolCallbacks();
          }
          
          @Override
          public ChatModel getChatModel(String gatewayId) {
              return chatModelMap.get(gatewayId);
          }
          

          }

          buildChatModel 接口目的在于构建 LLM 对话模型服务,以及动态加载 MCP 服务。构建完成后写入本地缓存。

          getChatModel 接口目的是为了获取 gatewayId 网关ID,对应的对话模型服务。

          2.2 编排服务
          @Service
          public class AdminLLMService implements IAdminLLMService {

          @Value("${server.servlet.context-path}")
          private String baseUrlContextPath;
          
          @Value("${server.port}")
          private Integer port;
          
          @Resource
          private ILLMService llmService;
          
          @Resource
          private IGatewayToolConfigService gatewayToolConfigService;
          
          @Override
          public GatewayLLMResponseDTO testCallGateway(GatewayLLMRequestDTO requestDTO) {
              log.info("AdminLLMService.testCallGateway {} {}", requestDTO.getGatewayId(), requestDTO.getMessage());
          
              String gatewayId = requestDTO.getGatewayId();
          
              String baseUrl = "http://localhost:" + port;
              String sseEndpoint = baseUrlContextPath + "/" + gatewayId + "/mcp/sse";
          
              // 获取对话模型
              ChatModel chatModel = llmService.getChatModel(gatewayId);
          
              // 判断是否重新加载 mcp 服务
              if (requestDTO.isReload() || null == chatModel) {
          
                  McpConfigVO mcpConfigVO = McpConfigVO.builder()
                          .baseUri(baseUrl)
                          .sseEndpoint(sseEndpoint)
                          .authApiKey(requestDTO.getAuthApiKey())
                          .timeout(requestDTO.getTimeout())
                          .build();
          
                  BuildChatModelCommandEntity commandEntity = BuildChatModelCommandEntity.builder()
                          .gatewayId(gatewayId)
                          .mcpConfigVO(mcpConfigVO)
                          .build();
          
                  llmService.buildChatModel(commandEntity);
          
                  chatModel = llmService.getChatModel(gatewayId);
              }
          
              String call = chatModel.call(requestDTO.getMessage());
          
              return GatewayLLMResponseDTO.builder().content(call).build();
          }
          

          }

          封装一个测试网关服务能力的 case 编排操作,拼接好 baseUrl、sseEndpoint,之后获取 llmService.getChatModel(gatewayId) 模型对话能力。

          如果获取为空,或者重新加载的时候,则动态创建 ChatModel 对话模型,之后就可以请求服务了。

          2.3 接口封装
          @RequestMapping(value = “test_call_gateway”, method = RequestMethod.POST)
          @Override
          public Response testCallGateway(GatewayLLMRequestDTO requestDTO) {
          try {
          log.info(“测试请求网关服务开始 gatewayId: {}”, requestDTO.getGatewayId());
          GatewayLLMResponseDTO responseDTO = adminLLMService.testCallGateway(requestDTO);
          log.info(“测试请求网关服务完成 gatewayId: {} resDTO:{}”, requestDTO.getGatewayId(), JSON.toJSONString(responseDTO));
          return Response.builder()
          .code(ResponseCode.SUCCESS.getCode())
          .info(ResponseCode.SUCCESS.getInfo())
          .data(responseDTO)
          .build();
          } catch (Exception e) {
          log.error(“测试请求网关服务失败 gatewayId: {}”, requestDTO.getGatewayId(), e);
          return Response.builder()
          .code(ResponseCode.UN_ERROR.getCode())
          .info(ResponseCode.UN_ERROR.getInfo())
          .build();
          }
          }

          这里的接口,只需要直接调用服务即可。之后封装返回结果。

          2.4 接口兼容(api_key)
          @Slf4j
          @RestController
          @CrossOrigin(origins = ““, allowedHeaders = ““, methods = {RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE, RequestMethod.OPTIONS})
          @RequestMapping(“/“)
          public class McpGatewayController implements IMcpGatewayService {

          @Resource
          private IMcpSessionService mcpSessionService;
          
          @Resource
          private IMcpMessageService mcpMessageService;
          
          @GetMapping(value = "{gatewayId}/mcp/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
          @Override
          public Flux<ServerSentEvent<String>> handleSseConnection(
                  @PathVariable("gatewayId") String gatewayId, @RequestParam(value = "api_key", required = false, defaultValue = "") String apiKey) throws Exception {
              try {
                  log.info("建立 MCP SSE 连接,gatewayId:{}", gatewayId);
                  if (StringUtils.isBlank(gatewayId)) {
                      log.info("非法参数,gateway is null");
                      throw new AppException(ResponseCode.ILLEGAL_PARAMETER.getCode(), ResponseCode.ILLEGAL_PARAMETER.getInfo());
                  }
          
                  return mcpSessionService.createMcpSession(gatewayId, apiKey);
              } catch (AppException e) {
                  log.error("建立 MCP SSE 连接拒绝,gatewayId: {}", gatewayId, e);
                  return Flux.just(ServerSentEvent.<String>builder()
                          .id(UUID.randomUUID().toString())
                          .event("error")
                          .data(JSON.toJSONString(Response.<String>builder()
                                  .code(e.getCode())
                                  .info(e.getInfo())
                                  .build()))
                          .build());
              } catch (Exception e) {
                  log.error("建立 MCP SSE 连接失败,gatewayId: {}", gatewayId, e);
                  throw e;
              }
          }
          
          
          @PostMapping(value = "{gatewayId}/mcp/sse", consumes = MediaType.APPLICATION_JSON_VALUE)
          public Mono<ResponseEntity<Void>> handleMessage(@PathVariable("gatewayId") String gatewayId,
                                                          @RequestParam("sessionId") String sessionId,
                                                          @RequestParam(value = "api_key", required = false, defaultValue = "") String apiKey,
                                                          @RequestBody String messageBody) {
              try {
                  log.info("处理 MCP SSE 消息,gatewayId:{} apiKey:{} sessionId:{} messageBody:{}", gatewayId, apiKey, sessionId, messageBody);
                  if (StringUtils.isBlank(gatewayId) || StringUtils.isBlank(sessionId)) {
                      log.info("非法参数,gateway、sessionId is null");
                      throw new AppException(ResponseCode.ILLEGAL_PARAMETER.getCode(), ResponseCode.ILLEGAL_PARAMETER.getInfo());
                  }
          
                  HandleMessageCommandEntity commandEntity = new HandleMessageCommandEntity(gatewayId, apiKey, sessionId, messageBody);
                  ResponseEntity<Void> responseEntity = mcpMessageService.handleMessage(commandEntity);
          
                  return Mono.just(responseEntity);
              } catch (Exception e) {
                  log.error("处理 MCP SSE 消息失败,gatewayId:{} sessionId:{} messageBody:{}", gatewayId, sessionId, messageBody, e);
                  return Mono.just(ResponseEntity.internalServerError().build());
              }
          }
          

          }

          因为 api_key 并不是必须的入参,所以这里设置不校验给默认值 @RequestParam(value = “api_key”, required = false, defaultValue = “”)

          handleSseConnection、handleMessage,这里两个接口都处理下。

          四、测试验证

          1. 注意事项

          先启动 ai-mcp-gateway-demo-mcp-server-test - 确保启动正常,让它提供接口能力。

          访问 http://localhost:8701/swagger-ui/index.html - 如果访问不了,可以更换为具体IP,以及检查端口是否为8701

          访问 http://localhost:8701/v3/api-docs - 也可以2里面的页面蓝色字**/v3/api-docs**,点击进来。进来后保存 json 文件。

          2~3 步骤,为的是如果你之前没操作过,可以处理后配置网关服务接口。

          1. yml 配置
            application-dev.yml

          spring:

          spring ai

          ai:
          openai:
          base-url: https://apis.itedus.cn
          api-key: sk-qqXMW8Td4ckEYfnPBe77C7B75d90485984100c**可以联系小傅哥获取申请渠道
          chat:
          options:
          model: gpt-4o

          配置 api_key 使用模型。

          1. 服务测试
            @Slf4j
            @RunWith(SpringRunner.class)
            @SpringBootTest
            public class AdminControllerTest {

            @Resource
            private AdminController adminController;

            @Test
            public void test_testCallGateway() {
            GatewayLLMRequestDTO requestDTO = GatewayLLMRequestDTO.builder()
            .gatewayId(“gateway_005”)
            .reload(true)
            .authApiKey(“gw-GPJBQHFeBWVMSGASFii5xtsmlHF5SjURFwh7C7yGRP3UtX”)
            .timeout(3000)
            .message(“””
            获取公司雇员信息,信息如下;
            城市;北京
            公司;谷歌
            雇员;小傅哥”””)
            .build();

             Response<GatewayLLMResponseDTO> response = adminController.testCallGateway(requestDTO);
            
             log.info("测试结果:{}", JSON.toJSONString(response));
            

            }

          }

          26-04-11.17:50:45.188 [main ] INFO LLMService - 构建对话模型 gatewayId:gateway_005 mcp:{“authApiKey”:”gw-GPJBQHFeBWVMSGASFii5xtsmlHF5SjURFwh7C7yGRP3UtX”,”baseUri”:”http://localhost:8777","sseEndpoint":"/api-gateway/gateway_005/mcp/sse","timeout":3000}
          26-04-11.17:50:45.262 [HttpClient-1-Worker-0] INFO McpAsyncClient - Server response with Protocol: 2024-11-05, Capabilities: ServerCapabilities[completions=CompletionCapabilities[], experimental={}, logging=LoggingCapabilities[], prompts=PromptCapabilities[listChanged=true], resources=ResourceCapabilities[subscribe=false, listChanged=true], tools=ToolCapabilities[listChanged=true]], Info: Implementation[name=课程演示, version=1.0.0] and Instructions 课程演示
          26-04-11.17:50:45.268 [main ] INFO LLMService - tool sse mcp initialize InitializeResult[protocolVersion=2024-11-05, capabilities=ServerCapabilities[completions=CompletionCapabilities[], experimental={}, logging=LoggingCapabilities[], prompts=PromptCapabilities[listChanged=true], resources=ResourceCapabilities[subscribe=false, listChanged=true], tools=ToolCapabilities[listChanged=true]], serverInfo=Implementation[name=课程演示, version=1.0.0], instructions=课程演示]
          26-04-11.17:50:57.216 [main ] INFO AdminController - 测试请求网关服务完成 gatewayId: gateway_005 resDTO:{“content”:”根据查询到的公司雇员信息:\n\n- 雇员姓名:小傅哥\n- 雇员编号:111\n- 每日工时:10小时\n- 工资:16.92单位(具体单位可再确认)。”}
          26-04-11.17:50:57.221 [main ] INFO AdminControllerTest - 测试结果:{“code”:”0000”,”data”:{“content”:”根据查询到的公司雇员信息:\n\n- 雇员姓名:小傅哥\n- 雇员编号:111\n- 每日工时:10小时\n- 工资:16.92单位(具体单位可再确认)。”},”info”:”成功”}

          到这,就表明你的测试没问题了。

          《AI MCP Gateway》第3-21节:验证服务,LLM对接测试MCP界面

          一、本章诉求
          将上一章节开发实现的 LLM 测试网关服务接口,在管理后台对接测试 MCP 服务调用页面,以便于通过页面选择网关的方式进行测试验证使用。

          只要你开发好了对应的服务接口,AI IDE 工具做这类事情很方便,也完全可以做一套自己的新的页面 UI 效果出来。(建议听劝,做一套自己的,可以面试演示的!)

          二、功能设计
          如图,LLM 对接 MCP 服务,从接口到服务接口的关系;

          img_41.png

          首先,这一部分主要添加的内容是从管理后台的UI,增加一个测试网关服务的界面。通过选择网关,选择认证(key),是否重新加载,之后发起对话。

          之后,因为要网关列表,并根据网关列表有配置认证的情况,查询认证(key),所以要适当调整接口。满足查询诉求。

          三、功能实现

          1. 工程结构

          本部分 UI 编码的操作,有视频提供说明,可以使用 AI IDE 工具完成页面开发。这块很希望大家做一个自己的 UI 效果。

          对接的时候,你的核心处理则是在 AI IDE(walicode.xiaofuge.cn、trae.ai 等),将后端 AdminController 加入到对话中(包括接口方法),之后明确描述出让根据接口在现有的 views 下增加一个测试对话的页面,也最好指出 app.js、config.js 让它知道具体干啥。这样也会目标明确,节省 token 消耗。

          1. 功能实现
            2.1 接口配置(config.js)
            // 测试调用网关 LLM 服务
            TEST_CALL_GATEWAY: ${API_BASE_URL}/admin/test_call_gateway

          在 config.js 文件中,增加了 test_call_gateway 测试网关调用接口。

          2.2 接口调用
          $.ajax({
          url: API_ENDPOINTS.TEST_CALL_GATEWAY,
          type: ‘POST’,
          contentType: ‘application/json’,
          data: JSON.stringify(requestBody),
          success: function(response) {
          const cost = Date.now() - startTime;
          $(‘#response-time’).text(cost);
          if (response && response.code === ‘0000’) {
          appendChatMessage(‘user’, message);
          const answer = (response.data && (response.data.answer || response.data.cont
          ? (response.data.answer || response.data.content)
          ‘[未返回 answer/content 字段]’;
          appendChatMessage(‘assistant’, answer);
          saveHistoryItem(gatewayId, message, true);
          showToast(‘调用成功!’);
          } else {
          const errorMsg = response && response.info ? response.info : ‘未知错误’;
          appendChatMessage(‘error’, 调用失败:${errorMsg});
          saveHistoryItem(gatewayId, message, false);
          showToast(调用失败:${errorMsg}, false);
          }
          },
          error: function(xhr, status, error) {
          const cost = Date.now() - startTime;
          $(‘#response-time’).text(cost);
          appendChatMessage(‘error’, 网络请求失败:${error});
          saveHistoryItem(gatewayId, message, false);
          showToast(‘网络请求失败:’ + error, false);
          },
          complete: function() {
          $btn.html(originalHtml).prop(‘disabled’, false);
          }
          });

          前端通过 ajax 调用服务接口。

          2.3 对接页面

          网关测试

          测试配置
          -
                          
                          <div class="mb-3" id="auth-key-container" style="display: none;">
                              <label class="form-label">Auth 校验 Key</label>
                              <select class="form-select" id="test-apiKey">
                                  <option value="">请选择认证 Key...</option>
                              </select>
                              <div class="form-text text-muted small">如网关开启认证,请选择对应的认证 Key 进行测试</div>
                          </div>
          
                          
                          <div class="mb-3">
                              <label class="form-label">超时时间 (ms)</label>
                              <div class="input-group">
                                  <span class="input-group-text bg-light"><i class="bi bi-clock"></i></span>
                                  <input type="number" class="form-control" id="test-timeout" value="3000" min="1000" max="60000" step="500">
                                  <button class="btn btn-outline-secondary" type="button" id="btn-reset-timeout" title="重置为默认值">
                                      <i class="bi bi-arrow-counterclockwise"></i>
                                  </button>
                              </div>
                              <div class="form-text text-muted small">范围: 1000-60000 ms,默认 3000 ms</div>
                          </div>
          
                          
                          <div class="mb-3 form-check form-switch">
                              <input class="form-check-input" type="checkbox" checked id="test-reload">
                              <label class="form-check-label" for="test-reload">协议变更后重新加载 LLM</label>
                              <div class="form-text text-muted small">勾选后,本次调用会重新加载最新协议配置。</div>
                          </div>
          
                          
                          <div class="mb-3">
                              <label class="form-label">请求消息 (Message) <span class="text-danger">*</span></label>
                              <textarea class="form-control" id="test-message" rows="6" required placeholder="请输入要发送的对话内容,例如:\n帮我总结一下今天的待办事项"></textarea>
                          </div>
          
                          
                          <div class="d-grid gap-2">
                              <button type="submit" class="btn btn-primary d-flex align-items-center justify-content-center gap-2" id="btn-send-test">
                                  <i class="bi bi-send"></i> 发送测试请求
                              </button>
                          </div>
                      </form>
                  </div>
              </div>
          </div>
          
          
          <div class="col-lg-8 mb-4">
              <div class="card h-100">
                  <div class="card-header bg-light d-flex justify-content-between align-items-center">
                      <h5 class="mb-0"><i class="bi bi-chat-dots me-2"></i>对话结果</h5>
                      <div class="d-flex align-items-center gap-2">
                          <span class="text-muted small">响应耗时: <span id="response-time">-</span> ms</span>
                          <button type="button" class="btn btn-sm btn-outline-secondary" id="btn-clear-result" title="清空结果">
                              <i class="bi bi-trash"></i>
                          </button>
                      </div>
                  </div>
                  <div class="card-body" style="background-color: #f8fafc;">
                      <div id="chat-window" style="max-height: 520px; overflow-y: auto;">
                          <div class="text-center text-muted py-5" id="chat-empty-placeholder">
                              <i class="bi bi-chat-square-text fs-3 d-block mb-2"></i>
                              暂无对话记录,请配置参数后发送测试请求
                          </div>
                      </div>
                  </div>
              </div>
          </div>
          

          这部分就是纯 HTML 对接了,由 AI IDE 工具(walicode)开发。如果你感觉用 AI IDE 还不熟练,那么也可以趁着这次玩起来,用着用着就会了。

          四、测试验证

          1. 注意事项
            1.1 工程信息

          先启动 ai-mcp-gateway-demo-mcp-server-test - 确保启动正常,让它提供接口能力。

          访问 http://localhost:8701/swagger-ui/index.html - 如果访问不了,可以更换为具体IP,以及检查端口是否为8701

          访问 http://localhost:8701/v3/api-docs - 也可以2里面的页面蓝色字**/v3/api-docs**,点击进来。进来后保存 json 文件。

          2~3 步骤,为的是如果你之前没操作过,可以处理后配置网关服务接口。

          1.2 配置信息
          application-dev.yml

          spring:

          spring ai

          ai:
          openai:
          base-url: https://apis.itedus.cn
          api-key: sk-qqXMW8Td4ckEYfnPBe77C7B75d90485984100c**可以联系小傅哥获取申请渠道
          chat:
          options:
          model: gpt-4o

          配置 api_key 使用模型。

          1. 服务测试
            2.1 启动网关
            . ____ _ __ _ _
            /\ / _ __ _ ()_ __ __ _ \ \ \
            ( ( )_
            _ | ‘_ | ‘| | ‘ / ` | \ \ \
            \/ _)| |)| | | | | || (_| | ) ) ) )
            ‘ |
            | .|| ||| |_, | / / / /
            =========|
            |==============|_/=//_//

          :: Spring Boot :: (v3.4.3)

          26-04-14.08:15:30.703 [main ] INFO Application - Starting Application using Java 17.0.15 with PID 82747 (/Users/fuzhengwei/coding/gitcode/KnowledgePlanet/ai-mcp-gateway/ai-mcp-gateway/ai-mcp-gateway-app/target/classes started by fuzhengwei in /Users/fuzhengwei/coding/gitcode/KnowledgePlanet/ai-mcp-gateway/ai-mcp-gateway)

          2.2 访问界面
          ai-mcp-gateway-3-21-03.png

          获取公司雇员信息,信息如下;
          城市;北京
          公司;谷歌
          雇员;小傅哥

          提问内容也可以问你具备什么能力等,通过这样的方式,我们可以很方便的验证一个网关是否可用。

          《AI MCP Gateway》第3-22节:streamable-http-api,测试验证案例

          一、本章诉求
          为了让大家学习的时候,更好的理解 streamable http 通信方式,这一节我们先来做一个固定的请求和应答结果的案例。之后后续的章节,在陆续完成 streamable http 接口与 case 编排层、domain 领域层的对接。

          在前面章节已经用 sse 方式串联了整个流程,那么目前章节核心的实现,其实就是把 streamable http 引入进来,之后逐步与底层的 mcp 协议对接即可。像是上传 openapi 协议解析存储,工具调用,结果映射等,这些都是固定的。

          二、流程设计
          如图,streamable http 调用流程(写一个固定编码的案例);

          img_42.png
          首先,此图也就是上一节我们分析 streamable http 协议和调试 spring ai 框架中 streamable http 部分的代码,提炼的流程图。post 简历会话 id,get 创建 sse。之后回到 post 流程,处理接收 mcp 请求,发来的(tools/list、tools/call)请求。

          之后,就是 get、post 请求的执行流程,对于 post 请求的 method 方法处理,本节案例写了一个固定代码,暂时先不和 mcp 逻辑代码对接。

          三、功能实现

          1. 工程结构
            本章节,就这一个代码类,McpStreamableController 提供一套模拟但真实的接口结构,可被对接测试。主要目的就是通过最简单的方式,让大家先把最核心的东西学习到。之后在深入流程细节,把各类节点串联起来。

          学习这部分的时候,可以打开 McpStreamableController 代码,对照来看的学习。也可以和之前的协议分析来对照学习,这样会更加深入。再加上多几次的代码调试,也就逐步理解了。再往后,你甚至可以和之前实现的 mcp 内容进行对接。

          1. 接口说明
            Java
            @RequestMapping(“/mcp”)
            public class McpStreamableController implements IMcpStreamableService {
            Java
            @Override
            @GetMapping(produces = “text/event-stream”)
            public Flux<ServerSentEvent> handleGet(
            @RequestHeader(value = “Mcp-Session-Id”, required = false) String headerSessionId,
            @RequestHeader HttpHeaders headers) {
            Java
            @Override
            @PostMapping(consumes = “application/json”)
            public Mono<ResponseEntity<?>> handlePost(
            @RequestParam(value = “sessionId”, required = false) String paramSessionId,
            @RequestHeader(value = “Mcp-Session-Id”, required = false) String headerSessionId,
            @RequestBody String messageBody,
            @RequestHeader HttpHeaders headers) {
            注意,这里有一个技术点。/mcp 是一个统一的接口路径。之后 handleGet、handlePost 就是同名的不同方法。同一个路径,get、post、delete等按照不同的类型都能访问到。这个也是微信公众要验证签名和发送消息的同类设计。

          2. 请求操作
            3.1 get 请求
            Java
            @Override
            @GetMapping(produces = “text/event-stream”)
            public Flux<ServerSentEvent> handleGet(
            @RequestHeader(value = “Mcp-Session-Id”, required = false) String headerSessionId,
            @RequestHeader HttpHeaders headers) {

            String sessionId = headerSessionId != null ? headerSessionId : UUID.randomUUID().toString();
            Map<String, String> transportContext = extractContext(headers);
            log.info(“MCP SSE 连接建立,分配/使用 sessionId: {}, context: {}”, sessionId, transportContext);
            // 复用已有的 Sink(Streamable 模式下先 POST initialize 时已创建),或创建新的
            Sinks.Many<ServerSentEvent> sink = sessions.computeIfAbsent(sessionId,
            k -> Sinks.many().unicast().onBackpressureBuffer());

            /// 1. 发送 endpoint 事件,告诉客户端后续 POST 的地址
            // 如果为了兼容,可以做如下设计;
            sink.tryEmitNext(ServerSentEvent.builder()
            .id(sessionId)
            .event(“endpoint”)
            .data(“/api-gateway/mcp?sessionId=” + sessionId)
            .build());
            /

            // 2. 返回 Flux 并处理断开连接时的清理
            // 模拟 contextExtractor 将上下文信息写入 Reactor Context,供下游或过滤器使用
            return sink.asFlux()
            .contextWrite(ctx -> ctx.put(“MCP_TRANSPORT_CONTEXT”, transportContext))
            .doOnCancel(() -> {
            log.info(“MCP SSE 连接客户端取消, sessionId: {}”, sessionId);
            sessions.remove(sessionId);
            })
            .doOnTerminate(() -> {
            log.info(“MCP SSE 连接终止, sessionId: {}”, sessionId);
            sessions.remove(sessionId);
            });
            }
            get 请求是第二步,需要接收 Mcp-Session-Id(来自于post的初始化请求创建的),之后构建 sse 服务,监听着来不断地推送消息。

          3.2 post 请求
          Java
          @Override
          @PostMapping(consumes = “application/json”)
          public Mono<ResponseEntity<?>> handlePost(
          @RequestParam(value = “sessionId”, required = false) String paramSessionId,
          @RequestHeader(value = “Mcp-Session-Id”, required = false) String headerSessionId,
          @RequestBody String messageBody,
          @RequestHeader HttpHeaders headers) {

          String sessionId = paramSessionId != null ? paramSessionId : headerSessionId;
          Map<String, String> transportContext = extractContext(headers);
          log.info("MCP 收到消息,sessionId: {}, context: {}, message: {}", sessionId, transportContext, messageBody);
          try {
              JSONObject jsonRpcRequest = JSON.parseObject(messageBody);
              Object id = jsonRpcRequest.get("id");
              String method = jsonRpcRequest.getString("method");
              // 对于 initialize 请求,可能还没有分配好双向绑定的 sessionId(或者此时客户端还没有将 sessionId 带过来)
              // 在 Spring AI MCP 的标准实现中,如果它是 initialize,我们会为它建立双向会话并分配新的 session
              if ("initialize".equals(method)) {
                  if (sessionId == null) {
                      // 如果客户端此时没有 sessionId,我们在 initialize 阶段为其补发或者依赖之前建立好的
                      // (如果客户端严格按照 SSE endpoint 的参数,通常会带上,但为了容错可以这里补充分配)
                      sessionId = UUID.randomUUID().toString();
                  }
                  
                  // 确保 session 池中有该 session 的 Sink(兼容客户端先 POST initialize 的场景)
                  if (!sessions.containsKey(sessionId)) {
                       Sinks.Many<ServerSentEvent<String>> sink = Sinks.many().unicast().onBackpressureBuffer();
                       sessions.put(sessionId, sink);
                  }
                  JSONObject response = new JSONObject();
                  response.put("jsonrpc", "2.0");
                  if (id != null) {
                      response.put("id", id);
                  }
                  JSONObject result = new JSONObject();
                  result.put("protocolVersion", "2024-11-05");
                  
                  JSONObject capabilities = new JSONObject();
                  capabilities.put("tools", new JSONObject());
                  result.put("capabilities", capabilities);
                  JSONObject serverInfo = new JSONObject();
                  serverInfo.put("name", "MCP-Server");
                  serverInfo.put("version", "1.0.0");
                  result.put("serverInfo", serverInfo);
                  response.put("result", result);
                  // 在 initialize 返回时,必须在 Header 中返回 Mcp-Session-Id,告诉客户端分配好的 session
                  return Mono.<ResponseEntity<?>>just(ResponseEntity.ok()
                          .header("Mcp-Session-Id", sessionId)
                          .body(response.toJSONString()))
                          .contextWrite(ctx -> ctx.put("MCP_TRANSPORT_CONTEXT", transportContext));
              }
              // 对于非 initialize 的请求,必须校验 sessionId 是否合法且活跃
              if (sessionId == null || !sessions.containsKey(sessionId)) {
                  log.warn("无效的或已断开的 sessionId: {}", sessionId);
                  return Mono.just(ResponseEntity.badRequest().body("{\"error\": \"Invalid or missing sessionId\"}"));
              }
              Sinks.Many<ServerSentEvent<String>> sink = sessions.get(sessionId);
              JSONObject response = new JSONObject();
              response.put("jsonrpc", "2.0");
              if (id != null) {
                  response.put("id", id);
              }
              
              if ("ping".equals(method)) {
                  response.put("result", new JSONObject());
              } else {
                  // 模拟固定返回
                  JSONObject result = new JSONObject();
                  
                  if ("tools/list".equals(method)) {
                      JSONObject toolsResponse = new JSONObject();
                      com.alibaba.fastjson.JSONArray toolsArray = new com.alibaba.fastjson.JSONArray();
                      
                      // 构造一个模拟工具,以防客户端因工具列表为空而异常
                      JSONObject dummyTool = new JSONObject();
                      dummyTool.put("name", "dummy_tool");
                      dummyTool.put("description", "这是一个用于测试的模拟工具");
                      JSONObject inputSchema = new JSONObject();
                      inputSchema.put("type", "object");
                      inputSchema.put("properties", new JSONObject());
                      dummyTool.put("inputSchema", inputSchema);
                      toolsArray.add(dummyTool);
                      
                      toolsResponse.put("tools", toolsArray);
                      response.put("result", toolsResponse);
                  } else if ("tools/call".equals(method)) {
                      JSONObject toolCallResponse = new JSONObject();
                      com.alibaba.fastjson.JSONArray content = new com.alibaba.fastjson.JSONArray();
                      JSONObject contentItem = new JSONObject();
                      contentItem.put("type", "text");
                      contentItem.put("text", "这是一个固定的工具调用结果数据");
                      content.add(contentItem);
                      toolCallResponse.put("content", content);
                      response.put("result", toolCallResponse);
                  } else {
                      result.put("data", "这是一个固定的结果数据");
                      result.put("method", method);
                      response.put("result", result);
                  }
              }
              // 对于非 initialize 请求,通过 SSE 连接推送 JSON-RPC 响应
              // 同样需要指定事件 id (通常为 sessionId 或 messageId)
              sink.tryEmitNext(ServerSentEvent.<String>builder()
                      .id(sessionId)
                      .event("message")
                      .data(response.toJSONString())
                      .build());
              // POST 接口本身只返回 HTTP 202 Accepted
              return Mono.<ResponseEntity<?>>just(ResponseEntity.accepted().build())
                      .contextWrite(ctx -> ctx.put("MCP_TRANSPORT_CONTEXT", transportContext));
          } catch (Exception e) {
              log.error("处理 MCP 消息失败", e);
              return Mono.just(ResponseEntity.badRequest().body("{\"error\": \"Invalid request\"}"));
          }
          

          }
          post 请求这个里主要干3个事;initialize - 初始化创建 SessionId、tools/list - 获取工具列表、tools/call - 完成工具调用。

          只不过你目前到的是纯拼接的 json-rpc2,但应该也会很熟悉。因为咱们前面已经深入的折腾过这些了。后续就是在逐步的正式的对接到现有的 mcp 代码逻辑实现上。

          3.3 delete 请求
          Java
          @Override
          @DeleteMapping
          public Mono<ResponseEntity> handleDelete(
          @RequestParam(value = “sessionId”, required = false) String sessionId,
          @RequestHeader HttpHeaders headers) {

          Map<String, String> transportContext = extractContext(headers);
          
          if (sessionId != null && sessions.containsKey(sessionId)) {
              log.info("MCP 收到关闭请求,sessionId: {}, context: {}", sessionId, transportContext);
              Sinks.Many<ServerSentEvent<String>> sink = sessions.get(sessionId);
              // 结束事件流
              sink.tryEmitComplete();
              sessions.remove(sessionId);
              return Mono.just(ResponseEntity.ok().<Void>build())
                      .contextWrite(ctx -> ctx.put("MCP_TRANSPORT_CONTEXT", transportContext));
          }
          return Mono.just(ResponseEntity.notFound().build());
          

          }
          delete 是一个删除操作,暂时不是主要事项。

          四、测试验证

          1. 前置说明
            ai-mcp-gateway 是网关层,需要先启动。可以打好断点,方便调试。地址;http://localhost:8777/api-gateway/mcp

          准备好测试的 LLM baseURL、APIKey

          1. 测试代码
            Java
            @Slf4j
            public class GatewayStreamableApiTest {

            public static void main(String[] args) throws Exception {
            OpenAiApi openAiApi = OpenAiApi.builder()
            .baseUrl(“https://apis.itedus.cn“)
            .apiKey(“sk-SAHbdPIoA9acVNCHA64a00C17c0549BbB3**** *可以联系小傅哥申请”)
            .completionsPath(“v1/chat/completions”)
            .embeddingsPath(“v1/embeddings”)
            .build();

             ChatModel chatModel = OpenAiChatModel.builder()
                     .openAiApi(openAiApi)
                     .defaultOptions(OpenAiChatOptions.builder()
                             .model("gpt-4.1")
                             .toolCallbacks(new SyncMcpToolCallbackProvider(sseMcpClient_gateway()).getToolCallbacks())
                             .build())
                     .build();
            
             System.out.println("====== 测试01 ====== \r\n");
            
             log.info("测试结果:{}", chatModel.call("""
                     你具备什么工具能力
                     """));
            
             System.out.println("\r\n====== 测试02 ====== \r\n");
            
             log.info("测试结果:{}", chatModel.call("""
                     模拟调用工具 dummy_tool
                     """));
            

            }

            public static McpSyncClient sseMcpClient_gateway() {
            McpClientTransport mcpClientTransport = HttpClientStreamableHttpTransport
            .builder(“http://localhost:8777“)
            .endpoint(“/api-gateway/mcp”)
            .build();

             McpSyncClient mcpSyncClient = McpClient.sync(mcpClientTransport).requestTimeout(Duration.ofMinutes(360)).build();
             var init_sse = mcpSyncClient.initialize();
             log.info("Tool SSE MCP Initialized {}", init_sse);
            
             return mcpSyncClient;
            

            }

          }
          Java
          21:10:40.471 [HttpClient-3-Worker-0] INFO io.modelcontextprotocol.client.LifecycleInitializer – Server response with Protocol: 2024-11-05, Capabilities: ServerCapabilities[completions=null, experimental=null, logging=null, prompts=null, resources=null, tools=ToolCapabilities[listChanged=null]], Info: Implementation[name=MCP-Server, title=null, version=1.0.0] and Instructions null
          21:10:40.484 [main] INFO cn.bugstack.ai.mcp.server.test.gateway.GatewayStreamableApiTest – Tool SSE MCP Initialized InitializeResult[protocolVersion=2024-11-05, capabilities=ServerCapabilities[completions=null, experimental=null, logging=null, prompts=null, resources=null, tools=ToolCapabilities[listChanged=null]], serverInfo=Implementation[name=MCP-Server, title=null, version=1.0.0], instructions=null, meta=null]
          21:10:40.485 [HttpClient-3-Worker-0] ERROR io.modelcontextprotocol.spec.McpClientSession – Discarded MCP request response without session id. This is an indication of a bug in the request sender code that can lead to memory leaks as pending requests will never be completed.
          ====== 测试01 ======

          21:10:45.478 [main] INFO cn.bugstack.ai.mcp.server.test.gateway.GatewayStreamableApiTest – 测试结果:我具备以下工具能力:

          1. 信息查询与检索 :可以帮助你查找和检索各种类型的信息,包括百科知识、新闻、科技、历史、文化等。

          2. 分析与总结 :能够对文章、数据或资料进行阅读理解、提炼要点,并进行总结、归纳和分析。

          3. 多语种翻译 :支持多种语言间的翻译,包括中英文相互翻译及其他语种。

          4. 写作与文本生成 :可帮助撰写文章、作文、邮件、公文、策划案等各种类型的文本,并可进行润色、改写或扩展。

          5. 对话与问答 :可以与用户进行流畅的自然语言对话,回答各种问题、提供建议或方案。

          6. 推理与计算 :支持一定程度的逻辑推理、数学计算、单位换算等。

          7. 代码编写与调试 :能帮助你生成和理解编程代码(如Python、JavaScript等),并提供调试建议。

          8. 任务自动化与流程处理(工具调用) :通过内置和第三方API(如functions.dummy_tool等)实现简单的工具调用和流程自动化处理(比如批量操作、信息同步等)。

          如有具体需求,可以详细告诉我,我会根据你的场景选择并调用合适的工具来帮助你。

          ====== 测试02 ======

          21:10:49.705 [main] INFO cn.bugstack.ai.mcp.server.test.gateway.GatewayStreamableApiTest – 测试结果:已完成模拟调用工具 dummy_tool,返回结果为:这是一个固定的工具调用结果数据。请告知你需要进一步如何操作或者需要什么帮助。
          如上,测试调用结果。可以调用我们硬编码的工具以及调用工具返回的结果。

          《AI MCP Gateway》第3-23节:调整case层结构设计,处理不同方式的mcp实现

          一、本章诉求
          调整 case 层的结构,使其可以扩展 streamable、sse 多种不同协议方式处理 mcp 服务。

          无论是”古法编程“、还是使用 AI 写代码,如果不做结构边界的限制,那么人和 AI 都可能有随机发挥的问题。这些限制的动作就是架构的设计和编码规范,在 AI 里被定义为 Spec 规约,在”古法编程“定义为架构设计和代码评审。

          所以,这一节调整 case 的结构,以适应不同协议的处理方式也是非常重要的。如果什么都不做,就只是内部逻辑做 if···else 判断,那么代码就会趋向于增熵,而做设计和规约,就是减增熵的动作。

          温馨提示,结构化设计思维很重要,就像买房子,你要先选那些不错的格局的,这样以后生活起来,东西放的才会放的更整洁舒适。

          二、功能设计
          如图,case层、sse、streamable,两种设计;

          img_43.png

          首先,在 mcp 服务的 case 编排下,有2个接口类;IMcpSessionService、IMcpMessageService,原本只有一套对应的实现,处理 sse 服务。在结构分层的包下,没有额外再做什么区分。

          之后,这一节的操作,主要是调整 case 下的包的分层结构,增加 sse、streamable,两套文件夹结构,这样会更好的区分出是做什么协议处理。另外一种设计方式是在原有的 sse 实现下,增加 node 的处理节点,通过分发节点来处理。这有点,你和你媳妇的衣柜,是一个衣柜里分下格子用,还是一人一个衣柜的感觉。两个方式也都能实现,也都能说得通。一人一个,可能会有重复的地方。都放在一块,又怕时间久了混乱了。搜易看你具体的取舍。

          三、功能实现

          1. 工程结构

          一套接口 IMcpSessionService、IMcpMessageService,按照不同的协议场景,增加多种策略实现方式。这样可以更好的区分出 sse、streamable 两种方式,如果将来还有其他协议类型加入,也可以这样方式扩展。

          sse、streamable,单独分包看着一些节点会有重复,但这样的操作是很利于后期的维护的,也能非常方便的根据包名直接区域业务场景。这样的设计在复杂的大型项目中是非常多的。

          1. 接口调整
            2.1 会话接口(IMcpSessionService)
            public interface IMcpSessionService {

            /**

            • 创建 MCP 会话服务
            • @param gatewayId 网关ID(后续还要扩展 apiKey 验证字段)
            • @return 流式响应
              */
              Flux<ServerSentEvent> createMcpSession(String gatewayId, String apiKey) throws Exception;

            /**

            • 获取 MCP 会话服务
            • @param sessionId 会话ID
            • @return 流式响应
              */
              Flux<ServerSentEvent> getMcpSession(String gatewayId, String apiKey, String sessionId) throws Exception;

          }

          这里要新增加一个 getMcpSession 的接口,Streamable 的请求方式在get请求的时候,需要主动获取 Session 会话。

          2.2 消息接口(IMcpMessageService)
          public interface IMcpMessageService {

          ResponseEntity<Void> handleMessage(HandleMessageCommandEntity commandEntity) throws Exception;
          

          }

          这个接口,保持现有的内容就可以了。处理消息中,会有在初始化过程中,创建 Session 的行为,这部分内容放到 Node 节点里编排实现。

          1. 新增节点

          在 streamable 包下,按照 sse 的结构,新增加了一套代码模板,后续在逐步实现具体功能。

          这部分内容,直接看工程代码就可以,暂时还没有做什么逻辑。也可以先尝试在里面编写代码,后续小傅哥也会带着大家完成。

          注意,其他基础小的调整,直接看代码就可以了。

          《AI-MCP-Gateway》第3-24节:通过case和domain,串联出Streamable协议

          今天是我们《AI-MCP-Gateway》项目学习的第3-24节课程。在上一节中,我们调整了case层结构设计,处理不同方式的MCP实现。这一节,我们将通过case和domain层的串联,完成Streamable协议的实现。

          一、本章诉求
          在前面的章节中,我们已经实现了SSE协议的完整功能,但是在实际使用中,我们还需要支持Streamable HTTP协议,这是MCP协议的另一种传输方式。Streamable协议与SSE协议有一些不同之处,主要体现在:

          会话创建方式:Streamable协议通过POST initialize请求创建会话,而不是通过GET请求

          会话传递方式:Streamable协议通过响应头Mcp-Session-Id返回会话ID

          会话监听方式:Streamable协议的GET请求只负责监听已有会话,不创建新会话

          端点事件:Streamable协议不发送endpoint事件,避免破坏协议语义

          二、协议说明
          如图,case 层编排 sse、streamable 两种协议方式的处理;

          img_44.png
          首先,在上一节拆分的基础上,把 sse 的实现迁移过来,之后做兼容设计。包括;IMcpMessageService 需要添加泛型,因为 sse、streamable 返回的类型不一样(可以看代码),之后是执行步骤问题,sse、streamable 创建和使用 sessionId 的地方不一样,streamable 的会话只是获取 sessionId、消息处理中要单独拿出来 InitializeNode 分支来完成会话的创建和初消息的处理。

          之后,对于领域层的会话管理服务 SessionManagementService,还有一小部分的兼容动作。createSession 方法,之前在 sse 下,会直接创建 messageEndpoint,现在需要通过新增加枚举 SessionTransportTypeEnumVO 来区分类型,之后在创建。

          设计,现在你可以感受到 DDD 架构设计的魅力没。对于一个新逻辑的引入,不会废弃到 domain 核心领域(也就是你的积木),之后在 case 进行新的逻辑编排(搭积木)。最后提供对应的接口服务即可。

          三、服务开发

          1. 工程结构
            首先,让我们看一下本节新增和修改的主要文件:

          纯文本
          ai-mcp-gateway/
          ├── ai-mcp-gateway-api/
          │ └── src/main/java/cn/bugstack/ai/api/
          │ └── IMcpStreamableService.java # 修改:API接口调整
          ├── ai-mcp-gateway-case/
          │ └── src/main/java/cn/bugstack/ai/cases/mcp/
          │ ├── IMcpMessageService.java # 修改:泛型化接口
          │ ├── IMcpSessionService.java # 修改:新增删除会话方法
          │ ├── sse/
          │ │ ├── message/McpSSEMessageService.java # 修改:适配泛型接口
          │ │ └── session/McpSSESessionService.java # 修改:适配新接口
          │ └── streamable/
          │ ├── message/
          │ │ ├── AbstractMcpStreamableMessageServiceSupport.java # 修改:适配泛型
          │ │ ├── McpStreamableMessageService.java # 修改:完整实现
          │ │ ├── factory/DefaultMcpStreamableMessageFactory.java # 修改:Service化
          │ │ └── node/
          │ │ ├── RootNode.java # 修改:增加initialize路由
          │ │ ├── SessionNode.java # 修改:完整实现会话验证
          │ │ ├── InitializeNode.java # 新增:initialize处理节点
          │ │ └── MessageHandlerNode.java # 新增:消息处理节点
          │ └── session/
          │ ├── McpStreamableSessionService.java # 修改:完整实现
          │ ├── factory/DefaultMcpStreamableSessionFactory.java # 修改:Service化
          │ └── node/
          │ ├── EndNode.java # 修改:完整实现监听逻辑
          │ ├── StreamableSessionNode.java # 修改:完整实现会话获取
          │ └── VerifyNode.java # 修改:增加鉴权逻辑
          ├── ai-mcp-gateway-domain/
          │ └── src/main/java/cn/bugstack/ai/domain/session/
          │ ├── service/ISessionManagementService.java # 修改:新增传输类型参数
          │ ├── service/management/SessionManagementService.java # 修改:支持不同传输类型
          │ └── model/valobj/enums/
          │ └── SessionTransportTypeEnumVO.java # 新增:传输类型枚举
          └── ai-mcp-gateway-trigger/
          └── src/main/java/cn/bugstack/ai/trigger/http/
          ├── McpGatewayController.java # 修改:适配泛型接口
          └── McpStreamableController.java # 修改:下沉逻辑到case层
          2. 功能实现
          2.1 定义传输类型枚举
          首先,我们在domain层定义一个传输类型枚举,用于区分SSE和Streamable协议:

          Java
          package cn.bugstack.ai.domain.session.model.valobj.enums;

          import lombok.AllArgsConstructor;
          import lombok.Getter;

          /**

          • 会话传输协议类型

          • @author xiaofuge bugstack.cn @小傅哥

          • 2026/5/25 08:00
            */
            @Getter
            @AllArgsConstructor
            public enum SessionTransportTypeEnumVO {

            SSE(“sse”, “SSE 传输协议”),
            STREAMABLE(“streamable”, “Streamable HTTP 传输协议”),

            ;

            private final String code;
            private final String info;

          }
          2.2 扩展会话管理服务
          接下来,我们扩展domain层的会话管理服务,支持按传输类型创建会话:

          Java
          public interface ISessionManagementService {

          /**
           * 创建回话,默认使用 SSE 传输协议,保持原有 SSE 逻辑兼容
           * @return 会话配置
           */
          SessionConfigVO createSession(String gatewayId, String apiKey);
          
          /**
           * 创建回话,按传输协议类型做兼容处理
           * @return 会话配置
           */
          SessionConfigVO createSession(String gatewayId, String apiKey, SessionTransportTypeEnumVO transportType);
          
          // ... 其他方法
          

          }
          实现类中,我们根据传输类型决定是否发送endpoint事件:

          Java
          @Override
          public SessionConfigVO createSession(String gatewayId, String apiKey, SessionTransportTypeEnumVO transportType) {
          SessionTransportTypeEnumVO sessionTransportType = transportType == null ? SessionTransportTypeEnumVO.SSE : transportType;
          log.info(“创建会话 gatewayId:{} transportType:{}”, gatewayId, sessionTransportType.getCode());

          String sessionId = UUID.randomUUID().toString();
          
          Sinks.Many<ServerSentEvent<String>> sink = Sinks.many().multicast().onBackpressureBuffer();
          
          // SSE 协议需要发送 endpoint 事件,Streamable HTTP 协议通过 Mcp-Session-Id 响应头返回会话,不发送 endpoint,避免破坏协议语义。
          if (SessionTransportTypeEnumVO.SSE.equals(sessionTransportType)) {
              String messageEndpoint = "/api-gateway/" + gatewayId + "/mcp/sse?sessionId=" + sessionId;
              if (StringUtils.isNoneBlank(apiKey)) {
                  messageEndpoint += "&api_key=" + apiKey;
              }
          
              sink.tryEmitNext(ServerSentEvent.<String>builder()
                      .event("endpoint")
                      .data(messageEndpoint)
                      .build());
          }
          
          SessionConfigVO sessionConfigVO = new SessionConfigVO(sessionId, sink);
          
          activeSessions.put(sessionId, sessionConfigVO);
          
          log.info("创建会话 gatewayId:{} sessionId:{} transportType:{},当前活跃会话数:{}", gatewayId, sessionId, sessionTransportType.getCode(), activeSessions.size());
          
          return sessionConfigVO;
          

          }
          2.3 泛型化消息服务接口
          为了支持不同协议返回不同类型的响应,我们将消息服务接口泛型化:

          Java
          public interface IMcpMessageService {

          ResponseEntity<T> handleMessage(HandleMessageCommandEntity commandEntity) throws Exception;
          

          }
          SSE协议的实现类保持返回Void类型:

          Java
          @Slf4j
          @Service(“mcpSSEMessageService”)
          public class McpSSEMessageService implements IMcpMessageService {

          @Resource
          private DefaultMcpMessageFactory defaultMcpMessageFactory;
          
          // ... 实现代码
          

          }
          Streamable协议的实现类返回String类型:

          Java
          @Slf4j
          @Service(“mcpStreamableMessageService”)
          public class McpStreamableMessageService implements IMcpMessageService {

          @Resource
          private DefaultMcpStreamableMessageFactory defaultMcpStreamableMessageFactory;
          
          @Override
          public ResponseEntity<String> handleMessage(HandleMessageCommandEntity commandEntity) throws Exception {
              StrategyHandler<HandleMessageCommandEntity, DefaultMcpStreamableMessageFactory.DynamicContext, ResponseEntity<?>> strategyHandler =
                      defaultMcpStreamableMessageFactory.strategyHandler();
          
              ResponseEntity<?> responseEntity = strategyHandler.apply(commandEntity, new DefaultMcpStreamableMessageFactory.DynamicContext());
          
              return ResponseEntity.status(responseEntity.getStatusCodeValue())
                      .headers(responseEntity.getHeaders())
                      .body(responseEntity.getBody() == null ? null : responseEntity.getBody().toString());
          }
          

          }
          2.4 实现Initialize节点
          现在,我们来实现Streamable协议中最重要的initialize消息处理节点:

          Java
          @Slf4j
          @Service(“mcpStreamableMessageInitializeNode”)
          public class InitializeNode extends AbstractMcpStreamableMessageServiceSupport {

          @Resource
          private IAuthLicenseService authLicenseService;
          
          @Autowired
          private ObjectMapper objectMapper;
          
          @Override
          protected ResponseEntity<?> doApply(HandleMessageCommandEntity requestParameter, DefaultMcpStreamableMessageFactory.DynamicContext dynamicContext) throws Exception {
              log.info("Streamable 消息处理 InitializeNode:{}", requestParameter);
          
              boolean isCheckSuccess = authLicenseService.checkLicense(new LicenseCommandEntity(requestParameter.getGatewayId(), requestParameter.getApiKey()));
              if (!isCheckSuccess) {
                  throw new AppException(McpErrorCodes.INSUFFICIENT_PERMISSIONS, "fail to auth apikey");
              }
          
              SessionConfigVO sessionConfigVO = sessionManagementService.createSession(
                      requestParameter.getGatewayId(),
                      requestParameter.getApiKey(),
                      SessionTransportTypeEnumVO.STREAMABLE);
              dynamicContext.setSessionConfigVO(sessionConfigVO);
          
              McpSchemaVO.JSONRPCResponse jsonrpcResponse = serviceMessageService.processHandlerMessage(requestParameter);
              String responseJson = objectMapper.writeValueAsString(jsonrpcResponse);
          
              return ResponseEntity.ok()
                      .contentType(MediaType.APPLICATION_JSON)
                      .header("Mcp-Session-Id", sessionConfigVO.getSessionId())
                      .body(responseJson);
          }
          
          @Override
          public StrategyHandler<HandleMessageCommandEntity, DefaultMcpStreamableMessageFactory.DynamicContext, ResponseEntity<?>> get(HandleMessageCommandEntity requestParameter, DefaultMcpStreamableMessageFactory.DynamicContext dynamicContext) throws Exception {
              return defaultStrategyHandler;
          }
          

          }
          这个节点的主要职责是:

          验证License

          创建Streamable类型的会话

          处理initialize消息

          通过响应头返回会话ID

          2.5 更新Root节点路由逻辑
          我们需要更新Root节点,让它能够根据消息类型路由到不同的处理节点:

          Java
          @Override
          public StrategyHandler<HandleMessageCommandEntity, DefaultMcpStreamableMessageFactory.DynamicContext, ResponseEntity<?>> get(HandleMessageCommandEntity requestParameter, DefaultMcpStreamableMessageFactory.DynamicContext dynamicContext) throws Exception {
          if (requestParameter.getJsonrpcMessage() instanceof McpSchemaVO.JSONRPCRequest request
          && SessionMessageHandlerMethodEnum.INITIALIZE.getMethod().equals(request.method())) {
          return initializeNode;
          }
          return sessionNode;
          }
          2.6 实现Session节点会话验证
          Session节点负责验证会话是否存在:

          Java
          @Override
          protected ResponseEntity<?> doApply(HandleMessageCommandEntity requestParameter, DefaultMcpStreamableMessageFactory.DynamicContext dynamicContext) throws Exception {
          log.info(“Streamable 消息处理 SessionNode:{}”, requestParameter);

          SessionConfigVO sessionConfigVO = sessionManagementService.getSession(requestParameter.getSessionId());
          if (null == sessionConfigVO) {
              log.warn("Streamable 会话不存在或已过期,gatewayId:{} sessionId:{}", requestParameter.getGatewayId(), requestParameter.getSessionId());
              return ResponseEntity.notFound().build();
          }
          
          dynamicContext.setSessionConfigVO(sessionConfigVO);
          
          return router(requestParameter, dynamicContext);
          

          }
          2.7 实现Streamable会话服务
          接下来,我们完善Streamable会话服务的实现:

          Java
          @Slf4j
          @Service(“mcpStreamableSessionService”)
          public class McpStreamableSessionService implements IMcpSessionService {

          @Resource
          private DefaultMcpStreamableSessionFactory defaultMcpStreamableSessionFactory;
          
          @Resource
          private ISessionManagementService sessionManagementService;
          
          /**
           * Streamable HTTP 会话由 POST initialize 创建,GET 只负责监听已有会话。
           */
          @Override
          public Flux<ServerSentEvent<String>> createMcpSession(String gatewayId, String apiKey) throws Exception {
              throw new AppException(METHOD_NOT_FOUND.getCode(), METHOD_NOT_FOUND.getInfo());
          }
          
          @Override
          public Flux<ServerSentEvent<String>> getMcpSession(String gatewayId, String apiKey, String sessionId) throws Exception {
              StrategyHandler<String, DefaultMcpStreamableSessionFactory.DynamicContext, Flux<ServerSentEvent<String>>> strategyHandler =
                      defaultMcpStreamableSessionFactory.strategyHandler();
          
              DefaultMcpStreamableSessionFactory.DynamicContext dynamicContext = new DefaultMcpStreamableSessionFactory.DynamicContext();
              dynamicContext.setGatewayId(gatewayId);
              dynamicContext.setApiKey(apiKey);
          
              return strategyHandler.apply(sessionId, dynamicContext);
          }
          
          public void deleteMcpSession(String sessionId) {
              sessionManagementService.removeSession(sessionId);
          }
          

          }
          2.8 实现End节点监听逻辑
          End节点负责返回SSE流,并添加ping保活:

          Java
          @Override
          protected Flux<ServerSentEvent> doApply(String requestParameter, DefaultMcpStreamableSessionFactory.DynamicContext dynamicContext) throws Exception {
          log.info(“获取 Streamable 会话-EndNode gatewayId:{} sessionId:{}”, dynamicContext.getGatewayId(), requestParameter);

          SessionConfigVO sessionConfigVO = dynamicContext.getSessionConfigVO();
          String sessionId = sessionConfigVO.getSessionId();
          
          return sessionConfigVO.getSink().asFlux()
                  .mergeWith(Flux.interval(Duration.ofSeconds(60))
                          .map(i -> ServerSentEvent.<String>builder()
                                  .event("ping")
                                  .data("ping")
                                  .build()))
                  .doOnCancel(() -> log.info("Streamable SSE 监听取消,会话ID: {}", sessionId))
                  .doOnTerminate(() -> log.info("Streamable SSE 监听终止,会话ID: {}", sessionId));
          

          }
          2.9 重构Trigger层控制器
          最后,我们重构Trigger层的控制器,将业务逻辑下沉到case层:

          Java
          @Slf4j
          @RestController
          @CrossOrigin(origins = ““, allowedHeaders = ““, methods = {RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE, RequestMethod.OPTIONS})
          @RequestMapping(“/{gatewayId}/mcp”)
          public class McpStreamableController implements IMcpStreamableService {

          @Resource(name = "mcpStreamableSessionService")
          private IMcpSessionService mcpStreamableSessionService;
          
          @Resource(name = "mcpStreamableMessageService")
          private IMcpMessageService<String> mcpStreamableMessageService;
          
          @Override
          @GetMapping(produces = MediaType.TEXT_EVENT_STREAM_VALUE)
          public Flux<ServerSentEvent<String>> handleGet(@PathVariable("gatewayId") String gatewayId,
                                                          @RequestParam(value = "api_key", required = false, defaultValue = "") String apiKey,
                                                          @RequestParam(value = "sessionId", required = false) String paramSessionId,
                                                          @RequestHeader(value = "Mcp-Session-Id", required = false) String headerSessionId,
                                                          @RequestHeader HttpHeaders headers) {
              String sessionId = StringUtils.isNotBlank(headerSessionId) ? headerSessionId : paramSessionId;
              Map<String, String> transportContext = extractContext(headers);
              log.info("MCP Streamable GET 监听连接,gatewayId:{} sessionId:{} context:{}", gatewayId, sessionId, transportContext);
          
              if (StringUtils.isBlank(gatewayId) || StringUtils.isBlank(sessionId)) {
                  log.warn("MCP Streamable GET 参数非法,gatewayId:{} sessionId:{}", gatewayId, sessionId);
                  return Flux.just(ServerSentEvent.<String>builder()
                          .id(UUID.randomUUID().toString())
                          .event("error")
                          .data("{\"error\": \"Invalid or missing gatewayId/sessionId\"}")
                          .build());
              }
          
              try {
                  return mcpStreamableSessionService.getMcpSession(gatewayId, apiKey, sessionId)
                          .contextWrite(ctx -> ctx.put("MCP_TRANSPORT_CONTEXT", transportContext));
              } catch (Exception e) {
                  log.error("MCP Streamable GET 监听连接失败,gatewayId:{} sessionId:{}", gatewayId, sessionId, e);
                  return Flux.just(ServerSentEvent.<String>builder()
                          .id(UUID.randomUUID().toString())
                          .event("error")
                          .data(JSON.toJSONString(Map.of("error", e.getMessage())))
                          .build());
              }
          }
          
          @Override
          @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
          public Mono<ResponseEntity<?>> handlePost(@PathVariable("gatewayId") String gatewayId,
                                                     @RequestParam(value = "api_key", required = false, defaultValue = "") String apiKey,
                                                     @RequestParam(value = "sessionId", required = false) String paramSessionId,
                                                     @RequestHeader(value = "Mcp-Session-Id", required = false) String headerSessionId,
                                                     @RequestBody String messageBody,
                                                     @RequestHeader HttpHeaders headers) {
              String sessionId = StringUtils.isNotBlank(headerSessionId) ? headerSessionId : paramSessionId;
              Map<String, String> transportContext = extractContext(headers);
              log.info("MCP Streamable POST 收到消息,gatewayId:{} apiKey:{} sessionId:{} context:{} message:{}", gatewayId, apiKey, sessionId, transportContext, messageBody);
          
              try {
                  if (StringUtils.isBlank(gatewayId) || StringUtils.isBlank(messageBody)) {
                      log.warn("MCP Streamable POST 参数非法,gatewayId:{} messageBody:{}", gatewayId, messageBody);
                      return Mono.just(ResponseEntity.badRequest().body("{\"error\": \"Invalid or missing gatewayId/messageBody\"}"));
                  }
          
                  HandleMessageCommandEntity commandEntity = new HandleMessageCommandEntity(gatewayId, apiKey, sessionId, messageBody);
                  ResponseEntity<?> responseEntity = mcpStreamableMessageService.handleMessage(commandEntity);
          
                  return Mono.<ResponseEntity<?>>just(responseEntity)
                          .contextWrite(ctx -> ctx.put("MCP_TRANSPORT_CONTEXT", transportContext));
              } catch (Exception e) {
                  log.error("MCP Streamable POST 处理消息失败,gatewayId:{} sessionId:{} messageBody:{}", gatewayId, sessionId, messageBody, e);
                  return Mono.just(ResponseEntity.internalServerError().body(JSON.toJSONString(Map.of("error", e.getMessage()))));
              }
          }
          
          @Override
          @DeleteMapping
          public Mono<ResponseEntity<Void>> handleDelete(@PathVariable("gatewayId") String gatewayId,
                                                         @RequestParam(value = "sessionId", required = false) String paramSessionId,
                                                         @RequestHeader(value = "Mcp-Session-Id", required = false) String headerSessionId,
                                                         @RequestHeader HttpHeaders headers) {
              String sessionId = StringUtils.isNotBlank(headerSessionId) ? headerSessionId : paramSessionId;
              Map<String, String> transportContext = extractContext(headers);
              log.info("MCP Streamable DELETE 关闭会话,gatewayId:{} sessionId:{} context:{}", gatewayId, sessionId, transportContext);
          
              if (StringUtils.isBlank(gatewayId) || StringUtils.isBlank(sessionId)) {
                  log.warn("MCP Streamable DELETE 参数非法,gatewayId:{} sessionId:{}", gatewayId, sessionId);
                  return Mono.just(ResponseEntity.badRequest().build());
              }
          
              mcpStreamableSessionService.deleteMcpSession(sessionId);
              return Mono.just(ResponseEntity.ok().<Void>build())
                      .contextWrite(ctx -> ctx.put("MCP_TRANSPORT_CONTEXT", transportContext));
          }
          
          // ... extractContext方法
          

          }
          四、启动测试
          完成上述代码实现后,我们来进行启动测试,验证Streamable协议的功能是否正常工作。

          1. 前置说明
            启动 ai-mcp-gateway-demo-mcp-server-test 作为基础服务接口使用(网关层是调用了这个接口)

          ai-mcp-gateway 是网关层,可以debug调试模式启动。可以打好断点,方便调试。

          准备好测试的 LLM baseURL、APIKey,配置到 ai-mcp-gateway-demo-mcp-server-test 作为单测启动使用。

          协议1;sse;http://localhost:8777/api-gateway/gateway_005/mcp/sse

          协议2;streamable;http://localhost:8777/api-gateway/gateway_005/mcp

          1. 测试结果
            2.1 streamable 测试
            Java
            @Slf4j
            public class GatewayStreamableApiTest {

            public static void main(String[] args) throws Exception {
            OpenAiApi openAiApi = OpenAiApi.builder()
            .baseUrl(“https://apis.itedus.cn“)
            .apiKey(“sk-2kbUfpcleloxmBtpCbC3E59f659249D3****可以联系小傅哥申请”)
            .completionsPath(“v1/chat/completions”)
            .embeddingsPath(“v1/embeddings”)
            .build();

             ChatModel chatModel = OpenAiChatModel.builder()
                     .openAiApi(openAiApi)
                     .defaultOptions(OpenAiChatOptions.builder()
                             .model("gpt-4.1")
                             .toolCallbacks(new SyncMcpToolCallbackProvider(streamableMcpClient_gateway()).getToolCallbacks())
                             .build())
                     .build();
            
             System.out.println("====== 测试01 ====== \r\n");
            
             log.info("测试结果:{}", chatModel.call("""
                     你具备什么工具能力
                     """));
            
             System.out.println("\r\n====== 测试02 ====== \r\n");
            
             log.info("测试结果:{}", chatModel.call("""
                     获取公司雇员信息,信息如下;
                     城市;北京
                     公司;谷歌
                     雇员;小傅哥
                     """));
            

            }

            public static McpSyncClient streamableMcpClient_gateway() {
            McpClientTransport mcpClientTransport = HttpClientStreamableHttpTransport
            .builder(“http://localhost:8777“)
            .endpoint(“/api-gateway/gateway_005/mcp”)
            .build();

             McpSyncClient mcpSyncClient = McpClient.sync(mcpClientTransport).requestTimeout(Duration.ofMinutes(360)).build();
             var init_sse = mcpSyncClient.initialize();
             log.info("Tool SSE MCP Initialized {}", init_sse);
            
             return mcpSyncClient;
            

            }

          }
          TypeScript
          08:04:18.598 [main] INFO cn.bugstack.ai.mcp.server.test.gateway.GatewayStreamableApiTest – Tool SSE MCP Initialized InitializeResult[protocolVersion=2025-06-18, capabilities=ServerCapabilities[completions=CompletionCapabilities[], experimental={}, logging=LoggingCapabilities[], prompts=PromptCapabilities[listChanged=true], resources=ResourceCapabilities[subscribe=false, listChanged=true], tools=ToolCapabilities[listChanged=true]], serverInfo=Implementation[name=课程演示, title=null, version=1.0.0], instructions=课程演示, meta=null]
          ====== 测试01 ======

          08:04:21.500 [main] INFO cn.bugstack.ai.mcp.server.test.gateway.GatewayStreamableApiTest – 测试结果:我具备以下工具能力:

          1. 公司员工信息查询:可以根据指定的城市(拼音)及公司信息,查询公司员工相关信息。例如:你可以告诉我公司名称、类型和所在城市等,我能帮助查询员工列表或相关资料。

          2. 多工具并行调用:我可以同时调用多个功能模块,实现多项任务或多家公司、城市的批量查询,提高效率。

          你可以直接给我具体的任务,比如“查询北京某某公司的员工信息”或“同时查询上海和深圳两家不同公司的员工信息”等。如果有其他需求,也可以继续描述,我会告诉你能否实现!

          ====== 测试02 ======

          08:04:25.491 [main] INFO cn.bugstack.ai.mcp.server.test.gateway.GatewayStreamableApiTest – 测试结果:查询到以下公司雇员信息:

          • 城市:北京
          • 公司:谷歌
          • 雇员姓名:小傅哥
          • 日工时:23小时
          • 薪资:16.92(单位可能为千元/月或其他,未明确)

          如需更详细的雇员信息或其他公司员工信息,请告知!
          streamableMcpClient_gateway 下配置网关请求地址,注意网关请求地址,streamable;http://localhost:8777/api-gateway/gateway_005/mcp

          2.2 sse
          Java
          @Slf4j
          public class GatewaySSEApiTest {

          public static void main(String[] args) throws Exception {
              OpenAiApi openAiApi = OpenAiApi.builder()
                      .baseUrl("https://apis.itedus.cn")
                      .apiKey("sk-2kbUfpcleloxmBtpCbC3E59f659249D39aB1D67414F97324")
                      .completionsPath("v1/chat/completions")
                      .embeddingsPath("v1/embeddings")
                      .build();
          
              ChatModel chatModel = OpenAiChatModel.builder()
                      .openAiApi(openAiApi)
                      .defaultOptions(OpenAiChatOptions.builder()
                              .model("gpt-4o")
                              .toolCallbacks(new SyncMcpToolCallbackProvider(sseMcpClient_gateway()).getToolCallbacks())
                              .build())
                      .build();
          
              log.info("测试结果:{}", chatModel.call("""
                      获取公司雇员信息,信息如下;
                      城市;北京
                      公司;谷歌
                      雇员;小傅哥
                      """));
          }
          
          /**
           * http://localhost:8777/api-gateway/gateway_001/mcp/sse
           *
           * http://localhost:8777/api-gateway/gateway_001/mcp/sse?api_key=gw-lf3HFzlJCdnrYl20oHbd5lJQxE7GWz8wjsSgjDZfctJNV8s5
           */
          public static McpSyncClient sseMcpClient_gateway() {
              HttpClientSseClientTransport sseClientTransport = HttpClientSseClientTransport
                      .builder("http://localhost:8777")
                      .sseEndpoint("/api-gateway/gateway_005/mcp/sse")
                      .build();
          
              McpSyncClient mcpSyncClient = McpClient.sync(sseClientTransport).requestTimeout(Duration.ofMinutes(360)).build();
              var init_sse = mcpSyncClient.initialize();
              log.info("Tool SSE MCP Initialized {}", init_sse);
          
              return mcpSyncClient;
          }
          

          }
          Java
          08:23:26.982 [main] INFO cn.bugstack.ai.mcp.server.test.gateway.GatewaySSEApiTest – 测试结果:已获取到公司雇员信息:

          • 城市:北京
          • 公司:谷歌
          • 雇员:小傅哥
            开发后也验证下之前的 sse 功能是否完整。

          《AI MCP Gateway》第3-25节:验证服务,LLM对接测试Streamable接口

          一、本章诉求
          前面我们已经把 Streamable HTTP 协议的基础能力开发出来了,这一节要做的事情就是把这套能力真正验证起来。

          这一部分主要做三件事:

          验证 Streamable HTTP 服务接口是否可用,包括 GET / POST / DELETE 三个入口。

          验证会话管理语义是否符合 Streamable 协议,重点确认不会再像 SSE 一样发送 endpoint 事件。

          验证 LLM 侧是否可以通过 Streamable HTTP 方式,对接网关并完成工具调用。

          只要这三件事跑通了,就说明我们前面做的协议适配不是“写出来了”,而是“真的能用起来了”。

          二、功能设计
          如图,Streamable HTTP 协议对接 LLM 服务的整体关系;

          img_45.png

          首先,这一部分的重点是把 Streamable HTTP 协议真正接入到网关服务验证链路中。

          之后,需要调整会话管理逻辑,让 Streamable HTTP 走它自己的协议语义,而不是继续沿用 SSE 的 endpoint 下发方式。

          最后,在 LLM 侧增加 Streamable 工具回调策略,让模型也可以通过这个协议去加载 MCP 服务。

          这里要特别注意一点:Streamable HTTP 和 SSE 是两套不同的协议语义。它们虽然都和 MCP 会话相关,但不能直接混用。

          三、功能实现

          1. 工程结构
            本节的实现主要分布在 trigger**、case、**domain 三个层次。之后由 gateway-test.html 通过 app.js 调用接口。

          McpStreamableController 负责 HTTP 入口适配。

          SessionManagementService 负责会话语义管理。

          LLMService 负责把 Streamable HTTP 协议接入到模型工具调用中。

          这一节不是新增一个全新业务,而是在已有架构上,把 Streamable HTTP 的调用链路补完整。让前端可以测试不同的MCP服务协议接口即可。

          1. 功能实现
            2.1 Streamable HTTP 接口验证(回顾)
            2.1.1 控制器入口
            Java
            @Slf4j
            @RestController
            @CrossOrigin(origins = ““, allowedHeaders = ““, methods = {RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE, RequestMethod.OPTIONS})
            @RequestMapping(“/{gatewayId}/mcp”)
            public class McpStreamableController implements IMcpStreamableService {
            这一层的职责很明确:

          GET 用于建立或监听会话;

          POST 用于接收消息;

          DELETE 用于关闭会话。

          控制器内部只做协议适配,不做复杂业务判断,真正的会话和消息逻辑继续交给 Case 层和 Domain 层处理。

          2.1.2 GET:建立会话
          Java
          @GetMapping(produces = MediaType.TEXT_EVENT_STREAM_VALUE)
          public Flux<ServerSentEvent> handleGet(@PathVariable(“gatewayId”) String gatewayId,
          @RequestParam(value = “api_key”, required = false, defaultValue = “”) String apiKey,
          @RequestParam(value = “sessionId”, required = false) String paramSessionId,
          @RequestHeader(value = “Mcp-Session-Id”, required = false) String headerSessionId,
          @RequestHeader HttpHeaders headers)
          这里有几个关键点:

          sessionId 优先从 Mcp-Session-Id 请求头读取;

          如果请求头没有,再回退到 query 参数 sessionId**;**

          从 HttpHeaders 中提取传输上下文,放到 Reactor Context 中;

          参数非法时直接返回 error 事件,方便前端和 LLM 侧识别。

          这一段代码的核心目标,是让 Streamable HTTP 能够建立起一个可持续交互的会话通道。

          2.1.3 POST:处理消息
          Java
          @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
          public Mono<ResponseEntity<?>> handlePost(@PathVariable(“gatewayId”) String gatewayId,
          @RequestParam(value = “api_key”, required = false, defaultValue = “”) String apiKey,
          @RequestParam(value = “sessionId”, required = false) String paramSessionId,
          @RequestHeader(value = “Mcp-Session-Id”, required = false) String headerSessionId,
          @RequestBody String messageBody,
          @RequestHeader HttpHeaders headers)
          POST 接口的作用,是把消息体交给消息处理链路。

          这里的实现方式比较标准:

          先判断 gatewayId 和 messageBody 是否为空;

          再构造 HandleMessageCommandEntity**;**

          然后交给 mcpStreamableMessageService.handleMessage(commandEntity)

          也就是说,消息处理并没有写死在 Controller 中,而是继续保持了层次分离。

          2.1.4 DELETE:关闭会话
          Java
          @DeleteMapping
          public Mono<ResponseEntity> handleDelete(@PathVariable(“gatewayId”) String gatewayId,
          @RequestParam(value = “sessionId”, required = false) String paramSessionId,
          @RequestHeader(value = “Mcp-Session-Id”, required = false) String headerSessionId,
          @RequestHeader HttpHeaders headers)
          DELETE 接口负责关闭会话。

          这一段逻辑同样很简单:

          获取 sessionId;

          校验 gatewayId 和 sessionId;

          调用 mcpStreamableSessionService.deleteMcpSession(sessionId)

          返回 200 OK**。**

          到这里,Streamable HTTP 的生命周期就完整了:建立、消息、关闭。

          2.2 会话管理调整(兼容)
          这一节另一个关键点,是会话创建逻辑需要配合 Streamable HTTP 的协议语义做调整。

          在 SessionManagementService 中,创建会话时会根据协议类型做不同处理:

          Java
          @Override
          public SessionConfigVO createSession(String gatewayId, String apiKey, SessionTransportTypeEnumVO transportType) {
          SessionTransportTypeEnumVO sessionTransportType = transportType == null ? SessionTransportTypeEnumVO.SSE : transportType;

          if (SessionTransportTypeEnumVO.SSE.equals(sessionTransportType)) {
          String messageEndpoint = “/api-gateway/“ + gatewayId + “/mcp/sse?sessionId=” + sessionId;
          if (StringUtils.isNoneBlank(apiKey)) {
          messageEndpoint += “&api_key=” + apiKey;
          }

              sink.tryEmitNext(ServerSentEvent.<String>builder()
                      .event("endpoint")
                      .data(messageEndpoint)
                      .build());
          }
          

          这里最重要的变化就是:

          SSE 协议继续发送 endpoint 事件;

          Streamable HTTP 协议不再发送 endpoint 事件。

          为什么要这么改?

          因为 Streamable HTTP 的会话语义本身就是基于 Mcp-Session-Id 来维持的,如果还沿用 SSE 那种“先下发 endpoint”的方式,就会让协议语义混乱,后续 LLM 侧接入时也会出现理解偏差。

          所以这一块其实不是单纯的代码改动,而是协议语义的修正。

          2.3 LLM 对接 Streamable HTTP
          接下来我们看 LLMService 的改造。

          2.3.1 增加策略映射
          Java
          public LLMService() {
          strategyMap.put(McpTypeEnumVO.SSE, new SseToolCallbackBuilderStrategy());
          strategyMap.put(McpTypeEnumVO.STREAMABLE, new StreamableToolCallbackBuilderStrategy());
          }
          这段代码的作用很简单,就是把 MCP 类型和具体构建策略对应起来。

          SSE 走 SseToolCallbackBuilderStrategy**;**

          Streamable HTTP 走 StreamableToolCallbackBuilderStrategy**。**

          这也是策略模式在这里的直接体现。

          2.3.2 Streamable 策略实现
          Java
          private static class StreamableToolCallbackBuilderStrategy implements ToolCallbackBuilderStrategy {
          @Override
          public ToolCallback[] build(McpConfigVO mcpConfigVO) {
          McpClientTransport mcpClientTransport = HttpClientStreamableHttpTransport
          .builder(mcpConfigVO.getBaseUri())
          .endpoint(mcpConfigVO.getSseEndpoint())
          .build();
          McpSyncClient mcpSyncClient = McpClient.sync(mcpClientTransport)
          .requestTimeout(Duration.ofMillis(mcpConfigVO.getTimeout())).build();
          var init_streamable = mcpSyncClient.initialize();
          log.info(“tool streamable mcp initialize {}”, init_streamable);
          return SyncMcpToolCallbackProvider.builder().mcpClients(mcpSyncClient).build().getToolCallbacks();
          }
          }
          这一段就是关键。

          通过 HttpClientStreamableHttpTransport,我们把 LLM 侧的 MCP 客户端构建方式切到了 Streamable HTTP。

          也就是说:

          网关侧支持 Streamable HTTP;

          LLM 侧也能按 Streamable HTTP 的方式去初始化和注册工具;

          最后通过 toolCallbacks 把工具回调交给模型。

          这一步完成之后,模型就不只是“知道有这个服务”,而是真的可以调用。

          2.4 前端测试页面对接(UI)
          在测试页面里,已经准备好了 Streamable HTTP 的选择项:

          Java


          这意味着在管理后台测试页里,用户可以直接切换 MCP 协议类型,然后对当前网关进行验证。

          页面层面的支持,主要起到两个作用:

          让测试更直观;

          让协议切换更方便。

          对于这类网关型功能,页面测试是很有价值的,因为它能快速暴露协议语义和参数传递上的问题。

          四、测试验证

          1. 注意事项
            先启动 ai-mcp-gateway-demo-mcp-server-test**,确保测试服务可用。**

          访问 http://localhost:8701/swagger-ui/index.html,确认接口文档正常。

          访问 http://localhost:8701/v3/api-docs,必要时保存 JSON,用于后续网关配置。

          确认网关服务、认证 Key、会话参数都配置正确,再进行 LLM 测试。

          1. 服务测试
            2.1 启动网关
            先启动项目,确认服务正常加载。

          Java
          :: Spring Boot :: (v3.4.3)
          26-04-14.08:15:30.703 [main ] INFO Application - Starting Application using Java 17.0.15 with PID 82747
          2.2 访问界面
          ai-mcp-gateway-3-25-03.png
          在页面中选择 Streamable HTTP 类型后,可以直接发起测试请求。

          例如,可以输入一段测试内容(这里帮你做成了快捷案例):

          Java
          获取公司雇员信息,信息如下;
          城市;北京
          公司;谷歌
          雇员;小傅哥
          如果返回结果正常,就说明:

          Streamable 接口可用;

          LLM 对接链路可用;

          测试页面可以完成协议切换。

          这一节做到这里,就已经把 Streamable HTTP 这条链路真正验证起来了。


          https://allendericdalexander.github.io/2026/07/31/paper/mcp_gateway/
          作者
          AtLuoFu
          发布于
          2026年7月31日
          许可协议