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>
This commit is contained in:
hugocasa
2026-09-03 11:30:20 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent e39dd7eb12
commit e474e8803c
7 changed files with 381 additions and 43 deletions
@@ -0,0 +1,186 @@
//! 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(())
}
+7 -7
View File
@@ -452,15 +452,15 @@ pub async fn run_server(
// 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.)
//
// The request's own header list is mirrored rather than enumerated: a browser
// MCP client may send any custom name for a preprocessor to read, and no fixed
// list could cover them. Nothing is granted by echoing it: the origin is
// `Any`, so browsers never attach credentials, and the endpoint authenticates
// each request on its own.
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"),
])
.allow_headers(tower_http::cors::AllowHeaders::mirror_request())
// 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.
+11 -3
View File
@@ -12,7 +12,9 @@ use windmill_mcp::common::transform::transform_property_keys;
use windmill_mcp::common::types::{
FlowInfo, HubScriptInfo, ResourceInfo, ResourceType, SchemaType, ScriptInfo, WorkspaceInfo,
};
use windmill_mcp::server::{BackendResult, EndpointTool, ErrorData, McpBackend, PathFilter};
use windmill_mcp::server::{
BackendResult, EndpointTool, ErrorData, McpBackend, McpRequest, PathFilter,
};
use crate::auth::AuthCache;
use crate::db::ApiAuthed;
@@ -214,8 +216,11 @@ impl McpBackend for WindmillBackend {
workspace_id: &str,
path: &str,
args: Value,
request: &McpRequest<'_>,
) -> BackendResult<Value> {
let push_args = prepare_push_args(args);
let push_args = prepare_push_args(&self.db, workspace_id, path, false, args, request)
.await
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
let result = run_wait_result_script_by_path_internal(
self.db.clone(),
@@ -238,8 +243,11 @@ impl McpBackend for WindmillBackend {
workspace_id: &str,
path: &str,
args: Value,
request: &McpRequest<'_>,
) -> BackendResult<Value> {
let push_args = prepare_push_args(args);
let push_args = prepare_push_args(&self.db, workspace_id, path, true, args, request)
.await
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
let result = run_wait_result_flow_by_path_internal(
self.db.clone(),
+109 -9
View File
@@ -11,15 +11,19 @@ use serde_json::Value;
use sql_builder::prelude::*;
use windmill_common::auth::create_jwt_token;
use windmill_common::db::{Authed, UserDB};
use windmill_common::error::Error;
use windmill_common::scripts::{get_full_hub_script_by_path, Schema};
use windmill_common::triggers::{RunnableFormat, RunnableFormatVersion, TriggerKind};
use windmill_common::utils::{query_elems_from_hub, StripPath};
use windmill_common::worker::to_raw_value;
use windmill_common::{DB, HUB_BASE_URL};
use windmill_mcp::server::{
non_empty_body_fields, BackendResult, EndpointTool, ErrorData, PathFilter,
non_empty_body_fields, BackendResult, EndpointTool, ErrorData, McpRequest, PathFilter,
};
use windmill_mcp::{HubResponse, HubScriptInfo, ItemSchema, ResourceInfo, ResourceType};
use windmill_trigger::trigger_helpers::{get_runnable_format, RunnableId};
use crate::args::build_headers;
use crate::db::ApiAuthed;
use crate::HTTP_CLIENT;
@@ -641,7 +645,7 @@ fn selects_endpoint_tool(caller_scopes: &[String], tool: &str) -> bool {
.is_ok_and(|config| config.endpoints.iter().any(|e| e == tool))
}
/// Create HTTP request with authentication
/// Create HTTP request with authentication.
pub async fn create_http_request(
method: &str,
url: &str,
@@ -702,17 +706,113 @@ pub async fn create_http_request(
.map_err(|e| ErrorData::internal_error(format!("Failed to execute request: {}", e), None))
}
/// Convert a JSON Value into PushArgsOwned for job execution
pub fn prepare_push_args(args: Value) -> windmill_queue::PushArgsOwned {
/// The `kind` an MCP-invoked runnable sees on its preprocessor event, alongside
/// `webhook`, `http` and the trigger kinds.
const MCP_TRIGGER_KEY: &str = "mcp";
/// A preprocessor's view of the MCP request that ran it. Mirrors the HTTP
/// trigger event: `body` is what the model sent, everything else describes the
/// call itself.
#[derive(serde::Serialize)]
struct McpPreprocessorEvent<'a> {
kind: &'a str,
body: Box<serde_json::value::RawValue>,
headers: HashMap<String, Box<serde_json::value::RawValue>>,
tool_name: &'a str,
}
/// Headers withheld from a preprocessor because they authenticate the connection.
///
/// Not a security boundary: a webhook preprocessor receives all three. Withheld
/// because nothing needs them yet, and releasing one later is additive while
/// withdrawing one after runnables read it is not.
const WITHHELD_FROM_PREPROCESSOR: &[&str] = &["authorization", "cookie", "proxy-authorization"];
/// Every header a preprocessor may see.
fn preprocessor_headers(
headers: &http::HeaderMap,
) -> HashMap<String, Box<serde_json::value::RawValue>> {
let mut selected = build_headers(headers, None, true);
selected.retain(|name, _| {
!WITHHELD_FROM_PREPROCESSOR
.iter()
.any(|withheld| withheld.eq_ignore_ascii_case(name))
});
selected
}
/// Build the job arguments for a script or flow run as an MCP tool.
///
/// Shaped by the runnable's own format: a preprocessor receives the request as
/// an event, and a runnable without one receives only what the model sent.
pub async fn prepare_push_args(
db: &DB,
w_id: &str,
path: &str,
is_flow: bool,
args: Value,
request: &McpRequest<'_>,
) -> Result<windmill_queue::PushArgsOwned, Error> {
let mut main_args = HashMap::new();
if let Value::Object(map) = args {
let mut args_hash = HashMap::new();
for (k, v) in map {
args_hash.insert(k, to_raw_value(&v));
main_args.insert(k, to_raw_value(&v));
}
windmill_queue::PushArgsOwned { extra: None, args: args_hash }
} else {
windmill_queue::PushArgsOwned::default()
}
let runnable_id = if is_flow {
RunnableId::from_flow_path(path)
} else {
// Resolves a `hub/<version_id>` path to the hub script on its own.
RunnableId::from_script_path(path)
};
// MCP is not one of the `TRIGGER_KIND` enum values and does not need to be:
// the per-kind arms of the no-preprocessor heuristic are payload-shape
// special cases for message triggers, and `Webhook` reaches the same generic
// arm MCP wants while sharing that kind's format cache.
let runnable_format = get_runnable_format(runnable_id, w_id, db, &TriggerKind::Webhook).await?;
Ok(match runnable_format {
// Without a preprocessor there is nowhere for a header to go that the
// model does not also write: its arguments *are* the runnable's
// parameters, so a header bound to one of them would be a value the model
// could set. The request is reachable through a preprocessor, where it
// arrives in a key of the event the model never fills.
RunnableFormat { has_preprocessor: false, .. } => {
windmill_queue::PushArgsOwned { args: main_args, extra: None }
}
RunnableFormat { has_preprocessor: true, version } => {
let headers = preprocessor_headers(request.headers);
match version {
RunnableFormatVersion::V2 => {
let event = McpPreprocessorEvent {
kind: MCP_TRIGGER_KEY,
body: to_raw_value(&main_args),
headers,
tool_name: request.tool_name,
};
windmill_queue::PushArgsOwned {
args: HashMap::from([("event".to_string(), to_raw_value(&event))]),
extra: None,
}
}
RunnableFormatVersion::V1 => windmill_queue::PushArgsOwned {
args: main_args,
extra: Some(HashMap::from([(
"wm_trigger".to_string(),
to_raw_value(&serde_json::json!({
"kind": MCP_TRIGGER_KEY,
MCP_TRIGGER_KEY: {
"headers": headers,
"tool_name": request.tool_name,
}
})),
)])),
},
}
}
})
}
/// Parse an HTTP response body into a JSON Value
@@ -16,6 +16,14 @@ use crate::server::endpoints::EndpointTool;
/// Result type for backend operations using rmcp's ErrorData directly
pub type BackendResult<T> = Result<T, ErrorData>;
/// What the backend needs about the HTTP request a tool call arrived on, in order
/// to hand a runnable the headers of the call that triggered it.
pub struct McpRequest<'a> {
pub headers: &'a http::HeaderMap,
/// The MCP tool name the caller invoked, reported to preprocessors.
pub tool_name: &'a str,
}
/// How a script/flow listing is narrowed by path at the SQL layer, *before* the
/// `ITEMS_FETCH_MAX_LIMIT` cap applies.
///
@@ -157,6 +165,7 @@ pub trait McpBackend: Send + Sync + Clone + 'static {
workspace_id: &str,
path: &str,
args: Value,
request: &McpRequest<'_>,
) -> BackendResult<Value>;
/// Run a flow and wait for result
@@ -166,6 +175,7 @@ pub trait McpBackend: Send + Sync + Clone + 'static {
workspace_id: &str,
path: &str,
args: Value,
request: &McpRequest<'_>,
) -> BackendResult<Value>;
/// Call an endpoint tool (generated API endpoint)
+1 -1
View File
@@ -12,7 +12,7 @@ pub mod tools;
// Re-export main types
pub use crate::common::types::{McpToken, MultiWorkspaceMcp, WorkspaceInfo};
pub use backend::{BackendResult, McpAuth, McpBackend, PathFilter};
pub use backend::{BackendResult, McpAuth, McpBackend, McpRequest, PathFilter};
pub use endpoints::{
endpoint_tool_to_mcp_tool, endpoint_tool_to_mcp_tool_multi, is_endpoint_read_only,
list_workspaces_tool, non_empty_body_fields, EndpointTool,
+57 -23
View File
@@ -9,8 +9,10 @@ use crate::common::transform::{
extract_hub_version_id_from_hashed, extract_path_prefix_from_hashed, parse_tool_prefix,
reverse_transform, reverse_transform_key,
};
use crate::common::types::{McpToken, MultiWorkspaceMcp, ResourceInfo, ToolableItem, WorkspaceId};
use crate::server::backend::{McpAuth, McpBackend, PathFilter};
use crate::common::types::{
McpToken, MultiWorkspaceMcp, ResourceInfo, SchemaType, ToolableItem, WorkspaceId,
};
use crate::server::backend::{McpAuth, McpBackend, McpRequest, PathFilter};
use crate::server::endpoints::{
endpoint_tool_to_mcp_tool, endpoint_tool_to_mcp_tool_multi, list_workspaces_tool, EndpointTool,
};
@@ -101,16 +103,24 @@ enum McpMode {
Multi(String),
}
/// Everything a request carries besides its MCP payload.
struct McpContext<A> {
auth: A,
mode: McpMode,
headers: http::HeaderMap,
}
impl<B: McpBackend> Runner<B> {
/// Create a new Runner with the given backend
pub fn new(backend: B) -> Self {
Self { backend: Arc::new(backend) }
}
/// Extract authentication and the workspace mode from request context
/// Extract authentication, the workspace mode and the HTTP request itself
/// from the request context
fn extract_context(
context: &RequestContext<RoleServer>,
) -> Result<(B::Auth, McpMode), ErrorData> {
) -> Result<McpContext<B::Auth>, ErrorData> {
let http_parts = context.extensions.get::<HttpParts>().ok_or_else(|| {
tracing::error!("http::request::Parts not found");
ErrorData::internal_error("http::request::Parts not found", None)
@@ -148,7 +158,7 @@ impl<B: McpBackend> Runner<B> {
McpMode::Single(workspace_id)
};
Ok((auth.clone(), mode))
Ok(McpContext { auth: auth.clone(), mode, headers: http_parts.headers.clone() })
}
}
@@ -391,6 +401,18 @@ fn authorize_endpoint_call(
Ok(())
}
/// Map the model's argument keys back to the runnable's original parameter names.
fn transform_call_args(args: Value, item_schema: &Option<SchemaType>) -> Value {
let Value::Object(map) = args else {
return args;
};
let mut args_hash = HashMap::new();
for (k, v) in map {
args_hash.insert(reverse_transform_key(&k, item_schema), v);
}
Value::Object(args_hash.into_iter().collect())
}
fn find_matching_path<T: ToolableItem>(candidates: Vec<T>, request_name: &str) -> Option<String> {
candidates
.into_iter()
@@ -427,7 +449,7 @@ impl<B: McpBackend> ServerHandler for Runner<B> {
_request: Option<PaginatedRequestParams>,
context: RequestContext<RoleServer>,
) -> Result<ListToolsResult, ErrorData> {
let (auth, mode) = Self::extract_context(&context)?;
let McpContext { auth, mode, .. } = Self::extract_context(&context)?;
// Parse MCP scopes to determine what to expose
let scopes = auth.scopes().unwrap_or(&[]);
@@ -455,7 +477,7 @@ impl<B: McpBackend> ServerHandler for Runner<B> {
request: CallToolRequestParams,
context: RequestContext<RoleServer>,
) -> Result<CallToolResponse, ErrorData> {
let (auth, mode) = Self::extract_context(&context)?;
let McpContext { auth, mode, headers } = Self::extract_context(&context)?;
// Parse MCP scopes for authorization
let scopes = auth.scopes().unwrap_or(&[]);
@@ -464,6 +486,7 @@ impl<B: McpBackend> ServerHandler for Runner<B> {
let read_only = auth.read_only();
let args = request.arguments.map(Value::Object).unwrap_or(Value::Null);
let mcp_request = McpRequest { headers: &headers, tool_name: request.name.as_ref() };
// 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.
@@ -474,14 +497,22 @@ impl<B: McpBackend> ServerHandler for Runner<B> {
&workspace_id,
&scope_config,
read_only,
request.name,
request.name.clone(),
args,
&mcp_request,
)
.await
}
McpMode::Multi(token) => {
self.call_tool_multi(&auth, &token, &scope_config, read_only, request.name, args)
.await
self.call_tool_multi(
&auth,
&token,
&scope_config,
read_only,
request.name.clone(),
args,
)
.await
}
}?;
Ok(result.into())
@@ -665,6 +696,7 @@ impl<B: McpBackend> Runner<B> {
read_only: bool,
name: std::borrow::Cow<'static, str>,
args: Value,
request: &McpRequest<'_>,
) -> Result<CallToolResult, ErrorData> {
// Check if this is an endpoint tool
let endpoint_tools = self.backend.all_endpoint_tools();
@@ -777,17 +809,7 @@ impl<B: McpBackend> Runner<B> {
.map_err(|e| ErrorData::internal_error(e.message, None))?
};
// Transform arguments back to original key names
let transformed_args = if let Value::Object(map) = args {
let mut args_hash = HashMap::new();
for (k, v) in map {
let original_key = reverse_transform_key(&k, &item_schema);
args_hash.insert(original_key, v);
}
Value::Object(args_hash.into_iter().collect())
} else {
args
};
let transformed_args = transform_call_args(args, &item_schema);
let script_or_flow_path = if is_hub {
format!("hub/{}", path)
@@ -798,11 +820,23 @@ impl<B: McpBackend> Runner<B> {
// Execute script or flow
let result = if tool_type == "script" {
self.backend
.run_script(auth, workspace_id, &script_or_flow_path, transformed_args)
.run_script(
auth,
workspace_id,
&script_or_flow_path,
transformed_args,
request,
)
.await
} else {
self.backend
.run_flow(auth, workspace_id, &script_or_flow_path, transformed_args)
.run_flow(
auth,
workspace_id,
&script_or_flow_path,
transformed_args,
request,
)
.await
};