Skip to content
Merged
40 changes: 40 additions & 0 deletions internal/cli/declarative/inspector/inspector.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Package inspector launches MCP Inspector as a subprocess against a given
// MCP server URL. The implementation is a thin wrapper around
// `npx -y @modelcontextprotocol/inspector --server-url <url>` — it has no
// independent protocol handling. The caller owns the returned process's
// lifecycle and must call Process.Kill() on shutdown.
package inspector

import (
"context"
"fmt"
"os"
"os/exec"
)

// commandFactory matches the signature of exec.CommandContext so tests can
// inject a fake without invoking npx.
type commandFactory func(ctx context.Context, name string, args ...string) *exec.Cmd

// starter matches the signature of (*exec.Cmd).Start so tests can avoid
// spawning processes during unit tests.
type starter func(cmd *exec.Cmd) error

// Launch starts MCP Inspector as a subprocess pointed at serverURL.
// The subprocess's stdout/stderr are wired to the current process's streams.
// Returns the *exec.Cmd so the caller can call Process.Kill() on shutdown.
// Returns an error only if the subprocess fails to start (typically because
// npx is not on PATH).
func Launch(ctx context.Context, serverURL string) (*exec.Cmd, error) {
return launchWith(ctx, serverURL, exec.CommandContext, func(c *exec.Cmd) error { return c.Start() })
}

func launchWith(ctx context.Context, serverURL string, makeCmd commandFactory, start starter) (*exec.Cmd, error) {
cmd := makeCmd(ctx, "npx", "-y", "@modelcontextprotocol/inspector", "--server-url", serverURL)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := start(cmd); err != nil {
return nil, fmt.Errorf("starting MCP Inspector subprocess: %w", err)
}
return cmd, nil
}
55 changes: 55 additions & 0 deletions internal/cli/declarative/inspector/inspector_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package inspector

import (
"context"
"os/exec"
"testing"
)

func TestLaunch_BuildsExpectedArgv(t *testing.T) {
var gotName string
var gotArgs []string
fakeFactory := func(ctx context.Context, name string, args ...string) *exec.Cmd {
gotName = name
gotArgs = args
// Return a cmd we never actually start; the fake starter swallows it.
return exec.CommandContext(ctx, "echo")
}

_, err := launchWith(context.Background(), "http://localhost:3000/mcp", fakeFactory, fakeStarter)
if err != nil {
t.Fatalf("launchWith returned error: %v", err)
}
if gotName != "npx" {
t.Errorf("expected npx, got %q", gotName)
}
want := []string{"-y", "@modelcontextprotocol/inspector", "--server-url", "http://localhost:3000/mcp"}
if len(gotArgs) != len(want) {
t.Fatalf("argv length: got %d want %d (%v)", len(gotArgs), len(want), gotArgs)
}
for i := range want {
if gotArgs[i] != want[i] {
t.Errorf("argv[%d]: got %q want %q", i, gotArgs[i], want[i])
}
}
}

func TestLaunch_ReturnsErrorWhenStartFails(t *testing.T) {
fakeFactory := func(ctx context.Context, name string, args ...string) *exec.Cmd {
return exec.CommandContext(ctx, "/nonexistent/path")
}
failingStarter := func(_ *exec.Cmd) error {
return exec.ErrNotFound
}

cmd, err := launchWith(context.Background(), "http://localhost:3000/mcp", fakeFactory, failingStarter)
if err == nil {
t.Fatalf("expected error, got cmd=%v err=nil", cmd)
}
if cmd != nil {
t.Errorf("expected nil cmd on error, got %v", cmd)
}
}

