我给国密设备写了 3 个 MCP Server,LLM 现在会当"密评工程师"了

0 阅读9分钟

字数:约 4200 字
标签:AI 编程 / MCP / 国密 / 密评 / Rust


开始之前,先讲一个现编的故事

前段时间,一个做金融系统的朋友找我抱怨。

他们公司接到密评整改通知,要把一套核心交易系统里的 RSA-2048 签名全部换成 SM2。领导给了三个月,他自己搞了一个月没搞定,急了,就去问 AI。

他把 RSA 签名的代码贴给 LLM,说:"帮我改成 SM2。"

LLM 确实给出了代码,洋洋洒洒写了好几十行,用的是 BouncyCastle,API 也对。他满怀期待地跑起来,报错了:

SM2Engine cannot operate on empty data

他又问 AI,AI 给出了修复方案。再跑,又报错。来来回回折腾了三轮,最后还是没通——因为 SM2 签名有个 Z 值预处理步骤,那段代码根本没处理,而 AI 对这个细节语焉不详,每次给的答案都各说各话。

他最后的结论是:"AI 不懂国密。"


我跟他说:你冤枉它了。

不是 AI 不懂国密——大模型背过 GM/T 0009,知道 Z 值是怎么算的,知道 SM2 用的是 256 位素域,知道签名算法标识是 0x00020201

问题是:AI 没手。

它只能说"应该这样写",但它没法自己跑起来验证一下生成的代码到底能不能签名、签出来的签名格式对不对、验签时另一端能不能认。

就像一个没有化验室的医生——知识到位,但只能靠经验下药,没法当场化验。


给 LLM 装上手:MCP Server 是什么

MCP(Model Context Protocol)是 Anthropic 在 2024 年提出的开放协议,核心思想很简单:

让 LLM 可以调用外部工具,获取实时数据,操作真实系统。

LLM 调用 MCP tool 就像人用手机里的 App——你告诉手机"帮我打车",手机调用滴滴 App,而不是光嘴上告诉你"你应该去路边打车"。

国密场景天然适合这个模式:

  1. 操作可离散化:SM2 签名、SM3 摘要、SM4 加解密、证书管理——每个都是一次输入→输出的操作,完美映射为 MCP tool
  2. 结果可验证:签出来的签名,同一个工具链可以立刻验签,闭环不需要真实设备
  3. 上下文连续:LLM 可以一步步"签名→验签→打包 CMS→验证证书链",像流水线一样串起来

我做了什么:gm-agent-stack

我把三个国密设备模拟器(Mock)各自包了一层 MCP Server,暴露为 LLM 可调用的工具:

                  ┌──────────────────────────┐
                  │   LLM         │  ← 你的 AI 助手
                  └──────────┬───────────────┘
                             │  MCP Tool Use
             ┌───────────────┼───────────────┐
             ▼               ▼               ▼
      ┌──────────┐    ┌──────────┐    ┌──────────┐
      │ skf-mock │    │ sdf-mock │    │ svs-mock │
      │ GM/T 0016│    │ GM/T 0018│    │ GM/T 0029│
      │ USB Key  │    │ 加密机   │    │ 签名服务 │
      │ :16000   │    │ :18000   │    │ :9000    │
      └──────────┘    └──────────┘    └──────────┘
  • skf-mock:模拟 GM/T 0016-2012 智能密码钥匙(USB Key),12 个 MCP tool
  • sdf-mock:模拟 GM/T 0018-2023 服务端加密机(HSM),10 个 MCP tool
  • svs-mock:模拟 GM/T 0029-2014 签名验签服务器,6 个 MCP tool

加起来 28 个 tool,覆盖了密评中最核心的三类密码设备。


实际效果

启动 svs-mock 后,把它加到 LLM:

docker run -d -p 9000:9000 svs-mock
LLM mcp add svs-mock --url http://localhost:9000/mcp

然后在 LLM 里对话:

:用 SM2 对 "Hello World" 签名,然后验签,把全过程告诉我

LLM

我用 svs_digest 先计算 SM3 摘要(含 Z 值前缀),再用 svs_sign 进行 SM2 P1 格式签名...

