diff --git a/backend/generate_mcp_endpoints_tools/generate_mcp_tools.py b/backend/generate_mcp_endpoints_tools/generate_mcp_tools.py index c619650a87..ae1e27a288 100644 --- a/backend/generate_mcp_endpoints_tools/generate_mcp_tools.py +++ b/backend/generate_mcp_endpoints_tools/generate_mcp_tools.py @@ -43,7 +43,7 @@ def load_openapi_spec(file_path: str) -> Dict[str, Any]: print(f"Error loading OpenAPI spec: {e}", file=sys.stderr) sys.exit(1) -def extract_separate_schemas(parameters: List[Dict[str, Any]], request_body: Optional[Dict[str, Any]], spec: Dict[str, Any], required_fields: Optional[List[str]] = None) -> tuple: +def extract_separate_schemas(parameters: List[Dict[str, Any]], request_body: Optional[Dict[str, Any]], spec: Dict[str, Any], required_fields: Optional[List[str]] = None, base_path: str = "") -> tuple: """Extract separate schemas for path parameters, query parameters, and request body.""" path_params_schema = { "type": "object", @@ -63,16 +63,16 @@ def extract_separate_schemas(parameters: List[Dict[str, Any]], request_body: Opt for param in parameters: # Resolve $ref if present if '$ref' in param: - param = resolve_schema_refs(param, spec) - + param = resolve_schema_refs(param, spec, base_path) + param_name = param.get('name', '') param_schema = param.get('schema', {'type': 'string'}) param_required = param.get('required', False) param_description = param.get('description', '') param_in = param.get('in', 'query') - + # Resolve any refs in the parameter schema - param_schema = resolve_schema_refs(param_schema, spec) + param_schema = resolve_schema_refs(param_schema, spec, base_path) # Add description if available if param_description: @@ -93,7 +93,7 @@ def extract_separate_schemas(parameters: List[Dict[str, Any]], request_body: Opt # Process request body if present if request_body: - body_schema = extract_request_body_schema(request_body, spec) + body_schema = extract_request_body_schema(request_body, spec, base_path) # If we have required fields specified and a body schema, update the required array if body_schema and required_fields: @@ -115,68 +115,109 @@ def extract_separate_schemas(parameters: List[Dict[str, Any]], request_body: Opt return (path_params_schema, query_params_schema, body_schema) -def resolve_ref(ref_path: str, spec: Dict[str, Any]) -> Optional[Dict[str, Any]]: - """Resolve a $ref path to the actual schema definition.""" - if not ref_path.startswith('#/'): +# Cache for loaded external files +_external_file_cache: Dict[str, Dict[str, Any]] = {} + +def load_external_file(file_path: str, base_path: str) -> Optional[Dict[str, Any]]: + """Load an external YAML file relative to the base path.""" + if file_path in _external_file_cache: + return _external_file_cache[file_path] + + try: + import yaml + from pathlib import Path + + # Resolve the path relative to the base file + base_dir = Path(base_path).parent + full_path = (base_dir / file_path).resolve() + + with open(full_path, 'r', encoding='utf-8') as f: + content = yaml.safe_load(f) + _external_file_cache[file_path] = content + return content + except Exception as e: + print(f"Warning: Could not load external file {file_path}: {e}", file=sys.stderr) return None - + +def resolve_ref(ref_path: str, spec: Dict[str, Any], base_path: str = "") -> tuple: + """Resolve a $ref path to the actual schema definition. + + Handles both internal refs (#/...) and external file refs (file.yaml#/...). + + Returns a tuple of (resolved_schema, resolved_spec) where resolved_spec is the spec + that should be used for resolving any nested refs within the resolved schema. + """ + # Check if this is an external file reference + if '#' in ref_path and not ref_path.startswith('#'): + # External file reference: "../../openflow.openapi.yaml#/components/schemas/Retry" + file_part, fragment = ref_path.split('#', 1) + external_spec = load_external_file(file_part, base_path) + if external_spec is None: + return None, spec + # Resolve the fragment within the external file, and return external_spec for nested refs + resolved, _ = resolve_ref('#' + fragment, external_spec, base_path) + return resolved, external_spec + + if not ref_path.startswith('#/'): + return None, spec + # Remove the '#/' prefix and split by '/' path_parts = ref_path[2:].split('/') - + # Navigate through the spec following the path current = spec for part in path_parts: if isinstance(current, dict) and part in current: current = current[part] else: - return None - - return current if isinstance(current, dict) else None + return None, spec -def resolve_schema_refs(schema: Dict[str, Any], spec: Dict[str, Any]) -> Dict[str, Any]: + return (current if isinstance(current, dict) else None), spec + +def resolve_schema_refs(schema: Dict[str, Any], spec: Dict[str, Any], base_path: str = "") -> Dict[str, Any]: """Recursively resolve all $ref references in a schema.""" if not isinstance(schema, dict): return schema - + # If this is a $ref, resolve it if '$ref' in schema: ref_path = schema['$ref'] - resolved = resolve_ref(ref_path, spec) + resolved, resolved_spec = resolve_ref(ref_path, spec, base_path) if resolved: - # Recursively resolve any refs in the resolved schema - return resolve_schema_refs(resolved, spec) + # Recursively resolve any refs in the resolved schema using the appropriate spec + return resolve_schema_refs(resolved, resolved_spec, base_path) else: print(f"Warning: Could not resolve $ref: {ref_path}") return schema - + # Recursively process all values in the schema resolved_schema = {} for key, value in schema.items(): if isinstance(value, dict): - resolved_schema[key] = resolve_schema_refs(value, spec) + resolved_schema[key] = resolve_schema_refs(value, spec, base_path) elif isinstance(value, list): resolved_schema[key] = [ - resolve_schema_refs(item, spec) if isinstance(item, dict) else item + resolve_schema_refs(item, spec, base_path) if isinstance(item, dict) else item for item in value ] else: resolved_schema[key] = value - + return resolved_schema -def extract_request_body_schema(request_body: Dict[str, Any], spec: Dict[str, Any]) -> Optional[Dict[str, Any]]: +def extract_request_body_schema(request_body: Dict[str, Any], spec: Dict[str, Any], base_path: str = "") -> Optional[Dict[str, Any]]: """Extract request body schema from OpenAPI requestBody definition and resolve refs.""" if not request_body: return None - + content = request_body.get('content', {}) json_content = content.get('application/json', {}) schema = json_content.get('schema', {}) - + if schema: # Resolve any $ref references in the schema - return resolve_schema_refs(schema, spec) - + return resolve_schema_refs(schema, spec, base_path) + return None def http_method_to_rust(method: str) -> str: @@ -221,7 +262,7 @@ def find_mcp_tools(spec: Dict[str, Any]) -> List[Dict[str, Any]]: return tools -def generate_typescript_code(tools: List[Dict[str, Any]], spec: Dict[str, Any]) -> str: +def generate_typescript_code(tools: List[Dict[str, Any]], spec: Dict[str, Any], base_path: str = "") -> str: """Generate TypeScript code with MCP endpoint tools.""" if not tools: return """// Auto-generated MCP tools from OpenAPI specification @@ -252,7 +293,7 @@ export const mcpEndpointTools: EndpointTool[] = []; # Generate separate schemas path_params_schema, query_params_schema, body_schema = extract_separate_schemas( - tool['parameters'], tool['requestBody'], spec, tool['required_fields'] + tool['parameters'], tool['requestBody'], spec, tool['required_fields'], base_path ) # Convert schemas to TypeScript - use 'as const' for better type inference @@ -297,7 +338,7 @@ export const mcpEndpointTools: EndpointTool[] = [ return typescript_code -def generate_rust_code(tools: List[Dict[str, Any]], spec: Dict[str, Any]) -> str: +def generate_rust_code(tools: List[Dict[str, Any]], spec: Dict[str, Any], base_path: str = "") -> str: """Generate the complete Rust code with MCP tools.""" if not tools: return """// No MCP tools found in the OpenAPI specification @@ -320,7 +361,7 @@ pub fn all_tools() -> Vec { # Generate separate schemas path_params_schema, query_params_schema, body_schema = extract_separate_schemas( - tool['parameters'], tool['requestBody'], spec, tool['required_fields'] + tool['parameters'], tool['requestBody'], spec, tool['required_fields'], base_path ) path_params_rust = schema_to_rust_value(path_params_schema) @@ -386,7 +427,7 @@ def main(): # Generate and write Rust code print(f"Generating Rust code...") - rust_code = generate_rust_code(tools, spec) + rust_code = generate_rust_code(tools, spec, str(openapi_file)) print(f"Writing Rust code to: {rust_output_file}") rust_output_file.parent.mkdir(parents=True, exist_ok=True) @@ -395,7 +436,7 @@ def main(): # Generate and write TypeScript code print(f"Generating TypeScript code...") - typescript_code = generate_typescript_code(tools, spec) + typescript_code = generate_typescript_code(tools, spec, str(openapi_file)) print(f"Writing TypeScript code to: {ts_output_file}") ts_output_file.parent.mkdir(parents=True, exist_ok=True) diff --git a/backend/windmill-api/src/mcp/tools/auto_generated_endpoints.rs b/backend/windmill-api/src/mcp/tools/auto_generated_endpoints.rs index a33b80f9ba..d5425dcd7f 100644 --- a/backend/windmill-api/src/mcp/tools/auto_generated_endpoints.rs +++ b/backend/windmill-api/src/mcp/tools/auto_generated_endpoints.rs @@ -1058,8 +1058,62 @@ You should get the schema of the script or flow before creating the schedule to "description": "Whether the WebSocket error handler is muted" }, "retry": { - "$ref": "../../openflow.openapi.yaml#/components/schemas/Retry", - "description": "The retry configuration for the schedule" + "type": "object", + "description": "Retry configuration for failed module executions", + "properties": { + "constant": { + "type": "object", + "description": "Retry with constant delay between attempts", + "properties": { + "attempts": { + "type": "integer", + "description": "Number of retry attempts" + }, + "seconds": { + "type": "integer", + "description": "Seconds to wait between retries" + } + } + }, + "exponential": { + "type": "object", + "description": "Retry with exponential backoff (delay doubles each time)", + "properties": { + "attempts": { + "type": "integer", + "description": "Number of retry attempts" + }, + "multiplier": { + "type": "integer", + "description": "Multiplier for exponential backoff" + }, + "seconds": { + "type": "integer", + "minimum": 1, + "description": "Initial delay in seconds" + }, + "random_factor": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "Random jitter percentage (0-100) to avoid thundering herd" + } + } + }, + "retry_if": { + "type": "object", + "description": "Conditional retry based on error or result", + "properties": { + "expr": { + "type": "string", + "description": "JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables" + } + }, + "required": [ + "expr" + ] + } + } }, "no_flow_overlap": { "type": "boolean", @@ -1182,8 +1236,62 @@ You should get the schema of the script or flow before updating the schedule to "description": "Whether the WebSocket error handler is muted" }, "retry": { - "$ref": "../../openflow.openapi.yaml#/components/schemas/Retry", - "description": "The retry configuration for the schedule" + "type": "object", + "description": "Retry configuration for failed module executions", + "properties": { + "constant": { + "type": "object", + "description": "Retry with constant delay between attempts", + "properties": { + "attempts": { + "type": "integer", + "description": "Number of retry attempts" + }, + "seconds": { + "type": "integer", + "description": "Seconds to wait between retries" + } + } + }, + "exponential": { + "type": "object", + "description": "Retry with exponential backoff (delay doubles each time)", + "properties": { + "attempts": { + "type": "integer", + "description": "Number of retry attempts" + }, + "multiplier": { + "type": "integer", + "description": "Multiplier for exponential backoff" + }, + "seconds": { + "type": "integer", + "minimum": 1, + "description": "Initial delay in seconds" + }, + "random_factor": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "Random jitter percentage (0-100) to avoid thundering herd" + } + } + }, + "retry_if": { + "type": "object", + "description": "Conditional retry based on error or result", + "properties": { + "expr": { + "type": "string", + "description": "JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables" + } + }, + "required": [ + "expr" + ] + } + } }, "no_flow_overlap": { "type": "boolean", diff --git a/frontend/src/lib/mcpEndpointTools.ts b/frontend/src/lib/mcpEndpointTools.ts index 8f825f79df..4292e8c24d 100644 --- a/frontend/src/lib/mcpEndpointTools.ts +++ b/frontend/src/lib/mcpEndpointTools.ts @@ -1048,8 +1048,62 @@ export const mcpEndpointTools: EndpointTool[] = [ "description": "Whether the WebSocket error handler is muted" }, "retry": { - "$ref": "../../openflow.openapi.yaml#/components/schemas/Retry", - "description": "The retry configuration for the schedule" + "type": "object", + "description": "Retry configuration for failed module executions", + "properties": { + "constant": { + "type": "object", + "description": "Retry with constant delay between attempts", + "properties": { + "attempts": { + "type": "integer", + "description": "Number of retry attempts" + }, + "seconds": { + "type": "integer", + "description": "Seconds to wait between retries" + } + } + }, + "exponential": { + "type": "object", + "description": "Retry with exponential backoff (delay doubles each time)", + "properties": { + "attempts": { + "type": "integer", + "description": "Number of retry attempts" + }, + "multiplier": { + "type": "integer", + "description": "Multiplier for exponential backoff" + }, + "seconds": { + "type": "integer", + "minimum": 1, + "description": "Initial delay in seconds" + }, + "random_factor": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "Random jitter percentage (0-100) to avoid thundering herd" + } + } + }, + "retry_if": { + "type": "object", + "description": "Conditional retry based on error or result", + "properties": { + "expr": { + "type": "string", + "description": "JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables" + } + }, + "required": [ + "expr" + ] + } + } }, "no_flow_overlap": { "type": "boolean", @@ -1169,8 +1223,62 @@ export const mcpEndpointTools: EndpointTool[] = [ "description": "Whether the WebSocket error handler is muted" }, "retry": { - "$ref": "../../openflow.openapi.yaml#/components/schemas/Retry", - "description": "The retry configuration for the schedule" + "type": "object", + "description": "Retry configuration for failed module executions", + "properties": { + "constant": { + "type": "object", + "description": "Retry with constant delay between attempts", + "properties": { + "attempts": { + "type": "integer", + "description": "Number of retry attempts" + }, + "seconds": { + "type": "integer", + "description": "Seconds to wait between retries" + } + } + }, + "exponential": { + "type": "object", + "description": "Retry with exponential backoff (delay doubles each time)", + "properties": { + "attempts": { + "type": "integer", + "description": "Number of retry attempts" + }, + "multiplier": { + "type": "integer", + "description": "Multiplier for exponential backoff" + }, + "seconds": { + "type": "integer", + "minimum": 1, + "description": "Initial delay in seconds" + }, + "random_factor": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "Random jitter percentage (0-100) to avoid thundering herd" + } + } + }, + "retry_if": { + "type": "object", + "description": "Conditional retry based on error or result", + "properties": { + "expr": { + "type": "string", + "description": "JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables" + } + }, + "required": [ + "expr" + ] + } + } }, "no_flow_overlap": { "type": "boolean",