ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

手写MCP Server,让大模型调用Java工具

手写MCP Server,让大模型调用Java工具

你的 Java 服务里已经有一堆写好的方法:查订单、查库存、发通知。现在要接大模型,让 AI 助手能"动手"调这些方法,而不是只会聊天。问题来了——OpenAI 的 Function Calling、Spring AI 的 @Tool、LangChain4j 的工具定义,格式各不相同,你不想为每家客户端都重写一遍适配。MCP(Model Context Protocol,模型上下文协议)就是来解决这件事的:一套统一标准,写一次,Claude、Cursor、各种 Agent 都能直接调用。这篇文章用 Spring AI 2.0 手写一个 MCP Server,把 Java 方法暴露成大模型能用的工具,代码完整可跑。

一、这个问题到底是什么

大模型只会"说",不会"做"。你跟它聊得再欢,它也没法真的去查你的数据库、调你的下单接口。要让它真正办事,你得给它"工具"——也就是把你代码里的方法包一层,告诉大模型"有这么一个能力,参数长这样,你可以调用它"。

麻烦出在"包一层"这一步。不同框架对"工具"的定义方式完全不一样:

  • OpenAI 用的是 Function Calling,要求你用 JSON Schema 描述每个函数的参数;
  • Spring AI 用@Tool注解;
  • LangChain4j 用@Tool注解但参数写法和 Spring AI 又不同;
  • 各家 Agent 平台、各家 IDE 的 AI 插件,各自又是一套。

结果就是:同一个"查订单"方法,你想接 Claude Desktop 得写一遍适配,接 Cursor 再写一遍,接自家 Agent 平台还得再来一遍。三份几乎一样的胶水代码,维护起来是噩梦。

MCP 就是来解决这个"碎片化"的。它是 Anthropic 在 2024 年底提出的一套开放协议,定义的是"AI 应用(客户端)"和"提供能力的程序(服务端)“之间怎么通信。你可以把它理解成 USB-C 接口:以前每个设备要配一种专用线,现在一根 Type-C 线全通用。MCP 之于 AI 工具,就是那个"统一接口”。

这篇文章要交付三样东西:第一,用 Spring AI 2.0 的@McpTool注解把一个 Java 方法变成 MCP 工具;第二,解释背后 JSON Schema 自动生成的原理;第三,跑起来并用 MCP Inspector 验证。学完你就能把团队现有的 Java 服务,低成本地"升级"成任何大模型都能调用的工具仓库。

二、底层原理到底怎么回事

先看 MCP 的整体架构,一共三个角色:

  • Host(宿主应用):最终用户使用的那个软件,比如 Claude Desktop、Cursor 编辑器、你自己的 App。它负责发起对话。
  • Client(客户端):跑在 Host 内部的一层,负责和 Server 建立连接、收发消息。普通开发者通常不用管它。
  • Server(服务端):你写的、真正提供能力的那部分,里面装着一堆工具、资源和提示词。

一次工具调用的完整流程是:用户在 Claude Desktop 里问"查一下 ORD-001 这个订单",Host 里的 Client 连上你的 Server,Server 告诉 Client"我有query_order这个工具",Client 再把工具清单和用户的问题一起交给大模型,大模型决定调用query_order并给出参数,最后这个调用又通过 Client 传回你的 Server 真正执行。

MCP 有三种传输协议,决定了 Server 以什么方式被连接:

协议连接方式典型场景
STDIO标准输入输出,进程内本机工具,比如 Claude Desktop 本地插件
Streamable HTTPHTTP,独立进程远程部署,微服务,多客户端共享
StatelessHTTP,无会话状态云原生、无状态部署

(SSE 协议在 Spring AI 2.0 里已经废弃,官方建议改用 Streamable HTTP。)

一个 Server 能暴露三种能力:**Tools(工具)**是让大模型执行动作的,比如查订单、取消订单;**Resources(资源)**是给大模型提供上下文数据的,比如一段配置、一份文档;**Prompts(提示词)**是预置好的提示词模板,供客户端复用。本文重点讲 Tools,顺带演示 Resources。

Spring AI 2.0 的关键设计是注解驱动。你给方法标上@McpTool,Spring 启动时会扫描所有@ComponentBean 里的这些方法,然后做两件事:

  1. 用反射读方法签名,自动生成 JSON Schema——也就是这份工具的"说明书",里面写着工具叫啥、每个参数叫什么类型、哪些是必填。
  2. 把方法包成一个回调,注册进 MCP Server。

为什么 JSON Schema 这么重要?因为大模型"调用工具"靠的不是直接执行你的代码,而是读说明书。它先看 Schema 知道query_order要一个orderId参数、参数是字符串、必填,然后才吐出"我要调 query_order,orderId 传 ORD-001"。所以 Schema 写得好不好,直接决定大模型能不能正确调用。Spring AI 帮你自动生成,省掉了手写 JSON 这一步,也避免了手写和代码不一致的坑。