// svs_digest 结果
{"hex":"b94d27b9934d3e08a52e52d7da7dabfac484efe04294e576...","length":32}

// svs_sign 结果
{"signed_data_hex":"3045022100f4a3b...","mode":"data"}

// svs_verify 结果
{"valid":true}

签名有效!整个流程:

  1. SM3(Z || data) → 摘要
  2. SM2 签名摘要 → DER SEQUENCE{r, s}
  3. 用公钥验签 → valid: true

对应的 Java BouncyCastle 代码:...

这就是"有手"和"没手"的区别:LLM 不只是告诉你"应该这样写",它自己跑了一遍,结果是真的


技术实现

选型:为什么用 rmcp + Streamable HTTP

MCP 有三种传输层:stdio、SSE、Streamable HTTP。

  • stdio:只适合本地进程,无法在网络上暴露
  • SSE(Server-Sent Events):2025 年 3 月已被 MCP spec 标记为 deprecated
  • Streamable HTTP:当前主推,单端口即可,LLM 都支持

Rust SDK 选 rmcpmodelcontextprotocol/rust-sdk,3300+ stars,官方背书)。一个 binary 同时跑 REST API 和 MCP:

// src/main.rs — 双模启动,共享业务层
#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let cli = Cli::parse();           // --mode rest|mcp|both
    let store = Arc::new(CertStore::from_config(&config)?);

    let mut router = axum::Router::new();
    match cli.mode {
        Mode::Both => {
            router = router
                .merge(routes::build_router(store.clone()))     // REST
                .nest_service("/mcp", mcp::build_mcp_service(store)); // MCP
        }
        // ...
    }
    axum::serve(listener, router).await?;
}

关键:REST 和 MCP 共享同一个业务层(service/),不重复实现任何密码逻辑。


tool 粒度:场景化聚合,不做 C API 直映射

GM/T 标准里有 130+ 个 C 函数(SKF_xxxSDF_xxx)。如果 1:1 映射为 MCP tool,LLM 会淹死在函数列表里。

错误做法(130 个 tool):

skf_init_dev
skf_connect_dev
skf_disconnect_dev
skf_get_dev_state
skf_open_dev
...

正确做法(场景聚合,12 个 tool):

skf_device_info     // 枚举 + 连接 + 获取信息,一步到位
skf_open_app        // 打开应用 + 验 PIN + 打开容器
skf_sm2_sign        // 含 Z 值计算的完整签名流程
...

Init/Update/Final 三步流式接口全部内化到单个 tool 里,LLM 只需要传入完整数据。


tool description:每个字段必须说清楚

Anthropic 内部数据显示,tool description 是影响 LLM 调用准确率最关键的因素。字段描述漏写,LLM 经常乱猜。

踩过的坑

// 错误:没说输入格式
pub data: String,

// 正确:明确 hex,举例子,说清楚语义
#[schemars(description = "待摘要的原始数据,hex 编码,例如 \"48656c6c6f\" = \"Hello\")"]
pub data_hex: String,

我的实践:所有二进制字段统一用 hex 编码,字段名加 _hex 后缀,description 里必须写清楚格式和含义。


#[tool_handler] 不能漏

这个坑我踩了很久。

rmcp 的 #[tool_router] 宏负责收集 tool 定义,#[tool_handler] 宏负责把 tool router 接入 MCP 协议处理链。缺少后者,服务启动正常,但 tools/list 返回空数组,LLM 看不到任何 tool。

#[tool_router]          // ← 定义 tool
impl SvsMcpServer {
    #[tool(description = "...")]
    async fn svs_digest(&self, ...) -> String { ... }
}

#[tool_handler]         // ← 接入协议,缺了这个 tools/list 返回空
impl ServerHandler for SvsMcpServer {
    fn get_info(&self) -> ServerInfo { ... }
}

两个宏都要有,缺一不可。


StateLess 模式:每次 tool call 独立

加密机(SDF)和 USB Key(SKF)的真实 C API 是有状态的:打开设备 → 打开会话 → 操作 → 关闭会话。

MCP 是无状态的(每次 tool call 相互独立)。解法:

