feat(mcp): resources support (#2123)

Carlos Alexandro Becker created

* feat(mcp): resources support

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

* fix: log

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

* fix: mcp events

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

* wip

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

---------

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

Change summary

internal/agent/coordinator.go              |   8 +
internal/agent/tools/list_mcp_resources.go | 105 ++++++++++++++
internal/agent/tools/list_mcp_resources.md |  18 ++
internal/agent/tools/mcp/init.go           |  44 +++++
internal/agent/tools/mcp/resources.go      |  96 ++++++++++++
internal/agent/tools/read_mcp_resource.go  | 102 +++++++++++++
internal/agent/tools/read_mcp_resource.md  |  20 ++
internal/config/config.go                  |   2 
internal/config/load_test.go               |   4 
internal/ui/completions/completions.go     | 105 +++++++++++--
internal/ui/completions/item.go            |   8 +
internal/ui/model/mcp.go                   |   5 
internal/ui/model/ui.go                    | 179 ++++++++++++++++++-----
13 files changed, 624 insertions(+), 72 deletions(-)

Detailed changes

internal/agent/coordinator.go 🔗

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

internal/agent/tools/list_mcp_resources.go 🔗

@@ -0,0 +1,105 @@
+package tools
+
+import (
+	"cmp"
+	"context"
+	_ "embed"
+	"fmt"
+	"sort"
+	"strings"
+
+	"charm.land/fantasy"
+	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/filepathext"
+	"github.com/charmbracelet/crush/internal/permission"
+)
+
+type ListMCPResourcesParams struct {
+	MCPName string `json:"mcp_name" description:"The MCP server name"`
+}
+
+type ListMCPResourcesPermissionsParams struct {
+	MCPName string `json:"mcp_name"`
+}
+
+const ListMCPResourcesToolName = "list_mcp_resources"
+
+//go:embed list_mcp_resources.md
+var listMCPResourcesDescription []byte
+
+func NewListMCPResourcesTool(cfg *config.Config, permissions permission.Service) fantasy.AgentTool {
+	return fantasy.NewParallelAgentTool(
+		ListMCPResourcesToolName,
+		string(listMCPResourcesDescription),
+		func(ctx context.Context, params ListMCPResourcesParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			params.MCPName = strings.TrimSpace(params.MCPName)
+			if params.MCPName == "" {
+				return fantasy.NewTextErrorResponse("mcp_name parameter is required"), nil
+			}
+
+			sessionID := GetSessionFromContext(ctx)
+			if sessionID == "" {
+				return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for listing MCP resources")
+			}
+
+			relPath := filepathext.SmartJoin(cfg.WorkingDir(), cmp.Or(params.MCPName, "mcp-resources"))
+			p, err := permissions.Request(ctx,
+				permission.CreatePermissionRequest{
+					SessionID:   sessionID,
+					Path:        relPath,
+					ToolCallID:  call.ID,
+					ToolName:    ListMCPResourcesToolName,
+					Action:      "list",
+					Description: fmt.Sprintf("List MCP resources from %s", params.MCPName),
+					Params:      ListMCPResourcesPermissionsParams(params),
+				},
+			)
+			if err != nil {
+				return fantasy.ToolResponse{}, err
+			}
+			if !p {
+				return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
+			}
+
+			resources, err := mcp.ListResources(ctx, cfg, params.MCPName)
+			if err != nil {
+				return fantasy.NewTextErrorResponse(err.Error()), nil
+			}
+			if len(resources) == 0 {
+				return fantasy.NewTextResponse("No resources found"), nil
+			}
+
+			lines := make([]string, 0, len(resources))
+			for _, resource := range resources {
+				if resource == nil {
+					continue
+				}
+				title := resource.Title
+				if title == "" {
+					title = resource.Name
+				}
+				if title == "" {
+					title = resource.URI
+				}
+				line := fmt.Sprintf("- %s", title)
+				if resource.URI != "" {
+					line = fmt.Sprintf("%s (%s)", line, resource.URI)
+				}
+				if resource.Description != "" {
+					line = fmt.Sprintf("%s: %s", line, resource.Description)
+				}
+				if resource.MIMEType != "" {
+					line = fmt.Sprintf("%s [mime: %s]", line, resource.MIMEType)
+				}
+				if resource.Size > 0 {
+					line = fmt.Sprintf("%s [size: %d]", line, resource.Size)
+				}
+				lines = append(lines, line)
+			}
+
+			sort.Strings(lines)
+			return fantasy.NewTextResponse(strings.Join(lines, "\n")), nil
+		},
+	)
+}

internal/agent/tools/list_mcp_resources.md 🔗

@@ -0,0 +1,18 @@
+Lists available resources from an MCP server.
+
+<when_to_use>
+Use this tool to discover which resources are available before reading them.
+</when_to_use>
+
+<usage>
+- Provide MCP server name
+- Returns resource titles and URIs
+</usage>
+
+<parameters>
+- mcp_name: The MCP server name
+</parameters>
+
+<notes>
+- Results include resource titles, URIs, and metadata when available
+</notes>

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

@@ -25,6 +25,19 @@ import (
 	"github.com/modelcontextprotocol/go-sdk/mcp"
 )
 
+func parseLevel(level mcp.LoggingLevel) slog.Level {
+	switch level {
+	case "info":
+		return slog.LevelInfo
+	case "notice":
+		return slog.LevelInfo
+	case "warning":
+		return slog.LevelWarn
+	default:
+		return slog.LevelDebug
+	}
+}
+
 var (
 	sessions = csync.NewMap[string, *mcp.ClientSession]()
 	states   = csync.NewMap[string, ClientInfo]()
@@ -65,6 +78,7 @@ const (
 	EventStateChanged EventType = iota
 	EventToolsListChanged
 	EventPromptsListChanged
+	EventResourcesListChanged
 )
 
 // Event represents an event in the MCP system
@@ -78,8 +92,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
@@ -189,13 +204,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
+			}
+
 			toolCount := updateTools(cfg, name, tools)
 			updatePrompts(name, prompts)
+			resourceCount := updateResources(name, resources)
 			sessions.Set(name, session)
 
 			updateState(name, StateConnected, nil, session, Counts{
-				Tools:   toolCount,
-				Prompts: len(prompts),
+				Tools:     toolCount,
+				Prompts:   len(prompts),
+				Resources: resourceCount,
 			})
 		}(name, m)
 	}
