Skip to content

Commit a3a80a1

Browse files
Copilotintel352
andauthored
docs: sync DOCUMENTATION.md with all registered module/step types + add CI and MCP coverage (#296)
* Initial plan * fix: sync DOCUMENTATION.md with all registered module/step types and add CI coverage test - Add TestDocumentationCoverage test in plugins/all that fails if any registered module or step type is missing from DOCUMENTATION.md (prevents future drift) - Update module type tables: add Plugin column, add 35 previously undocumented module types (api.gateway, auth.m2m, auth.oauth2, auth.token-blacklist, security.field-protection, openapi, config.provider, database.partitioned, dlq.service, eventstore.service, featureflag.service, timeline.service, nosql.*, platform.kubernetes/ecs/dns/networking/..., argo.workflows, app.container, aws.codebuild, gitlab.webhook/client, cloud.account, security.scanner, policy.mock, storage.artifact, cache.redis, tracing.propagation, http.middleware.otel, etc.) - Mark removed types as deprecated with migration notes (auth.modular, chimux.router, database.modular, eventlogger.modular, httpclient.modular, httpserver.modular, eventbus.modular) - Add 104 previously undocumented step types across all plugins with Plugin column - Add Platform & Infrastructure Pipeline Steps section (47 new step types) - Add new module sections: NoSQL/Datastores, Event Sourcing & Messaging Services - Add detailed reference docs for openapi, auth.m2m, api.gateway, database.partitioned, config.provider, featureflag.service, dlq.service, eventstore.service, timeline.service Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> * fix: address code review - fix count accuracy and standardize to US English spelling Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> * feat(mcp): add step/module coverage validation and expose DOCUMENTATION.md as MCP resource - Add TestListStepTypes_AllBuiltinsPresent and TestListModuleTypes_AllBuiltinsPresent in mcp/step_coverage_test.go: loads all built-in plugins via plugins/all.DefaultPlugins(), registers their types into global schema registries, and verifies handleListStepTypes / handleListModuleTypes return every registered type (MCP equivalent of TestDocumentationCoverage) - Add workflow://docs/full-reference MCP resource that serves DOCUMENTATION.md content; resolves file via: explicit WithDocumentationFile path → pluginDir-relative path → CWD → graceful fallback message with public repo URL - Add WithDocumentationFile ServerOption to mcp.Server for explicit doc file path - Add -documentation-file flag to `wfctl mcp` command - Add TestDocsFullReference_* tests for fallback, WithFile, and repo file cases Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> * fix: apply PR review feedback on openapi docs accuracy - Add plugins/openapi to plugins/all.DefaultPlugins() so openapi module type is part of the standard plugin set (resolves review comment on line 24 claiming all types are in plugins/all) - Fix openapi swagger_ui config docs: it's an object {enabled, path}, not a bool; default path is /docs not /swagger/ (review comment line 524-532) - Remove validation.response and validation.response_action from openapi docs and example — Validation.Response field exists in the struct but is never read in the route handling code, and ResponseAction does not exist at all (review comment line 526-531) - Fix openapi module table description: "request/response validation" → "request validation" Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> * merge: resolve DOCUMENTATION.md conflict with main — add step.json_parse, restore response validation, trustedKeys, autoSync docs Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> Co-authored-by: Jonathan Langevin <codingsloth@pm.me>
1 parent e70843e commit a3a80a1

6 files changed

Lines changed: 1071 additions & 167 deletions

File tree

DOCUMENTATION.md

Lines changed: 656 additions & 163 deletions
Large diffs are not rendered by default.

cmd/wfctl/mcp.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ func runMCP(args []string) error {
1313
fs := flag.NewFlagSet("mcp", flag.ContinueOnError)
1414
pluginDir := fs.String("plugin-dir", "data/plugins", "Plugin data directory")
1515
registryDir := fs.String("registry-dir", "", "Path to cloned workflow-registry for plugin search")
16+
documentationFile := fs.String("documentation-file", "", "Path to DOCUMENTATION.md (auto-detected when empty)")
1617
fs.Usage = func() {
1718
fmt.Fprintf(fs.Output(), `Usage: wfctl mcp [options]
1819
@@ -54,6 +55,9 @@ See docs/mcp.md for full setup instructions.
5455
if *registryDir != "" {
5556
opts = append(opts, workflowmcp.WithRegistryDir(*registryDir))
5657
}
58+
if *documentationFile != "" {
59+
opts = append(opts, workflowmcp.WithDocumentationFile(*documentationFile))
60+
}
5761

5862
srv := workflowmcp.NewServer(*pluginDir, opts...)
5963
return srv.ServeStdio()

mcp/server.go

Lines changed: 87 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,25 @@ func WithRegistryDir(dir string) ServerOption {
5757
}
5858
}
5959

60+
// WithDocumentationFile sets an explicit path to DOCUMENTATION.md so that the
61+
// workflow://docs/full-reference MCP resource serves the actual repo documentation.
62+
// When not set the server attempts to locate the file automatically (see
63+
// handleDocsFullReference). If the file cannot be found the resource returns a
64+
// brief message directing users to the public documentation URL.
65+
func WithDocumentationFile(path string) ServerOption {
66+
return func(s *Server) {
67+
s.documentationFile = path
68+
}
69+
}
70+
6071
// Server wraps an MCP server instance and provides workflow-engine-specific
6172
// tools and resources.
6273
type Server struct {
63-
mcpServer *server.MCPServer
64-
pluginDir string
65-
registryDir string
66-
engine EngineProvider // optional; enables execution tools when set
74+
mcpServer *server.MCPServer
75+
pluginDir string
76+
registryDir string
77+
documentationFile string // optional explicit path to DOCUMENTATION.md
78+
engine EngineProvider // optional; enables execution tools when set
6779
}
6880

6981
// NewServer creates a new MCP server with all workflow engine tools and
@@ -278,6 +290,16 @@ func (s *Server) registerResources() {
278290
),
279291
s.handleDocsModuleReference,
280292
)
293+
294+
s.mcpServer.AddResource(
295+
mcp.NewResource(
296+
"workflow://docs/full-reference",
297+
"Full Workflow Engine Documentation",
298+
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."),
299+
mcp.WithMIMEType("text/markdown"),
300+
),
301+
s.handleDocsFullReference,
302+
)
281303
}
282304

283305
// --- Tool Handlers ---
@@ -635,6 +657,67 @@ func (s *Server) handleDocsModuleReference(_ context.Context, _ mcp.ReadResource
635657
}, nil
636658
}
637659

660+
// handleDocsFullReference serves the complete DOCUMENTATION.md from the
661+
// GoCodeAlone/workflow repository. It resolves the file in this order:
662+
// 1. The explicit path set via WithDocumentationFile (if provided).
663+
// 2. A path derived from the plugin directory (same parent-of-data layout used
664+
// by handleGetConfigExamples): <pluginDir>/../../DOCUMENTATION.md.
665+
// 3. DOCUMENTATION.md in the current working directory.
666+
//
667+
// If none of the candidates can be read, a fallback message with the public
668+
// documentation URL is returned so the resource is always usable.
669+
func (s *Server) handleDocsFullReference(_ context.Context, _ mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
670+
content := s.resolveDocumentationContent()
671+
return []mcp.ResourceContents{
672+
mcp.TextResourceContents{
673+
URI: "workflow://docs/full-reference",
674+
MIMEType: "text/markdown",
675+
Text: content,
676+
},
677+
}, nil
678+
}
679+
680+
// resolveDocumentationContent attempts to read DOCUMENTATION.md from several
681+
// well-known locations and returns its content, or a fallback string on failure.
682+
func (s *Server) resolveDocumentationContent() string {
683+
candidates := s.documentationFileCandidates()
684+
for _, p := range candidates {
685+
if data, err := os.ReadFile(p); err == nil { //nolint:gosec // G304: path derived from trusted server config
686+
return string(data)
687+
}
688+
}
689+
return "# GoCodeAlone/workflow Documentation\n\n" +
690+
"The full documentation (DOCUMENTATION.md) could not be found on the local filesystem.\n\n" +
691+
"Please refer to the repository documentation at:\n" +
692+
"https://github.com/GoCodeAlone/workflow/blob/main/DOCUMENTATION.md\n"
693+
}
694+
695+
// documentationFileCandidates returns ordered candidate paths for DOCUMENTATION.md.
696+
func (s *Server) documentationFileCandidates() []string {
697+
var candidates []string
698+
699+
// 1. Explicit override via WithDocumentationFile.
700+
if s.documentationFile != "" {
701+
candidates = append(candidates, s.documentationFile)
702+
}
703+
704+
// 2. Derive from pluginDir: <pluginDir> = .../data/plugins → root = pluginDir/../..
705+
if s.pluginDir != "" {
706+
pluginBase := filepath.Base(s.pluginDir)
707+
dataDir := filepath.Dir(s.pluginDir)
708+
dataBase := filepath.Base(dataDir)
709+
if pluginBase == "plugins" && dataBase == "data" {
710+
root := filepath.Dir(dataDir)
711+
candidates = append(candidates, filepath.Join(root, "DOCUMENTATION.md"))
712+
}
713+
}
714+
715+
// 3. Current working directory.
716+
candidates = append(candidates, "DOCUMENTATION.md")
717+
718+
return candidates
719+
}
720+
638721
// --- Helpers ---
639722

640723
func marshalToolResult(v any) (*mcp.CallToolResult, error) {

mcp/step_coverage_test.go

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
package mcp
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"os"
7+
"path/filepath"
8+
"runtime"
9+
"sort"
10+
"strings"
11+
"testing"
12+
13+
"github.com/GoCodeAlone/workflow/capability"
14+
"github.com/GoCodeAlone/workflow/plugin"
15+
pluginall "github.com/GoCodeAlone/workflow/plugins/all"
16+
"github.com/GoCodeAlone/workflow/schema"
17+
"github.com/mark3labs/mcp-go/mcp"
18+
)
19+
20+
// registerBuiltinPluginTypesForTest loads all built-in plugins into the global
21+
// schema registries (schema.KnownModuleTypes / schema.GetStepSchemaRegistry)
22+
// so that MCP tools that rely on these registries reflect the full type set.
23+
// This mirrors what happens at runtime when the workflow engine calls LoadPlugin
24+
// for each built-in plugin.
25+
func registerBuiltinPluginTypesForTest(t *testing.T) {
26+
t.Helper()
27+
capReg := capability.NewRegistry()
28+
schemaReg := schema.NewModuleSchemaRegistry()
29+
loader := plugin.NewPluginLoader(capReg, schemaReg)
30+
for _, p := range pluginall.DefaultPlugins() {
31+
if err := loader.LoadPlugin(p); err != nil {
32+
t.Fatalf("LoadPlugin(%q) failed: %v", p.Name(), err)
33+
}
34+
// Register module and step types into the global schema registry so
35+
// that schema.KnownModuleTypes() and handleListStepTypes see them.
36+
for typeName := range loader.ModuleFactories() {
37+
schema.RegisterModuleType(typeName)
38+
}
39+
for typeName := range loader.StepFactories() {
40+
schema.RegisterModuleType(typeName)
41+
}
42+
// Register rich step schemas (descriptions, config fields, outputs).
43+
for _, ss := range loader.StepSchemaRegistry().All() {
44+
schema.GetStepSchemaRegistry().Register(ss)
45+
}
46+
}
47+
}
48+
49+
// TestListStepTypes_AllBuiltinsPresent validates that every step type registered
50+
// by the built-in plugins (plugins/all) appears in the MCP list_step_types tool
51+
// response. This is the MCP equivalent of TestDocumentationCoverage and ensures
52+
// that wfctl's MCP server accurately reflects all available step types.
53+
func TestListStepTypes_AllBuiltinsPresent(t *testing.T) {
54+
registerBuiltinPluginTypesForTest(t)
55+
56+
srv := NewServer("")
57+
result, err := srv.handleListStepTypes(context.Background(), mcp.CallToolRequest{})
58+
if err != nil {
59+
t.Fatalf("handleListStepTypes error: %v", err)
60+
}
61+
62+
text := extractText(t, result)
63+
var data map[string]any
64+
if err := json.Unmarshal([]byte(text), &data); err != nil {
65+
t.Fatalf("failed to parse result JSON: %v", err)
66+
}
67+
68+
steps, ok := data["step_types"].([]any)
69+
if !ok {
70+
t.Fatal("step_types not found in result")
71+
}
72+
listed := make(map[string]bool, len(steps))
73+
for _, s := range steps {
74+
if entry, ok := s.(map[string]any); ok {
75+
if typeName, ok := entry["type"].(string); ok {
76+
listed[typeName] = true
77+
}
78+
}
79+
}
80+
81+
// Collect all step types from the built-in plugins.
82+
capReg := capability.NewRegistry()
83+
schemaReg := schema.NewModuleSchemaRegistry()
84+
loader := plugin.NewPluginLoader(capReg, schemaReg)
85+
for _, p := range pluginall.DefaultPlugins() {
86+
if err := loader.LoadPlugin(p); err != nil {
87+
t.Fatalf("LoadPlugin(%q) failed: %v", p.Name(), err)
88+
}
89+
}
90+
91+
var missing []string
92+
for typeName := range loader.StepFactories() {
93+
if !listed[typeName] {
94+
missing = append(missing, typeName)
95+
}
96+
}
97+
98+
if len(missing) > 0 {
99+
sort.Strings(missing)
100+
t.Errorf("step types registered by built-in plugins but missing from list_step_types (%d missing):\n %s\n\n"+
101+
"Add these step types to schema/schema.go coreModuleTypes slice "+
102+
"or register them via schema.RegisterModuleType so they appear in KnownModuleTypes.",
103+
len(missing), strings.Join(missing, "\n "))
104+
}
105+
}
106+
107+
// TestListModuleTypes_AllBuiltinsPresent validates that every module type registered
108+
// by the built-in plugins (plugins/all) appears in the MCP list_module_types tool
109+
// response.
110+
func TestListModuleTypes_AllBuiltinsPresent(t *testing.T) {
111+
registerBuiltinPluginTypesForTest(t)
112+
113+
srv := NewServer("")
114+
result, err := srv.handleListModuleTypes(context.Background(), mcp.CallToolRequest{})
115+
if err != nil {
116+
t.Fatalf("handleListModuleTypes error: %v", err)
117+
}
118+
119+
text := extractText(t, result)
120+
var data map[string]any
121+
if err := json.Unmarshal([]byte(text), &data); err != nil {
122+
t.Fatalf("failed to parse result JSON: %v", err)
123+
}
124+
125+
rawTypes, ok := data["module_types"].([]any)
126+
if !ok {
127+
t.Fatal("module_types not found in result")
128+
}
129+
listed := make(map[string]bool, len(rawTypes))
130+
for _, mt := range rawTypes {
131+
if s, ok := mt.(string); ok {
132+
listed[s] = true
133+
}
134+
}
135+
136+
// Collect all module types from the built-in plugins.
137+
capReg := capability.NewRegistry()
138+
schemaReg := schema.NewModuleSchemaRegistry()
139+
loader := plugin.NewPluginLoader(capReg, schemaReg)
140+
for _, p := range pluginall.DefaultPlugins() {
141+
if err := loader.LoadPlugin(p); err != nil {
142+
t.Fatalf("LoadPlugin(%q) failed: %v", p.Name(), err)
143+
}
144+
}
145+
146+
var missing []string
147+
for typeName := range loader.ModuleFactories() {
148+
if !listed[typeName] {
149+
missing = append(missing, typeName)
150+
}
151+
}
152+
153+
if len(missing) > 0 {
154+
sort.Strings(missing)
155+
t.Errorf("module types registered by built-in plugins but missing from list_module_types (%d missing):\n %s\n\n"+
156+
"Add these module types to schema/schema.go coreModuleTypes slice "+
157+
"or register them via schema.RegisterModuleType so they appear in KnownModuleTypes.",
158+
len(missing), strings.Join(missing, "\n "))
159+
}
160+
}
161+
162+
// TestDocsFullReference_Fallback verifies that the full-reference resource
163+
// returns a usable fallback when DOCUMENTATION.md cannot be found.
164+
func TestDocsFullReference_Fallback(t *testing.T) {
165+
// Use a server with a non-existent plugin dir so no file is found.
166+
srv := NewServer("/nonexistent/data/plugins")
167+
contents, err := srv.handleDocsFullReference(context.Background(), mcp.ReadResourceRequest{})
168+
if err != nil {
169+
t.Fatalf("unexpected error: %v", err)
170+
}
171+
if len(contents) != 1 {
172+
t.Fatalf("expected 1 resource content, got %d", len(contents))
173+
}
174+
text, ok := contents[0].(mcp.TextResourceContents)
175+
if !ok {
176+
t.Fatal("expected TextResourceContents")
177+
}
178+
if text.URI != "workflow://docs/full-reference" {
179+
t.Errorf("unexpected URI: %q", text.URI)
180+
}
181+
if text.MIMEType != "text/markdown" {
182+
t.Errorf("unexpected MIME type: %q", text.MIMEType)
183+
}
184+
if !strings.Contains(text.Text, "GoCodeAlone/workflow") {
185+
t.Error("fallback text should mention 'GoCodeAlone/workflow'")
186+
}
187+
}
188+
189+
// TestDocsFullReference_WithFile verifies that the full-reference resource
190+
// serves the provided file content when WithDocumentationFile is used.
191+
func TestDocsFullReference_WithFile(t *testing.T) {
192+
// Write a temporary DOCUMENTATION.md-like file.
193+
dir := t.TempDir()
194+
docPath := filepath.Join(dir, "DOCUMENTATION.md")
195+
content := "# Workflow Engine Documentation\n\nTest content.\n"
196+
if err := os.WriteFile(docPath, []byte(content), 0600); err != nil {
197+
t.Fatalf("failed to write temp file: %v", err)
198+
}
199+
200+
srv := NewServer("", WithDocumentationFile(docPath))
201+
contents, err := srv.handleDocsFullReference(context.Background(), mcp.ReadResourceRequest{})
202+
if err != nil {
203+
t.Fatalf("unexpected error: %v", err)
204+
}
205+
if len(contents) != 1 {
206+
t.Fatalf("expected 1 resource content, got %d", len(contents))
207+
}
208+
text, ok := contents[0].(mcp.TextResourceContents)
209+
if !ok {
210+
t.Fatal("expected TextResourceContents")
211+
}
212+
if text.Text != content {
213+
t.Errorf("expected file content %q, got %q", content, text.Text)
214+
}
215+
}
216+
217+
// TestDocsFullReference_RepoFile verifies that the full-reference resource
218+
// serves the actual DOCUMENTATION.md when it exists next to the test.
219+
func TestDocsFullReference_RepoFile(t *testing.T) {
220+
// Locate the repo root via the test file's path.
221+
_, testFilePath, _, ok := runtime.Caller(0)
222+
if !ok {
223+
t.Skip("runtime.Caller failed")
224+
}
225+
repoRoot := filepath.Join(filepath.Dir(testFilePath), "..")
226+
docPath := filepath.Join(repoRoot, "DOCUMENTATION.md")
227+
if _, err := os.Stat(docPath); err != nil {
228+
t.Skipf("DOCUMENTATION.md not found at %q: %v", docPath, err)
229+
}
230+
231+
srv := NewServer("", WithDocumentationFile(docPath))
232+
contents, err := srv.handleDocsFullReference(context.Background(), mcp.ReadResourceRequest{})
233+
if err != nil {
234+
t.Fatalf("unexpected error: %v", err)
235+
}
236+
text, ok := contents[0].(mcp.TextResourceContents)
237+
if !ok {
238+
t.Fatal("expected TextResourceContents")
239+
}
240+
241+
// Spot-check a few key strings that should be in DOCUMENTATION.md.
242+
for _, want := range []string{"openapi", "auth.m2m", "database.partitioned", "config.provider"} {
243+
if !strings.Contains(text.Text, want) {
244+
t.Errorf("DOCUMENTATION.md should contain %q", want)
245+
}
246+
}
247+
}

plugins/all/all.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import (
4242
pluginmessaging "github.com/GoCodeAlone/workflow/plugins/messaging"
4343
pluginmodcompat "github.com/GoCodeAlone/workflow/plugins/modularcompat"
4444
pluginobs "github.com/GoCodeAlone/workflow/plugins/observability"
45+
pluginopenapi "github.com/GoCodeAlone/workflow/plugins/openapi"
4546
pluginpipeline "github.com/GoCodeAlone/workflow/plugins/pipelinesteps"
4647
pluginplatform "github.com/GoCodeAlone/workflow/plugins/platform"
4748
pluginpolicy "github.com/GoCodeAlone/workflow/plugins/policy"
@@ -67,6 +68,7 @@ func DefaultPlugins() []plugin.EnginePlugin {
6768
pluginlicense.New(),
6869
pluginconfigprovider.New(),
6970
pluginhttp.New(),
71+
pluginopenapi.New(),
7072
pluginobs.New(),
7173
pluginmessaging.New(),
7274
pluginsm.New(),

0 commit comments

Comments
 (0)