feat(mcp): tracer bullet — /mcp 端点骨架与 search_posts 工具

T1 探针式实现,接通端到端链路并验证双目标编译:

- 依赖:rmcp=3.0.0-beta.3(server/macros/transport-streamable-http-server/
  transport-worker)+ aes-gcm,均经 server feature 门控。
- 迁移 017_mcp_tokens:UUID 主键、scope 约束、token_enc 密文 + token_hash
  唯一索引(仅未撤销),users.id 外键级联。
- 模型 src/models/mcp_token.rs:TokenScope(read<write<admin 偏序,grants)+
  McpToken/McpTokenSummary/CreateTokenResponse DTO。
- src/mcp/(server-only):
  - crypto.rs:AES-GCM-256 加解密(hex 密钥、每次独立 nonce、8 项单测)。
  - auth.rs:bearer→SHA-256→DB 常量查找→注入 McpPrincipal,刷新 last_used_at。
  - server.rs:rmcp ServerHandler,search_posts 工具(read 作用域,pg_trgm 查询)。
  - router.rs:StreamableHttpService(无状态+JSON 响应+Origin 白名单)挂载 /mcp。
- main.rs:merge mcp_route 到应用路由;models/mod.rs 注册 mcp_token 模块。

验证:rmcp+axum-0.8 兼容性经临时探针实测(tools/list、tools/call、Origin→403、
bearer→工具 principal 全链路);--features web 与 --features server 均编译通过;
clippy --all-features -D warnings 干净;639 单测+1 集成 +13 新 MCP 测试全绿。
This commit is contained in:
xfy 2026-07-28 11:09:55 +08:00
parent 801cb79e0d
commit 7b9dfa7851
12 changed files with 1013 additions and 51 deletions

341
Cargo.lock generated
View File

