From 7d2c5ceb0fb8609aa58a1e28e61ae8ce86e48208 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 17 Jul 2026 19:22:31 +0200 Subject: [PATCH] fix(flows): make updateFlow body path optional so AI can update flows (#10176) * fix(mcp): default a body field to its same-named path param so updateFlow works Co-Authored-By: Claude Opus 4.8 * docs: trim mcp path-param fallback helper comment Co-Authored-By: Claude Opus 4.8 * refactor(mcp): keep path params un-mangled so update tools take plain `path` Co-Authored-By: Claude Opus 4.8 * fix(flows): default update_flow body path from URL via EditFlow Harmonizes updateFlow with the EditVariable/EditResource/EditApp convention: the flow to update is identified by the URL, so the body path is optional and only needed to rename. Fixes the 422 at the API layer for every client (MCP, the in-app AI chat, raw HTTP), not just the MCP tool schema. Co-Authored-By: Claude Opus 4.8 * refactor(mcp): drop redundant body-path fallback now that the server defaults it Co-Authored-By: Claude Opus 4.8 * docs: fix stale generator comment after removing mcp body-path fallback Co-Authored-By: Claude Opus 4.8 * fix(flows): mark updateFlow body path optional in the openapi contract Adds an `EditFlow` schema (path optional) for the update route so the public contract matches the server; createFlow keeps `OpenFlowWPath` (path required). Also trims two test comments to record constraints rather than history. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .../generate_mcp_tools.py | 31 ++++--- backend/windmill-api-flows/src/flows.rs | 6 +- backend/windmill-api/openapi.yaml | 33 ++++++- .../src/mcp/auto_generated_endpoints.rs | 85 ++++-------------- backend/windmill-api/src/mcp/core.rs | 1 - backend/windmill-api/src/mcp/utils.rs | 65 ++++++++++++-- backend/windmill-mcp/src/server/endpoints.rs | 2 - backend/windmill-types/src/flows.rs | 65 ++++++++++++++ frontend/src/lib/mcpEndpointTools.ts | 86 ++++--------------- 9 files changed, 213 insertions(+), 161 deletions(-) diff --git a/backend/generate_mcp_endpoints_tools/generate_mcp_tools.py b/backend/generate_mcp_endpoints_tools/generate_mcp_tools.py index b120a00d39..20ea1a0a6c 100644 --- a/backend/generate_mcp_endpoints_tools/generate_mcp_tools.py +++ b/backend/generate_mcp_endpoints_tools/generate_mcp_tools.py @@ -165,13 +165,14 @@ def extract_separate_schemas(parameters: List[Dict[str, Any]], request_body: Opt conflicts = (path_keys & query_keys) | (path_keys & body_keys) | (query_keys & body_keys) - path_field_renames = {} query_field_renames = {} body_field_renames = {} for field in conflicts: + # The path parameter keeps the plain name: it identifies the item, is what a + # caller reaches for first, and matches the non-colliding endpoints (`getFlowByPath` + # takes `path`). Only the other locations carry a suffix. schemas_and_renames = [ - (path_params_schema, path_keys, '__path', path_field_renames), (query_params_schema, query_keys, '__query', query_field_renames), (body_schema, body_keys, '__body', body_field_renames), ] @@ -197,11 +198,26 @@ def extract_separate_schemas(parameters: List[Dict[str, Any]], request_body: Opt # Store the reverse mapping: renamed -> original renames_map[new_name] = field + # A body field colliding with a same-named path parameter (`path` on the update + # endpoints) holds the new value and differs only when moving the item, so it must + # stay optional; the server defaults it from the URL path when the caller omits it. + if field in path_keys and field in body_keys and body_schema: + body_name = field + '__body' + if 'required' in body_schema: + body_schema['required'] = [r for r in body_schema['required'] if r != body_name] + prop = body_schema['properties'].get(body_name) + if isinstance(prop, dict): + existing_desc = prop.get('description', '').rstrip('. ') + prop['description'] = ( + f"{existing_desc}. Defaults to `{field}` when omitted; " + f"set it only to change the {field}." + ).lstrip('. ') + # Return None for empty schemas path_params_schema = path_params_schema if path_params_schema and path_params_schema.get('properties') else None query_params_schema = query_params_schema if query_params_schema and query_params_schema.get('properties') else None - return (path_params_schema, query_params_schema, body_schema, path_field_renames, query_field_renames, body_field_renames) + return (path_params_schema, query_params_schema, body_schema, query_field_renames, body_field_renames) # Cache for loaded external files _external_file_cache: Dict[str, Dict[str, Any]] = {} @@ -453,7 +469,7 @@ export const mcpEndpointTools: EndpointTool[] = []; method = tool['method'].upper() # Generate separate schemas - path_params_schema, query_params_schema, body_schema, path_field_renames, query_field_renames, body_field_renames = extract_separate_schemas( + path_params_schema, query_params_schema, body_schema, query_field_renames, body_field_renames = extract_separate_schemas( tool['parameters'], tool['requestBody'], spec, tool['required_fields'], base_path, tool.get('include_fields'), tool.get('opaque_fields'), tool.get('include_query_params') ) @@ -462,7 +478,6 @@ export const mcpEndpointTools: EndpointTool[] = []; path_params_ts = json.dumps(path_params_schema, indent=8) if path_params_schema else "undefined" query_params_ts = json.dumps(query_params_schema, indent=8) if query_params_schema else "undefined" body_schema_ts = json.dumps(body_schema, indent=8) if body_schema else "undefined" - path_field_renames_ts = json.dumps(path_field_renames, indent=8) if path_field_renames else "undefined" query_field_renames_ts = json.dumps(query_field_renames, indent=8) if query_field_renames else "undefined" body_field_renames_ts = json.dumps(body_field_renames, indent=8) if body_field_renames else "undefined" @@ -476,7 +491,6 @@ export const mcpEndpointTools: EndpointTool[] = []; pathParamsSchema: {path_params_ts}, queryParamsSchema: {query_params_ts}, bodySchema: {body_schema_ts}, - pathFieldRenames: {path_field_renames_ts}, queryFieldRenames: {query_field_renames_ts}, bodyFieldRenames: {body_field_renames_ts} }}""" @@ -497,7 +511,6 @@ export interface EndpointTool {{ pathParamsSchema?: object; queryParamsSchema?: object; bodySchema?: object; - pathFieldRenames?: Record; queryFieldRenames?: Record; bodyFieldRenames?: Record; }} @@ -529,7 +542,7 @@ pub fn all_tools() -> Vec {{ method = tool['method'].upper() # Generate separate schemas - path_params_schema, query_params_schema, body_schema, path_field_renames, query_field_renames, body_field_renames = extract_separate_schemas( + path_params_schema, query_params_schema, body_schema, query_field_renames, body_field_renames = extract_separate_schemas( tool['parameters'], tool['requestBody'], spec, tool['required_fields'], base_path, tool.get('include_fields'), tool.get('opaque_fields'), tool.get('include_query_params') ) @@ -537,7 +550,6 @@ pub fn all_tools() -> Vec {{ path_params_rust = schema_to_rust_value(path_params_schema) query_params_rust = schema_to_rust_value(query_params_schema) body_schema_rust = schema_to_rust_value(body_schema) - path_field_renames_rust = schema_to_rust_value(path_field_renames if path_field_renames else None) query_field_renames_rust = schema_to_rust_value(query_field_renames if query_field_renames else None) body_field_renames_rust = schema_to_rust_value(body_field_renames if body_field_renames else None) @@ -551,7 +563,6 @@ pub fn all_tools() -> Vec {{ path_params_schema: {path_params_rust}, query_params_schema: {query_params_rust}, body_schema: {body_schema_rust}, - path_field_renames: {path_field_renames_rust}, query_field_renames: {query_field_renames_rust}, body_field_renames: {body_field_renames_rust}, }}""" diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index a9376d02ed..ff555113c3 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -47,7 +47,7 @@ use windmill_common::HUB_BASE_URL; use windmill_common::{ db::UserDB, error::{self, to_anyhow, Error, JsonResult, Result}, - flows::{Flow, FlowWithStarred, ListFlowQuery, ListableFlow, NewFlow}, + flows::{EditFlow, Flow, FlowWithStarred, ListFlowQuery, ListableFlow, NewFlow}, jobs::JobPayload, schedule::Schedule, utils::{http_get_from_hub, not_found_if_none, paginate, Pagination, RunnableKind, StripPath}, @@ -1007,7 +1007,7 @@ async fn update_flow( Extension(db): Extension, Extension(webhook): Extension, Path((w_id, flow_path)): Path<(String, StripPath)>, - Json(nf): Json, + Json(ef): Json, ) -> Result { if authed.is_operator { return Err(Error::NotAuthorized( @@ -1015,6 +1015,8 @@ async fn update_flow( )); } let flow_path = flow_path.to_path(); + // The URL identifies the flow being updated; the body path is only needed to rename. + let nf = ef.into_new_flow(flow_path); check_scopes(&authed, || format!("flows:write:{}", flow_path))?; if let RuleCheckResult::Blocked(msg) = check_deploy_rules( diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 668cdae8cc..18a9120356 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -10775,7 +10775,7 @@ paths: application/json: schema: allOf: - - $ref: "#/components/schemas/OpenFlowWPath" + - $ref: "#/components/schemas/EditFlow" - type: object properties: deployment_message: @@ -29097,6 +29097,37 @@ components: type: string required: - path + # Like OpenFlowWPath but `path` is optional: on update the flow is identified by + # the URL, so the body path is only needed to rename it. Kept as a separate schema + # (rather than making OpenFlowWPath.path optional) so createFlow still requires path. + EditFlow: + allOf: + - $ref: "../../openflow.openapi.yaml#/components/schemas/OpenFlow" + - type: object + properties: + path: + type: string + tag: + type: string + ws_error_handler_muted: + type: boolean + priority: + type: integer + dedicated_worker: + type: boolean + timeout: + type: number + visible_to_runner_only: + type: boolean + on_behalf_of_email: + type: string + preserve_on_behalf_of: + type: boolean + description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original on_behalf_of_email value instead of overwriting it." + labels: + type: array + items: + type: string FlowPreview: type: object diff --git a/backend/windmill-api/src/mcp/auto_generated_endpoints.rs b/backend/windmill-api/src/mcp/auto_generated_endpoints.rs index d44484f9db..fda5966e30 100644 --- a/backend/windmill-api/src/mcp/auto_generated_endpoints.rs +++ b/backend/windmill-api/src/mcp/auto_generated_endpoints.rs @@ -26,7 +26,6 @@ pub fn all_tools() -> Vec { ] })), body_schema: None, - path_field_renames: None, query_field_renames: None, body_field_renames: None, }, @@ -54,7 +53,6 @@ pub fn all_tools() -> Vec { ] })), body_schema: None, - path_field_renames: None, query_field_renames: None, body_field_renames: None, }, @@ -124,7 +122,6 @@ pub fn all_tools() -> Vec { "description" ] })), - path_field_renames: None, query_field_renames: None, body_field_renames: None, }, @@ -147,7 +144,6 @@ pub fn all_tools() -> Vec { })), query_params_schema: None, body_schema: None, - path_field_renames: None, query_field_renames: None, body_field_renames: None, }, @@ -160,13 +156,12 @@ pub fn all_tools() -> Vec { path_params_schema: Some(serde_json::json!({ "type": "object", "properties": { - "path__path": { - "type": "string", - "description": "(path parameter)" + "path": { + "type": "string" } }, "required": [ - "path__path" + "path" ] })), query_params_schema: Some(serde_json::json!({ @@ -205,12 +200,9 @@ pub fn all_tools() -> Vec { }, "path__body": { "type": "string", - "description": "The path to the variable (body parameter)" + "description": "The path to the variable (body parameter). Defaults to `path` when omitted; set it only to change the path." } } -})), - path_field_renames: Some(serde_json::json!({ - "path__path": "path" })), query_field_renames: None, body_field_renames: Some(serde_json::json!({ @@ -253,7 +245,6 @@ pub fn all_tools() -> Vec { "required": [] })), body_schema: None, - path_field_renames: None, query_field_renames: None, body_field_renames: None, }, @@ -307,7 +298,6 @@ pub fn all_tools() -> Vec { "required": [] })), body_schema: None, - path_field_renames: None, query_field_renames: None, body_field_renames: None, }, @@ -362,7 +352,6 @@ pub fn all_tools() -> Vec { "resource_type" ] })), - path_field_renames: None, query_field_renames: None, body_field_renames: None, }, @@ -385,7 +374,6 @@ pub fn all_tools() -> Vec { })), query_params_schema: None, body_schema: None, - path_field_renames: None, query_field_renames: None, body_field_renames: None, }, @@ -398,13 +386,12 @@ pub fn all_tools() -> Vec { path_params_schema: Some(serde_json::json!({ "type": "object", "properties": { - "path__path": { - "type": "string", - "description": "(path parameter)" + "path": { + "type": "string" } }, "required": [ - "path__path" + "path" ] })), query_params_schema: None, @@ -433,12 +420,9 @@ pub fn all_tools() -> Vec { }, "path__body": { "type": "string", - "description": "The path to the resource (body parameter)" + "description": "The path to the resource (body parameter). Defaults to `path` when omitted; set it only to change the path." } } -})), - path_field_renames: Some(serde_json::json!({ - "path__path": "path" })), query_field_renames: None, body_field_renames: Some(serde_json::json!({ @@ -473,7 +457,6 @@ pub fn all_tools() -> Vec { "required": [] })), body_schema: None, - path_field_renames: None, query_field_renames: None, body_field_renames: None, }, @@ -535,7 +518,6 @@ pub fn all_tools() -> Vec { "required": [] })), body_schema: None, - path_field_renames: None, query_field_renames: None, body_field_renames: None, }, @@ -548,7 +530,6 @@ pub fn all_tools() -> Vec { path_params_schema: None, query_params_schema: None, body_schema: None, - path_field_renames: None, query_field_renames: None, body_field_renames: None, }, @@ -646,7 +627,6 @@ pub fn all_tools() -> Vec { "required": [] })), body_schema: None, - path_field_renames: None, query_field_renames: None, body_field_renames: None, }, @@ -699,7 +679,6 @@ Creates a new version of an existing script when called with the same path and t "language" ] })), - path_field_renames: None, query_field_renames: None, body_field_renames: None, }, @@ -722,7 +701,6 @@ Creates a new version of an existing script when called with the same path and t })), query_params_schema: None, body_schema: None, - path_field_renames: None, query_field_renames: None, body_field_renames: None, }, @@ -754,7 +732,6 @@ Creates a new version of an existing script when called with the same path and t "required": [] })), body_schema: None, - path_field_renames: None, query_field_renames: None, body_field_renames: None, }, @@ -789,7 +766,6 @@ Creates a new version of an existing script when called with the same path and t "required": [] })), body_schema: None, - path_field_renames: None, query_field_renames: None, body_field_renames: None, }, @@ -816,7 +792,6 @@ Creates a new version of an existing script when called with the same path and t "description": "The arguments to pass to the script or flow", "additionalProperties": true })), - path_field_renames: None, query_field_renames: None, body_field_renames: None, }, @@ -886,7 +861,6 @@ Creates a new version of an existing script when called with the same path and t "required": [] })), body_schema: None, - path_field_renames: None, query_field_renames: None, body_field_renames: None, }, @@ -921,7 +895,6 @@ Creates a new version of an existing script when called with the same path and t "required": [] })), body_schema: None, - path_field_renames: None, query_field_renames: None, body_field_renames: None, }, @@ -967,7 +940,6 @@ Creates a new version of an existing script when called with the same path and t ], "description": "Top-level flow definition containing metadata, configuration, and the flow structure" })), - path_field_renames: None, query_field_renames: None, body_field_renames: None, }, @@ -980,13 +952,12 @@ Creates a new version of an existing script when called with the same path and t path_params_schema: Some(serde_json::json!({ "type": "object", "properties": { - "path__path": { - "type": "string", - "description": "(path parameter)" + "path": { + "type": "string" } }, "required": [ - "path__path" + "path" ] })), query_params_schema: None, @@ -1015,18 +986,14 @@ Creates a new version of an existing script when called with the same path and t }, "path__body": { "type": "string", - "description": "(body parameter)" + "description": "(body parameter). Defaults to `path` when omitted; set it only to change the path." } }, "required": [ "summary", - "value", - "path__body" + "value" ], "description": "Top-level flow definition containing metadata, configuration, and the flow structure" -})), - path_field_renames: Some(serde_json::json!({ - "path__path": "path" })), query_field_renames: None, body_field_renames: Some(serde_json::json!({ @@ -1061,7 +1028,6 @@ Creates a new version of an existing script when called with the same path and t "required": [] })), body_schema: None, - path_field_renames: None, query_field_renames: None, body_field_renames: None, }, @@ -1099,7 +1065,6 @@ Creates a new version of an existing script when called with the same path and t "policy" ] })), - path_field_renames: None, query_field_renames: None, body_field_renames: None, }, @@ -1112,13 +1077,12 @@ Creates a new version of an existing script when called with the same path and t path_params_schema: Some(serde_json::json!({ "type": "object", "properties": { - "path__path": { - "type": "string", - "description": "(path parameter)" + "path": { + "type": "string" } }, "required": [ - "path__path" + "path" ] })), query_params_schema: None, @@ -1139,12 +1103,9 @@ Creates a new version of an existing script when called with the same path and t }, "path__body": { "type": "string", - "description": "(body parameter)" + "description": "(body parameter). Defaults to `path` when omitted; set it only to change the path." } } -})), - path_field_renames: Some(serde_json::json!({ - "path__path": "path" })), query_field_renames: None, body_field_renames: Some(serde_json::json!({ @@ -1174,7 +1135,6 @@ Creates a new version of an existing script when called with the same path and t "description": "The arguments to pass to the script or flow", "additionalProperties": true })), - path_field_renames: None, query_field_renames: None, body_field_renames: None, }, @@ -1269,7 +1229,6 @@ Creates a new version of an existing script when called with the same path and t "language" ] })), - path_field_renames: None, query_field_renames: None, body_field_renames: None, }, @@ -1390,7 +1349,6 @@ Creates a new version of an existing script when called with the same path and t "required": [] })), body_schema: None, - path_field_renames: None, query_field_renames: None, body_field_renames: None, }, @@ -1557,7 +1515,6 @@ Creates a new version of an existing script when called with the same path and t "required": [] })), body_schema: None, - path_field_renames: None, query_field_renames: None, body_field_renames: None, }, @@ -1596,7 +1553,6 @@ Creates a new version of an existing script when called with the same path and t "required": [] })), body_schema: None, - path_field_renames: None, query_field_renames: None, body_field_renames: None, }, @@ -1628,7 +1584,6 @@ Creates a new version of an existing script when called with the same path and t "required": [] })), body_schema: None, - path_field_renames: None, query_field_renames: None, body_field_renames: None, }, @@ -1841,7 +1796,6 @@ You should get the schema of the script or flow before creating the schedule to "args" ] })), - path_field_renames: None, query_field_renames: None, body_field_renames: None, }, @@ -2047,7 +2001,6 @@ You should get the schema of the script or flow before updating the schedule to "args" ] })), - path_field_renames: None, query_field_renames: None, body_field_renames: None, }, @@ -2070,7 +2023,6 @@ You should get the schema of the script or flow before updating the schedule to })), query_params_schema: None, body_schema: None, - path_field_renames: None, query_field_renames: None, body_field_renames: None, }, @@ -2102,7 +2054,6 @@ You should get the schema of the script or flow before updating the schedule to "required": [] })), body_schema: None, - path_field_renames: None, query_field_renames: None, body_field_renames: None, }, @@ -2168,7 +2119,6 @@ You should get the schema of the script or flow before updating the schedule to "required": [] })), body_schema: None, - path_field_renames: None, query_field_renames: None, body_field_renames: None, }, @@ -2198,7 +2148,6 @@ You should get the schema of the script or flow before updating the schedule to "required": [] })), body_schema: None, - path_field_renames: None, query_field_renames: None, body_field_renames: None, } diff --git a/backend/windmill-api/src/mcp/core.rs b/backend/windmill-api/src/mcp/core.rs index cdf1f0b3a3..5d78536d86 100644 --- a/backend/windmill-api/src/mcp/core.rs +++ b/backend/windmill-api/src/mcp/core.rs @@ -295,7 +295,6 @@ impl McpBackend for WindmillBackend { workspace_id, args_map, &endpoint_tool.path_params_schema, - &endpoint_tool.path_field_renames, )?; let query_string = build_query_string( args_map, diff --git a/backend/windmill-api/src/mcp/utils.rs b/backend/windmill-api/src/mcp/utils.rs index 70a4c6c1f4..7bca92dd57 100644 --- a/backend/windmill-api/src/mcp/utils.rs +++ b/backend/windmill-api/src/mcp/utils.rs @@ -295,7 +295,7 @@ pub async fn get_hub_script_schema(path: &str, db: &DB) -> Result // ============================================================================ /// Look up the original field name from a field_renames map. -/// field_renames maps renamed_key -> original_key (e.g. {"path__path": "path"}). +/// field_renames maps renamed_key -> original_key (e.g. {"path__body": "path"}). fn get_original_name(renamed_key: &str, field_renames: &Option) -> String { field_renames .as_ref() @@ -373,20 +373,17 @@ pub fn substitute_path_params( workspace_id: &str, args_map: &serde_json::Map, path_schema: &Option, - path_field_renames: &Option, ) -> BackendResult { let mut path_template = path.replace("{workspace}", workspace_id); if let Some(schema) = path_schema { if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) { for (param_name, _) in props { - // param_name may be renamed (e.g. "path__path"), get original for URL placeholder - let original_name = get_original_name(param_name, path_field_renames); - let placeholder = format!("{{{}}}", original_name); + let placeholder = format!("{{{}}}", param_name); match args_map.get(param_name) { Some(param_value) => { if let Some(str_val) = param_value.as_str() { - validate_path_param_value(&original_name, str_val)?; + validate_path_param_value(param_name, str_val)?; path_template = path_template.replace(&placeholder, str_val); } } @@ -675,6 +672,60 @@ mod tests { ); } + // updateFlow-shaped: `path` is both a path parameter and a body field. The path + // parameter keeps the plain name; only the body side is mangled. + fn update_flow_schemas() -> (Option, Option, Option) { + ( + Some(json!({ + "type": "object", + "properties": { "path": { "type": "string" } }, + "required": ["path"] + })), + Some(json!({ + "type": "object", + "properties": { + "summary": { "type": "string" }, + "value": { "type": "object" }, + "path__body": { "type": "string" } + }, + "required": ["summary", "value"] + })), + Some(json!({ "path__body": "path" })), + ) + } + + #[test] + fn build_request_body_maps_body_path_alias_for_rename() { + // The mangled body field carries the *new* path when renaming; it must reach the + // API under its original name `path`. (An omitted `path__body` is intentionally + // absent from the body; the server defaults it from the URL path parameter.) + let (path_schema, body_schema, body_renames) = update_flow_schemas(); + let args: serde_json::Map = json!({ + "path": "f/team/my_flow", + "path__body": "f/team/renamed_flow", + "summary": "s", + "value": {} + }) + .as_object() + .unwrap() + .clone(); + + let body = build_request_body( + "POST", + &args, + &body_schema, + &body_renames, + &path_schema, + &None, + ) + .expect("body should be built"); + assert_eq!( + body.as_object().unwrap().get("path"), + Some(&json!("f/team/renamed_flow")), + "path__body must be sent as `path` so a rename takes effect" + ); + } + #[test] fn build_request_body_get_has_no_body() { let body_schema = Some(json!({ "type": "object", "additionalProperties": true })); @@ -745,7 +796,6 @@ mod tests { "dev", &args, &path_schema, - &None, ); assert!( result.is_err(), @@ -767,7 +817,6 @@ mod tests { "dev", &args, &path_schema, - &None, ) .expect("legitimate path should substitute"); assert_eq!(result, "/w/dev/scripts/get/p/u/alice/my_script"); diff --git a/backend/windmill-mcp/src/server/endpoints.rs b/backend/windmill-mcp/src/server/endpoints.rs index c3a50f9715..9e3adffc0b 100644 --- a/backend/windmill-mcp/src/server/endpoints.rs +++ b/backend/windmill-mcp/src/server/endpoints.rs @@ -21,7 +21,6 @@ pub struct EndpointTool { pub path_params_schema: Option, pub query_params_schema: Option, pub body_schema: Option, - pub path_field_renames: Option, pub query_field_renames: Option, pub body_field_renames: Option, } @@ -215,7 +214,6 @@ mod tests { "required": [] })), body_schema: None, - path_field_renames: None, query_field_renames: None, body_field_renames: None, } diff --git a/backend/windmill-types/src/flows.rs b/backend/windmill-types/src/flows.rs index 0c95b9612b..7e70dc5acd 100644 --- a/backend/windmill-types/src/flows.rs +++ b/backend/windmill-types/src/flows.rs @@ -145,6 +145,57 @@ impl NewFlow { } } +/// Body for updating an existing flow. Mirrors `NewFlow`, but `path` is optional: the +/// flow to update is identified by the URL, so the body only needs `path` to rename it. +/// This matches the `EditVariable` / `EditResource` / `EditApp` convention and lets a +/// caller update in place without restating the path. +#[derive(Debug, Deserialize)] +pub struct EditFlow { + #[serde(default)] + pub path: Option, + pub summary: String, + pub description: Option, + #[serde(deserialize_with = "validate_flow_value")] + pub value: Box, + pub schema: Option, + pub tag: Option, + pub dedicated_worker: Option, + pub timeout: Option, + pub deployment_message: Option, + pub visible_to_runner_only: Option, + pub on_behalf_of_email: Option, + pub preserve_on_behalf_of: Option, + pub ws_error_handler_muted: Option, + #[serde(default)] + pub labels: Option>, + #[serde(default)] + pub skip_draft_deletion: Option, +} + +impl EditFlow { + /// Resolve into a `NewFlow`, defaulting the target path to `current_path` (the flow's + /// URL path) when the body omits it. A body `path` that differs renames the flow. + pub fn into_new_flow(self, current_path: &str) -> NewFlow { + NewFlow { + path: self.path.unwrap_or_else(|| current_path.to_string()), + summary: self.summary, + description: self.description, + value: self.value, + schema: self.schema, + tag: self.tag, + dedicated_worker: self.dedicated_worker, + timeout: self.timeout, + deployment_message: self.deployment_message, + visible_to_runner_only: self.visible_to_runner_only, + on_behalf_of_email: self.on_behalf_of_email, + preserve_on_behalf_of: self.preserve_on_behalf_of, + ws_error_handler_muted: self.ws_error_handler_muted, + labels: self.labels, + skip_draft_deletion: self.skip_draft_deletion, + } + } +} + fn validate_retry(retry: &Retry, module_id: &str) -> anyhow::Result<()> { if retry.exponential.attempts > 0 && retry.exponential.seconds == 0 { return Err(anyhow::anyhow!( @@ -1233,6 +1284,20 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn edit_flow_defaults_path_from_url_and_renames_when_given() { + // An omitted body path resolves to the URL path; an explicit body path renames. + let ef: EditFlow = + serde_json::from_value(json!({ "summary": "s", "value": { "modules": [] } })).unwrap(); + assert_eq!(ef.into_new_flow("f/team/my_flow").path, "f/team/my_flow"); + + let ef: EditFlow = serde_json::from_value( + json!({ "path": "f/team/renamed", "summary": "s", "value": { "modules": [] } }), + ) + .unwrap(); + assert_eq!(ef.into_new_flow("f/team/my_flow").path, "f/team/renamed"); + } + #[test] fn flow_value_ignores_notes_and_groups() { // FlowValue should parse successfully even when notes/groups are present — diff --git a/frontend/src/lib/mcpEndpointTools.ts b/frontend/src/lib/mcpEndpointTools.ts index 8a46163009..336c24cefc 100644 --- a/frontend/src/lib/mcpEndpointTools.ts +++ b/frontend/src/lib/mcpEndpointTools.ts @@ -10,7 +10,6 @@ export interface EndpointTool { pathParamsSchema?: object; queryParamsSchema?: object; bodySchema?: object; - pathFieldRenames?: Record; queryFieldRenames?: Record; bodyFieldRenames?: Record; } @@ -36,7 +35,6 @@ export const mcpEndpointTools: EndpointTool[] = [ ] }, bodySchema: undefined, - pathFieldRenames: undefined, queryFieldRenames: undefined, bodyFieldRenames: undefined }, @@ -64,7 +62,6 @@ export const mcpEndpointTools: EndpointTool[] = [ ] }, bodySchema: undefined, - pathFieldRenames: undefined, queryFieldRenames: undefined, bodyFieldRenames: undefined }, @@ -134,7 +131,6 @@ export const mcpEndpointTools: EndpointTool[] = [ "description" ] }, - pathFieldRenames: undefined, queryFieldRenames: undefined, bodyFieldRenames: undefined }, @@ -157,7 +153,6 @@ export const mcpEndpointTools: EndpointTool[] = [ }, queryParamsSchema: undefined, bodySchema: undefined, - pathFieldRenames: undefined, queryFieldRenames: undefined, bodyFieldRenames: undefined }, @@ -170,13 +165,12 @@ export const mcpEndpointTools: EndpointTool[] = [ pathParamsSchema: { "type": "object", "properties": { - "path__path": { - "type": "string", - "description": "(path parameter)" + "path": { + "type": "string" } }, "required": [ - "path__path" + "path" ] }, queryParamsSchema: { @@ -215,12 +209,9 @@ export const mcpEndpointTools: EndpointTool[] = [ }, "path__body": { "type": "string", - "description": "The path to the variable (body parameter)" + "description": "The path to the variable (body parameter). Defaults to `path` when omitted; set it only to change the path." } } -}, - pathFieldRenames: { - "path__path": "path" }, queryFieldRenames: undefined, bodyFieldRenames: { @@ -263,7 +254,6 @@ export const mcpEndpointTools: EndpointTool[] = [ "required": [] }, bodySchema: undefined, - pathFieldRenames: undefined, queryFieldRenames: undefined, bodyFieldRenames: undefined }, @@ -317,7 +307,6 @@ export const mcpEndpointTools: EndpointTool[] = [ "required": [] }, bodySchema: undefined, - pathFieldRenames: undefined, queryFieldRenames: undefined, bodyFieldRenames: undefined }, @@ -372,7 +361,6 @@ export const mcpEndpointTools: EndpointTool[] = [ "resource_type" ] }, - pathFieldRenames: undefined, queryFieldRenames: undefined, bodyFieldRenames: undefined }, @@ -395,7 +383,6 @@ export const mcpEndpointTools: EndpointTool[] = [ }, queryParamsSchema: undefined, bodySchema: undefined, - pathFieldRenames: undefined, queryFieldRenames: undefined, bodyFieldRenames: undefined }, @@ -408,13 +395,12 @@ export const mcpEndpointTools: EndpointTool[] = [ pathParamsSchema: { "type": "object", "properties": { - "path__path": { - "type": "string", - "description": "(path parameter)" + "path": { + "type": "string" } }, "required": [ - "path__path" + "path" ] }, queryParamsSchema: undefined, @@ -443,12 +429,9 @@ export const mcpEndpointTools: EndpointTool[] = [ }, "path__body": { "type": "string", - "description": "The path to the resource (body parameter)" + "description": "The path to the resource (body parameter). Defaults to `path` when omitted; set it only to change the path." } } -}, - pathFieldRenames: { - "path__path": "path" }, queryFieldRenames: undefined, bodyFieldRenames: { @@ -483,7 +466,6 @@ export const mcpEndpointTools: EndpointTool[] = [ "required": [] }, bodySchema: undefined, - pathFieldRenames: undefined, queryFieldRenames: undefined, bodyFieldRenames: undefined }, @@ -545,7 +527,6 @@ export const mcpEndpointTools: EndpointTool[] = [ "required": [] }, bodySchema: undefined, - pathFieldRenames: undefined, queryFieldRenames: undefined, bodyFieldRenames: undefined }, @@ -558,7 +539,6 @@ export const mcpEndpointTools: EndpointTool[] = [ pathParamsSchema: undefined, queryParamsSchema: undefined, bodySchema: undefined, - pathFieldRenames: undefined, queryFieldRenames: undefined, bodyFieldRenames: undefined }, @@ -656,7 +636,6 @@ export const mcpEndpointTools: EndpointTool[] = [ "required": [] }, bodySchema: undefined, - pathFieldRenames: undefined, queryFieldRenames: undefined, bodyFieldRenames: undefined }, @@ -708,7 +687,6 @@ export const mcpEndpointTools: EndpointTool[] = [ "language" ] }, - pathFieldRenames: undefined, queryFieldRenames: undefined, bodyFieldRenames: undefined }, @@ -731,7 +709,6 @@ export const mcpEndpointTools: EndpointTool[] = [ }, queryParamsSchema: undefined, bodySchema: undefined, - pathFieldRenames: undefined, queryFieldRenames: undefined, bodyFieldRenames: undefined }, @@ -763,7 +740,6 @@ export const mcpEndpointTools: EndpointTool[] = [ "required": [] }, bodySchema: undefined, - pathFieldRenames: undefined, queryFieldRenames: undefined, bodyFieldRenames: undefined }, @@ -798,7 +774,6 @@ export const mcpEndpointTools: EndpointTool[] = [ "required": [] }, bodySchema: undefined, - pathFieldRenames: undefined, queryFieldRenames: undefined, bodyFieldRenames: undefined }, @@ -825,7 +800,6 @@ export const mcpEndpointTools: EndpointTool[] = [ "description": "The arguments to pass to the script or flow", "additionalProperties": true }, - pathFieldRenames: undefined, queryFieldRenames: undefined, bodyFieldRenames: undefined }, @@ -895,7 +869,6 @@ export const mcpEndpointTools: EndpointTool[] = [ "required": [] }, bodySchema: undefined, - pathFieldRenames: undefined, queryFieldRenames: undefined, bodyFieldRenames: undefined }, @@ -930,7 +903,6 @@ export const mcpEndpointTools: EndpointTool[] = [ "required": [] }, bodySchema: undefined, - pathFieldRenames: undefined, queryFieldRenames: undefined, bodyFieldRenames: undefined }, @@ -976,7 +948,6 @@ export const mcpEndpointTools: EndpointTool[] = [ ], "description": "Top-level flow definition containing metadata, configuration, and the flow structure" }, - pathFieldRenames: undefined, queryFieldRenames: undefined, bodyFieldRenames: undefined }, @@ -989,13 +960,12 @@ export const mcpEndpointTools: EndpointTool[] = [ pathParamsSchema: { "type": "object", "properties": { - "path__path": { - "type": "string", - "description": "(path parameter)" + "path": { + "type": "string" } }, "required": [ - "path__path" + "path" ] }, queryParamsSchema: undefined, @@ -1024,18 +994,14 @@ export const mcpEndpointTools: EndpointTool[] = [ }, "path__body": { "type": "string", - "description": "(body parameter)" + "description": "(body parameter). Defaults to `path` when omitted; set it only to change the path." } }, "required": [ "summary", - "value", - "path__body" + "value" ], "description": "Top-level flow definition containing metadata, configuration, and the flow structure" -}, - pathFieldRenames: { - "path__path": "path" }, queryFieldRenames: undefined, bodyFieldRenames: { @@ -1070,7 +1036,6 @@ export const mcpEndpointTools: EndpointTool[] = [ "required": [] }, bodySchema: undefined, - pathFieldRenames: undefined, queryFieldRenames: undefined, bodyFieldRenames: undefined }, @@ -1108,7 +1073,6 @@ export const mcpEndpointTools: EndpointTool[] = [ "policy" ] }, - pathFieldRenames: undefined, queryFieldRenames: undefined, bodyFieldRenames: undefined }, @@ -1121,13 +1085,12 @@ export const mcpEndpointTools: EndpointTool[] = [ pathParamsSchema: { "type": "object", "properties": { - "path__path": { - "type": "string", - "description": "(path parameter)" + "path": { + "type": "string" } }, "required": [ - "path__path" + "path" ] }, queryParamsSchema: undefined, @@ -1148,12 +1111,9 @@ export const mcpEndpointTools: EndpointTool[] = [ }, "path__body": { "type": "string", - "description": "(body parameter)" + "description": "(body parameter). Defaults to `path` when omitted; set it only to change the path." } } -}, - pathFieldRenames: { - "path__path": "path" }, queryFieldRenames: undefined, bodyFieldRenames: { @@ -1183,7 +1143,6 @@ export const mcpEndpointTools: EndpointTool[] = [ "description": "The arguments to pass to the script or flow", "additionalProperties": true }, - pathFieldRenames: undefined, queryFieldRenames: undefined, bodyFieldRenames: undefined }, @@ -1278,7 +1237,6 @@ export const mcpEndpointTools: EndpointTool[] = [ "language" ] }, - pathFieldRenames: undefined, queryFieldRenames: undefined, bodyFieldRenames: undefined }, @@ -1399,7 +1357,6 @@ export const mcpEndpointTools: EndpointTool[] = [ "required": [] }, bodySchema: undefined, - pathFieldRenames: undefined, queryFieldRenames: undefined, bodyFieldRenames: undefined }, @@ -1566,7 +1523,6 @@ export const mcpEndpointTools: EndpointTool[] = [ "required": [] }, bodySchema: undefined, - pathFieldRenames: undefined, queryFieldRenames: undefined, bodyFieldRenames: undefined }, @@ -1605,7 +1561,6 @@ export const mcpEndpointTools: EndpointTool[] = [ "required": [] }, bodySchema: undefined, - pathFieldRenames: undefined, queryFieldRenames: undefined, bodyFieldRenames: undefined }, @@ -1637,7 +1592,6 @@ export const mcpEndpointTools: EndpointTool[] = [ "required": [] }, bodySchema: undefined, - pathFieldRenames: undefined, queryFieldRenames: undefined, bodyFieldRenames: undefined }, @@ -1847,7 +1801,6 @@ export const mcpEndpointTools: EndpointTool[] = [ "args" ] }, - pathFieldRenames: undefined, queryFieldRenames: undefined, bodyFieldRenames: undefined }, @@ -2050,7 +2003,6 @@ export const mcpEndpointTools: EndpointTool[] = [ "args" ] }, - pathFieldRenames: undefined, queryFieldRenames: undefined, bodyFieldRenames: undefined }, @@ -2073,7 +2025,6 @@ export const mcpEndpointTools: EndpointTool[] = [ }, queryParamsSchema: undefined, bodySchema: undefined, - pathFieldRenames: undefined, queryFieldRenames: undefined, bodyFieldRenames: undefined }, @@ -2105,7 +2056,6 @@ export const mcpEndpointTools: EndpointTool[] = [ "required": [] }, bodySchema: undefined, - pathFieldRenames: undefined, queryFieldRenames: undefined, bodyFieldRenames: undefined }, @@ -2171,7 +2121,6 @@ export const mcpEndpointTools: EndpointTool[] = [ "required": [] }, bodySchema: undefined, - pathFieldRenames: undefined, queryFieldRenames: undefined, bodyFieldRenames: undefined }, @@ -2201,7 +2150,6 @@ export const mcpEndpointTools: EndpointTool[] = [ "required": [] }, bodySchema: undefined, - pathFieldRenames: undefined, queryFieldRenames: undefined, bodyFieldRenames: undefined }