diff --git a/Makefile b/Makefile index 37757ba2..a033cef9 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,10 @@ build-ui: build-go: go build -o server ./cmd/server +# Build MCP server binary +build-mcp: + go build -o workflow-mcp-server ./cmd/mcp + # Run all tests with race detection test: go test -race ./... diff --git a/cmd/mcp/main.go b/cmd/mcp/main.go new file mode 100644 index 00000000..c225b83b --- /dev/null +++ b/cmd/mcp/main.go @@ -0,0 +1,39 @@ +// Command mcp starts the workflow MCP (Model Context Protocol) server. +// +// The server runs over stdio by default, making it compatible with any +// MCP-capable AI client. It exposes workflow engine tools (validate, +// list types, generate schemas) and resources (documentation, examples). +// +// Usage: +// +// workflow-mcp-server [options] +// +// Options: +// +// -plugin-dir string Plugin data directory (default "data/plugins") +package main + +import ( + "flag" + "fmt" + "os" + + workflowmcp "github.com/GoCodeAlone/workflow/mcp" +) + +func main() { + pluginDir := flag.String("plugin-dir", "data/plugins", "Plugin data directory") + showVersion := flag.Bool("version", false, "Show version and exit") + flag.Parse() + + if *showVersion { + fmt.Printf("workflow-mcp-server %s\n", workflowmcp.Version) + os.Exit(0) + } + + srv := workflowmcp.NewServer(*pluginDir) + if err := srv.ServeStdio(); err != nil { + fmt.Fprintf(os.Stderr, "MCP server error: %v\n", err) + os.Exit(1) + } +} diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 00000000..1bb110f5 --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,164 @@ +# MCP Server for Workflow Engine + +The workflow engine includes a built-in [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that exposes engine functionality to AI assistants and tools. + +## Features + +The MCP server provides: + +### Tools + +| Tool | Description | +|------|-------------| +| `list_module_types` | List all available module types for workflow YAML configs | +| `list_step_types` | List all pipeline step types | +| `list_trigger_types` | List all trigger types (http, schedule, event, eventbus) | +| `list_workflow_types` | List all workflow handler types | +| `generate_schema` | Generate JSON Schema for workflow config files | +| `validate_config` | Validate a workflow YAML configuration string | +| `inspect_config` | Inspect a config and get structured summary of modules, workflows, triggers | +| `list_plugins` | List installed external plugins | +| `get_config_skeleton` | Generate a skeleton YAML config for given module types | + +### Resources + +| Resource | Description | +|----------|-------------| +| `workflow://docs/overview` | Engine overview documentation | +| `workflow://docs/yaml-syntax` | YAML configuration syntax guide | +| `workflow://docs/module-reference` | Dynamic module type reference | + +## Building + +```bash +# Build the MCP server binary +make build-mcp + +# Or directly with Go +go build -o workflow-mcp-server ./cmd/mcp +``` + +## Installation + +### Claude Desktop + +Add the following to your Claude Desktop configuration file: + +**macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` +**Windows**: `%APPDATA%\Claude\claude_desktop_config.json` + +```json +{ + "mcpServers": { + "workflow": { + "command": "/path/to/workflow-mcp-server", + "args": ["-plugin-dir", "/path/to/data/plugins"] + } + } +} +``` + +### VS Code with GitHub Copilot + +Add to your VS Code `settings.json`: + +```json +{ + "github.copilot.chat.mcpServers": { + "workflow": { + "command": "/path/to/workflow-mcp-server", + "args": ["-plugin-dir", "/path/to/data/plugins"] + } + } +} +``` + +### Cursor + +Add to your Cursor MCP configuration (`.cursor/mcp.json`): + +```json +{ + "mcpServers": { + "workflow": { + "command": "/path/to/workflow-mcp-server", + "args": ["-plugin-dir", "/path/to/data/plugins"] + } + } +} +``` + +### Generic MCP Client + +The server communicates over **stdio** using JSON-RPC 2.0. Any MCP-compatible client can connect: + +```bash +./workflow-mcp-server -plugin-dir ./data/plugins +``` + +## Usage Examples + +Once connected, the AI assistant can use the tools to: + +### List available module types +``` +Use the list_module_types tool to see what modules are available. +``` + +### Validate a configuration +``` +Validate this workflow config: +modules: + - name: webServer + type: http.server + config: + address: ":8080" +``` + +### Generate a config skeleton +``` +Generate a skeleton config with http.server, http.router, and http.handler modules. +``` + +### Inspect a configuration +``` +Inspect this config and show me the dependency graph... +``` + +## Command Line Options + +``` +Usage: workflow-mcp-server [options] + +Options: + -plugin-dir string Plugin data directory (default "data/plugins") + -version Show version and exit +``` + +## Dynamic Updates + +The MCP server dynamically reflects the current state of the engine: + +- **Module types** are read from the schema registry, which includes both built-in types and any dynamically registered plugin types +- **Plugin list** scans the configured plugin directory at query time +- **Schema generation** uses the current module schema registry +- **Validation** uses the same validation logic as `wfctl validate` + +This means the MCP server automatically picks up new module types and plugins without requiring a restart. + +## Running Tests + +```bash +go test -v ./mcp/ +``` + +## Architecture + +``` +cmd/mcp/main.go → Entry point (stdio transport) +mcp/server.go → MCP server setup, tool handlers, resource handlers +mcp/docs.go → Embedded documentation content +mcp/server_test.go → Unit tests +``` + +The server uses the [mcp-go](https://github.com/mark3labs/mcp-go) library for MCP protocol implementation over stdio transport. diff --git a/go.mod b/go.mod index 18bd52e9..383b6912 100644 --- a/go.mod +++ b/go.mod @@ -43,6 +43,7 @@ require ( github.com/jackc/pgx/v5 v5.7.5 github.com/launchdarkly/go-sdk-common/v3 v3.5.0 github.com/launchdarkly/go-server-sdk/v7 v7.14.5 + github.com/mark3labs/mcp-go v0.27.0 github.com/nats-io/nats.go v1.48.0 github.com/prometheus/client_golang v1.19.1 github.com/redis/go-redis/v9 v9.18.0 @@ -54,6 +55,8 @@ require ( go.opentelemetry.io/otel/trace v1.40.0 golang.org/x/crypto v0.48.0 golang.org/x/oauth2 v0.35.0 + golang.org/x/sync v0.19.0 + golang.org/x/text v0.34.0 golang.org/x/time v0.14.0 google.golang.org/api v0.265.0 google.golang.org/grpc v1.79.1 @@ -186,7 +189,9 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/robfig/cron/v3 v3.0.1 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect + github.com/spf13/cast v1.7.1 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect @@ -200,9 +205,7 @@ require ( go.uber.org/zap v1.27.0 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/net v0.50.0 // indirect - golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect diff --git a/go.sum b/go.sum index f3b92885..70e4bb2c 100644 --- a/go.sum +++ b/go.sum @@ -199,6 +199,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw= @@ -354,6 +356,8 @@ github.com/launchdarkly/go-test-helpers/v3 v3.1.0 h1:E3bxJMzMoA+cJSF3xxtk2/chr1z github.com/launchdarkly/go-test-helpers/v3 v3.1.0/go.mod h1:Ake5+hZFS/DmIGKx/cizhn5W9pGA7pplcR7xCxWiLIo= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mark3labs/mcp-go v0.27.0 h1:iok9kU4DUIU2/XVLgFS2Q9biIDqstC0jY4EQTK2Erzc= +github.com/mark3labs/mcp-go v0.27.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= @@ -439,6 +443,8 @@ github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIH github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= @@ -465,6 +471,8 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0 h1:3UeQBvD0TFrlVjOeLOBz+CPAI8dnbqNSVwUwRrkp7vQ= github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0/go.mod h1:IXCdmsXIht47RaVFLEdVnh1t+pgYtTAhQGj73kz+2DM= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= diff --git a/mcp/docs.go b/mcp/docs.go new file mode 100644 index 00000000..82e39143 --- /dev/null +++ b/mcp/docs.go @@ -0,0 +1,264 @@ +package mcp + +// docsOverview contains the overview documentation for the workflow engine. +const docsOverview = `# GoCodeAlone/workflow Engine Overview + +## What is the Workflow Engine? + +The workflow engine is a configuration-driven application framework that allows you to build +complete applications entirely from YAML configuration files. The same codebase can operate as: + +- **API servers** with authentication middleware +- **Event processing systems** with pub/sub messaging +- **Message-based communication systems** (Kafka, NATS, in-memory) +- **Scheduled job processors** with cron scheduling +- **State machines** for complex business logic +- **CI/CD pipelines** with build, test, and deploy steps + +## Key Concepts + +### Modules +Modules are the building blocks of a workflow application. Each module has a **type** (e.g., +` + "`http.server`" + `, ` + "`messaging.broker`" + `) and a **name** (unique identifier). Modules are +declared in the ` + "`modules`" + ` section of the YAML config. + +### Workflows +Workflows define how modules interact. Workflow handlers (e.g., ` + "`http`" + `, ` + "`messaging`" + `, +` + "`statemachine`" + `) configure routing, subscriptions, and state transitions. + +### Triggers +Triggers start workflow execution. Types include: +- ` + "`http`" + ` - HTTP request triggers +- ` + "`schedule`" + ` - Cron-based scheduled triggers +- ` + "`event`" + ` - Event-driven triggers +- ` + "`eventbus`" + ` - Event bus triggers + +### Pipelines +Pipelines are ordered sequences of steps that process data. Steps can be conditional, +parallel, or sequential. Each step type (e.g., ` + "`step.http_call`" + `, ` + "`step.transform`" + `, +` + "`step.shell_exec`" + `) performs a specific action. + +### Plugins +The engine is extensible via plugins that provide additional module types, step types, +trigger types, and workflow handlers. Plugins can be: +- **Built-in** (compiled into the engine) +- **External** (separate binaries communicating via gRPC) + +## Architecture + +1. **Engine** creates and wires modules from config +2. **Plugins** register factories for modules, steps, triggers +3. **BuildFromConfig** instantiates everything from YAML +4. **Triggers** start the workflow (HTTP requests, schedules, events) +5. **Handlers** process work through configured pipelines +6. **Steps** execute individual operations within pipelines + +## Getting Started + +1. Create a YAML configuration file +2. Define your modules (server, router, handlers) +3. Configure workflows (routes, subscriptions) +4. Add triggers or pipelines as needed +5. Run with: ` + "`wfctl run -config your-config.yaml`" + ` +` + +// docsYAMLSyntax contains the YAML configuration syntax guide. +const docsYAMLSyntax = `# Workflow YAML Configuration Syntax + +## Top-Level Structure + +A workflow configuration YAML file has these top-level fields: + +` + "```yaml" + ` +# Optional: declare required plugins +requires: + plugins: + - name: workflow-plugin-http + version: ">=1.0.0" + +# Required: module definitions +modules: + - name: + type: + config: + : + dependsOn: + - + +# Optional: workflow handler configurations +workflows: + http: + routes: [...] + messaging: + subscriptions: [...] + statemachine: + states: [...] + +# Optional: trigger configurations +triggers: + http: + routes: [...] + schedule: + jobs: [...] + +# Optional: pipeline definitions +pipelines: + my-pipeline: + timeout: 30s + errorStrategy: stop + steps: + - name: step-1 + type: step.http_call + config: + url: "https://api.example.com" + method: GET +` + "```" + ` + +## Module Configuration + +Each module requires: +- ` + "`name`" + `: Unique identifier (alphanumeric, dots, dashes, underscores) +- ` + "`type`" + `: Module type identifier (use ` + "`list_module_types`" + ` tool to see all types) +- ` + "`config`" + `: Module-specific key-value configuration +- ` + "`dependsOn`" + `: (optional) List of module names this module depends on + +### Example Modules + +` + "```yaml" + ` +modules: + # HTTP server + - name: webServer + type: http.server + config: + address: ":8080" + + # Router + - name: router + type: http.router + dependsOn: [webServer] + + # Request handler + - name: userHandler + type: http.handler + config: + contentType: "application/json" + response: '{"status": "ok"}' + dependsOn: [router] + + # Authentication middleware + - name: authMiddleware + type: http.middleware.auth + config: + type: jwt + secret: "${JWT_SECRET}" + dependsOn: [router] +` + "```" + ` + +## Workflow Configuration + +### HTTP Workflows + +` + "```yaml" + ` +workflows: + http: + routes: + - method: GET + path: /api/users + handler: userHandler + - method: POST + path: /api/users + handler: createUserHandler + middlewares: + - authMiddleware +` + "```" + ` + +### Messaging Workflows + +` + "```yaml" + ` +workflows: + messaging: + subscriptions: + - topic: user-events + handler: userEventHandler + - topic: order-events + handler: orderEventHandler +` + "```" + ` + +### State Machine Workflows + +` + "```yaml" + ` +workflows: + statemachine: + initial: pending + states: + - name: pending + transitions: + - event: approve + target: approved + - event: reject + target: rejected + - name: approved + transitions: + - event: complete + target: completed + - name: rejected + - name: completed +` + "```" + ` + +## Pipeline Configuration + +` + "```yaml" + ` +pipelines: + process-order: + timeout: 60s + errorStrategy: stop # stop, skip, or compensate + steps: + - name: validate-input + type: step.validate + config: + schema: '{"type": "object", "required": ["orderId"]}' + + - name: fetch-order + type: step.http_call + config: + url: "https://api.example.com/orders/{{.orderId}}" + method: GET + + - name: transform-data + type: step.transform + config: + template: '{"id": "{{.orderId}}", "status": "processed"}' + + - name: log-result + type: step.log + config: + message: "Order {{.orderId}} processed" + level: info +` + "```" + ` + +## Environment Variables and Secrets + +Configuration values can reference environment variables using ` + "`${VAR_NAME}`" + ` syntax: + +` + "```yaml" + ` +modules: + - name: dbConnection + type: database.workflow + config: + connectionString: "${DATABASE_URL}" + maxConnections: 10 +` + "```" + ` + +## Multi-Workflow Applications + +For larger applications, use an application config that references multiple workflow files: + +` + "```yaml" + ` +application: + name: my-ecommerce-app + workflows: + - file: services/order-service.yaml + - file: services/payment-service.yaml + - file: services/notification-service.yaml +` + "```" + ` +` diff --git a/mcp/server.go b/mcp/server.go new file mode 100644 index 00000000..917c69bd --- /dev/null +++ b/mcp/server.go @@ -0,0 +1,562 @@ +// Package mcp provides a Model Context Protocol (MCP) server that exposes +// workflow engine functionality to AI assistants. The server dynamically +// reflects available module types, step types, trigger types, plugin +// information, and configuration validation so that AI tools can author +// and validate workflow YAML files with accurate, up-to-date knowledge. +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/GoCodeAlone/workflow/config" + "golang.org/x/text/cases" + "golang.org/x/text/language" + "github.com/GoCodeAlone/workflow/schema" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// Version is the MCP server version, set at build time. +var Version = "dev" + +// Server wraps an MCP server instance and provides workflow-engine-specific +// tools and resources. +type Server struct { + mcpServer *server.MCPServer + pluginDir string +} + +// NewServer creates a new MCP server with all workflow engine tools and +// resources registered. pluginDir is the directory where installed plugins +// reside (e.g., "data/plugins"). +func NewServer(pluginDir string) *Server { + s := &Server{ + pluginDir: pluginDir, + } + + s.mcpServer = server.NewMCPServer( + "workflow-mcp-server", + Version, + server.WithToolCapabilities(true), + server.WithResourceCapabilities(false, true), + server.WithInstructions("This MCP server exposes the GoCodeAlone/workflow engine. "+ + "Use the provided tools to list available module types, step types, trigger types, "+ + "workflow types, generate JSON schemas, validate YAML configurations, inspect configs, "+ + "and manage plugins. Resources provide documentation and example configurations."), + ) + + s.registerTools() + s.registerResources() + + return s +} + +// MCPServer returns the underlying mcp-go server instance (useful for testing). +func (s *Server) MCPServer() *server.MCPServer { + return s.mcpServer +} + +// ServeStdio starts the MCP server over standard input/output. +func (s *Server) ServeStdio() error { + return server.ServeStdio(s.mcpServer) +} + +// registerTools registers all workflow engine tools with the MCP server. +func (s *Server) registerTools() { + // list_module_types + s.mcpServer.AddTool( + mcp.NewTool("list_module_types", + mcp.WithDescription("List all available workflow module types that can be used in the 'modules' section of a workflow YAML config. Returns both built-in and plugin-provided types."), + mcp.WithReadOnlyHintAnnotation(true), + ), + s.handleListModuleTypes, + ) + + // list_step_types + s.mcpServer.AddTool( + mcp.NewTool("list_step_types", + mcp.WithDescription("List all available pipeline step types that can be used in pipeline definitions. Steps are the building blocks of workflow pipelines."), + mcp.WithReadOnlyHintAnnotation(true), + ), + s.handleListStepTypes, + ) + + // list_trigger_types + s.mcpServer.AddTool( + mcp.NewTool("list_trigger_types", + mcp.WithDescription("List all available trigger types (e.g., http, schedule, event, eventbus) that can start workflow execution."), + mcp.WithReadOnlyHintAnnotation(true), + ), + s.handleListTriggerTypes, + ) + + // list_workflow_types + s.mcpServer.AddTool( + mcp.NewTool("list_workflow_types", + mcp.WithDescription("List all available workflow handler types (e.g., http, messaging, statemachine, scheduler, integration, event) that define how workflows process work."), + mcp.WithReadOnlyHintAnnotation(true), + ), + s.handleListWorkflowTypes, + ) + + // generate_schema + s.mcpServer.AddTool( + mcp.NewTool("generate_schema", + mcp.WithDescription("Generate the JSON Schema for workflow configuration YAML files. This schema describes all valid fields, module types, and structure for authoring workflow configs."), + mcp.WithReadOnlyHintAnnotation(true), + ), + s.handleGenerateSchema, + ) + + // validate_config + s.mcpServer.AddTool( + mcp.NewTool("validate_config", + mcp.WithDescription("Validate a workflow YAML configuration string. Returns validation results including any errors found. Use this to check if a YAML config is well-formed and uses valid module types."), + mcp.WithString("yaml_content", + mcp.Required(), + mcp.Description("The YAML content of the workflow configuration to validate"), + ), + mcp.WithBoolean("strict", + mcp.Description("Enable strict validation (no empty modules allowed). Default: false"), + ), + mcp.WithBoolean("skip_unknown_types", + mcp.Description("Skip unknown module/workflow/trigger type checks. Default: false"), + ), + ), + s.handleValidateConfig, + ) + + // inspect_config + s.mcpServer.AddTool( + mcp.NewTool("inspect_config", + mcp.WithDescription("Inspect a workflow YAML configuration and return a structured summary of its modules, workflows, triggers, pipelines, and dependency graph."), + mcp.WithString("yaml_content", + mcp.Required(), + mcp.Description("The YAML content of the workflow configuration to inspect"), + ), + ), + s.handleInspectConfig, + ) + + // list_plugins + s.mcpServer.AddTool( + mcp.NewTool("list_plugins", + mcp.WithDescription("List installed external plugins from the plugin directory."), + mcp.WithString("data_dir", + mcp.Description("Plugin data directory. Defaults to 'data/plugins'"), + ), + mcp.WithReadOnlyHintAnnotation(true), + ), + s.handleListPlugins, + ) + + // get_config_skeleton + s.mcpServer.AddTool( + mcp.NewTool("get_config_skeleton", + mcp.WithDescription("Generate a skeleton/template workflow YAML configuration for a given set of module types. Useful for bootstrapping new workflow configs."), + mcp.WithArray("module_types", + mcp.Required(), + mcp.Description("List of module type strings to include in the skeleton config (e.g., ['http.server', 'http.router', 'http.handler'])"), + ), + ), + s.handleGetConfigSkeleton, + ) +} + +// registerResources registers documentation and example resources. +func (s *Server) registerResources() { + s.mcpServer.AddResource( + mcp.NewResource( + "workflow://docs/overview", + "Workflow Engine Overview", + mcp.WithResourceDescription("Overview documentation for the GoCodeAlone/workflow engine, including architecture, key concepts, and configuration patterns."), + mcp.WithMIMEType("text/markdown"), + ), + s.handleDocsOverview, + ) + + s.mcpServer.AddResource( + mcp.NewResource( + "workflow://docs/yaml-syntax", + "YAML Configuration Syntax Guide", + mcp.WithResourceDescription("Detailed guide on workflow YAML configuration file syntax, including modules, workflows, triggers, and pipelines."), + mcp.WithMIMEType("text/markdown"), + ), + s.handleDocsYAMLSyntax, + ) + + s.mcpServer.AddResource( + mcp.NewResource( + "workflow://docs/module-reference", + "Module Type Reference", + mcp.WithResourceDescription("Reference documentation for all available module types with their configuration options."), + mcp.WithMIMEType("text/markdown"), + ), + s.handleDocsModuleReference, + ) +} + +// --- Tool Handlers --- + +func (s *Server) handleListModuleTypes(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + types := schema.KnownModuleTypes() + result := map[string]any{ + "module_types": types, + "count": len(types), + } + return marshalToolResult(result) +} + +func (s *Server) handleListStepTypes(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + types := schema.KnownModuleTypes() + var stepTypes []string + for _, t := range types { + if strings.HasPrefix(t, "step.") { + stepTypes = append(stepTypes, t) + } + } + result := map[string]any{ + "step_types": stepTypes, + "count": len(stepTypes), + } + return marshalToolResult(result) +} + +func (s *Server) handleListTriggerTypes(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + types := schema.KnownTriggerTypes() + result := map[string]any{ + "trigger_types": types, + "count": len(types), + } + return marshalToolResult(result) +} + +func (s *Server) handleListWorkflowTypes(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + types := schema.KnownWorkflowTypes() + result := map[string]any{ + "workflow_types": types, + "count": len(types), + } + return marshalToolResult(result) +} + +func (s *Server) handleGenerateSchema(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + sch := schema.GenerateWorkflowSchema() + data, err := json.MarshalIndent(sch, "", " ") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to generate schema: %v", err)), nil + } + return mcp.NewToolResultText(string(data)), nil +} + +func (s *Server) handleValidateConfig(_ context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + yamlContent := mcp.ParseString(req, "yaml_content", "") + if yamlContent == "" { + return mcp.NewToolResultError("yaml_content is required"), nil + } + + strict := mcp.ParseBoolean(req, "strict", false) + skipUnknown := mcp.ParseBoolean(req, "skip_unknown_types", false) + + cfg, err := config.LoadFromString(yamlContent) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("YAML parse error: %v", err)), nil + } + + var opts []schema.ValidationOption + if !strict { + opts = append(opts, schema.WithAllowEmptyModules()) + } + if skipUnknown { + opts = append(opts, schema.WithSkipModuleTypeCheck(), schema.WithSkipWorkflowTypeCheck(), schema.WithSkipTriggerTypeCheck()) + } else { + opts = append(opts, schema.WithSkipWorkflowTypeCheck(), schema.WithSkipTriggerTypeCheck()) + } + opts = append(opts, schema.WithAllowNoEntryPoints()) + + if err := schema.ValidateConfig(cfg, opts...); err != nil { + result := map[string]any{ + "valid": false, + "errors": err.Error(), + "summary": fmt.Sprintf("%d modules parsed", len(cfg.Modules)), + } + return marshalToolResult(result) + } + + result := map[string]any{ + "valid": true, + "summary": fmt.Sprintf("%d modules, %d workflows, %d triggers", len(cfg.Modules), len(cfg.Workflows), len(cfg.Triggers)), + } + return marshalToolResult(result) +} + +func (s *Server) handleInspectConfig(_ context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + yamlContent := mcp.ParseString(req, "yaml_content", "") + if yamlContent == "" { + return mcp.NewToolResultError("yaml_content is required"), nil + } + + cfg, err := config.LoadFromString(yamlContent) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("YAML parse error: %v", err)), nil + } + + // Build modules summary + type moduleInfo struct { + Name string `json:"name"` + Type string `json:"type"` + DependsOn []string `json:"depends_on,omitempty"` + } + modules := make([]moduleInfo, 0, len(cfg.Modules)) + typeCount := make(map[string]int) + for _, mod := range cfg.Modules { + modules = append(modules, moduleInfo{ + Name: mod.Name, + Type: mod.Type, + DependsOn: mod.DependsOn, + }) + typeCount[mod.Type]++ + } + + // Module type distribution + types := make([]string, 0, len(typeCount)) + for t := range typeCount { + types = append(types, t) + } + sort.Strings(types) + typeDist := make(map[string]int) + for _, t := range types { + typeDist[t] = typeCount[t] + } + + // Workflow and trigger names + workflowNames := make([]string, 0, len(cfg.Workflows)) + for name := range cfg.Workflows { + workflowNames = append(workflowNames, name) + } + sort.Strings(workflowNames) + + triggerNames := make([]string, 0, len(cfg.Triggers)) + for name := range cfg.Triggers { + triggerNames = append(triggerNames, name) + } + sort.Strings(triggerNames) + + pipelineNames := make([]string, 0, len(cfg.Pipelines)) + for name := range cfg.Pipelines { + pipelineNames = append(pipelineNames, name) + } + sort.Strings(pipelineNames) + + // Dependency graph + var depEdges []string + for _, mod := range cfg.Modules { + for _, dep := range mod.DependsOn { + depEdges = append(depEdges, fmt.Sprintf("%s -> %s", mod.Name, dep)) + } + } + + result := map[string]any{ + "modules": modules, + "module_count": len(modules), + "module_types": typeDist, + "workflows": workflowNames, + "triggers": triggerNames, + "pipelines": pipelineNames, + "dependency_graph": depEdges, + } + return marshalToolResult(result) +} + +func (s *Server) handleListPlugins(_ context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + dataDir := mcp.ParseString(req, "data_dir", s.pluginDir) + if dataDir == "" { + dataDir = "data/plugins" + } + + entries, err := os.ReadDir(dataDir) + if os.IsNotExist(err) { + result := map[string]any{ + "plugins": []any{}, + "count": 0, + "message": "No plugins installed (directory does not exist)", + } + return marshalToolResult(result) + } + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to read plugin directory %s: %v", dataDir, err)), nil + } + + type pluginInfo struct { + Name string `json:"name"` + Version string `json:"version"` + } + var plugins []pluginInfo + for _, e := range entries { + if !e.IsDir() { + continue + } + ver := readPluginVersion(filepath.Join(dataDir, e.Name())) + plugins = append(plugins, pluginInfo{Name: e.Name(), Version: ver}) + } + + result := map[string]any{ + "plugins": plugins, + "count": len(plugins), + } + return marshalToolResult(result) +} + +func (s *Server) handleGetConfigSkeleton(_ context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + rawTypes := req.Params.Arguments["module_types"] + if rawTypes == nil { + return mcp.NewToolResultError("module_types is required"), nil + } + + typesSlice, ok := rawTypes.([]any) + if !ok { + return mcp.NewToolResultError("module_types must be an array of strings"), nil + } + + var moduleTypes []string + for _, t := range typesSlice { + if s, ok := t.(string); ok { + moduleTypes = append(moduleTypes, s) + } + } + + if len(moduleTypes) == 0 { + return mcp.NewToolResultError("at least one module type is required"), nil + } + + yaml := generateConfigSkeleton(moduleTypes) + return mcp.NewToolResultText(yaml), nil +} + +// --- Resource Handlers --- + +func (s *Server) handleDocsOverview(_ context.Context, _ mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + return []mcp.ResourceContents{ + mcp.TextResourceContents{ + URI: "workflow://docs/overview", + MIMEType: "text/markdown", + Text: docsOverview, + }, + }, nil +} + +func (s *Server) handleDocsYAMLSyntax(_ context.Context, _ mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + return []mcp.ResourceContents{ + mcp.TextResourceContents{ + URI: "workflow://docs/yaml-syntax", + MIMEType: "text/markdown", + Text: docsYAMLSyntax, + }, + }, nil +} + +func (s *Server) handleDocsModuleReference(_ context.Context, _ mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + // Dynamically generate module reference from known types + doc := generateModuleReference() + return []mcp.ResourceContents{ + mcp.TextResourceContents{ + URI: "workflow://docs/module-reference", + MIMEType: "text/markdown", + Text: doc, + }, + }, nil +} + +// --- Helpers --- + +func marshalToolResult(v any) (*mcp.CallToolResult, error) { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("internal error: %v", err)), nil + } + return mcp.NewToolResultText(string(data)), nil +} + +func readPluginVersion(dir string) string { + data, err := os.ReadFile(filepath.Join(dir, "plugin.json")) + if err != nil { + return "unknown" + } + var m struct { + Version string `json:"version"` + } + if err := json.Unmarshal(data, &m); err != nil || m.Version == "" { + return "unknown" + } + return m.Version +} + +func generateConfigSkeleton(moduleTypes []string) string { + var b strings.Builder + b.WriteString("# Workflow configuration skeleton\n") + b.WriteString("# Generated by workflow MCP server\n\n") + b.WriteString("modules:\n") + + for i, mt := range moduleTypes { + name := strings.ReplaceAll(mt, ".", "-") + fmt.Fprintf(&b, " - name: %s-%d\n", name, i+1) + fmt.Fprintf(&b, " type: %s\n", mt) + b.WriteString(" config: {}\n") + if i < len(moduleTypes)-1 { + b.WriteString("\n") + } + } + + b.WriteString("\nworkflows: {}\n") + b.WriteString("\ntriggers: {}\n") + + return b.String() +} + +func generateModuleReference() string { + types := schema.KnownModuleTypes() + + // Group by prefix + groups := make(map[string][]string) + for _, t := range types { + parts := strings.SplitN(t, ".", 2) + prefix := parts[0] + groups[prefix] = append(groups[prefix], t) + } + + prefixes := make([]string, 0, len(groups)) + for p := range groups { + prefixes = append(prefixes, p) + } + sort.Strings(prefixes) + + var b strings.Builder + b.WriteString("# Module Type Reference\n\n") + b.WriteString("This reference lists all available module types grouped by category.\n\n") + + for _, prefix := range prefixes { + fmt.Fprintf(&b, "## %s\n\n", cases.Title(language.English).String(prefix)) + for _, t := range groups[prefix] { + fmt.Fprintf(&b, "- `%s`\n", t) + } + b.WriteString("\n") + } + + // Add step types separately + b.WriteString("## Pipeline Steps\n\n") + b.WriteString("Pipeline steps are used in the `pipelines` section and execute sequentially.\n\n") + for _, t := range types { + if strings.HasPrefix(t, "step.") { + fmt.Fprintf(&b, "- `%s`\n", t) + } + } + b.WriteString("\n") + + return b.String() +} diff --git a/mcp/server_test.go b/mcp/server_test.go new file mode 100644 index 00000000..20a5a561 --- /dev/null +++ b/mcp/server_test.go @@ -0,0 +1,551 @@ +package mcp + +import ( + "context" + "encoding/json" + "os" + "strings" + "testing" + + "github.com/mark3labs/mcp-go/mcp" +) + +func TestNewServer(t *testing.T) { + srv := NewServer("testdata/plugins") + if srv == nil { + t.Fatal("NewServer returned nil") + } + if srv.MCPServer() == nil { + t.Fatal("MCPServer() returned nil") + } +} + +func TestListModuleTypes(t *testing.T) { + srv := NewServer("") + result, err := srv.handleListModuleTypes(context.Background(), mcp.CallToolRequest{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result == nil { + t.Fatal("result is nil") + } + + 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) + } + + types, ok := data["module_types"].([]any) + if !ok { + t.Fatal("module_types not found in result") + } + if len(types) == 0 { + t.Fatal("expected at least one module type") + } + + // Verify some known types are present + typeSet := make(map[string]bool) + for _, tt := range types { + typeSet[tt.(string)] = true + } + for _, expected := range []string{"http.server", "http.router", "http.handler", "messaging.broker"} { + if !typeSet[expected] { + t.Errorf("expected module type %q not found", expected) + } + } +} + +func TestListStepTypes(t *testing.T) { + srv := NewServer("") + result, err := srv.handleListStepTypes(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) + } + + steps, ok := data["step_types"].([]any) + if !ok { + t.Fatal("step_types not found in result") + } + if len(steps) == 0 { + t.Fatal("expected at least one step type") + } + + // All step types should start with "step." + for _, s := range steps { + str := s.(string) + if !strings.HasPrefix(str, "step.") { + t.Errorf("step type %q does not start with 'step.'", str) + } + } +} + +func TestListTriggerTypes(t *testing.T) { + srv := NewServer("") + result, err := srv.handleListTriggerTypes(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) + } + + triggers, ok := data["trigger_types"].([]any) + if !ok { + t.Fatal("trigger_types not found in result") + } + if len(triggers) == 0 { + t.Fatal("expected at least one trigger type") + } + + typeSet := make(map[string]bool) + for _, tt := range triggers { + typeSet[tt.(string)] = true + } + if !typeSet["http"] || !typeSet["schedule"] { + t.Error("expected http and schedule trigger types") + } +} + +func TestListWorkflowTypes(t *testing.T) { + srv := NewServer("") + result, err := srv.handleListWorkflowTypes(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) + } + + workflows, ok := data["workflow_types"].([]any) + if !ok { + t.Fatal("workflow_types not found in result") + } + if len(workflows) == 0 { + t.Fatal("expected at least one workflow type") + } + + typeSet := make(map[string]bool) + for _, wt := range workflows { + typeSet[wt.(string)] = true + } + if !typeSet["http"] || !typeSet["messaging"] { + t.Error("expected http and messaging workflow types") + } +} + +func TestGenerateSchema(t *testing.T) { + srv := NewServer("") + result, err := srv.handleGenerateSchema(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 schema JSON: %v", err) + } + + if data["$schema"] == nil { + t.Error("schema should have $schema field") + } + if data["properties"] == nil { + t.Error("schema should have properties field") + } +} + +func TestValidateConfig_Valid(t *testing.T) { + srv := NewServer("") + + validYAML := ` +modules: + - name: webServer + type: http.server + config: + address: ":8080" + - name: router + type: http.router + dependsOn: [webServer] +` + + req := makeCallToolRequest(map[string]any{ + "yaml_content": validYAML, + }) + + result, err := srv.handleValidateConfig(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["valid"] != true { + t.Errorf("expected valid=true, got %v", data["valid"]) + } +} + +func TestValidateConfig_Invalid(t *testing.T) { + srv := NewServer("") + + invalidYAML := ` +modules: + - name: "" + type: http.server +` + + req := makeCallToolRequest(map[string]any{ + "yaml_content": invalidYAML, + "strict": true, + }) + + result, err := srv.handleValidateConfig(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["valid"] != false { + t.Errorf("expected valid=false, got %v", data["valid"]) + } +} + +func TestValidateConfig_MissingContent(t *testing.T) { + srv := NewServer("") + + req := makeCallToolRequest(map[string]any{}) + result, err := srv.handleValidateConfig(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + text := extractText(t, result) + if text == "" { + t.Fatal("expected error message") + } +} + +func TestValidateConfig_MalformedYAML(t *testing.T) { + srv := NewServer("") + + req := makeCallToolRequest(map[string]any{ + "yaml_content": "{{invalid yaml", + }) + + result, err := srv.handleValidateConfig(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 TestInspectConfig(t *testing.T) { + srv := NewServer("") + + yaml := ` +modules: + - name: webServer + type: http.server + config: + address: ":8080" + - name: router + type: http.router + dependsOn: [webServer] + - name: handler + type: http.handler + dependsOn: [router] + +workflows: + http: + routes: + - method: GET + path: /api/health + handler: handler + +triggers: + http: + routes: + - method: GET + path: /api/health +` + + req := makeCallToolRequest(map[string]any{ + "yaml_content": yaml, + }) + + result, err := srv.handleInspectConfig(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["module_count"].(float64) != 3 { + t.Errorf("expected 3 modules, got %v", data["module_count"]) + } + if len(data["workflows"].([]any)) != 1 { + t.Errorf("expected 1 workflow, got %v", len(data["workflows"].([]any))) + } + if len(data["triggers"].([]any)) != 1 { + t.Errorf("expected 1 trigger, got %v", len(data["triggers"].([]any))) + } +} + +func TestInspectConfig_MissingContent(t *testing.T) { + srv := NewServer("") + + req := makeCallToolRequest(map[string]any{}) + result, err := srv.handleInspectConfig(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + text := extractText(t, result) + if text == "" { + t.Fatal("expected error message") + } +} + +func TestListPlugins_NoDir(t *testing.T) { + srv := NewServer("/nonexistent/path") + + req := makeCallToolRequest(map[string]any{}) + result, err := srv.handleListPlugins(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["count"].(float64) != 0 { + t.Errorf("expected 0 plugins for nonexistent dir, got %v", data["count"]) + } +} + +func TestListPlugins_WithPlugins(t *testing.T) { + // Create a temp directory with a fake plugin + dir := t.TempDir() + pluginDir := dir + "/test-plugin" + if err := createTestPlugin(pluginDir, "1.2.3"); err != nil { + t.Fatal(err) + } + + srv := NewServer(dir) + req := makeCallToolRequest(map[string]any{}) + result, err := srv.handleListPlugins(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["count"].(float64) != 1 { + t.Errorf("expected 1 plugin, got %v", data["count"]) + } + + plugins := data["plugins"].([]any) + plugin := plugins[0].(map[string]any) + if plugin["name"] != "test-plugin" { + t.Errorf("expected plugin name 'test-plugin', got %v", plugin["name"]) + } + if plugin["version"] != "1.2.3" { + t.Errorf("expected version '1.2.3', got %v", plugin["version"]) + } +} + +func TestGetConfigSkeleton(t *testing.T) { + srv := NewServer("") + + req := makeCallToolRequest(map[string]any{ + "module_types": []any{"http.server", "http.router"}, + }) + + result, err := srv.handleGetConfigSkeleton(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + text := extractText(t, result) + if text == "" { + t.Fatal("expected non-empty skeleton") + } + + // Verify the skeleton contains expected content + if !contains(text, "http.server") { + t.Error("skeleton should contain http.server") + } + if !contains(text, "http.router") { + t.Error("skeleton should contain http.router") + } + if !contains(text, "modules:") { + t.Error("skeleton should contain modules: section") + } +} + +func TestGetConfigSkeleton_NoTypes(t *testing.T) { + srv := NewServer("") + + req := makeCallToolRequest(map[string]any{ + "module_types": []any{}, + }) + + result, err := srv.handleGetConfigSkeleton(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + text := extractText(t, result) + if text == "" { + t.Fatal("expected error message") + } +} + +func TestDocsOverview(t *testing.T) { + srv := NewServer("") + contents, err := srv.handleDocsOverview(context.Background(), mcp.ReadResourceRequest{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(contents) != 1 { + t.Fatalf("expected 1 resource content, got %d", len(contents)) + } + text, ok := contents[0].(mcp.TextResourceContents) + if !ok { + t.Fatal("expected TextResourceContents") + } + if text.Text == "" { + t.Fatal("expected non-empty overview text") + } + if !contains(text.Text, "Workflow Engine") { + t.Error("overview should mention 'Workflow Engine'") + } +} + +func TestDocsYAMLSyntax(t *testing.T) { + srv := NewServer("") + contents, err := srv.handleDocsYAMLSyntax(context.Background(), mcp.ReadResourceRequest{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(contents) != 1 { + t.Fatalf("expected 1 resource content, got %d", len(contents)) + } + text, ok := contents[0].(mcp.TextResourceContents) + if !ok { + t.Fatal("expected TextResourceContents") + } + if !contains(text.Text, "modules:") { + t.Error("YAML syntax guide should contain 'modules:'") + } +} + +func TestDocsModuleReference(t *testing.T) { + srv := NewServer("") + contents, err := srv.handleDocsModuleReference(context.Background(), mcp.ReadResourceRequest{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(contents) != 1 { + t.Fatalf("expected 1 resource content, got %d", len(contents)) + } + text, ok := contents[0].(mcp.TextResourceContents) + if !ok { + t.Fatal("expected TextResourceContents") + } + if !contains(text.Text, "http.server") { + t.Error("module reference should contain 'http.server'") + } +} + +func TestGenerateConfigSkeleton(t *testing.T) { + yaml := generateConfigSkeleton([]string{"http.server", "http.router"}) + if !contains(yaml, "http.server") { + t.Error("skeleton should contain http.server") + } + if !contains(yaml, "http-server-1") { + t.Error("skeleton should generate name 'http-server-1'") + } + if !contains(yaml, "http-router-2") { + t.Error("skeleton should generate name 'http-router-2'") + } +} + +func TestGenerateModuleReference(t *testing.T) { + ref := generateModuleReference() + if ref == "" { + t.Fatal("module reference should not be empty") + } + if !contains(ref, "Module Type Reference") { + t.Error("reference should have title") + } +} + +// --- Test Helpers --- + +func extractText(t *testing.T, result *mcp.CallToolResult) string { + t.Helper() + if result == nil { + return "" + } + for _, c := range result.Content { + if tc, ok := c.(mcp.TextContent); ok { + return tc.Text + } + } + return "" +} + +func makeCallToolRequest(args map[string]any) mcp.CallToolRequest { + req := mcp.CallToolRequest{} + req.Params.Arguments = args + return req +} + +func contains(s, substr string) bool { + return strings.Contains(s, substr) +} + +func createTestPlugin(dir, version string) error { + if err := os.MkdirAll(dir, 0750); err != nil { + return err + } + data := []byte(`{"name":"test-plugin","version":"` + version + `"}`) + return os.WriteFile(dir+"/plugin.json", data, 0640) //nolint:gosec // G306: test helper +}