mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 08:02:18 +00:00
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:
co-authored by
Claude Opus 5
parent
e39dd7eb12
commit
e474e8803c
@@ -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(())
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user