MCP 实战(六):把这些零件拼起来,然后诚实盘一遍还差什么

目录


一、最终的形态

1.1 项目结构

mcp-java-tutorial/
├── src/main/java/com/example/mcp/
│   │
│   ├── step1/CalculatorServer.java      # 最小 MCP Server(无 LLM)
│   ├── step2/ManualClient.java          # 最小 MCP Client(无 LLM)
│   ├── step3/McpToClaudeTool.java       # 桥接层(30 行)
│   ├── step4/AgentMain.java             # agent loop
│   │
│   ├── core/                            # 通用基础设施
│   │   ├── McpAgent.java                #   可复用的 agent loop
│   │   └── StdioLifecycle.java          #   进程生命周期管理
│   │
│   └── db/                              # 只读数据库 MCP
│       ├── Db.java                      #   数据源(唯一要改的层)
│       ├── SqlGuard.java                # ★ 安全层
│       ├── AuditLog.java                #   审计日志
│       ├── SchemaIntrospector.java      #   表结构自省
│       ├── DbMcpServer.java             # ★ MCP Server 主体(4 个工具)
│       ├── DbAgentMain.java             #   接 LLM 的入口
│       ├── DbClientDemo.java            #   验证:功能 + 攻击 + 误伤
│       └── ReadOnlyAccountDemo.java     #   验证:只读账号

1.2 主流程

剥掉所有细节,整个项目的主线就是这一段:

// ① 起 MCP Client(子进程 + 初始化)
// 注:initialize() 属于「有握手」的那版规范。2026-07-28 已删除握手,
//     但官方 Java SDK 尚未跟进,所以这里仍要调——详见 mcp-02 的说明。
McpSyncClient mcp = McpClient.sync(new StdioClientTransport(serverParams, jsonMapper))
        .build();
mcp.initialize();

// ② MCP 工具 → Claude 工具(30 行胶水)
List<Tool> claudeTools = mcp.listTools().tools().stream()
        .map(McpToClaudeTool::toClaudeTool)
        .toList();

// ③ agent loop
List<MessageParam> messages = new ArrayList<>();
messages.add(MessageParam.builder().role(USER).content(question).build());

for (int turn = 1; turn <= MAX_TURNS; turn++) {
    Message response = claude.messages().create(params);   // 发给模型

    for (ContentBlock block : response.content()) {
        if (!block.isToolUse()) continue;

        ToolUseBlock toolUse = block.toolUse().orElseThrow();
        Map<String, Object> args = toolUse._input().convert(Map.class);

        // ④ 模型要调工具 → 通过 MCP 执行
        McpSchema.CallToolResult r = mcp.callTool(
                new McpSchema.CallToolRequest(toolUse.name(), args));

        toolResults.add(...);                              // 包成 tool_result
    }

    if (toolResults.isEmpty()) break;                      // ⑤ 模型不再要工具 → 结束
    messages.add(工具结果);
}

1.3 完整链路

┌──────────┐
│   你     │ "9 月领用最多的备件是什么?"
└────┬─────┘
     ▼
┌─────────────────────────────────────────────────────────────┐
│  Agent 层(core/McpAgent)                                   │
│  · 维护对话历史                                              │
│  · 把 MCP 工具转成 Claude 工具格式                            │
│  · 驱动 while 循环                                           │
└────┬───────────────────────────────────────────┬────────────┘
     │ tool_use                                  │ tool_result
     ▼                                           ▲
┌──────────┐                              ┌──────┴──────┐
│  Claude  │                              │  你的代码    │
└──────────┘                              └──────┬──────┘
                                                 │ JSON-RPC (stdio)
                                                 ▼
┌─────────────────────────────────────────────────────────────┐
│  MCP Server 层(db/DbMcpServer)                             │
│  ┌───────────────────────────────────────────────────────┐  │
│  │ ① SqlGuard    SQL 白名单 / 强制 LIMIT                  │  │
│  ├───────────────────────────────────────────────────────┤  │
│  │ ② schema 自省  读表注释喂给模型                         │  │
│  ├───────────────────────────────────────────────────────┤  │
│  │ ③ 只读连接 + 超时                                       │  │
│  ├───────────────────────────────────────────────────────┤  │
│  │ ④ AuditLog    记录每一次调用(含被拒绝的)               │  │
│  └───────────────────────────────────────────────────────┘  │
└──────────────────────────┬──────────────────────────────────┘
                           ▼
                    ┌─────────────┐
                    │  数据库      │
                    └─────────────┘

