diff --git a/go.sum b/go.sum index 8d5eb67..a533655 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -dappco.re/go/core v0.4.7 h1:KmIA/2lo6rl1NMtLrKqCWfMlUqpDZYH3q0/d10dTtGA= -dappco.re/go/core v0.4.7/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk= dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= forge.lthn.ai/core/api v0.1.5 h1:NwZrcOyBjaiz5/cn0n0tnlMUodi8Or6FHMx59C7Kv2o= diff --git a/pkg/mcp/ide/bridge_test.go b/pkg/mcp/ide/bridge_test.go index f1e3881..ad51959 100644 --- a/pkg/mcp/ide/bridge_test.go +++ b/pkg/mcp/ide/bridge_test.go @@ -412,11 +412,10 @@ func TestBridge_Good_NoAuthHeaderWhenTokenEmpty(t *testing.T) { } } -func TestBridge_Good_WithTokenOption(t *testing.T) { - // Verify the WithToken option function works. +func TestBridge_Good_ConfigToken(t *testing.T) { + // Verify the Config DTO carries token settings through unchanged. cfg := DefaultConfig() - opt := WithToken("my-token") - opt(&cfg) + cfg.Token = "my-token" if cfg.Token != "my-token" { t.Errorf("expected token 'my-token', got %q", cfg.Token) @@ -424,14 +423,14 @@ func TestBridge_Good_WithTokenOption(t *testing.T) { } func TestSubsystem_Good_Name(t *testing.T) { - sub := New(nil) + sub := New(nil, Config{}) if sub.Name() != "ide" { t.Errorf("expected name 'ide', got %q", sub.Name()) } } func TestSubsystem_Good_NilHub(t *testing.T) { - sub := New(nil) + sub := New(nil, Config{}) if sub.Bridge() != nil { t.Error("expected nil bridge when hub is nil") } diff --git a/pkg/mcp/ide/config.go b/pkg/mcp/ide/config.go index ff64419..8511687 100644 --- a/pkg/mcp/ide/config.go +++ b/pkg/mcp/ide/config.go @@ -25,33 +25,22 @@ type Config struct { // DefaultConfig returns sensible defaults for local development. func DefaultConfig() Config { - return Config{ - LaravelWSURL: "ws://localhost:9876/ws", - WorkspaceRoot: ".", - ReconnectInterval: 2 * time.Second, - MaxReconnectInterval: 30 * time.Second, + return Config{}.WithDefaults() +} + +// WithDefaults fills unset fields with the default development values. +func (c Config) WithDefaults() Config { + if c.LaravelWSURL == "" { + c.LaravelWSURL = "ws://localhost:9876/ws" } -} - -// Option configures the IDE subsystem. -type Option func(*Config) - -// WithLaravelURL sets the Laravel WebSocket endpoint. -func WithLaravelURL(url string) Option { - return func(c *Config) { c.LaravelWSURL = url } -} - -// WithWorkspaceRoot sets the workspace root directory. -func WithWorkspaceRoot(root string) Option { - return func(c *Config) { c.WorkspaceRoot = root } -} - -// WithReconnectInterval sets the base reconnect interval. -func WithReconnectInterval(d time.Duration) Option { - return func(c *Config) { c.ReconnectInterval = d } -} - -// WithToken sets the Bearer token for WebSocket authentication. -func WithToken(token string) Option { - return func(c *Config) { c.Token = token } + if c.WorkspaceRoot == "" { + c.WorkspaceRoot = "." + } + if c.ReconnectInterval == 0 { + c.ReconnectInterval = 2 * time.Second + } + if c.MaxReconnectInterval == 0 { + c.MaxReconnectInterval = 30 * time.Second + } + return c } diff --git a/pkg/mcp/ide/ide.go b/pkg/mcp/ide/ide.go index 64c4274..c4f7565 100644 --- a/pkg/mcp/ide/ide.go +++ b/pkg/mcp/ide/ide.go @@ -19,13 +19,12 @@ type Subsystem struct { hub *ws.Hub } -// New creates an IDE subsystem. The ws.Hub is used for real-time forwarding; -// pass nil if headless (tools still work but real-time streaming is disabled). -func New(hub *ws.Hub, opts ...Option) *Subsystem { - cfg := DefaultConfig() - for _, opt := range opts { - opt(&cfg) - } +// New creates an IDE subsystem from a Config DTO. +// +// The ws.Hub is used for real-time forwarding; pass nil if headless +// (tools still work but real-time streaming is disabled). +func New(hub *ws.Hub, cfg Config) *Subsystem { + cfg = cfg.WithDefaults() var bridge *Bridge if hub != nil { bridge = NewBridge(hub, cfg) diff --git a/pkg/mcp/ide/tools_test.go b/pkg/mcp/ide/tools_test.go index 21a01fa..ae74eae 100644 --- a/pkg/mcp/ide/tools_test.go +++ b/pkg/mcp/ide/tools_test.go @@ -15,7 +15,7 @@ import ( // newNilBridgeSubsystem returns a Subsystem with no hub/bridge (headless mode). func newNilBridgeSubsystem() *Subsystem { - return New(nil) + return New(nil, Config{}) } // newConnectedSubsystem returns a Subsystem with a connected bridge and a @@ -42,10 +42,10 @@ func newConnectedSubsystem(t *testing.T) (*Subsystem, context.CancelFunc, *httpt ctx, cancel := context.WithCancel(context.Background()) go hub.Run(ctx) - sub := New(hub, - WithLaravelURL(wsURL(ts)), - WithReconnectInterval(50*time.Millisecond), - ) + sub := New(hub, Config{ + LaravelWSURL: wsURL(ts), + ReconnectInterval: 50 * time.Millisecond, + }) sub.StartBridge(ctx) waitConnected(t, sub.Bridge(), 2*time.Second) @@ -690,7 +690,7 @@ func TestSubsystem_Good_RegisterTools(t *testing.T) { // RegisterTools requires a real mcp.Server which is complex to construct // in isolation. This test verifies the Subsystem can be created and // the Bridge/Shutdown path works end-to-end. - sub := New(nil) + sub := New(nil, Config{}) if sub.Bridge() != nil { t.Error("expected nil bridge with nil hub") } @@ -701,20 +701,20 @@ func TestSubsystem_Good_RegisterTools(t *testing.T) { // TestSubsystem_Good_StartBridgeNilHub verifies StartBridge is a no-op with nil hub. func TestSubsystem_Good_StartBridgeNilHub(t *testing.T) { - sub := New(nil) + sub := New(nil, Config{}) // Should not panic sub.StartBridge(context.Background()) } -// TestSubsystem_Good_WithOptions verifies all config options apply correctly. -func TestSubsystem_Good_WithOptions(t *testing.T) { +// TestSubsystem_Good_WithConfig verifies the Config DTO applies correctly. +func TestSubsystem_Good_WithConfig(t *testing.T) { hub := ws.NewHub() - sub := New(hub, - WithLaravelURL("ws://custom:1234/ws"), - WithWorkspaceRoot("/tmp/test"), - WithReconnectInterval(5*time.Second), - WithToken("secret-123"), - ) + sub := New(hub, Config{ + LaravelWSURL: "ws://custom:1234/ws", + WorkspaceRoot: "/tmp/test", + ReconnectInterval: 5 * time.Second, + Token: "secret-123", + }) if sub.cfg.LaravelWSURL != "ws://custom:1234/ws" { t.Errorf("expected custom URL, got %q", sub.cfg.LaravelWSURL) @@ -761,7 +761,10 @@ func TestChatSend_Good_BridgeMessageType(t *testing.T) { ctx := t.Context() go hub.Run(ctx) - sub := New(hub, WithLaravelURL(wsURL(ts)), WithReconnectInterval(50*time.Millisecond)) + sub := New(hub, Config{ + LaravelWSURL: wsURL(ts), + ReconnectInterval: 50 * time.Millisecond, + }) sub.StartBridge(ctx) waitConnected(t, sub.Bridge(), 2*time.Second) diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go index 785fe9c..7541a20 100644 --- a/pkg/mcp/mcp.go +++ b/pkg/mcp/mcp.go @@ -9,6 +9,7 @@ import ( "iter" "net/http" "os" + "path/filepath" "slices" "sync" @@ -93,10 +94,18 @@ func New(opts Options) (*Service, error) { } else { root := opts.WorkspaceRoot if root == "" { - root = core.Env("DIR_CWD") + cwd, err := os.Getwd() + if err != nil { + return nil, core.E("mcp.New", "failed to get working directory", err) + } + root = cwd } - s.workspaceRoot = root - m, merr := io.NewSandboxed(root) + abs, err := filepath.Abs(root) + if err != nil { + return nil, core.E("mcp.New", "failed to resolve workspace root", err) + } + s.workspaceRoot = abs + m, merr := io.NewSandboxed(abs) if merr != nil { return nil, core.E("mcp.New", "failed to create workspace medium", merr) } @@ -177,7 +186,6 @@ func (s *Service) Shutdown(ctx context.Context) error { return nil } - // WSHub returns the WebSocket hub, or nil if not configured. // // if hub := svc.WSHub(); hub != nil { @@ -423,8 +431,8 @@ type LanguageInfo struct { // } type EditDiffInput struct { Path string `json:"path"` // e.g. "main.go" - OldString string `json:"old_string"` // text to find - NewString string `json:"new_string"` // replacement text + OldString string `json:"old_string"` // text to find + NewString string `json:"new_string"` // replacement text ReplaceAll bool `json:"replace_all,omitempty"` // replace all occurrences (default: first only) } @@ -668,7 +676,6 @@ func (s *Service) Run(ctx context.Context) error { }) } - // countOccurrences counts non-overlapping instances of substr in s. func countOccurrences(s, substr string) int { if substr == "" {