@@ -302,8 +327,15 @@ func createSession(ctx context.Context, name string, m config.MCPConfig, resolve
 					Name: name,
 				})
 			},
-			LoggingMessageHandler: func(_ context.Context, req *mcp.LoggingMessageRequest) {
-				slog.Info("MCP log", "name", name, "data", req.Params.Data)
+			ResourceListChangedHandler: func(context.Context, *mcp.ResourceListChangedRequest) {
+				broker.Publish(pubsub.UpdatedEvent, Event{
+					Type: EventResourcesListChanged,
+					Name: name,
+				})
+			},
+			LoggingMessageHandler: func(ctx context.Context, req *mcp.LoggingMessageRequest) {
+				level := parseLevel(req.Params.Level)
+				slog.Log(ctx, level, "MCP log", "name", name, "logger", req.Params.Logger, "data", req.Params.Data)
 			},
 		},
 	)

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

@@ -0,0 +1,96 @@
+package mcp
+
+import (
+	"context"
+	"iter"
+	"log/slog"
+
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+type Resource = mcp.Resource
+
+type ResourceContents = mcp.ResourceContents
+
+var allResources = csync.NewMap[string, []*Resource]()
+
+// Resources returns all available MCP resources.
+func Resources() iter.Seq2[string, []*Resource] {
+	return allResources.Seq2()
+}
+
+// ListResources returns the current resources for an MCP server.
+func ListResources(ctx context.Context, cfg *config.Config, name string) ([]*Resource, error) {
+	session, err := getOrRenewClient(ctx, cfg, name)
+	if err != nil {
+		return nil, err
+	}
+
+	resources, err := getResources(ctx, session)
+	if err != nil {
+		return nil, err
+	}
+
+	resourceCount := updateResources(name, resources)
+	prev, _ := states.Get(name)
+	prev.Counts.Resources = resourceCount
+	updateState(name, StateConnected, nil, session, prev.Counts)
+	return resources, nil
+}
+
+// ReadResource reads the contents of a resource from an MCP server.
+func ReadResource(ctx context.Context, cfg *config.Config, name, uri string) ([]*ResourceContents, error) {
+	session, err := getOrRenewClient(ctx, cfg, name)
+	if err != nil {
+		return nil, err
+	}
+	result, err := session.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
+	}
+
+	resourceCount := updateResources(name, resources)
+
+	prev, _ := states.Get(name)
+	prev.Counts.Resources = resourceCount
+	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(name string, resources []*Resource) int {
+	if len(resources) == 0 {
+		allResources.Del(name)
+		return 0
+	}
+	allResources.Set(name, resources)
+	return len(resources)
+}

internal/agent/tools/read_mcp_resource.go 🔗

@@ -0,0 +1,102 @@
+package tools
+
+import (
+	"cmp"
+	"context"
+	_ "embed"
+	"fmt"
+	"log/slog"
+	"strings"
+
+	"charm.land/fantasy"
+	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/filepathext"
+	"github.com/charmbracelet/crush/internal/permission"
+)
+
+type ReadMCPResourceParams struct {
+	MCPName string `json:"mcp_name" description:"The MCP server name"`
+	URI     string `json:"uri" description:"The resource URI to read"`
+}
+
+type ReadMCPResourcePermissionsParams struct {
+	MCPName string `json:"mcp_name"`
+	URI     string `json:"uri"`
+}
+
+const ReadMCPResourceToolName = "read_mcp_resource"
+
+//go:embed read_mcp_resource.md
+var readMCPResourceDescription []byte
+
+func NewReadMCPResourceTool(cfg *config.Config, permissions permission.Service) fantasy.AgentTool {
+	return fantasy.NewParallelAgentTool(
+		ReadMCPResourceToolName,
+		string(readMCPResourceDescription),
+		func(ctx context.Context, params ReadMCPResourceParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			params.MCPName = strings.TrimSpace(params.MCPName)
+			params.URI = strings.TrimSpace(params.URI)
+			if params.MCPName == "" {
+				return fantasy.NewTextErrorResponse("mcp_name parameter is required"), nil
+			}
+			if params.URI == "" {
+				return fantasy.NewTextErrorResponse("uri parameter is required"), nil
+			}
+
+			sessionID := GetSessionFromContext(ctx)
+			if sessionID == "" {
+				return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for reading MCP resources")
+			}
+
+			relPath := filepathext.SmartJoin(cfg.WorkingDir(), cmp.Or(params.URI, "mcp-resource"))
+			p, err := permissions.Request(ctx,
+				permission.CreatePermissionRequest{
+					SessionID:   sessionID,
+					Path:        relPath,
+					ToolCallID:  call.ID,
+					ToolName:    ReadMCPResourceToolName,
+					Action:      "read",
+					Description: fmt.Sprintf("Read MCP resource from %s", params.MCPName),
+					Params:      ReadMCPResourcePermissionsParams(params),
+				},
+			)
+			if err != nil {
+				return fantasy.ToolResponse{}, err
+			}
+			if !p {
+				return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
+			}
+
+			contents, err := mcp.ReadResource(ctx, cfg, params.MCPName, params.URI)
+			if err != nil {
+				return fantasy.NewTextErrorResponse(err.Error()), nil
+			}
+			if len(contents) == 0 {
+				return fantasy.NewTextResponse(""), nil
+			}
+
+			var textParts []string
+			for _, content := range contents {
+				if content == nil {
+					continue
+				}
+				if content.Text != "" {
+					textParts = append(textParts, content.Text)
+					continue
+				}
+				if len(content.Blob) > 0 {
+					textParts = append(textParts, string(content.Blob))
+					continue
+				}
+				slog.Debug("MCP resource content missing text/blob", "uri", content.URI)
+			}
+
+			if len(textParts) == 0 {
+				return fantasy.NewTextResponse(""), nil
+			}
+
+			return fantasy.NewTextResponse(strings.Join(textParts, "\n")), nil
+		},
+	)
+}

internal/agent/tools/read_mcp_resource.md 🔗

@@ -0,0 +1,20 @@
+Reads a resource from an MCP server and returns its contents.
+
+<when_to_use>
+Use this tool to fetch a specific resource URI exposed by an MCP server.
+</when_to_use>
+
+<usage>
+- Provide MCP server name and resource URI
+- Returns resource text content
+</usage>
+
+<parameters>
+- mcp_name: The MCP server name
+- uri: The resource URI to read
+</parameters>
+
+<notes>
+- Returns text content by concatenating resource parts
+- Binary resources are returned as UTF-8 text when possible
+</notes>

internal/config/config.go 🔗

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

internal/config/load_test.go 🔗

@@ -486,7 +486,7 @@ func TestConfig_setupAgentsWithDisabledTools(t *testing.T) {
 	coderAgent, ok := cfg.Agents[AgentCoder]
 	require.True(t, ok)
 
-	assert.Equal(t, []string{"agent", "bash", "job_output", "job_kill", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "glob", "ls", "sourcegraph", "todos", "view", "write"}, coderAgent.AllowedTools)
+	assert.Equal(t, []string{"agent", "bash", "job_output", "job_kill", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "glob", "ls", "sourcegraph", "todos", "view", "write", "list_mcp_resources", "read_mcp_resource"}, coderAgent.AllowedTools)
 
 	taskAgent, ok := cfg.Agents[AgentTask]
 	require.True(t, ok)
@@ -509,7 +509,7 @@ func TestConfig_setupAgentsWithEveryReadOnlyToolDisabled(t *testing.T) {
 	cfg.SetupAgents()
 	coderAgent, ok := cfg.Agents[AgentCoder]
 	require.True(t, ok)
-	assert.Equal(t, []string{"agent", "bash", "job_output", "job_kill", "download", "edit", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "todos", "write"}, coderAgent.AllowedTools)
+	assert.Equal(t, []string{"agent", "bash", "job_output", "job_kill", "download", "edit", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "todos", "write", "list_mcp_resources", "read_mcp_resource"}, coderAgent.AllowedTools)
 
 	taskAgent, ok := cfg.Agents[AgentTask]
 	require.True(t, ok)

internal/ui/completions/completions.go 🔗

@@ -1,12 +1,15 @@
 package completions
 
 import (
+	"cmp"
 	"slices"
 	"strings"
+	"sync"
 
 	"charm.land/bubbles/v2/key"
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
 	"github.com/charmbracelet/crush/internal/fsext"
 	"github.com/charmbracelet/crush/internal/ui/list"
 	"github.com/charmbracelet/x/ansi"
@@ -21,17 +24,18 @@ const (
 )
 
 // SelectionMsg is sent when a completion is selected.
-type SelectionMsg struct {
-	Value  any
-	Insert bool // If true, insert without closing.
+type SelectionMsg[T any] struct {
+	Value    T
+	KeepOpen bool // If true, insert without closing.
 }
 
 // ClosedMsg is sent when the completions are closed.
 type ClosedMsg struct{}
 
-// FilesLoadedMsg is sent when files have been loaded for completions.
-type FilesLoadedMsg struct {
-	Files []string
+// CompletionItemsLoadedMsg is sent when files have been loaded for completions.
+type CompletionItemsLoadedMsg struct {
+	Files     []FileCompletionValue
+	Resources []ResourceCompletionValue
 }
 
 // Completions represents the completions popup component.
@@ -92,23 +96,43 @@ func (c *Completions) KeyMap() KeyMap {
 	return c.keyMap
 }
 
-// OpenWithFiles opens the completions with file items from the filesystem.
-func (c *Completions) OpenWithFiles(depth, limit int) tea.Cmd {
+// Open opens the completions with file items from the filesystem.
+func (c *Completions) Open(depth, limit int) tea.Cmd {
 	return func() tea.Msg {
-		files, _, _ := fsext.ListDirectory(".", nil, depth, limit)
-		slices.Sort(files)
-		return FilesLoadedMsg{Files: files}
+		var msg CompletionItemsLoadedMsg
+		var wg sync.WaitGroup
+		wg.Go(func() {
+			msg.Files = loadFiles(depth, limit)
+		})
+		wg.Go(func() {
+			msg.Resources = loadMCPResources()
+		})
+		wg.Wait()
+		return msg
 	}
 }
 
-// SetFiles sets the file items on the completions popup.
-func (c *Completions) SetFiles(files []string) {
-	items := make([]list.FilterableItem, 0, len(files))
+// SetItems sets the files and MCP resources and rebuilds the merged list.
+func (c *Completions) SetItems(files []FileCompletionValue, resources []ResourceCompletionValue) {
+	items := make([]list.FilterableItem, 0, len(files)+len(resources))
+
+	// Add files first.
 	for _, file := range files {
-		file = strings.TrimPrefix(file, "./")
 		item := NewCompletionItem(
+			file.Path,
 			file,
-			FileCompletionValue{Path: file},
+			c.normalStyle,
+			c.focusedStyle,
+			c.matchStyle,
+		)
+		items = append(items, item)
+	}
+
+	// Add MCP resources.
+	for _, resource := range resources {
+		item := NewCompletionItem(
+			resource.MCPName+"/"+cmp.Or(resource.Title, resource.URI),
+			resource,
 			c.normalStyle,
 			c.focusedStyle,
 			c.matchStyle,
@@ -119,7 +143,7 @@ func (c *Completions) SetFiles(files []string) {
 	c.open = true
 	c.query = ""
 	c.list.SetItems(items...)
-	c.list.SetFilter("") // Clear any previous filter.
+	c.list.SetFilter("")
 	c.list.Focus()
 
 	c.width = maxWidth
@@ -232,7 +256,7 @@ func (c *Completions) selectNext() {
 }
 
 // selectCurrent returns a command with the currently selected item.
-func (c *Completions) selectCurrent(insert bool) tea.Msg {
+func (c *Completions) selectCurrent(keepOpen bool) tea.Msg {
 	items := c.list.FilteredItems()
 	if len(items) == 0 {
 		return nil
@@ -248,13 +272,23 @@ func (c *Completions) selectCurrent(insert bool) tea.Msg {
 		return nil
 	}
 
-	if !insert {
+	if !keepOpen {
 		c.open = false
 	}
 
-	return SelectionMsg{
-		Value:  item.Value(),
-		Insert: insert,
+	switch item := item.Value().(type) {
+	case ResourceCompletionValue:
+		return SelectionMsg[ResourceCompletionValue]{
+			Value:    item,
+			KeepOpen: keepOpen,
+		}
+	case FileCompletionValue:
+		return SelectionMsg[FileCompletionValue]{
+			Value:    item,
+			KeepOpen: keepOpen,
+		}
+	default:
+		return nil
 	}
 }
 
@@ -271,3 +305,30 @@ func (c *Completions) Render() string {
 
 	return c.list.Render()
 }
+
+func loadFiles(depth, limit int) []FileCompletionValue {
+	files, _, _ := fsext.ListDirectory(".", nil, depth, limit)
+	slices.Sort(files)
+	result := make([]FileCompletionValue, 0, len(files))
+	for _, file := range files {
+		result = append(result, FileCompletionValue{
+			Path: strings.TrimPrefix(file, "./"),
+		})
+	}
+	return result
+}
+
+func loadMCPResources() []ResourceCompletionValue {
+	var resources []ResourceCompletionValue
+	for mcpName, mcpResources := range mcp.Resources() {
+		for _, r := range mcpResources {
+			resources = append(resources, ResourceCompletionValue{
+				MCPName:  mcpName,
+				URI:      r.URI,
+				Title:    r.Name,
+				MIMEType: r.MIMEType,
+			})
+		}
+	}
+	return resources
+}

internal/ui/completions/item.go 🔗

@@ -13,6 +13,14 @@ type FileCompletionValue struct {
 	Path string
 }
 
+// ResourceCompletionValue represents a MCP resource completion value.
+type ResourceCompletionValue struct {
+	MCPName  string
+	URI      string
+	Title    string
+	MIMEType string
+}
+
 // CompletionItem represents an item in the completions list.
 type CompletionItem struct {
 	text    string

internal/ui/model/mcp.go 🔗

@@ -34,7 +34,7 @@ func (m *UI) mcpInfo(width, maxItems int, isSection bool) string {
 	return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list))
 }
 
-// mcpCounts formats tool and prompt counts for display.
+// mcpCounts formats tool, prompt, and resource counts for display.
 func mcpCounts(t *styles.Styles, counts mcp.Counts) string {
 	parts := []string{}
 	if counts.Tools > 0 {
@@ -43,6 +43,9 @@ func mcpCounts(t *styles.Styles, counts mcp.Counts) string {
 	if counts.Prompts > 0 {
 		parts = append(parts, t.Subtle.Render(fmt.Sprintf("%d prompts", counts.Prompts)))
 	}
+	if counts.Resources > 0 {
+		parts = append(parts, t.Subtle.Render(fmt.Sprintf("%d resources", counts.Resources)))
+	}
 	return strings.Join(parts, " ")
 }
 

internal/ui/model/ui.go 🔗

@@ -354,18 +354,16 @@ func (m *UI) loadCustomCommands() tea.Cmd {
 }
 
 // loadMCPrompts loads the MCP prompts asynchronously.
-func (m *UI) loadMCPrompts() tea.Cmd {
-	return func() tea.Msg {
-		prompts, err := commands.LoadMCPPrompts()
-		if err != nil {
-			slog.Error("Failed to load MCP prompts", "error", err)
-		}
-		if prompts == nil {
-			// flag them as loaded even if there is none or an error
-			prompts = []commands.MCPPrompt{}
-		}
-		return mcpPromptsLoadedMsg{Prompts: prompts}
+func (m *UI) loadMCPrompts() tea.Msg {
+	prompts, err := commands.LoadMCPPrompts()
+	if err != nil {
+		slog.Error("Failed to load MCP prompts", "error", err)
 	}
+	if prompts == nil {
+		// flag them as loaded even if there is none or an error
+		prompts = []commands.MCPPrompt{}
+	}
+	return mcpPromptsLoadedMsg{Prompts: prompts}
 }
 
 // Update handles updates to the UI model.
@@ -505,17 +503,18 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case pubsub.Event[app.LSPEvent]:
 		m.lspStates = app.GetLSPStates()
 	case pubsub.Event[mcp.Event]:
-		m.mcpStates = mcp.GetStates()
-		// check if all mcps are initialized
-		initialized := true
-		for _, state := range m.mcpStates {
-			if state.State == mcp.StateStarting {
-				initialized = false
-				break
-			}
-		}
-		if initialized && m.mcpPrompts == nil {
-			cmds = append(cmds, m.loadMCPrompts())
+		switch msg.Payload.Type {
+		case mcp.EventStateChanged:
+			return m, tea.Batch(
+				m.handleStateChanged(),
+				m.loadMCPrompts,
+			)
+		case mcp.EventPromptsListChanged:
+			return m, handleMCPPromptsEvent(msg.Payload.Name)
+		case mcp.EventToolsListChanged:
+			return m, handleMCPToolsEvent(m.com.Config(), msg.Payload.Name)
+		case mcp.EventResourcesListChanged:
+			return m, handleMCPResourcesEvent(msg.Payload.Name)
 		}
 	case pubsub.Event[permission.PermissionRequest]:
 		if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
@@ -713,10 +712,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		cmds = append(cmds, clearInfoMsgCmd(ttl))
 	case util.ClearStatusMsg:
 		m.status.ClearInfoMsg()
-	case completions.FilesLoadedMsg:
-		// Handle async file loading for completions.
+	case completions.CompletionItemsLoadedMsg:
 		if m.completionsOpen {
-			m.completions.SetFiles(msg.Files)
+			m.completions.SetItems(msg.Files, msg.Resources)
 		}
 	case uv.KittyGraphicsEvent:
 		if !bytes.HasPrefix(msg.Payload, []byte("OK")) {
@@ -1517,12 +1515,14 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 			if m.completionsOpen {
 				if msg, ok := m.completions.Update(msg); ok {
 					switch msg := msg.(type) {
-					case completions.SelectionMsg:
-						// Handle file completion selection.
-						if item, ok := msg.Value.(completions.FileCompletionValue); ok {
-							cmds = append(cmds, m.insertFileCompletion(item.Path))
+					case completions.SelectionMsg[completions.FileCompletionValue]:
+						cmds = append(cmds, m.insertFileCompletion(msg.Value.Path))
+						if !msg.KeepOpen {
+							m.closeCompletions()
 						}
-						if !msg.Insert {
+					case completions.SelectionMsg[completions.ResourceCompletionValue]:
+						cmds = append(cmds, m.insertMCPResourceCompletion(msg.Value))
+						if !msg.KeepOpen {
 							m.closeCompletions()
 						}
 					case completions.ClosedMsg:
@@ -1636,7 +1636,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 						m.completionsStartIndex = curIdx
 						m.completionsPositionStart = m.completionsPosition()
 						depth, limit := m.com.Config().Options.TUI.Completions.Limits()
-						cmds = append(cmds, m.completions.OpenWithFiles(depth, limit))
+						cmds = append(cmds, m.completions.Open(depth, limit))
 					}
 				}
 
@@ -2475,24 +2475,29 @@ func (m *UI) closeCompletions() {
 	m.completions.Close()
 }
 
-// insertFileCompletion inserts the selected file path into the textarea,
-// replacing the @query, and adds the file as an attachment.
-func (m *UI) insertFileCompletion(path string) tea.Cmd {
+// insertCompletionText replaces the @query in the textarea with the given text.
+// Returns false if the replacement cannot be performed.
+func (m *UI) insertCompletionText(text string) bool {
 	value := m.textarea.Value()
-	word := m.textareaWord()
-
-	// Find the @ and query to replace.
 	if m.completionsStartIndex > len(value) {
-		return nil
+		return false
 	}
 
-	// Build the new value: everything before @, the path, everything after query.
+	word := m.textareaWord()
 	endIdx := min(m.completionsStartIndex+len(word), len(value))
-
-	newValue := value[:m.completionsStartIndex] + path + value[endIdx:]
+	newValue := value[:m.completionsStartIndex] + text + value[endIdx:]
 	m.textarea.SetValue(newValue)
 	m.textarea.MoveToEnd()
 	m.textarea.InsertRune(' ')
+	return true
+}
+
+// insertFileCompletion inserts the selected file path into the textarea,
+// replacing the @query, and adds the file as an attachment.
+func (m *UI) insertFileCompletion(path string) tea.Cmd {
+	if !m.insertCompletionText(path) {
+		return nil
+	}
 
 	return func() tea.Msg {
 		absPath, _ := filepath.Abs(path)
@@ -2527,6 +2532,61 @@ func (m *UI) insertFileCompletion(path string) tea.Cmd {
 	}
 }
 
+// insertMCPResourceCompletion inserts the selected resource into the textarea,
+// replacing the @query, and adds the resource as an attachment.
+func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValue) tea.Cmd {
+	displayText := item.Title
+	if displayText == "" {
+		displayText = item.URI
+	}
+
+	if !m.insertCompletionText(displayText) {
+		return nil
+	}
+
+	return func() tea.Msg {
+		contents, err := mcp.ReadResource(
+			context.Background(),
+			m.com.Config(),
+			item.MCPName,
+			item.URI,
+		)
+		if err != nil {
+			slog.Warn("Failed to read MCP resource", "uri", item.URI, "error", err)
+			return nil
+		}
+		if len(contents) == 0 {
+			return nil
+		}
+
+		content := contents[0]
+		var data []byte
+		if content.Text != "" {
+			data = []byte(content.Text)
+		} else if len(content.Blob) > 0 {
+			data = content.Blob
+		}
+		if len(data) == 0 {
+			return nil
+		}
+
+		mimeType := item.MIMEType
+		if mimeType == "" && content.MIMEType != "" {
+			mimeType = content.MIMEType
+		}
+		if mimeType == "" {
+			mimeType = "text/plain"
+		}
+
+		return message.Attachment{
+			FilePath: item.URI,
+			FileName: displayText,
+			MimeType: mimeType,
+			Content:  data,
+		}
+	}
+}
+
 // completionsPosition returns the X and Y position for the completions popup.
 func (m *UI) completionsPosition() image.Point {
 	cur := m.textarea.Cursor()
@@ -3088,6 +3148,43 @@ func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string
 	return tea.Sequence(cmds...)
 }
 
+func (m *UI) handleStateChanged() tea.Cmd {
+	slog.Warn("handleStateChanged")
+	return func() tea.Msg {
+		m.com.App.UpdateAgentModel(context.Background())
+		m.mcpStates = mcp.GetStates()
+		return nil
+	}
+}
+
+func handleMCPPromptsEvent(name string) tea.Cmd {
+	slog.Warn("handleMCPPromptsEvent")
+	return func() tea.Msg {
+		mcp.RefreshPrompts(context.Background(), name)
+		return nil
+	}
+}
+
+func handleMCPToolsEvent(cfg *config.Config, name string) tea.Cmd {
+	slog.Warn("handleMCPToolsEvent")
+	return func() tea.Msg {
+		mcp.RefreshTools(
+			context.Background(),
+			cfg,
+			name,
+		)
+		return nil
+	}
+}
+
+func handleMCPResourcesEvent(name string) tea.Cmd {
+	slog.Warn("handleMCPResourcesEvent")
+	return func() tea.Msg {
+		mcp.RefreshResources(context.Background(), name)
+		return nil
+	}
+}
+
 func (m *UI) copyChatHighlight() tea.Cmd {
 	text := m.chat.HighlightContent()
 	return common.CopyToClipboardWithCallback(