// fakeStarter returns nil so Launch treats it as a successful start.
func fakeStarter(_ *exec.Cmd) error { return nil }
63 changes: 51 additions & 12 deletions internal/cli/declarative/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,11 @@ func NewRunCmd() *cobra.Command {

func newRunCmd() *cobra.Command {
var (
extraEnv []string
dryRun bool
watch bool
noChat bool
extraEnv []string
dryRun bool
watch bool
noChat bool
inspector bool
)
cmd := &cobra.Command{
Use: "run [DIRECTORY]",
Expand All @@ -48,30 +49,34 @@ A2A chat. When chat exits the runtime is torn down. Use --no-chat to
keep the old foreground-only behavior.

For MCPServer kinds chat does not apply; the framework's run command runs
in the foreground until interrupted.
in the foreground until interrupted. Pass --inspector to launch the MCP
Inspector subprocess (requires 'npx' on PATH) alongside the server; the
Inspector retries until the server is reachable.

Reads arctl.yaml to look up the matching framework by (framework, language)
and dispatches to its run command. Loads .env (if present) and validates
that the framework's required env vars are set.`,
Example: ` arctl run
arctl run ./myagent
arctl run -e FOO=bar -e BAZ=qux
arctl run --no-chat
arctl run --watch`,
arctl run --no-chat # agent without chat
arctl run --watch # iterative dev loop
arctl run mymcp --inspector # MCP with MCP Inspector launched`,
SilenceUsage: true,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
dir, err := resolveProjectDir(args)
if err != nil {
return err
}
return runProject(cmd.OutOrStdout(), dir, extraEnv, dryRun, watch, noChat)
return runProject(cmd.Context(), cmd.OutOrStdout(), dir, extraEnv, dryRun, watch, noChat, inspector)
},
}
cmd.Flags().StringArrayVarP(&extraEnv, "env", "e", nil, "KEY=VALUE env override")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Skip actual exec; useful for tests")
cmd.Flags().BoolVar(&watch, "watch", false, "Rebuild and restart on file change")
cmd.Flags().BoolVar(&noChat, "no-chat", false, "Skip chat for Agents; run the framework command in the foreground")
cmd.Flags().BoolVar(&watch, "watch", false, "Rebuild and restart on file change (skips chat for agents; for chat open a second terminal)")
cmd.Flags().BoolVar(&noChat, "no-chat", false, "Skip chat for Agents; run the framework command in the foreground (agent projects only; errors on MCP projects)")
cmd.Flags().BoolVar(&inspector, "inspector", false, "Launch MCP Inspector alongside the server; it connects when ready (MCP projects only; errors on agent projects)")
return cmd
}

Expand All @@ -96,7 +101,7 @@ func resolveProjectDir(args []string) (string, error) {
return abs, nil
}

