Detailed changes
@@ -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) {
@@ -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
+ },
+ )
+}
@@ -0,0 +1,18 @@
+Lists available resources from an MCP server.
+
+<when_to_use>
+Use this tool to discover which resources are available before reading them.
+</when_to_use>
+
+<usage>
+- Provide MCP server name
+- Returns resource titles and URIs
+</usage>
+
+<parameters>
+- mcp_name: The MCP server name
+</parameters>
+
+<notes>
+- Results include resource titles, URIs, and metadata when available
+</notes>
@@ -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)
},
},
)
@@ -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)
+}
@@ -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
+ },
+ )
+}
@@ -0,0 +1,20 @@
+Reads a resource from an MCP server and returns its contents.
+
+<when_to_use>
+Use this tool to fetch a specific resource URI exposed by an MCP server.
+</when_to_use>
+
+<usage>
+- Provide MCP server name and resource URI
+- Returns resource text content
+</usage>
+
+<parameters>
+- mcp_name: The MCP server name
+- uri: The resource URI to read
+</parameters>
+
+<notes>
+- Returns text content by concatenating resource parts
+- Binary resources are returned as UTF-8 text when possible
+</notes>
@@ -710,6 +710,8 @@ func allToolNames() []string {
"todos",
"view",
"write",
+ "list_mcp_resources",
+ "read_mcp_resource",
}
}
@@ -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)
@@ -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
+}
@@ -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
@@ -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, " ")
}
@@ -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(