From 4fe4fac358e67ee6e20dbff8bfa1de0e92e5f3c7 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 5 Aug 2026 11:52:09 +0000 Subject: [PATCH] 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] --- backend/Cargo.lock | 164 +++++++------ backend/Cargo.toml | 9 +- backend/ee-repo-ref.txt | 2 +- .../tests/mcp_protocol_versions.rs | 221 ++++++++++++++++++ .../tests/resources.rs | 41 ++-- backend/windmill-api/src/lib.rs | 27 ++- backend/windmill-api/src/mcp/core.rs | 24 +- backend/windmill-mcp/Cargo.toml | 2 +- backend/windmill-mcp/src/client/mod.rs | 35 +-- .../windmill-mcp/src/client_registration.rs | 5 +- backend/windmill-mcp/src/lib.rs | 65 +++++- backend/windmill-mcp/src/server/endpoints.rs | 63 ++--- backend/windmill-mcp/src/server/mod.rs | 8 +- backend/windmill-mcp/src/server/runner.rs | 101 +++++--- backend/windmill-mcp/src/server/tools.rs | 30 ++- 15 files changed, 570 insertions(+), 227 deletions(-) create mode 100644 backend/windmill-api-integration-tests/tests/mcp_protocol_versions.rs diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 0752d0051b..204bafda83 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -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", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 38e38f5bed..0096cff28b 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -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"] } diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index c69a2444aa..c882e591c0 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -0373b4bfdaf8dd51533552e2e4de63ceb3c18b4d +c1665a881b61616f96ffe7702b44840905304660 diff --git a/backend/windmill-api-integration-tests/tests/mcp_protocol_versions.rs b/backend/windmill-api-integration-tests/tests/mcp_protocol_versions.rs new file mode 100644 index 0000000000..e684e3ee14 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/mcp_protocol_versions.rs @@ -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) -> 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, +) -> 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, +) -> 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, +) -> 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(()) +} diff --git a/backend/windmill-api-integration-tests/tests/resources.rs b/backend/windmill-api-integration-tests/tests/resources.rs index 3cc59be473..07afbb3442 100644 --- a/backend/windmill-api-integration-tests/tests/resources.rs +++ b/backend/windmill-api-integration-tests/tests/resources.rs @@ -756,37 +756,26 @@ async fn test_mcp_client_get_job_and_logs(db: Pool) -> 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 = 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) -> 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)?; diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index e4ffa16c9e..e67420c53c 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -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")] { diff --git a/backend/windmill-api/src/mcp/core.rs b/backend/windmill-api/src/mcp/core.rs index 5d78536d86..af7e22fcba 100644 --- a/backend/windmill-api/src/mcp/core.rs +++ b/backend/windmill-api/src/mcp/core.rs @@ -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); diff --git a/backend/windmill-mcp/Cargo.toml b/backend/windmill-mcp/Cargo.toml index 36e0d03d18..8ac23e885d 100644 --- a/backend/windmill-mcp/Cargo.toml +++ b/backend/windmill-mcp/Cargo.toml @@ -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 diff --git a/backend/windmill-mcp/src/client/mod.rs b/backend/windmill-mcp/src/client/mod.rs index f61a1c3f85..863d6b0355 100644 --- a/backend/windmill-mcp/src/client/mod.rs +++ b/backend/windmill-mcp/src/client/mod.rs @@ -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))?; diff --git a/backend/windmill-mcp/src/client_registration.rs b/backend/windmill-mcp/src/client_registration.rs index 637b76df80..3aa39e840b 100644 --- a/backend/windmill-mcp/src/client_registration.rs +++ b/backend/windmill-mcp/src/client_registration.rs @@ -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}")))?; diff --git a/backend/windmill-mcp/src/lib.rs b/backend/windmill-mcp/src/lib.rs index d5eb0b9597..33a20be2f0 100644 --- a/backend/windmill-mcp/src/lib.rs +++ b/backend/windmill-mcp/src/lib.rs @@ -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 { + 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 { 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 { @@ -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); + } } } diff --git a/backend/windmill-mcp/src/server/endpoints.rs b/backend/windmill-mcp/src/server/endpoints.rs index 9e3adffc0b..cea2896caa 100644 --- a/backend/windmill-mcp/src/server/endpoints.rs +++ b/backend/windmill-mcp/src/server/endpoints.rs @@ -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 diff --git a/backend/windmill-mcp/src/server/mod.rs b/backend/windmill-mcp/src/server/mod.rs index da7032418b..19c7e43df6 100644 --- a/backend/windmill-mcp/src/server/mod.rs +++ b/backend/windmill-mcp/src/server/mod.rs @@ -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::{ diff --git a/backend/windmill-mcp/src/server/runner.rs b/backend/windmill-mcp/src/server/runner.rs index 0e7c01699b..e8248920ed 100644 --- a/backend/windmill-mcp/src/server/runner.rs +++ b/backend/windmill-mcp/src/server/runner.rs @@ -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(candidates: Vec, request_name: &str) - impl ServerHandler for Runner { 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, - ) -> Result { - 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 ServerHandler for Runner { &self, request: CallToolRequestParams, context: RequestContext, - ) -> Result { + ) -> Result { let (auth, mode) = Self::extract_context(&context)?; // Parse MCP scopes for authorization @@ -370,7 +398,9 @@ impl ServerHandler for Runner { 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 ServerHandler for Runner { 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 ServerHandler for Runner { _request: Option, _context: RequestContext, ) -> Result { - 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 ServerHandler for Runner { _request: Option, _context: RequestContext, ) -> Result { - 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 ServerHandler for Runner { _request: Option, _context: RequestContext, ) -> Result { - Ok(ListResourceTemplatesResult::default()) + Ok(ListResourceTemplatesResult::default() + .with_ttl_ms(LIST_TTL_MS) + .with_cache_scope(LIST_CACHE_SCOPE)) } } @@ -547,7 +584,9 @@ impl Runner { 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 Runner { .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 Runner { }; 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 Runner { 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 Runner { .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 Runner { .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()), ), diff --git a/backend/windmill-mcp/src/server/tools.rs b/backend/windmill-mcp/src/server/tools.rs index 50ed03426b..efdf86ed22 100644 --- a/backend/windmill-mcp/src/server/tools.rs +++ b/backend/windmill-mcp/src/server/tools.rs @@ -190,21 +190,17 @@ pub fn create_tool_from_item( } }; - 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 + ) }