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

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


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

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

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

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

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

vbscript 复制代码
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 可调用的工具:

r 复制代码
                  ┌──────────────────────────┐
                  │   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:

bash 复制代码
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 格式签名...

json 复制代码
// 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:

rust 复制代码
// 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):

erlang 复制代码
skf_init_dev
skf_connect_dev
skf_disconnect_dev
skf_get_dev_state
skf_open_dev
...

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

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

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


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

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

踩过的坑

rust 复制代码
// 错误:没说输入格式
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。

rust 复制代码
#[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 相互独立)。解法:

rust 复制代码
// 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 的类型推断

rust 复制代码
// 错误:类型参数 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 可执行文件):

toml 复制代码
[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 目录权限问题

bash 复制代码
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 只需要知道一种格式。


现在就可以跑起来

bash 复制代码
# 克隆
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:

bash 复制代码
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 实现不提供真实安全保证。

相关推荐
counterxing8 小时前
Agent 跑起来之后,难的是复用、观测和评测
node.js·agent·ai编程
uccs8 小时前
大模型底层机制与Agent开发
agent·ai编程·claude
counterxing9 小时前
我把 Codex 里的 Skills 做成了一个 MCP,还支持分享
前端·agent·ai编程
夜雪闻竹9 小时前
vectra 向量索引文件损坏怎么办
ai编程·向量·vectra
ZzT9 小时前
Harness 到底指什么
openai·ai编程·claude
宅小年9 小时前
AI 创业最危险的地方:太容易做出来
openai·ai编程·claude
麦客奥德彪10 小时前
Android Skills
架构·ai编程
言萧凡_CookieBoty11 小时前
一文讲清 RAG:让 AI 读懂业务知识库的核心方法
ai编程
kyriewen11 小时前
产品经理把PRD写成“天书”,我用AI半小时重写了一遍,他当场愣住
前端·ai编程·cursor
Patrick_Wilson12 小时前
知识沉淀的四层模型:从个人笔记到企业资产,让文档真正长出复利
面试·程序员·ai编程