mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
feat(mcp): serve the 2026-07-28 spec alongside the legacy protocol (#10535)
* feat(mcp): serve the 2026-07-28 spec alongside the legacy protocol * fix(mcp): keep oauth discovery strict and preserve request limits * fix(mcp): allow the protocol's own headers through CORS * fix(mcp): expose the auth challenge to browser clients * chore: update ee-repo-ref to c1665a881b61616f96ffe7702b44840905304660 This commit updates the EE repository reference after PR #711 was merged in windmill-ee-private. Previous ee-repo-ref: bc1c001e3e386342415dfb8ac31c6b97f6629320 New ee-repo-ref: c1665a881b61616f96ffe7702b44840905304660 Automated by sync-ee-ref workflow. --------- Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
Generated
+98
-66
@@ -1671,6 +1671,12 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "base64-simd"
|
||||
version = "0.8.0"
|
||||
@@ -2270,6 +2276,12 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cast"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
|
||||
|
||||
[[package]]
|
||||
name = "castaway"
|
||||
version = "0.2.4"
|
||||
@@ -6662,9 +6674,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.80"
|
||||
version = "0.3.85"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "852f13bec5eba4ba9afbeb93fd7c13fe56147f055939ae21c43a29a0ecb2702e"
|
||||
checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
@@ -8286,6 +8298,12 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oorandom"
|
||||
version = "11.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.3.1"
|
||||
@@ -10081,16 +10099,16 @@ dependencies = [
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"wasm-streams 0.4.2",
|
||||
"web-sys",
|
||||
"webpki-roots 1.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.13.1"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62"
|
||||
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
@@ -10127,7 +10145,7 @@ dependencies = [
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"wasm-streams 0.5.0",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
@@ -10140,7 +10158,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"http 1.5.0",
|
||||
"reqwest 0.13.1",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"thiserror 2.0.19",
|
||||
"tower-service",
|
||||
@@ -10158,7 +10176,7 @@ dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"http 1.5.0",
|
||||
"hyper 1.11.0",
|
||||
"reqwest 0.13.1",
|
||||
"reqwest 0.13.4",
|
||||
"reqwest-middleware",
|
||||
"retry-policies",
|
||||
"thiserror 2.0.19",
|
||||
@@ -10252,13 +10270,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rmcp"
|
||||
version = "0.15.0"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bef41ebc9ebed2c1b1d90203e9d1756091e8a00bbc3107676151f39868ca0ee"
|
||||
checksum = "ad26b216c966e987e80e86daf784a455c039c43d98575ceed57b8faa259e5695"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum 0.8.9",
|
||||
"base64 0.22.1",
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"futures",
|
||||
@@ -10268,8 +10285,8 @@ dependencies = [
|
||||
"oauth2",
|
||||
"pastey",
|
||||
"pin-project-lite",
|
||||
"rand 0.9.0",
|
||||
"reqwest 0.12.28",
|
||||
"rand 0.10.2",
|
||||
"reqwest 0.13.4",
|
||||
"rmcp-macros",
|
||||
"schemars 1.2.2",
|
||||
"serde",
|
||||
@@ -10287,9 +10304,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rmcp-macros"
|
||||
version = "0.15.0"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e88ad84b8b6237a934534a62b379a5be6388915663c0cc598ceb9b3292bbbfe"
|
||||
checksum = "41bc748630c2be2a71b614c2f40d27bc0df0060696d224e1692c72345b7e0b79"
|
||||
dependencies = [
|
||||
"darling 0.23.0",
|
||||
"proc-macro2",
|
||||
@@ -14220,9 +14237,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.103"
|
||||
version = "0.2.108"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab10a69fbd0a177f5f649ad4d8d3305499c42bab9aef2f7ff592d0ec8f833819"
|
||||
checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@@ -14232,27 +14249,14 @@ dependencies = [
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-backend"
|
||||
version = "0.2.103"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bb702423545a6007bbc368fde243ba47ca275e549c8a28617f56f6ba53b1d1c"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"log",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.53"
|
||||
version = "0.4.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0b221ff421256839509adbb55998214a70d829d3a28c69b4a6672e9d2a42f67"
|
||||
checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
@@ -14261,9 +14265,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.103"
|
||||
version = "0.2.108"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc65f4f411d91494355917b605e1480033152658d71f722a90647f56a70c88a0"
|
||||
checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -14271,50 +14275,65 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.103"
|
||||
version = "0.2.108"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ffc003a991398a8ee604a401e194b6b3a39677b3173d6e74495eb51b82e99a32"
|
||||
checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"wasm-bindgen-backend",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.103"
|
||||
version = "0.2.108"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "293c37f4efa430ca14db3721dfbe48d8c33308096bd44d80ebaa775ab71ba1cf"
|
||||
checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test"
|
||||
version = "0.3.53"
|
||||
version = "0.3.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aee0a0f5343de9221a0d233b04520ed8dc2e6728dce180b1dcd9288ec9d9fa3c"
|
||||
checksum = "45649196a53b0b7a15101d845d44d2dda7374fc1b5b5e2bbf58b7577ff4b346d"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"cast",
|
||||
"js-sys",
|
||||
"libm",
|
||||
"minicov",
|
||||
"nu-ansi-term",
|
||||
"num-traits",
|
||||
"oorandom",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-bindgen-test-macro",
|
||||
"wasm-bindgen-test-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test-macro"
|
||||
version = "0.3.53"
|
||||
version = "0.3.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a369369e4360c2884c3168d22bded735c43cccae97bbc147586d4b480edd138d"
|
||||
checksum = "f579cdd0123ac74b94e1a4a72bd963cf30ebac343f2df347da0b8df24cdebed2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test-shared"
|
||||
version = "0.2.108"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8145dd1593bf0fb137dbfa85b8be79ec560a447298955877804640e40c2d6ea"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.4.2"
|
||||
@@ -14328,6 +14347,19 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm_dep_analyzer"
|
||||
version = "0.3.0"
|
||||
@@ -14354,9 +14386,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.80"
|
||||
version = "0.3.85"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fbe734895e869dc429d78c4b433f8d17d95f8d05317440b4fad5ab2d33e596dc"
|
||||
checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@@ -14522,7 +14554,7 @@ dependencies = [
|
||||
"prometheus",
|
||||
"rand 0.9.0",
|
||||
"rdkafka",
|
||||
"reqwest 0.13.1",
|
||||
"reqwest 0.13.4",
|
||||
"rumqttc",
|
||||
"rustls 0.23.35",
|
||||
"serde",
|
||||
@@ -14599,7 +14631,7 @@ dependencies = [
|
||||
"http 1.5.0",
|
||||
"lazy_static",
|
||||
"mime_guess",
|
||||
"reqwest 0.13.1",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
@@ -14679,7 +14711,7 @@ dependencies = [
|
||||
"quick_cache",
|
||||
"rand 0.9.0",
|
||||
"regex",
|
||||
"reqwest 0.13.1",
|
||||
"reqwest 0.13.4",
|
||||
"rsa",
|
||||
"rust-embed",
|
||||
"rustls 0.23.35",
|
||||
@@ -14819,7 +14851,7 @@ dependencies = [
|
||||
"jsonwebtoken 8.3.0",
|
||||
"lazy_static",
|
||||
"quick_cache",
|
||||
"reqwest 0.13.1",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
@@ -14893,7 +14925,7 @@ dependencies = [
|
||||
"candle-transformers",
|
||||
"hf-hub",
|
||||
"lazy_static",
|
||||
"reqwest 0.13.1",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
@@ -14994,7 +15026,7 @@ dependencies = [
|
||||
"hmac",
|
||||
"rand 0.9.0",
|
||||
"rdkafka",
|
||||
"reqwest 0.13.1",
|
||||
"reqwest 0.13.4",
|
||||
"rmcp",
|
||||
"rumqttc",
|
||||
"serde",
|
||||
@@ -15044,7 +15076,7 @@ version = "1.780.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"flate2",
|
||||
"reqwest 0.13.1",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
@@ -15111,7 +15143,7 @@ dependencies = [
|
||||
"lazy_static",
|
||||
"prometheus",
|
||||
"quick_cache",
|
||||
"reqwest 0.13.1",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sql-builder",
|
||||
@@ -15346,7 +15378,7 @@ dependencies = [
|
||||
"quick_cache",
|
||||
"rand 0.9.0",
|
||||
"regex",
|
||||
"reqwest 0.13.1",
|
||||
"reqwest 0.13.4",
|
||||
"reqwest-middleware",
|
||||
"reqwest-retry",
|
||||
"rsa",
|
||||
@@ -15488,7 +15520,7 @@ dependencies = [
|
||||
"futures",
|
||||
"http 1.5.0",
|
||||
"oauth2",
|
||||
"reqwest 0.12.28",
|
||||
"reqwest 0.13.4",
|
||||
"rmcp",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -15513,7 +15545,7 @@ dependencies = [
|
||||
"http 1.5.0",
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
"reqwest 0.13.1",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
@@ -15575,7 +15607,7 @@ dependencies = [
|
||||
"lazy_static",
|
||||
"object_store",
|
||||
"quick_cache",
|
||||
"reqwest 0.13.1",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
@@ -15898,7 +15930,7 @@ dependencies = [
|
||||
"quick_cache",
|
||||
"rand 0.9.0",
|
||||
"regex",
|
||||
"reqwest 0.13.1",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
@@ -15940,7 +15972,7 @@ dependencies = [
|
||||
"lazy_static",
|
||||
"rcgen",
|
||||
"regex",
|
||||
"reqwest 0.13.1",
|
||||
"reqwest 0.13.4",
|
||||
"rustls 0.23.35",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -15981,7 +16013,7 @@ dependencies = [
|
||||
"lazy_static",
|
||||
"magic-crypt",
|
||||
"quick_cache",
|
||||
"reqwest 0.13.1",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
@@ -16036,7 +16068,7 @@ dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
"rand 0.9.0",
|
||||
"reqwest 0.13.1",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sql-builder",
|
||||
@@ -16100,7 +16132,7 @@ dependencies = [
|
||||
"lazy_static",
|
||||
"quick_cache",
|
||||
"rand 0.9.0",
|
||||
"reqwest 0.13.1",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
@@ -16153,7 +16185,7 @@ dependencies = [
|
||||
"jsonwebtoken 8.3.0",
|
||||
"lazy_static",
|
||||
"quick_cache",
|
||||
"reqwest 0.13.1",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
@@ -16440,7 +16472,7 @@ dependencies = [
|
||||
"rand 0.9.0",
|
||||
"rcgen",
|
||||
"regex",
|
||||
"reqwest 0.13.1",
|
||||
"reqwest 0.13.4",
|
||||
"reqwest-middleware",
|
||||
"rsa",
|
||||
"rust_decimal",
|
||||
|
||||
+7
-2
@@ -568,7 +568,12 @@ dashmap = "6.1.0"
|
||||
gosyn = "0.2.6"
|
||||
bytes = "1.4.0"
|
||||
gethostname = "0.4.3"
|
||||
wasm-bindgen = "=0.2.103"
|
||||
# Not pinned exactly: the excluded `parsers/windmill-parser-wasm` workspace pins
|
||||
# =0.2.103 to match its vendored `cli/wasm/*` artifacts, yet path-depends on
|
||||
# sibling parser crates that inherit this requirement from here. Two exact pins
|
||||
# on the same semver range cannot both resolve, so keep this a range and let each
|
||||
# workspace's lockfile settle it (here, js-sys forces 0.2.108).
|
||||
wasm-bindgen = "0.2"
|
||||
serde-wasm-bindgen = "^0"
|
||||
wasm-bindgen-test = "^0"
|
||||
convert_case = "0.6.0"
|
||||
@@ -612,7 +617,7 @@ nkeys = "0.4.4"
|
||||
nu-parser = { version = "0.101.0", default-features = false }
|
||||
globset = "0.4.16"
|
||||
croner = "2.2.0"
|
||||
rmcp = { version = "=0.15.0", features = ["client", "transport-streamable-http-client", "transport-streamable-http-client-reqwest"] }
|
||||
rmcp = { version = "=3.1.0", features = ["client", "transport-streamable-http-client", "transport-streamable-http-client-reqwest"] }
|
||||
rquickjs = { version = "0.11", features = ["futures", "parallel", "macro"] }
|
||||
process-wrap = { version = "8.2.1", features = ["tokio1"] }
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
0373b4bfdaf8dd51533552e2e4de63ceb3c18b4d
|
||||
c1665a881b61616f96ffe7702b44840905304660
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Protocol-version negotiation for the MCP endpoint.
|
||||
//!
|
||||
//! The endpoint is dual-era: legacy revisions keep the `initialize` handshake,
|
||||
//! while `2026-07-28` carries its version as per-request metadata and is served
|
||||
//! statelessly. Both are answered on the same URL, so a bump of the rmcp SDK
|
||||
//! must not silently drop either side.
|
||||
#![cfg(feature = "mcp")]
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::{Pool, Postgres};
|
||||
|
||||
use windmill_test_utils::*;
|
||||
|
||||
/// Every revision the server advertises, oldest first.
|
||||
const SUPPORTED: [&str; 5] = [
|
||||
"2024-11-05",
|
||||
"2025-03-26",
|
||||
"2025-06-18",
|
||||
"2025-11-25",
|
||||
"2026-07-28",
|
||||
];
|
||||
|
||||
const MODERN: &str = "2026-07-28";
|
||||
|
||||
async fn insert_mcp_token(db: &Pool<Postgres>) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, scopes)
|
||||
VALUES (encode(sha256('MCP_TOKEN'::bytea), 'hex'), 'MCP_TOK', 'MCP_TOKEN', 'test@windmill.dev', 'mcp token', true, ARRAY['mcp:all'])",
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// POST one JSON-RPC message and return the HTTP status plus the decoded body.
|
||||
/// The endpoint answers either `application/json` or a single-event SSE stream,
|
||||
/// so strip the `data: ` framing before parsing.
|
||||
async fn post(
|
||||
port: u16,
|
||||
headers: &[(&str, &str)],
|
||||
body: Value,
|
||||
) -> anyhow::Result<(reqwest::StatusCode, Value)> {
|
||||
let mut req = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/mcp/w/test-workspace/mcp"
|
||||
))
|
||||
.header("Authorization", "Bearer MCP_TOKEN")
|
||||
.header("Accept", "application/json, text/event-stream")
|
||||
.json(&body);
|
||||
for (k, v) in headers {
|
||||
req = req.header(*k, *v);
|
||||
}
|
||||
let resp = req.send().await?;
|
||||
let status = resp.status();
|
||||
let text = resp.text().await?;
|
||||
let payload = text
|
||||
.lines()
|
||||
.find_map(|l| l.strip_prefix("data: "))
|
||||
.unwrap_or(text.trim());
|
||||
let parsed = serde_json::from_str(payload)
|
||||
.map_err(|e| anyhow::anyhow!("status {status}, unparseable body {text:?}: {e}"))?;
|
||||
Ok((status, parsed))
|
||||
}
|
||||
|
||||
fn modern_meta() -> Value {
|
||||
json!({
|
||||
"io.modelcontextprotocol/protocolVersion": MODERN,
|
||||
"io.modelcontextprotocol/clientInfo": { "name": "test-client", "version": "0.0.1" },
|
||||
"io.modelcontextprotocol/clientCapabilities": {},
|
||||
})
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_mcp_legacy_initialize_negotiates_requested_version(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
set_jwt_secret().await;
|
||||
insert_mcp_token(&db).await?;
|
||||
let server = ApiServer::start_mcp(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
// A legacy client must be answered with the revision it asked for, not with
|
||||
// whatever the SDK happens to call `LATEST`.
|
||||
for version in SUPPORTED.iter().filter(|v| **v != MODERN) {
|
||||
let (status, body) = post(
|
||||
port,
|
||||
&[("MCP-Protocol-Version", version)],
|
||||
json!({
|
||||
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": version,
|
||||
"capabilities": {},
|
||||
"clientInfo": { "name": "test-client", "version": "0.0.1" },
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(status, 200, "initialize {version} failed: {body}");
|
||||
assert_eq!(
|
||||
body["result"]["protocolVersion"], *version,
|
||||
"initialize {version} negotiated the wrong revision: {body}"
|
||||
);
|
||||
// `Implementation::from_build_env()` expands its `env!` inside rmcp, so
|
||||
// the obvious constructor makes the server introduce itself as the SDK.
|
||||
assert_eq!(
|
||||
body["result"]["serverInfo"]["name"], "windmill",
|
||||
"server must identify itself, not the SDK: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_mcp_modern_requests_are_served_without_initialize(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
set_jwt_secret().await;
|
||||
insert_mcp_token(&db).await?;
|
||||
let server = ApiServer::start_mcp(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
// `server/discover` is the modern replacement for the handshake: it must
|
||||
// exist and advertise exactly the revisions the server implements.
|
||||
let (status, body) = post(
|
||||
port,
|
||||
&[
|
||||
("MCP-Protocol-Version", MODERN),
|
||||
("Mcp-Method", "server/discover"),
|
||||
],
|
||||
json!({
|
||||
"jsonrpc": "2.0", "id": 1, "method": "server/discover",
|
||||
"params": { "_meta": modern_meta() }
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(status, 200, "server/discover failed: {body}");
|
||||
assert_eq!(
|
||||
body["result"]["supportedVersions"],
|
||||
json!(SUPPORTED),
|
||||
"server/discover advertised the wrong revisions: {body}"
|
||||
);
|
||||
|
||||
// A modern call carries its version in `_meta` and needs no prior session.
|
||||
let (status, body) = post(
|
||||
port,
|
||||
&[
|
||||
("MCP-Protocol-Version", MODERN),
|
||||
("Mcp-Method", "tools/list"),
|
||||
],
|
||||
json!({
|
||||
"jsonrpc": "2.0", "id": 2, "method": "tools/list",
|
||||
"params": { "_meta": modern_meta() }
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(status, 200, "modern tools/list failed: {body}");
|
||||
assert!(
|
||||
body["result"]["tools"]
|
||||
.as_array()
|
||||
.is_some_and(|t| !t.is_empty()),
|
||||
"modern tools/list returned no tools: {body}"
|
||||
);
|
||||
|
||||
// SEP-2549 cache hints are required at 2026-07-28 and rmcp omits them unless
|
||||
// set, which makes strict clients (e.g. the Python SDK) reject the whole
|
||||
// response rather than degrade.
|
||||
assert!(
|
||||
body["result"]["ttlMs"].is_number(),
|
||||
"modern tools/list is missing ttlMs: {body}"
|
||||
);
|
||||
assert_eq!(
|
||||
body["result"]["cacheScope"], "private",
|
||||
"tools/list must not be cached across callers: {body}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_mcp_unsupported_version_lists_supported_ones(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
set_jwt_secret().await;
|
||||
insert_mcp_token(&db).await?;
|
||||
let server = ApiServer::start_mcp(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
// The client's only way forward is the `supported` list, so an unknown
|
||||
// version must fail with it rather than with a generic error.
|
||||
let (status, body) = post(
|
||||
port,
|
||||
&[
|
||||
("MCP-Protocol-Version", "1900-01-01"),
|
||||
("Mcp-Method", "tools/list"),
|
||||
],
|
||||
json!({
|
||||
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
|
||||
"params": { "_meta": {
|
||||
"io.modelcontextprotocol/protocolVersion": "1900-01-01",
|
||||
"io.modelcontextprotocol/clientInfo": { "name": "test-client", "version": "0.0.1" },
|
||||
"io.modelcontextprotocol/clientCapabilities": {},
|
||||
}}
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(status, 400, "expected 400 for unknown version: {body}");
|
||||
assert_eq!(body["error"]["code"], -32022, "wrong error code: {body}");
|
||||
assert_eq!(
|
||||
body["error"]["data"]["supported"],
|
||||
json!(SUPPORTED),
|
||||
"error did not advertise the supported revisions: {body}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -756,37 +756,26 @@ async fn test_mcp_client_get_job_and_logs(db: Pool<Postgres>) -> anyhow::Result<
|
||||
.auth_header("MCP_TOKEN");
|
||||
let transport = StreamableHttpClientTransport::from_config(config);
|
||||
|
||||
let client_info = ClientInfo {
|
||||
protocol_version: Default::default(),
|
||||
capabilities: ClientCapabilities::default(),
|
||||
client_info: Implementation {
|
||||
name: "test-client".to_string(),
|
||||
title: None,
|
||||
version: "0.0.1".to_string(),
|
||||
description: None,
|
||||
website_url: None,
|
||||
icons: None,
|
||||
},
|
||||
meta: None,
|
||||
};
|
||||
let client_info = ClientInfo::new(
|
||||
ClientCapabilities::default(),
|
||||
Implementation::new("test-client", "0.0.1"),
|
||||
);
|
||||
|
||||
let client: RunningService<RoleClient, InitializeRequestParams> =
|
||||
client_info.serve(transport).await?;
|
||||
|
||||
// --- Test getJob ---
|
||||
let result = client
|
||||
.call_tool(CallToolRequestParams {
|
||||
name: "getJob".into(),
|
||||
arguments: Some(serde_json::from_value(json!({ "id": job_id.to_string() }))?),
|
||||
task: None,
|
||||
meta: None,
|
||||
})
|
||||
.call_tool(
|
||||
CallToolRequestParams::new("getJob")
|
||||
.with_arguments(serde_json::from_value(json!({ "id": job_id.to_string() }))?),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let text = result
|
||||
.content
|
||||
.first()
|
||||
.and_then(|c| c.raw.as_text())
|
||||
.and_then(|c| c.as_text())
|
||||
.expect("getJob should return text content");
|
||||
let job: serde_json::Value = serde_json::from_str(&text.text)?;
|
||||
assert_eq!(job["id"], job_id.to_string());
|
||||
@@ -800,18 +789,16 @@ async fn test_mcp_client_get_job_and_logs(db: Pool<Postgres>) -> anyhow::Result<
|
||||
|
||||
// --- Test getJobLogs ---
|
||||
let result = client
|
||||
.call_tool(CallToolRequestParams {
|
||||
name: "getJobLogs".into(),
|
||||
arguments: Some(serde_json::from_value(json!({ "id": job_id.to_string() }))?),
|
||||
task: None,
|
||||
meta: None,
|
||||
})
|
||||
.call_tool(
|
||||
CallToolRequestParams::new("getJobLogs")
|
||||
.with_arguments(serde_json::from_value(json!({ "id": job_id.to_string() }))?),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let text = result
|
||||
.content
|
||||
.first()
|
||||
.and_then(|c| c.raw.as_text())
|
||||
.and_then(|c| c.as_text())
|
||||
.expect("getJobLogs should return text content");
|
||||
// The logs endpoint returns text/plain, which gets wrapped as a JSON string by call_endpoint
|
||||
let logs: String = serde_json::from_str(&text.text)?;
|
||||
|
||||
@@ -424,6 +424,27 @@ pub async fn run_server(
|
||||
.allow_headers([http::header::CONTENT_TYPE, http::header::AUTHORIZATION])
|
||||
.allow_origin(Any);
|
||||
|
||||
// MCP carries protocol state in its own headers: `MCP-Protocol-Version` from
|
||||
// revision 2025-06-18 onward, plus `Mcp-Method` and `Mcp-Name` at 2026-07-28.
|
||||
// None of them are CORS-simple, so a browser-based MCP client fails preflight
|
||||
// unless they are allowed — hence a separate layer rather than widening the
|
||||
// one every other route shares. (`Mcp-Param-*` is only sent for tool inputs
|
||||
// annotated with `x-mcp-header`, which no tool here declares.)
|
||||
let mcp_cors = CorsLayer::new()
|
||||
.allow_methods([http::Method::GET, http::Method::POST, http::Method::DELETE])
|
||||
.allow_headers([
|
||||
http::header::CONTENT_TYPE,
|
||||
http::header::AUTHORIZATION,
|
||||
http::HeaderName::from_static("mcp-protocol-version"),
|
||||
http::HeaderName::from_static("mcp-method"),
|
||||
http::HeaderName::from_static("mcp-name"),
|
||||
])
|
||||
// The 401 challenge is how a client discovers where to authorize (RFC 9728),
|
||||
// and it is not a safelisted response header, so without this a browser
|
||||
// client sees an empty one and has no way to begin the OAuth flow.
|
||||
.expose_headers([http::header::WWW_AUTHENTICATE])
|
||||
.allow_origin(Any);
|
||||
|
||||
let sp_extension = Arc::new(saml_oss::build_sp_extension().await?);
|
||||
|
||||
if server_mode {
|
||||
@@ -820,13 +841,13 @@ pub async fn run_server(
|
||||
// Deprecated, here for backwards compatibility: user should use /mcp/w/{workspace_id}/mcp instead
|
||||
.nest(
|
||||
"/mcp/w/{workspace_id}/sse",
|
||||
mcp_router.clone().layer(cors.clone()),
|
||||
mcp_router.clone().layer(mcp_cors.clone()),
|
||||
)
|
||||
.nest(
|
||||
"/mcp/w/{workspace_id}/mcp",
|
||||
mcp_router.clone().layer(cors.clone()),
|
||||
mcp_router.clone().layer(mcp_cors.clone()),
|
||||
)
|
||||
.nest("/mcp/gateway", gateway_mcp_router.layer(cors.clone()))
|
||||
.nest("/mcp/gateway", gateway_mcp_router.layer(mcp_cors.clone()))
|
||||
.nest("/agent_workers", {
|
||||
#[cfg(feature = "agent_worker_server")]
|
||||
{
|
||||
|
||||
@@ -570,12 +570,24 @@ pub async fn setup_mcp_server(
|
||||
let backend = WindmillBackend::new(db, user_db, base_internal_url, auth_cache);
|
||||
let runner = Runner::new(backend);
|
||||
|
||||
let service_config = StreamableHttpServerConfig {
|
||||
sse_keep_alive: Some(Duration::from_secs(15)),
|
||||
stateful_mode: false,
|
||||
cancellation_token: cancellation_token.clone(),
|
||||
sse_retry: Some(Duration::from_secs(15)),
|
||||
};
|
||||
let service_config = StreamableHttpServerConfig::default()
|
||||
.with_sse_keep_alive(Some(Duration::from_secs(15)))
|
||||
.with_sse_retry(Some(Duration::from_secs(15)))
|
||||
.with_cancellation_token(cancellation_token.clone())
|
||||
// Sessionless: every request re-resolves auth from its own bearer token, so
|
||||
// there is no session to bind. This also makes legacy `initialize` clients
|
||||
// take the same stateless path as 2026-07-28 ones.
|
||||
.with_legacy_session_mode(false)
|
||||
// rmcp's Host allowlist defaults to localhost, which guards an unauthenticated
|
||||
// locally-bound server against DNS rebinding. This endpoint instead sits behind
|
||||
// Windmill's own authentication, and is reached under whatever hostname the
|
||||
// instance is served on, so keeping that default would reject every remote MCP
|
||||
// client while adding nothing.
|
||||
.disable_allowed_hosts()
|
||||
// MCP bodies are ordinary API payloads — `createApp`/`updateApp` carry whole app
|
||||
// sources — so they follow the instance's request size limit rather than rmcp's
|
||||
// much smaller default, which would 413 them with no way to raise it.
|
||||
.with_max_request_body_bytes(*crate::REQUEST_SIZE_LIMIT.read().await);
|
||||
|
||||
let service =
|
||||
StreamableHttpService::new(move || Ok(runner.clone()), session_manager, service_config);
|
||||
|
||||
@@ -17,7 +17,7 @@ auth = ["rmcp/auth", "dep:oauth2", "dep:sqlx", "dep:chrono"]
|
||||
oauth2 = { version = "5.0", optional = true }
|
||||
windmill-common = { workspace = true, default-features = false }
|
||||
anyhow.workspace = true
|
||||
reqwest = { version = "=0.12", features = ["json", "stream", "gzip"] }
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
@@ -85,10 +85,7 @@ impl McpClient {
|
||||
// and does not legitimately rely on redirects.
|
||||
.redirect(reqwest::redirect::Policy::none());
|
||||
// Pin DNS to the address validated above so the connect cannot rebind to
|
||||
// an internal IP between the check and the request. `apply_dns_pinning`
|
||||
// lives on windmill-common's reqwest, but this crate resolves a
|
||||
// different reqwest version (via rmcp), so pin directly with the
|
||||
// std-typed host/addrs the validation surfaced. Empty addrs (IP literal
|
||||
// an internal IP between the check and the request. Empty addrs (IP literal
|
||||
// or ALLOW_PRIVATE_MCP_SERVER_URLS) leave resolution untouched.
|
||||
if !validated.addrs.is_empty() {
|
||||
client_builder = client_builder.resolve_to_addrs(&validated.host, &validated.addrs);
|
||||
@@ -102,19 +99,11 @@ impl McpClient {
|
||||
let transport = StreamableHttpClientTransport::with_client(reqwest_client, config);
|
||||
|
||||
// Set up client info
|
||||
let client_info = ClientInfo {
|
||||
protocol_version: Default::default(),
|
||||
capabilities: ClientCapabilities::default(),
|
||||
client_info: Implementation {
|
||||
name: "windmill-ai-agent".to_string(),
|
||||
title: Some("Windmill AI Agent".to_string()),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
description: None,
|
||||
website_url: None,
|
||||
icons: None,
|
||||
},
|
||||
meta: None,
|
||||
};
|
||||
let client_info = ClientInfo::new(
|
||||
ClientCapabilities::default(),
|
||||
Implementation::new("windmill-ai-agent", env!("CARGO_PKG_VERSION"))
|
||||
.with_title("Windmill AI Agent"),
|
||||
);
|
||||
|
||||
// Initialize the connection
|
||||
let client = client_info
|
||||
@@ -143,14 +132,14 @@ impl McpClient {
|
||||
let mcp_args =
|
||||
Self::openai_args_to_mcp_args(arguments).context("Failed to parse tool arguments")?;
|
||||
|
||||
let mut params = CallToolRequestParams::new(name.to_string());
|
||||
if let Some(args) = mcp_args {
|
||||
params = params.with_arguments(args);
|
||||
}
|
||||
|
||||
let result = self
|
||||
.client
|
||||
.call_tool(CallToolRequestParams {
|
||||
name: name.to_string().into(),
|
||||
arguments: mcp_args,
|
||||
task: None,
|
||||
meta: None,
|
||||
})
|
||||
.call_tool(params)
|
||||
.await
|
||||
.context(format!("Failed to call MCP tool: {}", name))?;
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ pub async fn get_or_refresh_mcp_client(
|
||||
.map_err(|e| error::Error::BadRequest(format!("Failed to create auth manager: {e}")))?;
|
||||
// Discovery hits the well-known endpoint on the MCP server host validated
|
||||
// above; pin to that address so it cannot rebind between check and connect.
|
||||
// Limitation: rmcp's discover_metadata may additionally follow server-supplied
|
||||
// Limitation: rmcp's resolve_metadata may additionally follow server-supplied
|
||||
// metadata URLs (resource_metadata / authorization_servers) on other hosts,
|
||||
// which this per-host pin does not cover — a pre-existing gap in rmcp discovery
|
||||
// that a validating resolver would need to close, out of scope for this pin.
|
||||
@@ -181,8 +181,7 @@ pub async fn get_or_refresh_mcp_client(
|
||||
.with_client(discovery_client)
|
||||
.map_err(|e| error::Error::BadRequest(format!("Failed to configure auth manager: {e}")))?;
|
||||
|
||||
let metadata = manager
|
||||
.discover_metadata()
|
||||
let metadata = crate::oauth::discover_authorization_metadata(&manager)
|
||||
.await
|
||||
.map_err(|e| error::Error::BadRequest(format!("OAuth discovery failed: {e}")))?;
|
||||
|
||||
|
||||
@@ -40,10 +40,30 @@ pub mod oauth {
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
pub use rmcp::transport::auth::AuthorizationManager;
|
||||
use rmcp::transport::auth::AuthorizationMetadataSource;
|
||||
pub use rmcp::transport::auth::{AuthorizationManager, AuthorizationMetadata};
|
||||
|
||||
const DEFAULT_OAUTH_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Discover the MCP server's OAuth metadata, refusing endpoints the server
|
||||
/// never advertised.
|
||||
///
|
||||
/// When a server publishes no metadata at all, rmcp's `resolve_metadata`
|
||||
/// falls back to inventing `/authorize`, `/token` and `/register` on the
|
||||
/// server's own host. Dynamic client registration and the token exchange
|
||||
/// both carry secrets, so they must only ever reach endpoints the server
|
||||
/// actually published — a guessed path would send them somewhere the
|
||||
/// operator never designated as an authorization server.
|
||||
pub async fn discover_authorization_metadata(
|
||||
manager: &AuthorizationManager,
|
||||
) -> anyhow::Result<AuthorizationMetadata> {
|
||||
let resolution = manager.resolve_metadata().await?;
|
||||
if resolution.source == AuthorizationMetadataSource::LegacyEndpointFallback {
|
||||
anyhow::bail!("MCP server does not publish OAuth authorization metadata");
|
||||
}
|
||||
Ok(resolution.metadata)
|
||||
}
|
||||
|
||||
pub fn no_redirect_http_client() -> Result<reqwest::Client, reqwest::Error> {
|
||||
no_redirect_http_client_with_timeout(DEFAULT_OAUTH_HTTP_TIMEOUT)
|
||||
}
|
||||
@@ -61,11 +81,8 @@ pub mod oauth {
|
||||
/// guard validated for the request URL so the connect cannot rebind to an
|
||||
/// internal IP after the check (TOCTOU). The OAuth DCR/discovery/token
|
||||
/// requests target author-controlled URLs and carry secrets, so they must
|
||||
/// go through this rather than the unpinned client. `apply_dns_pinning`
|
||||
/// lives on windmill-common's reqwest, which this crate resolves at a
|
||||
/// different version (via rmcp), so pin directly with the std-typed
|
||||
/// host/addrs. Empty `addrs` (IP literal or ALLOW_PRIVATE_MCP_SERVER_URLS)
|
||||
/// leaves resolution untouched.
|
||||
/// go through this rather than the unpinned client. Empty `addrs` (IP literal
|
||||
/// or ALLOW_PRIVATE_MCP_SERVER_URLS) leaves resolution untouched.
|
||||
pub fn no_redirect_http_client_pinned(
|
||||
target: &windmill_common::ssrf::ValidatedTarget,
|
||||
) -> Result<reqwest::Client, reqwest::Error> {
|
||||
@@ -124,5 +141,41 @@ pub mod oauth {
|
||||
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
/// A server publishing no OAuth metadata must be rejected, not have its
|
||||
/// endpoints guessed: rmcp's own fallback would invent `/authorize`,
|
||||
/// `/token` and `/register` on that host, and DCR and the token exchange
|
||||
/// send secrets to whatever comes back.
|
||||
#[tokio::test]
|
||||
async fn discovery_refuses_endpoints_the_server_never_published() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
// Every discovery probe 404s, which is what a plain MCP server
|
||||
// with no authorization server looks like.
|
||||
while let Ok((mut stream, _)) = listener.accept() {
|
||||
let mut buffer = [0u8; 2048];
|
||||
let _ = stream.read(&mut buffer);
|
||||
let _ = std::io::Write::write_all(
|
||||
&mut stream,
|
||||
b"HTTP/1.1 404 Not Found\r\ncontent-length: 0\r\nconnection: close\r\n\r\n",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
let manager = AuthorizationManager::new(format!("http://{addr}/mcp"))
|
||||
.await
|
||||
.expect("manager should construct");
|
||||
let err = discover_authorization_metadata(&manager)
|
||||
.await
|
||||
.expect_err("must not fall back to guessed endpoints");
|
||||
assert!(
|
||||
err.to_string().contains("does not publish OAuth"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
|
||||
drop(handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,17 +59,13 @@ pub fn endpoint_tool_to_mcp_tool(tool: &EndpointTool) -> Tool {
|
||||
// Create annotations based on HTTP method and endpoint characteristics
|
||||
let annotations = create_endpoint_annotations(tool);
|
||||
|
||||
Tool {
|
||||
name: tool.name.clone(),
|
||||
description: Some(description.into()),
|
||||
input_schema: Arc::new(combined_schema.as_object().unwrap().clone()),
|
||||
title: Some(tool.name.to_string()),
|
||||
output_schema: None,
|
||||
icons: None,
|
||||
annotations: Some(annotations),
|
||||
meta: None,
|
||||
execution: None,
|
||||
}
|
||||
Tool::new(
|
||||
tool.name.clone(),
|
||||
description,
|
||||
Arc::new(combined_schema.as_object().unwrap().clone()),
|
||||
)
|
||||
.with_title(tool.name.to_string())
|
||||
.with_annotations(annotations)
|
||||
}
|
||||
|
||||
/// Convert an endpoint tool to an MCP tool for multi-workspace mode.
|
||||
@@ -130,26 +126,19 @@ pub fn list_workspaces_tool() -> Tool {
|
||||
"required": []
|
||||
});
|
||||
|
||||
Tool {
|
||||
name: Cow::Borrowed("list_workspaces"),
|
||||
description: Some(
|
||||
"List the Windmill workspaces this token can access. Use the returned workspace ids as the `workspace_id` argument of the other tools."
|
||||
.into(),
|
||||
),
|
||||
input_schema: Arc::new(schema.as_object().unwrap().clone()),
|
||||
title: Some("List accessible workspaces".to_string()),
|
||||
output_schema: None,
|
||||
icons: None,
|
||||
annotations: Some(ToolAnnotations {
|
||||
title: Some("List accessible workspaces".to_string()),
|
||||
read_only_hint: Some(true),
|
||||
destructive_hint: Some(false),
|
||||
idempotent_hint: Some(true),
|
||||
open_world_hint: Some(false),
|
||||
}),
|
||||
meta: None,
|
||||
execution: None,
|
||||
}
|
||||
Tool::new(
|
||||
Cow::Borrowed("list_workspaces"),
|
||||
"List the Windmill workspaces this token can access. Use the returned workspace ids as the `workspace_id` argument of the other tools.",
|
||||
Arc::new(schema.as_object().unwrap().clone()),
|
||||
)
|
||||
.with_title("List accessible workspaces")
|
||||
.with_annotations(
|
||||
ToolAnnotations::with_title("List accessible workspaces")
|
||||
.read_only(true)
|
||||
.destructive(false)
|
||||
.idempotent(true)
|
||||
.open_world(false),
|
||||
)
|
||||
}
|
||||
|
||||
/// Create appropriate annotations for endpoint tools based on HTTP method
|
||||
@@ -166,13 +155,11 @@ fn create_endpoint_annotations(tool: &EndpointTool) -> ToolAnnotations {
|
||||
_ => (false, true, false, true), // Default: assume can modify and be destructive
|
||||
};
|
||||
|
||||
ToolAnnotations {
|
||||
title: Some(format!("{} {}", method, tool.path)),
|
||||
read_only_hint: Some(read_only),
|
||||
destructive_hint: Some(destructive),
|
||||
idempotent_hint: Some(idempotent),
|
||||
open_world_hint: Some(open_world),
|
||||
}
|
||||
ToolAnnotations::with_title(format!("{} {}", method, tool.path))
|
||||
.read_only(read_only)
|
||||
.destructive(destructive)
|
||||
.idempotent(idempotent)
|
||||
.open_world(open_world)
|
||||
}
|
||||
|
||||
/// Merge schema into combined properties and required fields
|
||||
|
||||
@@ -23,10 +23,10 @@ pub use tools::create_tool_from_item;
|
||||
// Re-export rmcp types for convenience
|
||||
pub use rmcp::handler::server::ServerHandler;
|
||||
pub use rmcp::model::{
|
||||
Annotated, CallToolRequestParams, CallToolResult, Content, Implementation,
|
||||
InitializeRequestParams, InitializeResult, ListPromptsResult, ListResourceTemplatesResult,
|
||||
ListResourcesResult, ListToolsResult, PaginatedRequestParams, ProtocolVersion, RawContent,
|
||||
RawTextContent, ServerCapabilities, ServerInfo, Tool, ToolAnnotations,
|
||||
CallToolRequestParams, CallToolResult, ContentBlock, Implementation, InitializeRequestParams,
|
||||
InitializeResult, ListPromptsResult, ListResourceTemplatesResult, ListResourcesResult,
|
||||
ListToolsResult, PaginatedRequestParams, ProtocolVersion, ServerCapabilities, ServerInfo, Tool,
|
||||
ToolAnnotations,
|
||||
};
|
||||
pub use rmcp::service::{RequestContext, RoleServer};
|
||||
pub use rmcp::transport::streamable_http_server::{
|
||||
|
||||
@@ -17,16 +17,42 @@ use crate::server::endpoints::{
|
||||
use crate::server::tools::create_tool_from_item;
|
||||
use rmcp::handler::server::ServerHandler;
|
||||
use rmcp::model::{
|
||||
CallToolRequestParams, CallToolResult, Content, Implementation, InitializeRequestParams,
|
||||
InitializeResult, ListPromptsResult, ListResourceTemplatesResult, ListResourcesResult,
|
||||
ListToolsResult, PaginatedRequestParams, ProtocolVersion, ServerCapabilities, ServerInfo,
|
||||
CacheScope, CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock,
|
||||
Implementation, InitializeResult, ListPromptsResult, ListResourceTemplatesResult,
|
||||
ListResourcesResult, ListToolsResult, PaginatedRequestParams, ProtocolVersion,
|
||||
ServerCapabilities, ServerInfo,
|
||||
};
|
||||
use rmcp::service::{RequestContext, RoleServer};
|
||||
use rmcp::ErrorData;
|
||||
use serde_json::Value;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Protocol revisions this server is willing to speak. `2026-07-28` is served
|
||||
/// statelessly with per-request metadata; the older revisions keep the
|
||||
/// `initialize` handshake, so both eras are answered on the same endpoint.
|
||||
const SUPPORTED_PROTOCOL_VERSIONS: &[ProtocolVersion] = &[
|
||||
ProtocolVersion::V_2024_11_05,
|
||||
ProtocolVersion::V_2025_03_26,
|
||||
ProtocolVersion::V_2025_06_18,
|
||||
ProtocolVersion::V_2025_11_25,
|
||||
ProtocolVersion::V_2026_07_28,
|
||||
];
|
||||
|
||||
/// SEP-2549 cache hints, required on every list result at `2026-07-28` — rmcp
|
||||
/// leaves them unset, and a strict client rejects the response without them.
|
||||
///
|
||||
/// Zero because nothing here is cacheable: the listing is rebuilt from the
|
||||
/// workspace's scripts and flows, which change at any time, and this server
|
||||
/// advertises no `listChanged` capability, so a client that cached a stale list
|
||||
/// would have no way to learn it had gone stale.
|
||||
const LIST_TTL_MS: u64 = 0;
|
||||
/// Every listing is filtered by the caller's token scopes and workspace
|
||||
/// membership, so no two callers necessarily see the same tools — a shared
|
||||
/// cache entry would leak one token's view to another.
|
||||
const LIST_CACHE_SCOPE: CacheScope = CacheScope::Private;
|
||||
|
||||
// Re-export from http crate for extracting request parts
|
||||
use http::request::Parts as HttpParts;
|
||||
|
||||
@@ -307,24 +333,26 @@ fn find_matching_path<T: ToolableItem>(candidates: Vec<T>, request_name: &str) -
|
||||
|
||||
impl<B: McpBackend> ServerHandler for Runner<B> {
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
ServerInfo {
|
||||
protocol_version: ProtocolVersion::default(),
|
||||
capabilities: ServerCapabilities::builder().enable_tools().build(),
|
||||
server_info: Implementation::from_build_env(),
|
||||
instructions: Some(
|
||||
// Not `Implementation::from_build_env()`: its `env!` expands inside rmcp, so it
|
||||
// would name the SDK crate rather than this server.
|
||||
let server_info = Implementation::new("windmill", env!("CARGO_PKG_VERSION"))
|
||||
.with_title("Windmill")
|
||||
.with_website_url("https://windmill.dev");
|
||||
|
||||
InitializeResult::new(ServerCapabilities::builder().enable_tools().build())
|
||||
.with_server_info(server_info)
|
||||
.with_instructions(
|
||||
"This server provides a list of scripts and flows the user can run on Windmill. \
|
||||
Each flow and script is a tool callable with their respective arguments."
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
Each flow and script is a tool callable with their respective arguments.",
|
||||
)
|
||||
}
|
||||
|
||||
async fn initialize(
|
||||
&self,
|
||||
_request: InitializeRequestParams,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<InitializeResult, ErrorData> {
|
||||
Ok(self.get_info())
|
||||
/// Pinned rather than left to rmcp's default (every version the SDK knows), so a
|
||||
/// future SDK revision cannot start advertising a version this server has not been
|
||||
/// exercised against. Bounds `initialize` negotiation, `server/discover`, and
|
||||
/// per-request version validation alike.
|
||||
fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
|
||||
Cow::Borrowed(SUPPORTED_PROTOCOL_VERSIONS)
|
||||
}
|
||||
|
||||
async fn list_tools(
|
||||
@@ -359,7 +387,7 @@ impl<B: McpBackend> ServerHandler for Runner<B> {
|
||||
&self,
|
||||
request: CallToolRequestParams,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
) -> Result<CallToolResponse, ErrorData> {
|
||||
let (auth, mode) = Self::extract_context(&context)?;
|
||||
|
||||
// Parse MCP scopes for authorization
|
||||
@@ -370,7 +398,9 @@ impl<B: McpBackend> ServerHandler for Runner<B> {
|
||||
|
||||
let args = request.arguments.map(Value::Object).unwrap_or(Value::Null);
|
||||
|
||||
match mode {
|
||||
// Every tool here runs to completion in one round trip: none of them ask the
|
||||
// client for input, so the MRTR variants of `CallToolResponse` are never built.
|
||||
let result = match mode {
|
||||
McpMode::Single(workspace_id) => {
|
||||
self.call_tool_single(
|
||||
&auth,
|
||||
@@ -386,7 +416,8 @@ impl<B: McpBackend> ServerHandler for Runner<B> {
|
||||
self.call_tool_multi(&auth, &token, &scope_config, read_only, request.name, args)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}?;
|
||||
Ok(result.into())
|
||||
}
|
||||
|
||||
async fn list_resources(
|
||||
@@ -394,7 +425,9 @@ impl<B: McpBackend> ServerHandler for Runner<B> {
|
||||
_request: Option<PaginatedRequestParams>,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<ListResourcesResult, ErrorData> {
|
||||
Ok(ListResourcesResult { resources: vec![], next_cursor: None, meta: None })
|
||||
Ok(ListResourcesResult::with_all_items(vec![])
|
||||
.with_ttl_ms(LIST_TTL_MS)
|
||||
.with_cache_scope(LIST_CACHE_SCOPE))
|
||||
}
|
||||
|
||||
async fn list_prompts(
|
||||
@@ -402,7 +435,9 @@ impl<B: McpBackend> ServerHandler for Runner<B> {
|
||||
_request: Option<PaginatedRequestParams>,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<ListPromptsResult, ErrorData> {
|
||||
Ok(ListPromptsResult::default())
|
||||
Ok(ListPromptsResult::default()
|
||||
.with_ttl_ms(LIST_TTL_MS)
|
||||
.with_cache_scope(LIST_CACHE_SCOPE))
|
||||
}
|
||||
|
||||
async fn list_resource_templates(
|
||||
@@ -410,7 +445,9 @@ impl<B: McpBackend> ServerHandler for Runner<B> {
|
||||
_request: Option<PaginatedRequestParams>,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<ListResourceTemplatesResult, ErrorData> {
|
||||
Ok(ListResourceTemplatesResult::default())
|
||||
Ok(ListResourceTemplatesResult::default()
|
||||
.with_ttl_ms(LIST_TTL_MS)
|
||||
.with_cache_scope(LIST_CACHE_SCOPE))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -547,7 +584,9 @@ impl<B: McpBackend> Runner<B> {
|
||||
tools.push(endpoint_tool_to_mcp_tool(&endpoint_tool));
|
||||
}
|
||||
|
||||
Ok(ListToolsResult { tools, next_cursor: None, meta: None })
|
||||
Ok(ListToolsResult::with_all_items(tools)
|
||||
.with_ttl_ms(LIST_TTL_MS)
|
||||
.with_cache_scope(LIST_CACHE_SCOPE))
|
||||
}
|
||||
|
||||
/// Handle a tool call for a single, bound workspace.
|
||||
@@ -575,7 +614,7 @@ impl<B: McpBackend> Runner<B> {
|
||||
.await
|
||||
.map_err(|e| ErrorData::internal_error(e.message, None))?;
|
||||
|
||||
return Ok(CallToolResult::success(vec![Content::text(
|
||||
return Ok(CallToolResult::success(vec![ContentBlock::text(
|
||||
truncate_tool_result(
|
||||
serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string()),
|
||||
),
|
||||
@@ -699,7 +738,7 @@ impl<B: McpBackend> Runner<B> {
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(value) => Ok(CallToolResult::success(vec![Content::text(
|
||||
Ok(value) => Ok(CallToolResult::success(vec![ContentBlock::text(
|
||||
truncate_tool_result(
|
||||
serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_string()),
|
||||
),
|
||||
@@ -733,7 +772,9 @@ impl<B: McpBackend> Runner<B> {
|
||||
tools.push(endpoint_tool_to_mcp_tool_multi(&endpoint_tool));
|
||||
}
|
||||
|
||||
ListToolsResult { tools, next_cursor: None, meta: None }
|
||||
ListToolsResult::with_all_items(tools)
|
||||
.with_ttl_ms(LIST_TTL_MS)
|
||||
.with_cache_scope(LIST_CACHE_SCOPE)
|
||||
}
|
||||
|
||||
/// Handle a tool call for a multi-workspace session. `base_auth` is the
|
||||
@@ -754,7 +795,7 @@ impl<B: McpBackend> Runner<B> {
|
||||
.list_accessible_workspaces(base_auth)
|
||||
.await
|
||||
.map_err(|e| ErrorData::internal_error(e.message, None))?;
|
||||
return Ok(CallToolResult::success(vec![Content::text(
|
||||
return Ok(CallToolResult::success(vec![ContentBlock::text(
|
||||
serde_json::to_string_pretty(&workspaces).unwrap_or_else(|_| "[]".to_string()),
|
||||
)]));
|
||||
}
|
||||
@@ -827,7 +868,7 @@ impl<B: McpBackend> Runner<B> {
|
||||
.await
|
||||
.map_err(|e| ErrorData::internal_error(e.message, None))?;
|
||||
|
||||
Ok(CallToolResult::success(vec![Content::text(
|
||||
Ok(CallToolResult::success(vec![ContentBlock::text(
|
||||
truncate_tool_result(
|
||||
serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string()),
|
||||
),
|
||||
|
||||
@@ -190,21 +190,17 @@ pub fn create_tool_from_item<T: ToolableItem, B: McpBackend>(
|
||||
}
|
||||
};
|
||||
|
||||
Tool {
|
||||
name: Cow::Owned(path),
|
||||
description: Some(Cow::Owned(description)),
|
||||
input_schema: Arc::new(input_schema_map),
|
||||
title: Some(title.clone()),
|
||||
output_schema: None,
|
||||
icons: None,
|
||||
annotations: Some(ToolAnnotations {
|
||||
title: Some(title),
|
||||
read_only_hint: Some(false), // Can modify environment
|
||||
destructive_hint: Some(true), // Can potentially be destructive
|
||||
idempotent_hint: Some(false), // Are not guaranteed to be idempotent
|
||||
open_world_hint: Some(true), // Can interact with external services
|
||||
}),
|
||||
meta: None,
|
||||
execution: None,
|
||||
}
|
||||
Tool::new(
|
||||
Cow::Owned(path),
|
||||
Cow::Owned(description),
|
||||
Arc::new(input_schema_map),
|
||||
)
|
||||
.with_title(title.clone())
|
||||
.with_annotations(
|
||||
ToolAnnotations::with_title(title)
|
||||
.read_only(false) // Can modify environment
|
||||
.destructive(true) // Can potentially be destructive
|
||||
.idempotent(false) // Are not guaranteed to be idempotent
|
||||
.open_world(true), // Can interact with external services
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user