diff --git a/Cargo.lock b/Cargo.lock index 0bd00ae..cc7205c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "aho-corasick" version = "1.1.4" @@ -81,7 +116,7 @@ checksum = "562827c72b3c65ebc7beabc710ffae5b991c0e9e60f7f71374c219107d22f335" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -144,7 +179,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -155,7 +190,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -274,7 +309,7 @@ checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -420,7 +455,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn", + "syn 2.0.119", ] [[package]] @@ -467,7 +502,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -594,6 +629,16 @@ dependencies = [ "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]] name = "cmov" version = "0.5.4" @@ -679,7 +724,7 @@ checksum = "4f160aad86b4343e8d4e261fee9965c3005b2fd6bc117d172ab65948779e4acf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -690,7 +735,7 @@ checksum = "42571ed01eb46d2e1adcf99c8ca576f081e46f2623d13500eba70d1d99a4c439" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -856,6 +901,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -888,7 +934,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "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]] @@ -930,7 +985,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -943,7 +998,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.119", ] [[package]] @@ -954,7 +1009,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core 0.21.3", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -965,7 +1020,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1048,7 +1103,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn", + "syn 2.0.119", "unicode-xid", ] @@ -1186,7 +1241,7 @@ dependencies = [ "dioxus-rsx", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1348,7 +1403,7 @@ dependencies = [ "convert_case 0.8.0", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "xxhash-rust", ] @@ -1414,7 +1469,7 @@ dependencies = [ "convert_case 0.8.0", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1509,7 +1564,7 @@ dependencies = [ "quote", "sha2 0.10.9", "slab", - "syn", + "syn 2.0.119", ] [[package]] @@ -1522,7 +1577,7 @@ dependencies = [ "proc-macro2-diagnostics", "quote", "rustversion", - "syn", + "syn 2.0.119", ] [[package]] @@ -1632,7 +1687,7 @@ dependencies = [ "convert_case 0.8.0", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1687,7 +1742,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1726,6 +1781,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1759,7 +1820,7 @@ dependencies = [ "darling 0.21.3", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1934,7 +1995,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2053,6 +2114,16 @@ dependencies = [ "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]] name = "gif" version = "0.14.2" @@ -2564,6 +2635,15 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "inventory" version = "0.3.24" @@ -2626,7 +2706,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2844,7 +2924,7 @@ checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2898,7 +2978,7 @@ dependencies = [ "manganis-core", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -3158,7 +3238,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -3242,6 +3322,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "parking" version = "2.2.1" @@ -3288,6 +3374,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3335,7 +3427,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -3364,7 +3456,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -3411,6 +3503,18 @@ dependencies = [ "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]] name = "portable-atomic" version = "1.13.1" @@ -3485,7 +3589,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.119", ] [[package]] @@ -3514,7 +3618,7 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "version_check", ] @@ -3796,7 +3900,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76009fbe0614077fc1a2ce255e3a1881a2e3a3527097d5dc6d8212c585e7e38b" dependencies = [ "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -3808,6 +3912,26 @@ dependencies = [ "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]] name = "regex" version = "1.13.1" @@ -3904,6 +4028,50 @@ dependencies = [ "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]] name = "rustc-hash" version = "1.1.0" @@ -4000,6 +4168,32 @@ dependencies = [ "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]] name = "scopeguard" version = "1.2.0" @@ -4078,7 +4272,18 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "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]] @@ -4124,7 +4329,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -4161,7 +4366,7 @@ checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -4256,7 +4461,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb251b407f50028476a600541542b605bb864d35d9ee1de4f6cab45d88475e6d" dependencies = [ "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -4319,6 +4524,19 @@ dependencies = [ "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]] name = "stable_deref_trait" version = "1.2.1" @@ -4373,7 +4591,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -4421,6 +4639,17 @@ dependencies = [ "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]] name = "sync_wrapper" version = "1.0.2" @@ -4438,7 +4667,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -4530,7 +4759,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -4541,7 +4770,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -4631,7 +4860,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -4847,7 +5076,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -5023,6 +5252,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "untrusted" version = "0.9.0" @@ -5114,7 +5353,7 @@ checksum = "59195a1db0e95b920366d949ba5e0d3fc0e70b67c09be15ce5abb790106b0571" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -5192,7 +5431,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "wasm-bindgen-shared", ] @@ -5362,7 +5601,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -5373,7 +5612,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -5627,6 +5866,7 @@ dependencies = [ name = "yggdrasil" version = "0.6.2" dependencies = [ + "aes-gcm", "argon2", "axum", "bollard", @@ -5652,6 +5892,7 @@ dependencies = [ "pulldown-cmark", "rand 0.8.7", "regex", + "rmcp", "serde", "serde-wasm-bindgen", "serde_json", @@ -5692,7 +5933,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] @@ -5758,7 +5999,7 @@ checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -5778,7 +6019,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] @@ -5818,7 +6059,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 4507012..7310a84 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,6 +67,13 @@ mimalloc = { version = "0.1", optional = true } # 默认 features 为空即原生服务端编译;wasm feature 仅前端构建才需,这里不开。 # 配套前端需引入 katex.min.css + 字体(见 public/katex/、Makefile katex-css)。 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] 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:mimalloc", "dep:katex-rs", + "dep:rmcp", + "dep:aes-gcm", ] diff --git a/migrations/017_mcp_tokens.sql b/migrations/017_mcp_tokens.sql new file mode 100644 index 0000000..7e76b24 --- /dev/null +++ b/migrations/017_mcp_tokens.sql @@ -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); diff --git a/src/db/migrate.rs b/src/db/migrate.rs index bd8c5ca..e4b089e 100644 --- a/src/db/migrate.rs +++ b/src/db/migrate.rs @@ -65,6 +65,7 @@ const MIGRATIONS: &[(&str, &str)] = &[ "016", include_str!("../../migrations/016_assets_content_hash.sql"), ), + ("017", include_str!("../../migrations/017_mcp_tokens.sql")), // 新增迁移在此追加,同时在 migrations/ 下创建对应 .sql 文件。 ]; diff --git a/src/main.rs b/src/main.rs index 8ffe6bd..db7fff3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -39,6 +39,13 @@ mod hooks; #[cfg(feature = "server")] mod middleware; 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 router; // sysinfo_sampler:主机指标快照。 @@ -333,7 +340,8 @@ fn main() { .merge(export_route) .merge(sse_route) .merge(app_routes) - .merge(static_routes); + .merge(static_routes) + .merge(crate::mcp::router::mcp_route()); // 版本头中间件置于最终合并 router 的最外层:所有端点(含 /healthz、/uploads/*、 // 被 CSRF 拒/超时/admin_guard 重定向的响应)都会带上版本头。受 EXPOSE_VERSION_HEADERS 控制。 diff --git a/src/mcp/auth.rs b/src/mcp/auth.rs new file mode 100644 index 0000000..fe3424a --- /dev/null +++ b/src/mcp/auth.rs @@ -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` +//! 提取器在工具内读取(见 `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 { + 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, next: Next) -> Result { + 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 { + 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(), + }) +} diff --git a/src/mcp/crypto.rs b/src/mcp/crypto.rs new file mode 100644 index 0000000..d7caebe --- /dev/null +++ b/src/mcp/crypto.rs @@ -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 { + let key_bytes = mcp_enc_key()?; + let cipher = Aes256Gcm::new(Key::::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 { + 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::::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 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); + 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"); + } +} diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs new file mode 100644 index 0000000..c5adaf7 --- /dev/null +++ b/src/mcp/mod.rs @@ -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` +//! 提取器读取,按作用域鉴权。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; diff --git a/src/mcp/router.rs b/src/mcp/router.rs new file mode 100644 index 0000000..1ba2f77 --- /dev/null +++ b/src/mcp/router.rs @@ -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 = 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)) +} diff --git a/src/mcp/server.rs b/src/mcp/server.rs new file mode 100644 index 0000000..5903797 --- /dev/null +++ b/src/mcp/server.rs @@ -0,0 +1,126 @@ +//! MCP 服务器:rmcp `ServerHandler` 实现,暴露博客工具。 +//! +//! T1(tracer bullet)仅暴露一个工具 `search_posts`,证明: +//! - rmcp handler 经 `tool_router`/`tool_handler` 宏装配成功; +//! - 工具内能经 `Extension` 读取鉴权中间件注入的 `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, + Extension(parts): Extension, + ) -> Result { + // 鉴权 + 作用域校验:read 工具要求 token.scope >= read。 + let principal = parts + .extensions + .get::() + .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, + tags: Vec, +} + +/// 直查 DB:与 src/api/posts/search.rs 的 server-fn 一致的 pg_trgm 查询。 +/// 这里不复用 server-fn(后者依赖 FullstackContext 做限流,MCP 路径不走 cookie 鉴权), +/// 直接走连接池。T3 会抽出共享 helper。 +async fn search_published(query: &str) -> Result, 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) +} diff --git a/src/models/mcp_token.rs b/src/models/mcp_token.rs new file mode 100644 index 0000000..af9fde1 --- /dev/null +++ b/src/models/mcp_token.rs @@ -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 { + 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 { + 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, + pub expires_at: Option>, + pub last_used_at: Option>, + pub revoked_at: Option>, +} + +/// 列表响应 DTO:不含任何密钥材料,仅展示用元数据。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct McpTokenSummary { + pub id: String, + pub name: String, + pub scope: TokenScope, + pub created_at: chrono::DateTime, + pub expires_at: Option>, + pub last_used_at: Option>, + pub revoked_at: Option>, +} + +impl From 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_`;客户端写入 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); + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs index dfad32c..4d7f1ab 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -5,6 +5,10 @@ /// 素材(图片)模型:assets 注册表与引用关联的 serde DTO。 pub mod asset; +/// MCP 服务器访问令牌模型与作用域枚举。 +/// allow(dead_code):T1 仅定义类型;T2 的 token 管理服务端函数才构造这些 DTO。 +#[allow(dead_code)] +pub mod mcp_token; /// 评论模型及其状态枚举。 pub mod comment; /// 文章模型、文章状态、标签与统计信息。