From 7c29be2a73f2ccf8f8f94e71a04b7ff26b109012 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Tue, 4 Nov 2025 14:09:16 -0300 Subject: [PATCH] feat(mcp): resources, read/list resource tools, server logs Signed-off-by: Carlos Alexandro Becker --- internal/agent/coordinator.go | 4 ++ internal/agent/tools/list_mcp_resource.md | 5 ++ internal/agent/tools/mcp/init.go | 43 +++++++++++-- internal/agent/tools/mcp/resources.go | 78 +++++++++++++++++++++++ internal/agent/tools/mcp/tools.go | 6 +- internal/agent/tools/read_mcp_resource.md | 6 ++ internal/agent/tools/resource.go | 74 +++++++++++++++++++++ internal/config/config.go | 2 + internal/tui/tui.go | 9 +++ 9 files changed, 219 insertions(+), 8 deletions(-) create mode 100644 internal/agent/tools/list_mcp_resource.md create mode 100644 internal/agent/tools/mcp/resources.go create mode 100644 internal/agent/tools/read_mcp_resource.md create mode 100644 internal/agent/tools/resource.go 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)