MCP实战手记系列(六):自建 MCP 网关(文末附github源码链接) MCP 实战手记系列六· 架构实操系列五把工具结果做成了一块能点的界面。可真到要接的时候问题变了我手上有两个机房跑的是同一套服务工具名一字不差。客户端连上去拿到两个get_room_dashboard模型分不清该开哪一个。这篇做个网关把它们收成一个入口代码在 tagv06。引子两个同名工具把客户端逼到没法配两个机房各跑一份系列五那套服务端口 8085 和 8087。分别tools/list一下返回长得一模一样get_room_dashboard、control_device连描述都一样。客户端要么配两个 endpoint配置散在每台机器、每个人手里要么自己想办法区分。我选第三条路前面挡一层网关8086客户端只认这一个地址。网关给出去的表长这样site-a__control_device site-a__get_room_dashboard site-b__control_device site-b__get_room_dashboard两个get_room_dashboard还在只是各自带上了机房前缀。模型看得懂调用也能落回正确的机房。一、先说什么时候不该上网关不是「有了多个 server 就该上网关」。我的判断很具体后端不超过两个、工具名全局唯一、也不需要统一鉴权和调用审计——这种情况直接配多个 endpoint别上网关。理由有两条都是这次踩出来的。网关自己会变成一个新的单点所有后端都挂在它后面而且它的工具表是启动时拉的快照后面会讲后端变了它不一定知道。为了省两行配置引入这两个麻烦不划算。真正把人逼到上网关的是命名空间撞车。几个团队各写各的 MCP Server工具名叫get_status、query、search的概率高得离谱谁也没错放一起就废了。二、网关干的其实是三件不同的事聚合排第一。启动时拿 MCP client 挨个连后端initialize之后listTools各家的工具拼成一张表。这一步决定了它做不了纯转发——tools/list的响应得合并不是一个请求转给一个人。命名空间是第二件也是这三件里最容易做漏的。给每个后端一个前缀工具名改成前缀__原名。我用双下划线看中的就是它不容易误伤工具名里几乎不会自己出现连续两个下划线。路由听着最平常。调用进来按前缀剥出后端和原始工具名转发出去后端挂了就返回isError的工具结果别拖死整个网关。顺带说一句协议版本后端握手时回的是2025-11-25。网关用的 MCP Java SDK 2.0.0 走 Streamable HTTP跟系列三的无状态配置是同一套。三、动手依赖先做个反直觉的选择前几篇一直用spring-ai-starter-mcp-server-webmvc靠McpTool声明工具。网关用不了这条路——工具是运行时从后端拉来的注解里没法写「等我连上 8085 再看有什么」。所以这一篇直接用底层 SDK网关两头都是它对上游是 server对下游是 client。dependencygroupIdio.modelcontextprotocol.sdk/groupIdartifactIdmcp-core/artifactIdversion2.0.0/version/dependency!-- JSON 映射实现SDK 通过 ServiceLoader 发现代码里不直接引用 --dependencygroupIdio.modelcontextprotocol.sdk/groupIdartifactIdmcp-json-jackson3/artifactIdversion2.0.0/version/dependency后端清单写在配置里prefix 就是命名空间mcp:gateway:backends:-name:site-aurl:http://localhost:8085prefix:site-a-name:site-burl:http://localhost:8087prefix:site-b① 建连与聚合BackendRegistry启动时跑一次vartransportHttpClientStreamableHttpTransport.builder(cfg.getUrl()).build();McpSyncClientclientMcpClient.sync(transport).requestTimeout(Duration.ofSeconds(8)).build();client.initialize();ListMcpSchema.Tooltoolsclient.listTools().tools();拼表的时候有个地方容易漏all.add(McpSchema.Tool.builder().name(backend.prefix()SEPARATORtool.name()).title(tool.title())// 漏了宿主列表页只剩裸名.description([backend.name()] tool.description()).inputSchema(tool.inputSchema()).outputSchema(tool.outputSchema())// 漏了结构化输出和界面都拿不到数据.annotations(tool.annotations()).meta(tool.meta()).build());② 路由。前缀剥掉剩下的原样转发BackendRegistry.Routerouteregistry.resolve(request.name());if(routenull){returnerror(网关不认识这个工具request.name());}try{returnroute.backend().client().callTool(newMcpSchema.CallToolRequest(route.originalName(),request.arguments()));}catch(Exceptione){// 一个后端故障不该变成网关级失败returnerror(后端 route.backend().name() 调用失败e.getMessage());}③ 对上游那一面。HttpServletStatelessServerTransport本身就是个HttpServlet注册到/mcp就行vartransportHttpServletStatelessServerTransport.builder().messageEndpoint(/mcp).build();McpServer.sync(transport).serverInfo(mcp-gateway,1.0.0).tools(specs).build();returnnewServletRegistrationBean(transport,/mcp);四、怎么证明它真的分流了只看tools/list有 4 个工具说明不了任何事——拼字符串谁都会。要证明调用真的落到了不同后端得让两边数据不一样。我通过网关只启动 site-b 的风扇然后各查一次# 只动 site-bcurl-slocalhost:8086/mcp-HContent-Type: application/json\-HAccept: application/json, text/event-stream--data-binary call-b-ctrl.json结果是 site-a 的fan-02仍是falsesite-b 的变成true。同一份代码、同一份内存 mock只有路由对了才会出现这个差异。再把 site-b 那个进程杀掉网关的行为也符合预期{content:[{type:text,text:后端 site-b 调用失败java.net.ConnectException}],isError:true}同一时刻调 site-a温湿度照常返回23.5 / 48.2。一个后端倒下另一个不受牵连。五、踩坑记录①title和outputSchema是我自己漏的代价不小。第一版拼表只复制了 name / description / inputSchema / annotations / meta。跑出来一看工具没有titleget_room_dashboard的outputSchema也没了。宿主列表页只剩一串下划线名字界面拿不到结构化数据。补两行解决但这类字段丢失不会报错只能靠对返回逐字段看。② 工具表是启动时的快照。site-b 被我杀掉之后tools/list照样列出site-b__*四个工具一个没少。网关不会去探活也不会刷新。这意味着后端的工具变更对网关基本不可见除非重启或者自己做热更新——我这篇没做。③ 后端没起来网关照样能起。启动时连不上不算致命BackendRegistry记一条 WARN 就往下走WARN BackendRegistry : 后端 site-bhttp://localhost:8087挂载失败 网关继续启动该后端工具不暴露java.lang.RuntimeException: Client failed to initialize by explicit API call好处是网关不会因一个后端挂掉而整体起不来代价是后端必须早于网关启动晚起的后端这次就进不来了。④ 资源没代理界面直接断链。这条是系列五的延续也是我觉得最值得写的一条。工具的_meta.ui.resourceUri被我原样透传指向ui://room-dashboard。宿主拿到工具就会回头找网关要这个资源而网关压根没注册 resources handlerFailed to handle request: Missing handler for request type: resources/list数据回得来界面出不来。想在网关后面保住 MCP Apps必须把resources/list和resources/read一起代理掉而ui://这个 URI 又在各后端之间重名——还得再做一层资源命名空间。这篇没做留给后面。⑤ 打包被自己锁住。网关正在跑的时候mvn packagespring-boot 插件要重写 jarWindows 上文件被占直接PluginExecutionException。先停进程再打包这个坑跟 MCP 没关系但每次都会撞上。六、还没解决什么resources 和 prompts 没代理界面和资源类工具在网关后面是残的。鉴权也没处理——系列四那套 CIMD token 怎么往下带、要不要换成网关自己的凭据我没动这是生产上绕不开的一节。工具表热更新没做后端调用耗时也没埋点网关自己还成了单点。网关把麻烦收拢到一处风险也跟着集中到一处。这笔账得自己算。小结网关真正解决的是撞名性能那点事轮不到它管。工具名撞车才是逼你上网关的原因拼表要连title/outputSchema一起复制漏字段不报错只会让界面和结构化输出悄悄失效工具表是启动时快照后端挂了它照样列、后端晚起它收不到故障隔离只做在调用这一层完整可运行代码Gitee 仓库 GitHub 镜像tag:v06目录06-mcp-gateway。启动顺序先起 8085、8087 两个后端再起网关 8086。v06目录06-mcp-gateway。启动顺序先起 8085、8087 两个后端再起网关 8086。MCP 实战手记系列路线图#篇目状态1总纲篇MCP 到哪一步了✅2跑通第一个 MCP Server✅3把 MCP Server 改成无状态✅4CIMD 授权实战✅5让工具返回一个能点的界面✅6自建 MCP 网关本篇✅7MCP 安全接入检查清单规划你们那边是几个 MCP Server 在跑工具名撞过车没有评论区说说撞得厉害的话我下一篇就写资源代理和鉴权透传。参考MCP Java SDKMCP 官方规范 · 2025-11-25MCP Apps 官方规范SEP-1865