From 14151e63be49821856f5b68abee7d4e3d8b8f56e Mon Sep 17 00:00:00 2001 From: Jahvon Dockery Date: Wed, 26 Aug 2026 23:21:55 -0400 Subject: [PATCH] feat(mcp): add run_python tool Exposes Python execution to agents as a first-class MCP tool alongside run_command, plus the `--interpreter` flag on `flow exec` that backs it. run_python is its own tool rather than a parameter on run_command because agents select tools by name, and because run_command's multi-command form builds serial/parallel steps, which carry no interpreter of their own. The CLI rejects `--interpreter` with multiple `--cmd` values for the same reason; a later change can relax that once step configs gain the field. Also allowlists the tool in this repo's own .claude config and adds it to the flow-context skill, so the agents working in this repo reach for it instead of shelling out to `python -c`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01R328pa3FUUfga4gYah1iQi --- .claude/settings.json | 1 + .claude/skills/flow-context/SKILL.md | 3 +- cmd/internal/exec.go | 19 ++++++ cmd/internal/flags/types.go | 8 +++ docs/cli/flow_exec.md | 21 +++--- docs/guides/ai-tools.md | 14 ++-- internal/mcp/resources/server-instructions.md | 3 +- internal/mcp/server_test.go | 66 +++++++++++++++++++ internal/mcp/tools_executable.go | 56 ++++++++++++++++ tests/python_exec_e2e_test.go | 30 +++++++++ 10 files changed, 204 insertions(+), 17 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index 5c631979..13956dcb 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -16,6 +16,7 @@ "Bash(flow mcp:*)", "Bash(flow exec:*)", "mcp__flow__run_command", + "mcp__flow__run_python", "mcp__flow__run_executable", "mcp__flow__get_execution_logs", "mcp__flow__sync_executables", diff --git a/.claude/skills/flow-context/SKILL.md b/.claude/skills/flow-context/SKILL.md index bb3020a7..674efe89 100644 --- a/.claude/skills/flow-context/SKILL.md +++ b/.claude/skills/flow-context/SKILL.md @@ -10,6 +10,7 @@ This repository uses **flow** for all development automation. The `mcp__flow__*` 1. **Named task?** (build, test, lint, validate, generate, deploy, …) → `mcp__flow__list_executables` to find it, then `mcp__flow__execute` with its verb + name. Don't hand-roll a shell command a flow executable already covers. 2. **Arbitrary one-off command?** (a `git ...`, `go test ./...`, a script) → `mcp__flow__run_command` with the command and a short `label`. This is preferred over raw `Bash`: it runs with workspace env/secrets and lands in `flow logs` with provenance. Pass `commands` (array) + `mode` (`serial`/`parallel`) to run several in one call. +3. **One-off is Python?** → `mcp__flow__run_python` with `code` and a short `label`, rather than `python -c` or a scratch `.py` file. flow resolves the workspace's virtualenv, so imports see the project's dependencies, and tracebacks report real line numbers. 3. **Something richer than one command?** (a serial/parallel batch, an HTTP `request`) → `mcp__flow__run_executable` with an inline `spec`. 4. Only fall back to `Bash` when a command genuinely shouldn't be recorded or flow isn't the right tool (e.g. interactive/TTY programs). @@ -21,5 +22,5 @@ Common refs: `test unit`, `test e2e`, `lint`, `validate`, `generate`, `build bin - Call `mcp__flow__get_info` at the start of a session, or when you need schema URLs to author `.flow` files. - Author or edit flow files with `mcp__flow__write_flowfile` (validates against the schema server-side) rather than writing YAML by hand. -- Runs are scoped to the current workspace by default; `run_command`/`run_executable` accept an optional `workspace` to target another without switching the global current workspace. +- Runs are scoped to the current workspace by default; `run_command`/`run_python`/`run_executable` accept an optional `workspace` to target another without switching the global current workspace. - To review what you've run this session, call `mcp__flow__get_execution_logs` with `mine: true`; `source`/`session`/`status` filter history more broadly (also `flow logs --source mcp --session `). diff --git a/cmd/internal/exec.go b/cmd/internal/exec.go index 69edc317..06bd1c0a 100644 --- a/cmd/internal/exec.go +++ b/cmd/internal/exec.go @@ -84,6 +84,7 @@ func RegisterExecCmd(ctx *context.Context, rootCmd *cobra.Command) { RegisterFlag(ctx, subCmd, *flags.LogModeFlag) RegisterFlag(ctx, subCmd, *flags.BackgroundFlag) RegisterFlag(ctx, subCmd, *flags.CmdFlag) + RegisterFlag(ctx, subCmd, *flags.InterpreterFlag) RegisterFlag(ctx, subCmd, *flags.CmdModeFlag) RegisterFlag(ctx, subCmd, *flags.LabelFlag) RegisterFlag(ctx, subCmd, *flags.CmdDirFlag) @@ -250,6 +251,21 @@ func execAdHoc(ctx *context.Context, cmd *cobra.Command, verb executable.Verb, c logMode = tuikitIO.LogMode(lm) } + interpreter := executable.ExecInterpreter(flags.ValueFor[string](cmd, *flags.InterpreterFlag, false)) + if interpreter != "" { + // Inline serial/parallel steps carry no interpreter of their own, so a + // multi-command batch could only run the first one as requested. + if len(commands) > 1 { + errhandler.HandleUsage(ctx, cmd, "--interpreter cannot be combined with multiple --cmd values") + return + } + probe := &executable.ExecExecutableType{Interpreter: &interpreter} + if err := probe.Validate(); err != nil { + errhandler.HandleUsage(ctx, cmd, "%v", err) + return + } + } + joined := strings.Join(commands, "\n") e := &executable.Executable{ Verb: verb, @@ -262,6 +278,9 @@ func execAdHoc(ctx *context.Context, cmd *cobra.Command, verb executable.Verb, c Dir: executable.Directory(dir), LogMode: logMode, } + if interpreter != "" { + e.Exec.Interpreter = &interpreter + } } else { steps := make(executable.SerialRefConfigList, len(commands)) for i, c := range commands { diff --git a/cmd/internal/flags/types.go b/cmd/internal/flags/types.go index 707b8c78..48fd2412 100644 --- a/cmd/internal/flags/types.go +++ b/cmd/internal/flags/types.go @@ -344,6 +344,14 @@ var CmdFlag = &Metadata{ Required: false, } +var InterpreterFlag = &Metadata{ + Name: "interpreter", + Usage: "The interpreter to run an ad-hoc --cmd with: 'sh' (default) or 'python'. " + + "Only valid with a single --cmd.", + Default: "", + Required: false, +} + var CmdModeFlag = &Metadata{ Name: "mode", Usage: "How to run multiple --cmd commands: 'serial' (default) or 'parallel'.", diff --git a/docs/cli/flow_exec.md b/docs/cli/flow_exec.md index 47ca36ed..833ffe91 100644 --- a/docs/cli/flow_exec.md +++ b/docs/cli/flow_exec.md @@ -46,16 +46,17 @@ flow exec EXECUTABLE_ID [-- args...] [flags] ### Options ``` - -b, --background Run the executable in the background and return a run ID immediately. - --cmd flow logs Run an ad-hoc shell command through flow instead of a named executable. The command runs with the current workspace's environment and is recorded in flow logs. Repeat --cmd to run multiple commands in one invocation (see --mode). - --dir string Working directory for an ad-hoc command (defaults to the current directory). Only valid with --cmd. - -h, --help help for exec - --label string A short, human-readable label for an ad-hoc command (used in history). Only valid with --cmd. - -m, --log-mode string Log mode (text, logfmt, json, hidden) - --mode string How to run multiple --cmd commands: 'serial' (default) or 'parallel'. (default "serial") - -p, --param stringArray Set a parameter value by env key. (i.e. KEY=value) Use multiple times to set multiple parameters. This will override any existing parameter values defined for the executable. - --spec flow logs Run a transient executable from an inline definition (any type: exec, serial, parallel, request, render, launch). Accepts inline YAML/JSON, '@path' to read a file, or '-' to read stdin. The executable is not saved to disk but is recorded in flow logs. - --workspace string Workspace whose environment the ad-hoc/transient run should use (only with --cmd or --spec). Defaults to the workspace containing the run directory, then the current workspace. Does not change the global current workspace. + -b, --background Run the executable in the background and return a run ID immediately. + --cmd flow logs Run an ad-hoc shell command through flow instead of a named executable. The command runs with the current workspace's environment and is recorded in flow logs. Repeat --cmd to run multiple commands in one invocation (see --mode). + --dir string Working directory for an ad-hoc command (defaults to the current directory). Only valid with --cmd. + -h, --help help for exec + --interpreter string The interpreter to run an ad-hoc --cmd with: 'sh' (default) or 'python'. Only valid with a single --cmd. + --label string A short, human-readable label for an ad-hoc command (used in history). Only valid with --cmd. + -m, --log-mode string Log mode (text, logfmt, json, hidden) + --mode string How to run multiple --cmd commands: 'serial' (default) or 'parallel'. (default "serial") + -p, --param stringArray Set a parameter value by env key. (i.e. KEY=value) Use multiple times to set multiple parameters. This will override any existing parameter values defined for the executable. + --spec flow logs Run a transient executable from an inline definition (any type: exec, serial, parallel, request, render, launch). Accepts inline YAML/JSON, '@path' to read a file, or '-' to read stdin. The executable is not saved to disk but is recorded in flow logs. + --workspace string Workspace whose environment the ad-hoc/transient run should use (only with --cmd or --spec). Defaults to the workspace containing the run directory, then the current workspace. Does not change the global current workspace. ``` ### Options inherited from parent commands diff --git a/docs/guides/ai-tools.md b/docs/guides/ai-tools.md index a86f33b6..f9a6f4c8 100644 --- a/docs/guides/ai-tools.md +++ b/docs/guides/ai-tools.md @@ -61,17 +61,18 @@ below: the `.mcp.json` supplies the tools, the skill tells the assistant to reac | `get_executable` | Full definition and metadata for a specific executable | | `execute` | Run a **named** executable by ref, in a given `dir` or `workspace` | | `run_command` | Run one or more **arbitrary** shell commands through flow (with a `label`, working `dir`, and optional `workspace`) — captured in history like any executable | +| `run_python` | Run **Python** code through flow (with `code`, a `label`, working `dir`, and optional `workspace`) — uses the workspace's virtualenv when there is one | | `run_executable` | Run a **transient executable of any type** from an inline `spec` — a serial/parallel batch, an HTTP `request`, a `render`, or a `launch` — without saving a file | | `get_execution_logs` | Output from recent runs, filterable by `source`/`session`/`status`, or `mine` for this session's own runs | | `sync_executables` | Refresh cached workspace and executable state | | `write_flowfile` | Create or update a `.flow` file, validated before writing | -The three run tools form a ladder, closest-fit first: **`execute`** for a task you've already named, **`run_command`** for a one-off shell command, **`run_executable`** for something richer than a single command. Reaching for flow before a raw shell tool means every run inherits the workspace's environment and secrets and is recorded — see [Observability](#observability) below. +The run tools form a ladder, closest-fit first: **`execute`** for a task you've already named, **`run_command`** for a one-off shell command, **`run_python`** when the one-off is Python rather than shell, **`run_executable`** for something richer than a single command. Reaching for flow before a raw shell tool means every run inherits the workspace's environment and secrets and is recorded — see [Observability](#observability) below. **Working in a worktree or a fresh clone** The MCP server inherits whatever directory it was started in, which is often not where you are -working. Pass `dir` on `execute`, `run_command`, `run_executable`, or `list_executables` and flow +working. Pass `dir` on `execute`, `run_command`, `run_python`, `run_executable`, or `list_executables` and flow resolves the workspace by walking up from *that* directory to the nearest `flow.yaml` — so a git worktree or a just-cloned repo works without being registered first. `get_info` reports `workspaceRegistered` and `workspaceSource` so you can tell which case you are in; an @@ -117,9 +118,12 @@ prefer them over raw Bash for anything runnable. 2. **Arbitrary one-off command?** (a `git ...`, a script) → `mcp__flow__run_command` with the command and a short `label`. Runs with workspace env/secrets and lands in `flow logs`. Pass `commands` (array) + `mode` (`serial`/`parallel`) to run several in one call. -3. **Something richer than one command?** (a serial/parallel batch, an HTTP `request`) → +3. **One-off is Python?** → `mcp__flow__run_python` with `code` and a short `label`, rather + than `python -c` or a scratch `.py` file. flow resolves the workspace's virtualenv, so + imports see the project's dependencies, and tracebacks report real line numbers. +4. **Something richer than one command?** (a serial/parallel batch, an HTTP `request`) → `mcp__flow__run_executable` with an inline `spec`. -4. Only fall back to Bash for things that genuinely shouldn't be recorded or that flow isn't +5. Only fall back to Bash for things that genuinely shouldn't be recorded or that flow isn't suited to (e.g. interactive/TTY programs). Call `mcp__flow__get_info` at the start of a session, or when you need schema URLs to author @@ -131,7 +135,7 @@ with `mine: true`. ## Observability -Every run flow launches — whether a named `execute`, a `run_command`, or a `run_executable` — is recorded as one lifecycle-aware history entry: written as `running` when it starts and updated to `completed` or `failed` when it finishes. Runs launched over MCP also capture **provenance**: which tool called (`claude`, `cursor`, …) and its session ID. That turns flow's history into an audit trail of what your assistant did. +Every run flow launches — whether a named `execute`, a `run_command`, a `run_python`, or a `run_executable` — is recorded as one lifecycle-aware history entry: written as `running` when it starts and updated to `completed` or `failed` when it finishes. Runs launched over MCP also capture **provenance**: which tool called (`claude`, `cursor`, …) and its session ID. That turns flow's history into an audit trail of what your assistant did. Query it from the CLI: diff --git a/internal/mcp/resources/server-instructions.md b/internal/mcp/resources/server-instructions.md index 31d6a70c..877bfe26 100644 --- a/internal/mcp/resources/server-instructions.md +++ b/internal/mcp/resources/server-instructions.md @@ -12,9 +12,10 @@ Prefer running work **through flow** over a raw shell tool — you get the works - `execute` — run a **named** executable (build, test, lint, deploy, …) by verb + optional ID. Try this first; discover names with `list_executables`. - `run_command` — run an **arbitrary** shell command when no named executable fits (a one-off `git status`, `npm ci`, a script). Pass a short `label` so the history entry is self-documenting; `dir` sets the working directory. To run several commands in one call, pass `commands` (array) with `mode: serial` (default) or `parallel`. +- `run_python` — run **Python** code through flow instead of `python -c` or a scratch file. Pass `code` (multi-line is fine — it runs from a file, so tracebacks report real line numbers) and a short `label`. flow picks the workspace's virtualenv when there is one, so imports resolve against the project's installed dependencies; read parameters and secrets from `os.environ`. - `run_executable` — run a **transient executable of any type** from an inline `spec` when a single command isn't enough: a `serial`/`parallel` batch, an HTTP `request`, a `render`, or a `launch`. The `spec` is one executable definition (same shape as an entry under a flowfile's `executables:`); author non-trivial ones against `schemaUrls.flowFile`. -`run_command` and `run_executable` take an optional `workspace` to scope a run to another workspace's environment **without** changing the current workspace; otherwise the workspace is inferred from the run directory, then the current one. +`run_command`, `run_python`, and `run_executable` take an optional `workspace` to scope a run to another workspace's environment **without** changing the current workspace; otherwise the workspace is inferred from the run directory, then the current one. Every run you launch is attributed to this session. `get_execution_logs` with `mine: true` returns only what *this* session has run — use it to review your own recent work; `source`/`session`/`status` filter more broadly. diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 5cc300bc..fd7e1012 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -91,6 +91,7 @@ var _ = Describe("MCP Server", func() { "list_executables", "execute", "run_command", + "run_python", "run_executable", "get_execution_logs", "sync_executables", @@ -113,6 +114,7 @@ var _ = Describe("MCP Server", func() { toolsWithSchema := map[string]bool{ "execute": true, "run_command": true, + "run_python": true, "run_executable": true, } @@ -397,6 +399,70 @@ var _ = Describe("MCP Server", func() { }) }) + Context("run_python tool", func() { + It("should run python via flow exec --interpreter python --cmd", func() { + mockExecutor.EXPECT(). + ExecuteContext(gomock.Any(), "exec", "--interpreter", "python", "--cmd", "print(1)", + "--label", "compute", "--dir", "/tmp"). + Return("1", nil) + + result, err := mcpClient.CallTool(ctx, newCallToolRequest("run_python", map[string]interface{}{ + "code": "print(1)", + "label": "compute", + "dir": "/tmp", + })) + + Expect(err).ToNot(HaveOccurred()) + Expect(getTextContent(result)).To(ContainSubstring("1")) + }) + + It("should forward workspace and sync", func() { + mockExecutor.EXPECT(). + ExecuteContext(gomock.Any(), "exec", "--interpreter", "python", "--cmd", "print(2)", + "--workspace", "other", "--sync"). + Return("2", nil) + + result, err := mcpClient.CallTool(ctx, newCallToolRequest("run_python", map[string]interface{}{ + "code": "print(2)", + "workspace": "other", + "sync": true, + })) + + Expect(err).ToNot(HaveOccurred()) + Expect(getTextContent(result)).To(ContainSubstring("2")) + }) + + It("should preserve multi-line code as a single argument", func() { + // Line structure has to survive intact or tracebacks point at the + // wrong line. + code := "import sys\nif True:\n print(sys.version)\n" + mockExecutor.EXPECT(). + ExecuteContext(gomock.Any(), "exec", "--interpreter", "python", "--cmd", code). + Return("ok", nil) + + result, err := mcpClient.CallTool(ctx, newCallToolRequest("run_python", map[string]interface{}{ + "code": code, + })) + + Expect(err).ToNot(HaveOccurred()) + Expect(getTextContent(result)).To(ContainSubstring("ok")) + }) + + It("should require code", func() { + result, err := mcpClient.CallTool(ctx, newCallToolRequest("run_python", map[string]interface{}{})) + Expect(err).ToNot(HaveOccurred()) + Expect(result.IsError).To(BeTrue()) + }) + + It("should reject empty code rather than running an empty script", func() { + result, err := mcpClient.CallTool(ctx, newCallToolRequest("run_python", map[string]interface{}{ + "code": "", + })) + Expect(err).ToNot(HaveOccurred()) + Expect(result.IsError).To(BeTrue()) + }) + }) + Context("run_executable tool", func() { It("should run a transient executable from an inline spec", func() { spec := `{"verb":"run","serial":{"execs":[{"cmd":"echo one"}]}}` diff --git a/internal/mcp/tools_executable.go b/internal/mcp/tools_executable.go index 2d689d84..1f7b255a 100644 --- a/internal/mcp/tools_executable.go +++ b/internal/mcp/tools_executable.go @@ -90,6 +90,7 @@ func addExecutableTools(srv *server.MCPServer, executor CommandExecutor) { srv.AddTool(executeFlow, executeFlowHandler(srv, executor)) addRunCommandTool(srv, executor) + addRunPythonTool(srv, executor) addRunExecutableTool(srv, executor) writeFlowfile := mcp.NewTool("write_flowfile", @@ -308,6 +309,61 @@ func runCommandHandler(srv *server.MCPServer, executor CommandExecutor) server.T } } +func addRunPythonTool(srv *server.MCPServer, executor CommandExecutor) { + runPython := mcp.NewTool("run_python", + mcp.WithDescription("Run Python code through flow instead of a raw shell tool or a scratch file. "+ + "The script runs with the current workspace's environment and secrets, output is captured to "+ + "flow's logs, and the run is recorded in execution history with provenance (visible via "+ + "get_execution_logs / `flow logs`). Prefer this over shelling out to `python -c` or writing a "+ + "temporary .py file. flow picks the workspace's virtualenv when there is one, so imports resolve "+ + "against the project's installed dependencies."), + mcp.WithString("code", mcp.Required(), + mcp.Description("The Python source to run. Multi-line scripts are fine — the code runs from a "+ + "file, so tracebacks report real line numbers. Read parameters and secrets from os.environ.")), + mcp.WithString("label", + mcp.Description("Short human-readable label describing what the script does (recorded in history).")), + mcp.WithString("dir", + mcp.Description("Working directory for the script (defaults to the current directory). This also "+ + "determines which workspace's environment and virtualenv are used.")), + mcp.WithString("workspace", + mcp.Description("Workspace whose environment to use for this run. Defaults to the workspace containing "+ + "the working directory, then the current workspace. Does not change the global current workspace.")), + mcp.WithBoolean("sync", mcp.Description("Sync flow cache and workspaces before running.")), + mcp.WithOutputSchema[ExecutionOutput](), + ) + runPython.Annotations = mcp.ToolAnnotation{ + Title: "Run Python through flow", + ReadOnlyHint: boolPtr(false), DestructiveHint: boolPtr(true), + IdempotentHint: boolPtr(false), OpenWorldHint: boolPtr(true), + } + srv.AddTool(runPython, runPythonHandler(srv, executor)) +} + +func runPythonHandler(srv *server.MCPServer, executor CommandExecutor) server.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + code, err := request.RequireString("code") + if err != nil || code == "" { + return toolError(ErrCodeInvalidInput, "code is required"), nil + } + + cmdArgs := []string{"exec", "--interpreter", "python", "--cmd", code} + if label := request.GetString("label", ""); label != "" { + cmdArgs = append(cmdArgs, "--label", label) + } + if dir := request.GetString("dir", ""); dir != "" { + cmdArgs = append(cmdArgs, "--dir", dir) + } + if ws := request.GetString("workspace", ""); ws != "" { + cmdArgs = append(cmdArgs, "--workspace", ws) + } + if request.GetBool("sync", false) { + cmdArgs = append(cmdArgs, "--sync") + } + + return runTransientTool(ctx, srv, request, executor, cmdArgs, "python script failed") + } +} + func addRunExecutableTool(srv *server.MCPServer, executor CommandExecutor) { runExecutable := mcp.NewTool("run_executable", mcp.WithDescription("Run a transient executable of ANY type from an inline definition, without saving a "+ diff --git a/tests/python_exec_e2e_test.go b/tests/python_exec_e2e_test.go index 4d1eddd1..80df21ee 100644 --- a/tests/python_exec_e2e_test.go +++ b/tests/python_exec_e2e_test.go @@ -98,6 +98,36 @@ var _ = Describe("python exec e2e", func() { }) }) + When("running an ad-hoc command with --interpreter", func() { + It("runs the command as python", func() { + runner := utils.NewE2ECommandRunner() + stdOut := ctx.StdOut() + Expect(runner.Run(ctx.Context, "exec", + "--interpreter", "python", + "--cmd", "import sys; print('adhoc py', sys.version_info[0])", + )).To(Succeed()) + out, _ := readFileContent(stdOut) + Expect(out).To(ContainSubstring("adhoc py 3")) + }) + + It("rejects an unknown interpreter instead of falling back to shell", func() { + runner := utils.NewE2ECommandRunner() + ctx.ExpectFailure() + err := runner.Run(ctx.Context, "exec", "--interpreter", "ruby", "--cmd", "puts 1") + Expect(err).To(HaveOccurred()) + }) + + It("rejects --interpreter with multiple commands", func() { + // Serial/parallel steps carry no interpreter, so only the single-command + // form can honour the flag. + runner := utils.NewE2ECommandRunner() + ctx.ExpectFailure() + err := runner.Run(ctx.Context, "exec", "--interpreter", "python", + "--cmd", "print(1)", "--cmd", "print(2)") + Expect(err).To(HaveOccurred()) + }) + }) + When("an executable runs a .py file", func() { It("infers the python interpreter from the extension", func() { dir := ctx.WorkspaceDir()