diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 652dd7da..da9d6e76 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -8,6 +8,10 @@ export default defineConfig({ base: '/', outDir: './dist', + sitemap: { + hostname: 'https://flowexec.io' + }, + head: [ ['link', { rel: 'icon', href: '/favicon.ico' }] ], diff --git a/docs/public/llms.txt b/docs/public/llms.txt new file mode 100644 index 00000000..0db07445 --- /dev/null +++ b/docs/public/llms.txt @@ -0,0 +1,124 @@ +# Flow + +> Flow is a local-first automation platform that helps you organize and execute any kind of workflow through declarative YAML. It provides a CLI, an interactive TUI, secret management via vaults, templating for code generation, and an MCP server for AI-agent integration. + +Flow organizes automation into **workspaces** (projects/domains), each rooted at a `flow.yaml` config file. Inside a workspace, **executables** are declared in `*.flow` (or `*.flow.yaml`) files and run by invoking a **verb** (e.g. `run`, `build`, `test`, `deploy`) optionally combined with a `workspace/namespace:name` reference. Executables can be simple commands, HTTP requests, or orchestrated serial/parallel pipelines. **Templates** (`*.flow.tmpl`) generate new workflows interactively. + +## Getting Started + +- [Home](https://flowexec.io/): Landing page with feature overview +- [Installation](https://flowexec.io/installation): Install flow on macOS, Linux, or Windows +- [Quick Start](https://flowexec.io/quickstart): Five-minute walkthrough from install to first executable + +## Core Guides + +- [Guides Overview](https://flowexec.io/guides/): Index of all user guides +- [Concepts](https://flowexec.io/guides/concepts): Executables, verbs, workspaces, namespaces, templates, vaults, execution model +- [Your First Workflow](https://flowexec.io/guides/first-workflow): End-to-end tutorial for building a workflow +- [Executables](https://flowexec.io/guides/executables): Full reference for executable types (exec, serial, parallel, request, launch, render) +- [Workspaces](https://flowexec.io/guides/workspaces): How workspaces organize projects and domains +- [Secrets](https://flowexec.io/guides/secrets): Vault-backed secret storage and injection into executions +- [Execution History & Logs](https://flowexec.io/guides/execution-history): Viewing, attaching to, and inspecting past runs + +## Advanced + +- [Imported Executables](https://flowexec.io/guides/generated-config): Auto-import from Makefile, package.json, docker-compose, shell scripts +- [Templates & Workflow Generation](https://flowexec.io/guides/templating): Generate executables from templates with interactive forms +- [Advanced Workflows](https://flowexec.io/guides/advanced): Serial/parallel pipelines, retries, conditional execution, arguments/params +- [Interactive UI](https://flowexec.io/guides/interactive): TUI customization and keybindings +- [Integrations](https://flowexec.io/guides/integrations): MCP server, GitHub Actions, Docker, CI pipelines + +## Configuration Reference + +- [Config Reference Overview](https://flowexec.io/types/): Index with direct JSON schema links +- [FlowFile Schema](https://flowexec.io/types/flowfile): Structure of `*.flow` / `*.flow.yaml` files +- [Workspace Schema](https://flowexec.io/types/workspace): Structure of `flow.yaml` workspace config +- [Template Schema](https://flowexec.io/types/template): Structure of `*.flow.tmpl` template files +- [Config Schema](https://flowexec.io/types/config): Structure of the user-level flow config + +## JSON Schemas + +- [flowfile_schema.json](https://flowexec.io/schemas/flowfile_schema.json): Use this to validate or generate `*.flow` files +- [workspace_schema.json](https://flowexec.io/schemas/workspace_schema.json): Use this to validate or generate `flow.yaml` files +- [template_schema.json](https://flowexec.io/schemas/template_schema.json): Use this to validate or generate `*.flow.tmpl` files +- [config_schema.json](https://flowexec.io/schemas/config_schema.json): Use this to validate or generate the user-level config + +## CLI Reference + +- [CLI Overview](https://flowexec.io/cli/flow): Top-level `flow` command and global flags +- [flow browse](https://flowexec.io/cli/flow_browse): Interactive TUI for discovering and running executables +- [flow exec](https://flowexec.io/cli/flow_exec): Run an executable directly +- [flow sync](https://flowexec.io/cli/flow_sync): Refresh cached executable and workspace state +- [flow mcp](https://flowexec.io/cli/flow_mcp): Start the MCP server over stdio for AI-agent integration + +### Logs + +- [flow logs](https://flowexec.io/cli/flow_logs): View execution history +- [flow logs attach](https://flowexec.io/cli/flow_logs_attach): Attach to a running background execution +- [flow logs clear](https://flowexec.io/cli/flow_logs_clear): Clear log history +- [flow logs kill](https://flowexec.io/cli/flow_logs_kill): Terminate a background execution + +### Cache + +- [flow cache](https://flowexec.io/cli/flow_cache): Manage cached executable metadata +- [flow cache clear](https://flowexec.io/cli/flow_cache_clear): Clear all cache entries +- [flow cache get](https://flowexec.io/cli/flow_cache_get): Read a specific cache entry +- [flow cache list](https://flowexec.io/cli/flow_cache_list): List cache entries +- [flow cache remove](https://flowexec.io/cli/flow_cache_remove): Remove a specific entry +- [flow cache set](https://flowexec.io/cli/flow_cache_set): Write a cache entry + +### Config + +- [flow config](https://flowexec.io/cli/flow_config): User-level configuration commands +- [flow config get](https://flowexec.io/cli/flow_config_get): Read current config +- [flow config reset](https://flowexec.io/cli/flow_config_reset): Reset to defaults +- [flow config set](https://flowexec.io/cli/flow_config_set): Update a config value +- [flow config set namespace](https://flowexec.io/cli/flow_config_set_namespace): Set current namespace +- [flow config set workspace](https://flowexec.io/cli/flow_config_set_workspace): Set current workspace +- [flow config set workspace-mode](https://flowexec.io/cli/flow_config_set_workspace-mode): Set fixed vs dynamic workspace mode +- [flow config set log-mode](https://flowexec.io/cli/flow_config_set_log-mode): Set default log output format +- [flow config set timeout](https://flowexec.io/cli/flow_config_set_timeout): Set default execution timeout +- [flow config set theme](https://flowexec.io/cli/flow_config_set_theme): Customize TUI theme +- [flow config set tui](https://flowexec.io/cli/flow_config_set_tui): Toggle interactive mode defaults +- [flow config set notifications](https://flowexec.io/cli/flow_config_set_notifications): Configure notification behavior + +### Secrets & Vaults + +- [flow secret](https://flowexec.io/cli/flow_secret): Manage secrets in the active vault +- [flow secret get](https://flowexec.io/cli/flow_secret_get): Read a secret value +- [flow secret list](https://flowexec.io/cli/flow_secret_list): List secrets +- [flow secret remove](https://flowexec.io/cli/flow_secret_remove): Delete a secret +- [flow secret set](https://flowexec.io/cli/flow_secret_set): Store a secret +- [flow vault](https://flowexec.io/cli/flow_vault): Manage vaults +- [flow vault create](https://flowexec.io/cli/flow_vault_create): Create a new vault (AES256, Age, Keyring, or unencrypted) +- [flow vault edit](https://flowexec.io/cli/flow_vault_edit): Edit vault metadata +- [flow vault get](https://flowexec.io/cli/flow_vault_get): Show vault details +- [flow vault list](https://flowexec.io/cli/flow_vault_list): List registered vaults +- [flow vault remove](https://flowexec.io/cli/flow_vault_remove): Remove a vault +- [flow vault switch](https://flowexec.io/cli/flow_vault_switch): Switch active vault + +### Templates + +- [flow template](https://flowexec.io/cli/flow_template): Manage workflow templates +- [flow template add](https://flowexec.io/cli/flow_template_add): Register a template +- [flow template generate](https://flowexec.io/cli/flow_template_generate): Generate a workflow from a template +- [flow template get](https://flowexec.io/cli/flow_template_get): Show template metadata +- [flow template list](https://flowexec.io/cli/flow_template_list): List registered templates + +### Workspaces + +- [flow workspace](https://flowexec.io/cli/flow_workspace): Workspace management commands +- [flow workspace add](https://flowexec.io/cli/flow_workspace_add): Register a workspace +- [flow workspace get](https://flowexec.io/cli/flow_workspace_get): Show workspace details +- [flow workspace list](https://flowexec.io/cli/flow_workspace_list): List registered workspaces +- [flow workspace remove](https://flowexec.io/cli/flow_workspace_remove): Unregister a workspace +- [flow workspace switch](https://flowexec.io/cli/flow_workspace_switch): Change active workspace +- [flow workspace view](https://flowexec.io/cli/flow_workspace_view): Open workspace in TUI + +## Optional + +- [Contributing](https://flowexec.io/development): Development setup and contribution guide +- [TUI Kit](https://flowexec.io/tuikit): Companion Bubble Tea-based TUI framework +- [GitHub Repository](https://github.com/flowexec/flow): Source code, issues, releases +- [Examples Repository](https://github.com/flowexec/examples): Real-world workflow examples +- [Discord Community](https://discord.gg/CtByNKNMxM): Community chat diff --git a/docs/public/robots.txt b/docs/public/robots.txt new file mode 100644 index 00000000..5d5c4098 --- /dev/null +++ b/docs/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://flowexec.io/sitemap.xml diff --git a/go.mod b/go.mod index eb2c2cae..df1ff4be 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/gen2brain/beeep v0.11.2 github.com/google/uuid v1.6.0 github.com/jahvon/expression v0.1.4 - github.com/mark3labs/mcp-go v0.43.2 + github.com/mark3labs/mcp-go v0.47.1 github.com/onsi/ginkgo/v2 v2.28.1 github.com/onsi/gomega v1.39.0 github.com/otiai10/copy v1.14.1 @@ -40,8 +40,6 @@ require ( github.com/alecthomas/chroma/v2 v2.20.0 // indirect github.com/aymanbagabas/go-udiff v0.4.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect - github.com/bahlo/generic-list-go v0.2.0 // indirect - github.com/buger/jsonparser v1.1.2 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect @@ -66,13 +64,12 @@ require ( github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/invopop/jsonschema v0.13.0 // indirect github.com/jackmordaunt/icns/v3 v3.0.1 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect - github.com/mailru/easyjson v0.9.0 // indirect github.com/mattn/go-runewidth v0.0.23 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect @@ -87,7 +84,6 @@ require ( github.com/sergeymakinen/go-ico v1.0.0-beta.0 // indirect github.com/spf13/cast v1.9.2 // indirect github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect - github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yuin/goldmark v1.7.13 // indirect diff --git a/go.sum b/go.sum index 61848c52..2e964d02 100644 --- a/go.sum +++ b/go.sum @@ -34,10 +34,6 @@ github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= -github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= -github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= -github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= -github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= @@ -120,6 +116,8 @@ github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= @@ -132,8 +130,6 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= -github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/jackmordaunt/icns/v3 v3.0.1 h1:xxot6aNuGrU+lNgxz5I5H0qSeCjNKp8uTXB1j8D4S3o= github.com/jackmordaunt/icns/v3 v3.0.1/go.mod h1:5sHL59nqTd2ynTnowxB/MDQFhKNqkK8X687uKNygaSQ= github.com/jahvon/expression v0.1.4 h1:4q/jvM5G2mBJDqXtTUDThtJ4Sfajx+vIhUf4r6EAy6A= @@ -148,10 +144,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= -github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I= -github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= +github.com/mark3labs/mcp-go v0.47.1 h1:A9sJJ20mscl/ssLYHjodfaoBmq6uuhMG7pAPNYaQymQ= +github.com/mark3labs/mcp-go v0.47.1/go.mod h1:JKTC7R2LLVagkEWK7Kwu7DbmA6iIvnNAod6yrHiQMag= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= @@ -222,8 +216,6 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= -github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= diff --git a/internal/mcp/command_executor.go b/internal/mcp/command_executor.go index e2f41077..bbab5f78 100644 --- a/internal/mcp/command_executor.go +++ b/internal/mcp/command_executor.go @@ -1,6 +1,7 @@ package mcp import ( + "context" "os" "os/exec" @@ -12,6 +13,7 @@ const cliBinaryEnvKey = "FLOW_CLI_BINARY" //go:generate mockgen -destination=mocks/command_executor.go -package=mocks . CommandExecutor type CommandExecutor interface { Execute(args ...string) (string, error) + ExecuteContext(ctx context.Context, args ...string) (string, error) } // FlowCLIExecutor runs the flow CLI with provided arguments. The CLI is being executed instead of importing the @@ -23,11 +25,15 @@ type CommandExecutor interface { type FlowCLIExecutor struct{} func (c *FlowCLIExecutor) Execute(args ...string) (string, error) { + return c.ExecuteContext(context.Background(), args...) +} + +func (c *FlowCLIExecutor) ExecuteContext(ctx context.Context, args ...string) (string, error) { name := "flow" if envName := os.Getenv(cliBinaryEnvKey); envName != "" { name = envName } - cmd := exec.Command(name, args...) // #nosec G204,G702 + cmd := exec.CommandContext(ctx, name, args...) // #nosec G204,G702 output, err := cmd.CombinedOutput() if err != nil { // Only return an error if it's not an exit error. diff --git a/internal/mcp/errors.go b/internal/mcp/errors.go new file mode 100644 index 00000000..77e86ac3 --- /dev/null +++ b/internal/mcp/errors.go @@ -0,0 +1,48 @@ +package mcp + +import ( + "encoding/json" + + "github.com/mark3labs/mcp-go/mcp" +) + +// Machine-readable error codes for structured error responses. +const ( + ErrCodeInvalidInput = "INVALID_INPUT" + ErrCodeNotFound = "NOT_FOUND" + ErrCodeExecutionFailed = "EXECUTION_FAILED" + ErrCodeTimeout = "TIMEOUT" + ErrCodeCancelled = "CANCELLED" + ErrCodeValidationFailed = "VALIDATION_FAILED" + ErrCodeInternal = "INTERNAL_ERROR" + ErrCodePermissionDenied = "PERMISSION_DENIED" +) + +type errorPayload struct { + Error errorDetail `json:"error"` +} + +type errorDetail struct { + Code string `json:"code"` + Message string `json:"message"` + Details map[string]any `json:"details,omitempty"` +} + +// toolError returns a CallToolResult with IsError set and a structured JSON error payload. +func toolError(code, message string) *mcp.CallToolResult { + return toolErrorWithDetails(code, message, nil) +} + +// toolErrorWithDetails is like toolError but includes a details object in the error payload. +func toolErrorWithDetails(code, message string, details map[string]any) *mcp.CallToolResult { + payload := errorPayload{Error: errorDetail{ + Code: code, + Message: message, + Details: details, + }} + data, err := json.Marshal(payload) + if err != nil { + return mcp.NewToolResultError(message) + } + return mcp.NewToolResultError(string(data)) +} diff --git a/internal/mcp/mocks/command_executor.go b/internal/mcp/mocks/command_executor.go index 6eb96be4..6687328a 100644 --- a/internal/mcp/mocks/command_executor.go +++ b/internal/mcp/mocks/command_executor.go @@ -10,6 +10,7 @@ package mocks import ( + context "context" reflect "reflect" gomock "go.uber.org/mock/gomock" @@ -56,3 +57,23 @@ func (mr *MockCommandExecutorMockRecorder) Execute(arg0 ...any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Execute", reflect.TypeOf((*MockCommandExecutor)(nil).Execute), arg0...) } + +// ExecuteContext mocks base method. +func (m *MockCommandExecutor) ExecuteContext(arg0 context.Context, arg1 ...string) (string, error) { + m.ctrl.T.Helper() + varargs := []any{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "ExecuteContext", varargs...) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ExecuteContext indicates an expected call of ExecuteContext. +func (mr *MockCommandExecutorMockRecorder) ExecuteContext(arg0 any, arg1 ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExecuteContext", reflect.TypeOf((*MockCommandExecutor)(nil).ExecuteContext), varargs...) +} diff --git a/internal/mcp/output_types.go b/internal/mcp/output_types.go new file mode 100644 index 00000000..5d5b13cb --- /dev/null +++ b/internal/mcp/output_types.go @@ -0,0 +1,134 @@ +package mcp + +// Output types for MCP tool responses. These structs are used with WithOutputSchema[T]() +// to generate JSON schemas for structured tool output. They mirror the CLI JSON output +// shapes but are decoupled from internal types to keep the MCP schema stable. + +// FlowInfoOutput is the output of the get_info tool. +// +// This is intentionally lightweight — the full concepts/file-types guides and JSON schemas +// are NOT embedded by default to avoid consuming large amounts of LLM context. Clients should +// fetch the docs index (llms.txt) and individual guide pages as needed. +type FlowInfoOutput struct { + CurrentContext CurrentContext `json:"currentContext"` + // Summary is a compact platform description suitable for context priming. + Summary string `json:"summary"` + // DocsURL is the root of the hosted documentation site. + DocsURL string `json:"docsUrl"` + // LLMsTxtURL points to the llms.txt index of docs pages (per https://llmstxt.org/). + LLMsTxtURL string `json:"llmsTxtUrl"` + // SchemaURLs maps each file type to its JSON schema URL. + SchemaURLs SchemaURLs `json:"schemaUrls"` + // GuideURLs maps key topic names to their documentation URL. + GuideURLs map[string]string `json:"guideUrls"` +} + +// SchemaURLs lists the URLs of the JSON schemas for flow file types. +type SchemaURLs struct { + FlowFile string `json:"flowFile"` + Workspace string `json:"workspace"` + Template string `json:"template"` + Config string `json:"config"` +} + +type CurrentContext struct { + Workspace string `json:"workspace"` + Namespace string `json:"namespace"` + Vault string `json:"vault"` + WorkspaceMode string `json:"workspaceMode"` + WorkspacePath string `json:"workspacePath"` +} + +// WorkspaceOutput is the output of the get_workspace tool. +type WorkspaceOutput struct { + Name string `json:"name"` + Path string `json:"path"` + DisplayName string `json:"displayName,omitempty"` + Description string `json:"description,omitempty"` + FullDescription string `json:"fullDescription,omitempty"` + DescriptionFile string `json:"descriptionFile,omitempty"` + Tags []string `json:"tags,omitempty"` + EnvFiles []string `json:"envFiles,omitempty"` + Executables *ExecutableFilter `json:"executables,omitempty"` + VerbAliases map[string][]string `json:"verbAliases,omitempty"` +} + +type ExecutableFilter struct { + Included []string `json:"included,omitempty"` + Excluded []string `json:"excluded,omitempty"` +} + +// WorkspaceListOutput is the output of the list_workspaces tool. +type WorkspaceListOutput struct { + Workspaces []WorkspaceOutput `json:"workspaces"` + NextCursor string `json:"nextCursor,omitempty"` + TotalCount int `json:"totalCount"` +} + +// ExecutableOutput is the output of the get_executable tool. +type ExecutableOutput struct { + ID string `json:"id"` + Ref string `json:"ref"` + Name string `json:"name"` + Namespace string `json:"namespace"` + Workspace string `json:"workspace"` + FlowFile string `json:"flowfile"` + Description string `json:"description,omitempty"` + FullDescription string `json:"fullDescription,omitempty"` + Verb string `json:"verb"` + Visibility string `json:"visibility,omitempty"` + Timeout string `json:"timeout,omitempty"` + Tags []string `json:"tags,omitempty"` + Aliases []string `json:"aliases,omitempty"` +} + +// ExecutableListOutput is the output of the list_executables tool. +type ExecutableListOutput struct { + Executables []ExecutableOutput `json:"executables"` + NextCursor string `json:"nextCursor,omitempty"` + TotalCount int `json:"totalCount"` +} + +// ExecutionOutput is the output of the execute tool. +type ExecutionOutput struct { + Output string `json:"output"` +} + +// LogEntry represents a single execution log record. +type LogEntry struct { + Ref string `json:"ref"` + StartedAt string `json:"startedAt"` + Duration string `json:"duration"` + ExitCode int `json:"exitCode"` + Error string `json:"error,omitempty"` + LogFile string `json:"logFile,omitempty"` +} + +// LogListOutput is the output of the get_execution_logs tool. +type LogListOutput struct { + History []LogEntry `json:"history"` + NextCursor string `json:"nextCursor,omitempty"` + TotalCount int `json:"totalCount"` +} + +// SyncOutput is the output of the sync_executables tool. +type SyncOutput struct { + Output string `json:"output"` +} + +// SwitchWorkspaceOutput is the output of the switch_workspace tool. +type SwitchWorkspaceOutput struct { + Output string `json:"output"` +} + +// WriteFlowFileOutput is the output of the write_flowfile tool. +type WriteFlowFileOutput struct { + Path string `json:"path"` + Executables []string `json:"executables"` + Overwritten bool `json:"overwritten"` +} + +// WorkspaceConfigOutput is the output of the get_workspace_config tool. +type WorkspaceConfigOutput struct { + WorkspaceOutput +} diff --git a/internal/mcp/pagination.go b/internal/mcp/pagination.go new file mode 100644 index 00000000..6c94cf67 --- /dev/null +++ b/internal/mcp/pagination.go @@ -0,0 +1,56 @@ +package mcp + +import ( + "encoding/base64" + "fmt" + "strconv" +) + +const defaultPageSize = 25 + +// paginate applies cursor-based pagination to a slice of items. +// The cursor is an opaque base64-encoded offset. An empty cursor starts from the beginning. +// Returns the page of items, the next cursor (empty if on the last page), and the total count. +func paginate[T any](items []T, cursor string, pageSize int) (page []T, nextCursor string, totalCount int, err error) { + totalCount = len(items) + if pageSize <= 0 { + pageSize = defaultPageSize + } + + offset := 0 + if cursor != "" { + offset, err = decodeCursor(cursor) + if err != nil { + return nil, "", totalCount, fmt.Errorf("invalid cursor: %w", err) + } + } + + if offset >= totalCount { + return []T{}, "", totalCount, nil + } + + end := offset + pageSize + if end > totalCount { + end = totalCount + } + + page = items[offset:end] + + if end < totalCount { + nextCursor = encodeCursor(end) + } + + return page, nextCursor, totalCount, nil +} + +func encodeCursor(offset int) string { + return base64.StdEncoding.EncodeToString([]byte(strconv.Itoa(offset))) +} + +func decodeCursor(cursor string) (int, error) { + data, err := base64.StdEncoding.DecodeString(cursor) + if err != nil { + return 0, err + } + return strconv.Atoi(string(data)) +} diff --git a/internal/mcp/pagination_test.go b/internal/mcp/pagination_test.go new file mode 100644 index 00000000..ae8ecebb --- /dev/null +++ b/internal/mcp/pagination_test.go @@ -0,0 +1,123 @@ +//nolint:testpackage // tests unexported pagination helpers +package mcp + +import ( + "testing" +) + +//nolint:gocognit +func TestPaginate(t *testing.T) { + items := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} + + t.Run("empty input", func(t *testing.T) { + page, next, total, err := paginate([]int{}, "", 5) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(page) != 0 { + t.Errorf("expected empty page, got %d items", len(page)) + } + if next != "" { + t.Errorf("expected empty next cursor, got %q", next) + } + if total != 0 { + t.Errorf("expected total 0, got %d", total) + } + }) + + t.Run("first page with more pages", func(t *testing.T) { + page, next, total, err := paginate(items, "", 3) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(page) != 3 { + t.Errorf("expected page size 3, got %d", len(page)) + } + if next == "" { + t.Errorf("expected non-empty next cursor") + } + if total != 10 { + t.Errorf("expected total 10, got %d", total) + } + }) + + t.Run("middle page", func(t *testing.T) { + // Decode: offset 3, next 6 + cursor := encodeCursor(3) + page, next, _, err := paginate(items, cursor, 3) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(page) != 3 { + t.Errorf("expected page size 3, got %d", len(page)) + } + if page[0] != 4 { + t.Errorf("expected first item 4, got %d", page[0]) + } + if next == "" { + t.Errorf("expected non-empty next cursor") + } + }) + + t.Run("last page no next cursor", func(t *testing.T) { + cursor := encodeCursor(8) + page, next, _, err := paginate(items, cursor, 3) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(page) != 2 { + t.Errorf("expected page size 2, got %d", len(page)) + } + if next != "" { + t.Errorf("expected empty next cursor, got %q", next) + } + }) + + t.Run("cursor past end", func(t *testing.T) { + cursor := encodeCursor(100) + page, next, total, err := paginate(items, cursor, 3) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(page) != 0 { + t.Errorf("expected empty page, got %d items", len(page)) + } + if next != "" { + t.Errorf("expected empty next cursor, got %q", next) + } + if total != 10 { + t.Errorf("expected total 10, got %d", total) + } + }) + + t.Run("invalid cursor returns error", func(t *testing.T) { + _, _, _, err := paginate(items, "not-base64!", 3) + if err == nil { + t.Errorf("expected error for invalid cursor") + } + }) + + t.Run("zero page size uses default", func(t *testing.T) { + page, _, _, err := paginate(items, "", 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // All 10 items fit within default page size of 25 + if len(page) != 10 { + t.Errorf("expected all 10 items, got %d", len(page)) + } + }) +} + +func TestCursorRoundTrip(t *testing.T) { + for _, offset := range []int{0, 1, 100, 99999} { + cursor := encodeCursor(offset) + decoded, err := decodeCursor(cursor) + if err != nil { + t.Fatalf("failed to decode cursor for offset %d: %v", offset, err) + } + if decoded != offset { + t.Errorf("cursor round-trip failed: expected %d, got %d", offset, decoded) + } + } +} diff --git a/internal/mcp/resources.go b/internal/mcp/resources.go new file mode 100644 index 00000000..6e5ec4a1 --- /dev/null +++ b/internal/mcp/resources.go @@ -0,0 +1,297 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + tuikitIO "github.com/flowexec/tuikit/io" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + + "github.com/flowexec/flow/pkg/filesystem" +) + +func addServerResources(srv *server.MCPServer) { + // Resource template: workspace by name + workspaceTemplate := mcp.NewResourceTemplate( + "flow://workspace/{name}", + "Workspace Configuration", + mcp.WithTemplateDescription("Workspace metadata and configuration as JSON"), + mcp.WithTemplateMIMEType("application/json"), + ) + srv.AddResourceTemplate(workspaceTemplate, workspaceResourceHandler) + + // Resource template: executable by workspace/namespace/name + executableTemplate := mcp.NewResourceTemplate( + "flow://executable/{workspace}/{namespace}/{name}", + "Executable Definition", + mcp.WithTemplateDescription("Executable definition and metadata as JSON"), + mcp.WithTemplateMIMEType("application/json"), + ) + srv.AddResourceTemplate(executableTemplate, executableResourceHandler) + + // Resource template: flowfile by path + flowfileTemplate := mcp.NewResourceTemplate( + "flow://flowfile/{+path}", + "Flow File", + mcp.WithTemplateDescription("Raw flowfile YAML content"), + mcp.WithTemplateMIMEType("text/yaml"), + ) + srv.AddResourceTemplate(flowfileTemplate, flowfileResourceHandler) + + // Resource template: execution log by run ID + logsTemplate := mcp.NewResourceTemplate( + "flow://logs/{run_id}", + "Execution Log", + mcp.WithTemplateDescription("Output of a specific execution run as plain text"), + mcp.WithTemplateMIMEType("text/plain"), + ) + srv.AddResourceTemplate(logsTemplate, logsResourceHandler) +} + +func workspaceResourceHandler(_ context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + uri := request.Params.URI + name := extractURIParam(uri, "flow://workspace/") + if name == "" { + return nil, fmt.Errorf("workspace name is required in URI") + } + + cfg, err := filesystem.LoadConfig() + if err != nil { + return nil, fmt.Errorf("failed to load config: %w", err) + } + + wsPath, ok := cfg.Workspaces[name] + if !ok { + return nil, fmt.Errorf("workspace %q not found", name) + } + + ws, err := filesystem.LoadWorkspaceConfig(name, wsPath) + if err != nil { + return nil, fmt.Errorf("failed to load workspace config: %w", err) + } + + output := WorkspaceOutput{ + Name: ws.AssignedName(), + Path: ws.Location(), + DisplayName: ws.DisplayName, + Description: ws.Description, + Tags: ws.Tags, + } + if ws.Executables != nil { + output.Executables = &ExecutableFilter{ + Included: ws.Executables.Included, + Excluded: ws.Executables.Excluded, + } + } + + jsonData, err := json.MarshalIndent(output, "", " ") + if err != nil { + return nil, fmt.Errorf("failed to marshal workspace: %w", err) + } + + return []mcp.ResourceContents{ + mcp.TextResourceContents{ + URI: uri, + MIMEType: "application/json", + Text: string(jsonData), + }, + }, nil +} + +func executableResourceHandler(_ context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + uri := request.Params.URI + // Parse flow://executable/{workspace}/{namespace}/{name}. + // Empty workspace or namespace segments resolve to the current context + // (workspace required; namespace may itself be empty = no-namespace executable). + parts := extractExecutableURIParts(uri) + if parts.name == "" { + return nil, fmt.Errorf("invalid executable URI: name is required") + } + + cfg, err := filesystem.LoadConfig() + if err != nil { + return nil, fmt.Errorf("failed to load config: %w", err) + } + + if parts.workspace == "" { + if cfg.CurrentWorkspace == "" { + return nil, fmt.Errorf("workspace is empty in URI and no current workspace is set") + } + parts.workspace = cfg.CurrentWorkspace + } + if parts.namespace == "" { + parts.namespace = cfg.CurrentNamespace + } + + wsPath, ok := cfg.Workspaces[parts.workspace] + if !ok { + return nil, fmt.Errorf("workspace %q not found", parts.workspace) + } + + ws, err := filesystem.LoadWorkspaceConfig(parts.workspace, wsPath) + if err != nil { + return nil, fmt.Errorf("failed to load workspace config: %w", err) + } + + flowFiles, err := filesystem.LoadWorkspaceFlowFiles(ws) + if err != nil { + return nil, fmt.Errorf("failed to load workspace flow files: %w", err) + } + + for _, ff := range flowFiles { + for _, exec := range ff.Executables { + if exec.Name == parts.name && ff.Namespace == parts.namespace { + visibility := "" + if exec.Visibility != nil { + visibility = string(*exec.Visibility) + } + output := ExecutableOutput{ + ID: exec.ID(), + Ref: exec.Ref().String(), + Name: exec.Name, + Namespace: ff.Namespace, + Workspace: parts.workspace, + FlowFile: ff.ConfigPath(), + Description: exec.Description, + Verb: string(exec.Verb), + Visibility: visibility, + Tags: exec.Tags, + Aliases: exec.Aliases, + } + + jsonData, err := json.MarshalIndent(output, "", " ") + if err != nil { + return nil, fmt.Errorf("failed to marshal executable: %w", err) + } + + return []mcp.ResourceContents{ + mcp.TextResourceContents{ + URI: uri, + MIMEType: "application/json", + Text: string(jsonData), + }, + }, nil + } + } + } + + return nil, fmt.Errorf("executable %s/%s:%s not found", parts.workspace, parts.namespace, parts.name) +} + +func flowfileResourceHandler(_ context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + uri := request.Params.URI + path := extractURIParam(uri, "flow://flowfile/") + if path == "" { + return nil, fmt.Errorf("path is required in URI") + } + + // Validate path is within a registered workspace + cfg, err := filesystem.LoadConfig() + if err != nil { + return nil, fmt.Errorf("failed to load config: %w", err) + } + + absPath := path + if !filepath.IsAbs(path) { + // Try to resolve relative to current workspace + if cfg.CurrentWorkspace != "" { + if wsPath, ok := cfg.Workspaces[cfg.CurrentWorkspace]; ok { + absPath = filepath.Join(wsPath, path) + } + } + } + + // Security check: path must be within a registered workspace + if !isPathInWorkspace(absPath, cfg.Workspaces) { + return nil, fmt.Errorf("path %q is not within a registered workspace", path) + } + + data, err := os.ReadFile(filepath.Clean(absPath)) + if err != nil { + return nil, fmt.Errorf("failed to read flowfile: %w", err) + } + + return []mcp.ResourceContents{ + mcp.TextResourceContents{ + URI: uri, + MIMEType: "text/yaml", + Text: string(data), + }, + }, nil +} + +func logsResourceHandler(_ context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + uri := request.Params.URI + runID := extractURIParam(uri, "flow://logs/") + if runID == "" { + return nil, fmt.Errorf("run_id is required in URI") + } + + logsDir := filesystem.LogsDir() + entries, err := tuikitIO.ListArchiveEntries(logsDir) + if err != nil { + return nil, fmt.Errorf("failed to list log entries: %w", err) + } + + for _, entry := range entries { + if entry.ID == runID { + content, err := entry.Read() + if err != nil { + return nil, fmt.Errorf("failed to read log entry: %w", err) + } + return []mcp.ResourceContents{ + mcp.TextResourceContents{ + URI: uri, + MIMEType: "text/plain", + Text: content, + }, + }, nil + } + } + + return nil, fmt.Errorf("log entry with run ID %q not found", runID) +} + +// extractURIParam extracts the value after a prefix from a URI. +func extractURIParam(uri, prefix string) string { + if !strings.HasPrefix(uri, prefix) { + return "" + } + return strings.TrimPrefix(uri, prefix) +} + +type executableURIParts struct { + workspace string + namespace string + name string +} + +// extractExecutableURIParts parses flow://executable/{workspace}/{namespace}/{name} +func extractExecutableURIParts(uri string) executableURIParts { + const prefix = "flow://executable/" + trimmed := strings.TrimPrefix(uri, prefix) + parts := strings.SplitN(trimmed, "/", 3) + if len(parts) != 3 { + return executableURIParts{} + } + return executableURIParts{ + workspace: parts[0], + namespace: parts[1], + name: parts[2], + } +} + +// isPathInWorkspace checks if an absolute path is within any registered workspace. +func isPathInWorkspace(absPath string, workspaces map[string]string) bool { + for _, wsPath := range workspaces { + if strings.HasPrefix(absPath, wsPath) { + return true + } + } + return false +} diff --git a/internal/mcp/resources/concepts-guide.md b/internal/mcp/resources/concepts-guide.md deleted file mode 100644 index 0ec6c932..00000000 --- a/internal/mcp/resources/concepts-guide.md +++ /dev/null @@ -1,288 +0,0 @@ -# Flow Concepts Guide - -## What is flow? - -flow is a local-first, customizable CLI automation platform designed to streamline development and operations workflows. -It helps developers organize, discover, and execute tasks across projects through a unified interface. - -More comprehensive details can be fetched from the [flow documentation](https://flowexec.io). - -## Core Philosophy - -- **Local-First**: All data and execution happens on your machine -- **Declarative**: Define what you want to happen, not how -- **Discoverable**: Find and explore workflows through interactive interfaces -- **Composable**: Build complex workflows from simple building blocks -- **Workspace-Centric**: Organize tasks by project/workspace context - -## Key Concepts - -### Executables -**Executables** are the core building blocks of flow - they define actions that can be performed. -Think of them as "smart scripts" with metadata, parameters, and rich configuration options. - -**Types of Executables:** -- **exec**: Run shell commands or scripts -- **serial**: Execute multiple steps sequentially -- **parallel**: Execute multiple steps concurrently -- **request**: Make HTTP API calls -- **launch**: Open applications or URIs -- **render**: Generate and display markdown content - -**Example executable:** -```yaml -executables: - - verb: build - name: web-app - description: Build the web application - exec: - cmd: npm run build - params: - - envKey: NODE_ENV - text: production -``` - -#### Executable References -Executables are identified by its reference: combination of **Verb** and **ID**. The ID is in the form `/:`. -A full reference must be unique across all registered workspaces. - -For instance: -- `build app` - current workspace, root namespace -- `build backend/app` - current workspace, backend namespace -- `build my-project/backend:app` - specific workspace and namespace -- `build` - current workspace, root namespace, no name set - -### Verbs -**Verbs** describe what action an executable performs. Flow groups related verbs together, allowing natural language-like commands. -By default, flow provide the following verb alias groups: - -- **Execution Group**: exec, run, execute -- **Retrieval Group**: get, fetch, retrieve -- **Display Group**: show, view, list -- **Configuration Group**: configure, setup -- **Update Group**: update, upgrade - -Additional aliases can be defined in the workspace or executable configuration to customize verb behavior. - -Users can invoke executables using any verb using the CLI (e.g. `flow build app` and `flow deploy app`) - -Run `flow exec --help` for more information on available verbs and execution details. - -### Workspaces -**Workspaces** are project containers that organize related executables. Each workspace: -- Has a root directory containing the workspace configuration file (`flow.yaml`) -- Can contain multiple namespaces (defined in flow files) -- Provides isolation between projects - -#### Workspace Modes -**Dynamic**: The flow workspace is determined by the current working directory that the flow CLI is executed from -**Fixed**: The flow workspace much be switched explicitly - -Only executables with `public` visibility can be executed across workspaces without switching. If the current workspace -is not the workspace under review, you should switch into that workspace to avoid execution errors. - -### Namespaces -**Namespaces** provide logical grouping within workspaces. They help organize executables by: -- Feature area (auth, payments, notifications) -- Environment (dev, staging, production) -- Technology stack (backend, frontend, mobile) -- Team ownership (platform, product, data) - - -Executable names are optional. When not specified, the verb is used as the identifier, -e.g. `flow build` refers to the executable with the verb "build" in the current workspace. - -### Executable Definitions (Flow Files) -**Flow files** (`.flow`, `.flow.yaml`, `.flow.yml`) are YAML configuration files that define executables. They support: -- Multiple executables per file -- Shared metadata (namespace, tags, descriptions) -- Environment variable management -- Conditional execution logic -- Can be located anywhere within the workspace directory - -### Secrets and Vaults -**Vaults** provide secure secret storage with multiple encryption backends: -- **AES256**: Symmetric encryption with generated keys -- **Age**: Asymmetric encryption for team sharing - -Executables reference secrets using `secretRef` parameters, keeping sensitive data separate from configuration. - -### Templates -**Templates** enable scaffolding new workspaces and executables. They support: -- Interactive form collection -- Go / Expr template rendering -- File artifact copying -- Pre/post-run hooks -- Conditional generation - -## Execution Model - -### Sync - -Anytime a new workspace is registered, an executable is added, or it's identifier changes, flow state will need to be synchronized. -This ensures that the latest workspace and executable definitions are available in the cache for execution. -This can be done using the `flow sync` command or with the `--sync` flag on any command. - -### Management Commands - -Flow provides a set of management commands to interact with workspaces, executables, and configurations: -```bash -flow config # Manage flow's user configuration (get, set, reset) -flow workspace # Manage workspaces (add, get, switch, list, remove) -flow browse # Discover executables (interactive TUI) -flow vault # Manage secrets and vaults (create, edit, get, list, remove, switch) -flow cache # Manage cache data (get, set, remove, list, clear) -flow secret # Manage secrets (get, list, remove, set) -flow template # Manage templates (add, generate, get, list) -flow logs # View execution logs -``` - -### Execution Command Structure -```bash -flow [arguments] [flags] -``` - -**Examples:** -- `flow build app` - Build the app executable -- `flow test backend/unit` - Run unit tests in backend namespace -- `flow deploy prod-project/k8s:webapp` - Deploy webapp in specific workspace/namespace - -### Conditional Execution -flow supports runtime conditions using the Expr language for serial and parallel executables: -```yaml -serial: - execs: - - if: os == "darwin" - cmd: brew install mytool - - if: env["CI"] == "true" - cmd: run-ci-specific-setup - - if: len(store["build-id"]) > 0 # checks the cache for a build ID key - cmd: use-cached-build -``` - -### State Management -flow provides state persistence through: -- **Cache**: Key-value store for sharing data between executables. State is only persisted during execution - - Use `flow cache set KEY VALUE` and `flow cache get KEY` to manage cache entries within executable scripts - - Users can also manage global keys outside the execution context using `flow cache` commands -- **Context variables**: OS, architecture, workspace, and path information -- **Environment inheritance**: Variables (`params` and `args`) flow from parent to child executables (for serial and parallel) -- **Temporary directories**: Isolated scratch space for executable runs - - A temporary directory is created for the execution when the executable's `dir` is set to `f:tmp`) - -## Workflow Patterns - -### Simple Task -```yaml -executables: - - verb: test - name: unit - exec: - cmd: npm test -``` - -### Multi-Step Workflow -```yaml -executables: - - verb: deploy - name: application - serial: - execs: - - ref: build app - - ref: test unit - - cmd: docker build -t myapp . - - cmd: kubectl apply -f k8s/ -``` - -### Parallel Execution -```yaml -executables: - - verb: test - name: all - parallel: - maxThreads: 3 - execs: - - ref: test unit - - ref: test integration - - ref: test e2e -``` - -## Integration Points - -### CLI Interface -The primary interface is the `flow` CLI command: -- Interactive TUI for browsing and discovery -- Direct command execution -- Workspace and configuration management -- Secret and vault operations - -### Desktop Application (Upcoming) -A companion GUI application providing: -- Visual workflow browsing -- Execution monitoring -- Configuration editing -- Documentation viewing - -## Common Use Cases - -Flow is NOT just a DevOps tool. It's a general-purpose automation platform for ANY repetitive task: - -### Development & Operations -- Build, test, deploy applications -- Manage infrastructure and environments -- Automate CI/CD workflows -- Code generation and scaffolding - -### Personal and Team Productivity -- Standardized development setup -- Shared workflow templates -- Manage todo lists via APIs (Todoist, Notion, etc.) -- Organize notes and knowledge bases -- Automate file organization and backups -- Schedule and run maintenance tasks - -### Content & Media -- Process images, videos, documents -- Generate reports and documentation -- Manage blog posts and publications -- Sync content between platforms - -### System Administration -- Monitor system health and resources -- Manage configurations and settings -- Automate routine maintenance -- Handle log analysis and cleanup - -### Custom Integrations -- Reusable libraries of executables -- Integration with / wrapper for existing CLI tools and APIs -- Connect different APIs and services -- Build personal dashboards and tools -- Automate data synchronization -- Create custom workflows for unique needs - -**Key Insight**: If someone has a repetitive task involving commands, APIs, or file operations, Flow can likely automate it. - -## Best Practices - -### Executable Design -- Use descriptive verbs and names -- Include clear descriptions and documentation -- Make executables idempotent when possible -- Handle errors gracefully with meaningful messages - -### Workspace Organization -- Group related functionality in the same flow file (and namespace if it makes sense) -- Use consistent naming conventions -- Document workspace purpose and setup -- Share common patterns through templates - -### Secret Management -- Never commit secrets to flow files -- Use descriptive secret references -- Include information on required secrets in executable documentation - -### Workflow Composition -- Break complex tasks into smaller, reusable executables -- Use conditional logic for environment differences -- Leverage parallel execution for independent tasks diff --git a/internal/mcp/resources/file-types-guide.md b/internal/mcp/resources/file-types-guide.md deleted file mode 100644 index 544795bf..00000000 --- a/internal/mcp/resources/file-types-guide.md +++ /dev/null @@ -1,142 +0,0 @@ -# Flow File Types Guide - -Flow users use three distinct types of configuration files that serve different purposes: - -## 1. Workspace Configuration: `flow.yaml` -- **Location**: Root of each workspace directory -- **Purpose**: Configure workspace-level settings -- **Contains**: Workspace metadata, executable filters, display settings -- **Quantity**: One per workspace -- **Example path**: `/my-project/flow.yaml` - -**Example content:** -```yaml -displayName: "My Project" -description: "A web application project" -tags: ["web", "typescript"] -``` - -**Key fields**: -- `displayName`: Human-readable workspace name -- `description`: Workspace description (markdown supported) -- `tags`: Workspace-level tags for organization - -## 2. Executable Definitions: *.flow files - -- Extensions: *.flow, *.flow.yaml, *.flow.yml -- Purpose: Define executable tasks and workflows -- Contains: Executable definitions with verbs, commands, parameters -- Quantity: Multiple per workspace -- Example paths: /my-project/build.flow, /my-project/deploy.flow.yaml, /my-project/.execs/test.flow.yml - -**Note**: The `.flow` extension is reserved for flow files, while `flow.yaml` is the workspace configuration file. -Do not try to create executables in a `.flow` directory, if grouping of flow files is desired use the `.execs` directory -if a name is needed. - -**Example content:** -```yaml -namespace: backend -executables: - - verb: build - name: api - exec: - cmd: npm run build - - verb: test - name: unit - exec: - cmd: npm test -``` - -**Key fields:** -- `namespace`: Optional logical grouping within workspace -- `executables`: Array of executable definitions -- Each executable has: `verb`, execution type (`exec`, `serial`, `parallel`, etc.) - -## 3. Flow File Templates: `.flow.tmpl` -- **Extensions**: `.flow.tmpl`, `.flow.tmpl.yaml`, `.flow.tmpl.yml` -- **Purpose**: Generate new flow files and workspace scaffolding -- **Contains**: Template configuration with forms, artifacts, and flow file template -- **Quantity**: Multiple templates can be registered -- **Schema**: Template schema -- **Example paths**: `/templates/k8s-app.flow.tmpl`, `/scaffolds/web-project.flow.template` - -**Example content:** -```yaml -form: - - key: "AppName" - prompt: "What is the application name?" - required: true - - key: "Namespace" - prompt: "What namespace should be used?" - default: "default" - - key: "Deploy" - prompt: "Deploy immediately after creation?" - type: "confirm" - -artifacts: - - srcName: "deployment.yaml" - asTemplate: true - dstName: "k8s-deployment.yaml" - if: form["Deploy"] - -template: | - namespace: {{ .form.Namespace }} - executables: - - verb: build - name: {{ .form.AppName }} - exec: - cmd: docker build -t {{ .form.AppName }} . - - verb: deploy - name: {{ .form.AppName }} - exec: - cmd: kubectl apply -f k8s-deployment.yaml -``` - -**Key fields:** -- `form`: Interactive form fields for user input -- `artifacts`: Files to copy/generate alongside the flow file -- `template`: Go template string that generates the actual flow file -- `preRun`/`postRun`: Optional executables to run during generation - -## Key Differences - -| Aspect | flow.yaml | .flow files | .flow.tmpl files | -|--------|-----------|---------------------------|------------------| -| **Purpose** | Workspace configuration | Executable definitions | Template generation | -| **Scope** | Entire workspace | Individual tasks | Scaffolding/generation | -| **Location** | Workspace root only | Anywhere in workspace | Template directories | -| **Schema** | Workspace schema | FlowFile schema | Template schema | -| **Contains** | Settings, filters, metadata | Executables, verbs, commands | Forms, templates, artifacts | -| **Executable** | No | Yes (defines executables) | No (generates executables) | -| **Usage** | Automatic (workspace config) | `flow ` | `flow template generate` | - -## Common Confusion Points - -### ❌ Don't Mix These Up: -- **flow.yaml is NOT executable** - it only configures the workspace -- **.flow files define executables** - they contain the actual tasks you run -- **.flow.tmpl files generate other files** - they're not executed directly - -### ✅ Remember: -- **flow.yaml** = "How should this workspace behave?" -- **.flow** = "What tasks can I run?" -- **.flow.tmpl** = "How do I generate new flow files?" - -## Examples Project Structure: -``` -my-web-app/ -├── flow.yaml # Workspace config -├── backend.flow # Backend executables -├── frontend.flow.yaml # Frontend executables -├── deployment/ -│ └── k8s.flow # Deployment executables -└── templates/ - └── microservice.flow.tmpl # Service template -``` - -## Typical Workflow: -1. **Create workspace**: Add `flow.yaml` to configure workspace -2. **Define executables**: Create `.flow` files with tasks -3. **Use templates**: (optional) Generate new `.flow` files from `.flow.tmpl` templates -4. **Sync workspace state**: Update flow's cache with `flow sync` -5. **Execute tasks**: Run executables with `flow ` diff --git a/internal/mcp/resources/flowfile_schema.json b/internal/mcp/resources/flowfile_schema.json deleted file mode 100644 index 5c42d720..00000000 --- a/internal/mcp/resources/flowfile_schema.json +++ /dev/null @@ -1,719 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://flowexec.io/schemas/flowfile_schema.json", - "title": "FlowFile", - "description": "Configuration for a group of Flow CLI executables. The file must have the extension `.flow`, `.flow.yaml`, or `.flow.yml` \nin order to be discovered by the CLI. It's configuration is used to define a group of executables with shared metadata \n(namespace, tags, etc). A workspace can have multiple flow files located anywhere in the workspace directory\n", - "type": "object", - "definitions": { - "CommonAliases": { - "description": "Alternate names that can be used to reference the executable in the CLI.", - "type": "array", - "items": { - "type": "string" - } - }, - "CommonTags": { - "description": "A list of tags.\nTags can be used with list commands to filter returned data.\n", - "type": "array", - "items": { - "type": "string" - } - }, - "CommonVisibility": { - "description": "The visibility of the executables to Flow.\nIf not set, the visibility will default to `public`.\n\n`public` executables can be executed and listed from anywhere.\n`private` executables can be executed and listed only within their own workspace.\n`internal` executables can be executed within their own workspace but are not listed.\n`hidden` executables cannot be executed or listed.\n", - "type": "string", - "default": "public", - "enum": [ - "public", - "private", - "internal", - "hidden" - ] - }, - "Executable": { - "title": "Executable", - "description": "The executable schema defines the structure of an executable in the Flow CLI.\nExecutables are the building blocks of workflows and are used to define the actions that can be performed in a workspace.\n", - "type": "object", - "required": [ - "verb" - ], - "properties": { - "aliases": { - "$ref": "#/definitions/CommonAliases", - "default": [] - }, - "description": { - "description": "A description of the executable.\nThis description is rendered as markdown in the interactive UI.\n", - "type": "string", - "default": "" - }, - "exec": { - "$ref": "#/definitions/ExecutableExecExecutableType" - }, - "launch": { - "$ref": "#/definitions/ExecutableLaunchExecutableType" - }, - "name": { - "description": "An optional name for the executable.\n\nName is used to reference the executable in the CLI using the format `workspace/namespace:name`.\n[Verb group + Name] must be unique within the namespace of the workspace.\n", - "type": "string", - "default": "" - }, - "parallel": { - "$ref": "#/definitions/ExecutableParallelExecutableType" - }, - "render": { - "$ref": "#/definitions/ExecutableRenderExecutableType" - }, - "request": { - "$ref": "#/definitions/ExecutableRequestExecutableType" - }, - "serial": { - "$ref": "#/definitions/ExecutableSerialExecutableType" - }, - "tags": { - "$ref": "#/definitions/CommonTags", - "default": [] - }, - "timeout": { - "description": "The maximum amount of time the executable is allowed to run before being terminated.\nThe timeout is specified in Go duration format (e.g. 30s, 5m, 1h).\n", - "type": "string" - }, - "verb": { - "$ref": "#/definitions/ExecutableVerb", - "default": "exec" - }, - "verbAliases": { - "description": "A list of aliases for the verb. This allows the executable to be referenced with multiple verbs.", - "type": "array", - "default": [], - "items": { - "$ref": "#/definitions/Verb" - } - }, - "visibility": { - "$ref": "#/definitions/CommonVisibility" - } - } - }, - "ExecutableArgument": { - "type": "object", - "properties": { - "default": { - "description": "The default value to use if the argument is not provided.\nIf the argument is required and no default is provided, the executable will fail.\n", - "type": "string", - "default": "" - }, - "envKey": { - "description": "The name of the environment variable that will be assigned the value.", - "type": "string", - "default": "" - }, - "flag": { - "description": "The flag to use when setting the argument from the command line.\nEither `flag` or `pos` must be set, but not both.\n", - "type": "string", - "default": "" - }, - "outputFile": { - "description": "A path where the argument value will be temporarily written to disk.\nThe file will be created before execution and cleaned up afterwards.\n", - "type": "string", - "default": "" - }, - "pos": { - "description": "The position of the argument in the command line ArgumentList. Values start at 1.\nEither `flag` or `pos` must be set, but not both.\n", - "type": "integer" - }, - "required": { - "description": "If the argument is required, the executable will fail if the argument is not provided.\nIf the argument is not required, the default value will be used if the argument is not provided.\n", - "type": "boolean", - "default": false - }, - "type": { - "description": "The type of the argument. This is used to determine how to parse the value of the argument.", - "type": "string", - "default": "string", - "enum": [ - "string", - "int", - "float", - "bool" - ] - } - } - }, - "ExecutableArgumentList": { - "type": "array", - "items": { - "$ref": "#/definitions/ExecutableArgument" - } - }, - "ExecutableDirectory": { - "description": "The directory to execute the command in.\nIf unset, the directory of the flow file will be used.\nIf set to `f:tmp`, a temporary directory will be created for the process.\nIf prefixed with `./`, the path will be relative to the current working directory.\nIf prefixed with `//`, the path will be relative to the workspace root.\nEnvironment variables in the path will be expended at runtime.\n", - "type": "string", - "default": "" - }, - "ExecutableExecExecutableType": { - "description": "Standard executable type. Runs a command/file in a subprocess.", - "type": "object", - "properties": { - "args": { - "$ref": "#/definitions/ExecutableArgumentList" - }, - "cmd": { - "description": "The command to execute.\nOnly one of `cmd` or `file` must be set.\n", - "type": "string", - "default": "" - }, - "dir": { - "$ref": "#/definitions/ExecutableDirectory", - "default": "" - }, - "file": { - "description": "The file to execute.\nOnly one of `cmd` or `file` must be set.\n", - "type": "string", - "default": "" - }, - "logMode": { - "description": "The log mode to use when running the executable.\nThis can either be `hidden`, `json`, `logfmt` or `text`\n", - "type": "string", - "default": "logfmt" - }, - "params": { - "$ref": "#/definitions/ExecutableParameterList" - } - } - }, - "ExecutableLaunchExecutableType": { - "description": "Launches an application or opens a URI.", - "type": "object", - "required": [ - "uri" - ], - "properties": { - "app": { - "description": "The application to launch the URI with.", - "type": "string", - "default": "" - }, - "args": { - "$ref": "#/definitions/ExecutableArgumentList" - }, - "params": { - "$ref": "#/definitions/ExecutableParameterList" - }, - "uri": { - "description": "The URI to launch. This can be a file path or a web URL.", - "type": "string", - "default": "" - } - } - }, - "ExecutableParallelExecutableType": { - "type": "object", - "required": [ - "execs" - ], - "properties": { - "args": { - "$ref": "#/definitions/ExecutableArgumentList" - }, - "dir": { - "$ref": "#/definitions/ExecutableDirectory", - "default": "" - }, - "execs": { - "$ref": "#/definitions/ExecutableParallelRefConfigList", - "description": "A list of executables to run in parallel.\nEach executable can be a command or a reference to another executable.\n" - }, - "failFast": { - "description": "End the parallel execution as soon as an exec exits with a non-zero status. This is the default behavior.\nWhen set to false, all execs will be run regardless of the exit status of parallel execs.\n", - "type": "boolean" - }, - "maxThreads": { - "description": "The maximum number of threads to use when executing the parallel executables.", - "type": "integer", - "default": 5 - }, - "params": { - "$ref": "#/definitions/ExecutableParameterList" - } - } - }, - "ExecutableParallelRefConfig": { - "description": "Configuration for a parallel executable.", - "type": "object", - "properties": { - "args": { - "description": "Arguments to pass to the executable.", - "type": "array", - "default": [], - "items": { - "type": "string" - } - }, - "cmd": { - "description": "The command to execute.\nOne of `cmd` or `ref` must be set.\n", - "type": "string", - "default": "" - }, - "if": { - "description": "An expression that determines whether the executable should run, using the Expr language syntax.\nThe expression is evaluated at runtime and must resolve to a boolean value.\n\nThe expression has access to OS/architecture information (os, arch), environment variables (env), stored data\n(store), and context information (ctx) like workspace and paths.\n\nFor example, `os == \"darwin\"` will only run on macOS, `len(store[\"feature\"]) \u003e 0` will run if a value exists\nin the store, and `env[\"CI\"] == \"true\"` will run in CI environments.\nSee the [Expr documentation](https://expr-lang.org/docs/language-definition) for more information.\n", - "type": "string", - "default": "" - }, - "ref": { - "$ref": "#/definitions/ExecutableRef", - "description": "A reference to another executable to run in serial.\nOne of `cmd` or `ref` must be set.\n", - "default": "" - }, - "retries": { - "description": "The number of times to retry the executable if it fails.", - "type": "integer", - "default": 0 - } - } - }, - "ExecutableParallelRefConfigList": { - "description": "A list of executables to run in parallel. The executables can be defined by it's exec `cmd` or `ref`.\n", - "type": "array", - "items": { - "$ref": "#/definitions/ExecutableParallelRefConfig" - } - }, - "ExecutableParameter": { - "description": "A parameter is a value that can be passed to an executable and all of its sub-executables.\nOnly one of `text`, `secretRef`, `prompt`, or `file` must be set. Specifying more than one will result in an error.\n", - "type": "object", - "properties": { - "envFile": { - "description": "A path to a file containing environment variables to be passed to the executable.\nThe file should contain one variable per line in the format `KEY=VALUE`.\n", - "type": "string", - "default": "" - }, - "envKey": { - "description": "The name of the environment variable that will be assigned the value.\n\nWhen specified with `envFile`, only the environment variable with this name will be set.\n", - "type": "string", - "default": "" - }, - "outputFile": { - "description": "A path where the parameter value will be temporarily written to disk.\nThe file will be created before execution and cleaned up afterwards.\n", - "type": "string", - "default": "" - }, - "prompt": { - "description": "A prompt to be displayed to the user when collecting an input value.", - "type": "string", - "default": "" - }, - "secretRef": { - "description": "A reference to a secret to be passed to the executable.", - "type": "string", - "default": "" - }, - "text": { - "description": "A static value to be passed to the executable.", - "type": "string", - "default": "" - } - } - }, - "ExecutableParameterList": { - "type": "array", - "items": { - "$ref": "#/definitions/ExecutableParameter" - } - }, - "ExecutableRef": { - "description": "A reference to an executable.\nThe format is `\u003cverb\u003e \u003cworkspace\u003e/\u003cnamespace\u003e:\u003cexecutable name\u003e`.\nFor example, `exec ws/ns:my-workflow`.\n\n- If the workspace is not specified, the current workspace will be used.\n- If the namespace is not specified, the current namespace will be used.\n- Excluding the name will reference the executable with a matching verb but an unspecified name and namespace (e.g. `exec ws` or simply `exec`).\n", - "type": "string" - }, - "ExecutableRenderExecutableType": { - "description": "Renders a markdown template file with data.", - "type": "object", - "required": [ - "templateFile" - ], - "properties": { - "args": { - "$ref": "#/definitions/ExecutableArgumentList" - }, - "dir": { - "$ref": "#/definitions/ExecutableDirectory", - "default": "" - }, - "params": { - "$ref": "#/definitions/ExecutableParameterList" - }, - "templateDataFile": { - "description": "The path to the JSON or YAML file containing the template data.", - "type": "string", - "default": "" - }, - "templateFile": { - "description": "The path to the markdown template file to render.", - "type": "string", - "default": "" - } - } - }, - "ExecutableRequestExecutableType": { - "description": "Makes an HTTP request.", - "type": "object", - "required": [ - "url" - ], - "properties": { - "args": { - "$ref": "#/definitions/ExecutableArgumentList" - }, - "body": { - "description": "The body of the request.", - "type": "string", - "default": "" - }, - "headers": { - "description": "A map of headers to include in the request.", - "type": "object", - "default": {}, - "additionalProperties": { - "type": "string" - } - }, - "logResponse": { - "description": "If set to true, the response will be logged as program output.", - "type": "boolean", - "default": false - }, - "method": { - "description": "The HTTP method to use when making the request.", - "type": "string", - "default": "GET", - "enum": [ - "GET", - "POST", - "PUT", - "PATCH", - "DELETE" - ] - }, - "params": { - "$ref": "#/definitions/ExecutableParameterList" - }, - "responseFile": { - "$ref": "#/definitions/ExecutableRequestResponseFile" - }, - "timeout": { - "description": "The timeout for the request in Go duration format (e.g. 30s, 5m, 1h).", - "type": "string", - "default": "30m0s" - }, - "transformResponse": { - "description": "[Expr](https://expr-lang.org/docs/language-definition) expression used to transform the response before\nsaving it to a file or outputting it.\n\nThe following variables are available in the expression:\n - `status`: The response status string.\n - `code`: The response status code.\n - `body`: The response body.\n - `headers`: The response headers.\n\nFor example, to capitalize a JSON body field's value, you can use `upper(fromJSON(body)[\"field\"])`.\n", - "type": "string", - "default": "" - }, - "url": { - "description": "The URL to make the request to.", - "type": "string", - "default": "" - }, - "validStatusCodes": { - "description": "A list of valid status codes. If the response status code is not in this list, the executable will fail.\nIf not set, the response status code will not be checked.\n", - "type": "array", - "default": [], - "items": { - "type": "integer" - } - } - } - }, - "ExecutableRequestResponseFile": { - "description": "Configuration for saving the response of a request to a file.", - "type": "object", - "required": [ - "filename" - ], - "properties": { - "dir": { - "$ref": "#/definitions/ExecutableDirectory", - "default": "" - }, - "filename": { - "description": "The name of the file to save the response to.", - "type": "string", - "default": "" - }, - "saveAs": { - "description": "The format to save the response as.", - "type": "string", - "default": "raw", - "enum": [ - "raw", - "json", - "indented-json", - "yaml", - "yml" - ] - } - } - }, - "ExecutableSerialExecutableType": { - "description": "Executes a list of executables in serial.", - "type": "object", - "required": [ - "execs" - ], - "properties": { - "args": { - "$ref": "#/definitions/ExecutableArgumentList" - }, - "dir": { - "$ref": "#/definitions/ExecutableDirectory", - "default": "" - }, - "execs": { - "$ref": "#/definitions/ExecutableSerialRefConfigList", - "description": "A list of executables to run in serial.\nEach executable can be a command or a reference to another executable.\n" - }, - "failFast": { - "description": "End the serial execution as soon as an exec exits with a non-zero status. This is the default behavior.\nWhen set to false, all execs will be run regardless of the exit status of the previous exec.\n", - "type": "boolean" - }, - "params": { - "$ref": "#/definitions/ExecutableParameterList" - } - } - }, - "ExecutableSerialRefConfig": { - "description": "Configuration for a serial executable.", - "type": "object", - "properties": { - "args": { - "description": "Arguments to pass to the executable.", - "type": "array", - "default": [], - "items": { - "type": "string" - } - }, - "cmd": { - "description": "The command to execute.\nOne of `cmd` or `ref` must be set.\n", - "type": "string", - "default": "" - }, - "if": { - "description": "An expression that determines whether the executable should run, using the Expr language syntax.\nThe expression is evaluated at runtime and must resolve to a boolean value.\n\nThe expression has access to OS/architecture information (os, arch), environment variables (env), stored data\n(store), and context information (ctx) like workspace and paths.\n\nFor example, `os == \"darwin\"` will only run on macOS, `len(store[\"feature\"]) \u003e 0` will run if a value exists\nin the store, and `env[\"CI\"] == \"true\"` will run in CI environments.\nSee the [Expr documentation](https://expr-lang.org/docs/language-definition) for more information.\n", - "type": "string", - "default": "" - }, - "ref": { - "$ref": "#/definitions/ExecutableRef", - "description": "A reference to another executable to run in serial.\nOne of `cmd` or `ref` must be set.\n", - "default": "" - }, - "retries": { - "description": "The number of times to retry the executable if it fails.", - "type": "integer", - "default": 0 - }, - "reviewRequired": { - "description": "If set to true, the user will be prompted to review the output of the executable before continuing.", - "type": "boolean", - "default": false - } - } - }, - "ExecutableSerialRefConfigList": { - "description": "A list of executables to run in serial. The executables can be defined by it's exec `cmd` or `ref`.\n", - "type": "array", - "items": { - "$ref": "#/definitions/ExecutableSerialRefConfig" - } - }, - "ExecutableVerb": { - "description": "Keywords that describe the action an executable performs. Executables are configured with a single verb,\nbut core verbs have aliases that can be used interchangeably when referencing executables. This allows users \nto use the verb that best describes the action they are performing.\n\n### Default Verb Aliases\n\n- **Execution Group**: `exec`, `run`, `execute`\n- **Retrieval Group**: `get`, `fetch`, `retrieve`\n- **Display Group**: `show`, `view`, `list`\n- **Configuration Group**: `configure`, `setup`\n- **Update Group**: `update`, `upgrade`\n\n### Usage Notes\n\n1. [Verb + Name] must be unique within the namespace of the workspace.\n2. When referencing an executable, users can use any verb from the default or configured alias group.\n3. All other verbs are standalone and self-descriptive.\n\n### Examples\n\n- An executable configured with the `exec` verb can also be referenced using \"run\" or \"execute\".\n- An executable configured with `get` can also be called with \"list\", \"show\", or \"view\".\n- Operations like `backup`, `migrate`, `flush` are standalone verbs without aliases.\n- Use domain-specific verbs like `deploy`, `scale`, `tunnel` for clear operational intent.\n\nBy providing minimal aliasing with comprehensive verb coverage, flow enables natural language operations\nwhile maintaining simplicity and flexibility for diverse development and operations workflows.\n", - "type": "string", - "default": "exec", - "enum": [ - "abort", - "activate", - "add", - "analyze", - "apply", - "archive", - "audit", - "backup", - "benchmark", - "build", - "bundle", - "check", - "clean", - "clear", - "commit", - "compile", - "compress", - "configure", - "connect", - "copy", - "create", - "deactivate", - "debug", - "decompress", - "decrypt", - "delete", - "deploy", - "destroy", - "disable", - "disconnect", - "edit", - "enable", - "encrypt", - "erase", - "exec", - "execute", - "export", - "expose", - "fetch", - "fix", - "flush", - "format", - "generate", - "get", - "import", - "index", - "init", - "inspect", - "install", - "join", - "kill", - "launch", - "lint", - "list", - "load", - "lock", - "login", - "logout", - "manage", - "merge", - "migrate", - "modify", - "monitor", - "mount", - "new", - "notify", - "open", - "package", - "partition", - "patch", - "pause", - "ping", - "preload", - "prefetch", - "profile", - "provision", - "publish", - "purge", - "push", - "queue", - "reboot", - "recover", - "refresh", - "release", - "reload", - "remove", - "request", - "reset", - "restart", - "restore", - "retrieve", - "rollback", - "run", - "save", - "scale", - "scan", - "schedule", - "seed", - "send", - "serve", - "set", - "setup", - "show", - "snapshot", - "start", - "stash", - "stop", - "tag", - "teardown", - "terminate", - "test", - "tidy", - "trace", - "transform", - "trigger", - "tunnel", - "undeploy", - "uninstall", - "unmount", - "unset", - "update", - "upgrade", - "validate", - "verify", - "view", - "watch" - ] - }, - "FromFile": { - "description": "A list of `.sh` files to convert into generated executables in the file's executable group.", - "type": "array", - "default": [], - "items": { - "type": "string" - } - }, - "Ref": {}, - "Verb": {} - }, - "properties": { - "description": { - "description": "A description of the executables defined within the flow file. This description will used as a shared description\nfor all executables in the flow file.\n", - "type": "string", - "default": "" - }, - "descriptionFile": { - "description": "A path to a markdown file that contains the description of the executables defined within the flow file.", - "type": "string", - "default": "" - }, - "executables": { - "type": "array", - "default": [], - "items": { - "$ref": "#/definitions/Executable" - } - }, - "fromFile": { - "$ref": "#/definitions/FromFile", - "description": "DEPRECATED: Use `imports` instead", - "default": [] - }, - "imports": { - "$ref": "#/definitions/FromFile", - "default": [] - }, - "namespace": { - "description": "The namespace to be given to all executables in the flow file.\nIf not set, the executables in the file will be grouped into the root (*) namespace. \nNamespaces can be reused across multiple flow files.\n\nNamespaces are used to reference executables in the CLI using the format `workspace:namespace/name`.\n", - "type": "string", - "default": "" - }, - "tags": { - "description": "Tags to be applied to all executables defined within the flow file.", - "type": "array", - "default": [], - "items": { - "type": "string" - } - }, - "visibility": { - "$ref": "#/definitions/CommonVisibility" - } - } -} \ No newline at end of file diff --git a/internal/mcp/resources/server-instructions.md b/internal/mcp/resources/server-instructions.md index 7b69e4ca..7aa82476 100644 --- a/internal/mcp/resources/server-instructions.md +++ b/internal/mcp/resources/server-instructions.md @@ -7,14 +7,23 @@ from development/operations tasks to personal productivity tools, content manage ## Essential Context to Load First Unless this information is provided to you, always start new conversations by calling the `get_info` tool. -This provides all essential context including: +The response is intentionally lightweight and includes: - Current workspace, namespace, and vault context -- File type distinctions and schemas -- Flow concepts and platform guide +- A short platform summary +- URLs for the docs site, the `llms.txt` docs index, JSON schemas, and key guide pages You should only need to run this at the start of the conversation as the response is unlikely to change unless you or the user explicitly switches context or configurations. +### When you need deeper context + +Fetch documentation from the URLs that `get_info` returns rather than asking the user: +- **`llmsTxtUrl`** (`https://flowexec.io/llms.txt`) — index of all docs pages in the llmstxt.org format +- **`schemaUrls.*`** — JSON schemas for flowfile, workspace, template, and config files; use these when generating or validating YAML +- **`guideUrls.*`** — specific guide pages for concepts, file types, workspaces, secrets, templates, and the first-workflow tutorial + +The `write_flowfile` tool already validates YAML against the flowfile schema server-side, so you don't need to embed the schema in the client context to generate valid files — but fetching the schema from `schemaUrls.flowFile` is the authoritative reference when authoring non-trivial executables. + ## Flow Concepts If the user prompts with any of these concepts, then they are likely referring to the flow automation platform. @@ -28,7 +37,7 @@ If the user prompts with any of these concepts, then they are likely referring t ### Safety - **Always confirm** before running `execute` with potentially destructive commands -- **Validate YAML** before suggesting users save it to files. The JSON Schemas are provided by the `get_info` tool +- **Validate YAML** before suggesting users save it to files. JSON schema URLs are returned by `get_info` under `schemaUrls`, and `write_flowfile` performs server-side validation on write. - **Check current context** before making workspace assumptions - **Use appropriate filters** when using tools that may return long lists. For instance, provide the appropriate arguments for the `list_executables` tool if you know the target workspace, a keyword, or verb for the executable that you're looking for. diff --git a/internal/mcp/resources/template_schema.json b/internal/mcp/resources/template_schema.json deleted file mode 100644 index e3ec1548..00000000 --- a/internal/mcp/resources/template_schema.json +++ /dev/null @@ -1,172 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://flowexec.io/schemas/template_schema.json", - "title": "Template", - "description": "Configuration for a flowfile template; templates can be used to generate flow files.", - "type": "object", - "required": [ - "template" - ], - "definitions": { - "Artifact": { - "description": "File source and destination configuration.\nGo templating from form data is supported in all fields.\n", - "type": "object", - "required": [ - "srcName" - ], - "properties": { - "asTemplate": { - "description": "If true, the artifact will be copied as a template file. The file will be rendered using Go templating from \nthe form data. [Expr language functions](https://expr-lang.org/docs/language-definition) are available for use in the template.\n", - "type": "boolean", - "default": false - }, - "dstDir": { - "description": "The directory to copy the file to. If not set, the file will be copied to the root of the flow file directory.\nThe directory will be created if it does not exist.\n", - "type": "string", - "default": "" - }, - "dstName": { - "description": "The name of the file to copy to. If not set, the file will be copied with the same name.", - "type": "string", - "default": "" - }, - "if": { - "description": "An expression that determines whether the the artifact should be copied, using the Expr language syntax. \nThe expression is evaluated at runtime and must resolve to a boolean value. If the condition is not met, \nthe artifact will not be copied.\n\nThe expression has access to OS/architecture information (os, arch), environment variables (env), form input \n(form), and context information (name, workspace, directory, etc.).\n\nSee the [flow documentation](https://flowexec.io/guide/templating) for more information.\n", - "type": "string", - "default": "" - }, - "srcDir": { - "description": "The directory to copy the file from. \nIf not set, the file will be copied from the directory of the template file.\n", - "type": "string", - "default": "" - }, - "srcName": { - "description": "The name of the file to copy.", - "type": "string" - } - } - }, - "ExecutableRef": { - "description": "A reference to an executable.\nThe format is `\u003cverb\u003e \u003cworkspace\u003e/\u003cnamespace\u003e:\u003cexecutable name\u003e`.\nFor example, `exec ws/ns:my-workflow`.\n\n- If the workspace is not specified, the current workspace will be used.\n- If the namespace is not specified, the current namespace will be used.\n- Excluding the name will reference the executable with a matching verb but an unspecified name and namespace (e.g. `exec ws` or simply `exec`).\n", - "type": "string" - }, - "Field": { - "description": "A field to be displayed to the user when generating a flow file from a template.", - "type": "object", - "required": [ - "key", - "prompt" - ], - "properties": { - "default": { - "description": "The default value to use if a value is not set.", - "type": "string", - "default": "" - }, - "description": { - "description": "A description of the field.", - "type": "string", - "default": "" - }, - "group": { - "description": "The group to display the field in. Fields with the same group will be displayed together.", - "type": "integer", - "default": 0 - }, - "key": { - "description": "The key to associate the data with. This is used as the key in the template data map.", - "type": "string" - }, - "prompt": { - "description": "A prompt to be displayed to the user when collecting an input value.", - "type": "string" - }, - "required": { - "description": "If true, a value must be set. If false, the default value will be used if a value is not set.", - "type": "boolean", - "default": false - }, - "type": { - "description": "The type of input field to display.", - "type": "string", - "default": "text", - "enum": [ - "text", - "masked", - "multiline", - "confirm" - ] - }, - "validate": { - "description": "A regular expression to validate the input value against.", - "type": "string", - "default": "" - } - } - }, - "TemplateRefConfig": { - "description": "Configuration for a template executable.", - "type": "object", - "properties": { - "args": { - "description": "Arguments to pass to the executable.", - "type": "array", - "default": [], - "items": { - "type": "string" - } - }, - "cmd": { - "description": "The command to execute.\nOne of `cmd` or `ref` must be set.\n", - "type": "string", - "default": "" - }, - "if": { - "description": "An expression that determines whether the executable should be run, using the Expr language syntax. \nThe expression is evaluated at runtime and must resolve to a boolean value. If the condition is not met, \nthe executable will be skipped.\n\nThe expression has access to OS/architecture information (os, arch), environment variables (env), form input \n(form), and context information (name, workspace, directory, etc.).\n\nSee the [flow documentation](https://flowexec.io/guide/templating) for more information.\n", - "type": "string", - "default": "" - }, - "ref": { - "$ref": "#/definitions/ExecutableRef", - "description": "A reference to another executable to run in serial.\nOne of `cmd` or `ref` must be set.\n", - "default": "" - } - } - } - }, - "properties": { - "artifacts": { - "description": "A list of artifacts to be copied after generating the flow file.", - "type": "array", - "items": { - "$ref": "#/definitions/Artifact" - } - }, - "form": { - "description": "Form fields to be displayed to the user when generating a flow file from a template. \nThe form will be rendered first, and the user's input can be used to render the template.\n", - "type": "array", - "default": [], - "items": { - "$ref": "#/definitions/Field" - } - }, - "postRun": { - "description": "A list of exec executables to run after generating the flow file.", - "type": "array", - "items": { - "$ref": "#/definitions/TemplateRefConfig" - } - }, - "preRun": { - "description": "A list of exec executables to run before generating the flow file.", - "type": "array", - "items": { - "$ref": "#/definitions/TemplateRefConfig" - } - }, - "template": { - "description": "The flow file template to generate. The template must be a valid flow file after rendering.", - "type": "string" - } - } -} \ No newline at end of file diff --git a/internal/mcp/resources/workspace_schema.json b/internal/mcp/resources/workspace_schema.json deleted file mode 100644 index 5dc17814..00000000 --- a/internal/mcp/resources/workspace_schema.json +++ /dev/null @@ -1,82 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://flowexec.io/schemas/workspace_schema.json", - "title": "Workspace", - "description": "Configuration for a workspace in the Flow CLI.\nThis configuration is used to define the settings for a workspace.\nEvery workspace has a workspace config file named `flow.yaml` in the root of the workspace directory.\n", - "type": "object", - "definitions": { - "CommonTags": { - "description": "A list of tags.\nTags can be used with list commands to filter returned data.\n", - "type": "array", - "items": { - "type": "string" - } - }, - "ExecutableFilter": { - "type": "object", - "properties": { - "excluded": { - "description": "A list of directories or file patterns to exclude from the executable search.\nSupports directory paths (e.g., \"node_modules/\", \"vendor/\") and glob patterns for filenames (e.g., \"*.js.flow\", \"*temp*\").\nCommon exclusions like node_modules/, vendor/, third_party/, external/, and *.js.flow are excluded by default.\n", - "type": "array", - "default": [], - "items": { - "type": "string" - } - }, - "included": { - "description": "A list of directories or file patterns to include in the executable search.\nSupports directory paths (e.g., \"src/\", \"scripts/\") and glob patterns for filenames (e.g., \"*.test.flow\", \"example*\").\n", - "type": "array", - "default": [], - "items": { - "type": "string" - } - } - } - }, - "VerbAliases": { - "description": "A map of executable verbs to valid aliases. This allows you to use custom aliases for exec commands in the workspace.\nSetting this will override all of the default flow command aliases. The verbs and its mapped aliases must be valid flow verbs.\n\nIf set to an empty object, verb aliases will be disabled.\n", - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "properties": { - "description": { - "description": "A description of the workspace. This description is rendered as markdown in the interactive UI.", - "type": "string", - "default": "" - }, - "descriptionFile": { - "description": "A path to a markdown file that contains the description of the workspace.", - "type": "string", - "default": "" - }, - "displayName": { - "description": "The display name of the workspace. This is used in the interactive UI.", - "type": "string", - "default": "" - }, - "envFiles": { - "description": "A list of environment variable files to load for the workspace. These files should contain key-value pairs of environment variables.\nBy default, the `.env` file in the workspace root is loaded if it exists.\n", - "type": "array", - "default": [], - "items": { - "type": "string" - } - }, - "executables": { - "$ref": "#/definitions/ExecutableFilter" - }, - "tags": { - "$ref": "#/definitions/CommonTags", - "default": [] - }, - "verbAliases": { - "$ref": "#/definitions/VerbAliases" - } - } -} \ No newline at end of file diff --git a/internal/mcp/resources_test.go b/internal/mcp/resources_test.go new file mode 100644 index 00000000..54e59b3d --- /dev/null +++ b/internal/mcp/resources_test.go @@ -0,0 +1,67 @@ +//nolint:testpackage // tests unexported URI parsing helpers +package mcp + +import ( + "testing" +) + +func TestExtractExecutableURIParts(t *testing.T) { + tests := []struct { + name string + uri string + wantWS string + wantNS string + wantName string + }{ + { + name: "fully qualified", + uri: "flow://executable/myws/myns/myexec", + wantWS: "myws", + wantNS: "myns", + wantName: "myexec", + }, + { + name: "empty namespace", + uri: "flow://executable/myws//myexec", + wantWS: "myws", + wantNS: "", + wantName: "myexec", + }, + { + name: "empty workspace", + uri: "flow://executable//myns/myexec", + wantWS: "", + wantNS: "myns", + wantName: "myexec", + }, + { + name: "empty workspace and namespace", + uri: "flow://executable///myexec", + wantWS: "", + wantNS: "", + wantName: "myexec", + }, + { + name: "malformed missing segments", + uri: "flow://executable/onlyname", + wantWS: "", + wantNS: "", + wantName: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := extractExecutableURIParts(tc.uri) + if got.workspace != tc.wantWS { + t.Errorf("workspace: got %q, want %q", got.workspace, tc.wantWS) + } + if got.namespace != tc.wantNS { + t.Errorf("namespace: got %q, want %q", got.namespace, tc.wantNS) + } + if got.name != tc.wantName { + t.Errorf("name: got %q, want %q", got.name, tc.wantName) + } + }) + } +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 26743bf1..94ff508d 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -21,12 +21,14 @@ func NewServer(executor CommandExecutor) *Server { srv := server.NewMCPServer( "Flow", "1.0.0", - server.WithToolCapabilities(false), + server.WithToolCapabilities(true), server.WithPromptCapabilities(false), + server.WithResourceCapabilities(true, true), server.WithInstructions(serverInstructions), ) addServerTools(srv, executor) addServerPrompts(srv) + addServerResources(srv) return &Server{srv: srv, executor: executor} } diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 42c7211e..ceb3c211 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -2,6 +2,10 @@ package mcp_test import ( "context" + "encoding/json" + "errors" + "os" + "path/filepath" "testing" "github.com/mark3labs/mcp-go/client" @@ -87,12 +91,60 @@ var _ = Describe("MCP Server", func() { "execute", "get_execution_logs", "sync_executables", + "write_flowfile", + "get_workspace_config", } for _, expectedTool := range expectedTools { Expect(toolNames).To(ContainElement(expectedTool)) } }) + + It("should include output schema on list tools", func() { + toolsResult, err := mcpClient.ListTools(ctx, mcp.ListToolsRequest{}) + Expect(err).ToNot(HaveOccurred()) + + // Verify tools that should have output schemas + toolsWithSchema := map[string]bool{ + "list_workspaces": true, + "list_executables": true, + "get_workspace": true, + "get_execution_logs": true, + "get_info": true, + "write_flowfile": true, + "get_workspace_config": true, + } + + for _, tool := range toolsResult.Tools { + if toolsWithSchema[tool.Name] { + Expect(tool.OutputSchema.Type).ToNot(BeEmpty(), + "tool %s should have an output schema", tool.Name) + } + } + }) + }) + + Describe("Resource Template Registration", func() { + It("should register all expected resource templates", func() { + result, err := mcpClient.ListResourceTemplates(ctx, mcp.ListResourceTemplatesRequest{}) + Expect(err).ToNot(HaveOccurred()) + + templates := make([]string, len(result.ResourceTemplates)) + for i, t := range result.ResourceTemplates { + templates[i] = t.URITemplate.Raw() + } + + expectedTemplates := []string{ + "flow://workspace/{name}", + "flow://executable/{workspace}/{namespace}/{name}", + "flow://flowfile/{+path}", + "flow://logs/{run_id}", + } + + for _, expected := range expectedTemplates { + Expect(templates).To(ContainElement(expected)) + } + }) }) Describe("Prompt Registration", func() { @@ -144,8 +196,9 @@ var _ = Describe("MCP Server", func() { Expect(err).ToNot(HaveOccurred()) content := getTextContent(result) Expect(content).To(ContainSubstring("currentContext")) - Expect(content).To(ContainSubstring("usageGuides")) - Expect(content).To(ContainSubstring("schemas")) + Expect(content).To(ContainSubstring("docsUrl")) + Expect(content).To(ContainSubstring("llmsTxtUrl")) + Expect(content).To(ContainSubstring("schemaUrls")) }) }) @@ -240,10 +293,9 @@ var _ = Describe("MCP Server", func() { Context("execute tool", func() { It("should call executor with provided arguments", func() { - expectedOutput := "execution result" mockExecutor.EXPECT(). - Execute("test", "test:test-flow", "arg1", "arg2"). - Return(expectedOutput, nil) + ExecuteContext(gomock.Any(), "test", "test:test-flow", "arg1", "arg2"). + Return("execution result", nil) result, err := mcpClient.CallTool(ctx, newCallToolRequest("execute", map[string]interface{}{ "executable_verb": "test", @@ -252,14 +304,13 @@ var _ = Describe("MCP Server", func() { })) Expect(err).ToNot(HaveOccurred()) - Expect(getTextContent(result)).To(Equal(expectedOutput)) + Expect(getTextContent(result)).To(ContainSubstring("execution result")) }) It("should handle no args", func() { - expectedOutput := "execution result with no args" mockExecutor.EXPECT(). - Execute("test", "test:test-flow"). - Return(expectedOutput, nil) + ExecuteContext(gomock.Any(), "test", "test:test-flow"). + Return("execution result with no args", nil) result, err := mcpClient.CallTool(ctx, newCallToolRequest("execute", map[string]interface{}{ "executable_verb": "test", @@ -267,7 +318,7 @@ var _ = Describe("MCP Server", func() { })) Expect(err).ToNot(HaveOccurred()) - Expect(getTextContent(result)).To(Equal(expectedOutput)) + Expect(getTextContent(result)).To(ContainSubstring("execution result with no args")) }) }) @@ -290,7 +341,7 @@ var _ = Describe("MCP Server", func() { Context("sync_executables tool", func() { It("should call executor with correct arguments", func() { mockExecutor.EXPECT(). - Execute("sync"). + ExecuteContext(gomock.Any(), "sync"). Return("Synced executables", nil) _, err := mcpClient.CallTool(ctx, newCallToolRequest("sync_executables", nil)) @@ -298,6 +349,159 @@ var _ = Describe("MCP Server", func() { Expect(err).ToNot(HaveOccurred()) }) }) + + Context("structured error responses", func() { + It("should return a structured error JSON when executor fails", func() { + mockExecutor.EXPECT(). + Execute("workspace", "get", "missing-ws", "--output", "json"). + Return("workspace not found", errors.New("exit status 1")) + + result, err := mcpClient.CallTool(ctx, newCallToolRequest("get_workspace", map[string]interface{}{ + "workspace_name": "missing-ws", + })) + Expect(err).ToNot(HaveOccurred()) + Expect(result.IsError).To(BeTrue()) + + text := getTextContent(result) + var payload struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + Expect(json.Unmarshal([]byte(text), &payload)).To(Succeed()) + Expect(payload.Error.Code).To(Equal("NOT_FOUND")) + Expect(payload.Error.Message).To(ContainSubstring("missing-ws")) + }) + + It("should return INVALID_INPUT error for missing required parameter", func() { + result, err := mcpClient.CallTool(ctx, newCallToolRequest("get_workspace", map[string]interface{}{})) + Expect(err).ToNot(HaveOccurred()) + Expect(result.IsError).To(BeTrue()) + + text := getTextContent(result) + Expect(text).To(ContainSubstring("INVALID_INPUT")) + Expect(text).To(ContainSubstring("workspace_name")) + }) + }) + + Context("write_flowfile tool", func() { + It("should write a valid flowfile and return summary", func() { + tmpDir := GinkgoTB().TempDir() + flowPath := filepath.Join(tmpDir, "test.flow") + + validYAML := ` +executables: + - verb: run + name: hello + description: say hello + exec: + cmd: echo hello + - verb: test + name: greet + exec: + cmd: echo greet +` + result, err := mcpClient.CallTool(ctx, newCallToolRequest("write_flowfile", map[string]interface{}{ + "path": flowPath, + "content": validYAML, + })) + Expect(err).ToNot(HaveOccurred()) + Expect(result.IsError).To(BeFalse()) + + text := getTextContent(result) + var out flowMcp.WriteFlowFileOutput + Expect(json.Unmarshal([]byte(text), &out)).To(Succeed()) + Expect(out.Path).To(Equal(flowPath)) + Expect(out.Executables).To(ContainElements("hello", "greet")) + + // Verify file was actually written + _, statErr := os.Stat(flowPath) + Expect(statErr).ToNot(HaveOccurred()) + }) + + It("should reject invalid file extension", func() { + result, err := mcpClient.CallTool(ctx, newCallToolRequest("write_flowfile", map[string]interface{}{ + "path": "/tmp/bad.txt", + "content": "executables: []", + })) + Expect(err).ToNot(HaveOccurred()) + Expect(result.IsError).To(BeTrue()) + Expect(getTextContent(result)).To(ContainSubstring("VALIDATION_FAILED")) + }) + + It("should reject invalid YAML content", func() { + tmpDir := GinkgoTB().TempDir() + flowPath := filepath.Join(tmpDir, "bad.flow") + + result, err := mcpClient.CallTool(ctx, newCallToolRequest("write_flowfile", map[string]interface{}{ + "path": flowPath, + "content": "not: valid: yaml: [[[", + })) + Expect(err).ToNot(HaveOccurred()) + Expect(result.IsError).To(BeTrue()) + Expect(getTextContent(result)).To(ContainSubstring("VALIDATION_FAILED")) + }) + + It("should reject existing file without overwrite flag", func() { + tmpDir := GinkgoTB().TempDir() + flowPath := filepath.Join(tmpDir, "existing.flow") + Expect(os.WriteFile(flowPath, []byte("executables: []\n"), 0600)).To(Succeed()) + + result, err := mcpClient.CallTool(ctx, newCallToolRequest("write_flowfile", map[string]interface{}{ + "path": flowPath, + "content": "executables: []", + })) + Expect(err).ToNot(HaveOccurred()) + Expect(result.IsError).To(BeTrue()) + Expect(getTextContent(result)).To(ContainSubstring("already exists")) + }) + + It("should overwrite existing file when overwrite=true", func() { + tmpDir := GinkgoTB().TempDir() + flowPath := filepath.Join(tmpDir, "existing.flow") + Expect(os.WriteFile(flowPath, []byte("executables: []\n"), 0600)).To(Succeed()) + + result, err := mcpClient.CallTool(ctx, newCallToolRequest("write_flowfile", map[string]interface{}{ + "path": flowPath, + "content": "executables: []", + "overwrite": true, + })) + Expect(err).ToNot(HaveOccurred()) + Expect(result.IsError).To(BeFalse()) + }) + }) + + Context("list_executables pagination", func() { + It("should paginate results and include next cursor", func() { + // Build a CLI JSON output with 30 executables. + var cliResp struct { + Executables []flowMcp.ExecutableOutput `json:"executables"` + } + for i := 0; i < 30; i++ { + cliResp.Executables = append(cliResp.Executables, flowMcp.ExecutableOutput{ + ID: "ws/ns:exec", + Ref: "run ws/ns:exec", + Name: "exec", + Verb: "run", + }) + } + cliJSON, _ := json.Marshal(cliResp) + + mockExecutor.EXPECT(). + Execute("browse", "--output", "json", "--workspace", "*", "--namespace", "*"). + Return(string(cliJSON), nil) + + result, err := mcpClient.CallTool(ctx, newCallToolRequest("list_executables", nil)) + Expect(err).ToNot(HaveOccurred()) + + var out flowMcp.ExecutableListOutput + Expect(json.Unmarshal([]byte(getTextContent(result)), &out)).To(Succeed()) + Expect(out.TotalCount).To(Equal(30)) + Expect(out.Executables).To(HaveLen(25)) // defaultPageSize + Expect(out.NextCursor).ToNot(BeEmpty()) + }) + }) }) }) diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index bf353190..a62ef5a2 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -1,354 +1,33 @@ -//nolint:nilerr package mcp import ( "context" - _ "embed" - "encoding/json" - "fmt" - "strings" - "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" - "github.com/pkg/errors" - - "github.com/flowexec/flow/pkg/filesystem" - "github.com/flowexec/flow/types/executable" -) - -var ( - //go:embed resources/concepts-guide.md - conceptsMD string - - //go:embed resources/file-types-guide.md - fileTypesMD string - - // The below schemas are updated by the docsgen tool. We embed instead of fetching to avoid unnecessary network - // requests and to ensure that the MCP server always has the schema needed for the current CLI version. - - //go:embed resources/flowfile_schema.json - flowFileSchema string - - //go:embed resources/template_schema.json - templateSchema string - - //go:embed resources/workspace_schema.json - workspaceSchema string ) -//nolint:funlen func addServerTools(srv *server.MCPServer, executor CommandExecutor) { - // Ideally this information would just be exposed via resources but many MCP clients don't support resources. - // This implementation should be revisited in the future. - // See https://modelcontextprotocol.io/clients - getFlowInfo := mcp.NewTool("get_info", - mcp.WithDescription( - "Get information about flow, it's usage, and the current workflow execution context. "+ - "This includes file JSON schemas for flow executable, template, and workspace files, concepts guides, "+ - "and the current user configuration and state details.")) - getFlowInfo.Annotations = mcp.ToolAnnotation{ - Title: "Get flow information and current context", - DestructiveHint: boolPtr(false), ReadOnlyHint: boolPtr(true), - IdempotentHint: boolPtr(false), OpenWorldHint: boolPtr(false), - } - srv.AddTool(getFlowInfo, getInfoHandler) - - getWorkspace := mcp.NewTool("get_workspace", - mcp.WithString("workspace_name", mcp.Required(), mcp.Description("Registered workspace name")), - mcp.WithDescription("Get details about a registered flow workspaces"), - ) - getWorkspace.Annotations = mcp.ToolAnnotation{ - Title: "Get a specific workspace by name", - DestructiveHint: boolPtr(false), ReadOnlyHint: boolPtr(true), - IdempotentHint: boolPtr(true), OpenWorldHint: boolPtr(true), - } - srv.AddTool(getWorkspace, getWorkspaceHandler(executor)) - - listWorkspaces := mcp.NewTool("list_workspaces", - mcp.WithDescription("List all registered flow workspaces"), - ) - listWorkspaces.Annotations = mcp.ToolAnnotation{ - Title: "List workspaces", - DestructiveHint: boolPtr(false), ReadOnlyHint: boolPtr(true), - IdempotentHint: boolPtr(true), OpenWorldHint: boolPtr(true), - } - srv.AddTool(listWorkspaces, listWorkspacesHandler(executor)) - - switchWorkspace := mcp.NewTool("switch_workspace", - mcp.WithString("workspace_name", mcp.Required(), mcp.Description("Registered workspace name")), - mcp.WithDescription("Change the current workspace"), - ) - switchWorkspace.Annotations = mcp.ToolAnnotation{ - Title: "Change the current workspace", - DestructiveHint: boolPtr(false), ReadOnlyHint: boolPtr(false), - IdempotentHint: boolPtr(true), OpenWorldHint: boolPtr(false), - } - srv.AddTool(switchWorkspace, switchWorkspaceHandler(executor)) - - getExecutable := mcp.NewTool("get_executable", - mcp.WithDescription("Get detailed information about an executable"), - mcp.WithString("executable_verb", mcp.Required(), - mcp.Enum(executable.SortedValidVerbs()...), - mcp.Description("Executable verb")), - mcp.WithString("executable_id", - mcp.Pattern(`^([a-zA-Z0-9_-]+(/[a-zA-Z0-9_-]+)?:)?[a-zA-Z0-9_-]+$`), - mcp.Description("Executable ID (workspace/namespace:name or just name if using the current workspace/namespace)")), - ) - getExecutable.Annotations = mcp.ToolAnnotation{ - Title: "Get a specific executable by reference", - DestructiveHint: boolPtr(false), ReadOnlyHint: boolPtr(true), - IdempotentHint: boolPtr(true), OpenWorldHint: boolPtr(true), - } - srv.AddTool(getExecutable, getExecutableHandler(executor)) - - listExecutables := mcp.NewTool("list_executables", - mcp.WithDescription("List and filter executables across all workspaces"), - mcp.WithString("workspace", mcp.Description("Workspace name (optional)")), - mcp.WithString("namespace", mcp.Description("Namespace filter (optional)")), - mcp.WithString("verb", mcp.Description("Verb filter (optional)")), - mcp.WithString("keyword", mcp.Description("Keyword filter (optional)")), - mcp.WithString("tag", mcp.Description("Tag filter (optional)")), - ) - listExecutables.Annotations = mcp.ToolAnnotation{ - Title: "List executables", - DestructiveHint: boolPtr(false), ReadOnlyHint: boolPtr(true), - IdempotentHint: boolPtr(true), OpenWorldHint: boolPtr(true), - } - srv.AddTool(listExecutables, listExecutablesHandler(executor)) - - executeFlow := mcp.NewTool("execute", - mcp.WithDescription("Execute a flow executable"), - mcp.WithString("executable_verb", mcp.Required(), - mcp.Enum(executable.SortedValidVerbs()...), - mcp.Description("Executable verb")), - mcp.WithString("executable_id", - mcp.Pattern(`^([a-zA-Z0-9_-]+(/[a-zA-Z0-9_-]+)?:)?[a-zA-Z0-9_-]+$`), - mcp.Description( - "Executable ID (workspace/namespace:name or just name if using the current workspace/namespace). "+ - "If the executable does not have a name, you can specify just the workspace (`ws/`), namespace (`ns:`) "+ - "both (`ws/ns:`) or neither if the current workspace/namespace should be used.")), - mcp.WithString("args", mcp.Description("Arguments to pass")), - mcp.WithBoolean("sync", mcp.Description("Sync executable changes before execution")), - ) - executeFlow.Annotations = mcp.ToolAnnotation{ - Title: "Execute executable", - ReadOnlyHint: boolPtr(false), DestructiveHint: boolPtr(true), - IdempotentHint: boolPtr(false), OpenWorldHint: boolPtr(true), - } - srv.AddTool(executeFlow, executeFlowHandler(executor)) - - getExecutionLogs := mcp.NewTool("get_execution_logs", - mcp.WithDescription("Get a list of the recent flow execution logs"), - mcp.WithBoolean("last", mcp.Description("Get only the last execution logs"))) - getExecutionLogs.Annotations = mcp.ToolAnnotation{ - Title: "Get execution logs", - DestructiveHint: boolPtr(false), ReadOnlyHint: boolPtr(true), - IdempotentHint: boolPtr(true), OpenWorldHint: boolPtr(true), - } - srv.AddTool(getExecutionLogs, getExecutionLogsHandler(executor)) - - sync := mcp.NewTool("sync_executables", - mcp.WithDescription("Sync the flow workspace and executable state")) - sync.Annotations = mcp.ToolAnnotation{ - Title: "Sync executable and workspace state", - DestructiveHint: boolPtr(false), ReadOnlyHint: boolPtr(false), - IdempotentHint: boolPtr(false), OpenWorldHint: boolPtr(true), - } - srv.AddTool(sync, syncStateHandler(executor)) -} - -func getInfoHandler(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { - cfg, err := filesystem.LoadConfig() - if err != nil { - return nil, errors.Wrap(err, "failed to load user config") - } - cfg.SetDefaults() - - wsName, err := cfg.CurrentWorkspaceName() - if err != nil { - return nil, errors.Wrap(err, "failed to get current workspace name") - } - - infoData := map[string]interface{}{ - "usageGuides": map[string]interface{}{ - "concepts": conceptsMD, - "fileTypes": fileTypesMD, - }, - "schemas": map[string]interface{}{ - "flowFileSchema": flowFileSchema, - "workspaceConfigSchema": workspaceSchema, - "templateFileSchema": templateSchema, - }, - "currentContext": map[string]interface{}{ - "workspace": wsName, - "namespace": cfg.CurrentNamespace, - "vault": cfg.CurrentVault, - "workspaceMode": cfg.WorkspaceMode, - "workspacePath": cfg.Workspaces[cfg.CurrentWorkspace], - }, - } - jsonData, err := json.Marshal(infoData) - if err != nil { - return nil, err - } - - return mcp.NewToolResultText(string(jsonData)), nil -} - -func getWorkspaceHandler(executor CommandExecutor) server.ToolHandlerFunc { - return func(_ context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - wsName, err := request.RequireString("workspace_name") - if err != nil { - return mcp.NewToolResultError("workspace_name is required"), nil - } - - output, err := executor.Execute("workspace", "get", wsName, "--output", "json") - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to get workspaces %s: %s", wsName, output)), nil - } - - return mcp.NewToolResultText(output), nil - } -} - -func listWorkspacesHandler(executor CommandExecutor) server.ToolHandlerFunc { - return func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { - output, err := executor.Execute("workspace", "list", "--output", "json") - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to list workspaces: %s", output)), nil - } - - return mcp.NewToolResultText(output), nil - } -} - -func switchWorkspaceHandler(executor CommandExecutor) server.ToolHandlerFunc { - return func(_ context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - wsName, err := request.RequireString("workspace_name") - if err != nil { - return mcp.NewToolResultError("workspace_name is required"), nil - } - - output, err := executor.Execute("workspace", "switch", wsName) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to switch workspace to %s: %s", wsName, output)), nil - } - - return mcp.NewToolResultText(output), nil - } -} - -func getExecutableHandler(executor CommandExecutor) server.ToolHandlerFunc { - return func(_ context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - executableVerb, err := request.RequireString("executable_verb") - if err != nil { - return mcp.NewToolResultError("executable_verb is required"), nil - } - executableID := request.GetString("executable_id", "") - - cmdArgs := []string{"browse", "--output", "json", executableVerb} - if executableID != "" { - cmdArgs = append(cmdArgs, executableID) - } - - output, err := executor.Execute(cmdArgs...) - if err != nil { - ref := strings.Join([]string{executableVerb, executableID}, " ") - return mcp.NewToolResultError(fmt.Sprintf("Failed to get executable %s: %s", ref, output)), nil - } - - return mcp.NewToolResultText(output), nil - } -} - -func listExecutablesHandler(executor CommandExecutor) server.ToolHandlerFunc { - return func(_ context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - wsFilter := request.GetString("workspace", executable.WildcardWorkspace) - nsFilter := request.GetString("namespace", executable.WildcardNamespace) - verbFilter := request.GetString("verb", "") - keywordFilter := request.GetString("keyword", "") - tagFilter := request.GetString("tag", "") - - cmdArgs := []string{"browse", "--output", "json", "--workspace", wsFilter, "--namespace", nsFilter} - if verbFilter != "" { - cmdArgs = append(cmdArgs, "--verb", verbFilter) - } - if keywordFilter != "" { - cmdArgs = append(cmdArgs, "--filter", keywordFilter) - } - if tagFilter != "" { - cmdArgs = append(cmdArgs, "--tag", tagFilter) - } - - output, err := executor.Execute(cmdArgs...) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to list executables: %s", output)), nil - } - - return mcp.NewToolResultText(output), nil - } -} - -func executeFlowHandler(executor CommandExecutor) server.ToolHandlerFunc { - return func(_ context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - executableVerb, err := request.RequireString("executable_verb") - if err != nil { - return mcp.NewToolResultError("executable_verb is required"), nil - } - executableID := request.GetString("executable_id", "") - - args := request.GetString("args", "") - sync := request.GetBool("sync", false) - - cmdArgs := []string{executableVerb} - if executableID != "" { - cmdArgs = append(cmdArgs, executableID) - } - if args != "" { - cmdArgs = append(cmdArgs, strings.Fields(args)...) - } - if sync { - cmdArgs = append(cmdArgs, "--sync") - } - - output, err := executor.Execute(cmdArgs...) - if err != nil { - ref := strings.Join([]string{executableVerb, executableID}, " ") - return mcp.NewToolResultError(fmt.Sprintf("%s execution failed: %s", ref, output)), nil - } - - return mcp.NewToolResultText(output), nil - } -} - -func getExecutionLogsHandler(executor CommandExecutor) server.ToolHandlerFunc { - return func(_ context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - last := request.GetBool("last", false) - cmdArgs := []string{"logs", "--output", "json"} - if last { - cmdArgs = append(cmdArgs, "--last") - } - - output, err := executor.Execute(cmdArgs...) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to get flow execution logs: %s", output)), nil - } - - return mcp.NewToolResultText(output), nil - } -} - -func syncStateHandler(executor CommandExecutor) server.ToolHandlerFunc { - return func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { - output, err := executor.Execute("sync") - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to sync flow's state: %s", output)), nil - } - - return mcp.NewToolResultText(output), nil - } + addSystemTools(srv, executor) + addWorkspaceTools(srv, executor) + addExecutableTools(srv, executor) } func boolPtr(b bool) *bool { return &b } + +// sendProgress sends a progress notification to the client if a progress token was provided. +// It silently ignores errors (e.g., no active session in test contexts). +func sendProgress(srv *server.MCPServer, ctx context.Context, token any, progress, total float64, message string) { + if token == nil || srv == nil { + return + } + // Recover from panics in case the session context is not available (e.g., in-process test clients). + defer func() { _ = recover() }() + _ = srv.SendNotificationToClient(ctx, "notifications/progress", map[string]any{ + "progressToken": token, + "progress": progress, + "total": total, + "message": message, + }) +} diff --git a/internal/mcp/tools_executable.go b/internal/mcp/tools_executable.go new file mode 100644 index 00000000..56113317 --- /dev/null +++ b/internal/mcp/tools_executable.go @@ -0,0 +1,279 @@ +//nolint:nilerr +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "gopkg.in/yaml.v3" + + "github.com/flowexec/flow/pkg/filesystem" + "github.com/flowexec/flow/types/executable" +) + +func addExecutableTools(srv *server.MCPServer, executor CommandExecutor) { + getExecutable := mcp.NewTool("get_executable", + mcp.WithDescription("Get detailed information about an executable"), + mcp.WithString("executable_verb", mcp.Required(), + mcp.Enum(executable.SortedValidVerbs()...), + mcp.Description("Executable verb")), + mcp.WithString("executable_id", + mcp.Pattern(`^([a-zA-Z0-9_-]+(/[a-zA-Z0-9_-]+)?:)?[a-zA-Z0-9_-]+$`), + mcp.Description("Executable ID (workspace/namespace:name or just name if using the current workspace/namespace)")), + mcp.WithOutputSchema[ExecutableOutput](), + ) + getExecutable.Annotations = mcp.ToolAnnotation{ + Title: "Get a specific executable by reference", + DestructiveHint: boolPtr(false), ReadOnlyHint: boolPtr(true), + IdempotentHint: boolPtr(true), OpenWorldHint: boolPtr(true), + } + srv.AddTool(getExecutable, getExecutableHandler(executor)) + + listExecutables := mcp.NewTool("list_executables", + mcp.WithDescription("List and filter executables across all workspaces"), + mcp.WithString("workspace", mcp.Description("Workspace name (optional)")), + mcp.WithString("namespace", mcp.Description("Namespace filter (optional)")), + mcp.WithString("verb", mcp.Description("Verb filter (optional)")), + mcp.WithString("keyword", mcp.Description("Keyword filter (optional)")), + mcp.WithString("tag", mcp.Description("Tag filter (optional)")), + mcp.WithString("cursor", mcp.Description("Pagination cursor for next page of results")), + mcp.WithOutputSchema[ExecutableListOutput](), + ) + listExecutables.Annotations = mcp.ToolAnnotation{ + Title: "List executables", + DestructiveHint: boolPtr(false), ReadOnlyHint: boolPtr(true), + IdempotentHint: boolPtr(true), OpenWorldHint: boolPtr(true), + } + srv.AddTool(listExecutables, listExecutablesHandler(executor)) + + executeFlow := mcp.NewTool("execute", + mcp.WithDescription("Execute a flow executable"), + mcp.WithString("executable_verb", mcp.Required(), + mcp.Enum(executable.SortedValidVerbs()...), + mcp.Description("Executable verb")), + mcp.WithString("executable_id", + mcp.Pattern(`^([a-zA-Z0-9_-]+(/[a-zA-Z0-9_-]+)?:)?[a-zA-Z0-9_-]+$`), + mcp.Description( + "Executable ID (workspace/namespace:name or just name if using the current workspace/namespace). "+ + "If the executable does not have a name, you can specify just the workspace (`ws/`), namespace (`ns:`) "+ + "both (`ws/ns:`) or neither if the current workspace/namespace should be used.")), + mcp.WithString("args", mcp.Description("Arguments to pass")), + mcp.WithBoolean("sync", mcp.Description("Sync executable changes before execution")), + mcp.WithOutputSchema[ExecutionOutput](), + ) + executeFlow.Annotations = mcp.ToolAnnotation{ + Title: "Execute executable", + ReadOnlyHint: boolPtr(false), DestructiveHint: boolPtr(true), + IdempotentHint: boolPtr(false), OpenWorldHint: boolPtr(true), + } + srv.AddTool(executeFlow, executeFlowHandler(srv, executor)) + + writeFlowfile := mcp.NewTool("write_flowfile", + mcp.WithDescription("Create or update a flow file with YAML content. Validates the YAML before writing."), + mcp.WithString("path", mcp.Required(), + mcp.Description("Absolute or workspace-relative path for the flowfile (must end in .flow or .flow.yaml)")), + mcp.WithString("content", mcp.Required(), + mcp.Description("Full YAML content of the flowfile")), + mcp.WithBoolean("overwrite", + mcp.Description("Whether to overwrite an existing file (default: false)")), + mcp.WithOutputSchema[WriteFlowFileOutput](), + ) + writeFlowfile.Annotations = mcp.ToolAnnotation{ + Title: "Write a flow file", + DestructiveHint: boolPtr(true), ReadOnlyHint: boolPtr(false), + IdempotentHint: boolPtr(false), OpenWorldHint: boolPtr(false), + } + srv.AddTool(writeFlowfile, writeFlowfileHandler(srv)) +} + +func getExecutableHandler(executor CommandExecutor) server.ToolHandlerFunc { + return func(_ context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + executableVerb, err := request.RequireString("executable_verb") + if err != nil { + return toolError(ErrCodeInvalidInput, "executable_verb is required"), nil + } + executableID := request.GetString("executable_id", "") + + cmdArgs := []string{"browse", "--output", "json", executableVerb} + if executableID != "" { + cmdArgs = append(cmdArgs, executableID) + } + + output, err := executor.Execute(cmdArgs...) + if err != nil { + ref := strings.Join([]string{executableVerb, executableID}, " ") + return toolError(ErrCodeNotFound, fmt.Sprintf("Failed to get executable %s: %s", ref, output)), nil + } + + return mcp.NewToolResultText(output), nil + } +} + +func listExecutablesHandler(executor CommandExecutor) server.ToolHandlerFunc { + return func(_ context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + wsFilter := request.GetString("workspace", executable.WildcardWorkspace) + nsFilter := request.GetString("namespace", executable.WildcardNamespace) + verbFilter := request.GetString("verb", "") + keywordFilter := request.GetString("keyword", "") + tagFilter := request.GetString("tag", "") + cursor := request.GetString("cursor", "") + + cmdArgs := []string{"browse", "--output", "json", "--workspace", wsFilter, "--namespace", nsFilter} + if verbFilter != "" { + cmdArgs = append(cmdArgs, "--verb", verbFilter) + } + if keywordFilter != "" { + cmdArgs = append(cmdArgs, "--filter", keywordFilter) + } + if tagFilter != "" { + cmdArgs = append(cmdArgs, "--tag", tagFilter) + } + + output, err := executor.Execute(cmdArgs...) + if err != nil { + return toolError(ErrCodeExecutionFailed, fmt.Sprintf("Failed to list executables: %s", output)), nil + } + + var cliOutput struct { + Executables []ExecutableOutput `json:"executables"` + } + if err := json.Unmarshal([]byte(output), &cliOutput); err != nil { + return mcp.NewToolResultText(output), nil + } + + page, nextCursor, totalCount, err := paginate(cliOutput.Executables, cursor, defaultPageSize) + if err != nil { + return toolError(ErrCodeInvalidInput, err.Error()), nil + } + + result := ExecutableListOutput{ + Executables: page, + NextCursor: nextCursor, + TotalCount: totalCount, + } + jsonData, _ := json.Marshal(result) + return mcp.NewToolResultText(string(jsonData)), nil + } +} + +func executeFlowHandler(srv *server.MCPServer, executor CommandExecutor) server.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + executableVerb, err := request.RequireString("executable_verb") + if err != nil { + return toolError(ErrCodeInvalidInput, "executable_verb is required"), nil + } + executableID := request.GetString("executable_id", "") + + args := request.GetString("args", "") + syncFlag := request.GetBool("sync", false) + var progressToken any + if request.Params.Meta != nil { + progressToken = request.Params.Meta.ProgressToken + } + + cmdArgs := []string{executableVerb} + if executableID != "" { + cmdArgs = append(cmdArgs, executableID) + } + if args != "" { + cmdArgs = append(cmdArgs, strings.Fields(args)...) + } + if syncFlag { + cmdArgs = append(cmdArgs, "--sync") + } + + sendProgress(srv, ctx, progressToken, 0, 2, "Preparing execution") + output, err := executor.ExecuteContext(ctx, cmdArgs...) + + if ctx.Err() != nil { + return toolError(ErrCodeCancelled, "execution was cancelled"), nil + } + + sendProgress(srv, ctx, progressToken, 1, 2, "Processing result") + + if err != nil { + ref := strings.Join([]string{executableVerb, executableID}, " ") + return toolError(ErrCodeExecutionFailed, fmt.Sprintf("%s execution failed: %s", ref, output)), nil + } + + sendProgress(srv, ctx, progressToken, 2, 2, "Complete") + + result := ExecutionOutput{Output: output} + jsonData, _ := json.Marshal(result) + return mcp.NewToolResultText(string(jsonData)), nil + } +} + +func writeFlowfileHandler(srv *server.MCPServer) server.ToolHandlerFunc { + return func(_ context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + path, err := request.RequireString("path") + if err != nil { + return toolError(ErrCodeInvalidInput, "path is required"), nil + } + content, err := request.RequireString("content") + if err != nil { + return toolError(ErrCodeInvalidInput, "content is required"), nil + } + overwrite := request.GetBool("overwrite", false) + + // Validate file extension + if !strings.HasSuffix(path, ".flow") && !strings.HasSuffix(path, ".flow.yaml") { + return toolError(ErrCodeValidationFailed, "path must end in .flow or .flow.yaml"), nil + } + + // Resolve absolute path + absPath := path + if !filepath.IsAbs(path) { + cfg, err := filesystem.LoadConfig() + if err != nil { + return toolError(ErrCodeInternal, fmt.Sprintf("failed to load config: %s", err)), nil + } + if cfg.CurrentWorkspace != "" { + if wsPath, ok := cfg.Workspaces[cfg.CurrentWorkspace]; ok { + absPath = filepath.Join(wsPath, path) + } + } + } + + // Check if file exists when overwrite is false + if !overwrite { + if _, err := os.Stat(absPath); err == nil { + msg := fmt.Sprintf("file already exists at %s (use overwrite=true to replace)", absPath) + return toolError(ErrCodeValidationFailed, msg), nil + } + } + + // Validate YAML content by parsing into FlowFile + var flowFile executable.FlowFile + if err := yaml.Unmarshal([]byte(content), &flowFile); err != nil { + return toolError(ErrCodeValidationFailed, fmt.Sprintf("invalid flowfile YAML: %s", err)), nil + } + + // Write the flowfile + if err := filesystem.WriteFlowFile(absPath, &flowFile); err != nil { + return toolError(ErrCodeInternal, fmt.Sprintf("failed to write flowfile: %s", err)), nil + } + + // Collect executable names for the summary + var execNames []string + for _, exec := range flowFile.Executables { + execNames = append(execNames, exec.Name) + } + + srv.SendNotificationToAllClients("notifications/resources/list_changed", nil) + + output := WriteFlowFileOutput{ + Path: absPath, + Executables: execNames, + Overwritten: overwrite, + } + jsonData, _ := json.Marshal(output) + return mcp.NewToolResultText(string(jsonData)), nil + } +} diff --git a/internal/mcp/tools_system.go b/internal/mcp/tools_system.go new file mode 100644 index 00000000..b796c51c --- /dev/null +++ b/internal/mcp/tools_system.go @@ -0,0 +1,193 @@ +//nolint:nilerr +package mcp + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + + "github.com/flowexec/flow/pkg/filesystem" +) + +// Documentation URLs — kept as constants so a get_info call returns pointers rather +// than embedding the full content (which would balloon the response to ~20KB). +const ( + docsBaseURL = "https://flowexec.io" + docsLLMsTxtURL = docsBaseURL + "/llms.txt" + + flowInfoSummary = "Flow is a local automation platform. " + + "Executables (tasks) are declared in *.flow YAML files; workspaces group them by project " + + "and are rooted at a flow.yaml config. Templates (*.flow.tmpl) generate new workflows. " + + "Secrets live in vaults. Use the `get_executable`, `list_executables`, and `execute` tools to " + + "explore and run; use `write_flowfile` to author new files. Refer to llms.txt for full docs." +) + +func addSystemTools(srv *server.MCPServer, executor CommandExecutor) { + getFlowInfo := mcp.NewTool("get_info", + mcp.WithDescription( + "Get information about flow, it's usage, and the current workflow execution context. "+ + "This includes file JSON schemas for flow executable, template, and workspace files, concepts guides, "+ + "and the current user configuration and state details."), + mcp.WithOutputSchema[FlowInfoOutput](), + ) + getFlowInfo.Annotations = mcp.ToolAnnotation{ + Title: "Get flow information and current context", + DestructiveHint: boolPtr(false), ReadOnlyHint: boolPtr(true), + IdempotentHint: boolPtr(false), OpenWorldHint: boolPtr(false), + } + srv.AddTool(getFlowInfo, getInfoHandler) + + getExecutionLogs := mcp.NewTool("get_execution_logs", + mcp.WithDescription("Get a list of the recent flow execution logs"), + mcp.WithBoolean("last", mcp.Description("Get only the last execution logs")), + mcp.WithString("cursor", mcp.Description("Pagination cursor for next page of results")), + mcp.WithOutputSchema[LogListOutput](), + ) + getExecutionLogs.Annotations = mcp.ToolAnnotation{ + Title: "Get execution logs", + DestructiveHint: boolPtr(false), ReadOnlyHint: boolPtr(true), + IdempotentHint: boolPtr(true), OpenWorldHint: boolPtr(true), + } + srv.AddTool(getExecutionLogs, getExecutionLogsHandler(executor)) + + sync := mcp.NewTool("sync_executables", + mcp.WithDescription("Sync the flow workspace and executable state"), + mcp.WithOutputSchema[SyncOutput](), + ) + sync.Annotations = mcp.ToolAnnotation{ + Title: "Sync executable and workspace state", + DestructiveHint: boolPtr(false), ReadOnlyHint: boolPtr(false), + IdempotentHint: boolPtr(false), OpenWorldHint: boolPtr(true), + } + srv.AddTool(sync, syncStateHandler(srv, executor)) +} + +func getInfoHandler(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + cfg, err := filesystem.LoadConfig() + if err != nil { + return toolError(ErrCodeInternal, fmt.Sprintf("failed to load user config: %s", err)), nil + } + cfg.SetDefaults() + + wsName, err := cfg.CurrentWorkspaceName() + if err != nil { + return toolError(ErrCodeInternal, fmt.Sprintf("failed to get current workspace name: %s", err)), nil + } + + output := FlowInfoOutput{ + CurrentContext: CurrentContext{ + Workspace: wsName, + Namespace: cfg.CurrentNamespace, + Vault: cfg.CurrentVaultName(), + WorkspaceMode: string(cfg.WorkspaceMode), + WorkspacePath: cfg.Workspaces[cfg.CurrentWorkspace], + }, + Summary: flowInfoSummary, + DocsURL: docsBaseURL, + LLMsTxtURL: docsLLMsTxtURL, + SchemaURLs: SchemaURLs{ + FlowFile: docsBaseURL + "/schemas/flowfile_schema.json", + Workspace: docsBaseURL + "/schemas/workspace_schema.json", + Template: docsBaseURL + "/schemas/template_schema.json", + Config: docsBaseURL + "/schemas/config_schema.json", + }, + GuideURLs: map[string]string{ + "concepts": docsBaseURL + "/guides/concepts", + "fileTypes": docsBaseURL + "/guides/executables", + "firstWorkflow": docsBaseURL + "/guides/first-workflow", + "workspaces": docsBaseURL + "/guides/workspaces", + "templates": docsBaseURL + "/guides/templating", + "secrets": docsBaseURL + "/guides/secrets", + }, + } + + jsonData, err := json.Marshal(output) + if err != nil { + return toolError(ErrCodeInternal, fmt.Sprintf("failed to marshal response: %s", err)), nil + } + + return mcp.NewToolResultText(string(jsonData)), nil +} + +func getExecutionLogsHandler(executor CommandExecutor) server.ToolHandlerFunc { + return func(_ context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + last := request.GetBool("last", false) + cursor := request.GetString("cursor", "") + + cmdArgs := []string{"logs", "--output", "json"} + if last { + cmdArgs = append(cmdArgs, "--last") + } + + output, err := executor.Execute(cmdArgs...) + if err != nil { + return toolError(ErrCodeExecutionFailed, fmt.Sprintf("Failed to get flow execution logs: %s", output)), nil + } + + // If requesting last log only, no pagination needed — wrap in list output. + if last { + var entry LogEntry + if err := json.Unmarshal([]byte(output), &entry); err != nil { + // Return raw output if we can't parse it. + return mcp.NewToolResultText(output), nil + } + result := LogListOutput{ + History: []LogEntry{entry}, + TotalCount: 1, + } + jsonData, _ := json.Marshal(result) + return mcp.NewToolResultText(string(jsonData)), nil + } + + // Parse the CLI list output and apply pagination. + var cliOutput struct { + History []LogEntry `json:"history"` + } + if err := json.Unmarshal([]byte(output), &cliOutput); err != nil { + return mcp.NewToolResultText(output), nil + } + + page, nextCursor, totalCount, err := paginate(cliOutput.History, cursor, defaultPageSize) + if err != nil { + return toolError(ErrCodeInvalidInput, err.Error()), nil + } + + result := LogListOutput{ + History: page, + NextCursor: nextCursor, + TotalCount: totalCount, + } + jsonData, _ := json.Marshal(result) + return mcp.NewToolResultText(string(jsonData)), nil + } +} + +func syncStateHandler(srv *server.MCPServer, executor CommandExecutor) server.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var progressToken any + if request.Params.Meta != nil { + progressToken = request.Params.Meta.ProgressToken + } + + sendProgress(srv, ctx, progressToken, 0, 1, "Syncing state") + output, err := executor.ExecuteContext(ctx, "sync") + + if ctx.Err() != nil { + return toolError(ErrCodeCancelled, "sync was cancelled"), nil + } + + if err != nil { + return toolError(ErrCodeExecutionFailed, fmt.Sprintf("Failed to sync flow's state: %s", output)), nil + } + + sendProgress(srv, ctx, progressToken, 1, 1, "Complete") + srv.SendNotificationToAllClients("notifications/resources/list_changed", nil) + + result := SyncOutput{Output: output} + jsonData, _ := json.Marshal(result) + return mcp.NewToolResultText(string(jsonData)), nil + } +} diff --git a/internal/mcp/tools_workspace.go b/internal/mcp/tools_workspace.go new file mode 100644 index 00000000..d481016c --- /dev/null +++ b/internal/mcp/tools_workspace.go @@ -0,0 +1,180 @@ +//nolint:nilerr +package mcp + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + + "github.com/flowexec/flow/pkg/filesystem" +) + +func addWorkspaceTools(srv *server.MCPServer, executor CommandExecutor) { + getWorkspace := mcp.NewTool("get_workspace", + mcp.WithString("workspace_name", mcp.Required(), mcp.Description("Registered workspace name")), + mcp.WithDescription("Get details about a registered flow workspaces"), + mcp.WithOutputSchema[WorkspaceOutput](), + ) + getWorkspace.Annotations = mcp.ToolAnnotation{ + Title: "Get a specific workspace by name", + DestructiveHint: boolPtr(false), ReadOnlyHint: boolPtr(true), + IdempotentHint: boolPtr(true), OpenWorldHint: boolPtr(true), + } + srv.AddTool(getWorkspace, getWorkspaceHandler(executor)) + + listWorkspaces := mcp.NewTool("list_workspaces", + mcp.WithDescription("List all registered flow workspaces"), + mcp.WithString("cursor", mcp.Description("Pagination cursor for next page of results")), + mcp.WithOutputSchema[WorkspaceListOutput](), + ) + listWorkspaces.Annotations = mcp.ToolAnnotation{ + Title: "List workspaces", + DestructiveHint: boolPtr(false), ReadOnlyHint: boolPtr(true), + IdempotentHint: boolPtr(true), OpenWorldHint: boolPtr(true), + } + srv.AddTool(listWorkspaces, listWorkspacesHandler(executor)) + + switchWorkspace := mcp.NewTool("switch_workspace", + mcp.WithString("workspace_name", mcp.Required(), mcp.Description("Registered workspace name")), + mcp.WithDescription("Change the current workspace"), + mcp.WithOutputSchema[SwitchWorkspaceOutput](), + ) + switchWorkspace.Annotations = mcp.ToolAnnotation{ + Title: "Change the current workspace", + DestructiveHint: boolPtr(false), ReadOnlyHint: boolPtr(false), + IdempotentHint: boolPtr(true), OpenWorldHint: boolPtr(false), + } + srv.AddTool(switchWorkspace, switchWorkspaceHandler(srv, executor)) + + getWorkspaceConfig := mcp.NewTool("get_workspace_config", + mcp.WithString("name", mcp.Required(), mcp.Description("Workspace name")), + mcp.WithDescription("Get the full workspace configuration as structured JSON"), + mcp.WithOutputSchema[WorkspaceConfigOutput](), + ) + getWorkspaceConfig.Annotations = mcp.ToolAnnotation{ + Title: "Get workspace configuration", + DestructiveHint: boolPtr(false), ReadOnlyHint: boolPtr(true), + IdempotentHint: boolPtr(true), OpenWorldHint: boolPtr(true), + } + srv.AddTool(getWorkspaceConfig, getWorkspaceConfigHandler()) +} + +func getWorkspaceHandler(executor CommandExecutor) server.ToolHandlerFunc { + return func(_ context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + wsName, err := request.RequireString("workspace_name") + if err != nil { + return toolError(ErrCodeInvalidInput, "workspace_name is required"), nil + } + + output, err := executor.Execute("workspace", "get", wsName, "--output", "json") + if err != nil { + return toolError(ErrCodeNotFound, fmt.Sprintf("Failed to get workspace %s: %s", wsName, output)), nil + } + + // Validate output parses as workspace JSON; return as-is if valid. + var ws WorkspaceOutput + if err := json.Unmarshal([]byte(output), &ws); err != nil { + return mcp.NewToolResultText(output), nil + } + + jsonData, _ := json.Marshal(ws) + return mcp.NewToolResultText(string(jsonData)), nil + } +} + +func listWorkspacesHandler(executor CommandExecutor) server.ToolHandlerFunc { + return func(_ context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + cursor := request.GetString("cursor", "") + + output, err := executor.Execute("workspace", "list", "--output", "json") + if err != nil { + return toolError(ErrCodeExecutionFailed, fmt.Sprintf("Failed to list workspaces: %s", output)), nil + } + + var cliOutput struct { + Workspaces []WorkspaceOutput `json:"workspaces"` + } + if err := json.Unmarshal([]byte(output), &cliOutput); err != nil { + return mcp.NewToolResultText(output), nil + } + + page, nextCursor, totalCount, err := paginate(cliOutput.Workspaces, cursor, defaultPageSize) + if err != nil { + return toolError(ErrCodeInvalidInput, err.Error()), nil + } + + result := WorkspaceListOutput{ + Workspaces: page, + NextCursor: nextCursor, + TotalCount: totalCount, + } + jsonData, _ := json.Marshal(result) + return mcp.NewToolResultText(string(jsonData)), nil + } +} + +func switchWorkspaceHandler(srv *server.MCPServer, executor CommandExecutor) server.ToolHandlerFunc { + return func(_ context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + wsName, err := request.RequireString("workspace_name") + if err != nil { + return toolError(ErrCodeInvalidInput, "workspace_name is required"), nil + } + + output, err := executor.Execute("workspace", "switch", wsName) + if err != nil { + return toolError(ErrCodeNotFound, fmt.Sprintf("Failed to switch workspace to %s: %s", wsName, output)), nil + } + + srv.SendNotificationToAllClients("notifications/resources/list_changed", nil) + + result := SwitchWorkspaceOutput{Output: output} + jsonData, _ := json.Marshal(result) + return mcp.NewToolResultText(string(jsonData)), nil + } +} + +func getWorkspaceConfigHandler() server.ToolHandlerFunc { + return func(_ context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + name, err := request.RequireString("name") + if err != nil { + return toolError(ErrCodeInvalidInput, "name is required"), nil + } + + cfg, err := filesystem.LoadConfig() + if err != nil { + return toolError(ErrCodeInternal, fmt.Sprintf("failed to load config: %s", err)), nil + } + + wsPath, ok := cfg.Workspaces[name] + if !ok { + return toolError(ErrCodeNotFound, fmt.Sprintf("workspace %q not found", name)), nil + } + + ws, err := filesystem.LoadWorkspaceConfig(name, wsPath) + if err != nil { + return toolError(ErrCodeNotFound, fmt.Sprintf("failed to load workspace config: %s", err)), nil + } + + output := WorkspaceConfigOutput{ + WorkspaceOutput: WorkspaceOutput{ + Name: ws.AssignedName(), + Path: ws.Location(), + DisplayName: ws.DisplayName, + Description: ws.Description, + Tags: ws.Tags, + }, + } + if ws.Executables != nil { + output.Executables = &ExecutableFilter{ + Included: ws.Executables.Included, + Excluded: ws.Executables.Excluded, + } + } + + jsonData, _ := json.Marshal(output) + return mcp.NewToolResultText(string(jsonData)), nil + } +} diff --git a/tools/docsgen/json.go b/tools/docsgen/json.go index 8f9c6382..ce297697 100644 --- a/tools/docsgen/json.go +++ b/tools/docsgen/json.go @@ -11,23 +11,14 @@ import ( ) const ( - schemaDir = "docs/public/schemas" - mcpSchemaDir = "internal/mcp/resources" - idBase = "https://flowexec.io/schemas" + schemaDir = "docs/public/schemas" + idBase = "https://flowexec.io/schemas" ) -// The JSON schema that's bundled in to the MCP server should always match the schemas that are provided via the docs -// site. Not all schema are needed so below is just an allowlist of schemas that we embed. -var mcpSchemaResources = []string{ - schema.WorkspaceDefinitionTitle, - schema.FlowfileDefinitionTitle, - schema.TemplateDefinitionTitle, -} - +// The JSON schemas are published at https://flowexec.io/schemas/*. func generateJSONSchemas() { sm := schema.RegisteredSchemaMap() for fn, s := range sm { - //nolint:nestif if slices.Contains(TopLevelPages, fn.Title()) { updateFileID(s, fn) for key, value := range s.Properties { @@ -50,12 +41,6 @@ func generateJSONSchemas() { if err := writeSchemaFile(string(schemaJSON), docsPath); err != nil { panic(err) } - if slices.Contains(mcpSchemaResources, fn.Title()) { - mcpPath := filepath.Join(rootDir(), mcpSchemaDir, fn.JSONSchemaFile()) - if err := writeSchemaFile(string(schemaJSON), mcpPath); err != nil { - panic(err) - } - } } } }