Files
windmill/backend/windmill-api-integration-tests/tests/mcp_preprocessor_headers.rs
hugocasaandClaude Opus 5 e474e8803c feat: expose request headers to scripts invoked via MCP (#10903)
* feat: expose allowlisted request headers to scripts invoked via MCP

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F4i1qCTY9HQMqCPTBeTiV9

* fix: close header-forgery routes flagged in review of MCP header passthrough

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F4i1qCTY9HQMqCPTBeTiV9

* fix: match allowlisted headers exactly and withdraw every model-args run path

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F4i1qCTY9HQMqCPTBeTiV9

* fix: address review nits on MCP header passthrough

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F4i1qCTY9HQMqCPTBeTiV9

* fix: stop over-withdrawing deleteScriptByHash and align schema strip key space

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F4i1qCTY9HQMqCPTBeTiV9

* refactor: move MCP header field detail into a label tooltip

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F4i1qCTY9HQMqCPTBeTiV9

* fix: bound include_header parsing and narrow the duplicate-header drop

* feat: handle runnable-executing tools instead of withdrawing them

* docs: record the preprocessor kind seam on proxied run-by-path

* fix: strip every runnable argument map and open the field to gateway tokens

* fix: withhold connection credentials from runnables unless explicitly named

* fix: keep endpoint control arguments out of the transport-owned strip

* fix: exempt workspace_id from the strip only where it routes the call

* style: reindent the MCP header tooltip block

* refactor: deliver MCP request headers through the preprocessor only

* fix: widen the proxy-owned header set and clear docs left by the redesign

* fix: count proxied header delivery and finish the redesign doc sweep

* fix: forward proxied headers only to a runnable that has a preprocessor

* refactor: drop include_header and the MCP credential deny list

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F4i1qCTY9HQMqCPTBeTiV9

* chore: restore the blank line in CreateToken

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F4i1qCTY9HQMqCPTBeTiV9

* refactor: drop the mcp header_passthrough feature usage counter

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F4i1qCTY9HQMqCPTBeTiV9

* test: pin that a caller credential other than the hop's own travels

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F4i1qCTY9HQMqCPTBeTiV9

* refactor: deliver headers only through the direct script and flow tools

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F4i1qCTY9HQMqCPTBeTiV9

* feat: withhold connection credentials and pin MCP header delivery end to end

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F4i1qCTY9HQMqCPTBeTiV9

* test: send every credential the withheld-list assertions cover

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F4i1qCTY9HQMqCPTBeTiV9

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 11:30:20 +02:00

187 lines
6.7 KiB
Rust

//! Request headers reaching a runnable's preprocessor over MCP.
//!
//! The property this pins is structural rather than a filter: the model writes
//! the tool's arguments, which become `event.body`, while the server writes
//! `event.headers`. A model that guesses a header's name can only ever land in
//! `body`, so an identity read from `headers` is one prompt injection cannot
//! forge. Nothing else in the suite exercises MCP argument shaping end to end.
//!
//! Requires: bun runtime, live database (migrations applied by sqlx::test).
#![cfg(feature = "mcp")]
use serde_json::{json, Value};
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
const SCRIPT_PATH: &str = "u/test-user/mcp_hdr_probe";
/// Echoes the two halves of the event separately, so the assertions can tell
/// which one a value arrived in.
const PREPROCESSOR_SCRIPT: &str = r#"
export async function preprocessor(event: any) {
return {
kind: event.kind,
from_headers: event.headers?.["x-user-id"] ?? "",
from_body: event.body?.x_user_id ?? "",
header_names: Object.keys(event.headers ?? {}).sort(),
};
}
export async function main(kind: string, from_headers: string, from_body: string, header_names: string[]) {
return { kind, from_headers, from_body, header_names };
}
"#;
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. The endpoint answers either `application/json` or
/// a single-event SSE stream, so strip the `data: ` framing before parsing.
async fn mcp_post(port: u16, headers: &[(&str, &str)], body: Value) -> anyhow::Result<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 text = req.send().await?.text().await?;
let payload = text
.lines()
.find_map(|l| l.strip_prefix("data: "))
.unwrap_or(text.trim());
serde_json::from_str(payload).map_err(|e| anyhow::anyhow!("unparseable MCP body {text:?}: {e}"))
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_mcp_preprocessor_receives_the_callers_headers(
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();
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/scripts/create"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({
"path": SCRIPT_PATH,
"summary": "mcp header probe",
"description": "",
"content": PREPROCESSOR_SCRIPT,
"language": "bun",
"lock": "",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": { "x_user_id": { "type": "string" } },
"required": []
}
}))
.send()
.await?;
assert_eq!(
resp.status(),
201,
"create script: {}",
resp.text().await.unwrap_or_default()
);
// A script counts as deployed once it has a lock, which normally arrives from
// a dependency job. Planting an empty one keeps the test to the path under
// test instead of a bun resolution whose timing it does not control.
sqlx::query("UPDATE script SET lock = '' WHERE path = $1 AND workspace_id = 'test-workspace'")
.bind(SCRIPT_PATH)
.execute(&db)
.await?;
let tools = mcp_post(
port,
&[],
json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}),
)
.await?;
let tool_name = tools["result"]["tools"]
.as_array()
.and_then(|list| {
list.iter()
.filter_map(|t| t["name"].as_str())
.find(|n| n.contains("mcp__hdr__probe"))
})
.ok_or_else(|| anyhow::anyhow!("the deployed script was not listed as a tool: {tools}"))?
.to_string();
let result = in_test_worker(
db.clone(),
async {
mcp_post(
port,
// Every name the withheld list covers has to be on the wire, or
// asserting its absence proves nothing. `Authorization` is already
// set by `mcp_post`, and `extract_token` reads it before the
// cookie, so sending one does not disturb auth.
&[
("X-User-Id", "alice@corp.example"),
("Cookie", "session=secret"),
("Proxy-Authorization", "Basic Zm9v"),
],
json!({
"jsonrpc": "2.0", "id": 2, "method": "tools/call",
// The model names the header it wants to spoof. Its value is an
// argument, so it can only ever reach `event.body`.
"params": { "name": tool_name, "arguments": { "x_user_id": "attacker@evil.test" } }
}),
)
.await
},
port,
)
.await?;
let text = result["result"]["content"][0]["text"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("tool call returned no text content: {result}"))?;
let out: Value = serde_json::from_str(text)?;
assert_eq!(out["kind"], "mcp", "preprocessor event kind: {out}");
assert_eq!(
out["from_headers"], "alice@corp.example",
"the caller's header must reach event.headers: {out}"
);
assert_eq!(
out["from_body"], "attacker@evil.test",
"the model's argument must land in event.body, not overwrite the header: {out}"
);
let names: Vec<&str> = out["header_names"]
.as_array()
.map(|a| a.iter().filter_map(|v| v.as_str()).collect())
.unwrap_or_default();
assert!(
names.contains(&"x-user-id"),
"event.headers must carry the request's own headers: {names:?}"
);
for withheld in ["authorization", "cookie", "proxy-authorization"] {
assert!(
!names.contains(&withheld),
"{withheld} is withheld from a preprocessor: {names:?}"
);
}
Ok(())
}