第 19 章
MCP 深度开发——把智能体能力暴露给整个生态
本章基线:项目
smart-doc-assistant| 端口8090| LangChain4j1.22.0+ 实验模块1.22.0-beta32| JDK21| 最后验证2026-10-28
本章目标:
- 理解为什么需要“造自己的 MCP Server”:Ch17 是用别人的,本章是造自己的
- 掌握一个关键事实:LangChain4j 1.22.0 核心模块没有 Server SDK,必须用社区包
langchain4j-community-mcp-server(官方文档明确指引,本章实测验证)- 掌握两种传输的实战:stdio(Claude Desktop 子进程方式)+ Streamable HTTP(Spring Boot 嵌入式)
- 掌握官方 SDK 的局限:只支持 Tools 原语(
@Tool);本章自实现McpJsonRpcDispatcher把 Resources / Prompts 也接上- 掌握安全与审计(呼应 Ch16):API Key 鉴权 + 工具白名单 + 调用审计 + 敏感字段 redact
- 记录一组P0 工程修复:JSON-RPC id 类型保留、空 API Key 万能 Key 漏洞、stdio 日志污染协议流、审计无上限
- 理解配置即代码的真相源设计:
KnowledgeBaseMcpTools.TOOL_NAMES单一来源驱动白名单
19.1 为什么需要自建 MCP Server
Ch17 讲的是用别人的 MCP Server(Node.js 写的 demo-server.mjs,stdio 传输)。本章讲的是造自己的 MCP Server——把 Java/Spring Boot 项目的能力(RAG / 数据库 / 内部 API)暴露为标准协议,让任何 MCP 客户端都能用:
┌─────────────────────────────────────┐
│ 你的业务能力 │
│ ├─ RAG 知识库(Ch08/Ch10-Ch13) │
│ ├─ Agent 工具集(Ch06/Ch14) │
│ ├─ Skills 技能库(Ch18) │
│ └─ 内部 API(业务系统) │
└─────────────┬───────────────────────┘
│ MCP 协议(JSON-RPC 2.0)
↓
┌──────────────────────────────────────────────┐
│ 你的 MCP Server(本章 v0.17 新增) │
│ ├─ 3 个 @Tool:searchKnowledgeBase 等 │
│ ├─ 4 个 Resource:docs:// 协议 │
│ ├─ 3 个 Prompt:review-code 等模板 │
│ └─ 安全:API Key + 工具白名单 │
└─────────────┬────────────────────────────────┘
│
┌────────────────────┼────────────────────┐
↓ ↓ ↓
Claude Desktop Cursor IDE 其他 LangChain4j 客户端
(stdio 子进程) (stdio) (Streamable HTTP)
三种典型业务场景:
| 场景 | 价值 |
|---|---|
| 企业内知识库对全公司开放 | 把 Confluence / Notion / 内部文档包装为 MCP Server,员工在 Claude/Cursor 里直接问 |
| AI 产品矩阵互操作 | 一个产品的 Agent 可以调用另一个产品暴露的 MCP Server,避免重复造轮子 |
| 调试与可观测性 | 把内部工具的调用记录、token 用量通过 MCP 协议统一暴露给运维仪表盘 |
Spring AI 对比:Spring AI 1.0 同样支持 MCP,但 Server 端主要走 Spring AI MCP Server 扩展(基于
spring-ai-starter-mcp-server),绑定 Spring WebFlux。LangChain4j 的方案更轻——stdio 模式不需要任何 Web 框架,HTTP 模式直接复用你已学的 Spring MVC。两种生态在协议层完全兼容。
19.2 关键事实:官方 SDK 的位置(实测验证)
写本章前必须先回答:LangChain4j 到底有没有 MCP Server SDK?
官方文档(https://docs.langchain4j.info/tutorials/mcp-stdio-server )明确指出:
LangChain4j provides an MCP client (
langchain4j-mcp) for connecting to MCP servers.
If you want to build a Java-based MCP stdio server (a local subprocess launched by an MCP client),
use the community module:langchain4j-community-mcp-server.
实测(写代码前的核实——方法论铁律):
# 1. 核心模块 langchain4j-mcp: 173 个 class,全部 client/transport/protocol/registry 侧,无 server 包
$ jar tf langchain4j-mcp-1.22.0-beta32.jar | grep -c '\.class$'
173
$ jar tf langchain4j-mcp-1.22.0-beta32.jar | grep -i server
dev/langchain4j/mcp/client/McpServerInfo.class # client 包里的"服务器信息"消息
dev/langchain4j/mcp/protocol/McpServerDiscoverParams.class
dev/langchain4j/mcp/protocol/McpServerDiscoverRequest.class # 1.22 新增:MCP Registry 发现协议
dev/langchain4j/mcp/protocol/McpServerDiscoverResponse.class
dev/langchain4j/mcp/protocol/McpServerDiscoverResponse$Result.class
dev/langchain4j/mcp/protocol/McpServerMethod.class
dev/langchain4j/mcp/registryclient/model/McpGetServerResponse.class
dev/langchain4j/mcp/registryclient/model/McpServer.class # Registry 里的服务器条目,不是 Server SDK
dev/langchain4j/mcp/registryclient/model/McpServerList.class
dev/langchain4j/mcp/registryclient/model/McpServerListRequest.class
dev/langchain4j/mcp/registryclient/model/McpServerListRequest$Builder.class
# 11 条全部命中 "server" 关键字,但没有一条是 Server SDK
$ jar tf langchain4j-mcp-1.22.0-beta32.jar | grep -c 'mcp/server/'
0
# 2. 社区模块 langchain4j-community-mcp-server: 4 个 class(3 个顶层 + 1 个匿名内部类),就是 Server SDK
$ jar tf langchain4j-community-mcp-server-1.22.0-beta32.jar | grep '\.class$'
dev/langchain4j/community/mcp/server/McpServer$1.class # 匿名内部类
dev/langchain4j/community/mcp/server/McpServer.class
dev/langchain4j/community/mcp/server/McpToolSchemaMapper.class
dev/langchain4j/community/mcp/server/transport/StdioMcpServerTransport.class
结论:
- 官方 Server SDK 在社区模块
dev.langchain4j:langchain4j-community-mcp-server(走 beta 版本线,1.22.0-beta32) - 1.22.0-beta32 这版仍然只支持 stdio 传输(
StdioMcpServerTransport),无官方 HTTP 实现——从 1.19 到 1.22,这几个类一直没变 - 只支持 Tools 原语(
@Tool注解),无@McpResource/@McpPrompt——Resources/Prompts 要自实现
坑点:写本章前我差点下了“LangChain4j 没有 Server SDK” 的错误结论。实测 + 看官方文档原文 才发现是社区包而不是核心包。永远不要凭印象写 API——这是你的方法论里“写代码前先核实”的核心铁律。
19.3 协议回顾(30 秒)
MCP 是 JSON-RPC 2.0 over stdio 或 Streamable HTTP,Ch17 已讲过。本章只列骨架,便于后续引用:
sequenceDiagram
participant C as MCP Client
participant S as MCP Server
Note over C,S: 握手阶段
C->>S: initialize (协议版本/能力协商)
S-->>C: serverInfo + capabilities
C->>S: notifications/initialized
Note over C,S: 工具调用
C->>S: tools/list
S-->>C: tools[] (JSON Schema)
C->>S: tools/call {name, arguments}
S-->>C: {content: [...]}
Note over C,S: Resources / Prompts(本章扩展)
C->>S: resources/list
S-->>C: resources[]
C->>S: resources/read {uri}
S-->>C: {contents: [...]}
C->>S: prompts/list
S-->>C: prompts[]
C->>S: prompts/get {name, arguments}
S-->>C: {messages: [...]}
JSON-RPC 2.0 响应三态:
| 字段组合 | 含义 |
|---|---|
{"result": ...} |
成功响应 |
{"error": {"code": -32xxx, "message": "..."}} |
错误响应 |
id 为 null |
通知类消息,不需要响应 |
id 类型允许 number / string / null——这是 P0-2 修复的源头:早期版本强转 long,客户端用字符串 id 时收不到关联响应。
但官方 SDK 的 Tools 分支是例外:
McpServer.handle()内部的extractId()只在idNode.isNumber()时返回值,字符串 id 会让它直接返回null——HTTP 层表现为 200 OK + 空响应体。本章自实现的 Resources / Prompts 分支没有这个限制。 详见 19.9.1 的提示和坑点 16。
19.4 引入依赖
smart-doc-assistant/pom.xml:
<!--
Ch19 MCP 深度开发:服务端 SDK(社区模块)。
仅含 stdio 传输(StdioMcpServerTransport)+ 协议层 + Jackson;
传递依赖 langchain4j-mcp 已由 Ch17 引入,本章不再额外加。
HTTP 传输由本章自实现(基于 Spring MVC + McpServer.handle(JsonNode))。
StdioMcpServerTransport 把 JSON-RPC 协议写 System.out;
日志必须走 System.err,否则污染协议流导致客户端断开。
-->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-community-mcp-server</artifactId>
<version>${langchain4j.beta.version}</version>
</dependency>
版本对齐:${langchain4j.beta.version} = 1.22.0-beta32,与 langchain4j-mcp(同一条 beta 线)保持一致。注意别把它和核心的 1.22.0 混成一个属性——稳定模块和实验模块是两条发布线,混用会 404。核心的 1.22.0 由 pom 顶部的 <dependencyManagement> 钉住(见 Ch01),社区模块不在其中,所以这里必须显式写版本。
19.5 实战一:stdio MCP Server(Claude Desktop 方式)
stdio 模式是 Claude Desktop / Cursor / Gemini CLI 等本地 MCP 客户端的首选方式:客户端启动一个 Java 子进程,通过 stdin/stdout 走 JSON-RPC。本章先做这个。
19.5.1 KnowledgeBaseMcpTools:把 @Tool 暴露给外部
这是连接“业务能力”与“MCP 协议”的桥梁。com/example/assistant/mcp/KnowledgeBaseMcpTools.java:
@Slf4j
public class KnowledgeBaseMcpTools {
/**
* 工具白名单的单一真相源。{@link McpSecurityService} 直接引用此列表,
* 加新工具时只需在这里加方法 + 在此列表里加名字,不会有两个真相源漂移的风险。
*/
public static final List<String> TOOL_NAMES = List.of(
"searchKnowledgeBase", "askWithRag", "getKnowledgeBaseStats"
);
private final EmbeddingModel embeddingModel;
private final EmbeddingStore<TextSegment> embeddingStore;
private final RetrievalAugmentor retrievalAugmentor;
public KnowledgeBaseMcpTools(EmbeddingModel embeddingModel,
EmbeddingStore<TextSegment> embeddingStore,
RetrievalAugmentor retrievalAugmentor) {
this.embeddingModel = embeddingModel;
this.embeddingStore = embeddingStore;
this.retrievalAugmentor = retrievalAugmentor;
}
@Tool("查询本地知识库,返回与问题最相关的文档片段(语义检索)。用于回答基于文档的事实性问题。")
public String searchKnowledgeBase(
@P("用户的自然语言问题,例如:'七天无理由退货的条件是什么?'") String question,
@P("返回片段数量,1 到 10 之间;常用 3 或 5") Integer topK,
@P("最低相似度阈值,0 到 1 之间;0 表示不过滤,0.5~0.7 是常用起点") Double minScore) {
String q = requireQuestion(question);
// ... 复用 Ch08 的 DocumentSearchService.search() 相同逻辑 ...
int k = (topK == null || topK < 1) ? 3 : Math.min(topK, 10);
double threshold = (minScore == null) ? 0.0 : Math.max(0.0, Math.min(1.0, minScore));
Embedding queryEmbedding = embeddingModel.embed(q).content();
EmbeddingSearchRequest request = EmbeddingSearchRequest.builder()
.queryEmbedding(queryEmbedding).maxResults(k).minScore(threshold).build();
List<EmbeddingMatch<TextSegment>> matches = embeddingStore.search(request).matches();
// ... 格式化返回 ...
}
@Tool("高级 RAG 检索:复用父子分块 + 向量/关键词混合 + reranker,返回精排后的文档片段。比纯向量检索质量更高,但更慢。")
public String askWithRag(
@P("用户的自然语言问题") String question,
@P("返回片段数量,常用 3 或 5") Integer topK,
@P("最低相似度阈值,0 到 1 之间;默认 0 不过滤") Double minScore) {
// ... 复用 Ch12 的 RetrievalAugmentor.augment() 相同逻辑 ...
}
@Tool("查询知识库统计信息:embedding 向量维度、当前 embedding 模型与向量库实现。")
public String getKnowledgeBaseStats() {
return String.format("知识库统计:\n- Embedding 维度: %d\n- Embedding 模型: %s\n- Vector Store: %s",
embeddingModel.dimension(),
embeddingModel.getClass().getSimpleName(),
embeddingStore.getClass().getSimpleName());
}
/** P2-5 修复:question null/blank 校验,避免 embeddingModel.embed(null) 抛 NPE */
private String requireQuestion(String question) {
if (question == null || question.isBlank()) {
log.warn(" [MCP] tool called with empty/null question");
return "";
}
return question;
}
}
关键设计点:
- 复用 Ch08/Ch12 的 Bean:
EmbeddingModel/EmbeddingStore/RetrievalAugmentor三个 Bean 在主应用里已有,stdio 启动器直接getBean()取出,零代码重复 - TOOL_NAMES 单一真相源:方法名
@Tool("...")是工具的“对外身份”,TOOL_NAMES列表是“允许谁调用”,两处必须同步——用静态常量固化 - 不加
@Component:stdio 启动器是独立进程,由McpServerStarter显式new,避免与 Spring 上下文里的同名 Bean 冲突 - 参数校验前置:避免
embed(null)NPE 让整个工具调用链路断裂
19.5.2 McpServerStarter:stdin/stdout 入口
com/example/assistant/mcp/McpServerStarter.java 核心:
public final class McpServerStarter {
public static void main(String[] args) throws Exception {
// P0-5 修复:把 Logback 输出重定向到 stderr,stdout 只剩 JSON-RPC 协议流
System.setProperty("org.springframework.boot.logging.LoggingSystem", "none");
redirectLogbackToStderr();
// 精准加载 Spring 上下文(不拉 Tomcat/Actuator/HTTP 客户端)
AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext();
StdioMcpServerTransport transport = null;
try {
ctx.register(AppConfig.class);
// 子进程不会像 SpringApplication 那样自动读 application.yml,
// 手动灌进 Environment,否则 @Value 全部落到注解默认值
loadClasspathYaml(ctx);
ctx.refresh();
EmbeddingModel embeddingModel = ctx.getBean(EmbeddingModel.class);
EmbeddingStore<TextSegment> embeddingStore = ctx.getBean(EmbeddingStore.class);
RetrievalAugmentor retrievalAugmentor = ctx.getBean(RetrievalAugmentor.class);
// P2-4 修复:通过工厂方法复用,避免与 McpServerConfig 重复装配代码
McpServer server = McpServerFactory.create(embeddingModel, embeddingStore, retrievalAugmentor);
transport = new StdioMcpServerTransport(server);
// 阻塞直到客户端断开
transport.awaitClose();
} finally {
// P0-4 修复:显式按反顺序释放——先 transport.close() 再 ctx.close(),
// 避免 try-with-resources 在异常路径下漏掉释放
if (transport != null) transport.close();
ctx.close();
}
}
}
关键约束:日志必须走 stderr
private static void redirectLogbackToStderr() {
LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory();
loggerContext.reset(); // 清掉默认 stdout appender
ConsoleAppender<ILoggingEvent> appender = new ConsoleAppender<>();
appender.setContext(loggerContext);
appender.setTarget("System.err"); // ← 关键
// ... PatternLayoutEncoder 配置 ...
appender.start();
loggerContext.getLogger(Logger.ROOT_LOGGER_NAME).addAppender(appender);
}
为什么必须这样:stdio 传输下,StdioMcpServerTransport 把 JSON-RPC 消息写到 System.out。任何 SLF4J / Logback / Jackson 默认日志输出到 stdout 会污染协议流——客户端解析失败立即断开。
最佳实践:stdio 启动器里只允许
System.err.println(...)与 SLF4J(已重定向)写日志,其他所有日志框架都得关掉。Spring Boot 自动 Logback 配置会被org.springframework.boot.logging.LoggingSystem=none关闭,然后我们编程式接管。
19.5.3 客户端配置(Claude Desktop 示例)
~/Library/Application Support/Claude/claude_desktop_config.json:
{
"mcpServers": {
"smart-doc-assistant": {
"command": "java",
"args": ["-jar", "/path/to/smart-doc-assistant-0.16.0-SNAPSHOT-mcp.jar"],
"env": {
"DEEPSEEK_API_KEY": "sk-xxx",
"DASHSCOPE_API_KEY": "sk-xxx"
}
}
}
}
注意这里必须是 -jar,不能写成 -cp xxx.jar com.example.assistant.mcp.McpServerStarter。原因见 19.9.2:mvn package 出来的是 Spring Boot fat jar,类在 BOOT-INF/classes/、依赖在 BOOT-INF/lib/,-cp 根本看不见它们,启动会直接 ClassNotFoundException。
19.6 实战二:Streamable HTTP 传输(远程调用方式)
stdio 适合本地子进程,生产环境通常需要远程 MCP Server——客户端通过 HTTP 调用。本章用 Spring MVC 自实现 HTTP 传输(官方 SDK 没提供)。
19.6.1 McpServerConfig:Spring Bean 装配
@Configuration
public class McpServerConfig {
/**
* 装配 MCP Server Bean(含 {@link KnowledgeBaseMcpTools#TOOL_NAMES} 三个 @Tool 方法)。
* 与 stdio 启动器共用 McpServerFactory.create(),保证双传输行为完全一致。
*/
@Bean
public McpServer mcpServer(EmbeddingModel embeddingModel,
EmbeddingStore<TextSegment> embeddingStore,
RetrievalAugmentor retrievalAugmentor) {
return McpServerFactory.create(embeddingModel, embeddingStore, retrievalAugmentor);
}
@Bean public McpResourcesProvider mcpResourcesProvider() { return new McpResourcesProvider(); }
@Bean public McpPromptsProvider mcpPromptsProvider() { return new McpPromptsProvider(); }
@Bean
public McpJsonRpcDispatcher mcpJsonRpcDispatcher(McpServer mcpServer,
McpResourcesProvider resources,
McpPromptsProvider prompts) {
return new McpJsonRpcDispatcher(mcpServer, resources, prompts);
}
}
19.6.2 McpServerFactory:消除重复装配
public final class McpServerFactory {
public static final String SERVER_NAME = "smart-doc-assistant";
public static final String SERVER_VERSION = "0.17.0";
/** stdio 启动器与 Spring HTTP 模式共用此工厂 */
public static McpServer create(EmbeddingModel embeddingModel,
EmbeddingStore<TextSegment> embeddingStore,
RetrievalAugmentor retrievalAugmentor) {
KnowledgeBaseMcpTools tools = new KnowledgeBaseMcpTools(embeddingModel, embeddingStore, retrievalAugmentor);
McpImplementation serverInfo = new McpImplementation();
serverInfo.setName(SERVER_NAME);
serverInfo.setVersion(SERVER_VERSION);
return new McpServer(List.of(tools), serverInfo);
}
}
19.6.3 McpHttpController:POST /mcp 端点
@Slf4j
@RestController
@RequestMapping("/mcp")
@RequiredArgsConstructor
public class McpHttpController {
private final McpJsonRpcDispatcher dispatcher;
private final McpAuditService auditService;
private final McpSecurityService securityService;
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Object> handle(@RequestBody JsonNode request,
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authHeader,
@RequestHeader(value = "X-MCP-API-Key", required = false) String apiKeyHeader) {
long start = System.currentTimeMillis();
String method = request.path("method").asText("");
// P0-2 修复:透传 id 节点(保留 number/string/null 类型)
JsonNode rawId = request.get("id");
Object id = (rawId == null || rawId.isNull()) ? null : rawId;
// 鉴权:initialize/ping 是握手阶段免鉴权
if (!method.equals("initialize") && !method.equals("ping") && !method.equals("notifications/initialized")) {
if (!securityService.isAuthorized(authHeader, apiKeyHeader)) {
return ResponseEntity.status(401)
.body(JsonRpcResponse.error(id, -32001, "Unauthorized"));
}
// 工具白名单检查
if (method.equals("tools/call")) {
String toolName = request.path("params").path("name").asText("");
if (!securityService.isToolAllowed(toolName)) {
return ResponseEntity.status(403)
.body(JsonRpcResponse.error(id, -32601, "Tool not allowed: " + toolName));
}
}
}
Object response;
try { response = dispatcher.handle(request); }
catch (Exception e) { response = JsonRpcResponse.error(id, -32603, e.getMessage()); }
// 审计 + 耗时
if (method.equals("tools/call")) {
String toolName = request.path("params").path("name").asText("");
auditService.record(toolName, request.path("params").path("arguments").toString(),
System.currentTimeMillis() - start, false);
}
return ResponseEntity.ok(response);
}
@GetMapping("/audit")
public Map<String, Object> recentAudit(/*鉴权头*/) {
return Map.of("entries", auditService.recent(20), "count", 20);
}
}
协议要点:返回值类型为 Object——Tools 分支返回 SDK 的 McpJsonRpcMessage(SDK 内部用 @JsonAnyGetter 序列化),Resources/Prompts 分支返回本章的 JsonRpcResponse(显式字段),Jackson 自动按各自字段序列化。
19.7 扩展 Resources / Prompts 原语(官方 SDK 不支持 → 自实现)
官方 SDK 1.22.0-beta32 只支持 Tools。但 MCP 规范定义了 Tools / Resources / Prompts 三种。本章用装饰器模式包装 McpServer,把 Resources 和 Prompts 也接上:
flowchart LR
REQ[JSON-RPC Request] --> DISP[McpJsonRpcDispatcher<br/>装饰器]
DISP -->|"method=tools/*<br/>initialize, ping"| MCPS[McpServer.handle<br/>官方 SDK]
DISP -->|"method=resources/list"| RP[McpResourcesProvider]
DISP -->|"method=resources/read"| RP
DISP -->|"method=prompts/list"| PP[McpPromptsProvider]
DISP -->|"method=prompts/get"| PP
DISP -->|"其他"| ERR[返回 Method Not Found<br/>-32601]
19.7.1 McpResourcesProvider:扫描 classpath:docs/
@Slf4j
public class McpResourcesProvider {
private static final String DOCS_PATH = "docs/";
private static final String URI_SCHEME = "docs://";
private final Map<String, ResourceEntry> resources = new LinkedHashMap<>();
public McpResourcesProvider() { loadAll(); }
private void loadAll() {
// 启动时扫描 classpath:docs/,全部读进内存
for (String name : List.of("product-faq.txt", "refund-policy.txt",
"technical-guide.md", "report.pdf")) {
try (InputStream in = getClass().getClassLoader()
.getResourceAsStream(DOCS_PATH + name)) {
byte[] bytes = in.readAllBytes();
resources.put(URI_SCHEME + name,
new ResourceEntry(URI_SCHEME + name, name, detectMime(name), bytes));
} catch (IOException e) { /* log */ }
}
}
public List<Map<String, Object>> listResources() { /* 返回 {uri, name, mimeType}[] */ }
public Optional<List<Map<String, Object>>> readResource(String uri) {
// P2-3 修复:二进制用 blob (base64),文本用 text 字段——符合 MCP 规范
ResourceEntry entry = resources.get(uri);
if (entry == null) return Optional.empty();
Map<String, Object> content = new LinkedHashMap<>();
content.put("uri", entry.uri);
content.put("mimeType", entry.mime);
if (entry.mime.startsWith("text/") || entry.mime.startsWith("application/json")) {
content.put("text", new String(entry.bytes, StandardCharsets.UTF_8));
} else {
content.put("blob", Base64.getEncoder().encodeToString(entry.bytes));
}
return Optional.of(List.of(content));
}
private static String detectMime(String name) {
String n = name.toLowerCase();
if (n.endsWith(".md")) return "text/markdown";
if (n.endsWith(".pdf")) return "application/pdf";
if (n.endsWith(".html") || n.endsWith(".htm")) return "text/html";
if (n.endsWith(".json")) return "application/json";
return "text/plain; charset=utf-8";
}
}
P2-3 修复重点:PDF 等二进制资源不能再当文本读(之前的实现把 PDF bytes 解码成 UTF-8 乱码存进 text 字段,误导客户端)。MCP 规范定义了 blob 字段用于二进制,本章按规范实现:客户端按 mimeType 决定解析方式。
教学简化:硬编码 4 个文件名清单(与 Ch10 的
DocumentIngestionService同样硬编码)。生产建议抽DocsScanner工具类用 ClassGraph 自动扫描,避免两个真相源漂移(P1-1 待办)。
19.7.2 McpPromptsProvider:3 个模板 + {{var}} 替换
public class McpPromptsProvider {
private static final Pattern PLACEHOLDER = Pattern.compile("\\{\\{\\s*([A-Za-z_][A-Za-z0-9_]*)\\s*}}");
public McpPromptsProvider() {
registerCodeReview(); // review-code
registerSummarizeDoc(); // summarize-doc
registerQaPolicy(); // qa-policy-check
}
/** 对应 MCP prompts/get:把 args 里的值替换进模板的 {{var}} */
public Optional<List<Map<String, Object>>> getPrompt(String name, Map<String, Object> args) {
PromptDefinition def = prompts.get(name);
if (def == null) return Optional.empty();
String rendered = renderTemplate(def.template, args);
return Optional.of(List.of(Map.of(
"role", "user",
"content", Map.of("type", "text", "text", rendered)
)));
}
/** P1-3 修复:真正的模板渲染(之前接 args 但不用) */
private static String renderTemplate(String template, Map<String, Object> args) {
Map<String, Object> safeArgs = args != null ? args : Map.of();
Matcher m = PLACEHOLDER.matcher(template);
StringBuilder sb = new StringBuilder();
while (m.find()) {
Object value = safeArgs.get(m.group(1));
// 缺值时替换为空字符串,避免 LLM 看到原始 {{var}} 被当成指令
m.appendReplacement(sb, Matcher.quoteReplacement(value != null ? value.toString() : ""));
}
m.appendTail(sb);
return sb.toString();
}
}
3 个预定义模板:
review-code:代码审查清单(language / code / focus)summarize-doc:文档摘要(document / max_words / language)qa-policy-check:强制“基于资料回答”的政策问答模板
P1-3 修复:之前的实现接 args 参数但完全不用——这是 API 假象,误导调用方。本次真正实现 {{var}} 正则替换。生产建议替换为 StringTemplate / Pebble 等正式模板引擎(支持 {{x | default(...)}} 过滤器、{% if %} 控制结构)。
19.7.3 McpJsonRpcDispatcher:装饰器模式分发
@Slf4j
public class McpJsonRpcDispatcher {
private final McpServer mcpServer;
private final McpResourcesProvider resourcesProvider;
private final McpPromptsProvider promptsProvider;
public Object handle(JsonNode request) {
// P0-2 修复:透传 id 节点,保留 number/string/null 类型
JsonNode idNode = request.get("id");
Object id = (idNode == null || idNode.isNull()) ? null : idNode;
String method = request.path("method").asText("");
switch (method) {
case "resources/list":
return JsonRpcResponse.success(id, Map.of("resources", resourcesProvider.listResources()));
case "resources/read": /* ... */
case "prompts/list":
return JsonRpcResponse.success(id, Map.of("prompts", promptsProvider.listPrompts()));
case "prompts/get": /* P1-4 修复:args 保留原始 JSON 类型 */
Map<String, Object> args = parseArgs(request.path("params").path("arguments"));
/* ... */
default:
return mcpServer.handle(request); // 委托 SDK:tools/*、initialize、ping
}
}
/** P1-4 修复:保留 number/boolean/string/object/array,不再 asText() 强转 */
private static Map<String, Object> parseArgs(JsonNode argsNode) {
Map<String, Object> out = new LinkedHashMap<>();
if (argsNode == null || !argsNode.isObject()) return out;
argsNode.fields().forEachRemaining(e -> out.put(e.getKey(), toJavaValue(e.getValue())));
return out;
}
}
为什么用 Object 而不是 long 作为 id:JSON-RPC 2.0 允许 id 是 number / string / null。早期版本用 request.path("id").asLong(0) 强转——客户端用 "req-001" 字符串 id 发请求,响应 id 变 0,客户端无法关联请求-响应。这是协议级 bug。
19.7.4 JsonRpcResponse:自定义 JSON-RPC 2.0 结构
@JsonInclude(JsonInclude.Include.NON_NULL)
public class JsonRpcResponse {
public final String jsonrpc = "2.0";
public final Object id; // ← 保留任意类型
public final Map<String, Object> result; // null = 错误响应
public final ErrorBody error; // null = 正常响应
public static JsonRpcResponse success(Object id, Map<String, Object> result) { ... }
public static JsonRpcResponse error(Object id, int code, String message) { ... }
public static class ErrorBody {
public final int code;
public final String message;
}
}
为什么自造:官方 SDK 的 McpJsonRpcMessage 是轻量 POJO,只带 jsonrpc + id;真正的 result/error 字段依赖 Jackson @JsonAnyGetter 魔法——不利于扩展 Resources/Prompts。本章做更朴素的事:显式字段 + 显式构造,更易读、易测、易扩展。
19.8 安全与审计(呼应 Ch16)
19.8.1 McpSecurityService:API Key + 工具白名单
@Slf4j
@Service
public class McpSecurityService {
private static final int MIN_API_KEY_LENGTH = 16;
private final String apiKey;
private final List<String> allowedTools;
public McpSecurityService(@Value("${chat.mcp.api-key:dev-mcp-key-replace-in-production}") String apiKey) {
this.apiKey = apiKey;
// P1-2 修复:单一真相源——从 KnowledgeBaseMcpTools.TOOL_NAMES 直接读取
this.allowedTools = KnowledgeBaseMcpTools.TOOL_NAMES;
}
/** P0-3 修复:启动期校验,禁止空/默认值/过短 Key 上线 */
@PostConstruct
void validateConfig() {
if (apiKey == null || apiKey.length() < MIN_API_KEY_LENGTH) {
throw new IllegalStateException("Invalid chat.mcp.api-key: must be at least "
+ MIN_API_KEY_LENGTH + " characters (current length=" + (apiKey == null ? 0 : apiKey.length()) + ")");
}
if ("dev-mcp-key-replace-in-production".equals(apiKey)) {
log.warn(" [MCP Security] Using default API key — change chat.mcp.api-key for production!");
}
}
public boolean isAuthorized(String authHeader, String customHeader) {
// P2-2 修复:Bearer 大小写不敏感
if (authHeader != null && authHeader.trim().length() > 7) {
String prefix = authHeader.trim().substring(0, 7);
if (prefix.equalsIgnoreCase("Bearer ")) {
return constantTimeEquals(authHeader.trim().substring(7).trim(), apiKey);
}
}
return customHeader != null && constantTimeEquals(customHeader.trim(), apiKey);
}
/** 常量时间比较,防时序攻击 */
private boolean constantTimeEquals(String a, String b) {
if (a == null || b == null || a.length() != b.length()) return false;
int diff = 0;
for (int i = 0; i < a.length(); i++) diff |= a.charAt(i) ^ b.charAt(i);
return diff == 0;
}
}
P0-3 修复重点:原实现 apiKey=null 或 apiKey="" 时 constantTimeEquals("", "") 返回 true——空 Key 当万能 Key。启动期直接抛异常阻断,比运行时拒绝更安全(运维事故无法“带病上线”)。
19.8.2 McpAuditService:调用审计 + 敏感字段 redact
@Slf4j
@Service
public class McpAuditService {
private static final int MAX_ENTRIES = 500;
private static final String[] SENSITIVE_KEYS = {
"password", "passwd", "secret", "token", "apikey", "api_key",
"auth", "credential", "private", "session"
};
// P0-6 修复:环形 buffer 容量上限(避免长时间运行内存泄漏)
private final AuditEntry[] ringBuffer = new AuditEntry[MAX_ENTRIES];
private int writeIndex = 0;
private int size = 0;
public synchronized void record(String toolName, String arguments, long durationMs, boolean failed) {
// P1-5 修复:写入前 redact 敏感字段
String safeArgs = redact(arguments);
AuditEntry e = new AuditEntry(System.currentTimeMillis(), toolName, safeArgs, durationMs, failed);
ringBuffer[writeIndex] = e;
writeIndex = (writeIndex + 1) % MAX_ENTRIES;
if (size < MAX_ENTRIES) size++;
}
public synchronized List<AuditEntry> recent(int n) {
int take = Math.min(n, size);
List<AuditEntry> out = new ArrayList<>(take);
for (int i = 0; i < take; i++) {
int idx = (writeIndex - 1 - i + MAX_ENTRIES) % MAX_ENTRIES;
out.add(ringBuffer[idx]);
}
return out;
}
/** P1-5 修复:把 "password":"123456" → "password":"***" */
static String redact(String args) {
if (args == null || args.isEmpty()) return args;
String out = args;
for (String key : SENSITIVE_KEYS) {
out = out.replaceAll("(\"" + Pattern.quote(key) + "\"\\s*:\\s*\")([^\"]*)(\")", "$1***$3");
}
return out;
}
public record AuditEntry(long timestamp, String toolName, String arguments, long durationMs, boolean failed) {}
}
P0-6 + P1-5 修复:
- 环形 buffer 容量 500 条(教学版内存),超过覆盖最旧
- audit 日志 redact 常见敏感字段,避免用户问“我的密码是123456”被原样写入日志
生产化:
McpAuditService仅做内存版演示。生产应写 ELK / 数据库,且 redact 应改用 Jackson 树遍历(更可靠)。
19.9 端点验证
19.9.1 Streamable HTTP 模式
先看这条:JSON-RPC 的
id必须是数字。官方社区 SDK 的McpServer.handle()里extractId()只认idNode.isNumber()——传字符串 id 会让handle()直接返回null, HTTP 层表现为 200 OK 但响应体长度为 0(不是 4xx,是静默丢弃)。自实现的 Resources / Prompts 分支不受影响,因为JsonRpcResponse.id是Object。 下面的命令全部用数字 id。
# 1. 列出 MCP Server 暴露的工具(id 用数字 1)
curl -s -X POST http://localhost:8090/mcp \
-H "Content-Type: application/json" \
-H "Authorization: Bearer dev-mcp-key-replace-in-production" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
{"id":1,"result":{"tools":[{"name":"searchKnowledgeBase","description":"查询本地知识库,返回与问题最相关的文档片段(语义检索)。用于回答基于文档的事实性问题。","inputSchema":{"type":"object","properties":{"question":{"type":"string","description":"用户的自然语言问题,例如:'七天无理由退货的条件是什么?'"},"topK":{"type":"integer","description":"返回片段数量,1 到 10 之间;常用 3 或 5"},"minScore":{"type":"number","description":"最低相似度阈值,0 到 1 之间;0 表示不过滤,0.5~0.7 是常用起点"}},"required":["question","topK","minScore"]}},...]}}
# 2. 调用工具(参数名是 question,不是 query —— 写错会拿到业务错误而非参数错误)
curl -s -X POST http://localhost:8090/mcp \
-H "Content-Type: application/json" \
-H "Authorization: Bearer dev-mcp-key-replace-in-production" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"searchKnowledgeBase","arguments":{"question":"七天无理由退货的条件是什么?","topK":3,"minScore":0.5}}}'
参数名写错时的返回长这样——不是 JSON-RPC 的
-32602 Invalid params, 而是工具方法体里的业务校验抛出的异常被 SDK 包成isError:{"id":2,"result":{"content":[{"text":"text cannot be null or blank","type":"text"}],"isError":true},"jsonrpc":"2.0"}看到这个
isError:true就知道是参数名/取值不对,不是服务端挂了。
# 3. Resources 原语(4 个 classpath:docs/ 下的文件)
curl -s -X POST http://localhost:8090/mcp \
-H "Content-Type: application/json" \
-H "Authorization: Bearer dev-mcp-key-replace-in-production" \
-d '{"jsonrpc":"2.0","id":3,"method":"resources/list","params":{}}'
{"id":3,"result":{"resources":[{"uri":"docs://product-faq.txt","name":"product-faq.txt","mimeType":"text/plain; charset=utf-8"},{"uri":"docs://refund-policy.txt","name":"refund-policy.txt","mimeType":"text/plain; charset=utf-8"},{"uri":"docs://technical-guide.md","name":"technical-guide.md","mimeType":"text/markdown"},{"uri":"docs://report.pdf","name":"report.pdf","mimeType":"application/pdf"}]},"jsonrpc":"2.0"}
# 4. Resources 读取(text 格式)
curl -s -X POST http://localhost:8090/mcp \
-H "Content-Type: application/json" \
-H "Authorization: Bearer dev-mcp-key-replace-in-production" \
-d '{"jsonrpc":"2.0","id":4,"method":"resources/read","params":{"uri":"docs://refund-policy.txt"}}'
# → {"id":4,"result":{"contents":[{"uri":"docs://refund-policy.txt","mimeType":"text/plain; charset=utf-8","text":"退款政策说明(客服处理依据)..."}]}}
# 5. Resources 读取(blob 格式,PDF 走 base64)
curl -s -X POST http://localhost:8090/mcp \
-H "Content-Type: application/json" \
-H "Authorization: Bearer dev-mcp-key-replace-in-production" \
-d '{"jsonrpc":"2.0","id":6,"method":"resources/read","params":{"uri":"docs://report.pdf"}}'
# → {"id":6,"result":{"contents":[{"uri":"docs://report.pdf","mimeType":"application/pdf","blob":"JVBERi0xLjQKJZOMi54gUmVwb3J0TGFi..."}]}}
# 6. Prompts 原语 + {{var}} 替换(3 个模板:review-code / summarize-doc / qa-policy-check)
curl -s -X POST http://localhost:8090/mcp \
-H "Content-Type: application/json" \
-H "Authorization: Bearer dev-mcp-key-replace-in-production" \
-d '{"jsonrpc":"2.0","id":7,"method":"prompts/get","params":{"name":"qa-policy-check","arguments":{"question":"运费谁出","context":"用户买的机器坏了"}}}'
{"id":7,"result":{"messages":[{"role":"user","content":{"text":"你是智能客服助手。严格依据下方【参考资料】回答用户问题。\n规则:\n- 资料未提及的内容,明确说\"资料中未提及\",绝对不要编造\n- 引用资料时标注片段编号\n- 结尾提示\"如需进一步帮助请联系人工客服\"\n\n【参考资料】\n用户买的机器坏了\n\n【用户问题】\n运费谁出\n\n【回答】\n","type":"text"}}]},"jsonrpc":"2.0"}
{{question}}和{{context}}都被替换成了实参,没有残留的原始占位符——这是 P1-3 修复的验证点。
# 7. 工具白名单验证(不在 TOOL_NAMES 里的工具直接 403)
curl -s -w '\nstatus:%{http_code}\n' -X POST http://localhost:8090/mcp \
-H "Content-Type: application/json" \
-H "Authorization: Bearer dev-mcp-key-replace-in-production" \
-d '{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"rm_rf","arguments":{}}}'
{"id":8,"error":{"code":-32601,"message":"Tool not allowed: rm_rf (allowed: [searchKnowledgeBase, askWithRag, getKnowledgeBaseStats])"},"jsonrpc":"2.0"}
status:403
# 8. 审计查询(只记录 tools/call)
curl -s "http://localhost:8090/mcp/audit?n=10" \
-H "Authorization: Bearer dev-mcp-key-replace-in-production"
{"entries":[{"timestamp":1793196807289,"toolName":"searchKnowledgeBase","arguments":"{\"query\":\"退款政策\"}","durationMs":5,"failed":false}],"count":10}
19.9.2 stdio 模式(Claude Desktop)
# 1. 单独打一个 MCP 入口的可执行 jar(普通 jar 里的 Main-Class 是 Spring 的 JarLauncher,
# 直接 java -jar 会拉起整个 Web 应用,而不是 MCP 子进程)
cd smart-doc-assistant
mvn clean package -DskipTests -Pmcp
ls target/smart-doc-assistant-0.16.0-SNAPSHOT-mcp.jar
# 2. 配置 Claude Desktop(19.5.3 里的 args 指向这个 -mcp.jar)
# 3. 启动 Claude Desktop → 在对话里问:
"查询知识库里关于七天无理由退货的内容"
# Claude Desktop 自动启动 McpServerStarter,调用 searchKnowledgeBase 工具
-Pmcp 是 pom 里的一个 profile:它让 spring-boot-maven-plugin 用 classifier=mcp + mainClass=com.example.assistant.mcp.McpServerStarter 再压一个包。你可以先用 MCP 的 initialize 请求自测,不用真开 Claude Desktop:
# 别写成 `printf ... | java -jar ...` 一把梭:三条消息会随管道瞬间 EOF,
# 进程在响应写回之前就收尾退出,stdout 一行都看不到(我们实测如此)。
# 真实客户端不会关管道,所以自测时也要让喂消息的循环撑住。
{
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"probe","version":"0.0.1"}}}'
sleep 2
printf '%s\n' '{"jsonrpc":"2.0","method":"notifications/initialized"}'
sleep 1
printf '%s\n' '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
sleep 3
} | java -jar target/smart-doc-assistant-0.16.0-SNAPSHOT-mcp.jar
stdout 应该只有两行 JSON-RPC 响应(initialize 与 tools/list),日志全部走 stderr。如果 stdout 里混进任何一行非 JSON 文本,客户端就会解析失败。
19.10 P0 工程修复记录
本章做了 6 项 P0 修复,全是实战中会爆炸的雷:
| ID | 问题 | 症状 | 修复 |
|---|---|---|---|
| P0-1 | McpHttpController 双 “not not” typo | 审计日志看不清 | 改为 “not in” |
| P0-2 | JSON-RPC id 强转 long | 客户端用字符串 id 时收不到关联响应 | JsonRpcResponse.id 改 Object;Dispatcher / Controller 透传 JsonNode(自实现的 Resources/Prompts 分支支持字符串 id,但官方 SDK 的 Tools 分支仍然只认数字 id,见坑点 16) |
| P0-3 | 空 API Key 万能 Key漏洞 | constantTimeEquals("", "") 返回 true → 任意 Key 通过鉴权 |
@PostConstruct 校验 ≥16 字符,违者启动失败 |
| P0-4 | try-with-resources 资源顺序 | awaitClose() 抛异常时 ctx.close() 漏执行 | 显式 try-finally,反顺序释放 |
| P0-5 | stdio 启动器日志污染 stdout | Spring Boot 默认日志写 stdout → 协议流污染 → 客户端断开 | 编程式重配 Logback:stderr only |
| P0-6 | 审计无上限 | 长时间运行 entries 无限增长 → 内存泄漏 | 环形 buffer MAX_ENTRIES=500,覆盖最旧 |
经验:
- 不要相信“看起来对”的代码——typo、空值、字符串比较等小问题会被运行时放大
- 官方 SDK 的设计取舍要清楚——
McpJsonRpcMessage用@JsonAnyGetter是因为 SDK 想“基础类型简洁 + 字段灵活”,但自定义协议(Resources/Prompts)需要显式字段结构 - stdio 传输是隐藏陷阱的雷区——进程生命周期、日志流、子进程退出码、信号处理都要小心
19.11 高频坑点清单
| # | 坑 | 现象 | 对策 |
|---|---|---|---|
| 1 | stdio 日志污染 stdout | 客户端解析失败立即断开 | redirectLogbackToStderr() 编程式接管;org.springframework.boot.logging.LoggingSystem=none |
| 2 | JSON-RPC id 类型丢失 | 客户端用字符串 id 时收不到响应 | id 用 Object 承载,透传 JsonNode;官方 SDK 的 Tools 分支仍只认数字 id(坑点 16) |
| 3 | 空 / 默认 API Key 上线 | 任意 Key 通过鉴权 | @PostConstruct 启动期校验 |
| 4 | 工具白名单两个真相源漂移 | 加新工具忘了同步白名单 | KnowledgeBaseMcpTools.TOOL_NAMES 单一来源 |
| 5 | PDF 当文本读存乱码 | clients 拿到 base64 乱码 | MCP 规范的 blob 字段(二进制)+ text 字段(文本) |
| 6 | 审计日志泄露敏感数据 | 用户问“密码是 123456”被原样写入 | redact() 正则过滤 password/token/apiKey 等关键字 |
| 7 | args 类型丢失(数字/布尔变字符串) | 客户端传 false: true 被当成 “true” 文本 |
parseArgs() 保留原始 JSON 类型(number/bool/object/array) |
| 8 | McpResourcesProvider 硬编码文件名 | 新增文档要改两个地方 | 待办:抽 DocsScanner 工具类统一扫描(P1-1) |
| 9 | Prompts {{var}} 不替换 |
LLM 看到原始 {{language}} 当成指令 |
renderTemplate() 正则替换;缺值时换空字符串 |
| 10 | Stdio 子进程泄漏 | Claude 退出但 Java 进程残留 | Claude Desktop 负责管理子进程生命周期;自实现客户端要 transport.close() + mcpClient.close() |
| 11 | Bearer 大小写敏感 | bearer xxx 鉴权失败 |
equalsIgnoreCase("Bearer ") |
| 12 | McpServerStarter 启动 Spring 日志污染 | 启动 banner / Bean 装配日志写到 stdout | LoggingSystem=none + 编程式接管 Logback |
| 13 | 用 -cp 跑 fat jar 里的入口类 |
ClassNotFoundException: com.example.assistant.mcp.McpServerStarter |
fat jar 的类在 BOOT-INF/classes/,-cp 看不到;用 -Pmcp 打出独立可执行 jar,java -jar 启动 |
| 14 | commons-logging 与 spring-jcl 并存 | stdout 多出一行 Standard Commons Logging discovery in action with spring-jcl...,客户端解析失败 |
排除 commons-logging:commons-logging(spring-jcl 提供同一套 API)。本例来自 pdfbox 的传递依赖 |
| 15 | 子进程读不到 application.yml |
用 AnnotationConfigApplicationContext 时 @Value 全部落到注解默认值,和配置文件不一致(例如 reranker 变成 dashscope 却缺 key,启动即失败) |
启动器里手动 YamlPropertySourceLoader 加载 application.yml 进 Environment |
| 16 | Tools 分支的 id 必须用数字 | 传字符串 id → HTTP 200 但响应体长度 0(不是 4xx,静默丢弃,客户端以为服务端无响应) | 官方 SDK 的 extractId() 只认 idNode.isNumber();Tools 调用一律用数字 id。资源/提示原语是自实现分支,字符串 id 也能正常返回 |
| 17 | 工具参数名写错报的是业务错误 | 传 query 而工具签名是 question → 返回 isError:true + "text cannot be null or blank",不是 -32602 Invalid params |
看 isError 字段区分“参数写错”和“服务端故障”;JSON Schema 里 required 列出的字段名就是准确参数名 |
19.12 本章小结
概念速查表
| 概念 | 一句话 |
|---|---|
| MCP Server SDK | 官方在 langchain4j-community-mcp-server,不在核心模块(1.22.0-beta32 实测) |
| stdio 传输 | 子进程方式,本地 Claude Desktop / Cursor / Gemini CLI 用;StdioMcpServerTransport |
| Streamable HTTP | 远程调用方式;本章用 Spring MVC 自实现(官方 SDK 1.22.0-beta32 未提供) |
@Tool 注解 |
LangChain4j 标准注解,Server SDK 自动扫 List<Object> Bean 生成 JSON Schema |
| McpJsonRpcDispatcher | 装饰器模式包装 McpServer,扩展 Resources / Prompts 原语 |
| JsonRpcResponse | 自定义 JSON-RPC 2.0 结构;id 保留任意类型(number/string/null) |
| JSON-RPC id 类型 | 规范允许 number / string / null;自实现分支(资源/提示)三种都支持,官方 SDK 的 Tools 分支只认数字 id |
| stdio 日志硬约束 | 所有日志必须走 stderr,stdout 只剩 JSON-RPC 协议流 |
| McpServerFactory | 双传输共用的工厂方法,消除 stdio/HTTP 重复装配代码 |
| TOOL_NAMES 真相源 | 工具白名单从 KnowledgeBaseMcpTools.TOOL_NAMES 读取,避免两个真相源漂移 |
{{var}} 模板渲染 |
Prompts 用正则替换;缺值时换空字符串(避免 LLM 把 {{var}} 当指令) |
| MCP blob 字段 | 二进制资源(PDF / 图片)走 blob 字段(base64);文本走 text 字段(UTF-8) |
项目代码状态表(v0.17)
| 模块 | 文件 | 说明 |
|---|---|---|
| 依赖 | pom.xml |
新增:langchain4j-community-mcp-server:1.22.0-beta32 |
| 工具 | mcp/KnowledgeBaseMcpTools.java |
新增:3 个 @Tool + TOOL_NAMES 单一真相源 + question 校验 |
| 工具 | mcp/McpServerFactory.java |
新增:双传输共用工厂 |
| 启动 | mcp/McpServerStarter.java |
新增:stdio 入口(main 方法)+ Logback→stderr 重定向 |
| 配置 | mcp/McpServerConfig.java |
新增:Spring 上下文装配 HTTP 模式的 MCP Server |
| HTTP | mcp/McpHttpController.java |
新增:POST /mcp + GET /mcp/audit + 鉴权/白名单 |
| 协议 | mcp/JsonRpcResponse.java |
新增:JSON-RPC 2.0 响应结构(id 任意类型) |
| 协议 | mcp/McpJsonRpcDispatcher.java |
新增:装饰器模式分发 Tools/Resources/Prompts |
| 资源 | mcp/McpResourcesProvider.java |
新增:classpath:docs/ 扫描 + blob/text 双格式 |
| 提示 | mcp/McpPromptsProvider.java |
新增:3 个模板 + {{var}} 渲染 |
| 安全 | mcp/McpSecurityService.java |
新增:API Key 启动期校验 + Bearer 大小写不敏感 |
| 审计 | mcp/McpAuditService.java |
新增:环形 buffer + redact |
| 文档 | chapters/ch19-mcp-server-development.md |
新增:本章 |
| README | smart-doc-assistant/README.md |
新增:端点速查表 |
下一章预告
Ch21 Guardrails 安全护栏——本章做了“准入层”防护(API Key + 工具白名单),但请求进来之后的内容层威胁(提示注入 / 越权工具 / 输出泄露)还没防:Ch21 在 Application 层与 AiServices 层各加一道护栏,把本章的安全清单从“谁在调”延伸到“调进来的是什么、吐出去的是什么”。
练习题
- 用
npx @modelcontextprotocol/inspector启动 MCP Inspector,给 stdio 启动器测一遍 tools/list + tools/call + resources/read + prompts/get,验证本章所有功能。 - 把
chat.mcp.api-key设为空字符串,启动主应用——观察启动失败抛IllegalStateException(验证 P0-3 修复)。 - 给
McpPromptsProvider加一个新模板extract-fields(用 {{fields}} {{text}} 占位符),用 curl 验证{{text}}被替换成传入的字符串。 - 修改
McpSecurityService.allowedTools(或KnowledgeBaseMcpTools.TOOL_NAMES),加一个新工具名但不在KnowledgeBaseMcpTools类加 @Tool 方法——重启后用 curl 调该工具,观察返回 403 Forbidden(验证白名单真相源)。 - 思考题:为什么 stdio 启动器不能用 Spring Boot 自动配置?提示:思考 Spring Boot 启动时哪些组件会写 stdout/启动 HTTP 服务器/读 application.yml。
- 思考题:Ch19 的 Resources / Prompts 是装饰器模式实现。如果官方 SDK 未来直接支持
@McpResource/@McpPrompt,本章的代码会怎样被简化?提示:考虑McpServer构造器的List<Object>参数和McpJsonRpcDispatcher.handle()的 switch 分发。
下一章预告:Ch21 Guardrails 安全护栏 → Ch22 可观测性 · 日志 · 成本控制 → Ch24/25 综合实战:AI 招聘官。