Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 39 additions & 4 deletions cmd/mcp-sim/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import (
"os"
"os/signal"
"sort"
"strings"
"syscall"
"time"

"github.com/espetro/mcp-sim/controllers/agentdevice"
"github.com/espetro/mcp-sim/internal/config"
Expand Down Expand Up @@ -182,7 +184,16 @@ func serveImpl(prog, listenAddr, configPath string) error {
ctx := applog.WithContext(context.Background(), logger)

registry := core.NewRegistry(logger)
lifecycle := core.NewLifecycle(registry)

timeout, err := parseSessionIdleTimeout(cfg.Server.SessionIdleTimeout)
if err != nil {
return fmt.Errorf("parsing session_idle_timeout: %w", err)
}
sessionManager := core.NewManager(timeout, logger)
lifecycle := core.NewLifecycle(registry, sessionManager)
sessionManager.SetStopper(func(ctx context.Context, platform, target, owner string) error {
return lifecycle.StopDevice(ctx, platform, target, owner)
})

if cfg.Platforms.IOS.Enabled {
iosPlatform, err := ios.New(ctx, cfg.Platforms.IOS)
Expand Down Expand Up @@ -214,7 +225,7 @@ func serveImpl(prog, listenAddr, configPath string) error {
}
}

mcpServer := mcp.NewServer(registry, lifecycle, logger)
mcpServer := mcp.NewServer(registry, lifecycle, sessionManager, logger)

mux := http.NewServeMux()
mux.Handle("/mcp", mcpServer.StreamableHTTPHandler())
Expand All @@ -233,7 +244,9 @@ func serveImpl(prog, listenAddr, configPath string) error {

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
ctx = applog.WithContext(ctx, logger)

go sessionManager.Start(ctx)
go func() {
<-ctx.Done()
logger.Info("shutdown signal received")
Expand All @@ -257,7 +270,16 @@ func mcpImpl(prog, configPath string) error {
ctx := applog.WithContext(context.Background(), logger)

registry := core.NewRegistry(logger)
lifecycle := core.NewLifecycle(registry)

timeout, err := parseSessionIdleTimeout(cfg.Server.SessionIdleTimeout)
if err != nil {
return fmt.Errorf("parsing session_idle_timeout: %w", err)
}
sessionManager := core.NewManager(timeout, logger)
lifecycle := core.NewLifecycle(registry, sessionManager)
sessionManager.SetStopper(func(ctx context.Context, platform, target, owner string) error {
return lifecycle.StopDevice(ctx, platform, target, owner)
})

if cfg.Platforms.IOS.Enabled {
iosPlatform, _ := ios.New(ctx, cfg.Platforms.IOS)
Expand All @@ -277,7 +299,9 @@ func mcpImpl(prog, configPath string) error {
}
}

mcpServer := mcp.NewServer(registry, lifecycle, logger)
go sessionManager.Start(ctx)

mcpServer := mcp.NewServer(registry, lifecycle, sessionManager, logger)
return mcpServer.Run(ctx, &sdkmcp.StdioTransport{})
}

Expand All @@ -301,3 +325,14 @@ func controllerNames(r *core.Registry) []string {
sort.Strings(names)
return names
}

func parseSessionIdleTimeout(v string) (time.Duration, error) {
v = strings.TrimSpace(strings.ToLower(v))
if v == "" {
return 30 * time.Minute, nil
}
if v == "0" || v == "off" {
return 0, nil
}
return time.ParseDuration(v)
}
17 changes: 11 additions & 6 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ type Config struct {

// ServerConfig configures the HTTP server.
type ServerConfig struct {
Listen string `yaml:"listen"` // MCPSIM_LISTEN
LogLevel string `yaml:"log_level"` // MCPSIM_LOG_LEVEL
LogFormat string `yaml:"log_format"` // MCPSIM_LOG_FORMAT
Listen string `yaml:"listen"` // MCPSIM_LISTEN
LogLevel string `yaml:"log_level"` // MCPSIM_LOG_LEVEL
LogFormat string `yaml:"log_format"` // MCPSIM_LOG_FORMAT
SessionIdleTimeout string `yaml:"session_idle_timeout"` // MCPSIM_SESSION_IDLE_TIMEOUT
}

// PlatformsConfig holds per-platform configuration.
Expand Down Expand Up @@ -57,9 +58,10 @@ type AgentDeviceConfig struct {
func defaultConfig() Config {
return Config{
Server: ServerConfig{
Listen: ":9090",
LogLevel: "info",
LogFormat: "text",
Listen: ":9090",
LogLevel: "info",
LogFormat: "text",
SessionIdleTimeout: "30m",
},
Platforms: PlatformsConfig{
IOS: IOSConfig{
Expand Down Expand Up @@ -103,6 +105,9 @@ func Load() (Config, error) {
if v := os.Getenv("MCPSIM_LOG_FORMAT"); v != "" {
cfg.Server.LogFormat = v
}
if v := os.Getenv("MCPSIM_SESSION_IDLE_TIMEOUT"); v != "" {
cfg.Server.SessionIdleTimeout = v
}
if v := os.Getenv("MCPSIM_IOS_ENABLED"); v != "" {
cfg.Platforms.IOS.Enabled, _ = strconv.ParseBool(v)
}
Expand Down
46 changes: 38 additions & 8 deletions internal/core/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,30 @@ import (
// Lifecycle orchestrates boot/shutdown of devices with state management.
type Lifecycle struct {
registry *Registry
sessions *Manager
}

// NewLifecycle creates a new lifecycle orchestrator.
func NewLifecycle(registry *Registry) *Lifecycle {
return &Lifecycle{registry: registry}
func NewLifecycle(registry *Registry, sessions *Manager) *Lifecycle {
return &Lifecycle{registry: registry, sessions: sessions}
}

// BootDevice boots a device, handling already-running and not-found errors.
func (l *Lifecycle) BootDevice(ctx context.Context, platformName, target string, opts contract.StartOpts) (contract.Device, error) {
func (l *Lifecycle) BootDevice(ctx context.Context, platformName, target, sessionID string, opts contract.StartOpts) (dev contract.Device, err error) {
p, ok := l.registry.PlatformByName(platformName)
if !ok {
return contract.Device{}, &ToolError{Code: contract.ErrUnsupportedPlatform, Msg: "platform not found: " + platformName}
}

if err := l.sessions.Reserve(ctx, platformName, target, sessionID); err != nil {
return contract.Device{}, err
}
defer func() {
if err != nil {
l.sessions.Release(platformName, target)
}
}()

state, err := p.State(ctx, target)
if err != nil {
return contract.Device{}, err
Expand All @@ -33,7 +43,7 @@ func (l *Lifecycle) BootDevice(ctx context.Context, platformName, target string,
return contract.Device{}, &ToolError{Code: contract.ErrAlreadyRunning, Msg: "device already running: " + target}
}

dev, err := p.Start(ctx, target, opts)
dev, err = p.Start(ctx, target, opts)
if err != nil {
return contract.Device{}, err
}
Expand All @@ -48,16 +58,21 @@ func (l *Lifecycle) BootDevice(ctx context.Context, platformName, target string,
return dev, &ToolError{Code: contract.ErrTimeout, Msg: "device did not become ready: " + target}
}

l.sessions.RecordActivity(platformName, target)
return dev, nil
}

// StopDevice stops a device.
func (l *Lifecycle) StopDevice(ctx context.Context, platformName, target string) error {
func (l *Lifecycle) StopDevice(ctx context.Context, platformName, target, sessionID string) error {
p, ok := l.registry.PlatformByName(platformName)
if !ok {
return &ToolError{Code: contract.ErrUnsupportedPlatform, Msg: "platform not found: " + platformName}
}

if err := l.sessions.CheckAccess(platformName, target, sessionID); err != nil {
return err
}

state, err := p.State(ctx, target)
if err != nil {
return err
Expand All @@ -67,16 +82,26 @@ func (l *Lifecycle) StopDevice(ctx context.Context, platformName, target string)
return &ToolError{Code: contract.ErrNotRunning, Msg: "device not running: " + target}
}

return p.Stop(ctx, target)
if err := p.Stop(ctx, target); err != nil {
return err
}

l.sessions.Release(platformName, target)
return nil
}

// WipeDevice wipes a device.
func (l *Lifecycle) WipeDevice(ctx context.Context, platformName, target string) error {
func (l *Lifecycle) WipeDevice(ctx context.Context, platformName, target, sessionID string) error {
p, ok := l.registry.PlatformByName(platformName)
if !ok {
return &ToolError{Code: contract.ErrUnsupportedPlatform, Msg: "platform not found: " + platformName}
}

if err := l.sessions.CheckAccess(platformName, target, sessionID); err != nil {
return err
}
defer l.sessions.Release(platformName, target)

state, err := p.State(ctx, target)
if err != nil {
return err
Expand All @@ -92,10 +117,15 @@ func (l *Lifecycle) WipeDevice(ctx context.Context, platformName, target string)
}

// OpenURL opens a deep link on a device.
func (l *Lifecycle) OpenURL(ctx context.Context, platformName, target, url string) error {
func (l *Lifecycle) OpenURL(ctx context.Context, platformName, target, sessionID, url string) error {
p, ok := l.registry.PlatformByName(platformName)
if !ok {
return &ToolError{Code: contract.ErrUnsupportedPlatform, Msg: "platform not found: " + platformName}
}

if err := l.sessions.CheckAccess(platformName, target, sessionID); err != nil {
return err
}
l.sessions.RecordActivity(platformName, target)
return p.OpenURL(ctx, target, url)
}
144 changes: 144 additions & 0 deletions internal/core/lifecycle_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package core

import (
"context"
"errors"
"io"
"log/slog"
"sync"
"testing"
"time"

"github.com/espetro/mcp-sim/pkg/contract"
)

type fakePlatform struct {
mu sync.Mutex
name string
state contract.DeviceState
started bool
stopped bool
}

func (p *fakePlatform) Name() string { return p.name }

func (p *fakePlatform) List(ctx context.Context) ([]contract.Device, error) {
return []contract.Device{{ID: "dev1", Name: "dev1", Platform: p.name, State: p.state}}, nil
}

func (p *fakePlatform) Start(ctx context.Context, target string, opts contract.StartOpts) (contract.Device, error) {
p.mu.Lock()
defer p.mu.Unlock()
p.state = contract.DeviceStateRunning
p.started = true
return contract.Device{ID: target, Name: target, Platform: p.name, State: p.state}, nil
}

func (p *fakePlatform) Stop(ctx context.Context, target string) error {
p.mu.Lock()
defer p.mu.Unlock()
p.state = contract.DeviceStateStopped
p.stopped = true
return nil
}

func (p *fakePlatform) State(ctx context.Context, target string) (contract.DeviceState, error) {
p.mu.Lock()
defer p.mu.Unlock()
return p.state, nil
}

func (p *fakePlatform) AwaitReady(ctx context.Context, target string, timeout time.Duration) error {
return nil
}

func (p *fakePlatform) Wipe(ctx context.Context, target string) error {
p.mu.Lock()
defer p.mu.Unlock()
p.state = contract.DeviceStateStopped
return nil
}

func (p *fakePlatform) OpenURL(ctx context.Context, target, url string) error {
return nil
}

func newTestLifecycle(p contract.Platform) (*Lifecycle, *Manager) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
reg := NewRegistry(logger)
reg.RegisterPlatform(p)
sessions := NewManager(time.Hour, logger)
return NewLifecycle(reg, sessions), sessions
}

func TestBootDeviceReservesAndSecondSessionDenied(t *testing.T) {
fp := &fakePlatform{name: "ios", state: contract.DeviceStateStopped}
lc, sessions := newTestLifecycle(fp)
ctx := context.Background()

if _, err := lc.BootDevice(ctx, "ios", "dev1", "session-a", contract.StartOpts{}); err != nil {
t.Fatalf("BootDevice failed: %v", err)
}

owner, ok := sessions.Owner("ios", "dev1")
if !ok || owner != "session-a" {
t.Fatalf("Owner = (%q, %v), want (session-a, true)", owner, ok)
}

_, err := lc.BootDevice(ctx, "ios", "dev1", "session-b", contract.StartOpts{})
var te *ToolError
if !errors.As(err, &te) || te.Code != contract.ErrDeviceReserved {
t.Fatalf("BootDevice by other session returned %v, want device_reserved", err)
}
}

func TestBootDeviceIdempotentSameSession(t *testing.T) {
fp := &fakePlatform{name: "ios", state: contract.DeviceStateStopped}
lc, _ := newTestLifecycle(fp)
ctx := context.Background()

if _, err := lc.BootDevice(ctx, "ios", "dev1", "session-a", contract.StartOpts{}); err != nil {
t.Fatalf("first BootDevice failed: %v", err)
}
// Second boot by the same session is allowed through the reservation gate.
// Because the fake platform reports running, it returns already_running.
_, err := lc.BootDevice(ctx, "ios", "dev1", "session-a", contract.StartOpts{})
var te *ToolError
if !errors.As(err, &te) || te.Code != contract.ErrAlreadyRunning {
t.Fatalf("second BootDevice returned %v, want already_running", err)
}
}

func TestStopDeviceReleasesReservation(t *testing.T) {
fp := &fakePlatform{name: "ios", state: contract.DeviceStateRunning}
lc, sessions := newTestLifecycle(fp)
ctx := context.Background()

if err := lc.StopDevice(ctx, "ios", "dev1", "session-a"); err != nil {
t.Fatalf("StopDevice failed: %v", err)
}

if _, ok := sessions.Owner("ios", "dev1"); ok {
t.Fatal("reservation should be released after StopDevice")
}

// After release, another session can claim the device.
if _, err := lc.BootDevice(ctx, "ios", "dev1", "session-b", contract.StartOpts{}); err != nil {
t.Fatalf("BootDevice by another session after stop failed: %v", err)
}
}

func TestStopDeviceDeniedForOtherSession(t *testing.T) {
fp := &fakePlatform{name: "ios", state: contract.DeviceStateStopped}
lc, _ := newTestLifecycle(fp)
ctx := context.Background()

// Boot reserves the device to session-a.
if _, err := lc.BootDevice(ctx, "ios", "dev1", "session-a", contract.StartOpts{}); err != nil {
t.Fatalf("BootDevice failed: %v", err)
}

if err := lc.StopDevice(ctx, "ios", "dev1", "session-b"); err == nil {
t.Fatal("StopDevice by other session should fail")
}
}
Loading
Loading