From cd8edcd94f2bf44c3e771000cb0bbad08accc0e7 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 31 Mar 2026 07:09:34 +0000 Subject: [PATCH] feat: expose getJob and getJobLogs as MCP tools (#8632) * feat: expose getJob and getJobLogs as MCP tools Co-Authored-By: Claude Opus 4.6 (1M context) * test: add integration test for getJob/getJobLogs MCP endpoint tools Co-Authored-By: Claude Opus 4.6 (1M context) * test: add MCP client integration test for getJob and getJobLogs Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- backend/Cargo.lock | 1 + .../windmill-api-integration-tests/Cargo.toml | 3 +- .../tests/resources.rs | 181 ++++++++++++++ backend/windmill-api/openapi.yaml | 4 +- .../src/mcp/auto_generated_endpoints.rs | 235 ++++++++++++++++-- backend/windmill-test-utils/Cargo.toml | 1 + backend/windmill-test-utils/src/lib.rs | 21 +- frontend/src/lib/mcpEndpointTools.ts | 235 ++++++++++++++++-- 8 files changed, 629 insertions(+), 52 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 12eeddc3ee..9f35078d2b 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -16382,6 +16382,7 @@ dependencies = [ "rand 0.9.0", "rdkafka", "reqwest 0.13.1", + "rmcp", "rumqttc", "serde", "serde_json", diff --git a/backend/windmill-api-integration-tests/Cargo.toml b/backend/windmill-api-integration-tests/Cargo.toml index eb432e463f..54f836657c 100644 --- a/backend/windmill-api-integration-tests/Cargo.toml +++ b/backend/windmill-api-integration-tests/Cargo.toml @@ -13,7 +13,7 @@ default = [] private = ["windmill-test-utils/private", "dep:aws-config", "dep:aws-credential-types", "dep:aws-sdk-sqs", "windmill-git-sync/private"] enterprise = ["windmill-test-utils/enterprise", "dep:base64", "windmill-git-sync/enterprise"] deno_core = ["windmill-test-utils/deno_core"] -mcp = [] +mcp = ["windmill-test-utils/mcp", "dep:rmcp"] run_inline = ["dep:windmill-worker", "windmill-test-utils/run_inline", "windmill-test-utils/duckdb"] [dependencies] @@ -41,3 +41,4 @@ aws-credential-types = { workspace = true, optional = true } aws-sdk-sqs = { workspace = true, optional = true } base64 = { workspace = true, optional = true } axum.workspace = true +rmcp = { workspace = true, optional = true } diff --git a/backend/windmill-api-integration-tests/tests/resources.rs b/backend/windmill-api-integration-tests/tests/resources.rs index 22dab5d418..363217712f 100644 --- a/backend/windmill-api-integration-tests/tests/resources.rs +++ b/backend/windmill-api-integration-tests/tests/resources.rs @@ -1,5 +1,7 @@ use serde_json::json; use sqlx::{Pool, Postgres}; +#[cfg(feature = "mcp")] +use uuid::Uuid; use windmill_test_utils::*; @@ -518,3 +520,182 @@ async fn test_mcp_tools(db: Pool) -> anyhow::Result<()> { Ok(()) } + +#[cfg(feature = "mcp")] +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_mcp_endpoint_tools_list(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let resp = authed(client().get(format!( + "http://localhost:{port}/api/mcp/w/test-workspace/list_tools" + ))) + .send() + .await?; + assert_eq!(resp.status(), 200); + + let tools: Vec = resp.json().await?; + + let tool_names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect(); + + assert!( + tool_names.contains(&"getJob"), + "getJob not found in MCP endpoint tools: {tool_names:?}" + ); + assert!( + tool_names.contains(&"getJobLogs"), + "getJobLogs not found in MCP endpoint tools: {tool_names:?}" + ); + + // Verify getJob has the expected path and method + let get_job_tool = tools.iter().find(|t| t["name"] == "getJob").unwrap(); + assert_eq!(get_job_tool["path"], "/w/{workspace}/jobs_u/get/{id}"); + assert_eq!(get_job_tool["method"], "GET"); + + // Verify getJobLogs has the expected path and method + let get_job_logs_tool = tools.iter().find(|t| t["name"] == "getJobLogs").unwrap(); + assert_eq!( + get_job_logs_tool["path"], + "/w/{workspace}/jobs_u/get_logs/{id}" + ); + assert_eq!(get_job_logs_tool["method"], "GET"); + + Ok(()) +} + +#[cfg(feature = "mcp")] +async fn insert_completed_job_with_logs(db: &Pool) -> Uuid { + let id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, kind, tag, args) + VALUES ($1, 'test-workspace', 'test-user', 'u/test-user', 'script', 'deno', '{}'::jsonb)", + ) + .bind(id) + .execute(db) + .await + .unwrap(); + + sqlx::query( + "INSERT INTO v2_job_completed (id, workspace_id, duration_ms, result, status) + VALUES ($1, 'test-workspace', 100, '42'::jsonb, 'success')", + ) + .bind(id) + .execute(db) + .await + .unwrap(); + + sqlx::query( + "INSERT INTO job_logs (job_id, workspace_id, logs, log_offset) + VALUES ($1, 'test-workspace', 'hello world test log', 0)", + ) + .bind(id) + .execute(db) + .await + .unwrap(); + + id +} + +#[cfg(feature = "mcp")] +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_mcp_client_get_job_and_logs(db: Pool) -> anyhow::Result<()> { + use rmcp::model::{ + CallToolRequestParams, ClientCapabilities, ClientInfo, Implementation, + InitializeRequestParams, + }; + use rmcp::service::{RoleClient, RunningService}; + use rmcp::transport::streamable_http_client::{ + StreamableHttpClientTransport, StreamableHttpClientTransportConfig, + }; + use rmcp::ServiceExt; + + initialize_tracing().await; + set_jwt_secret().await; + let server = ApiServer::start_mcp(db.clone()).await?; + let port = server.addr.port(); + + let job_id = insert_completed_job_with_logs(&db).await; + + // Create a token with MCP scopes + 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?; + + // Connect as MCP client + let config = StreamableHttpClientTransportConfig::with_uri(format!( + "http://localhost:{port}/api/mcp/w/test-workspace/mcp" + )) + .auth_header("MCP_TOKEN"); + let transport = StreamableHttpClientTransport::from_config(config); + + let client_info = ClientInfo { + protocol_version: Default::default(), + capabilities: ClientCapabilities::default(), + client_info: Implementation { + name: "test-client".to_string(), + title: None, + version: "0.0.1".to_string(), + description: None, + website_url: None, + icons: None, + }, + meta: None, + }; + + let client: RunningService = + client_info.serve(transport).await?; + + // --- Test getJob --- + let result = client + .call_tool(CallToolRequestParams { + name: "getJob".into(), + arguments: Some(serde_json::from_value(json!({ "id": job_id.to_string() }))?), + task: None, + meta: None, + }) + .await?; + + let text = result + .content + .first() + .and_then(|c| c.raw.as_text()) + .expect("getJob should return text content"); + let job: serde_json::Value = serde_json::from_str(&text.text)?; + assert_eq!(job["id"], job_id.to_string()); + assert_eq!(job["workspace_id"], "test-workspace"); + assert_eq!(job["created_by"], "test-user"); + assert_eq!(job["job_kind"], "script"); + assert!( + job["success"].as_bool().unwrap_or(false), + "job should be successful: {job}" + ); + + // --- Test getJobLogs --- + let result = client + .call_tool(CallToolRequestParams { + name: "getJobLogs".into(), + arguments: Some(serde_json::from_value(json!({ "id": job_id.to_string() }))?), + task: None, + meta: None, + }) + .await?; + + let text = result + .content + .first() + .and_then(|c| c.raw.as_text()) + .expect("getJobLogs should return text content"); + // The logs endpoint returns text/plain, which gets wrapped as a JSON string by call_endpoint + let logs: String = serde_json::from_str(&text.text)?; + assert!( + logs.contains("hello world test log"), + "expected logs to contain test log, got: {logs}" + ); + + client.cancel().await?; + Ok(()) +} diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 23b2094676..fc87dcefaf 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -10598,6 +10598,7 @@ paths: get: summary: get job operationId: getJob + x-mcp-tool: true tags: - job parameters: @@ -10639,7 +10640,8 @@ paths: /w/{workspace}/jobs_u/get_logs/{id}: get: summary: get job logs - operationId: getJob logs + operationId: getJobLogs + x-mcp-tool: true tags: - job parameters: diff --git a/backend/windmill-api/src/mcp/auto_generated_endpoints.rs b/backend/windmill-api/src/mcp/auto_generated_endpoints.rs index 9425b6a01f..c9295ea8e9 100644 --- a/backend/windmill-api/src/mcp/auto_generated_endpoints.rs +++ b/backend/windmill-api/src/mcp/auto_generated_endpoints.rs @@ -221,6 +221,22 @@ pub fn all_tools() -> Vec { "type": "string", "description": "filter variables by path prefix" }, + "path": { + "type": "string", + "description": "exact path match filter" + }, + "description": { + "type": "string", + "description": "pattern match filter for description field (case-insensitive)" + }, + "value": { + "type": "string", + "description": "pattern match filter for non-secret variable values (case-insensitive)" + }, + "broad_filter": { + "type": "string", + "description": "broad search across multiple fields (case-insensitive substring match)" + }, "page": { "type": "integer", "description": "which page to return (start at 1, default 1)" @@ -405,6 +421,22 @@ pub fn all_tools() -> Vec { "path_start": { "type": "string", "description": "filter resources by path prefix" + }, + "path": { + "type": "string", + "description": "exact path match filter" + }, + "description": { + "type": "string", + "description": "pattern match filter for description field (case-insensitive)" + }, + "value": { + "type": "string", + "description": "JSONB subset match filter using base64 encoded JSON" + }, + "broad_filter": { + "type": "string", + "description": "broad search across multiple fields (case-insensitive substring match)" } }, "required": [] @@ -451,7 +483,7 @@ pub fn all_tools() -> Vec { }, "created_by": { "type": "string", - "description": "mask to filter exact matching user creator" + "description": "filter by exact matching user creator. Supports comma-separated list (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob')" }, "path_start": { "type": "string", @@ -562,7 +594,6 @@ pub fn all_tools() -> Vec { "required": [ "path", "summary", - "description", "content", "language" ] @@ -708,7 +739,7 @@ pub fn all_tools() -> Vec { }, "created_by": { "type": "string", - "description": "mask to filter exact matching user creator" + "description": "filter by exact matching user creator. Supports comma-separated list (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob')" }, "path_start": { "type": "string", @@ -1078,6 +1109,37 @@ pub fn all_tools() -> Vec { }, "lock": { "type": "string" + }, + "flow_path": { + "type": "string" + }, + "modules": { + "type": "object", + "nullable": true, + "description": "Additional script modules keyed by relative file path", + "additionalProperties": { + "type": "object", + "description": "An additional module file associated with a script", + "properties": { + "content": { + "type": "string", + "description": "The source code content of this module" + }, + "language": { + "type": "string", + "description": "Possible values: python3, deno, go, bash, powershell, postgresql, mysql, bigquery, snowflake, mssql, oracledb, graphql, nativets, bun, php, rust, ansible, csharp, nu, java, ruby, duckdb, bunnative" + }, + "lock": { + "type": "string", + "nullable": true, + "description": "Lock file content for this module's dependencies" + } + }, + "required": [ + "content", + "language" + ] + } } }, "required": [ @@ -1106,7 +1168,7 @@ pub fn all_tools() -> Vec { }, "created_by": { "type": "string", - "description": "mask to filter exact matching user creator" + "description": "filter by exact matching user creator. Supports comma-separated list (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob')" }, "parent_job": { "type": "string", @@ -1115,15 +1177,15 @@ pub fn all_tools() -> Vec { }, "worker": { "type": "string", - "description": "worker this job was ran on" + "description": "filter by worker this job ran on. Supports comma-separated list (e.g. 'worker-1,worker-2') and negation by prefixing all values with '!' (e.g. '!worker-1,!worker-2')" }, "script_path_exact": { "type": "string", - "description": "mask to filter exact matching path" + "description": "filter by exact matching script path. Supports comma-separated list (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2')" }, "script_path_start": { "type": "string", - "description": "mask to filter matching starting path" + "description": "filter by script path prefix. Supports comma-separated list (e.g. 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2')" }, "schedule_path": { "type": "string", @@ -1131,11 +1193,11 @@ pub fn all_tools() -> Vec { }, "trigger_path": { "type": "string", - "description": "mask to filter by trigger path" + "description": "filter by trigger path. Supports comma-separated list (e.g. 'f/trigger1,f/trigger2') and negation by prefixing all values with '!' (e.g. '!f/trigger1,!f/trigger2')" }, "trigger_kind": { - "description": "trigger kind (schedule, http, websocket...). Possible values: webhook, default_email, email, schedule, http, websocket, postgres, kafka, nats, mqtt, sqs, gcp", - "type": "string" + "type": "string", + "description": "filter by trigger kind. Supports comma-separated list (e.g. 'schedule,webhook') and negation by prefixing all values with '!' (e.g. '!schedule,!webhook')" }, "script_hash": { "type": "string", @@ -1161,7 +1223,7 @@ pub fn all_tools() -> Vec { }, "job_kinds": { "type": "string", - "description": "filter on job kind (values 'preview', 'script', 'dependencies', 'flow') separated by," + "description": "filter by job kind. Supports comma-separated list of values ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies')" }, "suspended": { "type": "boolean", @@ -1185,7 +1247,7 @@ pub fn all_tools() -> Vec { }, "tag": { "type": "string", - "description": "filter on jobs with a given tag/worker group" + "description": "filter by tag/worker group. Supports comma-separated list (e.g. 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem')" }, "page": { "type": "integer", @@ -1223,15 +1285,15 @@ pub fn all_tools() -> Vec { "properties": { "created_by": { "type": "string", - "description": "mask to filter exact matching user creator" + "description": "filter by exact matching user creator. Supports comma-separated list (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob')" }, "label": { "type": "string", - "description": "mask to filter exact matching job's label (job labels are completed jobs with as a result an object containing a string in the array at key 'wm_labels')" + "description": "filter by exact matching job label. Supports comma-separated list (e.g. 'deploy,release') and negation by prefixing all values with '!' (e.g. '!deploy,!release')" }, "worker": { "type": "string", - "description": "worker this job was ran on" + "description": "filter by worker this job ran on. Supports comma-separated list (e.g. 'worker-1,worker-2') and negation by prefixing all values with '!' (e.g. '!worker-1,!worker-2')" }, "parent_job": { "type": "string", @@ -1240,11 +1302,11 @@ pub fn all_tools() -> Vec { }, "script_path_exact": { "type": "string", - "description": "mask to filter exact matching path" + "description": "filter by exact matching script path. Supports comma-separated list (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2')" }, "script_path_start": { "type": "string", - "description": "mask to filter matching starting path" + "description": "filter by script path prefix. Supports comma-separated list (e.g. 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2')" }, "schedule_path": { "type": "string", @@ -1304,7 +1366,7 @@ pub fn all_tools() -> Vec { }, "job_kinds": { "type": "string", - "description": "filter on job kind (values 'preview', 'script', 'dependencies', 'flow') separated by," + "description": "filter by job kind. Supports comma-separated list of values ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies')" }, "suspended": { "type": "boolean", @@ -1316,7 +1378,7 @@ pub fn all_tools() -> Vec { }, "tag": { "type": "string", - "description": "filter on jobs with a given tag/worker group" + "description": "filter by tag/worker group. Supports comma-separated list (e.g. 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem')" }, "result": { "type": "string", @@ -1331,8 +1393,8 @@ pub fn all_tools() -> Vec { "description": "number of items to return for a given page (default 30, max 100)" }, "trigger_kind": { - "description": "trigger kind (schedule, http, websocket...). Possible values: webhook, default_email, email, schedule, http, websocket, postgres, kafka, nats, mqtt, sqs, gcp", - "type": "string" + "type": "string", + "description": "filter by trigger kind. Supports comma-separated list (e.g. 'schedule,webhook') and negation by prefixing all values with '!' (e.g. '!schedule,!webhook')" }, "is_skipped": { "type": "boolean", @@ -1357,6 +1419,77 @@ pub fn all_tools() -> Vec { "is_not_schedule": { "type": "boolean", "description": "is not a scheduled job" + }, + "broad_filter": { + "type": "string", + "description": "broad search across multiple fields (case-insensitive substring match on path, tag, schedule path, trigger kind, label)" + } + }, + "required": [] +})), + body_schema: None, + path_field_renames: None, + query_field_renames: None, + body_field_renames: None, + }, + EndpointTool { + name: Cow::Borrowed("getJob"), + description: Cow::Borrowed("get job"), + instructions: Cow::Borrowed(""), + path: Cow::Borrowed("/w/{workspace}/jobs_u/get/{id}"), + method: Cow::Borrowed("GET"), + path_params_schema: Some(serde_json::json!({ + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "id" + ] +})), + query_params_schema: Some(serde_json::json!({ + "type": "object", + "properties": { + "no_logs": { + "type": "boolean" + }, + "no_code": { + "type": "boolean" + } + }, + "required": [] +})), + body_schema: None, + path_field_renames: None, + query_field_renames: None, + body_field_renames: None, + }, + EndpointTool { + name: Cow::Borrowed("getJobLogs"), + description: Cow::Borrowed("get job logs"), + instructions: Cow::Borrowed(""), + path: Cow::Borrowed("/w/{workspace}/jobs_u/get_logs/{id}"), + method: Cow::Borrowed("GET"), + path_params_schema: Some(serde_json::json!({ + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "id" + ] +})), + query_params_schema: Some(serde_json::json!({ + "type": "object", + "properties": { + "remove_ansi_warnings": { + "type": "boolean" } }, "required": [] @@ -1411,14 +1544,17 @@ You should get the schema of the script or flow before creating the schedule to }, "on_failure": { "type": "string", + "nullable": true, "description": "Path to a script or flow to run when the scheduled job fails" }, "on_failure_times": { "type": "number", + "nullable": true, "description": "Number of consecutive failures before the on_failure handler is triggered (default 1)" }, "on_failure_exact": { "type": "boolean", + "nullable": true, "description": "If true, trigger on_failure handler only on exactly N failures, not on every failure after N" }, "on_failure_extra_args": { @@ -1428,10 +1564,12 @@ You should get the schema of the script or flow before creating the schedule to }, "on_recovery": { "type": "string", + "nullable": true, "description": "Path to a script or flow to run when the schedule recovers after failures" }, "on_recovery_times": { "type": "number", + "nullable": true, "description": "Number of consecutive successes before the on_recovery handler is triggered (default 1)" }, "on_recovery_extra_args": { @@ -1441,6 +1579,7 @@ You should get the schema of the script or flow before creating the schedule to }, "on_success": { "type": "string", + "nullable": true, "description": "Path to a script or flow to run after each successful execution" }, "on_success_extra_args": { @@ -1516,28 +1655,42 @@ You should get the schema of the script or flow before creating the schedule to }, "summary": { "type": "string", + "nullable": true, "description": "Short summary describing the purpose of this schedule" }, "description": { "type": "string", + "nullable": true, "description": "Detailed description of what this schedule does" }, "tag": { "type": "string", + "nullable": true, "description": "Worker tag to route jobs to specific worker groups" }, "paused_until": { "type": "string", + "nullable": true, "format": "date-time", "description": "ISO 8601 datetime until which the schedule is paused. Schedule resumes automatically after this time" }, "cron_version": { "type": "string", + "nullable": true, "description": "Cron parser version. Use 'v2' for extended syntax with additional features" }, "dynamic_skip": { "type": "string", + "nullable": true, "description": "Path to a script that validates scheduled datetimes. Receives scheduled_for datetime and returns boolean to skip (true) or run (false)" + }, + "permissioned_as": { + "type": "string", + "description": "The user or group this schedule runs as. Used during deployment to preserve the original schedule owner." + }, + "preserve_permissioned_as": { + "type": "boolean", + "description": "When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it." } }, "required": [ @@ -1592,14 +1745,17 @@ You should get the schema of the script or flow before updating the schedule to }, "on_failure": { "type": "string", + "nullable": true, "description": "Path to a script or flow to run when the scheduled job fails" }, "on_failure_times": { "type": "number", + "nullable": true, "description": "Number of consecutive failures before the on_failure handler is triggered (default 1)" }, "on_failure_exact": { "type": "boolean", + "nullable": true, "description": "If true, trigger on_failure handler only on exactly N failures, not on every failure after N" }, "on_failure_extra_args": { @@ -1609,10 +1765,12 @@ You should get the schema of the script or flow before updating the schedule to }, "on_recovery": { "type": "string", + "nullable": true, "description": "Path to a script or flow to run when the schedule recovers after failures" }, "on_recovery_times": { "type": "number", + "nullable": true, "description": "Number of consecutive successes before the on_recovery handler is triggered (default 1)" }, "on_recovery_extra_args": { @@ -1622,6 +1780,7 @@ You should get the schema of the script or flow before updating the schedule to }, "on_success": { "type": "string", + "nullable": true, "description": "Path to a script or flow to run after each successful execution" }, "on_success_extra_args": { @@ -1697,28 +1856,44 @@ You should get the schema of the script or flow before updating the schedule to }, "summary": { "type": "string", + "nullable": true, "description": "Short summary describing the purpose of this schedule" }, "description": { "type": "string", + "nullable": true, "description": "Detailed description of what this schedule does" }, "tag": { "type": "string", + "nullable": true, "description": "Worker tag to route jobs to specific worker groups" }, "paused_until": { "type": "string", + "nullable": true, "format": "date-time", "description": "ISO 8601 datetime until which the schedule is paused. Schedule resumes automatically after this time" }, "cron_version": { "type": "string", + "nullable": true, "description": "Cron parser version. Use 'v2' for extended syntax with additional features" }, "dynamic_skip": { "type": "string", + "nullable": true, "description": "Path to a script that validates scheduled datetimes. Receives scheduled_for datetime and returns boolean to skip (true) or run (false)" + }, + "permissioned_as": { + "type": "string", + "nullable": true, + "description": "The user or group this schedule runs as (e.g., 'u/admin' or 'g/mygroup'). Only admins and wm_deployers can set this via preserve_permissioned_as." + }, + "preserve_permissioned_as": { + "type": "boolean", + "nullable": true, + "description": "If true and user is admin/wm_deployers, preserve the provided permissioned_as instead of using the deploying user's identity" } }, "required": [ @@ -1801,7 +1976,7 @@ You should get the schema of the script or flow before updating the schedule to }, "path": { "type": "string", - "description": "filter by path" + "description": "filter by path (script path)" }, "is_flow": { "type": "boolean", @@ -1810,6 +1985,22 @@ You should get the schema of the script or flow before updating the schedule to "path_start": { "type": "string", "description": "filter schedules by path prefix" + }, + "schedule_path": { + "type": "string", + "description": "exact match on the schedule's path" + }, + "description": { + "type": "string", + "description": "pattern match filter for description field (case-insensitive)" + }, + "summary": { + "type": "string", + "description": "pattern match filter for summary field (case-insensitive)" + }, + "broad_filter": { + "type": "string", + "description": "broad search across multiple fields (case-insensitive substring match)" } }, "required": [] diff --git a/backend/windmill-test-utils/Cargo.toml b/backend/windmill-test-utils/Cargo.toml index 18cf8cde85..a221e53911 100644 --- a/backend/windmill-test-utils/Cargo.toml +++ b/backend/windmill-test-utils/Cargo.toml @@ -14,6 +14,7 @@ private = ["windmill-api/private"] enterprise = ["windmill-api/enterprise"] python = ["windmill-common/python"] deno_core = ["dep:windmill-runtime-nativets"] +mcp = ["windmill-api/mcp"] agent_worker_server = ["dep:windmill-api-agent-workers"] run_inline = ["windmill-api/run_inline"] duckdb = ["windmill-worker/duckdb"] diff --git a/backend/windmill-test-utils/src/lib.rs b/backend/windmill-test-utils/src/lib.rs index 6a15ffa5ae..153bdeeb7c 100644 --- a/backend/windmill-test-utils/src/lib.rs +++ b/backend/windmill-test-utils/src/lib.rs @@ -81,20 +81,29 @@ pub struct ApiServer { impl ApiServer { pub async fn start(db: Pool) -> anyhow::Result { - Self::start_inner(db, false).await + Self::start_inner(db, false, false).await } pub async fn start_agent_mode(db: Pool) -> anyhow::Result { - Self::start_inner(db, true).await + Self::start_inner(db, true, false).await } /// Start the API server with server_mode=true so trigger listeners are active. /// Alias for `start_agent_mode` with a clearer name for trigger e2e tests. pub async fn start_with_listeners(db: Pool) -> anyhow::Result { - Self::start_inner(db, true).await + Self::start_inner(db, true, false).await } - async fn start_inner(db: Pool, agent_mode: bool) -> anyhow::Result { + /// Start the API server with mcp_mode=true so MCP routes are active. + pub async fn start_mcp(db: Pool) -> anyhow::Result { + Self::start_inner(db, false, true).await + } + + async fn start_inner( + db: Pool, + server_mode: bool, + mcp_mode: bool, + ) -> anyhow::Result { let (tx, rx) = tokio::sync::broadcast::channel::<()>(1); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") @@ -114,8 +123,8 @@ impl ApiServer { listener, rx, port_tx, - agent_mode, - false, + server_mode, + mcp_mode, format!("http://localhost:{}", addr.port()), Some(name.clone()), )); diff --git a/frontend/src/lib/mcpEndpointTools.ts b/frontend/src/lib/mcpEndpointTools.ts index e080914f51..e60103a65a 100644 --- a/frontend/src/lib/mcpEndpointTools.ts +++ b/frontend/src/lib/mcpEndpointTools.ts @@ -231,6 +231,22 @@ export const mcpEndpointTools: EndpointTool[] = [ "type": "string", "description": "filter variables by path prefix" }, + "path": { + "type": "string", + "description": "exact path match filter" + }, + "description": { + "type": "string", + "description": "pattern match filter for description field (case-insensitive)" + }, + "value": { + "type": "string", + "description": "pattern match filter for non-secret variable values (case-insensitive)" + }, + "broad_filter": { + "type": "string", + "description": "broad search across multiple fields (case-insensitive substring match)" + }, "page": { "type": "integer", "description": "which page to return (start at 1, default 1)" @@ -415,6 +431,22 @@ export const mcpEndpointTools: EndpointTool[] = [ "path_start": { "type": "string", "description": "filter resources by path prefix" + }, + "path": { + "type": "string", + "description": "exact path match filter" + }, + "description": { + "type": "string", + "description": "pattern match filter for description field (case-insensitive)" + }, + "value": { + "type": "string", + "description": "JSONB subset match filter using base64 encoded JSON" + }, + "broad_filter": { + "type": "string", + "description": "broad search across multiple fields (case-insensitive substring match)" } }, "required": [] @@ -461,7 +493,7 @@ export const mcpEndpointTools: EndpointTool[] = [ }, "created_by": { "type": "string", - "description": "mask to filter exact matching user creator" + "description": "filter by exact matching user creator. Supports comma-separated list (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob')" }, "path_start": { "type": "string", @@ -572,7 +604,6 @@ export const mcpEndpointTools: EndpointTool[] = [ "required": [ "path", "summary", - "description", "content", "language" ] @@ -718,7 +749,7 @@ export const mcpEndpointTools: EndpointTool[] = [ }, "created_by": { "type": "string", - "description": "mask to filter exact matching user creator" + "description": "filter by exact matching user creator. Supports comma-separated list (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob')" }, "path_start": { "type": "string", @@ -1088,6 +1119,37 @@ export const mcpEndpointTools: EndpointTool[] = [ }, "lock": { "type": "string" + }, + "flow_path": { + "type": "string" + }, + "modules": { + "type": "object", + "nullable": true, + "description": "Additional script modules keyed by relative file path", + "additionalProperties": { + "type": "object", + "description": "An additional module file associated with a script", + "properties": { + "content": { + "type": "string", + "description": "The source code content of this module" + }, + "language": { + "type": "string", + "description": "Possible values: python3, deno, go, bash, powershell, postgresql, mysql, bigquery, snowflake, mssql, oracledb, graphql, nativets, bun, php, rust, ansible, csharp, nu, java, ruby, duckdb, bunnative" + }, + "lock": { + "type": "string", + "nullable": true, + "description": "Lock file content for this module's dependencies" + } + }, + "required": [ + "content", + "language" + ] + } } }, "required": [ @@ -1116,7 +1178,7 @@ export const mcpEndpointTools: EndpointTool[] = [ }, "created_by": { "type": "string", - "description": "mask to filter exact matching user creator" + "description": "filter by exact matching user creator. Supports comma-separated list (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob')" }, "parent_job": { "type": "string", @@ -1125,15 +1187,15 @@ export const mcpEndpointTools: EndpointTool[] = [ }, "worker": { "type": "string", - "description": "worker this job was ran on" + "description": "filter by worker this job ran on. Supports comma-separated list (e.g. 'worker-1,worker-2') and negation by prefixing all values with '!' (e.g. '!worker-1,!worker-2')" }, "script_path_exact": { "type": "string", - "description": "mask to filter exact matching path" + "description": "filter by exact matching script path. Supports comma-separated list (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2')" }, "script_path_start": { "type": "string", - "description": "mask to filter matching starting path" + "description": "filter by script path prefix. Supports comma-separated list (e.g. 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2')" }, "schedule_path": { "type": "string", @@ -1141,11 +1203,11 @@ export const mcpEndpointTools: EndpointTool[] = [ }, "trigger_path": { "type": "string", - "description": "mask to filter by trigger path" + "description": "filter by trigger path. Supports comma-separated list (e.g. 'f/trigger1,f/trigger2') and negation by prefixing all values with '!' (e.g. '!f/trigger1,!f/trigger2')" }, "trigger_kind": { - "description": "trigger kind (schedule, http, websocket...). Possible values: webhook, default_email, email, schedule, http, websocket, postgres, kafka, nats, mqtt, sqs, gcp", - "type": "string" + "type": "string", + "description": "filter by trigger kind. Supports comma-separated list (e.g. 'schedule,webhook') and negation by prefixing all values with '!' (e.g. '!schedule,!webhook')" }, "script_hash": { "type": "string", @@ -1171,7 +1233,7 @@ export const mcpEndpointTools: EndpointTool[] = [ }, "job_kinds": { "type": "string", - "description": "filter on job kind (values 'preview', 'script', 'dependencies', 'flow') separated by," + "description": "filter by job kind. Supports comma-separated list of values ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies')" }, "suspended": { "type": "boolean", @@ -1195,7 +1257,7 @@ export const mcpEndpointTools: EndpointTool[] = [ }, "tag": { "type": "string", - "description": "filter on jobs with a given tag/worker group" + "description": "filter by tag/worker group. Supports comma-separated list (e.g. 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem')" }, "page": { "type": "integer", @@ -1233,15 +1295,15 @@ export const mcpEndpointTools: EndpointTool[] = [ "properties": { "created_by": { "type": "string", - "description": "mask to filter exact matching user creator" + "description": "filter by exact matching user creator. Supports comma-separated list (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob')" }, "label": { "type": "string", - "description": "mask to filter exact matching job's label (job labels are completed jobs with as a result an object containing a string in the array at key 'wm_labels')" + "description": "filter by exact matching job label. Supports comma-separated list (e.g. 'deploy,release') and negation by prefixing all values with '!' (e.g. '!deploy,!release')" }, "worker": { "type": "string", - "description": "worker this job was ran on" + "description": "filter by worker this job ran on. Supports comma-separated list (e.g. 'worker-1,worker-2') and negation by prefixing all values with '!' (e.g. '!worker-1,!worker-2')" }, "parent_job": { "type": "string", @@ -1250,11 +1312,11 @@ export const mcpEndpointTools: EndpointTool[] = [ }, "script_path_exact": { "type": "string", - "description": "mask to filter exact matching path" + "description": "filter by exact matching script path. Supports comma-separated list (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2')" }, "script_path_start": { "type": "string", - "description": "mask to filter matching starting path" + "description": "filter by script path prefix. Supports comma-separated list (e.g. 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2')" }, "schedule_path": { "type": "string", @@ -1314,7 +1376,7 @@ export const mcpEndpointTools: EndpointTool[] = [ }, "job_kinds": { "type": "string", - "description": "filter on job kind (values 'preview', 'script', 'dependencies', 'flow') separated by," + "description": "filter by job kind. Supports comma-separated list of values ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies')" }, "suspended": { "type": "boolean", @@ -1326,7 +1388,7 @@ export const mcpEndpointTools: EndpointTool[] = [ }, "tag": { "type": "string", - "description": "filter on jobs with a given tag/worker group" + "description": "filter by tag/worker group. Supports comma-separated list (e.g. 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem')" }, "result": { "type": "string", @@ -1341,8 +1403,8 @@ export const mcpEndpointTools: EndpointTool[] = [ "description": "number of items to return for a given page (default 30, max 100)" }, "trigger_kind": { - "description": "trigger kind (schedule, http, websocket...). Possible values: webhook, default_email, email, schedule, http, websocket, postgres, kafka, nats, mqtt, sqs, gcp", - "type": "string" + "type": "string", + "description": "filter by trigger kind. Supports comma-separated list (e.g. 'schedule,webhook') and negation by prefixing all values with '!' (e.g. '!schedule,!webhook')" }, "is_skipped": { "type": "boolean", @@ -1367,6 +1429,77 @@ export const mcpEndpointTools: EndpointTool[] = [ "is_not_schedule": { "type": "boolean", "description": "is not a scheduled job" + }, + "broad_filter": { + "type": "string", + "description": "broad search across multiple fields (case-insensitive substring match on path, tag, schedule path, trigger kind, label)" + } + }, + "required": [] +}, + bodySchema: undefined, + pathFieldRenames: undefined, + queryFieldRenames: undefined, + bodyFieldRenames: undefined + }, + { + name: "getJob", + description: "get job", + instructions: "", + path: "/w/{workspace}/jobs_u/get/{id}", + method: "GET", + pathParamsSchema: { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "id" + ] +}, + queryParamsSchema: { + "type": "object", + "properties": { + "no_logs": { + "type": "boolean" + }, + "no_code": { + "type": "boolean" + } + }, + "required": [] +}, + bodySchema: undefined, + pathFieldRenames: undefined, + queryFieldRenames: undefined, + bodyFieldRenames: undefined + }, + { + name: "getJobLogs", + description: "get job logs", + instructions: "", + path: "/w/{workspace}/jobs_u/get_logs/{id}", + method: "GET", + pathParamsSchema: { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "id" + ] +}, + queryParamsSchema: { + "type": "object", + "properties": { + "remove_ansi_warnings": { + "type": "boolean" } }, "required": [] @@ -1418,14 +1551,17 @@ export const mcpEndpointTools: EndpointTool[] = [ }, "on_failure": { "type": "string", + "nullable": true, "description": "Path to a script or flow to run when the scheduled job fails" }, "on_failure_times": { "type": "number", + "nullable": true, "description": "Number of consecutive failures before the on_failure handler is triggered (default 1)" }, "on_failure_exact": { "type": "boolean", + "nullable": true, "description": "If true, trigger on_failure handler only on exactly N failures, not on every failure after N" }, "on_failure_extra_args": { @@ -1435,10 +1571,12 @@ export const mcpEndpointTools: EndpointTool[] = [ }, "on_recovery": { "type": "string", + "nullable": true, "description": "Path to a script or flow to run when the schedule recovers after failures" }, "on_recovery_times": { "type": "number", + "nullable": true, "description": "Number of consecutive successes before the on_recovery handler is triggered (default 1)" }, "on_recovery_extra_args": { @@ -1448,6 +1586,7 @@ export const mcpEndpointTools: EndpointTool[] = [ }, "on_success": { "type": "string", + "nullable": true, "description": "Path to a script or flow to run after each successful execution" }, "on_success_extra_args": { @@ -1523,28 +1662,42 @@ export const mcpEndpointTools: EndpointTool[] = [ }, "summary": { "type": "string", + "nullable": true, "description": "Short summary describing the purpose of this schedule" }, "description": { "type": "string", + "nullable": true, "description": "Detailed description of what this schedule does" }, "tag": { "type": "string", + "nullable": true, "description": "Worker tag to route jobs to specific worker groups" }, "paused_until": { "type": "string", + "nullable": true, "format": "date-time", "description": "ISO 8601 datetime until which the schedule is paused. Schedule resumes automatically after this time" }, "cron_version": { "type": "string", + "nullable": true, "description": "Cron parser version. Use 'v2' for extended syntax with additional features" }, "dynamic_skip": { "type": "string", + "nullable": true, "description": "Path to a script that validates scheduled datetimes. Receives scheduled_for datetime and returns boolean to skip (true) or run (false)" + }, + "permissioned_as": { + "type": "string", + "description": "The user or group this schedule runs as. Used during deployment to preserve the original schedule owner." + }, + "preserve_permissioned_as": { + "type": "boolean", + "description": "When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it." } }, "required": [ @@ -1596,14 +1749,17 @@ export const mcpEndpointTools: EndpointTool[] = [ }, "on_failure": { "type": "string", + "nullable": true, "description": "Path to a script or flow to run when the scheduled job fails" }, "on_failure_times": { "type": "number", + "nullable": true, "description": "Number of consecutive failures before the on_failure handler is triggered (default 1)" }, "on_failure_exact": { "type": "boolean", + "nullable": true, "description": "If true, trigger on_failure handler only on exactly N failures, not on every failure after N" }, "on_failure_extra_args": { @@ -1613,10 +1769,12 @@ export const mcpEndpointTools: EndpointTool[] = [ }, "on_recovery": { "type": "string", + "nullable": true, "description": "Path to a script or flow to run when the schedule recovers after failures" }, "on_recovery_times": { "type": "number", + "nullable": true, "description": "Number of consecutive successes before the on_recovery handler is triggered (default 1)" }, "on_recovery_extra_args": { @@ -1626,6 +1784,7 @@ export const mcpEndpointTools: EndpointTool[] = [ }, "on_success": { "type": "string", + "nullable": true, "description": "Path to a script or flow to run after each successful execution" }, "on_success_extra_args": { @@ -1701,28 +1860,44 @@ export const mcpEndpointTools: EndpointTool[] = [ }, "summary": { "type": "string", + "nullable": true, "description": "Short summary describing the purpose of this schedule" }, "description": { "type": "string", + "nullable": true, "description": "Detailed description of what this schedule does" }, "tag": { "type": "string", + "nullable": true, "description": "Worker tag to route jobs to specific worker groups" }, "paused_until": { "type": "string", + "nullable": true, "format": "date-time", "description": "ISO 8601 datetime until which the schedule is paused. Schedule resumes automatically after this time" }, "cron_version": { "type": "string", + "nullable": true, "description": "Cron parser version. Use 'v2' for extended syntax with additional features" }, "dynamic_skip": { "type": "string", + "nullable": true, "description": "Path to a script that validates scheduled datetimes. Receives scheduled_for datetime and returns boolean to skip (true) or run (false)" + }, + "permissioned_as": { + "type": "string", + "nullable": true, + "description": "The user or group this schedule runs as (e.g., 'u/admin' or 'g/mygroup'). Only admins and wm_deployers can set this via preserve_permissioned_as." + }, + "preserve_permissioned_as": { + "type": "boolean", + "nullable": true, + "description": "If true and user is admin/wm_deployers, preserve the provided permissioned_as instead of using the deploying user's identity" } }, "required": [ @@ -1805,7 +1980,7 @@ export const mcpEndpointTools: EndpointTool[] = [ }, "path": { "type": "string", - "description": "filter by path" + "description": "filter by path (script path)" }, "is_flow": { "type": "boolean", @@ -1814,6 +1989,22 @@ export const mcpEndpointTools: EndpointTool[] = [ "path_start": { "type": "string", "description": "filter schedules by path prefix" + }, + "schedule_path": { + "type": "string", + "description": "exact match on the schedule's path" + }, + "description": { + "type": "string", + "description": "pattern match filter for description field (case-insensitive)" + }, + "summary": { + "type": "string", + "description": "pattern match filter for summary field (case-insensitive)" + }, + "broad_filter": { + "type": "string", + "description": "broad search across multiple fields (case-insensitive substring match)" } }, "required": []