@ -8,6 +8,41 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common 0.1.7",
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures 0.2.17",
]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "1.1.4" version = "1.1.4"
@ -81,7 +116,7 @@ checksum = "562827c72b3c65ebc7beabc710ffae5b991c0e9e60f7f71374c219107d22f335"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -144,7 +179,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -155,7 +190,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -274,7 +309,7 @@ checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -420,7 +455,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"rustversion", "rustversion",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -467,7 +502,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -594,6 +629,16 @@ dependencies = [
"half", "half",
] ]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common 0.1.7",
"inout",
]
[[package]] [[package]]
name = "cmov" name = "cmov"
version = "0.5.4" version = "0.5.4"
@ -679,7 +724,7 @@ checksum = "4f160aad86b4343e8d4e261fee9965c3005b2fd6bc117d172ab65948779e4acf"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -690,7 +735,7 @@ checksum = "42571ed01eb46d2e1adcf99c8ca576f081e46f2623d13500eba70d1d99a4c439"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -856,6 +901,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [ dependencies = [
"generic-array", "generic-array",
"rand_core 0.6.4",
"typenum", "typenum",
] ]
@ -888,7 +934,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
dependencies = [ dependencies = [
"quote", "quote",
"syn", "syn 2.0.119",
]
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
] ]
[[package]] [[package]]
@ -930,7 +985,7 @@ dependencies = [
"ident_case", "ident_case",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -943,7 +998,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"strsim", "strsim",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -954,7 +1009,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
dependencies = [ dependencies = [
"darling_core 0.21.3", "darling_core 0.21.3",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -965,7 +1020,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [ dependencies = [
"darling_core 0.23.0", "darling_core 0.23.0",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -1048,7 +1103,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"rustc_version", "rustc_version",
"syn", "syn 2.0.119",
"unicode-xid", "unicode-xid",
] ]
@ -1186,7 +1241,7 @@ dependencies = [
"dioxus-rsx", "dioxus-rsx",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -1348,7 +1403,7 @@ dependencies = [
"convert_case 0.8.0", "convert_case 0.8.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
"xxhash-rust", "xxhash-rust",
] ]
@ -1414,7 +1469,7 @@ dependencies = [
"convert_case 0.8.0", "convert_case 0.8.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -1509,7 +1564,7 @@ dependencies = [
"quote", "quote",
"sha2 0.10.9", "sha2 0.10.9",
"slab", "slab",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -1522,7 +1577,7 @@ dependencies = [
"proc-macro2-diagnostics", "proc-macro2-diagnostics",
"quote", "quote",
"rustversion", "rustversion",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -1632,7 +1687,7 @@ dependencies = [
"convert_case 0.8.0", "convert_case 0.8.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -1687,7 +1742,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -1726,6 +1781,12 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "dyn-clone"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]] [[package]]
name = "encoding_rs" name = "encoding_rs"
version = "0.8.35" version = "0.8.35"
@ -1759,7 +1820,7 @@ dependencies = [
"darling 0.21.3", "darling 0.21.3",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -1934,7 +1995,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -2053,6 +2114,16 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]] [[package]]
name = "gif" name = "gif"
version = "0.14.2" version = "0.14.2"
@ -2564,6 +2635,15 @@ dependencies = [
"cfb", "cfb",
] ]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]] [[package]]
name = "inventory" name = "inventory"
version = "0.3.24" version = "0.3.24"
@ -2626,7 +2706,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [ dependencies = [
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -2844,7 +2924,7 @@ checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -2898,7 +2978,7 @@ dependencies = [
"manganis-core", "manganis-core",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -3158,7 +3238,7 @@ dependencies = [
"proc-macro-crate", "proc-macro-crate",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -3242,6 +3322,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]] [[package]]
name = "parking" name = "parking"
version = "2.2.1" version = "2.2.1"
@ -3288,6 +3374,12 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pastey"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
@ -3335,7 +3427,7 @@ dependencies = [
"phf_shared", "phf_shared",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -3364,7 +3456,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -3411,6 +3503,18 @@ dependencies = [
"miniz_oxide", "miniz_oxide",
] ]
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"opaque-debug",
"universal-hash",
]
[[package]] [[package]]
name = "portable-atomic" name = "portable-atomic"
version = "1.13.1" version = "1.13.1"
@ -3485,7 +3589,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -3514,7 +3618,7 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
"version_check", "version_check",
] ]
@ -3796,7 +3900,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76009fbe0614077fc1a2ce255e3a1881a2e3a3527097d5dc6d8212c585e7e38b" checksum = "76009fbe0614077fc1a2ce255e3a1881a2e3a3527097d5dc6d8212c585e7e38b"
dependencies = [ dependencies = [
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -3808,6 +3912,26 @@ dependencies = [
"bitflags", "bitflags",
] ]
[[package]]
name = "ref-cast"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d"
dependencies = [
"ref-cast-impl",
]
[[package]]
name = "ref-cast-impl"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]] [[package]]
name = "regex" name = "regex"
version = "1.13.1" version = "1.13.1"
@ -3904,6 +4028,50 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "rmcp"
version = "3.0.0-beta.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13a8472324b3d6c74f092cc5d07b2707b128196b020ed7c3c2d52bede542680"
dependencies = [
"async-trait",
"base64",
"bytes",
"chrono",
"futures",
"http",
"http-body",
"http-body-util",
"pastey",
"pin-project-lite",
"rand 0.10.2",
"rmcp-macros",
"schemars",
"serde",
"serde_json",
"sse-stream",
"thiserror 2.0.18",
"tokio",
"tokio-stream",
"tokio-util",
"tower-service",
"tracing",
"uuid",
]
[[package]]
name = "rmcp-macros"
version = "3.0.0-beta.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faa1de06ecdc489a8a03ca84f8491111c1e37acd8c69a815e253123039619d72"
dependencies = [
"darling 0.23.0",
"proc-macro2",
"quote",
"serde_json",
"syn 2.0.119",
]
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "1.1.0" version = "1.1.0"
@ -4000,6 +4168,32 @@ dependencies = [
"winapi-util", "winapi-util",
] ]
[[package]]
name = "schemars"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
dependencies = [
"chrono",
"dyn-clone",
"ref-cast",
"schemars_derive",
"serde",
"serde_json",
]
[[package]]
name = "schemars_derive"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba"
dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn 3.0.3",
]
[[package]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.2.0" version = "1.2.0"
@ -4078,7 +4272,18 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
]
[[package]]
name = "serde_derive_internals"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
] ]
[[package]] [[package]]
@ -4124,7 +4329,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -4161,7 +4366,7 @@ checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -4256,7 +4461,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb251b407f50028476a600541542b605bb864d35d9ee1de4f6cab45d88475e6d" checksum = "bb251b407f50028476a600541542b605bb864d35d9ee1de4f6cab45d88475e6d"
dependencies = [ dependencies = [
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -4319,6 +4524,19 @@ dependencies = [
"recursive", "recursive",
] ]
[[package]]
name = "sse-stream"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c123f296ade4ec4b8b0f6162116e6629f5146922ca5ab40ca9d3c2e73ab4761e"
dependencies = [
"bytes",
"futures-util",
"http-body",
"http-body-util",
"pin-project-lite",
]
[[package]] [[package]]
name = "stable_deref_trait" name = "stable_deref_trait"
version = "1.2.1" version = "1.2.1"
@ -4373,7 +4591,7 @@ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -4421,6 +4639,17 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "syn"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]] [[package]]
name = "sync_wrapper" name = "sync_wrapper"
version = "1.0.2" version = "1.0.2"
@ -4438,7 +4667,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -4530,7 +4759,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -4541,7 +4770,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -4631,7 +4860,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -4847,7 +5076,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -5023,6 +5252,16 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common 0.1.7",
"subtle",
]
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"
@ -5114,7 +5353,7 @@ checksum = "59195a1db0e95b920366d949ba5e0d3fc0e70b67c09be15ce5abb790106b0571"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -5192,7 +5431,7 @@ dependencies = [
"bumpalo", "bumpalo",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
@ -5362,7 +5601,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -5373,7 +5612,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -5627,6 +5866,7 @@ dependencies = [
name = "yggdrasil" name = "yggdrasil"
version = "0.6.2" version = "0.6.2"
dependencies = [ dependencies = [
"aes-gcm",
"argon2", "argon2",
"axum", "axum",
"bollard", "bollard",
@ -5652,6 +5892,7 @@ dependencies = [
"pulldown-cmark", "pulldown-cmark",
"rand 0.8.7", "rand 0.8.7",
"regex", "regex",
"rmcp",
"serde", "serde",
"serde-wasm-bindgen", "serde-wasm-bindgen",
"serde_json", "serde_json",
@ -5692,7 +5933,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
"synstructure", "synstructure",
] ]
@ -5758,7 +5999,7 @@ checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -5778,7 +6019,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
"synstructure", "synstructure",
] ]
@ -5818,7 +6059,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]

View File

