Files
windmill/backend/windmill-api-integration-tests/tests/mcp_protocol_versions.rs
T
Ruben Fiszelandwindmill-internal-app[bot] 4fe4fac358 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>
2026-08-05 13:52:09 +02:00

222 lines
7.3 KiB
Rust

//! 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(())
}