diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go
index fa0a9b90dc6fbf42f06ffa636f676ac0d94d79cc..bb617986701ba65b9f352e5cc9ae6d7ab7f286a2 100644
--- a/internal/agent/coordinator.go
+++ b/internal/agent/coordinator.go
@@ -337,6 +337,10 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
allTools = append(allTools, tools.NewDiagnosticsTool(c.lspClients), tools.NewReferencesTool(c.lspClients))
}
+ if len(c.cfg.MCP) > 0 {
+ allTools = append(allTools, tools.NewReadMCPResourceTool(), tools.NewListMCPResourceTool())
+ }
+
var filteredTools []fantasy.AgentTool
for _, tool := range allTools {
if slices.Contains(agent.AllowedTools, tool.Info().Name) {
diff --git a/internal/agent/tools/list_mcp_resource.md b/internal/agent/tools/list_mcp_resource.md
new file mode 100644
index 0000000000000000000000000000000000000000..6b29a8b06b4ad08a24bfd7024350bd46badecf4f
--- /dev/null
+++ b/internal/agent/tools/list_mcp_resource.md
@@ -0,0 +1,5 @@
+Lists all the resources available in a given MCP.
+
+
+- Provide MCP name from which to get the resource
+
diff --git a/internal/agent/tools/mcp/init.go b/internal/agent/tools/mcp/init.go
index cb97c0e440d87108d08a0c572c8ec2160ee5cf17..521252e0b8d03bc9692a272396c483c0f6641fec 100644
--- a/internal/agent/tools/mcp/init.go
+++ b/internal/agent/tools/mcp/init.go
@@ -64,6 +64,7 @@ const (
EventStateChanged EventType = iota
EventToolsListChanged
EventPromptsListChanged
+ EventResourcesListChanged
)
// Event represents an event in the MCP system
@@ -77,8 +78,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
@@ -178,13 +180,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
+ }
+
updateTools(name, tools)
updatePrompts(name, prompts)
+ updateResources(name, resources)
sessions.Set(name, session)
updateState(name, StateConnected, nil, session, Counts{
- Tools: len(tools),
- Prompts: len(prompts),
+ Tools: len(tools),
+ Prompts: len(prompts),
+ Resources: len(resources),
})
}(name, m)
}
@@ -281,8 +293,14 @@ func createSession(ctx context.Context, name string, m config.MCPConfig, resolve
Name: name,
})
},
+ ResourceListChangedHandler: func(context.Context, *mcp.ResourceListChangedRequest) {
+ broker.Publish(pubsub.UpdatedEvent, Event{
+ Type: EventResourcesListChanged,
+ Name: name,
+ })
+ },
LoggingMessageHandler: func(_ context.Context, req *mcp.LoggingMessageRequest) {
- slog.Info("mcp log", "name", name, "data", req.Params.Data)
+ slog.Log(ctx, parseLevel(req.Params.Level), "mcp server log", "data", req.Params.Data)
},
KeepAlive: time.Minute * 10,
},
@@ -303,6 +321,21 @@ func createSession(ctx context.Context, name string, m config.MCPConfig, resolve
return session, nil
}
+func parseLevel(level mcp.LoggingLevel) slog.Level {
+ switch level {
+ case "debug":
+ return slog.LevelDebug
+ case "info", "notice":
+ return slog.LevelInfo
+ case "warning", "warn":
+ return slog.LevelWarn
+ case "error", "emergency", "alert", "fatal":
+ fallthrough
+ default:
+ return slog.LevelError
+ }
+}
+
// maybeStdioErr if a stdio mcp prints an error in non-json format, it'll fail
// to parse, and the cli will then close it, causing the EOF error.
// so, if we got an EOF err, and the transport is STDIO, we try to exec it
diff --git a/internal/agent/tools/mcp/resources.go b/internal/agent/tools/mcp/resources.go
new file mode 100644
index 0000000000000000000000000000000000000000..3ab7581417e1eb3d119914bdb07504bd29ef6b8e
--- /dev/null
+++ b/internal/agent/tools/mcp/resources.go
@@ -0,0 +1,78 @@
+package mcp
+
+import (
+ "context"
+ "iter"
+ "log/slog"
+
+ "github.com/charmbracelet/crush/internal/csync"
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+type (
+ Resource = mcp.Resource
+ ResourceContents = mcp.ResourceContents
+)
+
+var allResources = csync.NewMap[string, []*Resource]()
+
+// Resources returns all available MCP resources.
+func Resources() iter.Seq2[string, []*Resource] {
+ return allResources.Seq2()
+}
+
+// ReadResource retrieves the content of an MCP resource with the given arguments.
+func ReadResource(ctx context.Context, clientName, uri string) ([]*ResourceContents, error) {
+ c, err := getOrRenewClient(ctx, clientName)
+ if err != nil {
+ return nil, err
+ }
+ result, err := c.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
+ }
+
+ updateResources(name, resources)
+
+ prev, _ := states.Get(name)
+ prev.Counts.Resources = len(resources)
+ 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(mcpName string, resources []*Resource) {
+ if len(resources) == 0 {
+ allResources.Del(mcpName)
+ return
+ }
+ allResources.Set(mcpName, resources)
+}
diff --git a/internal/agent/tools/mcp/tools.go b/internal/agent/tools/mcp/tools.go
index 561783e6f7986a772b2d1deb9bcdd6dac2bf8f34..a84873f93598fdb0e5dfc016da19cf5a2584655b 100644
--- a/internal/agent/tools/mcp/tools.go
+++ b/internal/agent/tools/mcp/tools.go
@@ -84,10 +84,10 @@ func getTools(ctx context.Context, session *mcp.ClientSession) ([]*Tool, error)
return result.Tools, nil
}
-func updateTools(name string, tools []*Tool) {
+func updateTools(mcpName string, tools []*Tool) {
if len(tools) == 0 {
- allTools.Del(name)
+ allTools.Del(mcpName)
return
}
- allTools.Set(name, tools)
+ allTools.Set(mcpName, tools)
}
diff --git a/internal/agent/tools/read_mcp_resource.md b/internal/agent/tools/read_mcp_resource.md
new file mode 100644
index 0000000000000000000000000000000000000000..b70dc5c313a1c45e073bded3aa8853ecfc9b75c1
--- /dev/null
+++ b/internal/agent/tools/read_mcp_resource.md
@@ -0,0 +1,6 @@
+Reads the content from a resource URI and returns it.
+
+
+- Provide MCP name from which to get the resource
+- Provide the resource URI
+
diff --git a/internal/agent/tools/resource.go b/internal/agent/tools/resource.go
new file mode 100644
index 0000000000000000000000000000000000000000..4830c2ab8991134bc35971ab2f1c5fd326a28912
--- /dev/null
+++ b/internal/agent/tools/resource.go
@@ -0,0 +1,74 @@
+package tools
+
+import (
+ "context"
+ _ "embed"
+ "log/slog"
+ "strings"
+
+ "charm.land/fantasy"
+ "github.com/charmbracelet/crush/internal/agent/tools/mcp"
+)
+
+type ReadMCPResourceParams struct {
+ Name string `json:"name" description:"MCP name"`
+ URI string `json:"uri,omitempty" description:"Resource URI"`
+}
+
+type ListMCPResourceParams struct {
+ Name string `json:"name" description:"MCP name"`
+}
+
+const (
+ ReadMCPResourceToolName = "read_mcp_resource"
+ ListMCPResourceToolName = "list_mcp_resources"
+)
+
+//go:embed read_mcp_resource.md
+var readMCPResourceDescription []byte
+
+//go:embed list_mcp_resource.md
+var listMCPResourceDescription []byte
+
+func NewReadMCPResourceTool() fantasy.AgentTool {
+ return fantasy.NewAgentTool(
+ ReadMCPResourceToolName,
+ string(readMCPResourceDescription),
+ func(ctx context.Context, input ReadMCPResourceParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+ resource, err := mcp.ReadResource(ctx, input.Name, input.URI)
+ if err != nil {
+ return fantasy.NewTextErrorResponse(err.Error()), nil
+ }
+ var sb strings.Builder
+ for _, part := range resource {
+ if !strings.HasPrefix(part.MIMEType, "text/") {
+ slog.Warn("ignoring resource of type", "type", part.MIMEType)
+ continue
+ }
+ sb.WriteString(part.Text)
+ }
+ return fantasy.NewTextResponse(sb.String()), nil
+ },
+ )
+}
+
+func NewListMCPResourceTool() fantasy.AgentTool {
+ return fantasy.NewAgentTool(
+ ListMCPResourceToolName,
+ string(listMCPResourceDescription),
+ func(ctx context.Context, input ListMCPResourceParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+ for name, resources := range mcp.Resources() {
+ if name != input.Name {
+ continue
+ }
+ var sb strings.Builder
+ sb.WriteString("Resources available for " + input.Name + ":\n\n")
+ for _, res := range resources {
+ sb.WriteString("- " + res.URI + "\n")
+ }
+ return fantasy.NewTextResponse(sb.String()), nil
+ }
+ return fantasy.NewTextResponse("No resources found for " + input.Name), nil
+ },
+ )
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 02c0b468d9dd30208f4ec6efd376306a34f504e4..14fdaae1a707170f9468f9a6dcd787f82fdb0723 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -481,6 +481,8 @@ func allToolNames() []string {
"sourcegraph",
"view",
"write",
+ "read_mcp_resource",
+ "list_mcp_resources",
}
}
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index 3efc581a88167493d520cc4a9d37f00e2296ac1b..3a001ab3a3cf04d65fc780a4f343f94a1d8c2cb9 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -149,6 +149,8 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, handleMCPPromptsEvent(context.Background(), msg.Payload.Name)
case mcp.EventToolsListChanged:
return a, handleMCPToolsEvent(context.Background(), msg.Payload.Name)
+ case mcp.EventResourcesListChanged:
+ return a, handleMCPResourcesEvent(context.Background(), msg.Payload.Name)
}
// Completions messages
@@ -650,6 +652,13 @@ func handleMCPToolsEvent(ctx context.Context, name string) tea.Cmd {
}
}
+func handleMCPResourcesEvent(ctx context.Context, name string) tea.Cmd {
+ return func() tea.Msg {
+ mcp.RefreshResources(ctx, name)
+ return nil
+ }
+}
+
// New creates and initializes a new TUI application model.
func New(app *app.App) *appModel {
chatPage := chat.New(app)