@ -67,6 +67,13 @@ mimalloc = { version = "0.1", optional = true }
# 默认 features 为空即原生服务端编译wasm feature 仅前端构建才需,这里不开。 # 默认 features 为空即原生服务端编译wasm feature 仅前端构建才需,这里不开。
# 配套前端需引入 katex.min.css + 字体(见 public/katex/、Makefile katex-css # 配套前端需引入 katex.min.css + 字体(见 public/katex/、Makefile katex-css
katex-rs = { version = "0.2", optional = true } katex-rs = { version = "0.2", optional = true }
# MCPModel Context Protocol服务器官方 Rust SDK rmcp。
# 必须锁在 3.x beta 线(稳定版 0.2.1 缺 Origin 校验/协议版本头/体积上限/无状态默认等
# spec 强制项。2026-07-28 经临时 axum-0.8 探针验证可挂载(见 docs/mcp-spec.md
# transport-worker 必须显式加LocalSessionManager 无条件引用 transport::worker。
rmcp = { version = "=3.0.0-beta.3", optional = true, features = ["server", "macros", "transport-streamable-http-server", "transport-worker"] }
# MCP token 静态加密AES-GCM管理员可在后台重查明文DB 不裸存。
aes-gcm = { version = "0.10", optional = true }
[target.'cfg(target_arch = "wasm32")'.dependencies] [target.'cfg(target_arch = "wasm32")'.dependencies]
web-sys = { version = "0.3", features = ["Document", "Window", "Storage", "Element", "HtmlElement", "DomTokenList", "MediaQueryList", "HtmlImageElement", "MouseEvent", "KeyboardEvent", "Node", "EventTarget", "Navigator", "Clipboard", "File", "FileList", "FormData", "Request", "RequestInit", "Response", "Headers", "ClipboardEvent", "DataTransfer", "HtmlInputElement", "EventSource", "MessageEvent"] } web-sys = { version = "0.3", features = ["Document", "Window", "Storage", "Element", "HtmlElement", "DomTokenList", "MediaQueryList", "HtmlImageElement", "MouseEvent", "KeyboardEvent", "Node", "EventTarget", "Navigator", "Clipboard", "File", "FileList", "FormData", "Request", "RequestInit", "Response", "Headers", "ClipboardEvent", "DataTransfer", "HtmlInputElement", "EventSource", "MessageEvent"] }
@ -130,4 +137,6 @@ server = [
"dep:tokio-stream", "dep:tokio-stream",
"dep:mimalloc", "dep:mimalloc",
"dep:katex-rs", "dep:katex-rs",
"dep:rmcp",
"dep:aes-gcm",
] ]

View File

@ -0,0 +1,25 @@
-- MCPModel Context Protocol服务器的访问令牌。
-- 令牌由管理员在后台签发绑定用户与作用域read/write/admin
-- 静态加密存储token_enc = AES-GCM 密文),并以 SHA-256 哈希做每请求常量查找。
-- 与 sessions 表一致:明文不入库;但本表支持「管理员重查明文」(解密 token_enc
-- 故不存裸明文而是可逆密文。expires_at 为 NULL 表示永不过期(签发时显式选择)。
CREATE TABLE IF NOT EXISTS mcp_tokens (
id UUID PRIMARY KEY, -- 应用层生成uuid crate
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL, -- 管理员自命名,仅展示
scope TEXT NOT NULL CHECK (scope IN ('read', 'write', 'admin')),
-- AES-GCM 密文nonce ‖ ct ‖ tag的 hex 编码;管理员可解密重查明文。
token_enc TEXT NOT NULL,
-- 明文 token 的 SHA-256 hex64 字符),用于每请求 O(1) 常量查找。
token_hash CHAR(64) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ, -- NULL = 永不过期(显式选择)
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ -- NULL = 有效;撤销后置为 NOW()
);
-- 仅在「未撤销」令牌间保证 token_hash 唯一:撤销后可回收同名 hash理论碰撞极低
CREATE UNIQUE INDEX IF NOT EXISTS mcp_tokens_token_hash_active_idx
ON mcp_tokens (token_hash) WHERE revoked_at IS NULL;
CREATE INDEX IF NOT EXISTS mcp_tokens_user_idx ON mcp_tokens (user_id);

View File

@ -65,6 +65,7 @@ const MIGRATIONS: &[(&str, &str)] = &[
"016", "016",
include_str!("../../migrations/016_assets_content_hash.sql"), include_str!("../../migrations/016_assets_content_hash.sql"),
), ),
("017", include_str!("../../migrations/017_mcp_tokens.sql")),
// 新增迁移在此追加,同时在 migrations/ 下创建对应 .sql 文件。 // 新增迁移在此追加,同时在 migrations/ 下创建对应 .sql 文件。
]; ];

View File

