mcp/pkg/mcp/tools_process_ci_test.go
Snider 8021475bf5 refactor(mcp): Options{} struct + notification broadcasting + claude/channel
Phase 1: Replace functional options (WithWorkspaceRoot, WithSubsystem,
WithProcessService, WithWSHub) with Options{} struct. Breaking change
for consumers (agent, ide).

Phase 2: Add notification broadcasting and claude/channel capability.
- SendNotificationToAllClients: broadcasts to all MCP sessions
- ChannelSend: push named events (agent.complete, build.failed, etc.)
- ChannelSendToSession: push to a specific session
- Notifier interface for sub-packages to avoid circular imports
- claude/channel registered as experimental MCP capability

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-21 13:51:14 +00:00

521 lines
14 KiB
Go

package mcp
import (
"context"
"strings"
"testing"
"time"
"dappco.re/go/core"
"forge.lthn.ai/core/go-process"
)
// newTestProcessService creates a real process.Service backed by a core.Core for CI tests.
func newTestProcessService(t *testing.T) *process.Service {
t.Helper()
c := core.New()
raw, err := process.NewService(process.Options{})(c)
if err != nil {
t.Fatalf("Failed to create process service: %v", err)
}
svc := raw.(*process.Service)
resultFrom := func(err error) core.Result {
if err != nil {
return core.Result{Value: err}
}
return core.Result{OK: true}
}
c.Service("process", core.Service{
OnStart: func() core.Result { return resultFrom(svc.OnStartup(context.Background())) },
OnStop: func() core.Result { return resultFrom(svc.OnShutdown(context.Background())) },
})
if r := c.ServiceStartup(context.Background(), nil); !r.OK {
t.Fatalf("Failed to start core: %v", r.Value)
}
t.Cleanup(func() { c.ServiceShutdown(context.Background()) })
return svc
}
// newTestMCPWithProcess creates an MCP Service wired to a real process.Service.
func newTestMCPWithProcess(t *testing.T) (*Service, *process.Service) {
t.Helper()
ps := newTestProcessService(t)
s, err := New(Options{ProcessService: ps})
if err != nil {
t.Fatalf("Failed to create MCP service: %v", err)
}
return s, ps
}
// --- CI-safe handler tests ---
// TestProcessStart_Good_Echo starts "echo hello" and verifies the output.
func TestProcessStart_Good_Echo(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
_, out, err := s.processStart(ctx, nil, ProcessStartInput{
Command: "echo",
Args: []string{"hello"},
})
if err != nil {
t.Fatalf("processStart failed: %v", err)
}
if out.ID == "" {
t.Error("Expected non-empty process ID")
}
if out.Command != "echo" {
t.Errorf("Expected command 'echo', got %q", out.Command)
}
if out.PID <= 0 {
t.Errorf("Expected positive PID, got %d", out.PID)
}
if out.StartedAt.IsZero() {
t.Error("Expected non-zero StartedAt")
}
}
// TestProcessStart_Bad_EmptyCommand verifies empty command returns an error.
func TestProcessStart_Bad_EmptyCommand(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
_, _, err := s.processStart(ctx, nil, ProcessStartInput{})
if err == nil {
t.Fatal("Expected error for empty command")
}
if !strings.Contains(err.Error(), "command cannot be empty") {
t.Errorf("Unexpected error: %v", err)
}
}
// TestProcessStart_Bad_NonexistentCommand verifies an invalid command returns an error.
func TestProcessStart_Bad_NonexistentCommand(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
_, _, err := s.processStart(ctx, nil, ProcessStartInput{
Command: "/nonexistent/binary/that/does/not/exist",
})
if err == nil {
t.Fatal("Expected error for nonexistent command")
}
}
// TestProcessList_Good_Empty verifies list is empty initially.
func TestProcessList_Good_Empty(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
_, out, err := s.processList(ctx, nil, ProcessListInput{})
if err != nil {
t.Fatalf("processList failed: %v", err)
}
if out.Total != 0 {
t.Errorf("Expected 0 processes, got %d", out.Total)
}
}
// TestProcessList_Good_AfterStart verifies a started process appears in list.
func TestProcessList_Good_AfterStart(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
// Start a short-lived process
_, startOut, err := s.processStart(ctx, nil, ProcessStartInput{
Command: "echo",
Args: []string{"listing"},
})
if err != nil {
t.Fatalf("processStart failed: %v", err)
}
// Give it a moment to register
time.Sleep(50 * time.Millisecond)
// List all processes (including exited)
_, listOut, err := s.processList(ctx, nil, ProcessListInput{})
if err != nil {
t.Fatalf("processList failed: %v", err)
}
if listOut.Total < 1 {
t.Fatalf("Expected at least 1 process, got %d", listOut.Total)
}
found := false
for _, p := range listOut.Processes {
if p.ID == startOut.ID {
found = true
if p.Command != "echo" {
t.Errorf("Expected command 'echo', got %q", p.Command)
}
}
}
if !found {
t.Errorf("Process %s not found in list", startOut.ID)
}
}
// TestProcessList_Good_RunningOnly verifies filtering for running-only processes.
func TestProcessList_Good_RunningOnly(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
// Start a process that exits quickly
_, _, err := s.processStart(ctx, nil, ProcessStartInput{
Command: "echo",
Args: []string{"done"},
})
if err != nil {
t.Fatalf("processStart failed: %v", err)
}
// Wait for it to exit
time.Sleep(100 * time.Millisecond)
// Running-only should be empty now
_, listOut, err := s.processList(ctx, nil, ProcessListInput{RunningOnly: true})
if err != nil {
t.Fatalf("processList failed: %v", err)
}
if listOut.Total != 0 {
t.Errorf("Expected 0 running processes after echo exits, got %d", listOut.Total)
}
}
// TestProcessOutput_Good_Echo verifies output capture from echo.
func TestProcessOutput_Good_Echo(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
_, startOut, err := s.processStart(ctx, nil, ProcessStartInput{
Command: "echo",
Args: []string{"output_test"},
})
if err != nil {
t.Fatalf("processStart failed: %v", err)
}
// Wait for process to complete and output to be captured
time.Sleep(200 * time.Millisecond)
_, outputOut, err := s.processOutput(ctx, nil, ProcessOutputInput{ID: startOut.ID})
if err != nil {
t.Fatalf("processOutput failed: %v", err)
}
if !strings.Contains(outputOut.Output, "output_test") {
t.Errorf("Expected output to contain 'output_test', got %q", outputOut.Output)
}
}
// TestProcessOutput_Bad_EmptyID verifies empty ID returns error.
func TestProcessOutput_Bad_EmptyID(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
_, _, err := s.processOutput(ctx, nil, ProcessOutputInput{})
if err == nil {
t.Fatal("Expected error for empty ID")
}
if !strings.Contains(err.Error(), "id cannot be empty") {
t.Errorf("Unexpected error: %v", err)
}
}
// TestProcessOutput_Bad_NotFound verifies nonexistent ID returns error.
func TestProcessOutput_Bad_NotFound(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
_, _, err := s.processOutput(ctx, nil, ProcessOutputInput{ID: "nonexistent-id"})
if err == nil {
t.Fatal("Expected error for nonexistent ID")
}
}
// TestProcessStop_Good_LongRunning starts a sleep, stops it, and verifies.
func TestProcessStop_Good_LongRunning(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
// Start a process that sleeps for a while
_, startOut, err := s.processStart(ctx, nil, ProcessStartInput{
Command: "sleep",
Args: []string{"10"},
})
if err != nil {
t.Fatalf("processStart failed: %v", err)
}
// Verify it's running
time.Sleep(50 * time.Millisecond)
_, listOut, _ := s.processList(ctx, nil, ProcessListInput{RunningOnly: true})
if listOut.Total < 1 {
t.Fatal("Expected at least 1 running process")
}
// Stop it
_, stopOut, err := s.processStop(ctx, nil, ProcessStopInput{ID: startOut.ID})
if err != nil {
t.Fatalf("processStop failed: %v", err)
}
if !stopOut.Success {
t.Error("Expected stop to succeed")
}
if stopOut.ID != startOut.ID {
t.Errorf("Expected ID %q, got %q", startOut.ID, stopOut.ID)
}
}
// TestProcessStop_Bad_EmptyID verifies empty ID returns error.
func TestProcessStop_Bad_EmptyID(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
_, _, err := s.processStop(ctx, nil, ProcessStopInput{})
if err == nil {
t.Fatal("Expected error for empty ID")
}
}
// TestProcessStop_Bad_NotFound verifies nonexistent ID returns error.
func TestProcessStop_Bad_NotFound(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
_, _, err := s.processStop(ctx, nil, ProcessStopInput{ID: "nonexistent-id"})
if err == nil {
t.Fatal("Expected error for nonexistent ID")
}
}
// TestProcessKill_Good_LongRunning starts a sleep, kills it, and verifies.
func TestProcessKill_Good_LongRunning(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
_, startOut, err := s.processStart(ctx, nil, ProcessStartInput{
Command: "sleep",
Args: []string{"10"},
})
if err != nil {
t.Fatalf("processStart failed: %v", err)
}
time.Sleep(50 * time.Millisecond)
_, killOut, err := s.processKill(ctx, nil, ProcessKillInput{ID: startOut.ID})
if err != nil {
t.Fatalf("processKill failed: %v", err)
}
if !killOut.Success {
t.Error("Expected kill to succeed")
}
if killOut.Message != "Process killed" {
t.Errorf("Expected message 'Process killed', got %q", killOut.Message)
}
}
// TestProcessKill_Bad_EmptyID verifies empty ID returns error.
func TestProcessKill_Bad_EmptyID(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
_, _, err := s.processKill(ctx, nil, ProcessKillInput{})
if err == nil {
t.Fatal("Expected error for empty ID")
}
}
// TestProcessKill_Bad_NotFound verifies nonexistent ID returns error.
func TestProcessKill_Bad_NotFound(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
_, _, err := s.processKill(ctx, nil, ProcessKillInput{ID: "nonexistent-id"})
if err == nil {
t.Fatal("Expected error for nonexistent ID")
}
}
// TestProcessInput_Bad_EmptyID verifies empty ID returns error.
func TestProcessInput_Bad_EmptyID(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
_, _, err := s.processInput(ctx, nil, ProcessInputInput{})
if err == nil {
t.Fatal("Expected error for empty ID")
}
}
// TestProcessInput_Bad_EmptyInput verifies empty input string returns error.
func TestProcessInput_Bad_EmptyInput(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
_, _, err := s.processInput(ctx, nil, ProcessInputInput{ID: "some-id"})
if err == nil {
t.Fatal("Expected error for empty input")
}
}
// TestProcessInput_Bad_NotFound verifies nonexistent process ID returns error.
func TestProcessInput_Bad_NotFound(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
_, _, err := s.processInput(ctx, nil, ProcessInputInput{
ID: "nonexistent-id",
Input: "hello\n",
})
if err == nil {
t.Fatal("Expected error for nonexistent ID")
}
}
// TestProcessInput_Good_Cat sends input to cat and reads it back.
func TestProcessInput_Good_Cat(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
// Start cat which reads stdin and echoes to stdout
_, startOut, err := s.processStart(ctx, nil, ProcessStartInput{
Command: "cat",
})
if err != nil {
t.Fatalf("processStart failed: %v", err)
}
time.Sleep(50 * time.Millisecond)
// Send input
_, inputOut, err := s.processInput(ctx, nil, ProcessInputInput{
ID: startOut.ID,
Input: "stdin_test\n",
})
if err != nil {
t.Fatalf("processInput failed: %v", err)
}
if !inputOut.Success {
t.Error("Expected input to succeed")
}
// Wait for output capture
time.Sleep(100 * time.Millisecond)
// Read output
_, outputOut, err := s.processOutput(ctx, nil, ProcessOutputInput{ID: startOut.ID})
if err != nil {
t.Fatalf("processOutput failed: %v", err)
}
if !strings.Contains(outputOut.Output, "stdin_test") {
t.Errorf("Expected output to contain 'stdin_test', got %q", outputOut.Output)
}
// Kill the cat process (it's still running)
_, _, _ = s.processKill(ctx, nil, ProcessKillInput{ID: startOut.ID})
}
// TestProcessStart_Good_WithDir verifies working directory is respected.
func TestProcessStart_Good_WithDir(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
dir := t.TempDir()
_, startOut, err := s.processStart(ctx, nil, ProcessStartInput{
Command: "pwd",
Dir: dir,
})
if err != nil {
t.Fatalf("processStart failed: %v", err)
}
time.Sleep(200 * time.Millisecond)
_, outputOut, err := s.processOutput(ctx, nil, ProcessOutputInput{ID: startOut.ID})
if err != nil {
t.Fatalf("processOutput failed: %v", err)
}
if !strings.Contains(outputOut.Output, dir) {
t.Errorf("Expected output to contain dir %q, got %q", dir, outputOut.Output)
}
}
// TestProcessStart_Good_WithEnv verifies environment variables are passed.
func TestProcessStart_Good_WithEnv(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
_, startOut, err := s.processStart(ctx, nil, ProcessStartInput{
Command: "env",
Env: []string{"TEST_MCP_VAR=hello_from_test"},
})
if err != nil {
t.Fatalf("processStart failed: %v", err)
}
time.Sleep(200 * time.Millisecond)
_, outputOut, err := s.processOutput(ctx, nil, ProcessOutputInput{ID: startOut.ID})
if err != nil {
t.Fatalf("processOutput failed: %v", err)
}
if !strings.Contains(outputOut.Output, "TEST_MCP_VAR=hello_from_test") {
t.Errorf("Expected output to contain env var, got %q", outputOut.Output)
}
}
// TestProcessToolsRegistered_Good_WithService verifies tools are registered when service is provided.
func TestProcessToolsRegistered_Good_WithService(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
if s.processService == nil {
t.Error("Expected process service to be set")
}
}
// TestProcessFullLifecycle_Good tests the start → list → output → kill → list cycle.
func TestProcessFullLifecycle_Good(t *testing.T) {
s, _ := newTestMCPWithProcess(t)
ctx := context.Background()
// 1. Start
_, startOut, err := s.processStart(ctx, nil, ProcessStartInput{
Command: "sleep",
Args: []string{"10"},
})
if err != nil {
t.Fatalf("processStart failed: %v", err)
}
time.Sleep(50 * time.Millisecond)
// 2. List (should be running)
_, listOut, _ := s.processList(ctx, nil, ProcessListInput{RunningOnly: true})
if listOut.Total < 1 {
t.Fatal("Expected at least 1 running process")
}
// 3. Kill
_, killOut, err := s.processKill(ctx, nil, ProcessKillInput{ID: startOut.ID})
if err != nil {
t.Fatalf("processKill failed: %v", err)
}
if !killOut.Success {
t.Error("Expected kill to succeed")
}
// 4. Wait for exit
time.Sleep(100 * time.Millisecond)
// 5. Should not be running anymore
_, listOut, _ = s.processList(ctx, nil, ProcessListInput{RunningOnly: true})
for _, p := range listOut.Processes {
if p.ID == startOut.ID {
t.Errorf("Process %s should not be running after kill", startOut.ID)
}
}
}