教程以任务看板为例,用 Spring AI 暴露 MCP 工具,并加入 OAuth 2、工具级权限和参数校验。文中还指出传输协议配置可能导致 /mcp 返回 404。
Claude Code 可以往团队看板里添加任务,还能用表格展示 Ana 尚未完成的任务。它并没有专门的插件,而是在与一个用 Spring AI 构建的 Java 服务器通信,由这个服务器通过 Model Context Protocol(MCP)暴露看板功能。本文会一步步搭建这个服务器:先用几个带注解的方法实现功能,再加上 OAuth 2,为每个工具分配权限,并校验每一个参数。让它跑起来只需要几分钟,让它安全才是大部分工作所在。
本文也有动画视频版。
使用 Spring AI 2.0,几个带注解的方法就能构建一个 MCP 服务器。
设置 spring.ai.mcp.server.protocol: STREAMABLE。如果没有这一项,Spring AI 2.0.1 会使用旧版 SSE 传输,访问 /mcp 会返回 404。
HTTP MCP 服务器是一个 OAuth 2 资源服务器。匿名请求会收到 401 响应,其中会告诉客户端授权服务器的位置。
通过 @PreAuthorize 为每个工具分配独立的 scope,确保只读 token 无法创建任何内容。
每一个参数都由模型生成,因此要像处理互联网输入一样校验它们。
观看视频:本文的 YouTube 视频版。

代码位于 spring-ai-mcp-task-board 仓库,每一步都对应一个 commit:GITHUB-LINK-HERE
MCP 是 Anthropic 于 2024 年 11 月推出的开放协议,用于将 AI 应用连接到工具和数据。AI 应用是宿主(host),例如 Claude Code、Claude Desktop 或你的 IDE。在宿主内部,每个客户端维护一条通往一个服务器的连接。我们的服务器提供三类能力:
Tools 是模型决定执行的操作,例如创建任务。控制权在模型手中。
Resources 是应用可以读取的数据,每项资源都有一个地址,例如 task://1。由应用或用户决定何时将资源交给模型。
Prompts 是用户选择的模板,通常可以从斜杠菜单中选取。我们提供的模板用来撰写站会进展汇报。
每条消息都使用 JSON-RPC 2.0。对于远程服务器,传输方式是 Streamable HTTP,只有一个端点:/mcp。客户端通过 POST 发送消息,服务器返回 JSON 响应体;如果还有后续内容要发送,则返回一个流。initialize 完成后,服务器会返回 Mcp-Session-Id 响应头,客户端在后续每次请求中都要带上它。