@ -39,6 +39,13 @@ mod hooks;
#[cfg(feature = "server")] #[cfg(feature = "server")]
mod middleware; mod middleware;
mod models; mod models;
// mcpModel Context Protocol 服务器(/mcp Streamable HTTPbearer token 鉴权)。
// 仅 server feature 编译WASM 前端不引用任何 mcp 符号。
// allow(dead_code)T1 tracer bullet 仅接通 search_poststoken 管理服务端函数T2
// 完整工具集T3-T5、加密/解密的外部调用点在后续工单接入。
#[cfg(feature = "server")]
#[allow(dead_code)]
mod mcp;
mod pages; mod pages;
mod router; mod router;
// sysinfo_sampler主机指标快照。 // sysinfo_sampler主机指标快照。
@ -333,7 +340,8 @@ fn main() {
.merge(export_route) .merge(export_route)
.merge(sse_route) .merge(sse_route)
.merge(app_routes) .merge(app_routes)
.merge(static_routes); .merge(static_routes)
.merge(crate::mcp::router::mcp_route());
// 版本头中间件置于最终合并 router 的最外层:所有端点(含 /healthz、/uploads/*、 // 版本头中间件置于最终合并 router 的最外层:所有端点(含 /healthz、/uploads/*、
// 被 CSRF 拒/超时/admin_guard 重定向的响应)都会带上版本头。受 EXPOSE_VERSION_HEADERS 控制。 // 被 CSRF 拒/超时/admin_guard 重定向的响应)都会带上版本头。受 EXPOSE_VERSION_HEADERS 控制。

108
src/mcp/auth.rs Normal file
View File

@ -0,0 +1,108 @@
//! MCP 请求鉴权bearer token → 已认证主体。
//!
//! 流程(每请求):
//! 1. axum `from_fn` 中间件 [`mcp_auth_middleware`] 解析 `Authorization: Bearer ygg_...`
//! 2. SHA-256 哈希后在 `mcp_tokens` 表常量查找未撤销、未过期的行;
//! 3. 命中则把 [`McpPrincipal`] { user_id, scope, token_id } 注入 request.extensions
//! 并异步刷新 `last_used_at`
//! 4. 未命中/缺失 → 401不区分原因避免探测
//!
//! Origin→403、协议版本头、体积上限由 rmcp 的 `StreamableHttpServerConfig` 内置,
//! 本模块只管 bearer 鉴权。`McpPrincipal` 经 rmcp 的 `Extension<http::request::Parts>`
//! 提取器在工具内读取(见 `server.rs`)。
use axum::body::Body;
use axum::http::{HeaderMap, Request, StatusCode};
use axum::middleware::Next;
use axum::response::Response;
use sha2::{Digest, Sha256};
use crate::db::pool::get_conn;
use crate::models::mcp_token::TokenScope;
/// Bearer token 前缀(明文形式以 `ygg_` 起头,便于人眼识别与日志脱敏)。
pub const TOKEN_PREFIX: &str = "ygg_";
/// 已认证主体:由中间件注入 request.extensions工具经 Extension 提取器读取。
#[derive(Clone, Debug)]
pub struct McpPrincipal {
pub user_id: i32,
pub scope: TokenScope,
/// 令牌 DB idString与 model 一致;用于审计/last_used_at 刷新)。
pub token_id: String,
}
/// 从 Authorization 头解析 bearer 明文(去前缀后返回完整 token 字符串)。
/// 非 bearer、缺前缀、解码失败统一返回 None。
pub fn extract_bearer(headers: &HeaderMap) -> Option<String> {
let raw = headers.get(axum::http::header::AUTHORIZATION)?.to_str().ok()?;
let scheme = raw.strip_prefix("Bearer ")?.trim();
if scheme.starts_with(TOKEN_PREFIX) {
Some(scheme.to_string())
} else {
None
}
}
/// 明文 token → SHA-256 hex与 mcp_tokens.token_hash 列一致,用于 DB 查找)。
pub fn hash_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
hex::encode(hasher.finalize())
}
/// MCP 鉴权中间件:无 bearer 或 token 无效 → 401否则注入 McpPrincipal。
///
/// 注意:这是 T1 的最小实现——每请求同步查库 + 同步更新 last_used_at。
/// T6 会把 last_used_at 刷新改为节流(批量/惰性)以减负,鉴权查询本身保持同步
/// (这是认证的必要代价,无法乐观)。
pub async fn mcp_auth_middleware(mut req: Request<Body>, next: Next) -> Result<Response, StatusCode> {
let token = match extract_bearer(req.headers()) {
Some(t) => t,
None => return Err(StatusCode::UNAUTHORIZED),
};
match resolve_principal(&token).await {
Some(principal) => {
req.extensions_mut().insert(principal);
Ok(next.run(req).await)
}
None => Err(StatusCode::UNAUTHORIZED),
}
}
/// 查库解析 token → 主体;未撤销、未过期才返回 Some。
async fn resolve_principal(token: &str) -> Option<McpPrincipal> {
let hash = hash_token(token);
let client = get_conn().await.ok()?;
// 一次查询取出 + 校验所有条件row-level 过滤避免 TOCTOU。
let row = client
.query_opt(
"SELECT id, user_id, scope, expires_at, revoked_at
FROM mcp_tokens
WHERE token_hash = $1
AND revoked_at IS NULL
AND (expires_at IS NULL OR expires_at > NOW())",
&[&hash],
)
.await
.ok()??;
let token_id: uuid::Uuid = row.get(0);
let user_id: i32 = row.get(1);
let scope_str: &str = row.get(2);
let scope = TokenScope::from_db(scope_str)?;
// best-effort 刷新 last_used_at失败不影响鉴权已在 Some 分支)。
let _ = client
.execute(
"UPDATE mcp_tokens SET last_used_at = NOW() WHERE id = $1",
&[&token_id],
)
.await;
Some(McpPrincipal {
user_id,
scope,
token_id: token_id.to_string(),
})
}

193
src/mcp/crypto.rs Normal file
View File

