on main help sign in
feat(mcp): resources support (#2123)
b66f96eCarlos Alexandro Becker
internal/agent/coordinator.go+8
@@ -439,6 +439,14 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
439 allTools = append(allTools, tools.NewDiagnosticsTool(c.lspClients), tools.NewReferencesTool(c.lspClients), tools.NewLSPRestartTool(c.lspClients))
439 allTools = append(allTools, tools.NewDiagnosticsTool(c.lspClients), tools.NewReferencesTool(c.lspClients), tools.NewLSPRestartTool(c.lspClients))
440 }
440 }
441
441
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+
442 var filteredTools []fantasy.AgentTool
450 var filteredTools []fantasy.AgentTool
443 for _, tool := range allTools {
451 for _, tool := range allTools {
444 if slices.Contains(agent.AllowedTools, tool.Info().Name) {
452 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 (
25 "github.com/modelcontextprotocol/go-sdk/mcp"
25 "github.com/modelcontextprotocol/go-sdk/mcp"
26)
26)
27
27
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+
28var (
41var (
29 sessions = csync.NewMap[string, *mcp.ClientSession]()
42 sessions = csync.NewMap[string, *mcp.ClientSession]()
30 states = csync.NewMap[string, ClientInfo]()
43 states = csync.NewMap[string, ClientInfo]()
@@ -65,6 +78,7 @@ const (
65 EventStateChanged EventType = iota
78 EventStateChanged EventType = iota
66 EventToolsListChanged
79 EventToolsListChanged
67 EventPromptsListChanged
80 EventPromptsListChanged
81+ EventResourcesListChanged
68)
82)
69
83
70// Event represents an event in the MCP system
84// Event represents an event in the MCP system
@@ -78,8 +92,9 @@ type Event struct {
78
92
79// Counts number of available tools, prompts, etc.
93// Counts number of available tools, prompts, etc.
80type Counts struct {
94type Counts struct {
81− Tools int
95+ Tools int
82− Prompts int
96+ Prompts int
97+ Resources int
83}
98}
84
99
85// ClientInfo holds information about an MCP client's state
100// ClientInfo holds information about an MCP client's state
@@ -189,13 +204,23 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config
189 return
204 return
190 }
205 }
191
206
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+
192 toolCount := updateTools(cfg, name, tools)
215 toolCount := updateTools(cfg, name, tools)
193 updatePrompts(name, prompts)
216 updatePrompts(name, prompts)
217+ resourceCount := updateResources(name, resources)
194 sessions.Set(name, session)
218 sessions.Set(name, session)
195
219
196 updateState(name, StateConnected, nil, session, Counts{
220 updateState(name, StateConnected, nil, session, Counts{
197− Tools: toolCount,
221+ Tools: toolCount,
198− Prompts: len(prompts),
222+ Prompts: len(prompts),
223+ Resources: resourceCount,
199 })
224 })
200 }(name, m)
225 }(name, m)
201 }
226 }
@@ -302,8 +327,15 @@ func createSession(ctx context.Context, name string, m config.MCPConfig, resolve
302 Name: name,
327 Name: name,
303 })
328 })
304 },
329 },
330+ ResourceListChangedHandler: func(context.Context, *mcp.ResourceListChangedRequest) {
331+ broker.Publish(pubsub.UpdatedEvent, Event{
332+ Type: EventResourcesListChanged,
333+ Name: name,
334+ })
335+ },
305− LoggingMessageHandler: func(_ context.Context, req *mcp.LoggingMessageRequest) {
336+ LoggingMessageHandler: func(ctx context.Context, req *mcp.LoggingMessageRequest) {
337+ level := parseLevel(req.Params.Level)
306− slog.Info("MCP log", "name", name, "data", req.Params.Data)
338+ slog.Log(ctx, level, "MCP log", "name", name, "logger", req.Params.Logger, "data", req.Params.Data)
307 },
339 },
308 },
340 },
309 )
341 )
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 {
710 "todos",
710 "todos",
711 "view",
711 "view",
712 "write",
712 "write",
713+ "list_mcp_resources",
714+ "read_mcp_resource",
713 }
715 }
714}
716}
715
717
internal/config/load_test.go+2−2
@@ -486,7 +486,7 @@ func TestConfig_setupAgentsWithDisabledTools(t *testing.T) {
486 coderAgent, ok := cfg.Agents[AgentCoder]
486 coderAgent, ok := cfg.Agents[AgentCoder]
487 require.True(t, ok)
487 require.True(t, ok)
488
488
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)
490
490
491 taskAgent, ok := cfg.Agents[AgentTask]
491 taskAgent, ok := cfg.Agents[AgentTask]
492 require.True(t, ok)
492 require.True(t, ok)
@@ -509,7 +509,7 @@ func TestConfig_setupAgentsWithEveryReadOnlyToolDisabled(t *testing.T) {
509 cfg.SetupAgents()
509 cfg.SetupAgents()
510 coderAgent, ok := cfg.Agents[AgentCoder]
510 coderAgent, ok := cfg.Agents[AgentCoder]
511 require.True(t, ok)
511 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)
513
513
514 taskAgent, ok := cfg.Agents[AgentTask]
514 taskAgent, ok := cfg.Agents[AgentTask]
515 require.True(t, ok)
515 require.True(t, ok)
internal/ui/completions/completions.go+83−22
@@ -1,12 +1,15 @@
1package completions
1package completions
2
2
3import (
3import (
4+ "cmp"
4 "slices"
5 "slices"
5 "strings"
6 "strings"
7+ "sync"
6
8
7 "charm.land/bubbles/v2/key"
9 "charm.land/bubbles/v2/key"
8 tea "charm.land/bubbletea/v2"
10 tea "charm.land/bubbletea/v2"
9 "charm.land/lipgloss/v2"
11 "charm.land/lipgloss/v2"
12+ "github.com/charmbracelet/crush/internal/agent/tools/mcp"
10 "github.com/charmbracelet/crush/internal/fsext"
13 "github.com/charmbracelet/crush/internal/fsext"
11 "github.com/charmbracelet/crush/internal/ui/list"
14 "github.com/charmbracelet/crush/internal/ui/list"
12 "github.com/charmbracelet/x/ansi"
15 "github.com/charmbracelet/x/ansi"
@@ -21,17 +24,18 @@ const (
21)
24)
22
25
23// SelectionMsg is sent when a completion is selected.
26// SelectionMsg is sent when a completion is selected.
24−type SelectionMsg struct {
27+type SelectionMsg[T any] struct {
25− Value any
28+ Value T
26− Insert bool // If true, insert without closing.
29+ KeepOpen bool // If true, insert without closing.
27}
30}
28
31
29// ClosedMsg is sent when the completions are closed.
32// ClosedMsg is sent when the completions are closed.
30type ClosedMsg struct{}
33type ClosedMsg struct{}
31
34
32−// FilesLoadedMsg is sent when files have been loaded for completions.
35+// CompletionItemsLoadedMsg is sent when files have been loaded for completions.
33−type FilesLoadedMsg struct {
36+type CompletionItemsLoadedMsg struct {
34− Files []string
37+ Files []FileCompletionValue
38+ Resources []ResourceCompletionValue
35}
39}
36
40
37// Completions represents the completions popup component.
41// Completions represents the completions popup component.
@@ -92,23 +96,43 @@ func (c *Completions) KeyMap() KeyMap {
92 return c.keyMap
96 return c.keyMap
93}
97}
94
98
95−// OpenWithFiles opens the completions with file items from the filesystem.
99+// Open opens the completions with file items from the filesystem.
96−func (c *Completions) OpenWithFiles(depth, limit int) tea.Cmd {
100+func (c *Completions) Open(depth, limit int) tea.Cmd {
97 return func() tea.Msg {
101 return func() tea.Msg {
98− files, _, _ := fsext.ListDirectory(".", nil, depth, limit)
102+ var msg CompletionItemsLoadedMsg
99− slices.Sort(files)
103+ var wg sync.WaitGroup
100− return FilesLoadedMsg{Files: files}
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
101 }
112 }
102}
113}
103
114
104−// SetFiles sets the file items on the completions popup.
115+// SetItems sets the files and MCP resources and rebuilds the merged list.
105−func (c *Completions) SetFiles(files []string) {
116+func (c *Completions) SetItems(files []FileCompletionValue, resources []ResourceCompletionValue) {
106− items := make([]list.FilterableItem, 0, len(files))
117+ items := make([]list.FilterableItem, 0, len(files)+len(resources))
118+
119+ // Add files first.
107 for _, file := range files {
120 for _, file := range files {
108− file = strings.TrimPrefix(file, "./")
109 item := NewCompletionItem(
121 item := NewCompletionItem(
122+ file.Path,
110 file,
123 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,
112 c.normalStyle,
136 c.normalStyle,
113 c.focusedStyle,
137 c.focusedStyle,
114 c.matchStyle,
138 c.matchStyle,
@@ -119,7 +143,7 @@ func (c *Completions) SetFiles(files []string) {
119 c.open = true
143 c.open = true
120 c.query = ""
144 c.query = ""
121 c.list.SetItems(items...)
145 c.list.SetItems(items...)
122− c.list.SetFilter("") // Clear any previous filter.
146+ c.list.SetFilter("")
123 c.list.Focus()
147 c.list.Focus()
124
148
125 c.width = maxWidth
149 c.width = maxWidth
@@ -232,7 +256,7 @@ func (c *Completions) selectNext() {
232}
256}
233
257
234// selectCurrent returns a command with the currently selected item.
258// 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 {
236 items := c.list.FilteredItems()
260 items := c.list.FilteredItems()
237 if len(items) == 0 {
261 if len(items) == 0 {
238 return nil
262 return nil
@@ -248,13 +272,23 @@ func (c *Completions) selectCurrent(insert bool) tea.Msg {
248 return nil
272 return nil
249 }
273 }
250
274
251− if !insert {
275+ if !keepOpen {
252 c.open = false
276 c.open = false
253 }
277 }
254
278
279+ switch item := item.Value().(type) {
280+ case ResourceCompletionValue:
255− return SelectionMsg{
281+ return SelectionMsg[ResourceCompletionValue]{
282+ Value: item,
283+ KeepOpen: keepOpen,
284+ }
285+ case FileCompletionValue:
286+ return SelectionMsg[FileCompletionValue]{
256− Value: item.Value(),
287+ Value: item,
257− Insert: insert,
288+ KeepOpen: keepOpen,
289+ }
290+ default:
291+ return nil
258 }
292 }
259}
293}
260
294
@@ -271,3 +305,30 @@ func (c *Completions) Render() string {
271
305
272 return c.list.Render()
306 return c.list.Render()
273}
307}
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 {
13 Path string
13 Path string
14}
14}
15
15
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+
16// CompletionItem represents an item in the completions list.
24// CompletionItem represents an item in the completions list.
17type CompletionItem struct {
25type CompletionItem struct {
18 text string
26 text string
internal/ui/model/mcp.go+4−1
@@ -34,7 +34,7 @@ func (m *UI) mcpInfo(width, maxItems int, isSection bool) string {
34 return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list))
34 return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list))
35}
35}
36
36
37−// mcpCounts formats tool and prompt counts for display.
37+// mcpCounts formats tool, prompt, and resource counts for display.
38func mcpCounts(t *styles.Styles, counts mcp.Counts) string {
38func mcpCounts(t *styles.Styles, counts mcp.Counts) string {
39 parts := []string{}
39 parts := []string{}
40 if counts.Tools > 0 {
40 if counts.Tools > 0 {
@@ -43,6 +43,9 @@ func mcpCounts(t *styles.Styles, counts mcp.Counts) string {
43 if counts.Prompts > 0 {
43 if counts.Prompts > 0 {
44 parts = append(parts, t.Subtle.Render(fmt.Sprintf("%d prompts", counts.Prompts)))
44 parts = append(parts, t.Subtle.Render(fmt.Sprintf("%d prompts", counts.Prompts)))
45 }
45 }
46+ if counts.Resources > 0 {
47+ parts = append(parts, t.Subtle.Render(fmt.Sprintf("%d resources", counts.Resources)))
48+ }
46 return strings.Join(parts, " ")
49 return strings.Join(parts, " ")
47}
50}
48
51
internal/ui/model/ui.go+138−41
@@ -354,18 +354,16 @@ func (m *UI) loadCustomCommands() tea.Cmd {
354}
354}
355
355
356// loadMCPrompts loads the MCP prompts asynchronously.
356// loadMCPrompts loads the MCP prompts asynchronously.
357−func (m *UI) loadMCPrompts() tea.Cmd {
357+func (m *UI) loadMCPrompts() tea.Msg {
358− return func() tea.Msg {
359− prompts, err := commands.LoadMCPPrompts()
358+ prompts, err := commands.LoadMCPPrompts()
360− if err != nil {
359+ if err != nil {
361− slog.Error("Failed to load MCP prompts", "error", err)
360+ 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}
368 }
361 }
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}
369}
367}
370
368
371// Update handles updates to the UI model.
369// Update handles updates to the UI model.
@@ -505,17 +503,18 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
505 case pubsub.Event[app.LSPEvent]:
503 case pubsub.Event[app.LSPEvent]:
506 m.lspStates = app.GetLSPStates()
504 m.lspStates = app.GetLSPStates()
507 case pubsub.Event[mcp.Event]:
505 case pubsub.Event[mcp.Event]:
508− m.mcpStates = mcp.GetStates()
506+ switch msg.Payload.Type {
509− // check if all mcps are initialized
507+ case mcp.EventStateChanged:
510− initialized := true
508+ return m, tea.Batch(
511− for _, state := range m.mcpStates {
509+ m.handleStateChanged(),
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())
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)
519 }
518 }
520 case pubsub.Event[permission.PermissionRequest]:
519 case pubsub.Event[permission.PermissionRequest]:
521 if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
520 if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
@@ -713,10 +712,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
713 cmds = append(cmds, clearInfoMsgCmd(ttl))
712 cmds = append(cmds, clearInfoMsgCmd(ttl))
714 case util.ClearStatusMsg:
713 case util.ClearStatusMsg:
715 m.status.ClearInfoMsg()
714 m.status.ClearInfoMsg()
716− case completions.FilesLoadedMsg:
715+ case completions.CompletionItemsLoadedMsg:
717− // Handle async file loading for completions.
718 if m.completionsOpen {
716 if m.completionsOpen {
719− m.completions.SetFiles(msg.Files)
717+ m.completions.SetItems(msg.Files, msg.Resources)
720 }
718 }
721 case uv.KittyGraphicsEvent:
719 case uv.KittyGraphicsEvent:
722 if !bytes.HasPrefix(msg.Payload, []byte("OK")) {
720 if !bytes.HasPrefix(msg.Payload, []byte("OK")) {
@@ -1517,12 +1515,14 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1517 if m.completionsOpen {
1515 if m.completionsOpen {
1518 if msg, ok := m.completions.Update(msg); ok {
1516 if msg, ok := m.completions.Update(msg); ok {
1519 switch msg := msg.(type) {
1517 switch msg := msg.(type) {
1520− case completions.SelectionMsg:
1518+ case completions.SelectionMsg[completions.FileCompletionValue]:
1521− // Handle file completion selection.
1522− if item, ok := msg.Value.(completions.FileCompletionValue); ok {
1523− cmds = append(cmds, m.insertFileCompletion(item.Path))
1519+ cmds = append(cmds, m.insertFileCompletion(msg.Value.Path))
1520+ if !msg.KeepOpen {
1521+ m.closeCompletions()
1524 }
1522 }
1523+ case completions.SelectionMsg[completions.ResourceCompletionValue]:
1524+ cmds = append(cmds, m.insertMCPResourceCompletion(msg.Value))
1525− if !msg.Insert {
1525+ if !msg.KeepOpen {
1526 m.closeCompletions()
1526 m.closeCompletions()
1527 }
1527 }
1528 case completions.ClosedMsg:
1528 case completions.ClosedMsg:
@@ -1636,7 +1636,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1636 m.completionsStartIndex = curIdx
1636 m.completionsStartIndex = curIdx
1637 m.completionsPositionStart = m.completionsPosition()
1637 m.completionsPositionStart = m.completionsPosition()
1638 depth, limit := m.com.Config().Options.TUI.Completions.Limits()
1638 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))
1640 }
1640 }
1641 }
1641 }
1642
1642
@@ -2475,24 +2475,29 @@ func (m *UI) closeCompletions() {
2475 m.completions.Close()
2475 m.completions.Close()
2476}
2476}
2477
2477
2478−// insertFileCompletion inserts the selected file path into the textarea,
2478+// insertCompletionText replaces the @query in the textarea with the given text.
2479−// replacing the @query, and adds the file as an attachment.
2479+// Returns false if the replacement cannot be performed.
2480−func (m *UI) insertFileCompletion(path string) tea.Cmd {
2480+func (m *UI) insertCompletionText(text string) bool {
2481 value := m.textarea.Value()
2481 value := m.textarea.Value()
2482− word := m.textareaWord()
2483−
2484− // Find the @ and query to replace.
2485 if m.completionsStartIndex > len(value) {
2482 if m.completionsStartIndex > len(value) {
2486− return nil
2483+ return false
2487 }
2484 }
2488
2485
2489− // Build the new value: everything before @, the path, everything after query.
2486+ word := m.textareaWord()
2490 endIdx := min(m.completionsStartIndex+len(word), len(value))
2487 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:]
2493 m.textarea.SetValue(newValue)
2489 m.textarea.SetValue(newValue)
2494 m.textarea.MoveToEnd()
2490 m.textarea.MoveToEnd()
2495 m.textarea.InsertRune(' ')
2491 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+ }
2496
2501
2497 return func() tea.Msg {
2502 return func() tea.Msg {
2498 absPath, _ := filepath.Abs(path)
2503 absPath, _ := filepath.Abs(path)
@@ -2527,6 +2532,61 @@ func (m *UI) insertFileCompletion(path string) tea.Cmd {
2527 }
2532 }
2528}
2533}
2529
2534
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+
2530// completionsPosition returns the X and Y position for the completions popup.
2590// completionsPosition returns the X and Y position for the completions popup.
2531func (m *UI) completionsPosition() image.Point {
2591func (m *UI) completionsPosition() image.Point {
2532 cur := m.textarea.Cursor()
2592 cur := m.textarea.Cursor()
@@ -3088,6 +3148,43 @@ func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string
3088 return tea.Sequence(cmds...)
3148 return tea.Sequence(cmds...)
3089}
3149}
3090
3150
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+
3091func (m *UI) copyChatHighlight() tea.Cmd {
3188func (m *UI) copyChatHighlight() tea.Cmd {
3092 text := m.chat.HighlightContent()
3189 text := m.chat.HighlightContent()
3093 return common.CopyToClipboardWithCallback(
3190 return common.CopyToClipboardWithCallback(