feat(mcp): resources, read/list resource tools, server logs

Carlos Alexandro Becker created

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

Change summary

internal/agent/coordinator.go             |  4 +
internal/agent/tools/list_mcp_resource.md |  5 +
internal/agent/tools/mcp/init.go          | 43 ++++++++++++-
internal/agent/tools/mcp/resources.go     | 78 +++++++++++++++++++++++++
internal/agent/tools/mcp/tools.go         |  6 
internal/agent/tools/read_mcp_resource.md |  6 +
internal/agent/tools/resource.go          | 74 +++++++++++++++++++++++
internal/config/config.go                 |  2 
internal/tui/tui.go                       |  9 ++
9 files changed, 219 insertions(+), 8 deletions(-)

Detailed changes

internal/agent/coordinator.go 🔗

@@ -337,6 +337,10 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
 		allTools = append(allTools, tools.NewDiagnosticsTool(c.lspClients), tools.NewReferencesTool(c.lspClients))
 	}
 
+	if len(c.cfg.MCP) > 0 {
+		allTools = append(allTools, tools.NewReadMCPResourceTool(), tools.NewListMCPResourceTool())
+	}
+
 	var filteredTools []fantasy.AgentTool
 	for _, tool := range allTools {
 		if slices.Contains(agent.AllowedTools, tool.Info().Name) {

internal/agent/tools/mcp/init.go 🔗

@@ -64,6 +64,7 @@ const (
 	EventStateChanged EventType = iota
 	EventToolsListChanged
 	EventPromptsListChanged
+	EventResourcesListChanged
 )
 
 // Event represents an event in the MCP system
@@ -77,8 +78,9 @@ type Event struct {
 
 // Counts number of available tools, prompts, etc.
 type Counts struct {
-	Tools   int
-	Prompts int
+	Tools     int
+	Prompts   int
+	Resources int
 }
 
 // ClientInfo holds information about an MCP client's state
@@ -178,13 +180,23 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config
 				return
 			}
 
+			resources, err := getResources(ctx, session)
+			if err != nil {
+				slog.Error("error listing resources", "error", err)
+				updateState(name, StateError, err, nil, Counts{})
+				session.Close()
+				return
+			}
+
 			updateTools(name, tools)
 			updatePrompts(name, prompts)
+			updateResources(name, resources)
 			sessions.Set(name, session)
 
 			updateState(name, StateConnected, nil, session, Counts{
-				Tools:   len(tools),
-				Prompts: len(prompts),
+				Tools:     len(tools),
+				Prompts:   len(prompts),
+				Resources: len(resources),
 			})
 		}(name, m)
 	}
@@ -281,8 +293,14 @@ func createSession(ctx context.Context, name string, m config.MCPConfig, resolve
 					Name: name,
 				})
 			},
+			ResourceListChangedHandler: func(context.Context, *mcp.ResourceListChangedRequest) {
+				broker.Publish(pubsub.UpdatedEvent, Event{
+					Type: EventResourcesListChanged,
+					Name: name,
+				})
+			},
 			LoggingMessageHandler: func(_ context.Context, req *mcp.LoggingMessageRequest) {
-				slog.Info("mcp log", "name", name, "data", req.Params.Data)
+				slog.Log(ctx, parseLevel(req.Params.Level), "mcp server log", "data", req.Params.Data)
 			},
 			KeepAlive: time.Minute * 10,
 		},
@@ -303,6 +321,21 @@ func createSession(ctx context.Context, name string, m config.MCPConfig, resolve
 	return session, nil
 }
 
+func parseLevel(level mcp.LoggingLevel) slog.Level {
+	switch level {
+	case "debug":
+		return slog.LevelDebug
+	case "info", "notice":
+		return slog.LevelInfo
+	case "warning", "warn":
+		return slog.LevelWarn
+	case "error", "emergency", "alert", "fatal":
+		fallthrough
+	default:
+		return slog.LevelError
+	}
+}
+
 // maybeStdioErr if a stdio mcp prints an error in non-json format, it'll fail
 // to parse, and the cli will then close it, causing the EOF error.
 // so, if we got an EOF err, and the transport is STDIO, we try to exec it

internal/agent/tools/mcp/resources.go 🔗

