Skip to content
Merged
819 changes: 656 additions & 163 deletions DOCUMENTATION.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions cmd/wfctl/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ func runMCP(args []string) error {
fs := flag.NewFlagSet("mcp", flag.ContinueOnError)
pluginDir := fs.String("plugin-dir", "data/plugins", "Plugin data directory")
registryDir := fs.String("registry-dir", "", "Path to cloned workflow-registry for plugin search")
documentationFile := fs.String("documentation-file", "", "Path to DOCUMENTATION.md (auto-detected when empty)")
fs.Usage = func() {
fmt.Fprintf(fs.Output(), `Usage: wfctl mcp [options]

Expand Down Expand Up @@ -54,6 +55,9 @@ See docs/mcp.md for full setup instructions.
if *registryDir != "" {
opts = append(opts, workflowmcp.WithRegistryDir(*registryDir))
}
if *documentationFile != "" {
opts = append(opts, workflowmcp.WithDocumentationFile(*documentationFile))
}

srv := workflowmcp.NewServer(*pluginDir, opts...)
return srv.ServeStdio()
Expand Down
91 changes: 87 additions & 4 deletions mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,25 @@ func WithRegistryDir(dir string) ServerOption {
}
}

// WithDocumentationFile sets an explicit path to DOCUMENTATION.md so that the
// workflow://docs/full-reference MCP resource serves the actual repo documentation.
// When not set the server attempts to locate the file automatically (see
// handleDocsFullReference). If the file cannot be found the resource returns a
// brief message directing users to the public documentation URL.
func WithDocumentationFile(path string) ServerOption {
return func(s *Server) {
s.documentationFile = path
}
}

// Server wraps an MCP server instance and provides workflow-engine-specific
// tools and resources.
type Server struct {
mcpServer *server.MCPServer
pluginDir string
registryDir string
engine EngineProvider // optional; enables execution tools when set
mcpServer *server.MCPServer
pluginDir string
registryDir string
documentationFile string // optional explicit path to DOCUMENTATION.md
engine EngineProvider // optional; enables execution tools when set
}

// NewServer creates a new MCP server with all workflow engine tools and
Expand Down Expand Up @@ -278,6 +290,16 @@ func (s *Server) registerResources() {
),
s.handleDocsModuleReference,
)

s.mcpServer.AddResource(
mcp.NewResource(
"workflow://docs/full-reference",
"Full Workflow Engine Documentation",
mcp.WithResourceDescription("Complete DOCUMENTATION.md from the GoCodeAlone/workflow repository: all module types, step types, pipeline steps, template functions, configuration format, workflow types, trigger types, CI/CD steps, platform steps, and detailed per-module reference."),
mcp.WithMIMEType("text/markdown"),
),
s.handleDocsFullReference,
)
}

// --- Tool Handlers ---
Expand Down Expand Up @@ -635,6 +657,67 @@ func (s *Server) handleDocsModuleReference(_ context.Context, _ mcp.ReadResource
}, nil
}

// handleDocsFullReference serves the complete DOCUMENTATION.md from the
// GoCodeAlone/workflow repository. It resolves the file in this order:
// 1. The explicit path set via WithDocumentationFile (if provided).
// 2. A path derived from the plugin directory (same parent-of-data layout used
// by handleGetConfigExamples): <pluginDir>/../../DOCUMENTATION.md.
// 3. DOCUMENTATION.md in the current working directory.
//
// If none of the candidates can be read, a fallback message with the public
// documentation URL is returned so the resource is always usable.
func (s *Server) handleDocsFullReference(_ context.Context, _ mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
content := s.resolveDocumentationContent()
return []mcp.ResourceContents{
mcp.TextResourceContents{
URI: "workflow://docs/full-reference",
MIMEType: "text/markdown",
Text: content,
},
}, nil
}

// resolveDocumentationContent attempts to read DOCUMENTATION.md from several
// well-known locations and returns its content, or a fallback string on failure.
func (s *Server) resolveDocumentationContent() string {
candidates := s.documentationFileCandidates()
for _, p := range candidates {
if data, err := os.ReadFile(p); err == nil { //nolint:gosec // G304: path derived from trusted server config
return string(data)
}
}
return "# GoCodeAlone/workflow Documentation\n\n" +
"The full documentation (DOCUMENTATION.md) could not be found on the local filesystem.\n\n" +
"Please refer to the repository documentation at:\n" +
"https://github.com/GoCodeAlone/workflow/blob/main/DOCUMENTATION.md\n"
}

// documentationFileCandidates returns ordered candidate paths for DOCUMENTATION.md.
func (s *Server) documentationFileCandidates() []string {
var candidates []string

// 1. Explicit override via WithDocumentationFile.
if s.documentationFile != "" {
candidates = append(candidates, s.documentationFile)
}

// 2. Derive from pluginDir: <pluginDir> = .../data/plugins → root = pluginDir/../..
if s.pluginDir != "" {
pluginBase := filepath.Base(s.pluginDir)
dataDir := filepath.Dir(s.pluginDir)
dataBase := filepath.Base(dataDir)
if pluginBase == "plugins" && dataBase == "data" {
root := filepath.Dir(dataDir)
candidates = append(candidates, filepath.Join(root, "DOCUMENTATION.md"))
}
}

// 3. Current working directory.
candidates = append(candidates, "DOCUMENTATION.md")

return candidates
}

// --- Helpers ---

func marshalToolResult(v any) (*mcp.CallToolResult, error) {
Expand Down
247 changes: 247 additions & 0 deletions mcp/step_coverage_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
package mcp

import (
"context"
"encoding/json"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"testing"

"github.com/GoCodeAlone/workflow/capability"
"github.com/GoCodeAlone/workflow/plugin"
pluginall "github.com/GoCodeAlone/workflow/plugins/all"
"github.com/GoCodeAlone/workflow/schema"
"github.com/mark3labs/mcp-go/mcp"
)

// registerBuiltinPluginTypesForTest loads all built-in plugins into the global
// schema registries (schema.KnownModuleTypes / schema.GetStepSchemaRegistry)
// so that MCP tools that rely on these registries reflect the full type set.
// This mirrors what happens at runtime when the workflow engine calls LoadPlugin
// for each built-in plugin.
func registerBuiltinPluginTypesForTest(t *testing.T) {
t.Helper()
capReg := capability.NewRegistry()
schemaReg := schema.NewModuleSchemaRegistry()
loader := plugin.NewPluginLoader(capReg, schemaReg)
for _, p := range pluginall.DefaultPlugins() {
if err := loader.LoadPlugin(p); err != nil {
t.Fatalf("LoadPlugin(%q) failed: %v", p.Name(), err)
}
// Register module and step types into the global schema registry so
// that schema.KnownModuleTypes() and handleListStepTypes see them.
for typeName := range loader.ModuleFactories() {
schema.RegisterModuleType(typeName)
}
for typeName := range loader.StepFactories() {
schema.RegisterModuleType(typeName)
}
// Register rich step schemas (descriptions, config fields, outputs).
for _, ss := range loader.StepSchemaRegistry().All() {
schema.GetStepSchemaRegistry().Register(ss)
}
}
}

// TestListStepTypes_AllBuiltinsPresent validates that every step type registered
// by the built-in plugins (plugins/all) appears in the MCP list_step_types tool
// response. This is the MCP equivalent of TestDocumentationCoverage and ensures
// that wfctl's MCP server accurately reflects all available step types.
func TestListStepTypes_AllBuiltinsPresent(t *testing.T) {
registerBuiltinPluginTypesForTest(t)

srv := NewServer("")
result, err := srv.handleListStepTypes(context.Background(), mcp.CallToolRequest{})
if err != nil {
t.Fatalf("handleListStepTypes 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")
}
listed := make(map[string]bool, len(steps))
for _, s := range steps {
if entry, ok := s.(map[string]any); ok {
if typeName, ok := entry["type"].(string); ok {
listed[typeName] = true
}
}
}

// Collect all step types from the built-in plugins.
capReg := capability.NewRegistry()
schemaReg := schema.NewModuleSchemaRegistry()
loader := plugin.NewPluginLoader(capReg, schemaReg)
for _, p := range pluginall.DefaultPlugins() {
if err := loader.LoadPlugin(p); err != nil {
t.Fatalf("LoadPlugin(%q) failed: %v", p.Name(), err)
}
}

var missing []string
for typeName := range loader.StepFactories() {
if !listed[typeName] {
missing = append(missing, typeName)
}
}

if len(missing) > 0 {
sort.Strings(missing)
t.Errorf("step types registered by built-in plugins but missing from list_step_types (%d missing):\n %s\n\n"+
"Add these step types to schema/schema.go coreModuleTypes slice "+
"or register them via schema.RegisterModuleType so they appear in KnownModuleTypes.",
len(missing), strings.Join(missing, "\n "))
}
}

// TestListModuleTypes_AllBuiltinsPresent validates that every module type registered
// by the built-in plugins (plugins/all) appears in the MCP list_module_types tool
// response.
func TestListModuleTypes_AllBuiltinsPresent(t *testing.T) {
registerBuiltinPluginTypesForTest(t)

srv := NewServer("")
result, err := srv.handleListModuleTypes(context.Background(), mcp.CallToolRequest{})
if err != nil {
t.Fatalf("handleListModuleTypes 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)
}

rawTypes, ok := data["module_types"].([]any)
if !ok {
t.Fatal("module_types not found in result")
}
listed := make(map[string]bool, len(rawTypes))
for _, mt := range rawTypes {
if s, ok := mt.(string); ok {
listed[s] = true
}
}

// Collect all module types from the built-in plugins.
capReg := capability.NewRegistry()
schemaReg := schema.NewModuleSchemaRegistry()
loader := plugin.NewPluginLoader(capReg, schemaReg)
for _, p := range pluginall.DefaultPlugins() {
if err := loader.LoadPlugin(p); err != nil {
t.Fatalf("LoadPlugin(%q) failed: %v", p.Name(), err)
}
}

var missing []string
for typeName := range loader.ModuleFactories() {
if !listed[typeName] {
missing = append(missing, typeName)
}
}

if len(missing) > 0 {
sort.Strings(missing)
t.Errorf("module types registered by built-in plugins but missing from list_module_types (%d missing):\n %s\n\n"+
"Add these module types to schema/schema.go coreModuleTypes slice "+
"or register them via schema.RegisterModuleType so they appear in KnownModuleTypes.",
len(missing), strings.Join(missing, "\n "))
}
}

// TestDocsFullReference_Fallback verifies that the full-reference resource
// returns a usable fallback when DOCUMENTATION.md cannot be found.
func TestDocsFullReference_Fallback(t *testing.T) {
// Use a server with a non-existent plugin dir so no file is found.
srv := NewServer("/nonexistent/data/plugins")
contents, err := srv.handleDocsFullReference(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.URI != "workflow://docs/full-reference" {
t.Errorf("unexpected URI: %q", text.URI)
}
if text.MIMEType != "text/markdown" {
t.Errorf("unexpected MIME type: %q", text.MIMEType)
}
if !strings.Contains(text.Text, "GoCodeAlone/workflow") {
t.Error("fallback text should mention 'GoCodeAlone/workflow'")
}
}

// TestDocsFullReference_WithFile verifies that the full-reference resource
// serves the provided file content when WithDocumentationFile is used.
func TestDocsFullReference_WithFile(t *testing.T) {
// Write a temporary DOCUMENTATION.md-like file.
dir := t.TempDir()
docPath := filepath.Join(dir, "DOCUMENTATION.md")
content := "# Workflow Engine Documentation\n\nTest content.\n"
if err := os.WriteFile(docPath, []byte(content), 0600); err != nil {
t.Fatalf("failed to write temp file: %v", err)
}

srv := NewServer("", WithDocumentationFile(docPath))
contents, err := srv.handleDocsFullReference(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 != content {
t.Errorf("expected file content %q, got %q", content, text.Text)
}
}

// TestDocsFullReference_RepoFile verifies that the full-reference resource
// serves the actual DOCUMENTATION.md when it exists next to the test.
func TestDocsFullReference_RepoFile(t *testing.T) {
// Locate the repo root via the test file's path.
_, testFilePath, _, ok := runtime.Caller(0)
if !ok {
t.Skip("runtime.Caller failed")
}
repoRoot := filepath.Join(filepath.Dir(testFilePath), "..")
docPath := filepath.Join(repoRoot, "DOCUMENTATION.md")
if _, err := os.Stat(docPath); err != nil {
t.Skipf("DOCUMENTATION.md not found at %q: %v", docPath, err)
}

srv := NewServer("", WithDocumentationFile(docPath))
contents, err := srv.handleDocsFullReference(context.Background(), mcp.ReadResourceRequest{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
text, ok := contents[0].(mcp.TextResourceContents)
if !ok {
t.Fatal("expected TextResourceContents")
}

// Spot-check a few key strings that should be in DOCUMENTATION.md.
for _, want := range []string{"openapi", "auth.m2m", "database.partitioned", "config.provider"} {
if !strings.Contains(text.Text, want) {
t.Errorf("DOCUMENTATION.md should contain %q", want)
}
}
}
2 changes: 2 additions & 0 deletions plugins/all/all.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import (
pluginmessaging "github.com/GoCodeAlone/workflow/plugins/messaging"
pluginmodcompat "github.com/GoCodeAlone/workflow/plugins/modularcompat"
pluginobs "github.com/GoCodeAlone/workflow/plugins/observability"
pluginopenapi "github.com/GoCodeAlone/workflow/plugins/openapi"
pluginpipeline "github.com/GoCodeAlone/workflow/plugins/pipelinesteps"
pluginplatform "github.com/GoCodeAlone/workflow/plugins/platform"
pluginpolicy "github.com/GoCodeAlone/workflow/plugins/policy"
Expand All @@ -67,6 +68,7 @@ func DefaultPlugins() []plugin.EnginePlugin {
pluginlicense.New(),
pluginconfigprovider.New(),
pluginhttp.New(),
pluginopenapi.New(),
pluginobs.New(),
pluginmessaging.New(),
pluginsm.New(),
Expand Down
Loading
Loading