这篇文章面向一个从没看过任何现成项目的人:你已经有一个 Spring Boot 服务,现在想把它变成一个 MCP Server,让 Agent 可以像调用函数一样调用你后端里的能力。
我们不从协议论文讲起,也不先画很大的架构图。先抓住一句话:
MCP Server 的核心工作,就是把你项目里的 Java 方法,包装成模型可发现、可描述、可调用的 Tool。
所以接入 MCP,一般只做四件事:
引入 MCP Server 依赖。
配置 MCP Server 的名称、协议和访问地址。
编写带 @Tool 的 Tool Service。
把 Tool Service 注册到 ToolCallbackProvider。
下面按一个真实开发节奏来走。
一、先选接入方式 Spring Boot 项目里,最省心的方式是使用 Spring AI 的 MCP Server Starter。常见传输方式有:
STREAMABLE:适合 HTTP Streamable MCP,推荐给服务端应用使用。
SSE:老一些的 HTTP SSE 方式,部分客户端仍在使用。
STDIO:适合本地命令行工具,不太适合常驻 Web 服务。
如果你的项目本身就是后端服务,优先使用 WebMVC + Streamable HTTP:
1 2 3 4 5 6 spring: ai: mcp: server: protocol: STREAMABLE stdio: false
这样客户端通常访问 /mcp 即可。
二、引入依赖 下面以 Maven 为例。版本号按你项目使用的 Spring Boot / Spring AI 版本统一管理即可,文章里只示范结构。
父 pom.xml 可以集中管理版本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 <properties > <java.version > 21</java.version > <spring-ai.version > 1.1.2</spring-ai.version > <mcp.sdk.version > 0.17.0</mcp.sdk.version > <mcp.json.version > 0.17.0</mcp.json.version > </properties > <dependencyManagement > <dependencies > <dependency > <groupId > org.springframework.ai</groupId > <artifactId > spring-ai-starter-mcp-server-webmvc</artifactId > <version > ${spring-ai.version}</version > </dependency > <dependency > <groupId > io.modelcontextprotocol.sdk</groupId > <artifactId > mcp-json</artifactId > <version > ${mcp.json.version}</version > </dependency > </dependencies > </dependencyManagement >
应用模块里引入:
1 2 3 4 5 6 7 8 9 10 11 <dependencies > <dependency > <groupId > org.springframework.ai</groupId > <artifactId > spring-ai-starter-mcp-server-webmvc</artifactId > </dependency > <dependency > <groupId > io.modelcontextprotocol.sdk</groupId > <artifactId > mcp-json</artifactId > </dependency > </dependencies >
如果你的项目接入 Nacos、Consul 或其他服务发现,可以再接入对应的 registry 依赖。但这不是 MCP Tool 能跑起来的必要条件,第一步先把 /mcp 跑通。
三、配置 MCP Server 最小配置如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 spring: application: name: demo-mcp-server ai: mcp: server: name: demo-mcp-server version: 1.0 .0 type: ASYNC protocol: STREAMABLE stdio: false instructions: "MCP server for demo business tools"
几个字段建议这样理解:
name:MCP Server 名称,客户端发现和展示时会看到。
version:服务版本,建议跟应用版本同步。
type: ASYNC:工具调用可以异步处理,Web 服务里通常这样配。
protocol: STREAMABLE:使用 Streamable HTTP。
stdio: false:Web 服务不要启用 stdio。
instructions:给 Agent 看的服务说明,写清楚这个 MCP Server 提供什么能力。
如果你接入服务注册,例如 Nacos,可以追加:
1 2 3 4 5 6 7 8 9 10 11 12 spring: ai: alibaba: mcp: nacos: server-addr: ${nacos.server-addr} context-path: ${nacos.context-path:/nacos} namespace: ${nacos.namespace:public} username: ${nacos.username:} password: ${nacos.password:} register: enabled: true
这一步只是让 MCP Server 被注册和发现。即使没有注册中心,只要 HTTP 地址可访问,MCP Client 也可以直接连。
MCP Tool 最像什么?它最像一个”给 Agent 调用的 Controller 方法”,但它不是 HTTP Controller。它通过注解暴露给 MCP 协议。
先写一个最小例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 package com.example.mcp;import org.springframework.ai.tool.annotation.Tool;import org.springframework.ai.tool.annotation.ToolParam;import org.springframework.stereotype.Service;@Service public class DemoMcpToolService { @Tool(description = "Get current service health summary.") public HealthSummary getServiceHealth ( @ToolParam(description = "Optional subsystem name", required = false) String subsystem) { HealthSummary summary = new HealthSummary (); summary.setAvailable(true ); summary.setSubsystem(subsystem == null ? "all" : subsystem); summary.setMessage("Service is healthy" ); return summary; } public static class HealthSummary { private Boolean available; private String subsystem; private String message; public Boolean getAvailable () { return available; } public void setAvailable (Boolean available) { this .available = available; } public String getSubsystem () { return subsystem; } public void setSubsystem (String subsystem) { this .subsystem = subsystem; } public String getMessage () { return message; } public void setMessage (String message) { this .message = message; } } }
关键点只有两个:
方法上加 @Tool,它的 description 会被 Agent 看到。
参数上加 @ToolParam,每个参数都要写清楚用途、是否必填。
Tool 方法可以返回:
Java Bean / VO
List<T>
Map<String, Object>
基础类型
建议优先返回结构化对象,不要返回一大段拼接字符串。Agent 更容易理解结构化字段,也更容易做后续推理。
仅仅写了 @Tool 方法还不够,还要把这个对象交给 Spring AI MCP Server。
新增一个配置类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 package com.example.mcp;import org.springframework.ai.tool.ToolCallbackProvider;import org.springframework.ai.tool.method.MethodToolCallbackProvider;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;@Configuration public class McpToolConfig { @Bean public ToolCallbackProvider toolCallbackProvider (DemoMcpToolService demoMcpToolService) { return MethodToolCallbackProvider.builder() .toolObjects(demoMcpToolService) .build(); } }
如果有多个 Tool Service,就一起注册:
1 2 3 4 5 6 7 8 9 10 11 12 13 @Configuration public class McpToolConfig { @Bean public ToolCallbackProvider toolCallbackProvider ( DemoMcpToolService demoMcpToolService, OrderMcpToolService orderMcpToolService, OpsMcpToolService opsMcpToolService) { return MethodToolCallbackProvider.builder() .toolObjects(demoMcpToolService, orderMcpToolService, opsMcpToolService) .build(); } }
到这里,MCP Server 的核心注册链路就完整了:
1 2 3 4 5 6 @Tool method -> Tool Service Spring Bean -> MethodToolCallbackProvider -> Spring AI MCP Server -> /mcp -> MCP Client / Agent
很多人第一次写 MCP Tool,会把它写得像”万能 API”。这通常不好用。
更推荐的设计是:
一个 Tool 只做一件清晰的事。
参数尽量少,语义明确。
不让 Agent 传底层系统上下文,例如 AK/SK、租户密钥、region、endpoint。
默认值放在服务端配置里。
返回值里保留实际使用的上下文,方便排查。
比如不要这样:
1 2 3 4 5 6 7 8 9 10 @Tool(description = "Query cloud resource") public Object queryCloud ( String ak, String sk, String endpoint, String region, String resourceType, String queryJson) { }
这会让 Agent 既难用,也危险。
更推荐这样:
1 2 3 4 5 6 7 8 9 @Tool(description = "List servers using server-side cloud context.") public ServerListVo listServers ( @ToolParam(description = "Optional server name keyword", required = false) String keyword, @ToolParam(description = "Optional server status", required = false) String status, @ToolParam(description = "Optional page number, starts from 1", required = false) Integer page, @ToolParam(description = "Optional page size, default 100", required = false) Integer size) { CloudContext context = cloudContextResolver.resolve(); return serverService.listServers(context, keyword, status, page, size); }
上下文由服务端解析:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @Service public class CloudContextResolver { private final CloudProperties properties; public CloudContextResolver (CloudProperties properties) { this .properties = properties; } public CloudContext resolve () { if (isBlank(properties.getDefaultRegion()) || isBlank(properties.getDefaultProjectId())) { throw new IllegalStateException ("Default cloud region/project is not configured." ); } return new CloudContext (properties.getDefaultRegion(), properties.getDefaultProjectId()); } private boolean isBlank (String value) { return value == null || value.trim().isEmpty(); } }
配置类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 import org.springframework.boot.context.properties.ConfigurationProperties;import org.springframework.stereotype.Component;@Component @ConfigurationProperties(prefix = "cloud") public class CloudProperties { private String defaultRegion; private String defaultProjectId; public String getDefaultRegion () { return defaultRegion; } public void setDefaultRegion (String defaultRegion) { this .defaultRegion = defaultRegion; } public String getDefaultProjectId () { return defaultProjectId; } public void setDefaultProjectId (String defaultProjectId) { this .defaultProjectId = defaultProjectId; } }
上下文对象:
1 2 public record CloudContext (String region, String projectId) {}
服务端配置:
1 2 3 cloud: default-region: cn-southwest-2 default-project-id: your-project-id
这样 Agent 只需要关心业务参数,而不是底层账号细节。
下面给一个”订单查询 MCP Tool”的完整示例。它展示了推荐的分层方式:
Tool Service:只做参数接收、上下文补齐、审计包装。
Application Service:做业务逻辑。
VO:返回结构化结果。
1. 返回 VO 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 package com.example.order.vo;import java.util.List;public class OrderListVo { private Boolean available; private Integer total; private List<OrderItemVo> data; private String unavailableReason; public Boolean getAvailable () { return available; } public void setAvailable (Boolean available) { this .available = available; } public Integer getTotal () { return total; } public void setTotal (Integer total) { this .total = total; } public List<OrderItemVo> getData () { return data; } public void setData (List<OrderItemVo> data) { this .data = data; } public String getUnavailableReason () { return unavailableReason; } public void setUnavailableReason (String unavailableReason) { this .unavailableReason = unavailableReason; } }
1 2 3 4 5 6 7 8 9 10 11 12 13 package com.example.order.vo;import java.math.BigDecimal;public class OrderItemVo { private String orderId; private String customerName; private String status; private BigDecimal amount; private String createdAt; }
2. 业务 Service 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 package com.example.order;import com.example.order.vo.OrderItemVo;import com.example.order.vo.OrderListVo;import org.springframework.stereotype.Service;import java.util.List;@Service public class OrderQueryService { public OrderListVo listOrders (String tenantId, String keyword, String status, Integer page, Integer size) { int effectivePage = page == null || page < 1 ? 1 : page; int effectiveSize = size == null || size < 1 ? 20 : Math.min(size, 100 ); List<OrderItemVo> rows = List.of(); OrderListVo vo = new OrderListVo (); vo.setAvailable(true ); vo.setTotal(rows.size()); vo.setData(rows); return vo; } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 package com.example.mcp;import com.example.order.OrderQueryService;import com.example.order.vo.OrderListVo;import org.springframework.ai.tool.annotation.Tool;import org.springframework.ai.tool.annotation.ToolParam;import org.springframework.stereotype.Service;import java.util.LinkedHashMap;import java.util.Map;import java.util.function.Supplier;@Service public class OrderMcpToolService { private final OrderQueryService orderQueryService; private final TenantContextResolver tenantContextResolver; private final ToolAuditService toolAuditService; public OrderMcpToolService (OrderQueryService orderQueryService, TenantContextResolver tenantContextResolver, ToolAuditService toolAuditService) { this .orderQueryService = orderQueryService; this .tenantContextResolver = tenantContextResolver; this .toolAuditService = toolAuditService; } @Tool(description = "List orders by keyword and status using server-side tenant context.") public OrderListVo listOrders ( @ToolParam(description = "Optional keyword for order id or customer name", required = false) String keyword, @ToolParam(description = "Optional order status, for example PAID, REFUNDING, CLOSED", required = false) String status, @ToolParam(description = "Optional page number, starts from 1", required = false) Integer page, @ToolParam(description = "Optional page size, default 20 and max 100", required = false) Integer size) { TenantContext context = tenantContextResolver.resolve(); return audit("listOrders" , context, summary( "keyword" , keyword, "status" , status, "page" , page, "size" , size ), () -> orderQueryService.listOrders(context.tenantId(), keyword, status, page, size)); } private <T> T audit (String toolName, TenantContext context, Map<String, Object> summary, Supplier<T> supplier) { return toolAuditService.audited(toolName, context.tenantId(), summary, supplier); } private Map<String, Object> summary (Object... pairs) { Map<String, Object> result = new LinkedHashMap <>(); for (int i = 0 ; i + 1 < pairs.length; i += 2 ) { result.put(String.valueOf(pairs[i]), pairs[i + 1 ]); } return result; } }
4. 上下文解析 1 2 3 4 package com.example.mcp;public record TenantContext (String tenantId) {}
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 package com.example.mcp;import org.springframework.stereotype.Service;@Service public class TenantContextResolver { private final TenantProperties properties; public TenantContextResolver (TenantProperties properties) { this .properties = properties; } public TenantContext resolve () { if (properties.getDefaultTenantId() == null || properties.getDefaultTenantId().isBlank()) { throw new IllegalStateException ("Default tenant id is not configured." ); } return new TenantContext (properties.getDefaultTenantId()); } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 package com.example.mcp;import org.springframework.boot.context.properties.ConfigurationProperties;import org.springframework.stereotype.Component;@Component @ConfigurationProperties(prefix = "tenant") public class TenantProperties { private String defaultTenantId; public String getDefaultTenantId () { return defaultTenantId; } public void setDefaultTenantId (String defaultTenantId) { this .defaultTenantId = defaultTenantId; } }
配置:
1 2 tenant: default-tenant-id: demo-tenant
5. 审计包装 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 package com.example.mcp;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import org.springframework.stereotype.Service;import java.util.Map;import java.util.function.Supplier;@Service public class ToolAuditService { private static final Logger log = LoggerFactory.getLogger(ToolAuditService.class); public <T> T audited (String toolName, String tenantId, Map<String, Object> requestSummary, Supplier<T> supplier) { long start = System.currentTimeMillis(); try { T result = supplier.get(); log.info("MCP tool success, toolName={}, tenantId={}, elapsedMs={}, request={}" , toolName, tenantId, System.currentTimeMillis() - start, requestSummary); return result; } catch (Exception ex) { log.warn("MCP tool failed, toolName={}, tenantId={}, elapsedMs={}, request={}, error={}" , toolName, tenantId, System.currentTimeMillis() - start, requestSummary, ex.getMessage(), ex); throw ex; } } }
这类审计包装非常有用。MCP Tool 被 Agent 调用后,如果没有日志,你很难判断:
Agent 到底传了什么参数?
哪个 Tool 被调用?
是 Tool 没注册,还是业务接口失败?
是模型没选中 Tool,还是 Tool 执行报错?
建议至少记录:
toolName
租户/项目/业务上下文
脱敏后的入参摘要
耗时
成功/失败
失败原因
八、访问控制怎么做 很多内部项目已经有自己的登录态、SSO、网关鉴权。MCP 入口通常不适合直接复用普通用户登录态,因为 Agent 调用更像”服务到服务调用”。
推荐做法:
/mcp 路径从普通用户拦截器里放行。
单独给 /mcp 加一个轻量 API Token Filter。
Filter 校验通过后,给请求加内部访问标记。
后续平台拦截器识别内部访问标记。
1. 放行普通 Web 拦截器 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 @Configuration public class WebMvcConfig implements WebMvcConfigurer { private final UserContextInterceptor userContextInterceptor; public WebMvcConfig (UserContextInterceptor userContextInterceptor) { this .userContextInterceptor = userContextInterceptor; } @Override public void addInterceptors (InterceptorRegistry registry) { registry.addInterceptor(userContextInterceptor) .addPathPatterns("/**" ) .excludePathPatterns( "/error" , "/actuator/**" , "/mcp" , "/mcp/**" ); } }
2. MCP API Token 配置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 import org.springframework.boot.context.properties.ConfigurationProperties;@ConfigurationProperties(prefix = "app.mcp.access") public class McpAccessProperties { private String headerName = "X-API-Key" ; private String token; public String getHeaderName () { return headerName; } public void setHeaderName (String headerName) { this .headerName = headerName; } public String getToken () { return token; } public void setToken (String token) { this .token = token; } }
1 2 3 4 5 app: mcp: access: header-name: X-API-Key token: your-secret-token
3. MCP Filter 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 import jakarta.servlet.Filter;import jakarta.servlet.FilterChain;import jakarta.servlet.ServletException;import jakarta.servlet.ServletRequest;import jakarta.servlet.ServletResponse;import jakarta.servlet.http.HttpServletRequest;import jakarta.servlet.http.HttpServletResponse;import java.io.IOException;public class McpAccessFilter implements Filter { private final McpAccessProperties properties; public McpAccessFilter (McpAccessProperties properties) { this .properties = properties; } @Override public void doFilter (ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { if (!(request instanceof HttpServletRequest httpRequest) || !(response instanceof HttpServletResponse httpResponse)) { chain.doFilter(request, response); return ; } if (!isMcpRequest(httpRequest)) { chain.doFilter(request, response); return ; } String actual = httpRequest.getHeader(properties.getHeaderName()); if (properties.getToken() == null || !properties.getToken().equals(actual)) { httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED); httpResponse.setContentType("application/json;charset=UTF-8" ); httpResponse.getWriter().write("{\"code\":401,\"message\":\"MCP API token is invalid\"}" ); return ; } chain.doFilter(request, response); } private boolean isMcpRequest (HttpServletRequest request) { String uri = request.getRequestURI(); String contextPath = request.getContextPath(); String path = contextPath != null && !contextPath.isBlank() && uri.startsWith(contextPath) ? uri.substring(contextPath.length()) : uri; return "/mcp" .equals(path) || path.startsWith("/mcp/" ); } }
4. 注册 Filter 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import org.springframework.boot.context.properties.EnableConfigurationProperties;import org.springframework.boot.web.servlet.FilterRegistrationBean;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;@Configuration @EnableConfigurationProperties(McpAccessProperties.class) public class McpAccessFilterConfig { @Bean public FilterRegistrationBean<McpAccessFilter> mcpAccessFilter (McpAccessProperties properties) { FilterRegistrationBean<McpAccessFilter> registration = new FilterRegistrationBean <>(); registration.setFilter(new McpAccessFilter (properties)); registration.addUrlPatterns("/mcp" , "/mcp/*" ); registration.setOrder(-1100 ); return registration; } }
第一版可以只做到 API Token。后续如果要更精细,可以引入:
按 Tool 授权。
按租户授权。
按来源 IP 授权。
按调用频率限流。
Tool 调用审计入库。
九、多个 Transport 冲突时怎么办 有些项目引入依赖后,Spring 容器里可能同时出现多个 MCP transport,例如 STDIO、SSE、Streamable HTTP。然后启动时报类似”需要一个 Bean,但找到多个”的错误。
可以加一个 @Primary 配置,根据 spring.ai.mcp.server.protocol 选主 transport:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 import io.modelcontextprotocol.spec.McpServerTransportProviderBase;import org.springframework.beans.factory.ObjectProvider;import org.springframework.beans.factory.annotation.Value;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.context.annotation.Primary;import java.util.List;import java.util.Locale;@Configuration public class McpTransportPrimaryConfig { @Bean @Primary public McpServerTransportProviderBase primaryMcpServerTransport ( ObjectProvider<List<McpServerTransportProviderBase>> providerListProvider, @Value("${spring.ai.mcp.server.protocol:SSE}") String protocol) { List<McpServerTransportProviderBase> providers = providerListProvider.getIfAvailable(List::of); if (providers.isEmpty()) { throw new IllegalStateException ("No McpServerTransportProviderBase beans found." ); } String normalizedProtocol = protocol == null ? "" : protocol.toLowerCase(Locale.ROOT); boolean preferStreamable = normalizedProtocol.contains("stream" ); String preferredKeyword = preferStreamable ? "stream" : "sse" ; String fallbackKeyword = preferStreamable ? "sse" : "stream" ; return providers.stream() .filter(provider -> !className(provider).contains("stdio" )) .filter(provider -> className(provider).contains(preferredKeyword)) .findFirst() .or(() -> providers.stream() .filter(provider -> !className(provider).contains("stdio" )) .filter(provider -> className(provider).contains(fallbackKeyword)) .findFirst()) .orElseGet(() -> providers.stream() .filter(provider -> !className(provider).contains("stdio" )) .findFirst() .orElse(providers.get(0 ))); } private static String className (McpServerTransportProviderBase provider) { return provider.getClass().getName().toLowerCase(Locale.ROOT); } }
这段不是所有项目都需要。只有当你遇到 transport Bean 冲突时再加。
@Tool(description = "...") 不是给人看的注释,它是给模型做工具选择用的。
差的描述:
1 @Tool(description = "Query data")
好的描述:
1 @Tool(description = "List paid orders by optional customer keyword and time range. Read-only.")
差的参数描述:
1 @ToolParam(description = "id") String id
好的参数描述:
1 @ToolParam(description = "Order id, for example ORD-202606160001") String orderId
建议描述里明确:
这个 Tool 做什么。
是否只读。
适合什么场景。
关键参数格式。
默认值行为。
不要让 Agent 传什么。
例如:
1 2 3 4 5 6 7 @Tool(description = "Query recent payment failures by tenant. Read-only. Defaults to the last 24 hours when time range is omitted.") public PaymentFailureListVo queryPaymentFailures ( @ToolParam(description = "Optional start time in epoch milliseconds. Defaults to now minus 24 hours.", required = false) Long startTime, @ToolParam(description = "Optional end time in epoch milliseconds. Defaults to now.", required = false) Long endTime, @ToolParam(description = "Optional page size, default 50 and max 200.", required = false) Integer size) { }
十一、返回结构设计建议 给 Agent 的返回值要稳定、可解释、可降级。
推荐结构:
1 2 3 4 5 6 7 8 9 public class ToolResult <T> { private Boolean available; private T data; private String unavailableReason; private List<String> warnings; private Map<String, Object> diagnosis; }
如果是列表:
1 2 3 4 5 6 7 8 9 10 11 public class ListResult <T> { private Boolean available; private Integer total; private Integer page; private Integer size; private List<T> data; private String unavailableReason; private Map<String, Object> diagnosis; }
为什么要有 available?
因为 Tool 调用”成功返回 HTTP 200”不代表业务数据源可用。比如下游没配置、权限不足、查询超时,都应该让 Agent 能清楚区分。
推荐语义:
available=true:Tool 正常访问了目标能力,即使 data=[] 也算可用。
available=false:目标能力不可用、配置缺失、权限不足、下游异常。
unavailableReason:写清楚失败原因,避免只返回 null 或空数组。
diagnosis:放调试信息,例如查询时间、过滤条件、实际 endpoint。
十二、异常处理建议 不要让所有异常都直接冒泡给 MCP Client。对 Agent 来说,一段 Java 堆栈通常没什么用。
Tool 层或 Service 层可以做转换:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public ToolResult<List<OrderItemVo>> safeListOrders (...) { ToolResult<List<OrderItemVo>> result = new ToolResult <>(); try { List<OrderItemVo> data = orderRepository.query(...); result.setAvailable(true ); result.setData(data); return result; } catch (PermissionException ex) { result.setAvailable(false ); result.setUnavailableReason("Permission denied: " + ex.getMessage()); return result; } catch (Exception ex) { result.setAvailable(false ); result.setUnavailableReason("Order query failed: " + ex.getMessage()); return result; } }
聚合类 Tool 更应该局部降级:
1 2 3 4 generateOpsInventory - asset 查询成功 - metrics 查询失败 - audit 查询成功
这种情况下,不要让整个报告失败。更好的返回是:
1 2 3 4 5 6 7 8 9 10 { "available" : true , "asset" : { } , "metricSummary" : { "available" : false , "unavailableReason" : "Metrics query timeout" } , "auditSummary" : { } , "unavailableDataSources" : [ "metrics" ] }
十三、启动和验证 启动应用后,先确认服务端正常启动,没有 Tool 注册异常。
常见验证路径:
应用启动无异常。
/mcp 路径能被访问。
未带 API Token 时返回 401。
带正确 API Token 时可以完成 MCP 初始化。
MCP Client 能看到 tools/list。
MCP Client 能调用 tools/call。
如果使用 curl,只能验证 HTTP 层,不一定能完整模拟 MCP Client。更推荐使用 MCP Inspector 或你自己的 Agent 客户端。
请求头示例:
1 X-API-Key: your-secret-token
如果使用 Authorization:
1 Authorization: Bearer your-secret-token
对应 Filter 里要读取 Authorization 并处理 Bearer 前缀。
十四、常见踩坑 检查:
类是否是 Spring Bean:@Service / @Component。
方法是否有 @Tool。
是否注册到了 MethodToolCallbackProvider.toolObjects(...)。
ToolCallbackProvider 是否被 Spring 扫描到。
应用启动时是否有 Bean 冲突。
如果编译没有保留参数名,某些反射场景下可能出现参数名不可读。建议:
方法参数都加 @ToolParam。
Maven 编译打开 -parameters,视项目需要配置。
示例:
1 2 3 4 5 6 7 <plugin > <groupId > org.apache.maven.plugins</groupId > <artifactId > maven-compiler-plugin</artifactId > <configuration > <parameters > true</parameters > </configuration > </plugin >
3. /mcp 被业务登录拦截 检查 WebMVC 拦截器、网关、Sa-Token、Spring Security:
需要从普通用户态拦截中放行,再用 MCP 自己的 API Token 保护。
4. 同时出现 SSE 和 Streamable Transport 如果启动时报 transport Bean 冲突,加 @Primary transport 选择配置。
通常不是代码问题,而是 Tool 描述不够清楚。
改进:
Tool 名称要明确。
description 写出业务动作和只读/写入属性。
参数描述写出格式和默认值。
不要做一个万能 Tool。
不要让 Agent 猜复杂 JSON。
不要只返回 []。至少返回:
1 2 3 4 5 6 7 8 9 { "available" : true , "total" : 0 , "data" : [ ] , "diagnosis" : { "query" : "..." , "timeRange" : "..." } }
如果是配置缺失:
1 2 3 4 5 { "available" : false , "total" : 0 , "unavailableReason" : "Default data source is not configured" }
十五、一个推荐的目录结构 你可以按这样的结构放 MCP 相关代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 src/main/java/com/example/app/ mcp/ McpToolConfig.java McpTransportPrimaryConfig.java access/ McpAccessFilter.java McpAccessFilterConfig.java McpAccessProperties.java common/ ToolAuditService.java TenantContextResolver.java TenantContext.java order/ OrderMcpToolService.java ops/ OpsMcpToolService.java order/ OrderQueryService.java vo/ OrderListVo.java OrderItemVo.java
原则是:
mcp 包只放 MCP 暴露层、访问控制、上下文解析、审计包装。
真实业务逻辑仍放在原来的业务 Service。
MCP Tool 不直接写复杂 DAO。
MCP Tool 不暴露密钥、endpoint、底层账号参数。
十六、接入清单 最后给一份可以照着打勾的清单:
结语 MCP 接入本身不复杂。真正决定好不好用的,不是依赖怎么引,而是 Tool 怎么设计。
好的 MCP Tool 有三个特点:
Agent 参数少,不需要知道底层系统细节。
返回结构稳定,失败也能解释原因。
每个 Tool 都是清晰的业务动作,而不是一个万能入口。
当你按这个方式接入后,MCP Server 就不只是”把接口暴露给模型”,而是把你系统里的能力整理成一组 Agent 能理解、能选择、能安全调用的工具。