@ -0,0 +1,193 @@
//! MCP token 静态加密AES-GCM-256
//!
//! 设计要点:
//! - 明文 bearer token 永不裸存于 DB。存储列 `token_enc` 是 AES-GCM 密文
//! nonce ‖ ciphertext ‖ tag的 hex 编码;管理员可在后台解密重查明文。
//! - `token_hash`(明文 SHA-256 hex用于每请求 O(1) 常量查找,见 `auth.rs`。
//! - 主密钥来自环境变量 `MCP_TOKEN_ENC_KEY`hex 编码的 32 字节64 个 hex 字符;
//! 可用 `openssl rand -hex 32` 生成)。缺失或非法时 `mcp_enc_key()` 返回 None
//! 调用方按「MCP 不可用」降级(拒绝签发/认证),不 panic——符合 AGENTS.md §16。
//!
//! AES-GCM-25612 字节随机 nonce每次加密独立生成认证标签隐含在密文尾部。
//! nonce 复用是 AES-GCM 的唯一致命风险;这里每次 `encrypt_token` 都新取 nonce
//! 且 nonce 与密文一同存储,解密时无需额外恢复。仅用 hex已是直接依赖编码
//! 避免把 base64 由传递依赖提升为直接依赖。
use aes_gcm::aead::{Aead, KeyInit, OsRng};
use aes_gcm::{AeadCore, Aes256Gcm, Key, Nonce};
/// 从环境读取并解码主密钥;缺失或非法返回 None降级不 panic
///
/// 接受 hex 编码的 32 字节64 个 hex 字符)。解码后必须正好 32 字节AES-256
pub fn mcp_enc_key() -> Option<[u8; 32]> {
let raw = std::env::var("MCP_TOKEN_ENC_KEY").ok()?;
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
let bytes = hex::decode(trimmed).ok()?;
if bytes.len() == 32 {
let mut arr = [0u8; 32];
arr.copy_from_slice(&bytes);
Some(arr)
} else {
None
}
}
/// 加密明文 token返回 `nonce‖ct‖tag` 的 hex 字符串(存入 `token_enc`)。
///
/// 失败仅在不持有有效主密钥时(调用方应在签发前已检查 `mcp_enc_key().is_some()`)。
pub fn encrypt_token(plaintext: &str) -> Option<String> {
let key_bytes = mcp_enc_key()?;
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&key_bytes));
let nonce = Aes256Gcm::generate_nonce(&mut OsRng); // 12 字节,每次独立
let ct = cipher.encrypt(&nonce, plaintext.as_bytes()).ok()?;
let mut buf = Vec::with_capacity(nonce.len() + ct.len());
buf.extend_from_slice(nonce.as_slice());
buf.extend_from_slice(&ct);
Some(hex::encode(buf))
}
/// 解密 `token_enc``nonce‖ct‖tag` 的 hex还原明文 token。
///
/// 失败密钥缺失、hex 非法、密文被篡改、nonce 长度不符)统一返回 None——
/// 调用方无法区分具体原因,按「该 token 不可解密」处理(等同于失效)。
pub fn decrypt_token(enc_hex: &str) -> Option<String> {
let key_bytes = mcp_enc_key()?;
let buf = hex::decode(enc_hex).ok()?;
// AES-GCM-256 nonce 固定 12 字节;短于此必然损坏。
if buf.len() < 12 {
return None;
}
let (nonce_bytes, ct) = buf.split_at(12);
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&key_bytes));
let nonce = Nonce::from_slice(nonce_bytes);
let pt = cipher.decrypt(nonce, ct).ok()?;
String::from_utf8(pt).ok()
}
#[cfg(test)]
mod tests {
use super::*;
/// 测试用:把任意 32 字节映射到临时 hex 密钥环境,跑完闭包后还原 env。
/// 返回闭包的结果,便于 `let enc = with_key(&key, || encrypt_token(...))`。
/// 串行化serial_test避免跨用例污染进程级 env。
fn with_key<F: FnOnce() -> R, R>(key: &[u8; 32], body: F) -> R {
let prev = std::env::var("MCP_TOKEN_ENC_KEY").ok();
std::env::set_var("MCP_TOKEN_ENC_KEY", hex::encode(key));
// scope guard: 还原 env
struct Restore(Option<String>);
impl Drop for Restore {
fn drop(&mut self) {
match &self.0 {
Some(v) => std::env::set_var("MCP_TOKEN_ENC_KEY", v),
None => std::env::remove_var("MCP_TOKEN_ENC_KEY"),
}
}
}
let _r = Restore(prev);
body()
}
#[test]
#[serial_test::serial]
fn roundtrip_basic() {
let key = [42u8; 32];
with_key(&key, || {
let enc = encrypt_token("ygg_secret_token_value").expect("encrypt");
let dec = decrypt_token(&enc).expect("decrypt");
assert_eq!(dec, "ygg_secret_token_value");
});
}
#[test]
#[serial_test::serial]
fn roundtrip_unicode_and_long() {
let key = [7u8; 32];
let plaintext = "ygg_".to_string() + &"中文标题-漢字-🔑 ".repeat(500);
with_key(&key, || {
let enc = encrypt_token(&plaintext).expect("encrypt");
assert_eq!(decrypt_token(&enc).expect("decrypt"), plaintext);
});
}
#[test]
#[serial_test::serial]
fn distinct_nonces_per_encryption() {
// 相同明文加密两次密文必须不同nonce 随机);都可解回同一明文。
let key = [9u8; 32];
with_key(&key, || {
let a = encrypt_token("same").expect("encrypt");
let b = encrypt_token("same").expect("encrypt");
assert_ne!(a, b, "nonce must differ → ciphertext differs");
assert_eq!(decrypt_token(&a).unwrap(), "same");
assert_eq!(decrypt_token(&b).unwrap(), "same");
});
}
#[test]
#[serial_test::serial]
fn tampered_ciphertext_fails() {
let key = [1u8; 32];
with_key(&key, || {
let mut enc = encrypt_token("payload").expect("encrypt");
// 翻转最后一个字节GCM tag 区域)→ 认证失败
let mut bytes = hex::decode(&enc).unwrap();
let last = bytes.len() - 1;
bytes[last] ^= 0xff;
enc = hex::encode(&bytes);
assert_eq!(decrypt_token(&enc), None);
});
}
#[test]
#[serial_test::serial]
fn missing_key_returns_none() {
std::env::remove_var("MCP_TOKEN_ENC_KEY");
assert_eq!(mcp_enc_key(), None);
assert_eq!(encrypt_token("x"), None);
assert_eq!(decrypt_token("deadbeef"), None);
}
#[test]
#[serial_test::serial]
fn wrong_key_cannot_decrypt() {
let enc = {
let key = [1u8; 32];
with_key(&key, || encrypt_token("secret").unwrap())
};
// 换另一个密钥:解密必须失败(认证标签不匹配)
with_key(&[2u8; 32], || {
assert_eq!(decrypt_token(&enc), None);
});
}
#[test]
#[serial_test::serial]
fn accepts_hex_encoding() {
let key = [5u8; 32];
std::env::set_var("MCP_TOKEN_ENC_KEY", hex::encode(key));
assert_eq!(mcp_enc_key(), Some(key));
std::env::remove_var("MCP_TOKEN_ENC_KEY");
}
#[test]
#[serial_test::serial]
fn rejects_odd_length_hex() {
// 奇数长度 hex 非法 → None
std::env::set_var("MCP_TOKEN_ENC_KEY", "abc");
assert_eq!(mcp_enc_key(), None);
std::env::remove_var("MCP_TOKEN_ENC_KEY");
}
#[test]
#[serial_test::serial]
fn rejects_short_key() {
// 16 字节32 hex 字符)非 AES-256 密钥,必须拒绝
std::env::set_var("MCP_TOKEN_ENC_KEY", hex::encode([0u8; 16]));
assert_eq!(mcp_enc_key(), None, "16 字节非 AES-256 密钥,必须拒绝");
std::env::remove_var("MCP_TOKEN_ENC_KEY");
}
}

