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:
parent
801cb79e0d
commit
7b9dfa7851
341
Cargo.lock
generated
341
Cargo.lock
generated
@ -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]]
|
||||||
|
|||||||
@ -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 }
|
||||||
|
# MCP(Model 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",
|
||||||
]
|
]
|
||||||
|
|||||||
25
migrations/017_mcp_tokens.sql
Normal file
25
migrations/017_mcp_tokens.sql
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
-- MCP(Model 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 hex(64 字符),用于每请求 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);
|
||||||
@ -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 文件。
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
10
src/main.rs
10
src/main.rs
@ -39,6 +39,13 @@ mod hooks;
|
|||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
mod middleware;
|
mod middleware;
|
||||||
mod models;
|
mod models;
|
||||||
|
// mcp:Model Context Protocol 服务器(/mcp Streamable HTTP,bearer token 鉴权)。
|
||||||
|
// 仅 server feature 编译;WASM 前端不引用任何 mcp 符号。
|
||||||
|
// allow(dead_code):T1 tracer bullet 仅接通 search_posts;token 管理服务端函数(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
108
src/mcp/auth.rs
Normal 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 id(String,与 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
193
src/mcp/crypto.rs
Normal 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-256:12 字节随机 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
24
src/mcp/mod.rs
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
//! MCP(Model 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
47
src/mcp/router.rs
Normal 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
126
src/mcp/server.rs
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
//! MCP 服务器:rmcp `ServerHandler` 实现,暴露博客工具。
|
||||||
|
//!
|
||||||
|
//! T1(tracer bullet)仅暴露一个工具 `search_posts`,证明:
|
||||||
|
//! - rmcp handler 经 `tool_router`/`tool_handler` 宏装配成功;
|
||||||
|
//! - 工具内能经 `Extension<http::request::Parts>` 读取鉴权中间件注入的 `McpPrincipal`;
|
||||||
|
//! - 作用域鉴权可生效(此处 search_posts 要求 read)。
|
||||||
|
//!
|
||||||
|
//! T3 会扩展为完整 read 工具集 + Resources;T4/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
176
src/models/mcp_token.rs
Normal 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 密文 hex(nonce ‖ 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
/// 文章模型、文章状态、标签与统计信息。
|
/// 文章模型、文章状态、标签与统计信息。
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user