// MCP tool 内部自动打开/关闭会话
async fn sdf_sm2_sign(&self, Parameters(p): Parameters<SignParams>) -> String {
    let mut session = 0u32;
    sdf_impl::open_session(&mut session);  // 开
    let result = sdf_impl::sign(session, ...);
    sdf_impl::close_session(session);       // 关
    // ...
}

每个 tool call 开头打开 session,结束关闭。对 mock 来说性能完全够用,对 LLM 来说这些 session 细节完全透明。


StateFul 模式的坑:StreamableHttpService::new 的类型推断

// 错误:类型参数 M 无法推断
let mcp_svc = StreamableHttpService::new(...);

// 正确:显式标注类型
let mcp_svc: StreamableHttpService<SvsMcpServer, LocalSessionManager> =
    StreamableHttpService::new(...);

rmcp 的 StreamableHttpService 有两个泛型参数,Rust 编译器推断不出来,必须显式写。文档里没提,要么看源码,要么踩坑。


sdf-mock 和 skf-mock 的特殊之处

svs-mock 是一个 HTTP binary,直接加 MCP 层很自然。

但 sdf-mock 和 skf-mock 是动态链接库cdylib),原本设计是给应用程序 FFI 调用,没有 HTTP server。

解法:Cargo.toml 保留 [lib](动态库),同时新增 [[bin]](MCP Server 可执行文件):

[lib]
name = "sdf_mock"
crate-type = ["cdylib", "rlib"]   # 保留动态库不动

[[bin]]
name = "sdf-mcp"
path = "src/bin/mcp_server.rs"    # 新增 MCP binary

MCP binary 直接 use sdf_mock::sdf_impl::* 复用所有业务逻辑,零重复实现。


三个真实踩坑

坑 1:target 目录权限问题

error: could not write to target/debug/.cargo-lock: permission denied

原因:之前用 root 跑过一次 cargo,锁文件被 root 持有。解法:所有 cargo 命令加 --target-dir /tmp/xxx,避开被 root 占用的目录。

坑 2:tools/list 返回空数组

服务跑通了,initialize 也正常,但 tool 列表是空的。查了半天 rmcp 源码才发现,#[tool_handler] 缺少。上文提到的坑,这里再次强调:两个宏都要有

坑 3:SM2 密钥格式不统一

不同模块对 SM2 公钥的表示不一样:

  • libsmx 接受 04||x||y(65 字节)
  • SDF C API 接受右对齐的 x(64字节)||y(64字节)(ECCrefPublicKey 结构)
  • X.509 证书里的 SubjectPublicKeyInfo 是带 OID 的 DER 结构

MCP tool 的输入输出统一用 04||x||y hex,内部根据目标 API 做转换。LLM 只需要知道一种格式。


现在就可以跑起来

# 克隆
git clone https://github.com/kintaiW/mp-mock
cd mp-mock/0029-svs-mock

# 启动(REST + MCP 双模,默认 9000 端口)
cargo run -- --mode both

# 添加到 LLM
LLM mcp add svs-mock --url http://localhost:9000/mcp

或者用 Docker:

docker run -d -p 9000:9000 \
  -v $(pwd)/mock_certs.toml:/app/mock_certs.toml \
  svs-mock

然后在 LLM 里问任何 SM2/SM3 相关的问题,它都可以自己跑起来验证。


后续计划

  • GM-Bench:用这套工具链评测各大 LLM 的国密能力(DeepSeek、Qwen、GLM 谁更懂 SM2?)
  • CCASA Agent/Harness:把三个 MCP Server 接入密评咨询 AI,实现"咨询→生成代码→验证→报告"全自动流程
  • meta-repogm-agent-stack 一键 docker compose up 起全栈

最后

国密合规不是一个纯粹的技术问题,它还夹带着标准文件读不懂、真机买不起、测试环境搭不好的现实困境。

这套工具链的目标很简单:让 LLM 有手,让密评整改有一个可以跑通的沙箱

不是替代工程师,是让工程师有个不知疲倦的助手在旁边帮你跑、帮你验、帮你说清楚 Z 值到底是什么。

代码在这里:gm-agent-stack


警告:本项目仅供学习、开发测试和密评模拟使用,严禁用于生产环境。Mock 实现不提供真实安全保证。