Skip to content
Closed
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
73 changes: 73 additions & 0 deletions mcpserver/roots.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package mcpserver

import (
"context"
"fmt"
"log/slog"
"strings"
"sync"
"time"

"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)

// clientRoots holds the roots provided by the MCP client. Agents frequently
// pass a wrong environment_source; the roots give them a concrete hint about
// which repositories the client actually has open.
//
// This assumes a single client, which may go out the window when we add
// support for streaming http.
var (
clientRoots []mcp.Root
clientRootsMu sync.RWMutex
)

// requestRoots sends a roots/list request to the client and stores the
// result. Errors are logged and swallowed: roots are a hint, never required.
func requestRoots(ctx context.Context, s *server.MCPServer) {
// The ctx of hooks/notification handlers is canceled as soon as the
// triggering message is done processing, which would abort the request
// before the client can answer. Detach cancellation (values like the
// client session are kept) and bound the wait instead.
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
defer cancel()

result, err := s.RequestRoots(ctx, mcp.ListRootsRequest{})
if err != nil {
slog.Info("Failed to request roots from client", "error", err)
return
}

clientRootsMu.Lock()
clientRoots = result.Roots
clientRootsMu.Unlock()

slog.Info("Updated client roots", "count", len(result.Roots))
}

// repoOpenErrorMessage provides helpful error messages when repository
// opening fails, listing the roots the client has open when available.
func repoOpenErrorMessage(source string, originalErr error) error {
baseMsg := fmt.Sprintf("unable to open repository '%s'", source)

clientRootsMu.RLock()
defer clientRootsMu.RUnlock()

if len(clientRoots) > 0 {
baseMsg += "\n\nAvailable roots from client:"
for _, root := range clientRoots {
uri := strings.TrimPrefix(root.URI, "file://")
if root.Name != "" {
baseMsg += fmt.Sprintf("\n - %s (%s)", uri, root.Name)
} else {
baseMsg += fmt.Sprintf("\n - %s", uri)
}
}
return fmt.Errorf("%s: %w", baseMsg, originalErr)
}

// Fallback: suggest common patterns
baseMsg += "\n\nTry using:\n - '.' for current directory\n - An absolute path to your git repository"
return fmt.Errorf("%s: %w", baseMsg, originalErr)
}
57 changes: 57 additions & 0 deletions mcpserver/roots_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package mcpserver

import (
"errors"
"testing"

"github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/assert"
)

func withClientRoots(t *testing.T, roots []mcp.Root) {
t.Helper()
clientRootsMu.Lock()
old := clientRoots
clientRoots = roots
clientRootsMu.Unlock()
t.Cleanup(func() {
clientRootsMu.Lock()
clientRoots = old
clientRootsMu.Unlock()
})
}

func TestRepoOpenErrorMessage_NoRoots(t *testing.T) {
withClientRoots(t, nil)

err := repoOpenErrorMessage("/nonexistent", errors.New("not a git repo"))

assert.ErrorIs(t, err, err)
assert.Contains(t, err.Error(), "unable to open repository '/nonexistent'")
assert.Contains(t, err.Error(), "'.' for current directory")
assert.Contains(t, err.Error(), "not a git repo")
}

func TestRepoOpenErrorMessage_WithRoots(t *testing.T) {
withClientRoots(t, []mcp.Root{
{URI: "file:///home/user/project", Name: "project"},
{URI: "file:///home/user/other"},
})

err := repoOpenErrorMessage("/nonexistent", errors.New("not a git repo"))

assert.Contains(t, err.Error(), "Available roots from client:")
assert.Contains(t, err.Error(), "- /home/user/project (project)")
assert.Contains(t, err.Error(), "- /home/user/other")
assert.NotContains(t, err.Error(), "file://")
assert.Contains(t, err.Error(), "not a git repo")
}

func TestRepoOpenErrorMessage_WrapsOriginal(t *testing.T) {
withClientRoots(t, nil)
original := errors.New("root cause")

err := repoOpenErrorMessage(".", original)

assert.ErrorIs(t, err, original)
}
26 changes: 25 additions & 1 deletion mcpserver/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ func openRepository(ctx context.Context, request mcp.CallToolRequest) (*reposito

repo, err := repository.Open(ctx, source)
if err != nil {
return nil, fmt.Errorf("unable to open repository: %w", err)
return nil, repoOpenErrorMessage(source, err)
}
return repo, nil
}
Expand Down Expand Up @@ -105,12 +105,36 @@ func RunStdioServer(ctx context.Context, dag *dagger.Client, singleTenant bool)
// Store single-tenant mode in context for tool handlers
ctx = context.WithValue(ctx, singleTenantKey{}, singleTenant)

hooks := &server.Hooks{}
s := server.NewMCPServer(
"Dagger",
"1.0.0",
server.WithInstructions(rules.AgentRules),
server.WithHooks(hooks),
)

// Request the client's roots after initialization so we can produce
// helpful error messages when repository opening fails. Async because
// hooks run before the initialize response is written: a synchronous
// roots/list request would deadlock against a client still waiting
// for that response.
hooks.AddAfterInitialize(func(ctx context.Context, id any, message *mcp.InitializeRequest, result *mcp.InitializeResult) {
if message.Params.Capabilities.Roots != nil {
slog.Info("Client supports roots capability", "listChanged", message.Params.Capabilities.Roots.ListChanged)
go requestRoots(ctx, s)
} else {
slog.Info("Client does not support roots capability")
}
})

// Re-request roots when the client tells us they changed. Async for the
// same reason: notifications are processed in the stdio read loop, which
// is also where the client's roots/list response would arrive.
s.AddNotificationHandler(string(mcp.MethodNotificationRootsListChanged), func(ctx context.Context, notification mcp.JSONRPCNotification) {
slog.Info("Received notifications/roots/list_changed from client")
go requestRoots(ctx, s)
})

for _, t := range createTools(singleTenant) {
s.AddTool(t.Definition, wrapToolWithClient(t, dag, singleTenant).Handler)
}
Expand Down
Loading