refactor(mcp): align AX DTO defaults and IDE config

Normalize the MCP constructor workspace defaulting to use the actual working directory, and replace the IDE subsystem's functional options with a Config DTO so the codebase stays aligned with AX-style configuration objects.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-03-31 18:02:16 +00:00
parent 5177dc391b
commit 985bc2017f
6 changed files with 61 additions and 66 deletions

2
go.sum
View file

@ -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=

View file

@ -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")
}

View file

@ -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()
}
// 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 }
// WithDefaults fills unset fields with the default development values.
func (c Config) WithDefaults() Config {
if c.LaravelWSURL == "" {
c.LaravelWSURL = "ws://localhost:9876/ws"
}
// WithWorkspaceRoot sets the workspace root directory.
func WithWorkspaceRoot(root string) Option {
return func(c *Config) { c.WorkspaceRoot = root }
if c.WorkspaceRoot == "" {
c.WorkspaceRoot = "."
}
// WithReconnectInterval sets the base reconnect interval.
func WithReconnectInterval(d time.Duration) Option {
return func(c *Config) { c.ReconnectInterval = d }
if c.ReconnectInterval == 0 {
c.ReconnectInterval = 2 * time.Second
}
// WithToken sets the Bearer token for WebSocket authentication.
func WithToken(token string) Option {
return func(c *Config) { c.Token = token }
if c.MaxReconnectInterval == 0 {
c.MaxReconnectInterval = 30 * time.Second
}
return c
}

View file

@ -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)

View file

@ -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)

View file

@ -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)
}
s.workspaceRoot = root
m, merr := io.NewSandboxed(root)
root = cwd
}
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 {
@ -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 == "" {