on main help sign in
feat(mcp): resources support (#2123)

* 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>

commitb66f96ea78b4cddc9aefc1c1e3094008419e3a0a authorCarlos Alexandro Becker <caarlos0@users.noreply.github.com> dateFri 6 Feb 2026, 09:52 (8 months ago) parentff212d6
13 files changed+624−72
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+38−6 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+2−2 internal/ui/completions/completions.go+83−22 internal/ui/completions/item.go+8 internal/ui/model/mcp.go+4−1 internal/ui/model/ui.go+138−41
internal/agent/coordinator.go+8
@@ -439,6 +439,14 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
439439 allTools = append(allTools, tools.NewDiagnosticsTool(c.lspClients), tools.NewReferencesTool(c.lspClients), tools.NewLSPRestartTool(c.lspClients))
440440 }
441441
442+ if len(c.cfg.MCP) > 0 {
443+ allTools = append(
444+ allTools,
445+ tools.NewListMCPResourcesTool(c.cfg, c.permissions),
446+ tools.NewReadMCPResourceTool(c.cfg, c.permissions),
447+ )
448+ }
449+
442450 var filteredTools []fantasy.AgentTool
443451 for _, tool := range allTools {
444452 if slices.Contains(agent.AllowedTools, tool.Info().Name) {
internal/agent/tools/list_mcp_resources.gonew file+105
@@ -0,0 +1,105 @@
1+package tools
2+
3+import (
4+ "cmp"
5+ "context"
6+ _ "embed"
7+ "fmt"
8+ "sort"
9+ "strings"
10+
11+ "charm.land/fantasy"
12+ "github.com/charmbracelet/crush/internal/agent/tools/mcp"
13+ "github.com/charmbracelet/crush/internal/config"
14+ "github.com/charmbracelet/crush/internal/filepathext"
15+ "github.com/charmbracelet/crush/internal/permission"
16+)
17+
18+type ListMCPResourcesParams struct {
19+ MCPName string `json:"mcp_name" description:"The MCP server name"`
20+}
21+
22+type ListMCPResourcesPermissionsParams struct {
23+ MCPName string `json:"mcp_name"`
24+}
25+
26+const ListMCPResourcesToolName = "list_mcp_resources"
27+
28+//go:embed list_mcp_resources.md
29+var listMCPResourcesDescription []byte
30+
31+func NewListMCPResourcesTool(cfg *config.Config, permissions permission.Service) fantasy.AgentTool {
32+ return fantasy.NewParallelAgentTool(
33+ ListMCPResourcesToolName,
34+ string(listMCPResourcesDescription),
35+ func(ctx context.Context, params ListMCPResourcesParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
36+ params.MCPName = strings.TrimSpace(params.MCPName)
37+ if params.MCPName == "" {
38+ return fantasy.NewTextErrorResponse("mcp_name parameter is required"), nil
39+ }
40+
41+ sessionID := GetSessionFromContext(ctx)
42+ if sessionID == "" {
43+ return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for listing MCP resources")
44+ }
45+
46+ relPath := filepathext.SmartJoin(cfg.WorkingDir(), cmp.Or(params.MCPName, "mcp-resources"))
47+ p, err := permissions.Request(ctx,
48+ permission.CreatePermissionRequest{
49+ SessionID: sessionID,
50+ Path: relPath,
51+ ToolCallID: call.ID,
52+ ToolName: ListMCPResourcesToolName,
53+ Action: "list",
54+ Description: fmt.Sprintf("List MCP resources from %s", params.MCPName),
55+ Params: ListMCPResourcesPermissionsParams(params),
56+ },
57+ )
58+ if err != nil {
59+ return fantasy.ToolResponse{}, err
60+ }
61+ if !p {
62+ return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
63+ }
64+
65+ resources, err := mcp.ListResources(ctx, cfg, params.MCPName)
66+ if err != nil {
67+ return fantasy.NewTextErrorResponse(err.Error()), nil
68+ }
69+ if len(resources) == 0 {
70+ return fantasy.NewTextResponse("No resources found"), nil
71+ }
72+
73+ lines := make([]string, 0, len(resources))
74+ for _, resource := range resources {
75+ if resource == nil {
76+ continue
77+ }
78+ title := resource.Title
79+ if title == "" {
80+ title = resource.Name
81+ }
82+ if title == "" {
83+ title = resource.URI
84+ }
85+ line := fmt.Sprintf("- %s", title)
86+ if resource.URI != "" {
87+ line = fmt.Sprintf("%s (%s)", line, resource.URI)
88+ }
89+ if resource.Description != "" {
90+ line = fmt.Sprintf("%s: %s", line, resource.Description)
91+ }
92+ if resource.MIMEType != "" {
93+ line = fmt.Sprintf("%s [mime: %s]", line, resource.MIMEType)
94+ }
95+ if resource.Size > 0 {
96+ line = fmt.Sprintf("%s [size: %d]", line, resource.Size)
97+ }
98+ lines = append(lines, line)
99+ }
100+
101+ sort.Strings(lines)
102+ return fantasy.NewTextResponse(strings.Join(lines, "\n")), nil
103+ },
104+ )
105+}
internal/agent/tools/list_mcp_resources.mdnew file+18
@@ -0,0 +1,18 @@
1+Lists available resources from an MCP server.
2+
3+<when_to_use>
4+Use this tool to discover which resources are available before reading them.
5+</when_to_use>
6+
7+<usage>
8+- Provide MCP server name
9+- Returns resource titles and URIs
10+</usage>
11+
12+<parameters>
13+- mcp_name: The MCP server name
14+</parameters>
15+
16+<notes>
17+- Results include resource titles, URIs, and metadata when available
18+</notes>
internal/agent/tools/mcp/init.go+38−6
@@ -25,6 +25,19 @@ import (
2525 "github.com/modelcontextprotocol/go-sdk/mcp"
2626)
2727
28+func parseLevel(level mcp.LoggingLevel) slog.Level {
29+ switch level {
30+ case "info":
31+ return slog.LevelInfo
32+ case "notice":
33+ return slog.LevelInfo
34+ case "warning":
35+ return slog.LevelWarn
36+ default:
37+ return slog.LevelDebug
38+ }
39+}
40+
2841var (
2942 sessions = csync.NewMap[string, *mcp.ClientSession]()
3043 states = csync.NewMap[string, ClientInfo]()
@@ -65,6 +78,7 @@ const (
6578 EventStateChanged EventType = iota
6679 EventToolsListChanged
6780 EventPromptsListChanged
81+ EventResourcesListChanged
6882)
6983
7084// Event represents an event in the MCP system
@@ -78,8 +92,9 @@ type Event struct {
7892
7993// Counts number of available tools, prompts, etc.
8094type Counts struct {
81− Tools int
82− Prompts int
95+ Tools int
96+ Prompts int
97+ Resources int
8398}
8499
85100// ClientInfo holds information about an MCP client's state
@@ -189,13 +204,23 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config
189204 return
190205 }
191206
207+ resources, err := getResources(ctx, session)
208+ if err != nil {
209+ slog.Error("Error listing resources", "error", err)
210+ updateState(name, StateError, err, nil, Counts{})
211+ session.Close()
212+ return
213+ }
214+
192215 toolCount := updateTools(cfg, name, tools)
193216 updatePrompts(name, prompts)
217+ resourceCount := updateResources(name, resources)
194218 sessions.Set(name, session)
195219
196220 updateState(name, StateConnected, nil, session, Counts{
197− Tools: toolCount,
198− Prompts: len(prompts),
221+ Tools: toolCount,
222+ Prompts: len(prompts),
223+ Resources: resourceCount,
199224 })
200225 }(name, m)
201226 }
@@ -302,8 +327,15 @@ func createSession(ctx context.Context, name string, m config.MCPConfig, resolve
302327 Name: name,
303328 })
304329 },
305− LoggingMessageHandler: func(_ context.Context, req *mcp.LoggingMessageRequest) {
306− slog.Info("MCP log", "name", name, "data", req.Params.Data)
330+ ResourceListChangedHandler: func(context.Context, *mcp.ResourceListChangedRequest) {
331+ broker.Publish(pubsub.UpdatedEvent, Event{
332+ Type: EventResourcesListChanged,
333+ Name: name,
334+ })
335+ },
336+ LoggingMessageHandler: func(ctx context.Context, req *mcp.LoggingMessageRequest) {
337+ level := parseLevel(req.Params.Level)
338+ slog.Log(ctx, level, "MCP log", "name", name, "logger", req.Params.Logger, "data", req.Params.Data)
307339 },
308340 },
309341 )
internal/agent/tools/mcp/resources.gonew file+96
@@ -0,0 +1,96 @@
1+package mcp
2+
3+import (
4+ "context"
5+ "iter"
6+ "log/slog"
7+
8+ "github.com/charmbracelet/crush/internal/config"
9+ "github.com/charmbracelet/crush/internal/csync"
10+ "github.com/modelcontextprotocol/go-sdk/mcp"
11+)
12+
13+type Resource = mcp.Resource
14+
15+type ResourceContents = mcp.ResourceContents
16+
17+var allResources = csync.NewMap[string, []*Resource]()
18+
19+// Resources returns all available MCP resources.
20+func Resources() iter.Seq2[string, []*Resource] {
21+ return allResources.Seq2()
22+}
23+
24+// ListResources returns the current resources for an MCP server.
25+func ListResources(ctx context.Context, cfg *config.Config, name string) ([]*Resource, error) {
26+ session, err := getOrRenewClient(ctx, cfg, name)
27+ if err != nil {
28+ return nil, err
29+ }
30+
31+ resources, err := getResources(ctx, session)
32+ if err != nil {
33+ return nil, err
34+ }
35+
36+ resourceCount := updateResources(name, resources)
37+ prev, _ := states.Get(name)
38+ prev.Counts.Resources = resourceCount
39+ updateState(name, StateConnected, nil, session, prev.Counts)
40+ return resources, nil
41+}
42+
43+// ReadResource reads the contents of a resource from an MCP server.
44+func ReadResource(ctx context.Context, cfg *config.Config, name, uri string) ([]*ResourceContents, error) {
45+ session, err := getOrRenewClient(ctx, cfg, name)
46+ if err != nil {
47+ return nil, err
48+ }
49+ result, err := session.ReadResource(ctx, &mcp.ReadResourceParams{URI: uri})
50+ if err != nil {
51+ return nil, err
52+ }
53+ return result.Contents, nil
54+}
55+
56+// RefreshResources gets the updated list of resources from the MCP and updates the
57+// global state.
58+func RefreshResources(ctx context.Context, name string) {
59+ session, ok := sessions.Get(name)
60+ if !ok {
61+ slog.Warn("Refresh resources: no session", "name", name)
62+ return
63+ }
64+
65+ resources, err := getResources(ctx, session)
66+ if err != nil {
67+ updateState(name, StateError, err, nil, Counts{})
68+ return
69+ }
70+
71+ resourceCount := updateResources(name, resources)
72+
73+ prev, _ := states.Get(name)
74+ prev.Counts.Resources = resourceCount
75+ updateState(name, StateConnected, nil, session, prev.Counts)
76+}
77+
78+func getResources(ctx context.Context, c *mcp.ClientSession) ([]*Resource, error) {
79+ if c.InitializeResult().Capabilities.Resources == nil {
80+ return nil, nil
81+ }
82+ result, err := c.ListResources(ctx, &mcp.ListResourcesParams{})
83+ if err != nil {
84+ return nil, err
85+ }
86+ return result.Resources, nil
87+}
88+
89+func updateResources(name string, resources []*Resource) int {
90+ if len(resources) == 0 {
91+ allResources.Del(name)
92+ return 0
93+ }
94+ allResources.Set(name, resources)
95+ return len(resources)
96+}
internal/agent/tools/read_mcp_resource.gonew file+102
@@ -0,0 +1,102 @@
1+package tools
2+
3+import (
4+ "cmp"
5+ "context"
6+ _ "embed"
7+ "fmt"
8+ "log/slog"
9+ "strings"
10+
11+ "charm.land/fantasy"
12+ "github.com/charmbracelet/crush/internal/agent/tools/mcp"
13+ "github.com/charmbracelet/crush/internal/config"
14+ "github.com/charmbracelet/crush/internal/filepathext"
15+ "github.com/charmbracelet/crush/internal/permission"
16+)
17+
18+type ReadMCPResourceParams struct {
19+ MCPName string `json:"mcp_name" description:"The MCP server name"`
20+ URI string `json:"uri" description:"The resource URI to read"`
21+}
22+
23+type ReadMCPResourcePermissionsParams struct {
24+ MCPName string `json:"mcp_name"`
25+ URI string `json:"uri"`
26+}
27+
28+const ReadMCPResourceToolName = "read_mcp_resource"
29+
30+//go:embed read_mcp_resource.md
31+var readMCPResourceDescription []byte
32+
33+func NewReadMCPResourceTool(cfg *config.Config, permissions permission.Service) fantasy.AgentTool {
34+ return fantasy.NewParallelAgentTool(
35+ ReadMCPResourceToolName,
36+ string(readMCPResourceDescription),
37+ func(ctx context.Context, params ReadMCPResourceParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
38+ params.MCPName = strings.TrimSpace(params.MCPName)
39+ params.URI = strings.TrimSpace(params.URI)
40+ if params.MCPName == "" {
41+ return fantasy.NewTextErrorResponse("mcp_name parameter is required"), nil
42+ }
43+ if params.URI == "" {
44+ return fantasy.NewTextErrorResponse("uri parameter is required"), nil
45+ }
46+
47+ sessionID := GetSessionFromContext(ctx)
48+ if sessionID == "" {
49+ return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for reading MCP resources")
50+ }
51+
52+ relPath := filepathext.SmartJoin(cfg.WorkingDir(), cmp.Or(params.URI, "mcp-resource"))
53+ p, err := permissions.Request(ctx,
54+ permission.CreatePermissionRequest{
55+ SessionID: sessionID,
56+ Path: relPath,
57+ ToolCallID: call.ID,
58+ ToolName: ReadMCPResourceToolName,
59+ Action: "read",
60+ Description: fmt.Sprintf("Read MCP resource from %s", params.MCPName),
61+ Params: ReadMCPResourcePermissionsParams(params),
62+ },
63+ )
64+ if err != nil {
65+ return fantasy.ToolResponse{}, err
66+ }
67+ if !p {
68+ return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
69+ }
70+
71+ contents, err := mcp.ReadResource(ctx, cfg, params.MCPName, params.URI)
72+ if err != nil {
73+ return fantasy.NewTextErrorResponse(err.Error()), nil
74+ }
75+ if len(contents) == 0 {
76+ return fantasy.NewTextResponse(""), nil
77+ }
78+
79+ var textParts []string
80+ for _, content := range contents {
81+ if content == nil {
82+ continue
83+ }
84+ if content.Text != "" {
85+ textParts = append(textParts, content.Text)
86+ continue
87+ }
88+ if len(content.Blob) > 0 {
89+ textParts = append(textParts, string(content.Blob))
90+ continue
91+ }
92+ slog.Debug("MCP resource content missing text/blob", "uri", content.URI)
93+ }
94+
95+ if len(textParts) == 0 {
96+ return fantasy.NewTextResponse(""), nil
97+ }
98+
99+ return fantasy.NewTextResponse(strings.Join(textParts, "\n")), nil
100+ },
101+ )
102+}
internal/agent/tools/read_mcp_resource.mdnew file+20
@@ -0,0 +1,20 @@
1+Reads a resource from an MCP server and returns its contents.
2+
3+<when_to_use>
4+Use this tool to fetch a specific resource URI exposed by an MCP server.
5+</when_to_use>
6+
7+<usage>
8+- Provide MCP server name and resource URI
9+- Returns resource text content
10+</usage>
11+
12+<parameters>
13+- mcp_name: The MCP server name
14+- uri: The resource URI to read
15+</parameters>
16+
17+<notes>
18+- Returns text content by concatenating resource parts
19+- Binary resources are returned as UTF-8 text when possible
20+</notes>
internal/config/config.go+2
@@ -710,6 +710,8 @@ func allToolNames() []string {
710710 "todos",
711711 "view",
712712 "write",
713+ "list_mcp_resources",
714+ "read_mcp_resource",
713715 }
714716}
715717
internal/config/load_test.go+2−2
@@ -486,7 +486,7 @@ func TestConfig_setupAgentsWithDisabledTools(t *testing.T) {
486486 coderAgent, ok := cfg.Agents[AgentCoder]
487487 require.True(t, ok)
488488
489− 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)
489+ 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)
490490
491491 taskAgent, ok := cfg.Agents[AgentTask]
492492 require.True(t, ok)
@@ -509,7 +509,7 @@ func TestConfig_setupAgentsWithEveryReadOnlyToolDisabled(t *testing.T) {
509509 cfg.SetupAgents()
510510 coderAgent, ok := cfg.Agents[AgentCoder]
511511 require.True(t, ok)
512− 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)
512+ 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)
513513
514514 taskAgent, ok := cfg.Agents[AgentTask]
515515 require.True(t, ok)
internal/ui/completions/completions.go+83−22
@@ -1,12 +1,15 @@
11package completions
22
33import (
4+ "cmp"
45 "slices"
56 "strings"
7+ "sync"
68
79 "charm.land/bubbles/v2/key"
810 tea "charm.land/bubbletea/v2"
911 "charm.land/lipgloss/v2"
12+ "github.com/charmbracelet/crush/internal/agent/tools/mcp"
1013 "github.com/charmbracelet/crush/internal/fsext"
1114 "github.com/charmbracelet/crush/internal/ui/list"
1215 "github.com/charmbracelet/x/ansi"
@@ -21,17 +24,18 @@ const (
2124)
2225
2326// SelectionMsg is sent when a completion is selected.
24−type SelectionMsg struct {
25− Value any
26− Insert bool // If true, insert without closing.
27+type SelectionMsg[T any] struct {
28+ Value T
29+ KeepOpen bool // If true, insert without closing.
2730}
2831
2932// ClosedMsg is sent when the completions are closed.
3033type ClosedMsg struct{}
3134
32−// FilesLoadedMsg is sent when files have been loaded for completions.
33−type FilesLoadedMsg struct {
34− Files []string
35+// CompletionItemsLoadedMsg is sent when files have been loaded for completions.
36+type CompletionItemsLoadedMsg struct {
37+ Files []FileCompletionValue
38+ Resources []ResourceCompletionValue
3539}
3640
3741// Completions represents the completions popup component.
@@ -92,23 +96,43 @@ func (c *Completions) KeyMap() KeyMap {
9296 return c.keyMap
9397}
9498
95−// OpenWithFiles opens the completions with file items from the filesystem.
96−func (c *Completions) OpenWithFiles(depth, limit int) tea.Cmd {
99+// Open opens the completions with file items from the filesystem.
100+func (c *Completions) Open(depth, limit int) tea.Cmd {
97101 return func() tea.Msg {
98− files, _, _ := fsext.ListDirectory(".", nil, depth, limit)
99− slices.Sort(files)
100− return FilesLoadedMsg{Files: files}
102+ var msg CompletionItemsLoadedMsg
103+ var wg sync.WaitGroup
104+ wg.Go(func() {
105+ msg.Files = loadFiles(depth, limit)
106+ })
107+ wg.Go(func() {
108+ msg.Resources = loadMCPResources()
109+ })
110+ wg.Wait()
111+ return msg
101112 }
102113}
103114
104−// SetFiles sets the file items on the completions popup.
105−func (c *Completions) SetFiles(files []string) {
106− items := make([]list.FilterableItem, 0, len(files))
115+// SetItems sets the files and MCP resources and rebuilds the merged list.
116+func (c *Completions) SetItems(files []FileCompletionValue, resources []ResourceCompletionValue) {
117+ items := make([]list.FilterableItem, 0, len(files)+len(resources))
118+
119+ // Add files first.
107120 for _, file := range files {
108− file = strings.TrimPrefix(file, "./")
109121 item := NewCompletionItem(
122+ file.Path,
110123 file,
111− FileCompletionValue{Path: file},
124+ c.normalStyle,
125+ c.focusedStyle,
126+ c.matchStyle,
127+ )
128+ items = append(items, item)
129+ }
130+
131+ // Add MCP resources.
132+ for _, resource := range resources {
133+ item := NewCompletionItem(
134+ resource.MCPName+"/"+cmp.Or(resource.Title, resource.URI),
135+ resource,
112136 c.normalStyle,
113137 c.focusedStyle,
114138 c.matchStyle,
@@ -119,7 +143,7 @@ func (c *Completions) SetFiles(files []string) {
119143 c.open = true
120144 c.query = ""
121145 c.list.SetItems(items...)
122− c.list.SetFilter("") // Clear any previous filter.
146+ c.list.SetFilter("")
123147 c.list.Focus()
124148
125149 c.width = maxWidth
@@ -232,7 +256,7 @@ func (c *Completions) selectNext() {
232256}
233257
234258// selectCurrent returns a command with the currently selected item.
235−func (c *Completions) selectCurrent(insert bool) tea.Msg {
259+func (c *Completions) selectCurrent(keepOpen bool) tea.Msg {
236260 items := c.list.FilteredItems()
237261 if len(items) == 0 {
238262 return nil
@@ -248,13 +272,23 @@ func (c *Completions) selectCurrent(insert bool) tea.Msg {
248272 return nil
249273 }
250274
251− if !insert {
275+ if !keepOpen {
252276 c.open = false
253277 }
254278
255− return SelectionMsg{
256− Value: item.Value(),
257− Insert: insert,
279+ switch item := item.Value().(type) {
280+ case ResourceCompletionValue:
281+ return SelectionMsg[ResourceCompletionValue]{
282+ Value: item,
283+ KeepOpen: keepOpen,
284+ }
285+ case FileCompletionValue:
286+ return SelectionMsg[FileCompletionValue]{
287+ Value: item,
288+ KeepOpen: keepOpen,
289+ }
290+ default:
291+ return nil
258292 }
259293}
260294
@@ -271,3 +305,30 @@ func (c *Completions) Render() string {
271305
272306 return c.list.Render()
273307}
308+
309+func loadFiles(depth, limit int) []FileCompletionValue {
310+ files, _, _ := fsext.ListDirectory(".", nil, depth, limit)
311+ slices.Sort(files)
312+ result := make([]FileCompletionValue, 0, len(files))
313+ for _, file := range files {
314+ result = append(result, FileCompletionValue{
315+ Path: strings.TrimPrefix(file, "./"),
316+ })
317+ }
318+ return result
319+}
320+
321+func loadMCPResources() []ResourceCompletionValue {
322+ var resources []ResourceCompletionValue
323+ for mcpName, mcpResources := range mcp.Resources() {
324+ for _, r := range mcpResources {
325+ resources = append(resources, ResourceCompletionValue{
326+ MCPName: mcpName,
327+ URI: r.URI,
328+ Title: r.Name,
329+ MIMEType: r.MIMEType,
330+ })
331+ }
332+ }
333+ return resources
334+}
internal/ui/completions/item.go+8
@@ -13,6 +13,14 @@ type FileCompletionValue struct {
1313 Path string
1414}
1515
16+// ResourceCompletionValue represents a MCP resource completion value.
17+type ResourceCompletionValue struct {
18+ MCPName string
19+ URI string
20+ Title string
21+ MIMEType string
22+}
23+
1624// CompletionItem represents an item in the completions list.
1725type CompletionItem struct {
1826 text string
internal/ui/model/mcp.go+4−1
@@ -34,7 +34,7 @@ func (m *UI) mcpInfo(width, maxItems int, isSection bool) string {
3434 return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list))
3535}
3636
37−// mcpCounts formats tool and prompt counts for display.
37+// mcpCounts formats tool, prompt, and resource counts for display.
3838func mcpCounts(t *styles.Styles, counts mcp.Counts) string {
3939 parts := []string{}
4040 if counts.Tools > 0 {
@@ -43,6 +43,9 @@ func mcpCounts(t *styles.Styles, counts mcp.Counts) string {
4343 if counts.Prompts > 0 {
4444 parts = append(parts, t.Subtle.Render(fmt.Sprintf("%d prompts", counts.Prompts)))
4545 }
46+ if counts.Resources > 0 {
47+ parts = append(parts, t.Subtle.Render(fmt.Sprintf("%d resources", counts.Resources)))
48+ }
4649 return strings.Join(parts, " ")
4750}
4851
internal/ui/model/ui.go+138−41
@@ -354,18 +354,16 @@ func (m *UI) loadCustomCommands() tea.Cmd {
354354}
355355
356356// loadMCPrompts loads the MCP prompts asynchronously.
357−func (m *UI) loadMCPrompts() tea.Cmd {
358− return func() tea.Msg {
359− prompts, err := commands.LoadMCPPrompts()
360− if err != nil {
361− slog.Error("Failed to load MCP prompts", "error", err)
362− }
363− if prompts == nil {
364− // flag them as loaded even if there is none or an error
365− prompts = []commands.MCPPrompt{}
366− }
367− return mcpPromptsLoadedMsg{Prompts: prompts}
357+func (m *UI) loadMCPrompts() tea.Msg {
358+ prompts, err := commands.LoadMCPPrompts()
359+ if err != nil {
360+ slog.Error("Failed to load MCP prompts", "error", err)
368361 }
362+ if prompts == nil {
363+ // flag them as loaded even if there is none or an error
364+ prompts = []commands.MCPPrompt{}
365+ }
366+ return mcpPromptsLoadedMsg{Prompts: prompts}
369367}
370368
371369// Update handles updates to the UI model.
@@ -505,17 +503,18 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
505503 case pubsub.Event[app.LSPEvent]:
506504 m.lspStates = app.GetLSPStates()
507505 case pubsub.Event[mcp.Event]:
508− m.mcpStates = mcp.GetStates()
509− // check if all mcps are initialized
510− initialized := true
511− for _, state := range m.mcpStates {
512− if state.State == mcp.StateStarting {
513− initialized = false
514− break
515− }
516− }
517− if initialized && m.mcpPrompts == nil {
518− cmds = append(cmds, m.loadMCPrompts())
506+ switch msg.Payload.Type {
507+ case mcp.EventStateChanged:
508+ return m, tea.Batch(
509+ m.handleStateChanged(),
510+ m.loadMCPrompts,
511+ )
512+ case mcp.EventPromptsListChanged:
513+ return m, handleMCPPromptsEvent(msg.Payload.Name)
514+ case mcp.EventToolsListChanged:
515+ return m, handleMCPToolsEvent(m.com.Config(), msg.Payload.Name)
516+ case mcp.EventResourcesListChanged:
517+ return m, handleMCPResourcesEvent(msg.Payload.Name)
519518 }
520519 case pubsub.Event[permission.PermissionRequest]:
521520 if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
@@ -713,10 +712,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
713712 cmds = append(cmds, clearInfoMsgCmd(ttl))
714713 case util.ClearStatusMsg:
715714 m.status.ClearInfoMsg()
716− case completions.FilesLoadedMsg:
717− // Handle async file loading for completions.
715+ case completions.CompletionItemsLoadedMsg:
718716 if m.completionsOpen {
719− m.completions.SetFiles(msg.Files)
717+ m.completions.SetItems(msg.Files, msg.Resources)
720718 }
721719 case uv.KittyGraphicsEvent:
722720 if !bytes.HasPrefix(msg.Payload, []byte("OK")) {
@@ -1517,12 +1515,14 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
15171515 if m.completionsOpen {
15181516 if msg, ok := m.completions.Update(msg); ok {
15191517 switch msg := msg.(type) {
1520− case completions.SelectionMsg:
1521− // Handle file completion selection.
1522− if item, ok := msg.Value.(completions.FileCompletionValue); ok {
1523− cmds = append(cmds, m.insertFileCompletion(item.Path))
1518+ case completions.SelectionMsg[completions.FileCompletionValue]:
1519+ cmds = append(cmds, m.insertFileCompletion(msg.Value.Path))
1520+ if !msg.KeepOpen {
1521+ m.closeCompletions()
15241522 }
1525− if !msg.Insert {
1523+ case completions.SelectionMsg[completions.ResourceCompletionValue]:
1524+ cmds = append(cmds, m.insertMCPResourceCompletion(msg.Value))
1525+ if !msg.KeepOpen {
15261526 m.closeCompletions()
15271527 }
15281528 case completions.ClosedMsg:
@@ -1636,7 +1636,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
16361636 m.completionsStartIndex = curIdx
16371637 m.completionsPositionStart = m.completionsPosition()
16381638 depth, limit := m.com.Config().Options.TUI.Completions.Limits()
1639− cmds = append(cmds, m.completions.OpenWithFiles(depth, limit))
1639+ cmds = append(cmds, m.completions.Open(depth, limit))
16401640 }
16411641 }
16421642
@@ -2475,24 +2475,29 @@ func (m *UI) closeCompletions() {
24752475 m.completions.Close()
24762476}
24772477
2478−// insertFileCompletion inserts the selected file path into the textarea,
2479−// replacing the @query, and adds the file as an attachment.
2480−func (m *UI) insertFileCompletion(path string) tea.Cmd {
2478+// insertCompletionText replaces the @query in the textarea with the given text.
2479+// Returns false if the replacement cannot be performed.
2480+func (m *UI) insertCompletionText(text string) bool {
24812481 value := m.textarea.Value()
2482− word := m.textareaWord()
2483−
2484− // Find the @ and query to replace.
24852482 if m.completionsStartIndex > len(value) {
2486− return nil
2483+ return false
24872484 }
24882485
2489− // Build the new value: everything before @, the path, everything after query.
2486+ word := m.textareaWord()
24902487 endIdx := min(m.completionsStartIndex+len(word), len(value))
2491−
2492− newValue := value[:m.completionsStartIndex] + path + value[endIdx:]
2488+ newValue := value[:m.completionsStartIndex] + text + value[endIdx:]
24932489 m.textarea.SetValue(newValue)
24942490 m.textarea.MoveToEnd()
24952491 m.textarea.InsertRune(' ')
2492+ return true
2493+}
2494+
2495+// insertFileCompletion inserts the selected file path into the textarea,
2496+// replacing the @query, and adds the file as an attachment.
2497+func (m *UI) insertFileCompletion(path string) tea.Cmd {
2498+ if !m.insertCompletionText(path) {
2499+ return nil
2500+ }
24962501
24972502 return func() tea.Msg {
24982503 absPath, _ := filepath.Abs(path)
@@ -2527,6 +2532,61 @@ func (m *UI) insertFileCompletion(path string) tea.Cmd {
25272532 }
25282533}
25292534
2535+// insertMCPResourceCompletion inserts the selected resource into the textarea,
2536+// replacing the @query, and adds the resource as an attachment.
2537+func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValue) tea.Cmd {
2538+ displayText := item.Title
2539+ if displayText == "" {
2540+ displayText = item.URI
2541+ }
2542+
2543+ if !m.insertCompletionText(displayText) {
2544+ return nil
2545+ }
2546+
2547+ return func() tea.Msg {
2548+ contents, err := mcp.ReadResource(
2549+ context.Background(),
2550+ m.com.Config(),
2551+ item.MCPName,
2552+ item.URI,
2553+ )
2554+ if err != nil {
2555+ slog.Warn("Failed to read MCP resource", "uri", item.URI, "error", err)
2556+ return nil
2557+ }
2558+ if len(contents) == 0 {
2559+ return nil
2560+ }
2561+
2562+ content := contents[0]
2563+ var data []byte
2564+ if content.Text != "" {
2565+ data = []byte(content.Text)
2566+ } else if len(content.Blob) > 0 {
2567+ data = content.Blob
2568+ }
2569+ if len(data) == 0 {
2570+ return nil
2571+ }
2572+
2573+ mimeType := item.MIMEType
2574+ if mimeType == "" && content.MIMEType != "" {
2575+ mimeType = content.MIMEType
2576+ }
2577+ if mimeType == "" {
2578+ mimeType = "text/plain"
2579+ }
2580+
2581+ return message.Attachment{
2582+ FilePath: item.URI,
2583+ FileName: displayText,
2584+ MimeType: mimeType,
2585+ Content: data,
2586+ }
2587+ }
2588+}
2589+
25302590// completionsPosition returns the X and Y position for the completions popup.
25312591func (m *UI) completionsPosition() image.Point {
25322592 cur := m.textarea.Cursor()
@@ -3088,6 +3148,43 @@ func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string
30883148 return tea.Sequence(cmds...)
30893149}
30903150
3151+func (m *UI) handleStateChanged() tea.Cmd {
3152+ slog.Warn("handleStateChanged")
3153+ return func() tea.Msg {
3154+ m.com.App.UpdateAgentModel(context.Background())
3155+ m.mcpStates = mcp.GetStates()
3156+ return nil
3157+ }
3158+}
3159+
3160+func handleMCPPromptsEvent(name string) tea.Cmd {
3161+ slog.Warn("handleMCPPromptsEvent")
3162+ return func() tea.Msg {
3163+ mcp.RefreshPrompts(context.Background(), name)
3164+ return nil
3165+ }
3166+}
3167+
3168+func handleMCPToolsEvent(cfg *config.Config, name string) tea.Cmd {
3169+ slog.Warn("handleMCPToolsEvent")
3170+ return func() tea.Msg {
3171+ mcp.RefreshTools(
3172+ context.Background(),
3173+ cfg,
3174+ name,
3175+ )
3176+ return nil
3177+ }
3178+}
3179+
3180+func handleMCPResourcesEvent(name string) tea.Cmd {
3181+ slog.Warn("handleMCPResourcesEvent")
3182+ return func() tea.Msg {
3183+ mcp.RefreshResources(context.Background(), name)
3184+ return nil
3185+ }
3186+}
3187+
30913188func (m *UI) copyChatHighlight() tea.Cmd {
30923189 text := m.chat.HighlightContent()
30933190 return common.CopyToClipboardWithCallback(