diff --git a/mcpserver/roots.go b/mcpserver/roots.go new file mode 100644 index 00000000..f292a4e3 --- /dev/null +++ b/mcpserver/roots.go @@ -0,0 +1,102 @@ +package mcpserver + +import ( + "context" + "fmt" + "log/slog" + "strings" + "sync" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// clientRoots holds the roots provided by the client +// 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 +) + +// sendRootsListRequest sends a roots/list request to the client +func sendRootsListRequest(ctx context.Context, session server.ClientSession) { + if session == nil { + return + } + + // Send roots/list request as a notification + // Note: In a proper implementation, this would be a request-response + // but for now we'll send as notification and handle response separately + notification := mcp.JSONRPCNotification{ + JSONRPC: "2.0", + Notification: mcp.Notification{ + Method: "roots/list", + Params: mcp.NotificationParams{}, + }, + } + + select { + case session.NotificationChannel() <- notification: + slog.Info("Requested roots/list from client") + case <-ctx.Done(): + return + } +} + +// updateClientRoots parses roots from notification params and updates the global clientRoots +func updateClientRoots(notification mcp.JSONRPCNotification) int { + if notification.Params.AdditionalFields == nil { + return 0 + } + rootsData, ok := notification.Params.AdditionalFields["roots"].([]any) + if !ok { + return 0 + } + + newRoots := make([]mcp.Root, 0, len(rootsData)) + for _, rootData := range rootsData { + if rootMap, ok := rootData.(map[string]any); ok { + root := mcp.Root{} + if uri, ok := rootMap["uri"].(string); ok { + root.URI = uri + } + if name, ok := rootMap["name"].(string); ok { + root.Name = name + } + newRoots = append(newRoots, root) + } + } + + clientRootsMu.Lock() + clientRoots = newRoots + clientRootsMu.Unlock() + + return len(newRoots) +} + +// repoOpenErrorMessage provides helpful error messages when repository opening fails +func repoOpenErrorMessage(source string, originalErr error) error { + baseMsg := fmt.Sprintf("failed to open repository '%s'", source) + + // If we have client roots, suggest them + clientRootsMu.RLock() + defer clientRootsMu.RUnlock() + + if len(clientRoots) > 0 { + baseMsg += "\n\nAvailable roots from client:" + for _, root := range clientRoots { + uri := root.URI + uri = strings.TrimPrefix(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) +} diff --git a/mcpserver/tools.go b/mcpserver/tools.go index d7331429..9401d7cb 100644 --- a/mcpserver/tools.go +++ b/mcpserver/tools.go @@ -57,12 +57,44 @@ type Tool struct { } func RunStdioServer(ctx context.Context, dag *dagger.Client) error { + // Create hooks to handle initialization and roots + hooks := &server.Hooks{} + + // Add hook to check for roots capability and request initial roots + 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) + + // Get the client session and send roots/list request + if session := server.ClientSessionFromContext(ctx); session != nil { + sendRootsListRequest(ctx, session) + } + } else { + slog.Info("Client does not support roots capability") + } + }) + s := server.NewMCPServer( "Dagger", "1.0.0", server.WithInstructions(rules.AgentRules), + server.WithHooks(hooks), ) + // Add notification handler for roots updates from client + s.AddNotificationHandler("notifications/roots/list_changed", func(ctx context.Context, notification mcp.JSONRPCNotification) { + slog.Info("Received notifications/roots/list_changed from client") + count := updateClientRoots(notification) + slog.Info("Updated client roots", "count", count) + }) + + // Add response handler for roots/list responses + s.AddNotificationHandler("roots/list", func(ctx context.Context, notification mcp.JSONRPCNotification) { + slog.Info("Received roots/list response from client") + count := updateClientRoots(notification) + slog.Info("Updated client roots from response", "count", count) + }) + for _, t := range tools { s.AddTool(t.Definition, wrapToolWithClient(t, dag).Handler) } @@ -241,7 +273,7 @@ var EnvironmentOpenTool = &Tool{ func GetEnvironmentFromSource(ctx context.Context, dag *dagger.Client, source, envID string) (*environment.Environment, error) { repo, err := repository.Open(ctx, source) if err != nil { - return nil, err + return nil, repoOpenErrorMessage(source, err) } env, err := repo.Get(ctx, dag, envID) @@ -320,7 +352,7 @@ You MUST tell the user: To include these changes in the environment, they need t func CreateEnvironment(ctx context.Context, dag *dagger.Client, source, title, explanation string) (*repository.Repository, *environment.Environment, error) { repo, err := repository.Open(ctx, source) if err != nil { - return nil, nil, err + return nil, nil, repoOpenErrorMessage(source, err) } env, err := repo.Create(ctx, dag, title, explanation)