这篇文章面向一个从没看过任何现成项目的人:你已经有一个 Spring Boot 服务,现在想把它变成一个 MCP Server,让 Agent 可以像调用函数一样调用你后端里的能力。

我们不从协议论文讲起,也不先画很大的架构图。先抓住一句话:

MCP Server 的核心工作,就是把你项目里的 Java 方法,包装成模型可发现、可描述、可调用的 Tool。

所以接入 MCP,一般只做四件事:

  1. 引入 MCP Server 依赖。
  2. 配置 MCP Server 的名称、协议和访问地址。
  3. 编写带 @Tool 的 Tool Service。
  4. 把 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 也可以直接连。

四、写第一个 Tool Service

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 更容易理解结构化字段,也更容易做后续推理。

五、注册 ToolCallbackProvider

仅仅写了 @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

六、Tool Service 应该怎么设计

很多人第一次写 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 只需要关心业务参数,而不是底层账号细节。

七、一个更完整的 Tool Service 示例

下面给一个”订单查询 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;

// getter/setter 省略
}

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);

// 这里替换成真实 DAO 查询。
List<OrderItemVo> rows = List.of();

OrderListVo vo = new OrderListVo();
vo.setAvailable(true);
vo.setTotal(rows.size());
vo.setData(rows);
return vo;
}
}

3. MCP Tool 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
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 调用更像”服务到服务调用”。

推荐做法:

  1. /mcp 路径从普通用户拦截器里放行。
  2. 单独给 /mcp 加一个轻量 API Token Filter。
  3. Filter 校验通过后,给请求加内部访问标记。
  4. 后续平台拦截器识别内部访问标记。

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 描述怎么写,Agent 才更愿意正确调用

@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;

// getter/setter 省略
}

如果是列表:

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;

// getter/setter 省略
}

为什么要有 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 注册异常。

常见验证路径:

  1. 应用启动无异常。
  2. /mcp 路径能被访问。
  3. 未带 API Token 时返回 401。
  4. 带正确 API Token 时可以完成 MCP 初始化。
  5. MCP Client 能看到 tools/list。
  6. 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 前缀。

十四、常见踩坑

1. Tool Service 写了但客户端看不到

检查:

  • 类是否是 Spring Bean:@Service / @Component。
  • 方法是否有 @Tool。
  • 是否注册到了 MethodToolCallbackProvider.toolObjects(...)。
  • ToolCallbackProvider 是否被 Spring 扫描到。
  • 应用启动时是否有 Bean 冲突。

2. Tool 参数名不对

如果编译没有保留参数名,某些反射场景下可能出现参数名不可读。建议:

  • 方法参数都加 @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
  • /mcp/**

需要从普通用户态拦截中放行,再用 MCP 自己的 API Token 保护。

4. 同时出现 SSE 和 Streamable Transport

如果启动时报 transport Bean 冲突,加 @Primary transport 选择配置。

5. Agent 总是不用你的 Tool

通常不是代码问题,而是 Tool 描述不够清楚。

改进:

  • Tool 名称要明确。
  • description 写出业务动作和只读/写入属性。
  • 参数描述写出格式和默认值。
  • 不要做一个万能 Tool。
  • 不要让 Agent 猜复杂 JSON。

6. Tool 返回空数组,Agent 不知道为什么

不要只返回 []。至少返回:

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、底层账号参数。

十六、接入清单

最后给一份可以照着打勾的清单:

  • 引入 spring-ai-starter-mcp-server-webmvc。
  • 配置 spring.ai.mcp.server.name/version/protocol/stdio/instructions。
  • 确认使用 STREAMABLE 或 SSE。
  • 写一个最小 @Tool 方法。
  • 每个参数加 @ToolParam。
  • 注册 MethodToolCallbackProvider。
  • 如果有多个 Tool Service,全部放进 toolObjects(...)。
  • /mcp 从普通登录拦截中放行。
  • /mcp 单独加 API Token 保护。
  • Tool 调用加审计日志。
  • 返回结构包含 available/unavailableReason/diagnosis。
  • 聚合类 Tool 支持局部失败,不要一处失败整份报告失败。
  • 使用 MCP Client 或 Inspector 验证 tools/list 和 tools/call。

结语

MCP 接入本身不复杂。真正决定好不好用的,不是依赖怎么引,而是 Tool 怎么设计。

好的 MCP Tool 有三个特点:

  1. Agent 参数少,不需要知道底层系统细节。
  2. 返回结构稳定,失败也能解释原因。
  3. 每个 Tool 都是清晰的业务动作,而不是一个万能入口。

当你按这个方式接入后,MCP Server 就不只是”把接口暴露给模型”,而是把你系统里的能力整理成一组 Agent 能理解、能选择、能安全调用的工具。