24
src/mcp/mod.rs Normal file
View File

@ -0,0 +1,24 @@
//! MCPModel Context Protocol服务器。
//!
//! 把博客暴露为一个 MCP 服务器,管理员的 AI 客户端Claude Code / Cursor / Cline
//! 通过 `POST /mcp`Streamable HTTP无状态以 bearer token 认证,可:
//! - 查询已发布文章作为知识库read 作用域);
//! - 执行几乎所有后台操作:文章 CRUD、评论、标签、媒体、设置、代码运行器
//! write / admin 作用域)。
//!
//! 传输:官方 `rmcp` crate 的 `StreamableHttpService`,挂载于 axum `/mcp`。
//! 认证:每请求由 axum 中间件解析 `Authorization: Bearer ygg_...` → 注入
//! `McpPrincipal` 到 request extensions工具经 `Extension<http::request::Parts>`
//! 提取器读取按作用域鉴权。Origin→403 / 协议版本头 / 体积上限由 rmcp 内置。
//!
//! 仅 server feature 编译WASM 前端构建不含本模块(无 `#[cfg(not)]` stub 需求,
//! 整个模块用 `#[cfg(feature = "server")]` 门控,前端侧不引用任何 mcp 符号)。
#[cfg(feature = "server")]
pub mod auth;
#[cfg(feature = "server")]
pub mod crypto;
#[cfg(feature = "server")]
pub mod router;
#[cfg(feature = "server")]
pub mod server;

47
src/mcp/router.rs Normal file
View File

@ -0,0 +1,47 @@
//! 构建并返回挂载于 `/mcp` 的 axum 子路由。
//!
//! 装配:
//! - rmcp `StreamableHttpService`无状态、JSON 响应、Origin 白名单);
//! - 本 crate 的 `mcp_auth_middleware`bearer → McpPrincipal注入 extensions
//!
//! Origin 白名单:优先 `APP_BASE_URL`否则放空rmcp 默认对缺 Origin 放行)。
//! 生产部署 MUST 设置 `APP_BASE_URL`(见 docs/DEPLOYMENT.md白名单才会生效。
//! 协议版本头、体积上限4MiB 默认)由 rmcp 内置,无需此处重复。
use axum::middleware::from_fn;
use axum::Router;
use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService};
use crate::mcp::auth::mcp_auth_middleware;
use crate::mcp::server::YggMcpServer;
/// 构建 `/mcp` 子路由(供 main.rs 的 `.merge(mcp_route)` 合并)。
///
/// 仅在 server feature 下有意义WASM 构建不会调用本函数。
pub fn mcp_route() -> Router {
// 计算 Origin 白名单APP_BASE_URL 规范化后加入未设置则空rmcp 对缺 Origin 放行)。
let allowed_origins: Vec<String> = std::env::var("APP_BASE_URL")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.into_iter()
.collect();
let mut config = StreamableHttpServerConfig::default()
.with_legacy_session_mode(false) // 无状态SEP-2567
.with_json_response(true); // 简单工具用 application/json 直回
if !allowed_origins.is_empty() {
config = config.with_allowed_origins(allowed_origins.iter().map(|s| s.as_str()));
}
let service = StreamableHttpService::new(
|| Ok::<_, std::io::Error>(YggMcpServer),
LocalSessionManager::default().into(),
config,
);
Router::new()
.nest_service("/mcp", service)
.layer(from_fn(mcp_auth_middleware))
}

126
src/mcp/server.rs Normal file
View File