我们从 start.spring.io 开始,选择 Spring Web、Model Context Protocol Server 和 Validation,使用 Spring Boot 4.1.1 和 Java 25。关键依赖是面向 WebMVC 的 MCP server starter。最终 pom.xml 中相关的依赖如下:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
</dependency>
版本由 Spring AI 2.0.1 BOM 提供。
配置中指定了服务器名称、版本,以及客户端会展示给模型的一段使用说明:
spring:
ai:
mcp:
server:
name: task-board
protocol: STREAMABLE
version: 1.0.0
instructions: >
A small team task board. Use the tools to create,
list and complete tasks.
server:
port: 8080
容易漏掉的是 protocol: STREAMABLE。在 Spring AI 2.0.1 中,WebMVC starter 默认仍然通过 /sse 提供已弃用的 SSE 传输,访问 /mcp 会返回 404。
启动应用,发送每个客户端都会首先发送的 initialize 消息。响应包含服务器名称、版本、使用说明,以及它支持的能力:tools、resources 和 prompts。服务器已经能运行了,但还做不了任何事。
领域模型刻意保持简单。任务包含 id、标题、负责人、可选的截止日期,以及表示是否完成的 done 标记。TaskService 将任务保存在一个并发 map 中,支持创建、列出、查找和完成任务。
在真实应用中,这就是你已有的 service。MCP 只是叠加在现有业务代码之上的一层。
TaskTools 是一个 Spring 组件,通过依赖注入获取 service。每个工具都是一个带有 @McpTool 注解的方法。模型看到的是工具名称和描述,每个参数还会通过 @McpToolParam 获得各自的描述:
@McpTool(name = "create_task", description = "Create a task on the team board")
public Task createTask(
@McpToolParam(description = "Short title, e.g. 'Fix login bug'") String title,
@McpToolParam(description = "Who does it, e.g. 'ana'") String assignee,
@McpToolParam(description = "Due date as YYYY-MM-DD", required = false) String due) {
return tasks.create(title, assignee, parseDue(due));
}
这些描述不是代码注释,而是模型能看到的全部文档。描述含糊,模型就只能猜。所以,写每条描述时,都要像是在向新同事解释这个参数。
只读工具 list_tasks 还添加了一项提示:
@McpTool(name = "list_tasks", description = "List tasks, optionally only one person's",
annotations = @McpTool.McpAnnotations(readOnlyHint = true, destructiveHint = false))
客户端可以根据 readOnlyHint 之类的提示,减少向用户请求确认的次数。但它们只是提示,不是安全机制。
Spring AI 会将每个方法转换为工具,并根据 Java 类型和描述生成 JSON Schema。在 tools/list 中,create_task 的 title 和 assignee 是必填参数,due 则是可选参数。
[在此上传图片:mcp-tool-descriptions.png](替代文本:TaskTools.java,其中高亮显示了 @McpToolParam 的描述)
调用 create_task,传入标题、ana 和截止日期 2026-10-23,会以文本形式返回新建任务,id 为 1。列出 Ana 的任务时,也会返回这项任务。
更值得关注的是出错的情况。尝试完成不存在的任务 99 时,我们的 service 会抛出异常。Spring AI 会将异常转换为普通的工具结果,标记 isError: true,并附带文本 No task with id 99(Spring AI 2.0.1 会在文本中重复这条消息)。模型收到消息后可以自行纠正,而不至于让整个调用失败。
Tools 用于执行操作。对于数据,MCP 提供了 resources。TaskResources 中有一个带 @McpResource 注解的方法。它的地址是一个模板 task://{id},地址中的 id 会直接传入方法参数:
@McpResource(uri = "task://{id}", name = "Task",
description = "One task with all its details", mimeType = "text/markdown")
public String task(String id) {
// looks up the task and returns it as a small markdown document
}
在协议交互中,客户端先请求资源模板,看到 task://{id},然后读取 task://1。返回的是一小段 markdown 文档:
# Task 1: Fix login bug
- assignee: ana
- due: 2026-10-23
- status: open
它与工具的区别在于谁掌握控制权。模型决定调用哪个工具;应用或用户决定把哪项资源交给模型。
第三种基本能力是 prompt。TaskPrompts 中有一个带 @McpPrompt 注解的方法,它唯一的参数使用 @McpArg 注解,用来指定这是谁的站会汇报。方法会加载这个人的任务,将其转换为检查清单,再用一段指令包装起来。
客户端为 Ana 调用 prompts/get 时,会收到一条已经填入她真实任务的完整消息。用户选择模板,服务器提供事实数据。
现在,三种基本能力都已经实现,而且都能正常工作。但此时的服务器很危险。
这个客户端没有任何凭据,却能连接服务器,并将 Ana 的任务标记为完成:
只要能访问这个端口,任何人都可以读取所有任务、创建新任务,以及关闭其余任务。如果客户端是模型,那么它读到的文档只要让它这样做,它就会照做。MCP 服务器也是 API,需要同样的保护。
MCP 规范规定,HTTP 服务器是 OAuth 2.1 资源服务器:客户端从授权服务器获取 access token,并在每次请求中携带它。
在这个演示中,授权服务器是同一仓库里的一个小型 Spring Authorization Server,运行在 9000 端口,完全通过 properties 配置。它注册了两个客户端:agent 可以读写任务,dashboard 只能读取。两者都使用 client_credentials,这是面向机器间通信客户端的授权方式:
spring:
security:
oauth2:
authorizationserver:
issuer: http://localhost:9000
client:
agent:
registration:
client-id: agent
client-secret: "{noop}agent-secret" # dev only
authorization-grant-types: [client_credentials]
scopes: [tasks.read, tasks.write]
token:
access-token-time-to-live: 1h
dashboard:
registration:
client-id: dashboard
client-secret: "{noop}dashboard-secret"
authorization-grant-types: [client_credentials]
scopes: [tasks.read]
# ...
{noop} 密钥和保存在内存中的客户端仅用于本地开发。
回到任务服务器,添加两个依赖即可将它变成资源服务器:Spring AI 社区提供的 MCP security starter,以及 Spring Boot 的 OAuth 2 resource server starter。
<dependency>
<groupId>org.springaicommunity</groupId>
<artifactId>mcp-server-security-spring-boot</artifactId>
<version>0.1.14</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-oauth2-resource-server</artifactId>
</dependency>
接下来只需配置一个属性:issuer。任务服务器只会信任由该授权服务器签发的 token。
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:9000
现在,匿名客户端再次尝试访问,会收到 401:
HTTP/1.1 401
WWW-Authenticate: Bearer resource_metadata=http://localhost:8080/.well-known/oauth-protected-resource/mcp
这个 401 不只是拒绝访问。响应头还指向一份 Protected Resource Metadata 文档(RFC 9728),其中列出了我们的授权服务器。因此,即使没有预先配置,客户端也能发现该去哪里登录。
agent 使用自己的 client id 和 secret 完成身份认证,向授权服务器申请包含两个 scope 的 token:
curl -u agent:agent-secret http://localhost:9000/oauth2/token \
-d grant_type=client_credentials -d scope='tasks.read tasks.write'
{
"access_token": "eyJraWQiOiJiOGEyYzJhOS02YjY1…",
"scope": "tasks.write tasks.read",
"token_type": "Bearer",
"expires_in": 3599
}
access token 是一个经过签名的 JWT。解码后可以看到,issuer 是我们的授权服务器,subject 是 agent,scope claim 列出了两项权限。它在签发一小时后过期。任务服务器会从 issuer 地址下载密钥,用于验证签名。
带上这个 token 后,initialize 又能正常工作了,返回 200,并带有属于这次会话的 session id。
拥有有效 token,不应该意味着可以执行所有操作。dashboard 应该只能查看,不能修改。
我们在一个简单的 SecurityConfig 中通过 @EnableMethodSecurity 开启方法级安全控制,然后为每个工具指定所需的 scope。Spring Security 会将 JWT 的 scope claim 映射为以 SCOPE_ 开头的 authority,因此只需要一行注解:
@PreAuthorize("hasAuthority('SCOPE_tasks.write')")
@McpTool(name = "create_task", description = "Create a task on the team board")
public Task createTask(/* ... */) { /* ... */ }
创建和完成任务需要 tasks.write。列出任务和读取任务资源需要 tasks.read。由于 MCP security starter 提供了支持,@PreAuthorize 注解可以在 @McpTool 方法上生效。
现在,dashboard 的 token 仍然可以列出任务。但当它尝试创建任务时,调用会返回错误:
{
"content": [{ "type": "text", "text": "Access Denied\r\nAccess Denied" }],
"isError": true
}
模型能看到调用失败的原因,看板也不会发生变化。这就是最小权限原则:只负责读取的 Agent,拿到的 token 就只能读取。
大家容易忘记的一点是输入校验。每个工具参数都是模型生成的,而模型可能刚刚读过陌生人提供的文本,因此要像处理互联网输入一样对待它们。
service 会在操作看板之前检查每个参数。标题长度必须在 1 到 120 个字符之间。负责人必须符合用户名格式:简短、全小写,且不含空格或引号。看板还将任务数量限制为 1,000 个:
if (title == null || title.isBlank() || title.length() > 120) {
throw new IllegalArgumentException("title must be 1 to 120 characters");
}
if (assignee == null || !NAME.matcher(assignee).matches()) {
throw new IllegalArgumentException("assignee must be a lowercase username like 'ana'");
}
// ...
工具中还会单独检查截止日期。日期无效时,会返回一条明确说明正确格式的消息,而不是堆栈跟踪。
我们尝试了三次无效调用。空标题和看起来像 SQL 注入字符串的负责人参数都被拒绝,并返回说明规则的消息。将 next friday 作为截止日期时,会得到以下结果:
{
"content": [{ "type": "text", "text": "due must look like 2026-10-23\r\ndue must look like 2026-10-23" }],
"isError": true
}
模型读到规则后,会自行修正调用。
这个演示省略了四项对生产环境很重要的内容。第一项是 audience。我们 token 中的 aud 是客户端 agent,而不是我们的服务器。在生产环境中,token 应该明确指定它面向的服务器,而服务器应该拒绝所有指定了其他接收方的 token。否则,为一个服务签发的 token 就可能被拿到另一个服务上重放。MCP 规范要求执行这项检查,security starter 也支持它。
第二,不要将客户端的 token 透传给另一个 API。第三,保持较短的 token 有效期:演示中设为一小时没问题,对于 Agent,分钟级更合适。第四,如果客户端背后有真人用户,应使用带 PKCE(RFC 7636)的 OAuth 登录流程,这也是 MCP 客户端在应用内登录时使用的流程。Client credentials 用于机器。
那些看似枯燥的措施同样能保护你:限流、记录每一次工具调用、使用 HTTPS,以及用真正的数据库替代内存 map。
现在来连接一个真正的 Agent。我们在 Claude Code 中将该服务器注册为 HTTP MCP 服务器,配置服务器 URL,并通过 Authorization 请求头发送 agent 的 token。
接着,我们用自然英语让 Claude Code 为 Ana 添加一项任务:审查 MCP 安全相关的 pull request,周五截止,然后展示 Ana 尚未完成的任务。它找到了创建任务的工具。由于修改看板是一项实际操作,Claude Code 会先请求确认,并展示准确的参数,其中周五已经转换为具体日期。获得批准后,它会列出 Ana 的任务,并用表格回答,数据直接来自我们的 Java 服务器。
Spring AI 会将带注解的方法转换为 tools、resources 和 prompts,并处理协议交互。真正的工作是确保服务器安全:在端点上使用 OAuth 2,为每个工具分配 scope,并校验每一个参数。完整代码见上文,每一步都对应一个 commit。把它搭起来,试着把它弄坏,再接入你自己的服务。
Model Context Protocol 规范,2025-06-18 修订版
RFC 9728:OAuth 2.0 Protected Resource Metadata
MCP 授权(2025-06-18)
Spring AI MCP 服务器文档
Spring AI 社区:MCP 安全
RFC 7636:Proof Key for Code Exchange(PKCE)
更多系统设计和安全主题的动画讲解,尽在 jean code van debug YouTube 频道。你是 Java 开发者?欢迎加入 daily.dev 上的 Java devs squad。
如需采取进一步措施,可以考虑屏蔽此人和/或举报滥用行为。