From 20bb97fc1fabfd4e31ab86218248ee012fd98f5b Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 11 Feb 2026 06:44:10 +0000 Subject: [PATCH] reorg mcp_tools --- backend/windmill-api/src/apps.rs | 4 +- backend/windmill-api/src/lib.rs | 6 +- backend/windmill-api/src/mcp_tools.rs | 106 ++++++++++++++++ backend/windmill-api/src/openapi.rs | 2 +- backend/windmill-api/src/resources.rs | 117 +----------------- .../windmill-api/src/triggers/http/handler.rs | 2 +- backend/windmill-api/src/workspaces_export.rs | 8 +- 7 files changed, 119 insertions(+), 126 deletions(-) create mode 100644 backend/windmill-api/src/mcp_tools.rs diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 43fe926884..948dcc5ace 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -11,7 +11,6 @@ use crate::{ auth::OptTokened, db::{ApiAuthed, DB}, jobs::RunJobQuery, - resources::get_resource_value_interpolated_internal, users::{require_owner_of_path, OptAuthed}, utils::{check_scopes, WithStarredInfoQuery}, webhook_util::{WebhookMessage, WebhookShared}, @@ -68,6 +67,7 @@ use windmill_common::{ workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult}, HUB_BASE_URL, }; +use windmill_store::resources::get_resource_value_interpolated_internal; use windmill_git_sync::{handle_deployment_metadata, DeployedObject}; use windmill_queue::{push, PushArgs, PushArgsOwned, PushIsolationLevel}; @@ -1494,7 +1494,6 @@ async fn update_app( let path = path.to_path(); check_scopes(&authed, || format!("apps:write:{}", path))?; - if let RuleCheckResult::Blocked(msg) = check_user_against_rule( &w_id, &ProtectionRuleKind::DisableDirectDeployment, @@ -1539,7 +1538,6 @@ async fn update_app_raw<'a>( )); } - if let RuleCheckResult::Blocked(msg) = check_user_against_rule( &w_id, &ProtectionRuleKind::DisableDirectDeployment, diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 369d64e0e7..d76279da44 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -15,6 +15,7 @@ use crate::oauth2_oss::SlackVerifier; use crate::smtp_server_oss::SmtpServer; #[cfg(feature = "enterprise")] use windmill_api_auth::ee_oss::ExternalJwks; +use windmill_store::resources::public_service; #[cfg(feature = "mcp")] use crate::mcp::{extract_and_store_workspace_id, setup_mcp_server}; @@ -192,6 +193,9 @@ mod workspaces; pub mod workspaces_ee; mod workspaces_export; +#[cfg(feature = "mcp")] +mod mcp_tools; + #[cfg(feature = "mcp")] mod mcp; #[cfg(all(feature = "mcp", feature = "private"))] @@ -689,7 +693,7 @@ pub async fn run_server( }) .nest( "/w/:workspace_id/resources_u", - resources::public_service().layer(cors.clone()), + public_service().layer(cors.clone()), ) .nest( "/w/:workspace_id/capture_u", diff --git a/backend/windmill-api/src/mcp_tools.rs b/backend/windmill-api/src/mcp_tools.rs new file mode 100644 index 0000000000..10397f0dee --- /dev/null +++ b/backend/windmill-api/src/mcp_tools.rs @@ -0,0 +1,106 @@ +use axum::{ + extract::{Extension, Path}, + Json, +}; +use serde_json::value::RawValue; +use windmill_api_auth::{check_scopes, ApiAuthed}; +use windmill_common::{ + db::{UserDB, DB}, + error::{Error, JsonResult, Result}, + utils::{not_found_if_none, StripPath}, +}; +use windmill_store::resources::explain_resource_perm_error; + +pub(crate) async fn get_mcp_tools( + authed: ApiAuthed, + Extension(db): Extension, + Extension(user_db): Extension, + Path((w_id, path)): Path<(String, StripPath)>, +) -> JsonResult> { + let path = path.to_path(); + check_scopes(&authed, || format!("resources:read:{}", path))?; + + let mut tx = user_db.clone().begin(&authed).await?; + + let resource_value_o = sqlx::query_scalar!( + "SELECT value as \"value: sqlx::types::Json>\" FROM resource WHERE path = $1 AND workspace_id = $2", + &path, + &w_id + ) + .fetch_optional(&mut *tx) + .await?; + + tx.commit().await?; + + if resource_value_o.is_none() { + explain_resource_perm_error(&path, &w_id, &db, &authed).await?; + } + + let resource_value = not_found_if_none(resource_value_o, "Resource", path)? + .ok_or_else(|| Error::BadRequest(format!("Empty resource value for {}", path)))?; + + let mcp_resource = serde_json::from_str::(resource_value.0.get()) + .map_err(|e| Error::BadRequest(format!("Failed to parse MCP resource: {}", e)))?; + + #[cfg(feature = "oauth2")] + { + tracing::info!("Checking if token needs refresh before creating MCP client"); + if let Some(ref token_path) = mcp_resource.token { + let token_var_path = token_path.trim_start_matches("$var:"); + + let token_info = sqlx::query!( + r#" + SELECT + variable.account as account_id, + (now() > account.expires_at) as "is_expired: bool" + FROM variable + LEFT JOIN account ON variable.account = account.id AND account.workspace_id = $2 + WHERE variable.path = $1 AND variable.workspace_id = $2 + "#, + token_var_path, + &w_id + ) + .fetch_optional(&db) + .await?; + + if let Some(info) = token_info { + if let (Some(account_id), Some(true)) = (info.account_id, info.is_expired) { + let refresh_tx = user_db.begin(&authed).await?; + if let Err(e) = crate::oauth2_oss::_refresh_token( + refresh_tx, + token_var_path, + &w_id, + account_id, + &db, + ) + .await + { + tracing::warn!( + "Failed to refresh token for MCP resource: {}. Proceeding with possibly expired token.", + e + ); + } + } + } + } + } + + let client = windmill_mcp::McpClient::from_resource(mcp_resource, &db, &w_id) + .await + .map_err(|e| Error::ExecutionErr(format!("Failed to connect to MCP server: {}", e)))?; + + let tools: Vec = client + .available_tools() + .iter() + .map(|tool| { + serde_json::to_value(tool) + .map_err(|e| Error::ExecutionErr(format!("Failed to serialize MCP tool: {}", e))) + }) + .collect::>>()?; + + if let Err(e) = client.shutdown().await { + tracing::warn!("Failed to shutdown MCP client: {}", e); + } + + Ok(Json(tools)) +} diff --git a/backend/windmill-api/src/openapi.rs b/backend/windmill-api/src/openapi.rs index a178c38163..c564836b81 100644 --- a/backend/windmill-api/src/openapi.rs +++ b/backend/windmill-api/src/openapi.rs @@ -20,10 +20,10 @@ use windmill_common::{ utils::{deserialize_url, empty_as_none, is_empty, RunnableKind}, DB, }; +use windmill_store::resources::try_get_resource_from_db_as; use crate::{ db::ApiAuthed, - resources::try_get_resource_from_db_as, triggers::http::{ http_trigger_auth::ApiKeyAuthentication, AuthenticationMethod, HttpMethod, RequestType, }, diff --git a/backend/windmill-api/src/resources.rs b/backend/windmill-api/src/resources.rs index bc0380b2eb..f4bcfc621b 100644 --- a/backend/windmill-api/src/resources.rs +++ b/backend/windmill-api/src/resources.rs @@ -1,22 +1,5 @@ -// Re-export everything from windmill-store -pub use windmill_store::resources::*; - #[cfg(feature = "mcp")] -use axum::{ extract::{Extension, Path}, routing::get, - Json, Router, -}; -#[cfg(feature = "mcp")] -use serde_json::value::RawValue; -#[cfg(feature = "mcp")] -use windmill_api_auth::{check_scopes, ApiAuthed}; -#[cfg(feature = "mcp")] -use windmill_common::{ - db::{UserDB, DB}, - error::{Error, JsonResult, Result}, - utils::{not_found_if_none, StripPath}, -}; - -#[cfg(not(feature = "mcp"))] +use axum::routing::get; use axum::Router; /// Wraps the subcrate's workspaced_service with the mcp_tools route @@ -24,104 +7,10 @@ use axum::Router; pub fn workspaced_service() -> Router { let router = windmill_store::resources::workspaced_service(); + #[cfg(feature = "mcp")] + use crate::mcp_tools::get_mcp_tools; #[cfg(feature = "mcp")] let router = router.route("/mcp_tools/*path", get(get_mcp_tools)); router } - -/// Get list of tools from an MCP resource -#[cfg(feature = "mcp")] -async fn get_mcp_tools( - authed: ApiAuthed, - Extension(db): Extension, - Extension(user_db): Extension, - Path((w_id, path)): Path<(String, StripPath)>, -) -> JsonResult> { - let path = path.to_path(); - check_scopes(&authed, || format!("resources:read:{}", path))?; - - let mut tx = user_db.clone().begin(&authed).await?; - - let resource_value_o = sqlx::query_scalar!( - "SELECT value as \"value: sqlx::types::Json>\" FROM resource WHERE path = $1 AND workspace_id = $2", - &path, - &w_id - ) - .fetch_optional(&mut *tx) - .await?; - - tx.commit().await?; - - if resource_value_o.is_none() { - explain_resource_perm_error(&path, &w_id, &db, &authed).await?; - } - - let resource_value = not_found_if_none(resource_value_o, "Resource", path)? - .ok_or_else(|| Error::BadRequest(format!("Empty resource value for {}", path)))?; - - let mcp_resource = serde_json::from_str::(resource_value.0.get()) - .map_err(|e| Error::BadRequest(format!("Failed to parse MCP resource: {}", e)))?; - - #[cfg(feature = "oauth2")] - { - tracing::info!("Checking if token needs refresh before creating MCP client"); - if let Some(ref token_path) = mcp_resource.token { - let token_var_path = token_path.trim_start_matches("$var:"); - - let token_info = sqlx::query!( - r#" - SELECT - variable.account as account_id, - (now() > account.expires_at) as "is_expired: bool" - FROM variable - LEFT JOIN account ON variable.account = account.id AND account.workspace_id = $2 - WHERE variable.path = $1 AND variable.workspace_id = $2 - "#, - token_var_path, - &w_id - ) - .fetch_optional(&db) - .await?; - - if let Some(info) = token_info { - if let (Some(account_id), Some(true)) = (info.account_id, info.is_expired) { - let refresh_tx = user_db.begin(&authed).await?; - if let Err(e) = crate::oauth2_oss::_refresh_token( - refresh_tx, - token_var_path, - &w_id, - account_id, - &db, - ) - .await - { - tracing::warn!( - "Failed to refresh token for MCP resource: {}. Proceeding with possibly expired token.", - e - ); - } - } - } - } - } - - let client = windmill_mcp::McpClient::from_resource(mcp_resource, &db, &w_id) - .await - .map_err(|e| Error::ExecutionErr(format!("Failed to connect to MCP server: {}", e)))?; - - let tools: Vec = client - .available_tools() - .iter() - .map(|tool| { - serde_json::to_value(tool) - .map_err(|e| Error::ExecutionErr(format!("Failed to serialize MCP tool: {}", e))) - }) - .collect::>>()?; - - if let Err(e) = client.shutdown().await { - tracing::warn!("Failed to shutdown MCP client: {}", e); - } - - Ok(Json(tools)) -} diff --git a/backend/windmill-api/src/triggers/http/handler.rs b/backend/windmill-api/src/triggers/http/handler.rs index 44985ff8d6..540a1c148b 100644 --- a/backend/windmill-api/src/triggers/http/handler.rs +++ b/backend/windmill-api/src/triggers/http/handler.rs @@ -6,7 +6,6 @@ use crate::{ auth::{AuthCache, OptTokened}, db::{ApiAuthed, DB}, jobs::start_job_update_sse_stream, - resources::try_get_resource_from_db_as, triggers::trigger_helpers::{ get_runnable_format, trigger_runnable, trigger_runnable_and_wait_for_result, trigger_runnable_inner, RunnableId, @@ -30,6 +29,7 @@ use windmill_common::{ triggers::{TriggerKind, TriggerMetadata}, utils::{not_found_if_none, StripPath}, }; +use windmill_store::resources::try_get_resource_from_db_as; use windmill_trigger::TriggerMode; #[cfg(feature = "parquet")] diff --git a/backend/windmill-api/src/workspaces_export.rs b/backend/windmill-api/src/workspaces_export.rs index 2234b4a0f1..c881e9ae9b 100644 --- a/backend/windmill-api/src/workspaces_export.rs +++ b/backend/windmill-api/src/workspaces_export.rs @@ -10,12 +10,7 @@ use std::collections::HashMap; use crate::db::ApiAuthed; -use crate::{ - apps::AppWithLastVersion, - db::DB, - folders::Folder, - resources::{Resource, ResourceType}, -}; +use crate::{apps::AppWithLastVersion, db::DB, folders::Folder}; #[cfg(any( feature = "http_trigger", @@ -64,6 +59,7 @@ use serde_json::Value; use tempfile::TempDir; use tokio::fs::File; use tokio_util::io::ReaderStream; +use windmill_store::resources::{Resource, ResourceType}; #[derive(Serialize)] struct ScriptMetadata {