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:
Ruben Fiszel
2026-08-05 11:52:09 +00:00
committed by GitHub
parent 9f3f4fb6d0
commit 4fe4fac358
15 changed files with 570 additions and 227 deletions
+98 -66
View File
@@ -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
View File
@@ -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
View File
@@ -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)?;
+24 -3
View File
@@ -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")]
{
+18 -6
View File
@@ -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);
+1 -1
View File
@@ -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
+12 -23
View File
@@ -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}")))?;
+59 -6
View File
@@ -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);
}
}
}
+25 -38
View File
@@ -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
+4 -4
View File
@@ -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::{
+71 -30
View File
@@ -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()),
),
+13 -17
View File
@@ -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
)
}