func runProject(out io.Writer, projectDir string, extraEnv []string, dryRun, watch, noChat bool) error {
func runProject(ctx context.Context, out io.Writer, projectDir string, extraEnv []string, dryRun, watch, noChat, inspector bool) error {
cfg, err := buildconfig.Read(projectDir)
if err != nil {
return err
Expand Down Expand Up @@ -125,6 +130,17 @@ func runProject(out io.Writer, projectDir string, extraEnv []string, dryRun, wat
return fmt.Errorf("no framework for framework=%s language=%s", cfg.Framework, cfg.Language)
}

// Strict flag-vs-kind validation. Symmetric: --inspector errors on
// agent projects, --no-chat errors on MCP projects. Fail fast before
// any exec or dry-run narration so a typo'd flag gives clear feedback
// instead of being silently ignored.
if inspector && frameworkType == "agent" {
return fmt.Errorf("--inspector is only valid for MCP projects; this is an agent project (agents are inspected via chat, the default behavior of arctl run)")
}
if noChat && frameworkType == "mcp" {
return fmt.Errorf("--no-chat is only valid for agent projects; this is an MCP project (MCPs do not open a chat)")
}

name := filepath.Base(projectDir)

dotEnv, err := LoadDotEnv(projectDir)
Expand Down Expand Up @@ -185,7 +201,17 @@ func runProject(out io.Writer, projectDir string, extraEnv []string, dryRun, wat
// surface ("Watching for changes…", "Change detected") without
// shelling out to a long-running runtime.
if watch {
return runWithWatch(out, projectDir, p, envv, dryRun)
// Agent + --watch is the no-chat foreground rebuild loop. Print a
// signpost so users know (a) where the agent is reachable and
// (b) that chat lives in another terminal. Suppress the chat hint
// when the user has explicitly opted out via --no-chat.
if frameworkType == "agent" {
fmt.Fprintf(out, "→ Agent at %s\n", agentReadinessURL)
if !noChat {
fmt.Fprintf(out, "→ For chat, open another terminal: arctl run %s\n", name)
}
}
return runWithWatch(ctx, out, projectDir, p, image, port, envv, dryRun, inspector)
}

// Chat default applies only to Agents (not MCPServers) and when the
Expand All @@ -198,9 +224,22 @@ func runProject(out io.Writer, projectDir string, extraEnv []string, dryRun, wat

if dryRun {
fmt.Fprintf(out, "→ %s: %s\n", p.Name, strings.Join(rendered, " "))
if inspector {
fmt.Fprintf(out, "→ would launch MCP Inspector against http://localhost:%d/mcp\n", port)
}
fmt.Fprintln(out, "(dry-run; skipping exec)")
return nil
}

// Inspector retries connecting on its own until the MCP is up, so launch
// it BEFORE the foreground docker run — the race window is invisible.
// Not blocking the MCP on a missing npx is intentional: debug tools
// should degrade gracefully, not gate the dev loop.
if inspector {
stop := launchInspector(out, port)
defer stop()
}

fmt.Fprintf(out, "→ %s: %s\n", p.Name, strings.Join(rendered, " "))
return frameworks.ExecForeground(p.Run, projectDir, vars, envv)
}
Expand Down
141 changes: 141 additions & 0 deletions internal/cli/declarative/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package declarative_test

import (
"bytes"
"context"
"os"
"path/filepath"
"testing"
Expand Down Expand Up @@ -73,6 +74,79 @@ func TestRun_ChatDefault_DryRunNarratesFullLifecycle(t *testing.T) {
require.Contains(t, out, "(dry-run; skipping exec)")
}

// TestRun_InspectorOnAgentErrors verifies the strict-symmetric flag
// validation: --inspector is MCP-only and fails fast on agent projects
// before any exec or dry-run narration, with a message pointing the user
// at chat (the agent's equivalent inspection surface).
func TestRun_InspectorOnAgentErrors(t *testing.T) {
t.Setenv("GOOGLE_API_KEY", "fake")
tmp := t.TempDir()
cwd, err := os.Getwd()
require.NoError(t, err)
t.Cleanup(func() { _ = os.Chdir(cwd) })

require.NoError(t, os.Chdir(tmp))
initCmd := declarative.NewInitCmd()
initCmd.SetArgs([]string{"agent", "agentproj", "--framework", "adk", "--language", "python"})
require.NoError(t, initCmd.Execute())

require.NoError(t, os.Chdir(filepath.Join(tmp, "agentproj")))
cmd := declarative.NewRunCmd()
cmd.SetArgs([]string{"--inspector", "--dry-run"})
err = cmd.Execute()
require.Error(t, err)
require.Contains(t, err.Error(), "--inspector is only valid for MCP projects")
}

// TestRun_InspectorDryRunNarratesURL verifies that --inspector on an MCP
// project under --dry-run prints the inspector URL the user would see at
// runtime, so docs + CI can assert on the narration without spawning npx.
func TestRun_InspectorDryRunNarratesURL(t *testing.T) {
tmp := t.TempDir()
cwd, err := os.Getwd()
require.NoError(t, err)
t.Cleanup(func() { _ = os.Chdir(cwd) })

require.NoError(t, os.Chdir(tmp))
initCmd := declarative.NewInitCmd()
initCmd.SetArgs([]string{"mcp", "acme/inspmcp", "--framework", "fastmcp", "--language", "python"})
require.NoError(t, initCmd.Execute())

require.NoError(t, os.Chdir(filepath.Join(tmp, "inspmcp")))
cmd := declarative.NewRunCmd()
var buf bytes.Buffer
cmd.SetOut(&buf)
cmd.SetErr(&buf)
cmd.SetArgs([]string{"--inspector", "--dry-run"})
require.NoError(t, cmd.Execute())

out := buf.String()
require.Contains(t, out, "would launch MCP Inspector")
require.Contains(t, out, "http://localhost:3000/mcp")
require.Contains(t, out, "(dry-run; skipping exec)")
}

// TestRun_NoChatOnMCPErrors mirrors the above for the other direction:
// --no-chat is agent-only and fails fast on MCP projects.
func TestRun_NoChatOnMCPErrors(t *testing.T) {
tmp := t.TempDir()
cwd, err := os.Getwd()
require.NoError(t, err)
t.Cleanup(func() { _ = os.Chdir(cwd) })

require.NoError(t, os.Chdir(tmp))
initCmd := declarative.NewInitCmd()
initCmd.SetArgs([]string{"mcp", "acme/mcpproj", "--framework", "fastmcp", "--language", "python"})
require.NoError(t, initCmd.Execute())

require.NoError(t, os.Chdir(filepath.Join(tmp, "mcpproj")))
cmd := declarative.NewRunCmd()
cmd.SetArgs([]string{"--no-chat", "--dry-run"})
err = cmd.Execute()
require.Error(t, err)
require.Contains(t, err.Error(), "--no-chat is only valid for agent projects")
}

// TestRun_DoesNotRequireAgentYAML proves the structural decoupling: run
// reads arctl.yaml only. Removing agent.yaml from a freshly inited project
// must not break run.
Expand All @@ -96,3 +170,70 @@ func TestRun_DoesNotRequireAgentYAML(t *testing.T) {
cmd.SetArgs([]string{"--dry-run"})
require.NoError(t, cmd.Execute())
}

// TestRun_AgentWatch_DryRunNarratesSignpost verifies that the agent + --watch
// path emits the "where is my agent" + "open chat in another terminal"
// signpost so users know watch is the no-chat foreground iterate mode.
func TestRun_AgentWatch_DryRunNarratesSignpost(t *testing.T) {
t.Setenv("GOOGLE_API_KEY", "fake")
tmp := t.TempDir()
cwd, err := os.Getwd()
require.NoError(t, err)
t.Cleanup(func() { _ = os.Chdir(cwd) })

require.NoError(t, os.Chdir(tmp))
initCmd := declarative.NewInitCmd()
initCmd.SetArgs([]string{"agent", "watchagent", "--framework", "adk", "--language", "python"})
require.NoError(t, initCmd.Execute())

require.NoError(t, os.Chdir(filepath.Join(tmp, "watchagent")))
cmd := declarative.NewRunCmd()
var buf bytes.Buffer
cmd.SetOut(&buf)
cmd.SetErr(&buf)
cmd.SetArgs([]string{"--watch", "--dry-run"})
// Pre-cancel so runWithWatch's fsnotify loop exits immediately after
// printing the signpost + watcher banner. Without this, --watch under
// dry-run blocks forever waiting on file events.
ctx, cancel := context.WithCancel(context.Background())
cancel()
cmd.SetContext(ctx)
require.NoError(t, cmd.Execute())

out := buf.String()
require.Contains(t, out, "→ Agent at http://localhost:8080")
require.Contains(t, out, "→ For chat, open another terminal: arctl run watchagent")
require.Contains(t, out, "Watching for changes")
}

// TestRun_AgentWatchNoChat_DryRunSuppressesChatHint verifies that when the
// user explicitly opts out of chat with --no-chat alongside --watch, the
// signpost drops the "open another terminal for chat" line — the user
// already said they don't want chat, no need to nag.
func TestRun_AgentWatchNoChat_DryRunSuppressesChatHint(t *testing.T) {
t.Setenv("GOOGLE_API_KEY", "fake")
tmp := t.TempDir()
cwd, err := os.Getwd()
require.NoError(t, err)
t.Cleanup(func() { _ = os.Chdir(cwd) })

require.NoError(t, os.Chdir(tmp))
initCmd := declarative.NewInitCmd()
initCmd.SetArgs([]string{"agent", "watchquiet", "--framework", "adk", "--language", "python"})
require.NoError(t, initCmd.Execute())

require.NoError(t, os.Chdir(filepath.Join(tmp, "watchquiet")))
cmd := declarative.NewRunCmd()
var buf bytes.Buffer
cmd.SetOut(&buf)
cmd.SetErr(&buf)
cmd.SetArgs([]string{"--watch", "--no-chat", "--dry-run"})
ctx, cancel := context.WithCancel(context.Background())
cancel()
cmd.SetContext(ctx)
require.NoError(t, cmd.Execute())

out := buf.String()
require.Contains(t, out, "→ Agent at http://localhost:8080")
require.NotContains(t, out, "For chat, open another terminal")
}
Loading
Loading