From badc3481693b4c018b5cbf1123d7751c46ef32be Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 27 Feb 2026 10:02:51 -0500 Subject: [PATCH 1/2] feat: add module/step schema, template functions, and expression validation tools to MCP server Adds five new tools to the workflow MCP server to help IDE AI assistants author and validate workflow YAML configurations with accurate, schema-driven knowledge: - get_module_schema: returns full ModuleSchema for a module type (description, config fields with key/type/description/required/default/options, inputs, outputs, example snippet) - get_step_schema: returns step type info (description, config keys with types and descriptions via stepTypeInfoFull, plugin, example snippet) - get_template_functions: returns all available pipeline template functions with name, signature, description, and example (uuid/uuidv4/now/lower/default/ trimPrefix/trimSuffix/json/step/trigger) - validate_template_expressions: parses a YAML config, walks pipeline step configs for {{ }} expressions, and emits warnings for forward references, self-references, undefined step references, and hyphenated dot-access patterns - get_config_examples: lists .yaml files from the example/ directory or returns a specific example's content by name All five tools are covered by tests in mcp/tools_test.go (63 tests total, all pass). golangci-lint clean. Co-Authored-By: Claude Opus 4.6 --- mcp/server.go | 1 + mcp/tools.go | 951 ++++++++++++++++++++++++++++++++++++++++++++++ mcp/tools_test.go | 788 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 1740 insertions(+) create mode 100644 mcp/tools.go create mode 100644 mcp/tools_test.go diff --git a/mcp/server.go b/mcp/server.go index 1d311d1d..80dbe5fd 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -90,6 +90,7 @@ func NewServer(pluginDir string, opts ...ServerOption) *Server { } s.registerTools() + s.registerNewTools() s.registerResources() return s diff --git a/mcp/tools.go b/mcp/tools.go new file mode 100644 index 00000000..e53b980c --- /dev/null +++ b/mcp/tools.go @@ -0,0 +1,951 @@ +package mcp + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/schema" + "github.com/mark3labs/mcp-go/mcp" + "gopkg.in/yaml.v3" +) + +// registerNewTools registers the additional schema and validation tools. +func (s *Server) registerNewTools() { + // get_module_schema + s.mcpServer.AddTool( + mcp.NewTool("get_module_schema", + mcp.WithDescription("Return the full configuration schema for a given module type. "+ + "Includes description, config fields (key, type, description, required, default, options), "+ + "inputs, outputs, and an example usage snippet."), + mcp.WithString("module_type", + mcp.Required(), + mcp.Description("The module type string (e.g. 'http.server', 'messaging.broker')"), + ), + mcp.WithReadOnlyHintAnnotation(true), + ), + s.handleGetModuleSchema, + ) + + // get_step_schema + s.mcpServer.AddTool( + mcp.NewTool("get_step_schema", + mcp.WithDescription("Return the schema for a given pipeline step type. "+ + "Includes description, config keys with types and descriptions, and an example usage snippet."), + mcp.WithString("step_type", + mcp.Required(), + mcp.Description("The step type string (e.g. 'step.set', 'step.http_call')"), + ), + mcp.WithReadOnlyHintAnnotation(true), + ), + s.handleGetStepSchema, + ) + + // get_template_functions + s.mcpServer.AddTool( + mcp.NewTool("get_template_functions", + mcp.WithDescription("Return the complete list of available Go template functions for pipeline templates. "+ + "Each function includes its name, signature, description, and an example usage."), + mcp.WithReadOnlyHintAnnotation(true), + ), + s.handleGetTemplateFunctions, + ) + + // validate_template_expressions + s.mcpServer.AddTool( + mcp.NewTool("validate_template_expressions", + mcp.WithDescription("Validate template expressions ({{ ... }}) in a YAML pipeline config string. "+ + "Checks for forward references, self-references, undefined step references, "+ + "and hyphenated step name dot-access patterns that require index syntax."), + mcp.WithString("yaml_content", + mcp.Required(), + mcp.Description("The YAML content of the pipeline configuration to validate"), + ), + ), + s.handleValidateTemplateExpressions, + ) + + // get_config_examples + s.mcpServer.AddTool( + mcp.NewTool("get_config_examples", + mcp.WithDescription("List and optionally return example workflow config files from the example/ directory. "+ + "When called without a name, lists all available examples. "+ + "When called with a name, returns the full content of that example."), + mcp.WithString("name", + mcp.Description("Name of a specific example to fetch (e.g. 'api-server-config'). Omit to list all examples."), + ), + mcp.WithReadOnlyHintAnnotation(true), + ), + s.handleGetConfigExamples, + ) +} + +// --- Tool Handlers for new tools --- + +func (s *Server) handleGetModuleSchema(_ context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + moduleType := mcp.ParseString(req, "module_type", "") + if moduleType == "" { + return mcp.NewToolResultError("module_type is required"), nil + } + + reg := schema.GetModuleSchemaRegistry() + ms := reg.Get(moduleType) + if ms == nil { + return mcp.NewToolResultError(fmt.Sprintf("unknown module type %q", moduleType)), nil + } + + example := generateModuleExample(ms) + + result := map[string]any{ + "type": ms.Type, + "label": ms.Label, + "category": ms.Category, + "description": ms.Description, + "inputs": ms.Inputs, + "outputs": ms.Outputs, + "configFields": ms.ConfigFields, + "defaultConfig": ms.DefaultConfig, + "example": example, + } + return marshalToolResult(result) +} + +func (s *Server) handleGetStepSchema(_ context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + stepType := mcp.ParseString(req, "step_type", "") + if stepType == "" { + return mcp.NewToolResultError("step_type is required"), nil + } + if !strings.HasPrefix(stepType, "step.") { + return mcp.NewToolResultError(fmt.Sprintf("step type must begin with 'step.', got %q", stepType)), nil + } + + info, ok := knownStepTypeDescriptions()[stepType] + if !ok { + // Fall back to checking if the type is in the known module types list. + known := schema.KnownModuleTypes() + found := false + for _, t := range known { + if t == stepType { + found = true + break + } + } + if !found { + return mcp.NewToolResultError(fmt.Sprintf("unknown step type %q", stepType)), nil + } + // Return minimal info for step types not in the description table. + result := map[string]any{ + "type": stepType, + "configKeys": []string{}, + "example": generateStepExample(stepType, []string{}), + } + return marshalToolResult(result) + } + + result := map[string]any{ + "type": info.Type, + "description": info.Description, + "plugin": info.Plugin, + "configKeys": info.ConfigKeys, + "configDefs": info.ConfigDefs, + "example": generateStepExample(info.Type, info.ConfigKeys), + } + return marshalToolResult(result) +} + +func (s *Server) handleGetTemplateFunctions(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + funcs := templateFunctionDescriptions() + return marshalToolResult(map[string]any{ + "functions": funcs, + "count": len(funcs), + }) +} + +func (s *Server) handleValidateTemplateExpressions(_ context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + yamlContent := mcp.ParseString(req, "yaml_content", "") + if yamlContent == "" { + return mcp.NewToolResultError("yaml_content is required"), nil + } + + // Parse config to get basic structure. + _, err := config.LoadFromString(yamlContent) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("YAML parse error: %v", err)), nil + } + + // Re-parse into a generic map to access the pipelines section (which is map[string]any in config). + var rawDoc map[string]any + if err := yaml.Unmarshal([]byte(yamlContent), &rawDoc); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("YAML parse error: %v", err)), nil + } + + // Extract pipelines section. + pipelinesRaw, _ := rawDoc["pipelines"].(map[string]any) + + // Re-marshal pipelines back to YAML and parse as typed PipelineConfig. + type minStep struct { + Name string `yaml:"name"` + Type string `yaml:"type"` + Config map[string]any `yaml:"config"` + } + type minPipeline struct { + Steps []minStep `yaml:"steps"` + } + + pipelines := make(map[string]minPipeline, len(pipelinesRaw)) + for pName, pRaw := range pipelinesRaw { + data, err := yaml.Marshal(pRaw) + if err != nil { + continue + } + var p minPipeline + if err := yaml.Unmarshal(data, &p); err != nil { + continue + } + pipelines[pName] = p + } + + var warnings []string + + // Regex patterns for template expression analysis. + templateRefRe := regexp.MustCompile(`\{\{[^}]*\}\}`) + stepsRefRe := regexp.MustCompile(`\.steps\.([a-zA-Z0-9_-]+)`) + hyphenStepRe := regexp.MustCompile(`\.steps\.([a-zA-Z0-9_]*-[a-zA-Z0-9_-]*)`) + + // Walk every pipeline step config looking for template patterns. + for pName, p := range pipelines { + // Build ordered step index map. + stepIndexMap := make(map[string]int, len(p.Steps)) + for i, step := range p.Steps { + stepIndexMap[step.Name] = i + } + + for stepIdx, step := range p.Steps { + // Check all config values for template expressions. + for configKey, configVal := range step.Config { + valStr := fmt.Sprintf("%v", configVal) + if !strings.Contains(valStr, "{{") { + continue + } + exprs := templateRefRe.FindAllString(valStr, -1) + for _, expr := range exprs { + // Self-reference check. + if step.Name != "" { + selfRefRe := regexp.MustCompile(`\.steps\.` + regexp.QuoteMeta(step.Name) + `\b`) + if selfRefRe.MatchString(expr) { + warnings = append(warnings, fmt.Sprintf( + "[pipeline=%s step=%s config=%s] self-reference: step %q references itself in %s", + pName, step.Name, configKey, step.Name, expr, + )) + } + } + + refs := stepsRefRe.FindAllStringSubmatch(expr, -1) + for _, ref := range refs { + refName := ref[1] + // Forward reference check. + if refIdx, exists := stepIndexMap[refName]; exists && refIdx > stepIdx { + warnings = append(warnings, fmt.Sprintf( + "[pipeline=%s step=%s config=%s] forward reference: step %q (index %d) references step %q (index %d) which has not yet run", + pName, step.Name, configKey, step.Name, stepIdx, refName, refIdx, + )) + } + // Undefined step reference check. + if _, exists := stepIndexMap[refName]; !exists { + warnings = append(warnings, fmt.Sprintf( + "[pipeline=%s step=%s config=%s] undefined step reference: %q not found in pipeline", + pName, step.Name, configKey, refName, + )) + } + } + + // Hyphenated dot-access check. + hyphenRefs := hyphenStepRe.FindAllStringSubmatch(expr, -1) + for _, ref := range hyphenRefs { + stepName := ref[1] + warnings = append(warnings, fmt.Sprintf( + "[pipeline=%s step=%s config=%s] hyphenated step name %q uses dot-access; use: {{ index .steps %q \"field\" }} instead", + pName, step.Name, configKey, stepName, stepName, + )) + } + } + } + } + } + + sort.Strings(warnings) + + result := map[string]any{ + "warnings": warnings, + "warning_count": len(warnings), + "pipelines_checked": len(pipelines), + } + return marshalToolResult(result) +} + +func (s *Server) handleGetConfigExamples(_ context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + name := mcp.ParseString(req, "name", "") + + exampleDir := "example" + // Support absolute path from the server's working directory. + if s.pluginDir != "" { + // Try to derive the root from pluginDir (data/plugins -> .) + candidate := filepath.Join(filepath.Dir(filepath.Dir(s.pluginDir)), "example") + if _, err := os.Stat(candidate); err == nil { + exampleDir = candidate + } + } + + if name != "" { + // Return the content of the named example. + content, filename, err := readExampleFile(exampleDir, name) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("example %q not found: %v", name, err)), nil + } + result := map[string]any{ + "name": name, + "filename": filename, + "content": content, + } + return marshalToolResult(result) + } + + // List all .yaml files in the example directory. + examples, err := listExamples(exampleDir) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to list examples: %v", err)), nil + } + + result := map[string]any{ + "examples": examples, + "count": len(examples), + } + return marshalToolResult(result) +} + +// --- Helper types and data --- + +// stepTypeInfoFull extends StepTypeInfo with per-key descriptions. +type stepTypeInfoFull struct { + Type string + Plugin string + Description string + ConfigKeys []string + ConfigDefs []stepConfigKeyDef +} + +// stepConfigKeyDef describes a single config key for a step type. +type stepConfigKeyDef struct { + Key string `json:"key"` + Type string `json:"type"` + Description string `json:"description"` + Required bool `json:"required,omitempty"` +} + +// knownStepTypeDescriptions returns a map of all known step types with descriptions. +func knownStepTypeDescriptions() map[string]stepTypeInfoFull { + return map[string]stepTypeInfoFull{ + "step.set": { + Type: "step.set", + Plugin: "pipelinesteps", + Description: "Sets key/value pairs in the pipeline context. Values can contain template expressions.", + ConfigKeys: []string{"values"}, + ConfigDefs: []stepConfigKeyDef{ + {Key: "values", Type: "map", Description: "Map of key/value pairs to merge into the pipeline context", Required: true}, + }, + }, + "step.log": { + Type: "step.log", + Plugin: "pipelinesteps", + Description: "Logs a message at the specified log level.", + ConfigKeys: []string{"message", "level"}, + ConfigDefs: []stepConfigKeyDef{ + {Key: "message", Type: "string", Description: "Message to log (template expressions supported)", Required: true}, + {Key: "level", Type: "string", Description: "Log level: debug, info, warn, error (default: info)"}, + }, + }, + "step.validate": { + Type: "step.validate", + Plugin: "pipelinesteps", + Description: "Validates pipeline context fields against rules.", + ConfigKeys: []string{"rules", "required", "schema"}, + ConfigDefs: []stepConfigKeyDef{ + {Key: "rules", Type: "map", Description: "Validation rules per field"}, + {Key: "required", Type: "array", Description: "List of required field names"}, + {Key: "schema", Type: "string", Description: "JSON Schema for request body validation"}, + }, + }, + "step.transform": { + Type: "step.transform", + Plugin: "pipelinesteps", + Description: "Transforms pipeline context values using field mapping or template expressions.", + ConfigKeys: []string{"mapping", "template"}, + ConfigDefs: []stepConfigKeyDef{ + {Key: "mapping", Type: "map", Description: "Field mapping from source to target keys"}, + {Key: "template", Type: "string", Description: "Go template string for complex transformations"}, + }, + }, + "step.conditional": { + Type: "step.conditional", + Plugin: "pipelinesteps", + Description: "Branches pipeline execution based on a condition expression.", + ConfigKeys: []string{"condition", "then", "else"}, + ConfigDefs: []stepConfigKeyDef{ + {Key: "condition", Type: "string", Description: "Boolean template expression to evaluate", Required: true}, + {Key: "then", Type: "array", Description: "Steps to execute when condition is true"}, + {Key: "else", Type: "array", Description: "Steps to execute when condition is false"}, + }, + }, + "step.http_call": { + Type: "step.http_call", + Plugin: "pipelinesteps", + Description: "Makes an outbound HTTP request and stores the response in the pipeline context.", + ConfigKeys: []string{"url", "method", "headers", "body", "timeout", "auth"}, + ConfigDefs: []stepConfigKeyDef{ + {Key: "url", Type: "string", Description: "Request URL (template expressions supported)", Required: true}, + {Key: "method", Type: "string", Description: "HTTP method (GET, POST, PUT, DELETE, PATCH)", Required: true}, + {Key: "headers", Type: "map", Description: "Request headers"}, + {Key: "body", Type: "string", Description: "Request body (template expressions supported)"}, + {Key: "timeout", Type: "string", Description: "Request timeout duration (e.g. 30s)"}, + {Key: "auth", Type: "map", Description: "Authentication config (type, token, etc.)"}, + }, + }, + "step.json_response": { + Type: "step.json_response", + Plugin: "pipelinesteps", + Description: "Sends a JSON HTTP response and terminates pipeline execution.", + ConfigKeys: []string{"status", "body", "headers"}, + ConfigDefs: []stepConfigKeyDef{ + {Key: "status", Type: "number", Description: "HTTP status code (default: 200)", Required: true}, + {Key: "body", Type: "string|map", Description: "Response body (string template or map for JSON object)"}, + {Key: "headers", Type: "map", Description: "Additional response headers"}, + }, + }, + "step.request_parse": { + Type: "step.request_parse", + Plugin: "pipelinesteps", + Description: "Parses incoming HTTP request body, query params, and headers into the pipeline context.", + ConfigKeys: []string{"body", "query", "headers"}, + ConfigDefs: []stepConfigKeyDef{ + {Key: "body", Type: "boolean", Description: "Parse the request body (default: true)"}, + {Key: "query", Type: "boolean", Description: "Parse query parameters (default: true)"}, + {Key: "headers", Type: "boolean", Description: "Parse request headers (default: false)"}, + }, + }, + "step.db_query": { + Type: "step.db_query", + Plugin: "pipelinesteps", + Description: "Executes a database SELECT query and stores results in the pipeline context.", + ConfigKeys: []string{"database", "query", "params"}, + ConfigDefs: []stepConfigKeyDef{ + {Key: "database", Type: "string", Description: "Database module name", Required: true}, + {Key: "query", Type: "string", Description: "SQL query (template expressions supported)", Required: true}, + {Key: "params", Type: "array", Description: "Query parameters"}, + }, + }, + "step.db_exec": { + Type: "step.db_exec", + Plugin: "pipelinesteps", + Description: "Executes a database INSERT/UPDATE/DELETE statement.", + ConfigKeys: []string{"database", "query", "params"}, + ConfigDefs: []stepConfigKeyDef{ + {Key: "database", Type: "string", Description: "Database module name", Required: true}, + {Key: "query", Type: "string", Description: "SQL statement (template expressions supported)", Required: true}, + {Key: "params", Type: "array", Description: "Statement parameters"}, + }, + }, + "step.foreach": { + Type: "step.foreach", + Plugin: "pipelinesteps", + Description: "Iterates over a collection and executes nested steps for each item.", + ConfigKeys: []string{"collection", "item_var", "item_key", "step", "steps", "index_key"}, + ConfigDefs: []stepConfigKeyDef{ + {Key: "collection", Type: "string", Description: "Template expression resolving to the collection to iterate", Required: true}, + {Key: "item_var", Type: "string", Description: "Context key for the current item (default: 'item')"}, + {Key: "item_key", Type: "string", Description: "Context key for the current item's key/index"}, + {Key: "index_key", Type: "string", Description: "Context key for the numeric loop index"}, + {Key: "step", Type: "object", Description: "Single step to execute per item"}, + {Key: "steps", Type: "array", Description: "List of steps to execute per item"}, + }, + }, + "step.delegate": { + Type: "step.delegate", + Plugin: "pipelinesteps", + Description: "Delegates execution to another module service.", + ConfigKeys: []string{"service", "action"}, + ConfigDefs: []stepConfigKeyDef{ + {Key: "service", Type: "string", Description: "Name of the service module to delegate to", Required: true}, + {Key: "action", Type: "string", Description: "Action to invoke on the service"}, + }, + }, + "step.publish": { + Type: "step.publish", + Plugin: "pipelinesteps", + Description: "Publishes a message to a messaging broker topic.", + ConfigKeys: []string{"topic", "broker", "payload"}, + ConfigDefs: []stepConfigKeyDef{ + {Key: "topic", Type: "string", Description: "Topic name to publish to", Required: true}, + {Key: "broker", Type: "string", Description: "Messaging broker module name"}, + {Key: "payload", Type: "string|map", Description: "Message payload (template expressions supported)"}, + }, + }, + "step.event_publish": { + Type: "step.event_publish", + Plugin: "pipelinesteps", + Description: "Publishes a structured event to a messaging broker topic.", + ConfigKeys: []string{"topic", "broker", "payload", "headers", "event_type"}, + ConfigDefs: []stepConfigKeyDef{ + {Key: "topic", Type: "string", Description: "Topic name to publish to", Required: true}, + {Key: "broker", Type: "string", Description: "Messaging broker module name"}, + {Key: "payload", Type: "string|map", Description: "Event payload (template expressions supported)"}, + {Key: "headers", Type: "map", Description: "Event headers"}, + {Key: "event_type", Type: "string", Description: "Event type identifier"}, + }, + }, + "step.cache_get": { + Type: "step.cache_get", + Plugin: "pipelinesteps", + Description: "Retrieves a value from a cache module by key.", + ConfigKeys: []string{"cache", "key", "output"}, + ConfigDefs: []stepConfigKeyDef{ + {Key: "cache", Type: "string", Description: "Cache module name", Required: true}, + {Key: "key", Type: "string", Description: "Cache key (template expressions supported)", Required: true}, + {Key: "output", Type: "string", Description: "Context key to store the result (default: step name)"}, + }, + }, + "step.cache_set": { + Type: "step.cache_set", + Plugin: "pipelinesteps", + Description: "Stores a value in a cache module by key.", + ConfigKeys: []string{"cache", "key", "value", "ttl"}, + ConfigDefs: []stepConfigKeyDef{ + {Key: "cache", Type: "string", Description: "Cache module name", Required: true}, + {Key: "key", Type: "string", Description: "Cache key (template expressions supported)", Required: true}, + {Key: "value", Type: "string|any", Description: "Value to cache (template expressions supported)"}, + {Key: "ttl", Type: "string", Description: "Time-to-live duration (e.g. 5m, 1h)"}, + }, + }, + "step.cache_delete": { + Type: "step.cache_delete", + Plugin: "pipelinesteps", + Description: "Deletes a value from a cache module by key.", + ConfigKeys: []string{"cache", "key"}, + ConfigDefs: []stepConfigKeyDef{ + {Key: "cache", Type: "string", Description: "Cache module name", Required: true}, + {Key: "key", Type: "string", Description: "Cache key to delete", Required: true}, + }, + }, + "step.retry_with_backoff": { + Type: "step.retry_with_backoff", + Plugin: "pipelinesteps", + Description: "Retries a nested step with exponential backoff on failure.", + ConfigKeys: []string{"max_retries", "initial_delay", "max_delay", "multiplier", "step"}, + ConfigDefs: []stepConfigKeyDef{ + {Key: "max_retries", Type: "number", Description: "Maximum retry attempts", Required: true}, + {Key: "initial_delay", Type: "string", Description: "Initial delay before first retry (e.g. 100ms)"}, + {Key: "max_delay", Type: "string", Description: "Maximum delay between retries (e.g. 30s)"}, + {Key: "multiplier", Type: "number", Description: "Backoff multiplier (default: 2.0)"}, + {Key: "step", Type: "object", Description: "The step definition to retry", Required: true}, + }, + }, + "step.resilient_circuit_breaker": { + Type: "step.resilient_circuit_breaker", + Plugin: "pipelinesteps", + Description: "Wraps a step with a circuit breaker to prevent cascading failures.", + ConfigKeys: []string{"failure_threshold", "reset_timeout", "step", "fallback"}, + ConfigDefs: []stepConfigKeyDef{ + {Key: "failure_threshold", Type: "number", Description: "Number of failures before opening the circuit", Required: true}, + {Key: "reset_timeout", Type: "string", Description: "Duration to wait before trying half-open (e.g. 30s)", Required: true}, + {Key: "step", Type: "object", Description: "The step definition to protect", Required: true}, + {Key: "fallback", Type: "object", Description: "Optional fallback step when circuit is open"}, + }, + }, + "step.auth_required": { + Type: "step.auth_required", + Plugin: "pipelinesteps", + Description: "Validates JWT or API key authentication. Returns 401 if not authenticated.", + ConfigKeys: []string{"roles", "scopes"}, + ConfigDefs: []stepConfigKeyDef{ + {Key: "roles", Type: "array", Description: "Required roles (any match grants access)"}, + {Key: "scopes", Type: "array", Description: "Required OAuth2 scopes"}, + }, + }, + "step.jq": { + Type: "step.jq", + Plugin: "pipelinesteps", + Description: "Applies a jq expression to transform data in the pipeline context.", + ConfigKeys: []string{"expression", "input", "output"}, + ConfigDefs: []stepConfigKeyDef{ + {Key: "expression", Type: "string", Description: "jq filter expression", Required: true}, + {Key: "input", Type: "string", Description: "Context key of the input value (default: whole context)"}, + {Key: "output", Type: "string", Description: "Context key to store the result"}, + }, + }, + "step.webhook_verify": { + Type: "step.webhook_verify", + Plugin: "pipelinesteps", + Description: "Verifies webhook signatures from providers like GitHub, GitLab, or Stripe.", + ConfigKeys: []string{"provider", "scheme", "secret", "secret_from", "header", "signature_header"}, + ConfigDefs: []stepConfigKeyDef{ + {Key: "provider", Type: "string", Description: "Webhook provider (github, gitlab, stripe, generic)"}, + {Key: "scheme", Type: "string", Description: "Signature scheme (hmac-sha256, hmac-sha1)"}, + {Key: "secret", Type: "string", Description: "Shared secret for signature verification"}, + {Key: "secret_from", Type: "string", Description: "Context key containing the secret (alternative to secret)"}, + {Key: "signature_header", Type: "string", Description: "HTTP header containing the signature"}, + {Key: "header", Type: "string", Description: "Alias for signature_header"}, + }, + }, + "step.workflow_call": { + Type: "step.workflow_call", + Plugin: "pipelinesteps", + Description: "Calls another workflow and returns its result.", + ConfigKeys: []string{"workflow", "input"}, + ConfigDefs: []stepConfigKeyDef{ + {Key: "workflow", Type: "string", Description: "Workflow name to call", Required: true}, + {Key: "input", Type: "map", Description: "Input data to pass to the workflow"}, + }, + }, + "step.validate_path_param": { + Type: "step.validate_path_param", + Plugin: "pipelinesteps", + Description: "Validates a URL path parameter exists and matches the expected type.", + ConfigKeys: []string{"param", "type", "required"}, + ConfigDefs: []stepConfigKeyDef{ + {Key: "param", Type: "string", Description: "Path parameter name", Required: true}, + {Key: "type", Type: "string", Description: "Expected type (string, integer, uuid)"}, + {Key: "required", Type: "boolean", Description: "Whether the parameter is required (default: true)"}, + }, + }, + "step.validate_pagination": { + Type: "step.validate_pagination", + Plugin: "pipelinesteps", + Description: "Validates and normalizes pagination query parameters (limit, offset).", + ConfigKeys: []string{"maxLimit", "defaultLimit"}, + ConfigDefs: []stepConfigKeyDef{ + {Key: "maxLimit", Type: "number", Description: "Maximum allowed limit value (default: 100)"}, + {Key: "defaultLimit", Type: "number", Description: "Default limit when not provided (default: 20)"}, + }, + }, + "step.validate_request_body": { + Type: "step.validate_request_body", + Plugin: "pipelinesteps", + Description: "Validates the request body against a JSON Schema.", + ConfigKeys: []string{"schema", "required"}, + ConfigDefs: []stepConfigKeyDef{ + {Key: "schema", Type: "string|object", Description: "JSON Schema to validate against", Required: true}, + {Key: "required", Type: "array", Description: "List of required body fields"}, + }, + }, + "step.dlq_send": { + Type: "step.dlq_send", + Plugin: "pipelinesteps", + Description: "Sends a failed message to the dead-letter queue.", + ConfigKeys: []string{"topic", "original_topic", "error", "payload", "broker"}, + ConfigDefs: []stepConfigKeyDef{ + {Key: "topic", Type: "string", Description: "DLQ topic name", Required: true}, + {Key: "original_topic", Type: "string", Description: "Original topic the message came from"}, + {Key: "error", Type: "string", Description: "Error message describing the failure"}, + {Key: "payload", Type: "string|map", Description: "Original message payload"}, + {Key: "broker", Type: "string", Description: "Messaging broker module name"}, + }, + }, + "step.dlq_replay": { + Type: "step.dlq_replay", + Plugin: "pipelinesteps", + Description: "Replays messages from the dead-letter queue to the target topic.", + ConfigKeys: []string{"dlq_topic", "target_topic", "max_messages", "broker"}, + ConfigDefs: []stepConfigKeyDef{ + {Key: "dlq_topic", Type: "string", Description: "DLQ topic to read from", Required: true}, + {Key: "target_topic", Type: "string", Description: "Target topic to replay messages to", Required: true}, + {Key: "max_messages", Type: "number", Description: "Maximum messages to replay (default: all)"}, + {Key: "broker", Type: "string", Description: "Messaging broker module name"}, + }, + }, + "step.nosql_get": { + Type: "step.nosql_get", + Plugin: "datastores", + Description: "Retrieves a document from a NoSQL store by key.", + ConfigKeys: []string{"store", "key", "output", "miss_ok"}, + ConfigDefs: []stepConfigKeyDef{ + {Key: "store", Type: "string", Description: "NoSQL store module name", Required: true}, + {Key: "key", Type: "string", Description: "Document key (template expressions supported)", Required: true}, + {Key: "output", Type: "string", Description: "Context key to store the result"}, + {Key: "miss_ok", Type: "boolean", Description: "Don't fail if key not found (default: false)"}, + }, + }, + "step.nosql_put": { + Type: "step.nosql_put", + Plugin: "datastores", + Description: "Stores a document in a NoSQL store by key.", + ConfigKeys: []string{"store", "key", "item"}, + ConfigDefs: []stepConfigKeyDef{ + {Key: "store", Type: "string", Description: "NoSQL store module name", Required: true}, + {Key: "key", Type: "string", Description: "Document key (template expressions supported)", Required: true}, + {Key: "item", Type: "string|map", Description: "Document to store"}, + }, + }, + "step.nosql_query": { + Type: "step.nosql_query", + Plugin: "datastores", + Description: "Queries documents from a NoSQL store by key prefix.", + ConfigKeys: []string{"store", "prefix", "output"}, + ConfigDefs: []stepConfigKeyDef{ + {Key: "store", Type: "string", Description: "NoSQL store module name", Required: true}, + {Key: "prefix", Type: "string", Description: "Key prefix to filter documents"}, + {Key: "output", Type: "string", Description: "Context key to store results"}, + }, + }, + "step.base64_decode": { + Type: "step.base64_decode", + Plugin: "pipelinesteps", + Description: "Decodes a base64-encoded value and validates its type.", + ConfigKeys: []string{"input_from", "format", "allowed_types", "max_size_bytes"}, + ConfigDefs: []stepConfigKeyDef{ + {Key: "input_from", Type: "string", Description: "Context key containing the base64-encoded value"}, + {Key: "format", Type: "string", Description: "Expected format (e.g. image/png, application/pdf)"}, + {Key: "allowed_types", Type: "array", Description: "List of allowed MIME types"}, + {Key: "max_size_bytes", Type: "number", Description: "Maximum decoded size in bytes"}, + }, + }, + "step.statemachine_transition": { + Type: "step.statemachine_transition", + Plugin: "statemachine", + Description: "Triggers a state machine transition for a given instance.", + ConfigKeys: []string{"engine", "instanceId", "transition"}, + ConfigDefs: []stepConfigKeyDef{ + {Key: "engine", Type: "string", Description: "State machine engine module name", Required: true}, + {Key: "instanceId", Type: "string", Description: "State machine instance ID (template expressions supported)", Required: true}, + {Key: "transition", Type: "string", Description: "Transition name to trigger", Required: true}, + }, + }, + "step.statemachine_get": { + Type: "step.statemachine_get", + Plugin: "statemachine", + Description: "Retrieves the current state of a state machine instance.", + ConfigKeys: []string{"engine", "instanceId"}, + ConfigDefs: []stepConfigKeyDef{ + {Key: "engine", Type: "string", Description: "State machine engine module name", Required: true}, + {Key: "instanceId", Type: "string", Description: "State machine instance ID (template expressions supported)", Required: true}, + }, + }, + "step.feature_flag": { + Type: "step.feature_flag", + Plugin: "featureflags", + Description: "Evaluates a feature flag and stores the result in the pipeline context.", + ConfigKeys: []string{"flag", "default", "output"}, + ConfigDefs: []stepConfigKeyDef{ + {Key: "flag", Type: "string", Description: "Feature flag name", Required: true}, + {Key: "default", Type: "boolean", Description: "Default value when flag not found (default: false)"}, + {Key: "output", Type: "string", Description: "Context key to store the flag value"}, + }, + }, + "step.shell_exec": { + Type: "step.shell_exec", + Plugin: "cicd", + Description: "Executes a shell command and captures its output.", + ConfigKeys: []string{"command", "args", "env", "workdir", "timeout"}, + ConfigDefs: []stepConfigKeyDef{ + {Key: "command", Type: "string", Description: "Command to execute", Required: true}, + {Key: "args", Type: "array", Description: "Command arguments"}, + {Key: "env", Type: "map", Description: "Environment variables"}, + {Key: "workdir", Type: "string", Description: "Working directory"}, + {Key: "timeout", Type: "string", Description: "Execution timeout (e.g. 5m)"}, + }, + }, + } +} + +// TemplateFunctionDef describes a template function available in pipeline templates. +type TemplateFunctionDef struct { + Name string `json:"name"` + Signature string `json:"signature"` + Description string `json:"description"` + Example string `json:"example"` +} + +// templateFunctionDescriptions returns descriptions for all built-in template functions. +func templateFunctionDescriptions() []TemplateFunctionDef { + return []TemplateFunctionDef{ + { + Name: "uuid", + Signature: "uuid() string", + Description: "Generates a new random UUID v4 string.", + Example: `{{ uuid }}`, + }, + { + Name: "uuidv4", + Signature: "uuidv4() string", + Description: "Generates a new random UUID v4 string. Alias for uuid.", + Example: `{{ uuidv4 }}`, + }, + { + Name: "now", + Signature: "now(layout ...string) string", + Description: "Returns the current UTC time formatted with the given Go time layout or named constant (e.g. RFC3339, DateOnly). Defaults to RFC3339 when called with no arguments.", + Example: `{{ now "RFC3339" }} or {{ now "2006-01-02" }}`, + }, + { + Name: "lower", + Signature: "lower(s string) string", + Description: "Converts a string to lowercase.", + Example: `{{ lower .name }}`, + }, + { + Name: "default", + Signature: "default(fallback any, val any) any", + Description: "Returns fallback when val is nil or an empty string, otherwise returns val.", + Example: `{{ default "anonymous" .username }}`, + }, + { + Name: "trimPrefix", + Signature: "trimPrefix(prefix string, s string) string", + Description: "Removes the given prefix from s if present.", + Example: `{{ trimPrefix "/api" .path }}`, + }, + { + Name: "trimSuffix", + Signature: "trimSuffix(suffix string, s string) string", + Description: "Removes the given suffix from s if present.", + Example: `{{ trimSuffix "/" .path }}`, + }, + { + Name: "json", + Signature: "json(v any) string", + Description: "Marshals a value to a JSON string. Returns '{}' on marshal error.", + Example: `{{ json .data }}`, + }, + { + Name: "step", + Signature: "step(name string, keys ...string) any", + Description: "Accesses step outputs by step name and optional nested keys. Returns nil if the step does not exist or a key is missing.", + Example: `{{ step "parse-request" "body" "id" }}`, + }, + { + Name: "trigger", + Signature: "trigger(keys ...string) any", + Description: "Accesses trigger data by nested keys. Returns nil if keys do not exist.", + Example: `{{ trigger "path_params" "id" }}`, + }, + } +} + +// exampleInfo describes an available config example. +type exampleInfo struct { + Name string `json:"name"` + Filename string `json:"filename"` + Description string `json:"description,omitempty"` +} + +// listExamples lists all YAML example files in the given directory. +func listExamples(exampleDir string) ([]exampleInfo, error) { + entries, err := os.ReadDir(exampleDir) + if os.IsNotExist(err) { + return []exampleInfo{}, nil + } + if err != nil { + return nil, err + } + + var examples []exampleInfo + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") { + continue + } + name := strings.TrimSuffix(e.Name(), ".yaml") + // Strip leading numbers (e.g. "01-foo" -> "01-foo" kept as-is). + examples = append(examples, exampleInfo{ + Name: name, + Filename: e.Name(), + }) + } + return examples, nil +} + +// readExampleFile reads an example YAML file by name. +// name can be the base name with or without the .yaml extension. +func readExampleFile(exampleDir, name string) (string, string, error) { + // Normalize name. + if !strings.HasSuffix(name, ".yaml") { + name += ".yaml" + } + + candidates := []string{ + filepath.Join(exampleDir, name), + } + + for _, path := range candidates { + data, err := os.ReadFile(path) //nolint:gosec // G304: path is within example dir + if err == nil { + return string(data), filepath.Base(path), nil + } + } + return "", "", fmt.Errorf("file not found") +} + +// generateModuleExample creates an example YAML snippet for a module schema. +func generateModuleExample(ms *schema.ModuleSchema) string { + var b strings.Builder + name := strings.ReplaceAll(ms.Type, ".", "-") + fmt.Fprintf(&b, "modules:\n - name: my-%s\n type: %s\n", name, ms.Type) + if len(ms.ConfigFields) > 0 { + b.WriteString(" config:\n") + for i := range ms.ConfigFields { + f := &ms.ConfigFields[i] + val := f.DefaultValue + if val == nil { + val = exampleValue(*f) + } + fmt.Fprintf(&b, " %s: %v\n", f.Key, val) + } + } + return b.String() +} + +// exampleValue returns a placeholder value for a config field. +func exampleValue(f schema.ConfigFieldDef) any { + switch f.Type { + case schema.FieldTypeString, schema.FieldTypeFilePath, schema.FieldTypeSQL: + if f.Placeholder != "" { + return f.Placeholder + } + return "" + case schema.FieldTypeNumber: + return 0 + case schema.FieldTypeBool: + return false + case schema.FieldTypeDuration: + return "30s" + case schema.FieldTypeSelect: + if len(f.Options) > 0 { + return f.Options[0] + } + return "" + case schema.FieldTypeArray: + return "[]" + case schema.FieldTypeMap, schema.FieldTypeJSON: + return "{}" + default: + return "" + } +} + +// generateStepExample creates an example YAML snippet for a pipeline step type. +func generateStepExample(stepType string, configKeys []string) string { + var b strings.Builder + name := strings.TrimPrefix(stepType, "step.") + name = strings.ReplaceAll(name, "_", "-") + fmt.Fprintf(&b, "pipelines:\n my-pipeline:\n steps:\n") + fmt.Fprintf(&b, " - name: %s-step\n type: %s\n", name, stepType) + if len(configKeys) > 0 { + b.WriteString(" config:\n") + for _, k := range configKeys { + fmt.Fprintf(&b, " %s: \"\"\n", k) + } + } + return b.String() +} diff --git a/mcp/tools_test.go b/mcp/tools_test.go new file mode 100644 index 00000000..807923ed --- /dev/null +++ b/mcp/tools_test.go @@ -0,0 +1,788 @@ +package mcp + +import ( + "context" + "encoding/json" + "os" + "strings" + "testing" + + "github.com/mark3labs/mcp-go/mcp" +) + +// --- get_module_schema --- + +func TestGetModuleSchema_KnownType(t *testing.T) { + srv := NewServer("") + req := makeCallToolRequest(map[string]any{ + "module_type": "http.server", + }) + + result, err := srv.handleGetModuleSchema(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + text := extractText(t, result) + var data map[string]any + if err := json.Unmarshal([]byte(text), &data); err != nil { + t.Fatalf("failed to parse result JSON: %v", err) + } + + if data["type"] != "http.server" { + t.Errorf("expected type 'http.server', got %v", data["type"]) + } + if data["description"] == nil || data["description"] == "" { + t.Error("expected non-empty description") + } + configFields, ok := data["configFields"].([]any) + if !ok { + t.Fatal("expected configFields array") + } + if len(configFields) == 0 { + t.Error("expected at least one config field for http.server") + } + // Verify address field is present. + found := false + for _, cf := range configFields { + f := cf.(map[string]any) + if f["key"] == "address" { + found = true + break + } + } + if !found { + t.Error("expected 'address' config field for http.server") + } + + if data["example"] == nil || data["example"] == "" { + t.Error("expected non-empty example") + } + if !contains(data["example"].(string), "http.server") { + t.Error("example should mention http.server type") + } +} + +func TestGetModuleSchema_WithInputsOutputs(t *testing.T) { + srv := NewServer("") + req := makeCallToolRequest(map[string]any{ + "module_type": "http.router", + }) + + result, err := srv.handleGetModuleSchema(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + text := extractText(t, result) + var data map[string]any + if err := json.Unmarshal([]byte(text), &data); err != nil { + t.Fatalf("failed to parse result JSON: %v", err) + } + + // Router should have inputs (it receives requests). + inputs, ok := data["inputs"].([]any) + if !ok || len(inputs) == 0 { + t.Error("expected at least one input for http.router") + } +} + +func TestGetModuleSchema_UnknownType(t *testing.T) { + srv := NewServer("") + req := makeCallToolRequest(map[string]any{ + "module_type": "unknown.type.xyz", + }) + + result, err := srv.handleGetModuleSchema(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + text := extractText(t, result) + if !contains(text, "unknown module type") { + t.Errorf("expected 'unknown module type' error, got %q", text) + } +} + +func TestGetModuleSchema_MissingType(t *testing.T) { + srv := NewServer("") + req := makeCallToolRequest(map[string]any{}) + + result, err := srv.handleGetModuleSchema(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + text := extractText(t, result) + if !contains(text, "required") { + t.Errorf("expected 'required' error message, got %q", text) + } +} + +// --- get_step_schema --- + +func TestGetStepSchema_KnownType(t *testing.T) { + srv := NewServer("") + req := makeCallToolRequest(map[string]any{ + "step_type": "step.set", + }) + + result, err := srv.handleGetStepSchema(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + text := extractText(t, result) + var data map[string]any + if err := json.Unmarshal([]byte(text), &data); err != nil { + t.Fatalf("failed to parse result JSON: %v", err) + } + + if data["type"] != "step.set" { + t.Errorf("expected type 'step.set', got %v", data["type"]) + } + if data["description"] == nil || data["description"] == "" { + t.Error("expected non-empty description for step.set") + } + configKeys, ok := data["configKeys"].([]any) + if !ok || len(configKeys) == 0 { + t.Error("expected configKeys for step.set") + } + if data["example"] == nil || data["example"] == "" { + t.Error("expected non-empty example") + } + if !contains(data["example"].(string), "step.set") { + t.Error("example should mention step.set") + } +} + +func TestGetStepSchema_HTTPCall(t *testing.T) { + srv := NewServer("") + req := makeCallToolRequest(map[string]any{ + "step_type": "step.http_call", + }) + + result, err := srv.handleGetStepSchema(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + text := extractText(t, result) + var data map[string]any + if err := json.Unmarshal([]byte(text), &data); err != nil { + t.Fatalf("failed to parse result JSON: %v", err) + } + + if data["type"] != "step.http_call" { + t.Errorf("expected type 'step.http_call', got %v", data["type"]) + } + + configDefs, ok := data["configDefs"].([]any) + if !ok || len(configDefs) == 0 { + t.Error("expected configDefs for step.http_call") + } + + // Verify url field is present. + found := false + for _, cd := range configDefs { + f := cd.(map[string]any) + if f["key"] == "url" { + found = true + if f["required"] != true { + t.Error("expected url to be required") + } + } + } + if !found { + t.Error("expected 'url' in configDefs") + } +} + +func TestGetStepSchema_UnknownType(t *testing.T) { + srv := NewServer("") + req := makeCallToolRequest(map[string]any{ + "step_type": "step.nonexistent_xyz", + }) + + result, err := srv.handleGetStepSchema(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + text := extractText(t, result) + if !contains(text, "unknown step type") { + t.Errorf("expected 'unknown step type' error, got %q", text) + } +} + +func TestGetStepSchema_NotAStepType(t *testing.T) { + srv := NewServer("") + req := makeCallToolRequest(map[string]any{ + "step_type": "http.server", + }) + + result, err := srv.handleGetStepSchema(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + text := extractText(t, result) + if !contains(text, "step.") { + t.Errorf("expected error about step. prefix, got %q", text) + } +} + +func TestGetStepSchema_MissingType(t *testing.T) { + srv := NewServer("") + req := makeCallToolRequest(map[string]any{}) + + result, err := srv.handleGetStepSchema(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + text := extractText(t, result) + if !contains(text, "required") { + t.Errorf("expected 'required' error, got %q", text) + } +} + +// --- get_template_functions --- + +func TestGetTemplateFunctions(t *testing.T) { + srv := NewServer("") + result, err := srv.handleGetTemplateFunctions(context.Background(), mcp.CallToolRequest{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + text := extractText(t, result) + var data map[string]any + if err := json.Unmarshal([]byte(text), &data); err != nil { + t.Fatalf("failed to parse result JSON: %v", err) + } + + count, ok := data["count"].(float64) + if !ok || count == 0 { + t.Error("expected non-zero function count") + } + + funcs, ok := data["functions"].([]any) + if !ok || len(funcs) == 0 { + t.Fatal("expected functions list") + } + + // Verify expected function names are present. + funcNames := make(map[string]bool) + for _, f := range funcs { + fn := f.(map[string]any) + name, _ := fn["name"].(string) + funcNames[name] = true + + // Each function should have name, signature, description, and example. + if name == "" { + t.Error("function should have a name") + } + if fn["signature"] == nil || fn["signature"] == "" { + t.Errorf("function %q should have a signature", name) + } + if fn["description"] == nil || fn["description"] == "" { + t.Errorf("function %q should have a description", name) + } + if fn["example"] == nil || fn["example"] == "" { + t.Errorf("function %q should have an example", name) + } + } + + expectedFunctions := []string{"uuid", "uuidv4", "now", "lower", "default", "json", "step", "trigger"} + for _, expected := range expectedFunctions { + if !funcNames[expected] { + t.Errorf("expected function %q not found in list", expected) + } + } +} + +func TestGetTemplateFunctions_NowLayout(t *testing.T) { + srv := NewServer("") + result, err := srv.handleGetTemplateFunctions(context.Background(), mcp.CallToolRequest{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + text := extractText(t, result) + // Verify the now function mentions layout. + if !contains(text, "RFC3339") { + t.Error("expected 'RFC3339' in template functions (as part of now function description)") + } +} + +// --- validate_template_expressions --- + +func TestValidateTemplateExpressions_ForwardReference(t *testing.T) { + srv := NewServer("") + + yaml := ` +pipelines: + test-pipeline: + steps: + - name: step-a + type: step.set + config: + values: + msg: "{{ .steps.step-b.result }}" + - name: step-b + type: step.set + config: + values: + result: "hello" +` + req := makeCallToolRequest(map[string]any{ + "yaml_content": yaml, + }) + + result, err := srv.handleValidateTemplateExpressions(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + text := extractText(t, result) + var data map[string]any + if err := json.Unmarshal([]byte(text), &data); err != nil { + t.Fatalf("failed to parse result JSON: %v", err) + } + + warnings, ok := data["warnings"].([]any) + if !ok { + t.Fatal("expected warnings array") + } + + // Should detect a forward reference to step-b from step-a. + foundForward := false + for _, w := range warnings { + if contains(w.(string), "forward reference") { + foundForward = true + break + } + } + if !foundForward { + t.Errorf("expected forward reference warning, got: %v", warnings) + } +} + +func TestValidateTemplateExpressions_HyphenatedDotAccess(t *testing.T) { + srv := NewServer("") + + yaml := ` +pipelines: + test-pipeline: + steps: + - name: parse-request + type: step.request_parse + config: {} + - name: process + type: step.set + config: + values: + id: "{{ .steps.parse-request.body.id }}" +` + req := makeCallToolRequest(map[string]any{ + "yaml_content": yaml, + }) + + result, err := srv.handleValidateTemplateExpressions(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + text := extractText(t, result) + var data map[string]any + if err := json.Unmarshal([]byte(text), &data); err != nil { + t.Fatalf("failed to parse result JSON: %v", err) + } + + warnings, ok := data["warnings"].([]any) + if !ok { + t.Fatal("expected warnings array") + } + + foundHyphen := false + for _, w := range warnings { + if contains(w.(string), "hyphenated step name") { + foundHyphen = true + break + } + } + if !foundHyphen { + t.Errorf("expected hyphenated dot-access warning, got: %v", warnings) + } +} + +func TestValidateTemplateExpressions_UndefinedStep(t *testing.T) { + srv := NewServer("") + + yaml := ` +pipelines: + test-pipeline: + steps: + - name: process + type: step.set + config: + values: + val: "{{ .steps.nonexistent.field }}" +` + req := makeCallToolRequest(map[string]any{ + "yaml_content": yaml, + }) + + result, err := srv.handleValidateTemplateExpressions(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + text := extractText(t, result) + var data map[string]any + if err := json.Unmarshal([]byte(text), &data); err != nil { + t.Fatalf("failed to parse result JSON: %v", err) + } + + warnings, ok := data["warnings"].([]any) + if !ok { + t.Fatal("expected warnings array") + } + + foundUndef := false + for _, w := range warnings { + if contains(w.(string), "undefined step reference") { + foundUndef = true + break + } + } + if !foundUndef { + t.Errorf("expected undefined step reference warning, got: %v", warnings) + } +} + +func TestValidateTemplateExpressions_NoWarnings(t *testing.T) { + srv := NewServer("") + + yaml := ` +pipelines: + clean-pipeline: + steps: + - name: step-one + type: step.set + config: + values: + x: "hello" + - name: step-two + type: step.log + config: + message: "done" +` + req := makeCallToolRequest(map[string]any{ + "yaml_content": yaml, + }) + + result, err := srv.handleValidateTemplateExpressions(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + text := extractText(t, result) + var data map[string]any + if err := json.Unmarshal([]byte(text), &data); err != nil { + t.Fatalf("failed to parse result JSON: %v", err) + } + + count := data["warning_count"].(float64) + if count != 0 { + t.Errorf("expected 0 warnings for clean pipeline, got %v: %v", count, data["warnings"]) + } +} + +func TestValidateTemplateExpressions_MissingContent(t *testing.T) { + srv := NewServer("") + req := makeCallToolRequest(map[string]any{}) + + result, err := srv.handleValidateTemplateExpressions(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + text := extractText(t, result) + if !contains(text, "required") { + t.Errorf("expected 'required' error, got %q", text) + } +} + +func TestValidateTemplateExpressions_MalformedYAML(t *testing.T) { + srv := NewServer("") + req := makeCallToolRequest(map[string]any{ + "yaml_content": "{{not valid yaml}", + }) + + result, err := srv.handleValidateTemplateExpressions(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + text := extractText(t, result) + if text == "" { + t.Fatal("expected error message for malformed YAML") + } +} + +func TestValidateTemplateExpressions_NoPipelines(t *testing.T) { + srv := NewServer("") + + yaml := ` +modules: + - name: web + type: http.server + config: + address: ":8080" +` + req := makeCallToolRequest(map[string]any{ + "yaml_content": yaml, + }) + + result, err := srv.handleValidateTemplateExpressions(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + text := extractText(t, result) + var data map[string]any + if err := json.Unmarshal([]byte(text), &data); err != nil { + t.Fatalf("failed to parse result JSON: %v", err) + } + + if data["pipelines_checked"].(float64) != 0 { + t.Error("expected 0 pipelines_checked when no pipelines in config") + } +} + +// --- get_config_examples --- + +func TestGetConfigExamples_List(t *testing.T) { + // Create a temp dir with fake YAML files to simulate the example directory. + dir := t.TempDir() + files := []string{"api-server-config.yaml", "event-driven-workflow.yaml", "data-pipeline-config.yaml"} + for _, f := range files { + if err := os.WriteFile(dir+"/"+f, []byte("# test example\nmodules: []\n"), 0640); err != nil { + t.Fatal(err) + } + } + + srv := &Server{pluginDir: ""} + // Directly call listExamples (unit test the helper). + examples, err := listExamples(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(examples) != 3 { + t.Errorf("expected 3 examples, got %d", len(examples)) + } + + nameSet := make(map[string]bool) + for _, ex := range examples { + nameSet[ex.Name] = true + if !strings.HasSuffix(ex.Filename, ".yaml") { + t.Errorf("expected .yaml filename, got %q", ex.Filename) + } + } + + for _, f := range files { + name := strings.TrimSuffix(f, ".yaml") + if !nameSet[name] { + t.Errorf("expected example %q in list", name) + } + } + _ = srv +} + +func TestGetConfigExamples_GetContent(t *testing.T) { + dir := t.TempDir() + content := "# Example config\nmodules:\n - name: web\n type: http.server\n" + if err := os.WriteFile(dir+"/api-server-config.yaml", []byte(content), 0640); err != nil { + t.Fatal(err) + } + + gotContent, filename, err := readExampleFile(dir, "api-server-config") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotContent != content { + t.Errorf("expected content %q, got %q", content, gotContent) + } + if filename != "api-server-config.yaml" { + t.Errorf("expected filename 'api-server-config.yaml', got %q", filename) + } +} + +func TestGetConfigExamples_GetContentWithExtension(t *testing.T) { + dir := t.TempDir() + content := "modules: []\n" + if err := os.WriteFile(dir+"/simple-workflow.yaml", []byte(content), 0640); err != nil { + t.Fatal(err) + } + + // Call with .yaml extension explicitly. + gotContent, _, err := readExampleFile(dir, "simple-workflow.yaml") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotContent != content { + t.Errorf("expected content %q, got %q", content, gotContent) + } +} + +func TestGetConfigExamples_NotFound(t *testing.T) { + dir := t.TempDir() + _, _, err := readExampleFile(dir, "nonexistent") + if err == nil { + t.Error("expected error for nonexistent example") + } +} + +func TestGetConfigExamples_NoDir(t *testing.T) { + examples, err := listExamples("/nonexistent/directory") + if err != nil { + t.Fatalf("unexpected error listing nonexistent dir: %v", err) + } + if len(examples) != 0 { + t.Errorf("expected 0 examples for nonexistent dir, got %d", len(examples)) + } +} + +func TestHandleGetConfigExamples_List(t *testing.T) { + dir := t.TempDir() + files := []string{"simple.yaml", "advanced.yaml"} + for _, f := range files { + if err := os.WriteFile(dir+"/"+f, []byte("modules: []\n"), 0640); err != nil { + t.Fatal(err) + } + } + + srv := NewServer("") + // Override exampleDir by using a server method call with the temp dir. + // We test via the helper directly, since pluginDir-based lookup is environment-dependent. + req := makeCallToolRequest(map[string]any{}) + result, err := srv.handleGetConfigExamples(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + text := extractText(t, result) + var data map[string]any + if err := json.Unmarshal([]byte(text), &data); err != nil { + t.Fatalf("failed to parse result JSON: %v", err) + } + + // Without a real example dir, count may be 0 (not a failure). + if data["count"] == nil { + t.Error("expected count field in result") + } + if data["examples"] == nil { + t.Error("expected examples field in result") + } +} + +func TestHandleGetConfigExamples_SpecificName_NotFound(t *testing.T) { + srv := NewServer("") + req := makeCallToolRequest(map[string]any{ + "name": "this-does-not-exist-ever", + }) + + result, err := srv.handleGetConfigExamples(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + text := extractText(t, result) + if !contains(text, "not found") { + t.Errorf("expected 'not found' error, got %q", text) + } +} + +// --- helper unit tests --- + +func TestGenerateModuleExample(t *testing.T) { + // Unit test the module example generator via get_module_schema (integrated). + srv := NewServer("") + req := makeCallToolRequest(map[string]any{ + "module_type": "http.server", + }) + + result, err := srv.handleGetModuleSchema(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + text := extractText(t, result) + var data map[string]any + if err := json.Unmarshal([]byte(text), &data); err != nil { + t.Fatalf("failed to parse JSON: %v", err) + } + + example := data["example"].(string) + if !contains(example, "modules:") { + t.Error("example should contain 'modules:'") + } + if !contains(example, "type: http.server") { + t.Error("example should contain 'type: http.server'") + } +} + +func TestGenerateStepExample(t *testing.T) { + example := generateStepExample("step.http_call", []string{"url", "method"}) + if !contains(example, "step.http_call") { + t.Error("step example should mention the step type") + } + if !contains(example, "pipelines:") { + t.Error("step example should contain 'pipelines:'") + } +} + +func TestKnownStepTypeDescriptions_Coverage(t *testing.T) { + descs := knownStepTypeDescriptions() + if len(descs) == 0 { + t.Fatal("expected non-empty step type descriptions") + } + + // Spot-check some important types. + for _, must := range []string{"step.set", "step.http_call", "step.foreach", "step.retry_with_backoff"} { + if _, ok := descs[must]; !ok { + t.Errorf("expected step type %q in descriptions", must) + } + } + + // All entries must have a non-empty description. + for typ, info := range descs { + if info.Description == "" { + t.Errorf("step type %q has empty description", typ) + } + if info.Type != typ { + t.Errorf("step type key %q doesn't match info.Type %q", typ, info.Type) + } + } +} + +func TestTemplateFunctionDescriptions_AllHaveExamples(t *testing.T) { + funcs := templateFunctionDescriptions() + for _, f := range funcs { + if f.Name == "" { + t.Error("function has empty name") + } + if f.Signature == "" { + t.Errorf("function %q has empty signature", f.Name) + } + if f.Description == "" { + t.Errorf("function %q has empty description", f.Name) + } + if f.Example == "" { + t.Errorf("function %q has empty example", f.Name) + } + } +} From ae063196bf2b9e934f0ce9335b78aa34959749fa Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 27 Feb 2026 10:39:48 -0500 Subject: [PATCH 2/2] fix: address PR #207 review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Issues 1-3: confirmed false positives — helpers are defined in server_test.go (same package) - Issue 4: track warnedRefs set to avoid duplicate hyphen warnings for refs already covered by forward/undefined-reference checks - Issue 5: compile self-reference regex once per step, outside the expression loop - Issue 6: validate pluginDir follows expected data/plugins layout before deriving exampleDir - Issue 7: accumulate YAML marshal/unmarshal errors as warnings instead of silently skipping - Issue 8: add path traversal protection in readExampleFile (reject .., path separators, validate resolved path within exampleDir) - Issue 9: update hyphen warning message to say the engine auto-corrects it - Issue 10: change template regex to (?s)\{\{.*?\}\} (non-greedy dotall) with heuristic comment - Issue 11: remove "(default: 200)" from status field description since it is Required: true Co-Authored-By: Claude Opus 4.6 --- mcp/tools.go | 79 ++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 55 insertions(+), 24 deletions(-) diff --git a/mcp/tools.go b/mcp/tools.go index e53b980c..57dccefb 100644 --- a/mcp/tools.go +++ b/mcp/tools.go @@ -197,23 +197,26 @@ func (s *Server) handleValidateTemplateExpressions(_ context.Context, req mcp.Ca Steps []minStep `yaml:"steps"` } + var warnings []string + pipelines := make(map[string]minPipeline, len(pipelinesRaw)) for pName, pRaw := range pipelinesRaw { data, err := yaml.Marshal(pRaw) if err != nil { + warnings = append(warnings, fmt.Sprintf("[pipeline=%s] could not re-marshal pipeline for analysis: %v", pName, err)) continue } var p minPipeline if err := yaml.Unmarshal(data, &p); err != nil { + warnings = append(warnings, fmt.Sprintf("[pipeline=%s] could not parse pipeline steps for analysis: %v", pName, err)) continue } pipelines[pName] = p } - var warnings []string - // Regex patterns for template expression analysis. - templateRefRe := regexp.MustCompile(`\{\{[^}]*\}\}`) + // templateRefRe is a heuristic matcher — it may not handle every edge case in Go templates. + templateRefRe := regexp.MustCompile(`(?s)\{\{.*?\}\}`) stepsRefRe := regexp.MustCompile(`\.steps\.([a-zA-Z0-9_-]+)`) hyphenStepRe := regexp.MustCompile(`\.steps\.([a-zA-Z0-9_]*-[a-zA-Z0-9_-]*)`) @@ -226,6 +229,12 @@ func (s *Server) handleValidateTemplateExpressions(_ context.Context, req mcp.Ca } for stepIdx, step := range p.Steps { + // Compile self-reference regex once per step, outside the config/expression loops. + var selfRefRe *regexp.Regexp + if step.Name != "" { + selfRefRe = regexp.MustCompile(`\.steps\.` + regexp.QuoteMeta(step.Name) + `\b`) + } + // Check all config values for template expressions. for configKey, configVal := range step.Config { valStr := fmt.Sprintf("%v", configVal) @@ -235,8 +244,7 @@ func (s *Server) handleValidateTemplateExpressions(_ context.Context, req mcp.Ca exprs := templateRefRe.FindAllString(valStr, -1) for _, expr := range exprs { // Self-reference check. - if step.Name != "" { - selfRefRe := regexp.MustCompile(`\.steps\.` + regexp.QuoteMeta(step.Name) + `\b`) + if selfRefRe != nil { if selfRefRe.MatchString(expr) { warnings = append(warnings, fmt.Sprintf( "[pipeline=%s step=%s config=%s] self-reference: step %q references itself in %s", @@ -245,6 +253,9 @@ func (s *Server) handleValidateTemplateExpressions(_ context.Context, req mcp.Ca } } + // warnedRefs tracks step names that have already received a forward/undefined warning + // so the hyphen check below doesn't emit a redundant second warning for the same ref. + warnedRefs := make(map[string]bool) refs := stepsRefRe.FindAllStringSubmatch(expr, -1) for _, ref := range refs { refName := ref[1] @@ -254,6 +265,7 @@ func (s *Server) handleValidateTemplateExpressions(_ context.Context, req mcp.Ca "[pipeline=%s step=%s config=%s] forward reference: step %q (index %d) references step %q (index %d) which has not yet run", pName, step.Name, configKey, step.Name, stepIdx, refName, refIdx, )) + warnedRefs[refName] = true } // Undefined step reference check. if _, exists := stepIndexMap[refName]; !exists { @@ -261,17 +273,20 @@ func (s *Server) handleValidateTemplateExpressions(_ context.Context, req mcp.Ca "[pipeline=%s step=%s config=%s] undefined step reference: %q not found in pipeline", pName, step.Name, configKey, refName, )) + warnedRefs[refName] = true } } - // Hyphenated dot-access check. + // Hyphenated dot-access check — skip refs already reported above to avoid duplicate warnings. hyphenRefs := hyphenStepRe.FindAllStringSubmatch(expr, -1) - for _, ref := range hyphenRefs { - stepName := ref[1] - warnings = append(warnings, fmt.Sprintf( - "[pipeline=%s step=%s config=%s] hyphenated step name %q uses dot-access; use: {{ index .steps %q \"field\" }} instead", - pName, step.Name, configKey, stepName, stepName, - )) + for _, href := range hyphenRefs { + hyphenName := href[1] + if !warnedRefs[hyphenName] { + warnings = append(warnings, fmt.Sprintf( + "[pipeline=%s step=%s config=%s] hyphenated step name %q uses dot-access; the engine auto-corrects this, but consider using {{ index .steps %q \"field\" }} for clarity", + pName, step.Name, configKey, hyphenName, hyphenName, + )) + } } } } @@ -293,11 +308,17 @@ func (s *Server) handleGetConfigExamples(_ context.Context, req mcp.CallToolRequ exampleDir := "example" // Support absolute path from the server's working directory. + // Only derive the root when pluginDir follows the expected "data/plugins" layout. if s.pluginDir != "" { - // Try to derive the root from pluginDir (data/plugins -> .) - candidate := filepath.Join(filepath.Dir(filepath.Dir(s.pluginDir)), "example") - if _, err := os.Stat(candidate); err == nil { - exampleDir = candidate + // Validate expected layout: pluginDir must end with "data/plugins" (or "data"+sep+"plugins"). + pluginBase := filepath.Base(s.pluginDir) + dataDir := filepath.Dir(s.pluginDir) + dataBase := filepath.Base(dataDir) + if pluginBase == "plugins" && dataBase == "data" { + candidate := filepath.Join(filepath.Dir(dataDir), "example") + if _, err := os.Stat(candidate); err == nil { + exampleDir = candidate + } } } @@ -421,7 +442,7 @@ func knownStepTypeDescriptions() map[string]stepTypeInfoFull { Description: "Sends a JSON HTTP response and terminates pipeline execution.", ConfigKeys: []string{"status", "body", "headers"}, ConfigDefs: []stepConfigKeyDef{ - {Key: "status", Type: "number", Description: "HTTP status code (default: 200)", Required: true}, + {Key: "status", Type: "number", Description: "HTTP status code", Required: true}, {Key: "body", Type: "string|map", Description: "Response body (string template or map for JSON object)"}, {Key: "headers", Type: "map", Description: "Additional response headers"}, }, @@ -869,20 +890,30 @@ func listExamples(exampleDir string) ([]exampleInfo, error) { // readExampleFile reads an example YAML file by name. // name can be the base name with or without the .yaml extension. func readExampleFile(exampleDir, name string) (string, string, error) { + // Path traversal protection: reject names containing ".." or path separators. + if strings.Contains(name, "..") || strings.ContainsAny(name, `/\`) { + return "", "", fmt.Errorf("invalid example name %q", name) + } + // Normalize name. if !strings.HasSuffix(name, ".yaml") { name += ".yaml" } - candidates := []string{ - filepath.Join(exampleDir, name), + absDir, err := filepath.Abs(exampleDir) + if err != nil { + return "", "", fmt.Errorf("invalid example directory: %w", err) } - for _, path := range candidates { - data, err := os.ReadFile(path) //nolint:gosec // G304: path is within example dir - if err == nil { - return string(data), filepath.Base(path), nil - } + resolved := filepath.Join(absDir, name) + // Verify the resolved path stays within exampleDir. + if !strings.HasPrefix(resolved, absDir+string(filepath.Separator)) { + return "", "", fmt.Errorf("invalid example name %q", name) + } + + data, err := os.ReadFile(resolved) //nolint:gosec // G304: path is validated to be within absDir + if err == nil { + return string(data), filepath.Base(resolved), nil } return "", "", fmt.Errorf("file not found") }