一个容易忽略的点:@McpTool方法的返回类型也会影响大模型。如果返回的是 String 或 int 这类简单类型,直接转成文本回给模型就行;如果返回的是自定义对象(比如订单信息),你得把generateOutputSchema设成true,让 Spring 额外生成一份"输出结构说明",模型才知道返回值里有哪些字段。

三、实战:手把手写代码

我们做一个"订单服务 MCP Server",暴露两个工具(查订单、取消订单)和一个资源(查配置)。环境要求 JDK 21,用 Maven 构建。

先看 POM。这里只显式声明一个 MCP Server starter,它会把 Spring MVC、MCP SDK 这些依赖一起带进来:

<?xml version="1.0" encoding="UTF-8"?><projectxmlns="http://maven.apache.org/POM/4.0.0"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"><modelVersion>4.0.0</modelVersion><parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>4.1.1</version><relativePath/></parent><groupId>com.example</groupId><artifactId>mcp-order-server</artifactId><version>0.0.1-SNAPSHOT</version><name>mcp-order-server</name><properties><java.version>21</java.version><spring-ai.version>2.0.1</spring-ai.version></properties><dependencies><!-- 这个 starter 会传递引入 spring-boot-starter-web 和 MCP SDK --><dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-starter-mcp-server-webmvc</artifactId></dependency></dependencies><dependencyManagement><dependencies><dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-bom</artifactId><version>${spring-ai.version}</version><type>pom</type><scope>import</scope></dependency></dependencies></dependencyManagement><build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build></project>

这段配置在干嘛:spring-boot-starter-parent固定到 4.1.1,这是当前最新的 GA 版本;spring-ai-bom用 2.0.1 统一管理 Spring AI 各模块版本,避免版本对不齐;spring-ai-starter-mcp-server-webmvc是 WebMVC 传输的 MCP Server starter。版本号都查过 Maven Central,是最新 GA。

配置文件src/main/resources/application.yml:

spring:application:name:mcp-order-serverai:mcp:server:name:order-serverversion:1.0.0protocol:STREAMABLE

protocol: STREAMABLE指定用 Streamable HTTP 传输,这是官方推荐的远程方式(SSE 已废弃)。不写的话默认也是 STREAMABLE,但显式写出来更清楚。HTTP 端点的默认路径是/mcp。

主启动类:

packagecom.example.mcporderserver;importorg.springframework.boot.SpringApplication;importorg.springframework.boot.autoconfigure.SpringBootApplication;@SpringBootApplicationpublicclassMcpOrderServerApplication{publicstaticvoidmain(String[]args){SpringApplication.run(McpOrderServerApplication.class,args);}}

这是标准的 Spring Boot 启动入口,没有特殊配置。注解扫描默认开启,会自动发现下面带@McpTool的方法。

订单信息的数据类,用一个 Java record:

packagecom.example.mcporderserver;publicrecordOrderInfo(StringorderId,StringcustomerName,Stringstatus,doubleamount){}

这个 record 就是工具的返回值类型。因为它是自定义对象,后面在@McpTool上要开generateOutputSchema,让大模型知道返回结构里有哪些字段。

核心的工具类:

