MCP 服务端开发:把 Java 能力安全开放给 AI Agent
MCP 服务端开发:把 Java 能力安全开放给 AI Agent
摘要:本文以 Java 生态为背景,系统阐述了如何构建一个安全可落地的 MCP Server。核心围绕 Tools、Resources、Prompts 三类能力的工程化设计展开,强调从业务用例出发而非机械搬运内部 API,给出 Java 分层架构与依赖方向。在此基础上深入探讨 stdio 与 Streamable HTTP 的选型、基于 Spring AI/SDK 的窄 Tool 注册、Resource 的授权与搜索、Prompt 模板的安全约束、HTTP OAuth 与业务权限的分层实现,以及高风险操作的确认、幂等与审计。同时覆盖异常模型、超时与限流、配置版本管理、测试矩阵和性能指标,最后归纳常见误区与面试表达要点,帮助开发者把协议标准化能力转化为确定性的业务安全保障。
企业把 AI Agent 接入内部系统时,最容易走向两个极端:一种是让模型直接拼 HTTP 请求,把地址、认证头和历史 DTO 写进 Prompt;另一种是做一个“万能工具”,接收 URL、SQL 或脚本,由模型自由执行。前者脆弱且难以治理,后者则把模型的一次误判放大为真实系统操作。
Model Context Protocol(MCP)提供标准化的能力发现与调用接口,使客户端能够了解服务端有哪些工具、上下文资源和提示模板。不过,标准化协议不等于自动获得业务安全。MCP 的 HTTP 授权规范可以提供基于 OAuth 的传输层授权框架,但资源级权限、租户隔离、高风险审批、幂等和业务审计仍然是服务端实现责任。本文以 Java 与 Spring 体系为背景,从能力设计、分层实现、身份传递、输入输出约束到测试和运维,给出一套可落地的 MCP Server 工程方案。
一、先理解 MCP Server 的三类核心能力
服务端最常见的能力是 Tools、Resources 和 Prompts。它们不是同一种 RPC 的三个名字,而是控制权与用途不同的接口。
| 能力 | 主要用途 | 控制方式 | 例子 |
|---|---|---|---|
| Tools | 执行动作或计算,支持类型化输入输出 | 模型可以根据上下文选择调用 | 查询判题状态、创建工单、冻结库存 |
| Resources | 以 URI 暴露应用管理的上下文数据 | 应用选择读取和注入 | 规则文档、题目说明、只读状态快照 |
| Prompts | 服务端提供可参数化的提示模板 | 用户选择或触发 | 故障复盘模板、代码评审模板 |
“模型控制”不意味着模型拥有最终权限,只表示客户端可以让模型决定是否调用 Tool。服务端仍要认证调用方、校验参数和执行授权。“应用控制”也不意味着 Resource 可以公开读取,它仍需按身份过滤。“用户控制”的 Prompt 是可发现模板,不应偷偷执行有副作用的操作。
协议还包含能力协商、通知、日志、补全等机制。服务端在初始化阶段声明自己真正支持的能力,客户端据此使用,而不是假设所有 MCP Server 都具有全部特性。不要声明未完整实现的 capability,否则会出现“协商成功、调用却总是失败”的兼容问题。
二、从业务能力出发,而不是原样搬运内部 API
一个好的 Tool 应该窄、可解释、输入有界、输出稳定。例如活动和 OJ 系统可以提供 activity.get_detail、activity.check_stock、activity.reserve_stock、judge.get_submission。它们直接表达业务意图,并能针对对象做授权。
不建议提供 execute_sql、request_url、invoke_service 或 run_shell 等通用能力。它们把数据库、内网和主机攻击面交给模型,无法做细粒度资源授权,也很难审计“为什么发生这次修改”。即使工具描述写着“只查询当前租户”,服务端也必须从可信身份取得租户并追加查询条件;模型可能忽略提示,Prompt 也可能受恶意文本影响,安全控制只能依赖确定性代码。
Tool 粒度也不能过细。把“查询订单、判断状态、创建退款、发送通知”拆成四个底层工具,会让模型承担本应由应用服务保证的业务顺序。更适合暴露 refund.request 这一业务命令,由 Java 应用服务完成状态校验、幂等、事务和事件发布。协议能力是应用用例的入口,不是领域对象的 CRUD 映射。
三、Java 分层与依赖方向
MCP 协议对象不应进入领域核心。可以把系统分成四层:
MCP Transport / Session
|
MCP Handler(协议适配、Schema、结果包装)
|
Application Service(用例编排、事务、幂等、审批)
|
Domain / Gateway(规则与外部依赖)
|
数据库、HTTP 客户端、消息队列
Handler 解析协议参数、取得调用上下文、调用应用服务,再把稳定 DTO 转成 MCP 内容或结构化结果。应用服务不知道请求来自 MCP、REST 还是后台任务。这样未来升级 Java SDK、从 stdio 切换到 Streamable HTTP 时,无需修改领域规则。
public record GetSubmissionQuery(long submissionId) {}
public record SubmissionView(
long submissionId,
String status,
Integer timeMs,
Integer memoryKb,
Instant updatedAt) {}
public interface SubmissionQueryService {
SubmissionView get(GetSubmissionQuery query, CallerContext caller);
}
CallerContext 不由 Tool 参数构造,而由传输层认证结果创建,至少包含 subject、tenant、scopes、requestId 和必要授权属性。应用服务检查调用者能否查看该 submissionId 后再查询。Tool 入参里即使出现 tenantId,也只能作为业务筛选值并与可信租户交叉验证,不能覆盖 CallerContext。
包结构可以使用 mcp.handler、application、domain、infrastructure。MCP SDK 依赖只出现在 mcp 模块,ArchUnit 测试禁止 domain 和 application 依赖协议或 Spring Web 类型。
四、选择 stdio 还是 Streamable HTTP
本地桌面客户端或受控子进程通常适合 stdio:客户端启动 Java 进程,通过标准输入输出交换协议消息。它部署简单,没有开放网络端口,凭据从启动环境、受控配置或操作系统密钥存储传入。stdio 不采用 HTTP OAuth 授权流,但不能因此把云端密钥硬编码在普通配置或命令参数中。
远程、多用户和集中部署适合 Streamable HTTP。它支持网络访问、连接管理和标准 HTTP 安全设施,也需要 TLS、反向代理、Host/Origin 校验、限流和授权配置。旧式 HTTP+SSE 可能仍存在于历史客户端,新增系统应根据当前客户端兼容矩阵选择规范推荐传输,不要无需求地维护三套入口。
| 场景 | 推荐 | 主要风险 |
|---|---|---|
| 单机开发工具、客户端拉起进程 | stdio | 环境凭据、进程权限、stdout 污染 |
| 企业共享服务、多租户访问 | Streamable HTTP | 网络暴露、OAuth、会话与限流 |
| 历史客户端兼容 | 有期限保留旧传输 | 双协议维护、行为差异 |
stdio 服务不能把普通日志写到 stdout,否则会污染协议帧;日志应写 stderr 或独立日志系统。HTTP 服务则要限制请求体和响应体大小,设置读取、执行与空闲超时,并正确处理客户端断开。
五、用 Java SDK 注册窄 Tool
Java MCP SDK 提供同步和异步 API,并支持纯 Java 传输;较新的生态中,Spring 相关 WebMVC/WebFlux 传输由 Spring AI 提供。具体类名会随 SDK 版本演进,项目应通过 BOM 固定兼容版本,以锁定版本的官方示例为准。下面代码重点表达处理边界:
McpServerFeatures.SyncToolSpecification getSubmissionTool(
SubmissionQueryService service,
CallerContextResolver contexts) {
String schema = """
{
"type": "object",
"properties": {
"submissionId": {
"type": "integer",
"minimum": 1
}
},
"required": ["submissionId"],
"additionalProperties": false
}
""";
return ToolSpecifications.sync(
"judge.get_submission",
"查询当前调用者有权查看的判题结果",
schema,
(exchange, request) -> {
CallerContext caller = contexts.from(exchange);
long id = StrictArguments.of(request.arguments())
.requiredLong("submissionId");
SubmissionView view = service.get(
new GetSubmissionQuery(id), caller);
return ToolResults.structured(view);
});
}
示例中的 ToolSpecifications 和 ToolResults 可以是项目对具体 SDK 的薄封装。JSON Schema 是第一道输入约束,不是全部校验。服务端仍需防数值溢出、过长字符串、Unicode 规范化问题、非法枚举组合和业务越权。additionalProperties 设为 false 可以尽早发现拼错字段,也减少模型意外传入敏感参数。
输出采用小而稳定的 DTO,为集合长度、文本长度和嵌套深度设上限。不要直接返回 JPA 实体、Feign 响应或异常堆栈。Tool 描述、Schema 和实际输出应同步,可以用契约快照测试发现无意变更。
六、Resources:用 URI 提供受控上下文
Resources 适合提供应用维护的只读上下文,例如 oj://problems/1001/statement、policy://judge/runtime-limits/java 或 runbook://services/judge-worker。URI 是能力命名与寻址方式,不是绕过授权的直链。
ResourceContent readProblem(String uri, CallerContext caller) {
ProblemResourceKey key = resourceUris.parseProblem(uri);
authorization.requireProblemReadable(
caller.subject(), caller.tenantId(), key.problemId());
ProblemStatement statement = problemQuery.getPublished(key.problemId());
String markdown = renderer.renderForAgent(statement);
if (markdown.length() > limits.maxResourceChars()) {
throw new ResourceTooLargeException(uri);
}
return ResourceContent.text(uri, "text/markdown", markdown);
}
不要把任意文件路径或任意 URL 映射成 Resource。file、http 通用读取器容易带来目录穿越、SSRF 和云元数据泄露。若要暴露文档库,应使用内部资源 ID 到受控存储对象的映射,校验规范化路径,限制 MIME 类型、体积和读取时间。
资源列表本身也会泄露信息。用户无权读取工单时,listResources 不应返回标题和 URI。海量资源不要一次列出,应使用资源模板、搜索或分页,并把权限过滤下推到数据库,而不是取出全部后内存过滤。
七、Prompts:模板不是隐藏指令后门
Prompt 能力可以提供“生成故障复盘”“按团队规范审查代码”等参数化模板。模板参数需要 Schema 和长度限制,渲染结果应允许用户检查。不要在模板中嵌入密钥、内部系统指令或绕过审批的暗示。
prompt: incident.review
arguments:
service: judge-worker
timeRange: 2026-07-01T10:00:00Z/2026-07-01T10:30:00Z
rendered intent:
汇总指定时段告警、变更、影响和恢复证据;
对缺失数据标注“未确认”,不得臆测根因。
Prompt 内容应有版本和负责人。修改模板可能改变模型行为,应像代码一样评审和回归。用户参数始终是不可信文本;模板中的防注入提示只能降低风险,不能替代 Tool 权限。有副作用的操作仍由 Tool Handler 与应用服务的确定性校验控制。
模板若引用 Resource,应保持来源可追踪。在审计信息中保留资源 URI、版本或摘要哈希,便于复现模型为何得出某个结论。
八、HTTP 授权与业务权限必须分层
MCP 的 HTTP 授权规范提供基于 OAuth 的授权框架,包括受保护资源元数据、授权服务器发现和访问令牌使用。它解决客户端如何获得并向 MCP Server 呈现访问凭据。实现时遵循当前协议版本和所用 SDK 的授权扩展,不要自创 query 参数 token。
但有效访问令牌只完成第一步。服务端仍必须:
- 根据令牌建立 subject 与 tenant,不信任模型传入的 userId;
- 校验 scope 是否允许调用某类 Tool;
- 校验当前主体是否能访问具体 submissionId、orderId;
- 高风险写操作检查角色、金额上限、审批状态和环境;
- 记录可关联真实身份与 Agent 会话的审计事件。
public Reservation reserve(ReserveCommand command, CallerContext caller) {
scopeAuthorizer.require(caller, "inventory:reserve");
tenantGuard.requireSameTenant(caller.tenantId(), command.activityId());
policy.requireReservableBy(caller.subject(), command.activityId());
if (command.quantity() > approvalThreshold) {
approvalService.requireApproved(
caller.subject(),
command.approvalId(),
command.fingerprint());
}
return reservationService.reserve(
command, caller.idempotencyKey());
}
客户端凭据与最终用户身份也要区分。企业 MCP Client 可能以应用身份连接,却代表不同用户调用。若系统只看到共享服务账号,资源审计会失真。优先使用能表达授权主体的机制;无法传递用户身份时,至少限制为低风险只读能力,或建立受签名的受控委托上下文。
stdio 的凭据来自受控环境,不经过 HTTP OAuth 流。仍要最小权限、定期轮换并避免子进程继承无关环境变量。长期 Token 不能放入 Agent 可读取的普通 Resource。
九、高风险 Tool 需要确认、幂等与审计
删除数据、退款、发布配置等操作不应只依赖模型一句自然语言判断。可以采用“计划与执行分离”:prepare 返回规范化操作摘要、风险和短期一次性 actionToken;用户或审批系统确认后,execute 携带令牌执行。
refund.prepare -> 返回:
actionId、订单、金额、原因、影响、expiresAt、fingerprint
人工或策略审批 -> 绑定 actionId 与 fingerprint
refund.execute -> 再次校验:
身份、权限、审批、过期时间、参数指纹、幂等键、订单当前状态
actionToken 要绑定具体参数与调用者,防止审批后替换金额或订单。执行前重新检查资源状态,并用数据库条件更新推进状态机。幂等键由稳定业务操作或客户端请求生成,在数据库设唯一索引;模型重试同一 Tool 时返回第一次结果,而不是产生第二次效果。
审计日志至少包括 requestId、sessionId、subject、tenant、toolName、toolVersion、参数摘要、资源 ID、授权决策、审批 ID、结果码、耗时和幂等命中。敏感参数使用哈希或脱敏摘要,不能为了审计把密码和完整业务数据落日志。审计存储本身也要访问控制和防篡改。
十、异常模型:对 Agent 可行动,对运维可定位
把 Java 堆栈塞进 Tool 结果既泄露内部信息,也不能帮助 Agent 正确恢复。建议定义稳定错误分类:
| 类别 | 示例 | 客户端可采取动作 |
|---|---|---|
| INVALID_ARGUMENT | submissionId 非正数 | 修正参数,不重试原值 |
| NOT_FOUND | 资源不存在或不可见 | 停止猜测,向用户确认 |
| PERMISSION_DENIED | scope 或权限不足 | 请求授权,不尝试绕过 |
| CONFLICT | 当前状态不允许操作 | 重新读取状态 |
| RATE_LIMITED | 超过配额 | 在 retryAfter 后重试 |
| DEPENDENCY_UNAVAILABLE | 下游短暂故障 | 有界退避 |
| INTERNAL | 未分类故障 | 停止循环并提供 requestId |
对客户端返回安全消息、类别、是否可重试、retryAfter 和 requestId;详细堆栈只进服务日志。不要把数据库“无行”一律映射为 NOT_FOUND,因为权限过滤也可能产生无行。为避免资源枚举,某些场景对外统一表现为不可见,内部审计记录真实授权原因。
重试受总步骤、截止时间和预算限制。写 Tool 网络超时属于“不确定结果”,再次调用必须使用相同幂等键。服务端应能按该键查询最终状态,而不是只返回模糊的“请重试”。
十一、超时、并发、限流与输出大小
Agent 可能并发尝试多个工具,也可能在错误循环中重复调用。服务端需要按主体、租户、Tool 和资源设置限流,对昂贵 Tool 限制并发。总超时沿调用链传播,不能 MCP Handler 允许 60 秒、内部 HTTP 各自重试三次,最终占满线程池。
查询 Tool 可设置较短超时和有限重试;写 Tool 谨慎自动重试并依赖幂等。耗时任务适合返回 operationId,再通过只读工具查询进度,而不是让一个连接长期占线程。取消到达时尽量停止下游工作,但业务一致性仍由状态机保证。
响应设置硬上限。数据库列表先分页,日志只返回时间窗摘要,文件通过 Resource URI 引用,而不是内嵌几十兆文本。返回过大会增加序列化耗时、模型 Token 成本与提示注入面。推荐同时提供 machine-readable 字段和精简摘要。
十二、配置与版本管理
项目应固定 MCP SDK、Spring AI 和 Spring Boot 兼容版本,不使用动态版本。较新的 Java SDK 把 Spring WebMVC/WebFlux 传输放在 Spring AI 生态,核心 SDK 提供框架无关传输。选择一套并通过 BOM 管理,不要混用不同代际示例。
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp-bom</artifactId>
<version>${mcp.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
Tool 名、Schema 和语义也需要版本策略。可兼容地增加可选输出字段通常比修改字段含义安全;删除参数或改变单位应发布新 Tool 名或明确版本。服务端声明实现版本,审计记录 toolVersion,客户端升级前跑契约测试。
密钥来自环境或密钥管理系统,开发示例只给占位符。生产关闭详细错误响应,配置允许 Host、Origin、请求体上限和受信代理。不同环境使用独立客户端注册与 scope,避免测试 Agent 误连生产。
十三、测试矩阵:协议正确只是起点
单元测试覆盖参数解析、业务授权、错误翻译和输出裁剪;协议集成测试使用真实 MCP Client 与 Server 完成初始化、能力发现、Tool 调用、Resource 读取和 Prompt 获取;安全测试验证未授权、跨租户、伪造 userId、超大参数、未知字段、路径穿越、SSRF 和重复写。
@Test
void toolCannotReadAnotherTenantsSubmission() {
CallerContext caller = fixture.caller("tenant-a", "user-1");
long other = fixture.submission("tenant-b");
assertThrows(PermissionDenied.class,
() -> queryService.get(
new GetSubmissionQuery(other), caller));
}
@Test
void repeatedReserveReturnsSameBusinessResult() {
IdempotencyKey key = new IdempotencyKey("req-20260719-001");
Reservation first = tool.reserve(command, caller.with(key));
Reservation second = tool.reserve(command, caller.with(key));
assertEquals(first.id(), second.id());
assertEquals(1, repository.countByIdempotencyKey(key));
}
还要测试客户端断开、下游超时、授权元数据不可用、令牌过期、并发限流、优雅关闭和协议版本不兼容。stdio 测试验证 stdout 只有协议内容;HTTP 测试验证可信代理、Host/Origin 检查和请求大小限制。
Prompt 注入测试应把恶意文本分别放入 Resource、Tool 输出和用户参数,验证它无法越过服务端权限,也不能取得未授权数据。目标不是证明模型永不受骗,而是证明模型受骗后仍没有越权能力。
十四、性能与成本指标
指标至少分协议、业务和下游三层。协议层观察活跃会话、初始化失败、能力调用量、请求与响应大小;业务层观察每个 Tool 的成功率、错误分类、幂等命中、授权拒绝和审批次数;下游层观察数据库、HTTP、缓存和队列延迟。
按 Tool 记录 P50、P95、P99,但不要把用户 ID 和资源 ID 作为时序标签。对 Agent 成本,还应记录响应字符数或估算 Token、无效重复调用次数和被裁剪结果数。查询即使只耗时 50 毫秒,返回十万字也可能是昂贵且危险的设计。
容量测试使用可复现的混合分布:资源读取、缓存命中、慢下游、权限拒绝和写幂等;逐步增加并发,观察线程池、事件循环、连接池、GC、限流和下游放大。本文不虚构吞吐数字,实际阈值由部署规格、传输方式和业务依赖共同测定。
十五、常见误区
第一,把 MCP 当作“给 HTTP 接口换个壳”。如果 Tool 仍暴露旧 DTO 和万能路径,协议标准化没有解决业务耦合。第二,认为 MCP 完全没有授权,或反过来认为启用 OAuth 后自动完成所有安全。准确边界是:HTTP 传输可使用规范授权框架,业务资源授权、租户隔离、审批与审计仍由实现负责;stdio 不走这套 HTTP 流程。
第三,从 Tool 参数读取 userId、tenantId 作为可信身份。第四,给模型任意 SQL、URL、文件路径或脚本执行器。第五,只限制 Prompt,不在服务端执行权限校验。第六,高风险写操作没有审批和幂等,客户端超时重试造成重复效果。
第七,把完整实体、日志和堆栈返回模型,导致数据泄露与 Token 浪费。第八,在 stdout 输出普通日志破坏 stdio。第九,不固定 SDK 版本,复制不同版本示例后依赖冲突。第十,只做成功调用测试,没有跨租户、超时、断线、重复调用和恶意 Resource 用例。
十六、延伸与方案表达
面试中可以先区分:Tool 是模型可选择调用的类型化动作,Resource 是应用控制的 URI 上下文,Prompt 是用户选择的参数化模板。然后说明 MCP 负责发现、协商和调用标准化,不替代业务服务的认证授权与审计。
若被问如何开放退款能力,可以回答:不暴露通用 HTTP 工具;定义 prepare 与 execute;可信身份来自 HTTP 令牌或受控 stdio 环境;Handler 只做协议适配;应用服务校验 scope、租户、订单归属和状态;审批绑定参数指纹;唯一键保证幂等;结果结构化且脱敏;审计记录决策链;最后用跨租户和重复执行测试验证。
进一步可以讨论 stdio 与 Streamable HTTP 的取舍、Resource 列表泄露、Prompt 注入、不确定写结果对账、Schema 演进与限流维度。高质量回答的重点是把协议层能力和确定性业务控制分开。
十七、总结
可生产使用的 Java MCP Server,核心不是注册多少工具,而是为 Agent 建立一组窄、稳定、有权限边界的业务能力。Tools、Resources、Prompts 各有控制语义;协议 Handler 与应用服务分层;身份来自可信传输上下文;输入通过 Schema 和业务规则双重校验;输出被结构化、裁剪和脱敏;写操作具备审批、幂等与审计。
MCP 让能力发现与调用标准化,HTTP 授权规范也提供了可互操作框架,但最终能否安全上线,仍取决于服务端是否执行资源级授权、租户隔离、错误边界、容量保护和故障验证。把这些责任放回确定性的 Java 代码,Agent 才是在受控范围使用能力,而不是获得通往内部系统的万能入口。
参考资料
- MCP 官方 Server Concepts:能力分类与控制语义;
- MCP 官方 Authorization 规范:HTTP 授权与受保护资源发现;
- MCP Java SDK 官方文档:同步/异步 Server、传输和能力注册;
- Spring AI 官方 MCP 文档:Spring Boot 集成与传输配置。
版本与 API 会演进,实际项目应以锁定版本的官方文档和兼容矩阵为准,并在升级前运行协议契约与安全回归测试。
更多推荐

所有评论(0)