@ -0,0 +1,126 @@
//! MCP 服务器rmcp `ServerHandler` 实现,暴露博客工具。
//!
//! T1tracer bullet仅暴露一个工具 `search_posts`,证明:
//! - rmcp handler 经 `tool_router`/`tool_handler` 宏装配成功;
//! - 工具内能经 `Extension<http::request::Parts>` 读取鉴权中间件注入的 `McpPrincipal`
//! - 作用域鉴权可生效(此处 search_posts 要求 read
//!
//! T3 会扩展为完整 read 工具集 + ResourcesT4/T5 扩展 write/admin 工具。
//! 搜索 SQL 与 `src/api/posts/search.rs` 的 server-fn 一致pg_trgm word_similarity
//! T3 会把这段查询抽成共享 helper 供两条路径复用,避免重复维护。
use rmcp::handler::server::tool::Extension;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::{CallToolResult, ContentBlock, TextContent};
use rmcp::{schemars, tool, tool_handler, tool_router, ErrorData as McpError, ServerHandler};
use serde::Deserialize;
use crate::mcp::auth::McpPrincipal;
use crate::models::mcp_token::TokenScope;
/// search_posts 入参。
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct SearchPostsParams {
/// 搜索关键词(会做 SQL 通配符转义;空串返回空结果)。
pub query: String,
}
/// MCP 服务器实例(无状态:每个请求由 service_factory 新建一份)。
///
/// 共享状态DB 连接等)通过 `get_conn()` 全局池获取,无需在实例里持有。
#[derive(Clone)]
pub struct YggMcpServer;
#[tool_router]
impl YggMcpServer {
/// 搜索已发布文章(知识库)。要求 read 作用域。
#[tool(description = "全文搜索已发布文章,作为知识库。返回标题/slug/摘要/标签。")]
async fn search_posts(
&self,
Parameters(SearchPostsParams { query }): Parameters<SearchPostsParams>,
Extension(parts): Extension<http::request::Parts>,
) -> Result<CallToolResult, McpError> {
// 鉴权 + 作用域校验read 工具要求 token.scope >= read。
let principal = parts
.extensions
.get::<McpPrincipal>()
.ok_or_else(|| McpError::invalid_request("missing MCP principal", None))?;
if !principal.scope.grants(TokenScope::Read) {
return Err(McpError::invalid_request(
"insufficient_scope: search_posts requires read",
None,
));
}
let hits = search_published(&query)
.await
.map_err(|e| McpError::internal_error(format!("search failed: {e}"), None))?;
// 输出为 JSON 文本块(客户端 LLM 可解析。T3 会改用 resource_link + 结构化输出。
let text = serde_json::to_string_pretty(&hits)
.map_err(|e| McpError::internal_error(format!("encode failed: {e}"), None))?;
Ok(CallToolResult::success(vec![ContentBlock::Text(
TextContent::new(text),
)]))
}
}
#[tool_handler(name = "yggdrasil", version = "0.1.0")]
impl ServerHandler for YggMcpServer {}
/// 已发布文章的精简命中MCP 工具输出)。
#[derive(Debug, serde::Serialize)]
struct SearchHit {
id: i32,
title: String,
slug: String,
summary: Option<String>,
tags: Vec<String>,
}
/// 直查 DB与 src/api/posts/search.rs 的 server-fn 一致的 pg_trgm 查询。
/// 这里不复用 server-fn后者依赖 FullstackContext 做限流MCP 路径不走 cookie 鉴权),
/// 直接走连接池。T3 会抽出共享 helper。
async fn search_published(query: &str) -> Result<Vec<SearchHit>, String> {
let q = query.trim();
if q.is_empty() || q.chars().count() > 200 {
return Ok(Vec::new());
}
let client = crate::db::pool::get_conn()
.await
.map_err(|e| format!("db conn: {e}"))?;
let escaped = q
.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_");
let rows = client
.query(
"SELECT p.id, p.title, p.slug, p.summary,
COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags
FROM posts p
LEFT JOIN post_tags pt ON p.id = pt.post_id
LEFT JOIN tags t ON pt.tag_id = t.id
WHERE p.status = 'published' AND p.deleted_at IS NULL
AND p.search_text ILIKE '%' || $1 || '%' ESCAPE '\\'
GROUP BY p.id, p.search_text
ORDER BY word_similarity(p.search_text, $2) DESC, p.published_at DESC
LIMIT 50",
&[&escaped, &q],
)
.await
.map_err(|e| format!("query: {e}"))?;
let hits = rows
.iter()
.map(|r| SearchHit {
id: r.get(0),
title: r.get(1),
slug: r.get(2),
summary: r.get(3),
tags: r.get(4),
})
.collect();
Ok(hits)
}

176
src/models/mcp_token.rs Normal file
View File

