diff --git a/README.md b/README.md index 97f6bfb..c7b10d1 100644 --- a/README.md +++ b/README.md @@ -43,12 +43,14 @@ Then start PHP with debugging enabled (e.g. `php -dxdebug.mode=debug -dxdebug.st ## MCP server for AI agents -`dbgp mcp` runs the debugger as an [MCP](https://modelcontextprotocol.io) server on stdin and stdout, so an AI agent can debug PHP. To add it to Claude Code: +`dbgp mcp` runs the debugger as an [MCP](https://modelcontextprotocol.io) server on stdin and stdout, so an AI agent can debug PHP. Agents start it themselves; to add it to Claude Code: ```sh claude mcp add dbgp -- dbgp mcp ``` +It can also run in the background, so that it keeps its state between agent sessions and agents share it: `dbgp mcp start` starts it and returns, `dbgp mcp status` shows whether it is running and `dbgp mcp stop` stops it. While it runs, `dbgp mcp` started by an agent passes the agent on to it, and the options given to `dbgp mcp start` apply. The background server serves MCP over HTTP on `127.0.0.1:9004` (change it with `-http`); its log is `mcp.log` in dbgp's cache directory (e.g. `~/.cache/dbgp` on Linux, `~/Library/Caches/dbgp` on macOS). + Unlike the interactive mode, it does not listen for PHP connections until the agent calls the `listen` tool, so PHP runs at full speed until there is something to debug; `-listen` starts listening right away. `-addr`, `-idekey` and `-map LOCAL=REMOTE` work as in the interactive mode. Tools: `status` (listening state, sessions, breakpoints and path mappings), `listen` and `unlisten`; `add_breakpoint`, `remove_breakpoint` and `breakpoints`; `add_path_mapping` and `path_mappings`; `sessions`, `wait_for_session`, `continue` (run or step), `wait_for_stop`, `stop` and `detach`; `stack`, `variables`, `variable` (one variable, a page of its contents at a time) and `variable_value`; `source`, `output` and `warnings` (what the script printed and the PHP warnings it raised, each read incrementally); `eval` and `set_variable`, which run PHP code in the debugged script and can be left out with `-no-eval`. diff --git a/cmd/dbgp/mcp.go b/cmd/dbgp/mcp.go index 7525e61..f21439c 100644 --- a/cmd/dbgp/mcp.go +++ b/cmd/dbgp/mcp.go @@ -63,20 +63,47 @@ This is the user's choice, not a missing feature or an outdated build: do not suggest rebuilding or restarting dbgp to get them. Read values with variables, variable and variable_value instead, and work out results from them.` -// runMCP runs dbgp as an MCP server on stdin and stdout until the client -// disconnects. stdout carries the protocol, so diagnostics go to stderr. +// runMCP runs dbgp as an MCP server. Agents start it as dbgp mcp and talk +// to it on stdin and stdout: it serves them itself, or passes them on to the +// background server when one is running, so they share it. dbgp mcp start, +// stop and status manage the background server, which serves MCP over HTTP. func runMCP(args []string) int { + command := "" + if len(args) > 0 && !strings.HasPrefix(args[0], "-") { + command, args = args[0], args[1:] + } + switch command { + case "", "start", serveCommand: + case "stop", "status": + if len(args) > 0 { + fmt.Fprintf(os.Stderr, "dbgp mcp %s takes no options\n", command) + return 2 + } + if command == "stop" { + return stopDaemon() + } + return daemonStatus() + default: + fmt.Fprintf(os.Stderr, "unknown command %q: use dbgp mcp, dbgp mcp start, stop or status\n", command) + return 2 + } + fs := flag.NewFlagSet("dbgp mcp", flag.ContinueOnError) addr := fs.String("addr", dbgp.DefaultAddr, "address to listen on for PHP connections") idekey := fs.String("idekey", "", "accept only sessions with this IDE key") listen := fs.Bool("listen", false, "listen for PHP connections from the start, instead of waiting for the listen tool") noEval := fs.Bool("no-eval", false, "leave out the eval and set_variable tools, which run PHP code in the debugged script") + httpAddr := fs.String("http", defaultHTTPAddr, "with start: the address agents connect to over HTTP") var maps listFlag fs.Var(&maps, "map", "path mapping `LOCAL=REMOTE` for PHP in a container or on a server (repeatable)") fs.Usage = func() { - fmt.Fprintf(fs.Output(), "Usage: dbgp mcp [options]\n\n"+ - "Runs dbgp as an MCP server on stdin and stdout, for AI agents.\n"+ - "It does not listen for PHP connections until asked to.\n\nOptions:\n") + fmt.Fprintf(fs.Output(), "Usage: dbgp mcp [options]\n dbgp mcp start [options]\n dbgp mcp stop\n dbgp mcp status\n\n"+ + "dbgp mcp runs dbgp as an MCP server for AI agents, on stdin and stdout:\n"+ + "agents start it themselves. When the background server is running, it\n"+ + "connects the agent to that instead, and the background server's options apply.\n"+ + "dbgp mcp start starts the background server, which agents share, and returns;\n"+ + "dbgp mcp stop stops it and dbgp mcp status shows whether it is running.\n"+ + "dbgp does not listen for PHP connections until asked to.\n\nOptions:\n") fs.PrintDefaults() } if err := fs.Parse(args); err != nil { @@ -85,12 +112,35 @@ func runMCP(args []string) int { } return 2 } - + if fs.NArg() > 0 { + fmt.Fprintf(os.Stderr, "unexpected argument %q\n", fs.Arg(0)) + return 2 + } pathMap, err := parsePathMap(maps) if err != nil { fmt.Fprintln(os.Stderr, err) return 2 } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + switch command { + case "start": + return startDaemon(args) + case "": + if st, err := running(); err == nil && st != nil { + if len(args) > 0 { + fmt.Fprintf(os.Stderr, "dbgp: using the background server at %s, with its own options: %s ignored\n", + st.URL, strings.Join(args, " ")) + } + if err := bridge(ctx, st.URL); err != nil && ctx.Err() == nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + return 0 + } + } + srv, err := dbgp.NewServer(dbgp.Config{Addr: *addr, IDEKey: *idekey, PathMap: pathMap}) if err != nil { fmt.Fprintln(os.Stderr, err) @@ -103,10 +153,13 @@ func runMCP(args []string) int { return 1 } } - - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - if err := newMCPServer(srv, !*noEval).Run(ctx, &mcp.StdioTransport{}); err != nil && ctx.Err() == nil { + server := newMCPServer(srv, !*noEval) + if command == serveCommand { + err = serveHTTP(ctx, server, *httpAddr) + } else { + err = server.Run(ctx, &mcp.StdioTransport{}) + } + if err != nil && ctx.Err() == nil { fmt.Fprintln(os.Stderr, err) return 1 } diff --git a/cmd/dbgp/mcp_daemon.go b/cmd/dbgp/mcp_daemon.go new file mode 100644 index 0000000..2aa1a9a --- /dev/null +++ b/cmd/dbgp/mcp_daemon.go @@ -0,0 +1,323 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// The background MCP server, over HTTP: dbgp mcp start starts it and +// returns, dbgp mcp stop stops it, and dbgp mcp started by an agent passes +// the agent on to it. Its state file records its process and URL, so they +// find it; there is one per user. + +// serveCommand runs the background server in this process. dbgp mcp start +// runs it; it is not meant to be typed. +const serveCommand = "serve" + +// defaultHTTPAddr is where agents connect: on this machine only, next to +// DBGp's 9003. +const defaultHTTPAddr = "127.0.0.1:9004" + +// How long the commands wait for the server to start or stop. +const ( + startTimeout = 10 * time.Second + stopTimeout = 5 * time.Second +) + +// daemonState is what the state file records about a running server. +type daemonState struct { + PID int `json:"pid"` + URL string `json:"url"` +} + +// stateDir is where the state file and the background server's log are. +func stateDir() (string, error) { + dir, err := os.UserCacheDir() + if err != nil { + return "", fmt.Errorf("no directory for dbgp's state: %w", err) + } + return filepath.Join(dir, "dbgp"), nil +} + +func statePath() (string, error) { + dir, err := stateDir() + return filepath.Join(dir, "mcp.json"), err +} + +func logPath() (string, error) { + dir, err := stateDir() + return filepath.Join(dir, "mcp.log"), err +} + +// readState returns the recorded server, or nil when there is none. +func readState() (*daemonState, error) { + path, err := statePath() + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + var st daemonState + if err := json.Unmarshal(data, &st); err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + return &st, nil +} + +func writeState(st daemonState) error { + path, err := statePath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + data, err := json.Marshal(st) + if err != nil { + return err + } + return os.WriteFile(path, data, 0o600) +} + +// removeState removes the state file if it still records process pid. +func removeState(pid int) { + if st, err := readState(); err == nil && st != nil && st.PID == pid { + if path, err := statePath(); err == nil { + _ = os.Remove(path) + } + } +} + +// running returns the recorded server if it is answering, removing a state +// file left behind by one that is not. +func running() (*daemonState, error) { + st, err := readState() + if err != nil || st == nil { + return nil, err + } + if !answering(st.URL) { + removeState(st.PID) + return nil, nil + } + return st, nil +} + +// answering reports whether something accepts connections at rawURL. +func answering(rawURL string) bool { + u, err := url.Parse(rawURL) + if err != nil { + return false + } + conn, err := net.DialTimeout("tcp", u.Host, time.Second) + if err != nil { + return false + } + _ = conn.Close() + return true +} + +// startDaemon starts the server in the background, running this program +// with serveCommand and args, and waits for it to answer. +func startDaemon(args []string) int { + if st, err := running(); err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } else if st != nil { + fmt.Printf("dbgp MCP server already running at %s (pid %d)\n"+ + "To start it with other options, stop it first with: dbgp mcp stop\n", st.URL, st.PID) + return 0 + } + + exe, err := os.Executable() + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + logFile, err := openLog() + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + cmd := exec.Command(exe, append([]string{"mcp", serveCommand}, args...)...) + cmd.Stdout, cmd.Stderr = logFile, logFile + detach(cmd) + err = cmd.Start() + _ = logFile.Close() + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + exited := make(chan struct{}) + go func() { _ = cmd.Wait(); close(exited) }() + + deadline := time.After(startTimeout) + for { + if st, _ := readState(); st != nil && st.PID == cmd.Process.Pid && answering(st.URL) { + fmt.Printf("dbgp MCP server running at %s (pid %d)\n"+ + "Agents that start dbgp mcp now use it. Stop it with: dbgp mcp stop\n", st.URL, st.PID) + return 0 + } + select { + case <-exited: + fmt.Fprintf(os.Stderr, "dbgp MCP server did not start:\n%s", readLog()) + return 1 + case <-deadline: + _ = cmd.Process.Kill() + fmt.Fprintf(os.Stderr, "dbgp MCP server did not start within %v:\n%s", startTimeout, readLog()) + return 1 + case <-time.After(50 * time.Millisecond): + } + } +} + +// openLog creates the background server's log, replacing the last one. +func openLog() (*os.File, error) { + path, err := logPath() + if err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, err + } + return os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) +} + +func readLog() string { + path, err := logPath() + if err != nil { + return "" + } + data, _ := os.ReadFile(path) + return string(data) +} + +// stopDaemon stops the background server and waits for it to go. +func stopDaemon() int { + st, err := running() + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + if st == nil { + fmt.Println("dbgp MCP server is not running") + return 0 + } + proc, err := os.FindProcess(st.PID) + if err == nil { + err = terminate(proc) + } + if err != nil { + fmt.Fprintf(os.Stderr, "cannot stop the dbgp MCP server (pid %d): %v\n", st.PID, err) + return 1 + } + for deadline := time.Now().Add(stopTimeout); answering(st.URL); { + if time.Now().After(deadline) { + fmt.Fprintf(os.Stderr, "dbgp MCP server (pid %d) did not stop within %v\n", st.PID, stopTimeout) + return 1 + } + time.Sleep(50 * time.Millisecond) + } + // A server that was killed could not remove it itself. + removeState(st.PID) + fmt.Printf("dbgp MCP server stopped (pid %d)\n", st.PID) + return 0 +} + +// daemonStatus shows whether the background server is running. +func daemonStatus() int { + st, err := running() + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + if st == nil { + fmt.Println("dbgp MCP server is not running") + return 1 + } + fmt.Printf("dbgp MCP server running at %s (pid %d)\n", st.URL, st.PID) + return 0 +} + +// bridge passes MCP messages between stdin and stdout and the background +// server at url, until either side closes. +func bridge(ctx context.Context, url string) error { + local, err := (&mcp.StdioTransport{}).Connect(ctx) + if err != nil { + return err + } + defer local.Close() + remote, err := (&mcp.StreamableClientTransport{Endpoint: url}).Connect(ctx) + if err != nil { + return err + } + defer remote.Close() + + done := make(chan error, 2) + pass := func(from, to mcp.Connection) { + for { + msg, err := from.Read(ctx) + if err == nil { + err = to.Write(ctx, msg) + } + if err != nil { + done <- err + return + } + } + } + go pass(local, remote) + go pass(remote, local) + // The agent closing stdin is the usual end. + if err := <-done; !errors.Is(err, io.EOF) { + return err + } + return nil +} + +// serveHTTP serves MCP over HTTP at addr until ctx is done, recording the +// server in the state file meanwhile. +func serveHTTP(ctx context.Context, server *mcp.Server, addr string) error { + ln, err := net.Listen("tcp", addr) + if err != nil { + return err + } + st := daemonState{PID: os.Getpid(), URL: "http://" + ln.Addr().String() + "/mcp"} + if err := writeState(st); err != nil { + _ = ln.Close() + return err + } + defer removeState(st.PID) + + handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return server }, + &mcp.StreamableHTTPOptions{CrossOriginProtection: http.NewCrossOriginProtection()}) + mux := http.NewServeMux() + mux.Handle("/mcp", handler) + hs := &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second} + go func() { + <-ctx.Done() + // Tools may be waiting for PHP for minutes: do not wait for them. + _ = hs.Close() + }() + fmt.Fprintf(os.Stderr, "dbgp MCP server listening at %s\n", st.URL) + if err := hs.Serve(ln); !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil +} diff --git a/cmd/dbgp/mcp_daemon_test.go b/cmd/dbgp/mcp_daemon_test.go new file mode 100644 index 0000000..a938ce5 --- /dev/null +++ b/cmd/dbgp/mcp_daemon_test.go @@ -0,0 +1,200 @@ +package main + +import ( + "context" + "net" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// daemonEnv points dbgp's state directory, and this test's, at a temporary +// directory. +func daemonEnv(t *testing.T) { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CACHE_HOME", filepath.Join(home, "cache")) + t.Setenv("LocalAppData", filepath.Join(home, "appdata")) +} + +// runDbgp runs the binary and returns its exit code and output. +func runDbgp(t *testing.T, bin string, args ...string) (int, string) { + t.Helper() + cmd := exec.Command(bin, args...) + out, err := cmd.CombinedOutput() + if err != nil && cmd.ProcessState == nil { + t.Fatal(err) + } + return cmd.ProcessState.ExitCode(), string(out) +} + +var runningAt = regexp.MustCompile(`running at (\S+) \(pid (\d+)\)`) + +// startTestDaemon starts the server in the background on a free port and +// returns its URL. It is stopped at cleanup. +func startTestDaemon(t *testing.T, bin string, args ...string) string { + t.Helper() + code, out := runDbgp(t, bin, append([]string{"mcp", "start", "-http", "127.0.0.1:0"}, args...)...) + m := runningAt.FindStringSubmatch(out) + if code != 0 || m == nil { + t.Fatalf("dbgp mcp: exit %d\n%s", code, out) + } + t.Cleanup(func() { runDbgp(t, bin, "mcp", "stop") }) + return m[1] +} + +func TestMCPDaemon(t *testing.T) { + bin := buildBinary(t) + daemonEnv(t) + + url := startTestDaemon(t, bin) + if !strings.Contains(runDbgpOut(t, bin, "mcp", "status"), url) { + t.Error("dbgp mcp status does not show the URL") + } + // Starting it again leaves the running one alone. + if code, out := runDbgp(t, bin, "mcp", "start"); code != 0 || !strings.Contains(out, "already running at "+url) { + t.Errorf("second start: exit %d\n%s", code, out) + } + + // Agents connect over HTTP, and can come and go. + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + for range 2 { + cs, err := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "0"}, nil). + Connect(ctx, &mcp.StreamableClientTransport{Endpoint: url}, nil) + if err != nil { + t.Fatal(err) + } + var status statusOutput + callTool(t, cs, "status", nil, &status) + if status.Listening { + t.Error("listening before the listen tool") + } + _ = cs.Close() + } + + if code, out := runDbgp(t, bin, "mcp", "stop"); code != 0 || !strings.Contains(out, "stopped") { + t.Errorf("stop: exit %d\n%s", code, out) + } + if answering(url) { + t.Error("still answering after stop") + } + if code, out := runDbgp(t, bin, "mcp", "status"); code != 1 || !strings.Contains(out, "not running") { + t.Errorf("status after stop: exit %d\n%s", code, out) + } + if code, out := runDbgp(t, bin, "mcp", "stop"); code != 0 || !strings.Contains(out, "not running") { + t.Errorf("stop when not running: exit %d\n%s", code, out) + } + if st, err := readState(); st != nil || err != nil { + t.Errorf("state after stop = %+v, %v", st, err) + } +} + +// runDbgpOut runs the binary, which must succeed, and returns its output. +func runDbgpOut(t *testing.T, bin string, args ...string) string { + t.Helper() + code, out := runDbgp(t, bin, args...) + if code != 0 { + t.Fatalf("%v: exit %d\n%s", args, code, out) + } + return out +} + +func TestMCPDaemonDoesNotStart(t *testing.T) { + bin := buildBinary(t) + daemonEnv(t) + // The port is taken by something else. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + + code, out := runDbgp(t, bin, "mcp", "start", "-http", ln.Addr().String()) + if code != 1 || !strings.Contains(out, "did not start") || !strings.Contains(out, ln.Addr().String()) { + t.Errorf("start on a taken port: exit %d\n%s", code, out) + } +} + +func TestMCPDaemonStaleState(t *testing.T) { + daemonEnv(t) + // A server that died without removing its state file. + if err := writeState(daemonState{PID: 999999, URL: "http://127.0.0.1:1/mcp"}); err != nil { + t.Fatal(err) + } + if st, err := running(); st != nil || err != nil { + t.Errorf("running() = %+v, %v; want none", st, err) + } + if st, _ := readState(); st != nil { + t.Errorf("stale state kept: %+v", st) + } +} + +func TestMCPArguments(t *testing.T) { + for _, args := range [][]string{ + {"restart"}, + {"stop", "-http", "127.0.0.1:0"}, + {"start", "extra"}, + } { + if code := runMCP(args); code != 2 { + t.Errorf("%v: exit %d, want 2", args, code) + } + } +} + +// An agent that starts dbgp mcp while the background server runs uses it: +// with its options, and its state, shared with other agents. +func TestMCPBridge(t *testing.T) { + bin := buildBinary(t) + daemonEnv(t) + url := startTestDaemon(t, bin, "-no-eval") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + var stderr strings.Builder + cmd := exec.CommandContext(ctx, bin, "mcp", "-listen") + cmd.Stderr = &stderr + bridged, err := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "0"}, nil). + Connect(ctx, &mcp.CommandTransport{Command: cmd}, nil) + if err != nil { + t.Fatal(err) + } + direct, err := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "0"}, nil). + Connect(ctx, &mcp.StreamableClientTransport{Endpoint: url}, nil) + if err != nil { + t.Fatal(err) + } + defer direct.Close() + + list, err := bridged.ListTools(ctx, nil) + if err != nil { + t.Fatal(err) + } + for _, tool := range list.Tools { + if tool.Name == "eval" { + t.Error("eval offered: the background server was started with -no-eval") + } + } + var added addBreakpointOutput + callTool(t, bridged, "add_breakpoint", map[string]any{"file": "/app/index.php", "line": 3}, &added) + var status statusOutput + callTool(t, direct, "status", nil, &status) + if len(status.Breakpoints) != 1 || status.Listening { + t.Errorf("status seen by another agent = %+v; want the breakpoint, and -listen ignored", status) + } + + if err := bridged.Close(); err != nil { + t.Errorf("close: %v", err) + } + if !strings.Contains(stderr.String(), "using the background server at "+url) || !strings.Contains(stderr.String(), "-listen ignored") { + t.Errorf("stderr: %q", stderr.String()) + } + // The background server carries on after the agent leaves. + callTool(t, direct, "status", nil, &status) +} diff --git a/cmd/dbgp/mcp_daemon_unix.go b/cmd/dbgp/mcp_daemon_unix.go new file mode 100644 index 0000000..a4712a7 --- /dev/null +++ b/cmd/dbgp/mcp_daemon_unix.go @@ -0,0 +1,20 @@ +//go:build !windows + +package main + +import ( + "os" + "os/exec" + "syscall" +) + +// detach runs cmd in a session of its own, so it outlives the terminal +// that started it. +func detach(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} +} + +// terminate asks the server to stop, which it does cleanly. +func terminate(p *os.Process) error { + return p.Signal(syscall.SIGTERM) +} diff --git a/cmd/dbgp/mcp_daemon_windows.go b/cmd/dbgp/mcp_daemon_windows.go new file mode 100644 index 0000000..706fbe3 --- /dev/null +++ b/cmd/dbgp/mcp_daemon_windows.go @@ -0,0 +1,25 @@ +package main + +import ( + "os" + "os/exec" + "syscall" +) + +// detachedProcess is DETACHED_PROCESS: the process has no console. +const detachedProcess = 0x00000008 + +// detach runs cmd without a console and outside the console's process +// group, so it outlives the terminal that started it. +func detach(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{ + CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP | detachedProcess, + HideWindow: true, + } +} + +// terminate stops the server. Windows cannot signal another process, so it +// is killed. +func terminate(p *os.Process) error { + return p.Kill() +} diff --git a/cmd/dbgp/mcp_e2e_test.go b/cmd/dbgp/mcp_e2e_test.go index 52e3ccb..3345644 100644 --- a/cmd/dbgp/mcp_e2e_test.go +++ b/cmd/dbgp/mcp_e2e_test.go @@ -11,8 +11,9 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" ) -// A whole debugging run through the dbgp binary over stdio, as an agent -// does it: from listening to a finished script and nothing left behind. +// A whole debugging run as an agent does it, through dbgp mcp passing it on +// to the background server: from listening to a finished script and nothing +// left behind. func TestMCPEndToEnd(t *testing.T) { requireEngine(t) bin := buildBinary(t) @@ -22,7 +23,9 @@ func TestMCPEndToEnd(t *testing.T) { } ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() - transport := &mcp.CommandTransport{Command: exec.CommandContext(ctx, bin, "mcp", "-addr", "127.0.0.1:0")} + daemonEnv(t) + startTestDaemon(t, bin, "-addr", "127.0.0.1:0") + transport := &mcp.CommandTransport{Command: exec.CommandContext(ctx, bin, "mcp")} cs, err := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "0"}, nil).Connect(ctx, transport, nil) if err != nil { t.Fatal(err) diff --git a/cmd/dbgp/mcp_test.go b/cmd/dbgp/mcp_test.go index bb523ad..c3cdf53 100644 --- a/cmd/dbgp/mcp_test.go +++ b/cmd/dbgp/mcp_test.go @@ -253,6 +253,7 @@ func buildBinary(t *testing.T) string { func TestMCPBinary(t *testing.T) { bin := buildBinary(t) + daemonEnv(t) if out, _ := exec.Command(bin, "mcp", "-h").CombinedOutput(); !strings.Contains(string(out), "Usage: dbgp mcp [options]") { t.Errorf("mcp -h output:\n%s", out)