From b66f96ea78b4cddc9aefc1c1e3094008419e3a0a Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 6 Feb 2026 09:52:18 -0300 Subject: [PATCH] feat(mcp): resources support (#2123) * feat(mcp): resources support Signed-off-by: Carlos Alexandro Becker * fix: log Signed-off-by: Carlos Alexandro Becker * fix: mcp events Signed-off-by: Carlos Alexandro Becker * wip Signed-off-by: Carlos Alexandro Becker --------- Signed-off-by: Carlos Alexandro Becker --- 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(-) create mode 100644 internal/agent/tools/list_mcp_resources.go create mode 100644 internal/agent/tools/list_mcp_resources.md create mode 100644 internal/agent/tools/mcp/resources.go create mode 100644 internal/agent/tools/read_mcp_resource.go create mode 100644 internal/agent/tools/read_mcp_resource.md diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index ad57b20c4470aa0180120798034db1bdb1de601a..d4e23af0c676307756f4e39fda7e10dfb2b6da5e 100644 --- a/internal/agent/coordinator.go +++ b/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) { diff --git a/internal/agent/tools/list_mcp_resources.go b/internal/agent/tools/list_mcp_resources.go new file mode 100644 index 0000000000000000000000000000000000000000..9b0417ed6343bc9680fbf8344f4a87a87bc2e015 --- /dev/null +++ b/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 + }, + ) +} diff --git a/internal/agent/tools/list_mcp_resources.md b/internal/agent/tools/list_mcp_resources.md new file mode 100644 index 0000000000000000000000000000000000000000..ee2695d0d775b060696e11e80281ed4e2d3dd7c6 --- /dev/null +++ b/internal/agent/tools/list_mcp_resources.md @@ -0,0 +1,18 @@ +Lists available resources from an MCP server. + + +Use this tool to discover which resources are available before reading them. + + + +- Provide MCP server name +- Returns resource titles and URIs + + + +- mcp_name: The MCP server name + + + +- Results include resource titles, URIs, and metadata when available + diff --git a/internal/agent/tools/mcp/init.go b/internal/agent/tools/mcp/init.go index 3138e07d57d96a25569a48dab5b79fb46f52759e..7fdd2cd0a6477fce7a9dea85473e87e83d8e1a35 100644 --- a/internal/agent/tools/mcp/init.go +++ b/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) }, }, ) diff --git a/internal/agent/tools/mcp/resources.go b/internal/agent/tools/mcp/resources.go new file mode 100644 index 0000000000000000000000000000000000000000..92f6c83836181a8441d35431f900f5c68334a9eb --- /dev/null +++ b/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) +} diff --git a/internal/agent/tools/read_mcp_resource.go b/internal/agent/tools/read_mcp_resource.go new file mode 100644 index 0000000000000000000000000000000000000000..cc0450d63aa94574e45e4264906c77fc2b7a1127 --- /dev/null +++ b/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 + }, + ) +} diff --git a/internal/agent/tools/read_mcp_resource.md b/internal/agent/tools/read_mcp_resource.md new file mode 100644 index 0000000000000000000000000000000000000000..72cb82bf22a926f1f21958d396359b54a73ec9c8 --- /dev/null +++ b/internal/agent/tools/read_mcp_resource.md @@ -0,0 +1,20 @@ +Reads a resource from an MCP server and returns its contents. + + +Use this tool to fetch a specific resource URI exposed by an MCP server. + + + +- Provide MCP server name and resource URI +- Returns resource text content + + + +- mcp_name: The MCP server name +- uri: The resource URI to read + + + +- Returns text content by concatenating resource parts +- Binary resources are returned as UTF-8 text when possible + diff --git a/internal/config/config.go b/internal/config/config.go index d5f3b8fb65b0d8d7f694fa3368d0263f4c3336a9..9a5f0eebfcd557f0ef71e5da471bc3348aeb5d55 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -710,6 +710,8 @@ func allToolNames() []string { "todos", "view", "write", + "list_mcp_resources", + "read_mcp_resource", } } diff --git a/internal/config/load_test.go b/internal/config/load_test.go index 60a0b7379501a7d766b33c4828c644cdb390bada..0e23d22485c23e2b5e1c5fb1a98a86b561e00540 100644 --- a/internal/config/load_test.go +++ b/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) diff --git a/internal/ui/completions/completions.go b/internal/ui/completions/completions.go index a23ba5bf181f00856082b17aed8ef1ba5a816e93..ae130777a9278eb834f2eb544f630a4f51b8b212 100644 --- a/internal/ui/completions/completions.go +++ b/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 +} diff --git a/internal/ui/completions/item.go b/internal/ui/completions/item.go index 1114083fd1a118649921ead3ea2288d6e6085632..3e99408dcc8e04288d5775dc01e17bcdd42a59a4 100644 --- a/internal/ui/completions/item.go +++ b/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 diff --git a/internal/ui/model/mcp.go b/internal/ui/model/mcp.go index 40be8619133268edbc53cf2bee863ed89a2af00f..3345841618f0fdb6663fec80eb7784b1297c329c 100644 --- a/internal/ui/model/mcp.go +++ b/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, " ") } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 7f4f01c5bdc2e7240716cc5c41a27892a4bcedde..ad2944ab8e255797a4aedc8a3035019c1788bf89 100644 --- a/internal/ui/model/ui.go +++ b/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(