@ -0,0 +1,176 @@
//! MCP 服务器访问令牌模型。
//!
//! `mcp_tokens` 表承载管理员为 AI 客户端签发的 bearer 令牌:绑定用户、作用域、
//! 可选过期时间。明文 token 经 AES-GCM 静态加密存储(`token_enc`),可由管理员
//! 解密重查;同时存 SHA-256 哈希(`token_hash`)做每请求 O(1) 常量查找。
//!
//! 与 assets 一致id 以 String 承载SQL 侧 `id::text` 读出、`$1::uuid` 写入),
//! 避免把 server-only 的 uuid crate 引入 WASM 前端构建。chrono 用于两端共享。
use serde::{Deserialize, Serialize};
/// 令牌作用域read < write < admin支持偏序比较用于工具调度鉴权。
///
/// - `read`:仅查询已发布文章(知识库)。
/// - `write``read` + 文章 CRUD含草稿、评论、标签、媒体上传。
/// - `admin``write` + 站点设置、代码运行器。
///
/// 比较语义:`scope >= required` 表示该令牌有权调用要求 `required` 作用域的工具。
/// 例如 `admin` 令牌可调用 `read`/`write`/`admin` 任一工具。
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "lowercase")]
pub enum TokenScope {
Read,
Write,
Admin,
}
impl TokenScope {
/// 数据库存储的字符串形式。
pub fn as_str(self) -> &'static str {
match self {
TokenScope::Read => "read",
TokenScope::Write => "write",
TokenScope::Admin => "admin",
}
}
/// 从数据库字符串解析;非法值返回 None调用方按业务错误处理不走 panic
pub fn from_db(s: &str) -> Option<Self> {
match s {
"read" => Some(TokenScope::Read),
"write" => Some(TokenScope::Write),
"admin" => Some(TokenScope::Admin),
_ => None,
}
}
/// 数值用于偏序比较read=1 < write=2 < admin=3。
fn rank(self) -> u8 {
match self {
TokenScope::Read => 1,
TokenScope::Write => 2,
TokenScope::Admin => 3,
}
}
/// 该令牌是否满足某工具要求的作用域(`self.rank() >= required.rank()`)。
pub fn grants(self, required: TokenScope) -> bool {
self.rank() >= required.rank()
}
}
impl PartialOrd for TokenScope {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for TokenScope {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.rank().cmp(&other.rank())
}
}
/// mcp_tokens 表一行(不含明文;密文在 `token_enc`)。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct McpToken {
pub id: String,
pub user_id: i32,
pub name: String,
pub scope: TokenScope,
/// AES-GCM 密文 hexnonce ‖ ct ‖ tag。仅服务端解密使用不向前端暴露。
#[serde(skip)]
pub token_enc: String,
/// 明文 SHA-256 hex。仅服务端查找用不向前端暴露。
#[serde(skip)]
pub token_hash: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
pub last_used_at: Option<chrono::DateTime<chrono::Utc>>,
pub revoked_at: Option<chrono::DateTime<chrono::Utc>>,
}
/// 列表响应 DTO不含任何密钥材料仅展示用元数据。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct McpTokenSummary {
pub id: String,
pub name: String,
pub scope: TokenScope,
pub created_at: chrono::DateTime<chrono::Utc>,
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
pub last_used_at: Option<chrono::DateTime<chrono::Utc>>,
pub revoked_at: Option<chrono::DateTime<chrono::Utc>>,
}
impl From<McpToken> for McpTokenSummary {
fn from(t: McpToken) -> Self {
Self {
id: t.id,
name: t.name,
scope: t.scope,
created_at: t.created_at,
expires_at: t.expires_at,
last_used_at: t.last_used_at,
revoked_at: t.revoked_at,
}
}
}
/// 签发令牌的响应:摘要 + 一次性明文(明文仅在签发/重查时返回,不持久明文)。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CreateTokenResponse {
#[serde(flatten)]
pub summary: McpTokenSummary,
/// 完整 bearer 明文,形如 `ygg_<opaque>`;客户端写入 Authorization 头。
pub plaintext: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scope_ranking_grants_chain() {
// read 仅满足 read
assert!(TokenScope::Read.grants(TokenScope::Read));
assert!(!TokenScope::Read.grants(TokenScope::Write));
assert!(!TokenScope::Read.grants(TokenScope::Admin));
// write 满足 read+write不满足 admin
assert!(TokenScope::Write.grants(TokenScope::Read));
assert!(TokenScope::Write.grants(TokenScope::Write));
assert!(!TokenScope::Write.grants(TokenScope::Admin));
// admin 满足全部
assert!(TokenScope::Admin.grants(TokenScope::Read));
assert!(TokenScope::Admin.grants(TokenScope::Write));
assert!(TokenScope::Admin.grants(TokenScope::Admin));
}
#[test]
fn scope_ord_total_order() {
assert!(TokenScope::Read < TokenScope::Write);
assert!(TokenScope::Write < TokenScope::Admin);
assert!(TokenScope::Admin >= TokenScope::Read);
// 偏序完备Ord 实现,无 PartialOrd 退化分支)
let mut v = [TokenScope::Admin, TokenScope::Read, TokenScope::Write];
v.sort();
assert_eq!(v, [TokenScope::Read, TokenScope::Write, TokenScope::Admin]);
}
#[test]
fn scope_db_roundtrip() {
for s in [TokenScope::Read, TokenScope::Write, TokenScope::Admin] {
assert_eq!(TokenScope::from_db(s.as_str()), Some(s));
}
assert_eq!(TokenScope::from_db("root"), None);
assert_eq!(TokenScope::from_db(""), None);
}
#[test]
fn scope_serde_lowercase() {
let s = serde_json::to_string(&TokenScope::Admin).unwrap();
assert_eq!(s, "\"admin\"");
let v: TokenScope = serde_json::from_str("\"read\"").unwrap();
assert_eq!(v, TokenScope::Read);
}
}

View File

@ -5,6 +5,10 @@
/// 素材图片模型assets 注册表与引用关联的 serde DTO。 /// 素材图片模型assets 注册表与引用关联的 serde DTO。
pub mod asset; pub mod asset;
/// MCP 服务器访问令牌模型与作用域枚举。
/// allow(dead_code)T1 仅定义类型T2 的 token 管理服务端函数才构造这些 DTO。
#[allow(dead_code)]
pub mod mcp_token;
/// 评论模型及其状态枚举。 /// 评论模型及其状态枚举。
pub mod comment; pub mod comment;
/// 文章模型、文章状态、标签与统计信息。 /// 文章模型、文章状态、标签与统计信息。