二、这一路换来了什么

回头看,四步各解决了一个具体的认知问题。把它们列出来,比列代码清单有用。

2.1 第一步:不接 LLM 的 MCP

解决的问题:把 MCP 和 function calling 的混淆彻底消除。

怎么解决的:写了一个没有任何大模型的 MCP 链路。当你亲眼看到一个纯程序调用 MCP 工具,这个概念就再也混不了了。

代价:多花了一天。如果直接接 LLM 上手,第一天就能跑通 demo,但对协议的理解会一直浮在表面。

这个代价值不值? 值。因为后面所有设计决策都建立在这个理解上。比如「桥接层为什么只有 30 行」这个问题,不理解正交关系就答不出来。

2.2 第二步:接上 LLM

解决的问题:理解 agent 的本质。

怎么解决的:手写 agent loop,不用任何框架。

代价:要处理六个容易做错的地方(toParam、refusal 判断、tool_result 合并、is_error、退出条件、maxTokens)。用框架的话这些都被封装了。

值不值? 值。因为这六个点在生产环境出问题时会变成很难查的 bug——比如「模型渐渐不并行调用工具了」这种,不手写过一遍根本想不到和消息结构有关。

2.3 第三步:真实业务

解决的问题:理解「工具描述和数据结构描述」才是效果的决定因素。

怎么解决的:做只读数据库查询,被迫面对「模型看不见你的库」这个现实。

代价:要设计一套贴近真实的测试数据(含一张 5000 行大表)。

值不值? 值。这一步让我意识到,写 MCP Server 的工作量里,相当大一部分是在写「给模型看的文档」——工具描述、参数描述、表注释。

2.4 第四步:安全

解决的问题:从「能跑」到「能放心跑」。

怎么解决的:三道墙 + 12 个验证用例。

代价:写了不少代码,而且其中一部分(SqlGuard 的很多细节)在正常使用中永远不会触发。

值不值? 值。而且这一步的价值不在于代码本身,在于思维方式的转变:从「怎么让它工作」变成「怎么让它不闯祸」。


三、离生产还差什么

这一节是诚实的部分。上面那套东西能跑、能验证,但它不是生产系统。

缺什么影响怎么补
连接池每次查询新建连接,高并发下打爆数据库HikariCP
结果缓存相同查询重复执行按 SQL 哈希缓存 30 秒
真正的 SQL 解析手写分词器理论上有绕过路径JSqlParser 之类的 AST 解析
数据脱敏模型可能查出手机号、身份证号结果集过脱敏规则
行级权限所有用户看到的数据一样SQL 外套 WHERE,或按用户切换账号
结果摘要现在按 4000 字符硬截断只回传摘要 + 让模型按需再查
多轮记忆每次 ask() 从空历史开始把 messages 提升为 agent 的字段
调用限流模型可能疯狂查库按会话限制每分钟查询数

3.1 最该先做的两个

连接池和结果缓存。

前者是稳定性问题——没有连接池,几个并发请求就能把数据库连接数耗尽。

后者是成本问题——LLM 应用里,同一次会话中重复查询同一份数据非常常见,缓存能省下可观的 token 和时间。

3.2 数据脱敏为什么重要

这条容易被忽略,但它是合规问题。

模型查出来的数据会进入对话历史,而对话历史会被发送到模型提供方。如果你的库里有人事、财务、客户数据,这就是实实在在的数据泄露面。

只读账号解决的是「不能被改」,不是「不能被看」。 这是两个问题。


四、我对 MCP 的三点判断

做完这一轮,说三个结论。都是个人判断,仅供参考。

判断一:MCP 的价值在「边界」上

如果你只有一个模型、一个进程、一套工具,那 MCP 带来的收益很小——直接函数调用更简单。