@@ -0,0 +1,78 @@
+package mcp
+
+import (
+	"context"
+	"iter"
+	"log/slog"
+
+	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+type (
+	Resource         = mcp.Resource
+	ResourceContents = mcp.ResourceContents
+)
+
+var allResources = csync.NewMap[string, []*Resource]()
+
+// Resources returns all available MCP resources.
+func Resources() iter.Seq2[string, []*Resource] {
+	return allResources.Seq2()
+}
+
+// ReadResource retrieves the content of an MCP resource with the given arguments.
+func ReadResource(ctx context.Context, clientName, uri string) ([]*ResourceContents, error) {
+	c, err := getOrRenewClient(ctx, clientName)
+	if err != nil {
+		return nil, err
+	}
+	result, err := c.ReadResource(ctx, &mcp.ReadResourceParams{
+		URI: uri,
+	})
+	if err != nil {
+		return nil, err
+	}
+	return result.Contents, nil
+}
+
+// RefreshResources gets the updated list of resources from the MCP and updates the
+// global state.
+func RefreshResources(ctx context.Context, name string) {
+	session, ok := sessions.Get(name)
+	if !ok {
+		slog.Warn("refresh resources: no session", "name", name)
+		return
+	}
+
+	resources, err := getResources(ctx, session)
+	if err != nil {
+		updateState(name, StateError, err, nil, Counts{})
+		return
+	}
+
+	updateResources(name, resources)
+
+	prev, _ := states.Get(name)
+	prev.Counts.Resources = len(resources)
+	updateState(name, StateConnected, nil, session, prev.Counts)
+}
+
+func getResources(ctx context.Context, c *mcp.ClientSession) ([]*Resource, error) {
+	if c.InitializeResult().Capabilities.Resources == nil {
+		return nil, nil
+	}
+	result, err := c.ListResources(ctx, &mcp.ListResourcesParams{})
+	if err != nil {
+		return nil, err
+	}
+	return result.Resources, nil
+}
+
+func updateResources(mcpName string, resources []*Resource) {
+	if len(resources) == 0 {
+		allResources.Del(mcpName)
+		return
+	}
+	allResources.Set(mcpName, resources)
+}

internal/agent/tools/mcp/tools.go 🔗

@@ -84,10 +84,10 @@ func getTools(ctx context.Context, session *mcp.ClientSession) ([]*Tool, error)
 	return result.Tools, nil
 }
 
-func updateTools(name string, tools []*Tool) {
+func updateTools(mcpName string, tools []*Tool) {
 	if len(tools) == 0 {
-		allTools.Del(name)
+		allTools.Del(mcpName)
 		return
 	}
-	allTools.Set(name, tools)
+	allTools.Set(mcpName, tools)
 }

internal/agent/tools/resource.go 🔗

@@ -0,0 +1,74 @@
+package tools
+
+import (
+	"context"
+	_ "embed"
+	"log/slog"
+	"strings"
+
+	"charm.land/fantasy"
+	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
+)
+
+type ReadMCPResourceParams struct {
+	Name string `json:"name" description:"MCP name"`
+	URI  string `json:"uri,omitempty" description:"Resource URI"`
+}
+
+type ListMCPResourceParams struct {
+	Name string `json:"name" description:"MCP name"`
+}
+
+const (
+	ReadMCPResourceToolName = "read_mcp_resource"
+	ListMCPResourceToolName = "list_mcp_resources"
+)
+
+//go:embed read_mcp_resource.md
+var readMCPResourceDescription []byte
+
+//go:embed list_mcp_resource.md
+var listMCPResourceDescription []byte
+
+func NewReadMCPResourceTool() fantasy.AgentTool {
+	return fantasy.NewAgentTool(
+		ReadMCPResourceToolName,
+		string(readMCPResourceDescription),
+		func(ctx context.Context, input ReadMCPResourceParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			resource, err := mcp.ReadResource(ctx, input.Name, input.URI)
+			if err != nil {
+				return fantasy.NewTextErrorResponse(err.Error()), nil
+			}
+			var sb strings.Builder
+			for _, part := range resource {
+				if !strings.HasPrefix(part.MIMEType, "text/") {
+					slog.Warn("ignoring resource of type", "type", part.MIMEType)
+					continue
+				}
+				sb.WriteString(part.Text)
+			}
+			return fantasy.NewTextResponse(sb.String()), nil
+		},
+	)
+}
+
+func NewListMCPResourceTool() fantasy.AgentTool {
+	return fantasy.NewAgentTool(
+		ListMCPResourceToolName,
+		string(listMCPResourceDescription),
+		func(ctx context.Context, input ListMCPResourceParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			for name, resources := range mcp.Resources() {
+				if name != input.Name {
+					continue
+				}
+				var sb strings.Builder
+				sb.WriteString("Resources available for " + input.Name + ":\n\n")
+				for _, res := range resources {
+					sb.WriteString("- " + res.URI + "\n")
+				}
+				return fantasy.NewTextResponse(sb.String()), nil
+			}
+			return fantasy.NewTextResponse("No resources found for " + input.Name), nil
+		},
+	)
+}

internal/config/config.go 🔗

@@ -481,6 +481,8 @@ func allToolNames() []string {
 		"sourcegraph",
 		"view",
 		"write",
+		"read_mcp_resource",
+		"list_mcp_resources",
 	}
 }
 

internal/tui/tui.go 🔗

@@ -149,6 +149,8 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			return a, handleMCPPromptsEvent(context.Background(), msg.Payload.Name)
 		case mcp.EventToolsListChanged:
 			return a, handleMCPToolsEvent(context.Background(), msg.Payload.Name)
+		case mcp.EventResourcesListChanged:
+			return a, handleMCPResourcesEvent(context.Background(), msg.Payload.Name)
 		}
 
 	// Completions messages
@@ -650,6 +652,13 @@ func handleMCPToolsEvent(ctx context.Context, name string) tea.Cmd {
 	}
 }
 
+func handleMCPResourcesEvent(ctx context.Context, name string) tea.Cmd {
+	return func() tea.Msg {
+		mcp.RefreshResources(ctx, name)
+		return nil
+	}
+}
+
 // New creates and initializes a new TUI application model.
 func New(app *app.App) *appModel {
 	chatPage := chat.New(app)