packagecom.example.mcporderserver;importorg.springframework.ai.mcp.annotation.McpTool;importorg.springframework.ai.mcp.annotation.McpToolParam;importorg.springframework.stereotype.Component;@ComponentpublicclassOrderTools{@McpTool(name="query_order",description="根据订单号查询订单信息,返回订单状态和金额",generateOutputSchema=true)publicOrderInfoqueryOrder(@McpToolParam(description="订单号,例如 ORD-20240904-001",required=true)StringorderId){// 模拟查询数据库。实际项目里把这里换成 service 或 repository 的调用。returnnewOrderInfo(orderId,"张三","已发货",299.00);}@McpTool(name="cancel_order",description="取消一个未发货的订单,返回操作结果")publicStringcancelOrder(@McpToolParam(description="要取消的订单号",required=true)StringorderId){if(orderId==null||orderId.isBlank()){thrownewIllegalArgumentException("订单号不能为空");}return"订单 "+orderId+" 已成功取消";}}

逐行讲一下:@McpTool标记这是个工具方法,name是工具的唯一标识(大模型调用时用这个名字),description是给大模型看的说明——描述写得越清楚,大模型越知道什么时候该调它。@McpToolParam描述参数,required = true表示这个参数必填。queryOrder返回OrderInfo自定义对象,所以generateOutputSchema = true。cancelOrder里抛的IllegalArgumentException是 RuntimeException,Spring AI 会把它转成"错误结果"回传给大模型,大模型看到后可以自行修正重试。

再演示一个资源类,把配置暴露给大模型当上下文:

packagecom.example.mcporderserver;importjava.util.Map;importorg.springframework.ai.mcp.annotation.McpResource;importorg.springframework.stereotype.Component;@ComponentpublicclassConfigResource{privatefinalMap<String,String>config=Map.of("support_phone","400-888-1234","return_policy","7天无理由退货,运费险由平台承担");@McpResource(uri="config://{key}",name="AppConfig",title="应用配置",description="读取应用级配置项,例如客服电话、退换货政策",mimeType="text/plain")publicStringgetConfig(Stringkey){returnconfig.getOrDefault(key,"未找到配置项:"+key);}}

@McpResource的uri里用了{key}这个模板变量,方法参数String key会和它自动对应。大模型可以按需读取这些配置,而不必把它们塞进每次的系统提示词里。

跑起来:

mvn spring-boot:run

启动成功后,服务监听 8080 端口,MCP 端点在http://localhost:8080/mcp。验证有两种方式:最直观的是用官方的 MCP Inspector(一个可视化调试面板),命令行执行npx @modelcontextprotocol/inspector,选择 Streamable HTTP 传输,填入http://localhost:8080/mcp,就能看到query_order、cancel_order两个工具和AppConfig资源,还能直接在面板里手动传参调用;如果你想用程序连,把之前学过的 Spring AI MCP Client 指向这个地址即可。

四、踩坑经验和最佳实践

第一,SSE 别再用,统一上 STREAMABLE。Spring AI 2.0 里 SSE 协议已经标记废弃。如果你还在配置里写spring.ai.mcp.server.protocol=SSE,虽然能跑但会看到废弃警告。新项目直接用STREAMABLE,远程部署用STATELESS。

第二,HTTP 传输默认没有鉴权,别裸奔到公网。官方文档明确警告:HTTP 型 MCP Server 默认暴露一个无认证的 JSON-RPC 端点,任何能访问该端点的客户端都能列出并调用你的全部工具。对内网工具没问题,一旦要暴露到公网,必须前置一层安全边界(比如网关鉴权),或者干脆用 STDIO 走本机进程。

第三,返回自定义对象一定要开generateOutputSchema。如果你返回的是 record 或 POJO 却没开这个开关,模型只能拿到一个模糊的字符串,不知道返回值里有哪些字段,后续处理容易出错。反过来,返回 String、int 这类简单类型时不用开。

第四,描述写得好,工具才被用得对。description和@McpToolParam的description是给大模型看的"使用说明",不是给人看的注释。写清楚"什么时候用这个工具、参数是什么格式、参数从哪来",比写一堆 Java 注释有用得多。很多工具调用失败的根因不是代码,而是描述太含糊。

第五,区分 RuntimeException 和受检异常。工具方法里抛 RuntimeException(比如参数校验失败),Spring AI 会把它转成错误结果回传给大模型,让模型能自我纠正;而抛受检异常(比如 IOException)会直接让这次工具调用失败、不让模型看到错误。想要"模型能兜底重试"就用 RuntimeException,想要"失败就硬停"就声明受检异常。

第六,工具方法的参数类型别乱用。JSON Schema 是从 Java 类型自动推断的,所以参数尽量用 String、int、boolean、record 这些能干净映射到 JSON 的类型。用太复杂的泛型或 Optional 可能导致 Schema 生成不理想,必要时可以退回到手动接收CallToolRequest的方式自己解析。

五、性能对比和技术选型

先看三种传输协议怎么选,这是决定部署形态的第一件事:

协议部署复杂度延迟适用场景
STDIO低(进程内)最低本机工具、桌面 AI 插件
Streamable HTTP中(独立进程)低远程服务、多客户端共享
Stateless中(无状态)低云原生、弹性伸缩、无会话需求

再看实现方式怎么选:

  • 注解式(@McpTool):适合绝大多数场景。参数类型固定、能自动生成 Schema、代码最简洁。本文就是这条路。
  • 手动CallToolRequest:适合参数结构动态变化、需要运行时自定义 Schema 的场景,灵活但要多写解析代码。
  • 直接手写 MCP SDK:适合需要精细控制协议细节的极端场景,学习成本和代码量都最高,一般没必要。

结论:日常团队里把现有 Java 服务"工具化",无脑上 Spring AI 的@McpTool注解式方案,配 STREAMABLE 或 STDIO 传输,性价比最高。别一上来就自己撸 SDK。

六、总结

MCP 解决的是一件事:让"写一次工具,处处可调用"成为现实,而不是为每个 AI 客户端重写一遍胶水代码。Spring AI 2.0 把这件事的复杂度压到了最低——一个@McpTool注解,方法签名自动变 JSON Schema,@Component自动扫描注册,几乎零配置。

回顾本文:我们用 Spring Boot 4.1.1 + Spring AI 2.0.1 写了一个订单服务 MCP Server,暴露了查订单、取消订单两个工具和一个配置资源,代码完整可运行,用 MCP Inspector 就能验证。

记住三个关键点:用 STREAMABLE 协议;返回对象要开generateOutputSchema;HTTP 传输上线前必须加鉴权。

下一步建议你把团队现有的一个真实 Service 方法包成 MCP 工具,接进你常用的 AI 客户端里体验一下,感受"工具写一次、处处复用"的爽感。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进