MCP 的价值出现在边界上:

  • 工具和 AI 应用不同语言
  • 工具由不同团队维护
  • 需要换模型而不改工具
  • 工具需要独立部署和升级

只要有一项会变,那层协议抽象就值了。一项都没有,那它确实是多余的一层。

所以在决定用不用 MCP 之前,先问自己:我这些边界,未来会不会变?

判断二:真正的难点不在协议,在「怎么描述」

协议本身很简单——JSON-RPC 2.0 加四个方法名。看完第一篇你就能理解个大概。

难的是让模型用对。 而这件事的绝大部分工作量,花在写「给模型看的文档」上:

  • 工具描述(做什么 + 什么时候用)
  • 参数描述(每个参数是什么含义)
  • 表结构注释(业务含义,不是类型说明)

这些在传统开发里属于「文档工作」,可以敷衍。在 MCP 里它们是功能的一部分——写得不好,功能就是不可用。

判断三:安全要重新想一遍

传统 Web 安全的心智模型是「防外部攻击者」。MCP 引入了一个新角色:

不可信的输入源,从「用户输入」变成了:
  · 用户输入
  · 模型生成的参数
  · 模型读到的数据内容(← 这个是新的)

第三种是全新的攻击面——攻击载荷藏在数据里,用户完全不知情。

这意味着「输入校验」这件事的边界要往前推:不能只校验用户输入,还要校验模型生成的每一个参数,以及考虑模型读到的内容可能带有指令。

而这个防御不能靠 prompt,只能靠「模型管不到的地方」。


五、常见误区

误区一:以为跑通 demo 就等于会了。

跑通一个 demo 和能上生产之间,隔着的不是更多代码,是更多问题的意识:连接池、脱敏、限流、可观测性、失败降级。这些在 demo 里都不存在。

误区二:把手写 SQL 解析器当成完备方案。

它挡误操作很好用,但挡攻击不能只靠它。必须配一个只读账号。 这一点在第五篇反复讲过,但值得再强调一次。

误区三:工具描述写得像 API 文档。

API 文档是给人看的,读者有上下文。工具描述是给模型看的,它只有这一句话。

所以描述里要写「什么时候用」,而不只是「这是什么」。

误区四:跳过第一步,直接接 LLM。

可以,但代价是理解会浮在表面。后面遇到「为什么消息结构会影响模型的调用模式」这类问题时,会缺少底层依据。

误区五:只读账号就觉得安全了。

只读防的是「改数据」,不防「看数据」也不防「拖垮数据库」。这三个是不同的问题,需要不同的手段。


六、自检清单

系列收尾,做一次整体自测:

协议层

  • MCP 解决的核心问题是什么?
  • MCP 和 function calling 的关系,能用一句话说清吗?
  • Host / Client / Server 的角色划分?谁和谁是 1:1?
  • MCP 底层协议是什么?为什么规范删掉了握手,而手上这个 SDK 还在调 initialize()?

实现层

  • agent loop 的五个步骤?
  • tool_use 和 tool_result 怎么对应?多结果为什么要合并?
  • 桥接层为什么只要 30 行?
  • stdio 传输最容易踩的坑?

应用层

  • 为什么 describe_table 比 run_query 更重要?
  • 工具描述的写法公式?
  • 怎么让模型少写错 SQL?

安全层

  • 为什么 prompt 不算安全边界?
  • 三道墙分别挡什么?哪个不可替代?
  • SQL 掩码是为了解决什么?为什么等长?
  • 为什么要记录被拒绝的请求?

附:延伸阅读

相关资料

资源说明
Model Context Protocol 官方文档协议规范与各语言 SDK
modelcontextprotocol/servers官方参考实现,PostgreSQL server 那份值得对照本系列的安全设计看
OAuth 2.0 for MCP想把 Server 部署到公网时必读的鉴权部分
Building effective agents什么场景该用 agent、什么场景不该,判断框架很清晰
The 12-Factor App第四节的「离生产还差什么」,很多项都能对应到这里的条目

关联笔记


最后更新:2026-10-10