feat(mcp): add endpoint tools for scripts, flows, apps, and jobs (#7859)

* add endpoints

* feat: add MCP tools for script/flow/app CRUD and run endpoints with field filtering

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: convert enum arrays to description text in MCP tool schemas

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: auto-detect and rename conflicting parameter names across MCP tool schemas

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: use two-pass approach in convert_enums_to_descriptions to preserve dict ordering

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: add MCP instructions to createScript, runScriptByPath, and runFlowByPath

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: add query param exclusion for MCP tools, slim down run endpoints

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: preserve additional top-level keys in allOf schema flattening

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: replace x-mcp-tool-exclude-query-params with x-mcp-tool-include-query-params

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: replace empty {} schemas with valid JSON Schema draft 2020-12 equivalents

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: revert openapi value:{} changes, sanitize empty schemas in generator instead

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
centdix
2026-02-10 12:30:39 +01:00
committed by GitHub
parent 8e19f9652d
commit 03eb16a7c6
7 changed files with 1434 additions and 301 deletions

View File

@@ -29,7 +29,39 @@ 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, base_path: str = "") -> tuple:
def flatten_allof_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
"""Flatten an allOf schema into a single object schema by merging all properties."""
if 'allOf' not in schema:
return schema
merged = {"type": "object", "properties": {}, "required": []}
def collect_from(s: Dict[str, Any]):
if 'allOf' in s:
for item in s['allOf']:
if isinstance(item, dict):
collect_from(item)
if 'properties' in s:
merged['properties'].update(s['properties'])
if 'required' in s and isinstance(s['required'], list):
merged['required'].extend(s['required'])
if 'description' in s and 'description' not in merged:
merged['description'] = s['description']
collect_from(schema)
# Preserve additional top-level keys from the original schema
preserved_keys = {'additionalProperties', 'title', 'nullable', 'default', 'example'}
for key in preserved_keys:
if key in schema and key not in merged:
merged[key] = schema[key]
if not merged['required']:
del merged['required']
return merged
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 = "", include_fields: Optional[List[str]] = None, opaque_fields: Optional[List[str]] = None, include_query_params: Optional[List[str]] = None) -> tuple:
"""Extract separate schemas for path parameters, query parameters, and request body."""
path_params_schema = {
"type": "object",
@@ -72,7 +104,7 @@ def extract_separate_schemas(parameters: List[Dict[str, Any]], request_body: Opt
path_params_schema['properties'][param_name] = param_schema
if param_required:
path_params_schema['required'].append(param_name)
elif param_in == 'query':
elif param_in == 'query' and (include_query_params is None or param_name in include_query_params):
query_params_schema['properties'][param_name] = param_schema
if param_required:
query_params_schema['required'].append(param_name)
@@ -80,7 +112,28 @@ 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, base_path)
# Flatten allOf schemas into a single object schema for filtering
if body_schema and (include_fields is not None or opaque_fields):
body_schema = flatten_allof_schema(body_schema)
# Apply include_fields filter: only keep listed top-level properties
if body_schema and include_fields is not None and 'properties' in body_schema:
body_schema['properties'] = {
k: v for k, v in body_schema['properties'].items()
if k in include_fields
}
if 'required' in body_schema:
body_schema['required'] = [
r for r in body_schema['required'] if r in include_fields
]
# Apply opaque_fields: simplify listed properties to {"type": "object"}
if body_schema and opaque_fields and 'properties' in body_schema:
for field in opaque_fields:
if field in body_schema['properties']:
body_schema['properties'][field] = {"type": "object"}
# If we have required fields specified and a body schema, update the required array
if body_schema and required_fields:
if 'required' not in body_schema:
@@ -95,11 +148,60 @@ def extract_separate_schemas(parameters: List[Dict[str, Any]], request_body: Opt
# Log warning when a required field is missing from schema properties
print(f"Warning: Required field '{field}' not found in body schema properties", file=sys.stderr)
# Sanitize empty schemas for JSON Schema draft 2020-12 compliance
path_params_schema = sanitize_empty_schemas(path_params_schema)
query_params_schema = sanitize_empty_schemas(query_params_schema)
body_schema = sanitize_empty_schemas(body_schema)
# Convert enums to descriptions for client compatibility
path_params_schema = convert_enums_to_descriptions(path_params_schema)
query_params_schema = convert_enums_to_descriptions(query_params_schema)
body_schema = convert_enums_to_descriptions(body_schema)
# Detect overlapping property names across schemas and rename with suffixes
path_keys = set(path_params_schema['properties'].keys()) if path_params_schema and path_params_schema.get('properties') else set()
query_keys = set(query_params_schema['properties'].keys()) if query_params_schema and query_params_schema.get('properties') else set()
body_keys = set(body_schema['properties'].keys()) if body_schema and body_schema.get('properties') else set()
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:
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),
]
for schema, keys, suffix, renames_map in schemas_and_renames:
if field in keys and schema and 'properties' in schema:
new_name = field + suffix
# Rename in properties
schema['properties'][new_name] = schema['properties'].pop(field)
# Update description to clarify the renamed field
if 'description' not in schema['properties'][new_name]:
schema['properties'][new_name] = dict(schema['properties'][new_name])
prop = schema['properties'][new_name]
if isinstance(prop, dict):
existing_desc = prop.get('description', '')
location = suffix.lstrip('_')
if not existing_desc:
prop['description'] = f"({location} parameter)"
else:
prop['description'] = f"{existing_desc} ({location} parameter)"
# Rename in required array
if 'required' in schema and field in schema['required']:
schema['required'] = [new_name if r == field else r for r in schema['required']]
# Store the reverse mapping: renamed -> original
renames_map[new_name] = field
# Return None for empty schemas
path_params_schema = path_params_schema if path_params_schema['properties'] else None
query_params_schema = query_params_schema if query_params_schema['properties'] else None
return (path_params_schema, query_params_schema, body_schema)
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)
# Cache for loaded external files
_external_file_cache: Dict[str, Dict[str, Any]] = {}
@@ -160,18 +262,25 @@ def resolve_ref(ref_path: str, spec: Dict[str, Any], base_path: str = "") -> tup
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]:
def resolve_schema_refs(schema: Dict[str, Any], spec: Dict[str, Any], base_path: str = "", _visited_refs: Optional[set] = None) -> Dict[str, Any]:
"""Recursively resolve all $ref references in a schema."""
if _visited_refs is None:
_visited_refs = set()
if not isinstance(schema, dict):
return schema
# If this is a $ref, resolve it
if '$ref' in schema:
ref_path = schema['$ref']
if ref_path in _visited_refs:
# Circular reference detected - return empty object to break the cycle
return {"type": "object"}
_visited_refs = _visited_refs | {ref_path}
resolved, resolved_spec = resolve_ref(ref_path, spec, base_path)
if resolved:
# Recursively resolve any refs in the resolved schema using the appropriate spec
return resolve_schema_refs(resolved, resolved_spec, base_path)
return resolve_schema_refs(resolved, resolved_spec, base_path, _visited_refs)
else:
print(f"Warning: Could not resolve $ref: {ref_path}")
return schema
@@ -180,10 +289,10 @@ def resolve_schema_refs(schema: Dict[str, Any], spec: Dict[str, Any], base_path:
resolved_schema = {}
for key, value in schema.items():
if isinstance(value, dict):
resolved_schema[key] = resolve_schema_refs(value, spec, base_path)
resolved_schema[key] = resolve_schema_refs(value, spec, base_path, _visited_refs)
elif isinstance(value, list):
resolved_schema[key] = [
resolve_schema_refs(item, spec, base_path) if isinstance(item, dict) else item
resolve_schema_refs(item, spec, base_path, _visited_refs) if isinstance(item, dict) else item
for item in value
]
else:
@@ -191,6 +300,56 @@ def resolve_schema_refs(schema: Dict[str, Any], spec: Dict[str, Any], base_path:
return resolved_schema
def convert_enums_to_descriptions(schema: Any) -> Any:
"""Recursively convert enum arrays into description text to avoid client compatibility issues."""
if isinstance(schema, list):
return [convert_enums_to_descriptions(item) for item in schema]
if not isinstance(schema, dict):
return schema
result = {}
enum_value = None
# First pass: copy all non-enum keys so 'description' is available before enum processing
for key, value in schema.items():
if key == 'enum':
enum_value = value
else:
result[key] = convert_enums_to_descriptions(value)
# Second pass: process enum using the already-copied description
if enum_value is not None:
values_str = ', '.join(str(v) for v in enum_value)
existing = result.get('description', '')
enum_desc = f"Possible values: {values_str}"
result['description'] = f"{existing}. {enum_desc}" if existing else enum_desc
return result
def sanitize_empty_schemas(schema: Any) -> Any:
"""Replace empty {} schemas with valid JSON Schema draft 2020-12 equivalents.
In OpenAPI, {} means 'any value' but strict JSON Schema validators (e.g. Claude's API)
reject empty objects. This converts them to proper schemas.
"""
if isinstance(schema, list):
return [sanitize_empty_schemas(item) for item in schema]
if not isinstance(schema, dict):
return schema
result = {}
for key, value in schema.items():
if key == 'additionalProperties' and isinstance(value, dict) and len(value) == 0:
result[key] = True
elif key == 'properties' and isinstance(value, dict):
# properties is a map of name -> schema; sanitize each property schema
result[key] = {
k: {"type": "object"} if isinstance(v, dict) and len(v) == 0 else sanitize_empty_schemas(v)
for k, v in value.items()
}
else:
result[key] = sanitize_empty_schemas(value)
return result
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:
@@ -243,6 +402,9 @@ def find_mcp_tools(spec: Dict[str, Any]) -> List[Dict[str, Any]]:
'parameters': operation.get('parameters', []),
'requestBody': operation.get('requestBody'),
'required_fields': operation.get('x-mcp-required-fields', []),
'include_fields': operation.get('x-mcp-tool-include-fields'),
'opaque_fields': operation.get('x-mcp-tool-opaque-fields'),
'include_query_params': operation.get('x-mcp-tool-include-query-params'),
}
tools.append(tool)
@@ -278,14 +440,18 @@ export const mcpEndpointTools: EndpointTool[] = [];
method = tool['method'].upper()
# Generate separate schemas
path_params_schema, query_params_schema, body_schema = extract_separate_schemas(
tool['parameters'], tool['requestBody'], spec, tool['required_fields'], base_path
path_params_schema, query_params_schema, body_schema, path_field_renames, 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')
)
# Convert schemas to TypeScript - use 'as const' for better type inference
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"
# Generate tool definition
tool_def = f""" {{
@@ -296,7 +462,10 @@ export const mcpEndpointTools: EndpointTool[] = [];
method: "{method}",
pathParamsSchema: {path_params_ts},
queryParamsSchema: {query_params_ts},
bodySchema: {body_schema_ts}
bodySchema: {body_schema_ts},
pathFieldRenames: {path_field_renames_ts},
queryFieldRenames: {query_field_renames_ts},
bodyFieldRenames: {body_field_renames_ts}
}}"""
tool_definitions.append(tool_def)
@@ -315,6 +484,9 @@ export interface EndpointTool {{
pathParamsSchema?: object;
queryParamsSchema?: object;
bodySchema?: object;
pathFieldRenames?: Record<string, string>;
queryFieldRenames?: Record<string, string>;
bodyFieldRenames?: Record<string, string>;
}}
export const mcpEndpointTools: EndpointTool[] = [
@@ -344,13 +516,17 @@ pub fn all_tools() -> Vec<EndpointTool> {{
method = tool['method'].upper()
# Generate separate schemas
path_params_schema, query_params_schema, body_schema = extract_separate_schemas(
tool['parameters'], tool['requestBody'], spec, tool['required_fields'], base_path
path_params_schema, query_params_schema, body_schema, path_field_renames, 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')
)
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)
# Generate tool definition
tool_def = f""" EndpointTool {{
@@ -362,6 +538,9 @@ pub fn all_tools() -> Vec<EndpointTool> {{
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},
}}"""
tool_definitions.append(tool_def)

View File

@@ -5932,6 +5932,17 @@ paths:
post:
summary: create script
operationId: createScript
x-mcp-tool: true
x-mcp-instructions: "To create a script, specify the path (e.g., 'f/my_folder/my_script'), the content (source code), and the language. For TypeScript, use 'bun' unless deno-specific APIs are needed."
x-mcp-tool-include-fields:
- path
- content
- language
- summary
- description
- kind
- tag
- deployment_message
tags:
- script
parameters:
@@ -6208,6 +6219,7 @@ paths:
post:
summary: delete script by hash (erase content but keep hash, require admin)
operationId: deleteScriptByHash
x-mcp-tool: true
tags:
- script
parameters:
@@ -6225,6 +6237,7 @@ paths:
post:
summary: delete script at a given path (require admin)
operationId: deleteScriptByPath
x-mcp-tool: true
tags:
- script
parameters:
@@ -6615,6 +6628,9 @@ paths:
post:
summary: run script by path
operationId: runScriptByPath
x-mcp-tool: true
x-mcp-instructions: "You should first use getScriptByPath to retrieve the script's schema and understand what arguments are expected."
x-mcp-tool-include-query-params: []
tags:
- job
parameters:
@@ -7557,6 +7573,18 @@ paths:
post:
summary: create flow
operationId: createFlow
x-mcp-tool: true
x-mcp-tool-include-fields:
- path
- summary
- description
- value
- schema
- tag
- deployment_message
x-mcp-tool-opaque-fields:
- value
- schema
tags:
- flow
parameters:
@@ -7587,6 +7615,18 @@ paths:
post:
summary: update flow
operationId: updateFlow
x-mcp-tool: true
x-mcp-tool-include-fields:
- path
- summary
- description
- value
- schema
- tag
- deployment_message
x-mcp-tool-opaque-fields:
- value
- schema
tags:
- flow
parameters:
@@ -7644,6 +7684,7 @@ paths:
delete:
summary: delete flow by path
operationId: deleteFlowByPath
x-mcp-tool: true
tags:
- flow
parameters:
@@ -7886,6 +7927,16 @@ paths:
post:
summary: create app
operationId: createApp
x-mcp-tool: true
x-mcp-tool-include-fields:
- path
- value
- summary
- policy
- deployment_message
x-mcp-tool-opaque-fields:
- value
- policy
tags:
- app
parameters:
@@ -8233,6 +8284,16 @@ paths:
post:
summary: update app
operationId: updateApp
x-mcp-tool: true
x-mcp-tool-include-fields:
- path
- value
- summary
- policy
- deployment_message
x-mcp-tool-opaque-fields:
- value
- policy
tags:
- app
parameters:
@@ -8514,6 +8575,9 @@ paths:
post:
summary: run flow by path
operationId: runFlowByPath
x-mcp-tool: true
x-mcp-instructions: "You should first use getFlowByPath to retrieve the flow's schema and understand what arguments are expected."
x-mcp-tool-include-query-params: []
tags:
- job
parameters:
@@ -17909,7 +17973,7 @@ components:
ScriptArgs:
type: object
description: The arguments to pass to the script or flow
additionalProperties: {}
additionalProperties: true
Input:
type: object
@@ -21916,8 +21980,7 @@ components:
created_at:
type: string
format: date-time
value:
type: object
value: {}
policy:
$ref: "#/components/schemas/Policy"
execution_mode:
@@ -22466,8 +22529,7 @@ components:
properties:
name:
type: string
value:
type: object
value: {}
required:
- name
- value

File diff suppressed because it is too large Load Diff

View File

@@ -323,16 +323,25 @@ 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, &endpoint_tool.query_params_schema);
let query_string = build_query_string(
args_map,
&endpoint_tool.query_params_schema,
&endpoint_tool.query_field_renames,
);
let full_url = format!(
"{}/api{}{}",
self.base_internal_url, path_template, query_string
);
// Prepare request body
let body_json =
build_request_body(&endpoint_tool.method, args_map, &endpoint_tool.body_schema);
let body_json = build_request_body(
&endpoint_tool.method,
args_map,
&endpoint_tool.body_schema,
&endpoint_tool.body_field_renames,
);
// Create and execute request
let response = create_http_request(

View File

@@ -242,19 +242,34 @@ pub async fn get_hub_script_schema(path: &str, db: &DB) -> Result<Option<Schema>
// HTTP request utilities for endpoint tools
// ============================================================================
/// Look up the original field name from a field_renames map.
/// field_renames maps renamed_key -> original_key (e.g. {"path__path": "path"}).
fn get_original_name(renamed_key: &str, field_renames: &Option<Value>) -> String {
field_renames
.as_ref()
.and_then(|v| v.as_object())
.and_then(|m| m.get(renamed_key))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| renamed_key.to_string())
}
/// Substitute path parameters in the URL template
pub fn substitute_path_params(
path: &str,
workspace_id: &str,
args_map: &serde_json::Map<String, Value>,
path_schema: &Option<Value>,
path_field_renames: &Option<Value>,
) -> BackendResult<String> {
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 {
let placeholder = format!("{{{}}}", param_name);
// 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);
match args_map.get(param_name) {
Some(param_value) => {
if let Some(str_val) = param_value.as_str() {
@@ -280,6 +295,7 @@ pub fn substitute_path_params(
pub fn build_query_string(
args_map: &serde_json::Map<String, Value>,
query_schema: &Option<Value>,
query_field_renames: &Option<Value>,
) -> String {
let Some(schema) = query_schema else {
return String::new();
@@ -295,11 +311,13 @@ pub fn build_query_string(
.get(param_name)
.filter(|v| !v.is_null())
.map(|value| {
// Use the original name for the query parameter key
let original_name = get_original_name(param_name, query_field_renames);
let value_str = value.to_string();
let str_val = value_str.trim_matches('"');
format!(
"{}={}",
urlencoding::encode(param_name),
urlencoding::encode(&original_name),
urlencoding::encode(str_val)
)
})
@@ -318,6 +336,7 @@ pub fn build_request_body(
method: &str,
args_map: &serde_json::Map<String, Value>,
body_schema: &Option<Value>,
body_field_renames: &Option<Value>,
) -> Option<Value> {
if method == "GET" {
return None;
@@ -329,9 +348,11 @@ pub fn build_request_body(
let body_map: serde_json::Map<String, Value> = props
.keys()
.filter_map(|param_name| {
args_map
.get(param_name)
.map(|value| (param_name.clone(), value.clone()))
args_map.get(param_name).map(|value| {
// Use the original name as the key in the request body
let original_name = get_original_name(param_name, body_field_renames);
(original_name, value.clone())
})
})
.collect();

View File

@@ -21,6 +21,9 @@ pub struct EndpointTool {
pub path_params_schema: Option<serde_json::Value>,
pub query_params_schema: Option<serde_json::Value>,
pub body_schema: Option<serde_json::Value>,
pub path_field_renames: Option<serde_json::Value>,
pub query_field_renames: Option<serde_json::Value>,
pub body_field_renames: Option<serde_json::Value>,
}
/// Convert a single endpoint tool to MCP tool
@@ -100,7 +103,9 @@ fn merge_schema_into(
if let Some(required) = schema.get("required").and_then(|r| r.as_array()) {
for req in required.iter().filter_map(|r| r.as_str()) {
combined_required.push(req.to_string());
if !combined_required.contains(&req.to_string()) {
combined_required.push(req.to_string());
}
}
}
}

File diff suppressed because it is too large Load Diff