diff --git a/internal/agent/credhint.go b/internal/agent/credhint.go new file mode 100644 index 0000000..01e9dc7 --- /dev/null +++ b/internal/agent/credhint.go @@ -0,0 +1,25 @@ +package agent + +import ( + "regexp" + + "github.com/rootlyhq/rootly-cli/internal/mcp" +) + +const localCredsHint = "Refresh local creds, e.g. `aws sso login`" + +var ( + expiredLocalCredsPattern = regexp.MustCompile(`(?i)(sso session .*(expired|invalid)|token (has|is) expired|expiredtoken|expired ?token|credentials? (have|has) expired|aws sso login|unable to locate credentials)`) + deniedCredsPattern = regexp.MustCompile(`(?i)(\b401\b|\b403\b|unauthori[sz]ed|forbidden|invalid[_ ]token|access denied)`) +) + +// looksLikeExpiredCreds reports whether a local tool result resembles stale local credentials. +func looksLikeExpiredCreds(result mcp.CallResult) bool { + if result.Output == userDeclinedOutput { + return false + } + if expiredLocalCredsPattern.MatchString(result.Output) { + return true + } + return result.IsError && deniedCredsPattern.MatchString(result.Output) +} diff --git a/internal/agent/credhint_test.go b/internal/agent/credhint_test.go new file mode 100644 index 0000000..e777c68 --- /dev/null +++ b/internal/agent/credhint_test.go @@ -0,0 +1,56 @@ +package agent + +import ( + "context" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/rootlyhq/rootly-cli/internal/mcp" +) + +func TestLooksLikeExpiredCreds(t *testing.T) { + cases := []struct { + result mcp.CallResult + want bool + }{ + {mcp.CallResult{Output: "Error when retrieving token from sso: Token has expired and refresh failed"}, true}, + {mcp.CallResult{Output: "The SSO session associated with this profile has expired or is otherwise invalid."}, true}, + {mcp.CallResult{Output: "An error occurred (ExpiredToken) when calling the GetCallerIdentity operation"}, true}, + {mcp.CallResult{Output: "HTTP 401 Unauthorized", IsError: true}, true}, + {mcp.CallResult{Output: "request failed: 403 Forbidden", IsError: true}, true}, + {mcp.CallResult{Output: "found 401 rows in table forbidden_words"}, false}, + {mcp.CallResult{Output: userDeclinedOutput, IsError: true}, false}, + {mcp.CallResult{Output: "file not found", IsError: true}, false}, + } + for _, tc := range cases { + if got := looksLikeExpiredCreds(tc.result); got != tc.want { + t.Errorf("looksLikeExpiredCreds(%q, error=%v) = %v, want %v", tc.result.Output, tc.result.IsError, got, tc.want) + } + } +} + +func TestCredHintShownOnceInTUI(t *testing.T) { + model := NewTUIModel(context.Background(), &fakeStreamer{}, TUIOptions{NoColor: true}) + model.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + model.Init() + model.Update(localToolDoneMsg{turn: model.turn, credHint: true}) + model.Update(localToolDoneMsg{turn: model.turn, credHint: true}) + model.Update(localToolDoneMsg{turn: model.turn}) + if got := strings.Count(model.Transcript(), localCredsHint); got != 1 { + t.Fatalf("hint shown %d times, want once:\n%s", got, model.Transcript()) + } +} + +func TestCredHintShownOnceInPrintMode(t *testing.T) { + tools := &fakeLocalTools{ + tools: map[string]mcp.ToolRef{"local_aws_whoami": {ServerID: "user:aws", ServerName: "aws", Tool: "whoami", ReadOnly: true}}, + output: "The SSO session associated with this profile has expired or is otherwise invalid.", + } + trip := newToolRoundTrip(t, 2, "local_aws_whoami") + _, stderr, _ := trip.run(t, tools, true, "") + if got := strings.Count(stderr, localCredsHint); got != 1 { + t.Fatalf("hint shown %d times, want once:\n%s", got, stderr) + } +} diff --git a/internal/agent/localtools_test.go b/internal/agent/localtools_test.go index 70a2a51..2c055a1 100644 --- a/internal/agent/localtools_test.go +++ b/internal/agent/localtools_test.go @@ -21,9 +21,10 @@ import ( const localTestSessionID = "39fd7596-152c-43d3-afce-48b792ae386d" type fakeLocalTools struct { - mu sync.Mutex - tools map[string]mcp.ToolRef - calls []string + mu sync.Mutex + tools map[string]mcp.ToolRef + calls []string + output string } func (f *fakeLocalTools) ClientTools() (tools []mcp.ClientTool, omitted int) { @@ -43,6 +44,9 @@ func (f *fakeLocalTools) Call(_ context.Context, name string, arguments map[stri f.mu.Lock() defer f.mu.Unlock() f.calls = append(f.calls, name) + if f.output != "" { + return mcp.CallResult{Output: f.output, IsError: true} + } return mcp.CallResult{Output: fmt.Sprintf("ran %s with %v", name, arguments["path"])} } diff --git a/internal/agent/market.go b/internal/agent/market.go new file mode 100644 index 0000000..a58b77b --- /dev/null +++ b/internal/agent/market.go @@ -0,0 +1,209 @@ +package agent + +import ( + "context" + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/rootlyhq/rootly-cli/internal/mcp" +) + +const marketResultLimit = 50 + +var loadMarketCatalog = func(ctx context.Context, refresh bool) ([]mcp.MarketEntry, error) { + return mcp.LoadCatalog(ctx, nil, mcp.RegistryURL, mcp.IndexPath(), refresh, nil) +} + +type marketState struct { + query string + loading bool + err string + catalog []mcp.MarketEntry + results []mcp.MarketEntry + cursor int + offset int + preview *mcp.MarketEntry + request int +} + +type marketLoadedMsg struct { + request int + catalog []mcp.MarketEntry + err error +} + +func (m *TUIModel) openMarket() tea.Cmd { + if m.mcp == nil { + m.mcpPage.message = "Local MCP is unavailable in this session" + return nil + } + m.mcpPage.market = &marketState{} + return m.loadMarket(false) +} + +func (m *TUIModel) loadMarket(refresh bool) tea.Cmd { + market := m.mcpPage.market + m.marketRequest++ + market.request = m.marketRequest + market.loading, market.err = true, "" + request, ctx := market.request, m.ctx + return func() tea.Msg { + catalog, err := loadMarketCatalog(ctx, refresh) + return marketLoadedMsg{request: request, catalog: catalog, err: err} + } +} + +func (m *TUIModel) handleMarketLoaded(msg marketLoadedMsg) { + market := m.mcpPage.market + if market == nil || msg.request != market.request { + return + } + market.loading, market.catalog = false, msg.catalog + if msg.err != nil { + market.err = sanitizeTerminalText(msg.err.Error()) + } + m.searchMarket() +} + +func (m *TUIModel) searchMarket() { + market := m.mcpPage.market + market.results = mcp.SearchMarket(market.catalog, market.query, marketResultLimit) + market.cursor, market.offset = 0, 0 +} + +func (m *TUIModel) handleMarketKey(msg tea.KeyPressMsg) tea.Cmd { + market := m.mcpPage.market + key := msg.String() + if market.preview != nil { + switch key { + case "y": + m.addFromMarket(*market.preview) + case "n", "esc", "backspace": + market.preview = nil + } + return nil + } + switch key { + case "esc", "ctrl+c": + m.mcpPage.market = nil + case "up": + market.cursor = max(0, market.cursor-1) + case "down": + market.cursor = min(max(0, len(market.results)-1), market.cursor+1) + case "enter": + if market.cursor < len(market.results) { + entry := market.results[market.cursor] + market.preview = &entry + } + case "ctrl+r": + return m.loadMarket(true) + case "backspace": + if market.query != "" { + runes := []rune(market.query) + market.query = string(runes[:len(runes)-1]) + m.searchMarket() + } + default: + if msg.Text != "" { + market.query += msg.Text + m.searchMarket() + } + } + return nil +} + +func (m *TUIModel) addFromMarket(entry mcp.MarketEntry) { + server, err := m.mcp.AddFromMarket(entry) + if err != nil { + m.mcpPage.market.preview = nil + m.mcpPage.market.err = sanitizeTerminalText(err.Error()) + return + } + m.mcpPage.market = nil + m.mcpPage.message = fmt.Sprintf("Added %s to ~/.rootly-cli/mcp.json (off). Select it and press space to turn it on", sanitizeTerminalText(server.Name)) + m.refreshMCPViews() + for index, row := range m.mcpRows() { + if row.kind == mcpRowServer && row.view.Server.ID() == server.ID() { + m.mcpPage.cursor = index + } + } +} + +func (m *TUIModel) marketView() string { + market := m.mcpPage.market + width := max(20, m.width-1) + bold := lipgloss.NewStyle().Bold(true) + if market.preview != nil { + return m.marketPreviewView(*market.preview, width, bold) + } + lines := []string{m.mcpStyle(bold, "MCP marketplace"), faint(m.noColor, "Official MCP registry + curated list · Rootly-integrated vendors first"), "Search: " + sanitizeTerminalText(market.query) + "▌"} + switch { + case market.loading && len(market.catalog) == 0: + lines = append(lines, "", faint(m.noColor, "Indexing the MCP registry (cached in ~/.rootly-cli/mcp-index.json)…")) + case market.err != "": + lines = append(lines, faint(m.noColor, "✘ "+market.err)) + default: + lines = append(lines, "") + } + height := max(1, m.mcpListHeight()-2) + if market.cursor < market.offset { + market.offset = market.cursor + } + if market.cursor >= market.offset+height { + market.offset = market.cursor - height + 1 + } + end := min(len(market.results), market.offset+height) + for index := market.offset; index < end; index++ { + lines = append(lines, ansi.Truncate(m.renderMarketRow(market.results[index], index == market.cursor), width, "…")) + } + if len(market.results) == 0 && !market.loading { + lines = append(lines, faint(m.noColor, "No MCP servers match")) + } + for len(lines) < height+4 { + lines = append(lines, "") + } + lines = append(lines, "", faint(m.noColor, ansi.Truncate("type to search · ↑/↓ move · Enter preview · ctrl+r refresh index · Esc back", width, "…"))) + return strings.Join(lines, "\n") +} + +func (m *TUIModel) renderMarketRow(entry mcp.MarketEntry, selected bool) string { + marker := " " + if selected { + marker = "› " + } + name := sanitizeTerminalText(entry.Name) + if entry.Rootly { + name += " ★" + } + return marker + fmt.Sprintf("%-24s ", name) + faint(m.noColor, sanitizeTerminalText(strings.Join(strings.Fields(entry.Description), " "))) +} + +func (m *TUIModel) marketPreviewView(entry mcp.MarketEntry, width int, bold lipgloss.Style) string { + lines := []string{m.mcpStyle(bold, "Add "+sanitizeTerminalText(entry.Name)+" to your user MCPs?"), ""} + if entry.Description != "" { + lines = append(lines, sanitizeTerminalText(strings.Join(strings.Fields(entry.Description), " ")), "") + } + verb := "Runs" + if entry.Config.URL != "" { + verb = "Connects to" + } + lines = append(lines, + verb+": "+sanitizeTerminalText(mcp.InstallLabel(entry)), + "Writes to: ~/.rootly-cli/mcp.json (user scope)", + ) + if entry.Author != "" { + lines = append(lines, "Publisher: "+sanitizeTerminalText(entry.Author)) + } + if entry.EnvHint != "" { + lines = append(lines, "Needs: "+sanitizeTerminalText(entry.EnvHint)) + } + lines = append(lines, "", "It is added switched off: nothing runs until you turn it on in /mcp.", "", faint(m.noColor, "y add · n back")) + for index, line := range lines { + lines[index] = ansi.Truncate(line, width, "…") + } + return strings.Join(lines, "\n") +} diff --git a/internal/agent/market_test.go b/internal/agent/market_test.go new file mode 100644 index 0000000..d4608b4 --- /dev/null +++ b/internal/agent/market_test.go @@ -0,0 +1,144 @@ +package agent + +import ( + "context" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/rootlyhq/rootly-cli/internal/mcp" +) + +func stubMarket(t *testing.T) *int { + t.Helper() + calls := 0 + previous := loadMarketCatalog + loadMarketCatalog = func(context.Context, bool) ([]mcp.MarketEntry, error) { + calls++ + return mcp.Catalog([]mcp.MarketEntry{{Name: "pager-mcp", Description: "Paging tool", Author: "acme", Config: mcp.ServerConfig{Command: "npx", Args: []string{"-y", "@acme/pager-mcp"}}}}), nil + } + t.Cleanup(func() { loadMarketCatalog = previous }) + return &calls +} + +func typeKeys(model *TUIModel, text string) { + for _, char := range text { + model.Update(tea.KeyPressMsg{Code: char, Text: string(char)}) + } +} + +func TestMarketplaceSearchPreviewAndAddToUserScopeOff(t *testing.T) { + calls := stubMarket(t) + manager := &fakeMCPManager{views: testViews(), changes: make(chan struct{})} + model := newMCPTestModel(t, manager, &fakeConnectorAPI{}) + model.mcpPage.open = true + _, cmd := model.Update(tea.KeyPressMsg{Code: 'm', Text: "m"}) + runCmd(model, cmd) + if *calls != 1 || model.mcpPage.market == nil { + t.Fatalf("marketplace did not open: calls=%d", *calls) + } + view := model.mcpPageView() + if !strings.Contains(view, "MCP marketplace") || strings.Index(view, "datadog ★") > strings.Index(view, "github") { + t.Fatalf("Rootly vendors should be listed first:\n%s", view) + } + typeKeys(model, "pager") + if got := model.mcpPage.market.results; len(got) == 0 || got[0].Name != "pager-mcp" { + t.Fatalf("results = %+v", got) + } + model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + preview := model.mcpPageView() + for _, want := range []string{"Add pager-mcp to your user MCPs?", "Runs: npx -y @acme/pager-mcp", "Writes to: ~/.rootly-cli/mcp.json (user scope)", "nothing runs until you turn it on"} { + if !strings.Contains(preview, want) { + t.Fatalf("preview missing %q:\n%s", want, preview) + } + } + if len(manager.added) != 0 { + t.Fatal("nothing may be added before y") + } + model.Update(tea.KeyPressMsg{Code: 'y', Text: "y"}) + if len(manager.added) != 1 || manager.added[0].Name != "pager-mcp" || model.mcpPage.market != nil { + t.Fatalf("added = %+v market=%v", manager.added, model.mcpPage.market) + } + if !strings.Contains(model.mcpPage.message, "Added pager-mcp") || !strings.Contains(model.mcpPage.message, "(off)") { + t.Fatalf("message = %q", model.mcpPage.message) + } + if row, ok := model.selectedMCPRow(); !ok || row.view.Server.Name != "pager-mcp" || row.view.Status != mcp.StatusOff { + t.Fatalf("cursor should land on the new server, switched off: %+v", row) + } +} + +func TestMarketplaceDeclineAndDuplicate(t *testing.T) { + stubMarket(t) + manager := &fakeMCPManager{views: testViews(), changes: make(chan struct{})} + model := newMCPTestModel(t, manager, &fakeConnectorAPI{}) + model.mcpPage.open = true + _, cmd := model.Update(tea.KeyPressMsg{Code: 'm', Text: "m"}) + runCmd(model, cmd) + model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model.Update(tea.KeyPressMsg{Code: 'n', Text: "n"}) + if model.mcpPage.market.preview != nil || len(manager.added) != 0 { + t.Fatal("n should go back without adding") + } + typeKeys(model, "sentry") + model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model.Update(tea.KeyPressMsg{Code: 'y', Text: "y"}) + if len(manager.added) != 0 || !strings.Contains(model.mcpPage.market.err, "already exists") { + t.Fatalf("duplicate should be refused: added=%v err=%q", manager.added, model.mcpPage.market.err) + } + model.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if model.mcpPage.market != nil || !model.mcpPage.open { + t.Fatal("Esc should return to the /mcp list") + } +} + +func TestMarketplaceIgnoresLoadsFromEarlierVisits(t *testing.T) { + stubMarket(t) + manager := &fakeMCPManager{views: testViews(), changes: make(chan struct{})} + model := newMCPTestModel(t, manager, &fakeConnectorAPI{}) + model.mcpPage.open = true + model.openMarket() + first := model.mcpPage.market.request + model.mcpPage.market = nil + model.openMarket() + if model.mcpPage.market.request == first { + t.Fatal("a new marketplace visit must not reuse the previous request id") + } + model.handleMarketLoaded(marketLoadedMsg{request: first, catalog: []mcp.MarketEntry{{Name: "stale"}}}) + if model.mcpPage.market.catalog != nil || !model.mcpPage.market.loading { + t.Fatalf("stale load populated the new visit: %+v", model.mcpPage.market) + } +} + +func TestMarketplacePreviewIgnoresEnter(t *testing.T) { + stubMarket(t) + manager := &fakeMCPManager{views: testViews(), changes: make(chan struct{})} + model := newMCPTestModel(t, manager, &fakeConnectorAPI{}) + model.mcpPage.open = true + _, cmd := model.Update(tea.KeyPressMsg{Code: 'm', Text: "m"}) + runCmd(model, cmd) + typeKeys(model, "pager") + model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if len(manager.added) != 0 || model.mcpPage.market == nil || model.mcpPage.market.preview == nil { + t.Fatalf("Enter in the preview must not add: added=%+v", manager.added) + } +} + +func TestSameNamedServersMentionAndPromptCarryServerID(t *testing.T) { + imported := mcp.Server{Name: "docs", Scope: mcp.ScopeImported, Path: "/repo/.cursor/mcp.json"} + user := mcp.Server{Name: "docs", Scope: mcp.ScopeUser, Path: "/home/u/.rootly-cli/mcp.json"} + manager := &fakeMCPManager{views: []mcp.ServerView{ + {Server: imported, Status: mcp.StatusConnected}, + {Server: user, Status: mcp.StatusConnected, Prompts: []mcp.PromptInfo{{Name: "triage"}}, Resources: []mcp.ResourceInfo{{URI: "file:///runbook.md"}}}, + }} + model := NewTUIModel(context.Background(), &fakeStreamer{}, TUIOptions{NoColor: true, MCP: manager}) + model.mcpPage.views = manager.views + refs := model.resourceMentions("see @docs:file:///runbook.md") + if len(refs) != 1 || refs[0].serverID != user.ID() { + t.Fatalf("mention refs = %+v, want the user server's ID", refs) + } + if _, serverID, found := model.findPrompt("docs", "triage"); !found || serverID != user.ID() { + t.Fatalf("findPrompt serverID = %q found=%v", serverID, found) + } +} diff --git a/internal/agent/mcp_extras.go b/internal/agent/mcp_extras.go new file mode 100644 index 0000000..1262678 --- /dev/null +++ b/internal/agent/mcp_extras.go @@ -0,0 +1,361 @@ +package agent + +import ( + "context" + "fmt" + "regexp" + "strings" + "unicode" + + "charm.land/bubbles/v2/list" + tea "charm.land/bubbletea/v2" + + "github.com/rootlyhq/rootly-cli/internal/mcp" +) + +const ( + maxMentionSuggestions = 8 + maxPromptSuggestions = 8 + maxAttachedResources = 8 +) + +var resourceMentionPattern = regexp.MustCompile(`@([A-Za-z0-9_.-]+):(\S+)`) + +type mcpPromptMsg struct { + request int + label string + text string + err error +} + +type mentionsReadyMsg struct { + request int + echo string + message string + attached []string + notes []string +} + +type resourceRef struct { + server string + uri string + serverID string +} + +func (r resourceRef) mention() string { return "@" + r.server + ":" + r.uri } + +func (m *TUIModel) liveMCPViews() []mcp.ServerView { + if m.mcp == nil { + return nil + } + var views []mcp.ServerView + for _, view := range m.mcp.Views() { + if view.Status == mcp.StatusConnected && !view.SessionOff { + views = append(views, view) + } + } + return views +} + +func oneLine(text string) string { + return sanitizeTerminalText(strings.Join(strings.Fields(text), " ")) +} + +// appendPromptItems adds /server:prompt entries after built-ins and skills, which win on collision. +// paletteSafeName rejects prompt names that would print terminal controls or could not be typed back as one word. +func paletteSafeName(name string) bool { + return name != "" && name == sanitizeTerminalText(name) && !strings.ContainsFunc(name, unicode.IsSpace) +} + +func (m *TUIModel) appendPromptItems(items []list.Item, value string) []list.Item { + taken := map[string]bool{} + for _, item := range items { + if command, ok := item.(slashCommandItem); ok { + taken[strings.ToLower(command.command)] = true + } + } + lower := strings.ToLower(value) + added := 0 + for _, view := range m.liveMCPViews() { + for _, prompt := range view.Prompts { + if !paletteSafeName(prompt.Name) { + continue + } + command := "/" + view.Server.Name + ":" + prompt.Name + key := strings.ToLower(command) + if taken[key] || isBuiltinSlashCommand(command) || !strings.HasPrefix(key, lower) { + continue + } + description := oneLine(prompt.Description) + if description == "" { + description = "MCP prompt" + } + taken[key] = true + items = append(items, slashCommandItem{command: command, description: description, insert: len(prompt.Arguments) > 0}) + added++ + if added == maxPromptSuggestions { + return items + } + } + } + return items +} + +func (m *TUIModel) findPrompt(server, name string) (prompt mcp.PromptInfo, serverID string, found bool) { + for _, view := range m.liveMCPViews() { + if view.Server.Name != server { + continue + } + for _, candidate := range view.Prompts { + if candidate.Name == name { + return candidate, view.Server.ID(), true + } + } + } + return mcp.PromptInfo{}, "", false +} + +// promptArguments maps key=value tokens by name and fills the rest positionally; extra words join the last argument. +func promptArguments(prompt mcp.PromptInfo, tokens []string) (arguments map[string]string, missing []string) { + arguments = map[string]string{} + known := map[string]bool{} + for _, argument := range prompt.Arguments { + known[argument.Name] = true + } + var positional []string + for _, token := range tokens { + if key, value, ok := strings.Cut(token, "="); ok && known[key] { + arguments[key] = value + continue + } + positional = append(positional, token) + } + var open []string + for _, argument := range prompt.Arguments { + if _, set := arguments[argument.Name]; !set { + open = append(open, argument.Name) + } + } + for index, name := range open { + if index >= len(positional) { + break + } + if index == len(open)-1 { + arguments[name] = strings.Join(positional[index:], " ") + break + } + arguments[name] = positional[index] + } + for _, argument := range prompt.Arguments { + if argument.Required && arguments[argument.Name] == "" { + missing = append(missing, argument.Name) + } + } + return arguments, missing +} + +func promptUsage(command string, prompt mcp.PromptInfo) string { + parts := []string{command} + for _, argument := range prompt.Arguments { + if argument.Required { + parts = append(parts, "<"+argument.Name+">") + } else { + parts = append(parts, "["+argument.Name+"]") + } + } + return sanitizeTerminalText(strings.Join(parts, " ")) +} + +func (m *TUIModel) runMCPPrompt(command string) (tea.Cmd, bool) { + parts := strings.Fields(command) + if len(parts) == 0 || m.mcp == nil { + return nil, false + } + server, name, ok := strings.Cut(strings.TrimPrefix(parts[0], "/"), ":") + if !ok { + return nil, false + } + prompt, serverID, found := m.findPrompt(server, name) + if !found { + return nil, false + } + arguments, missing := promptArguments(prompt, parts[1:]) + if len(missing) > 0 { + return m.println("Usage: " + promptUsage(parts[0], prompt)), true + } + m.mentionRequest++ + manager, ctx, label, request := m.mcp, m.ctx, parts[0], m.mentionRequest + m.status = "Loading prompt " + sanitizeTerminalText(label) + "…" + return func() tea.Msg { + text, err := manager.GetPrompt(ctx, serverID, name, arguments) + return mcpPromptMsg{request: request, label: label, text: text, err: err} + }, true +} + +func (m *TUIModel) handleMCPPrompt(msg mcpPromptMsg) tea.Cmd { + if msg.request != m.mentionRequest { + return nil + } + m.status = "" + if msg.err != nil { + return m.println(faint(m.noColor, sanitizeTerminalText("Prompt "+msg.label+" failed: "+msg.err.Error()))) + } + return m.submitPrompt(msg.text) +} + +func (m *TUIModel) listMCPPrompts() tea.Cmd { + var lines []string + for _, view := range m.liveMCPViews() { + for _, prompt := range view.Prompts { + line := promptUsage("/"+view.Server.Name+":"+prompt.Name, prompt) + if description := oneLine(prompt.Description); description != "" { + line += " " + faint(m.noColor, description) + } + lines = append(lines, line) + } + } + if len(lines) == 0 { + return m.println("No MCP prompts from connected servers. Open /mcp to manage servers.") + } + return m.println("MCP prompts\n" + strings.Join(lines, "\n")) +} + +func (m *TUIModel) listMCPResources() tea.Cmd { + var lines []string + for _, view := range m.liveMCPViews() { + for _, resource := range view.Resources { + line := sanitizeTerminalText(resourceRef{server: view.Server.Name, uri: resource.URI}.mention()) + if name := oneLine(resource.Name); name != "" { + line += " " + faint(m.noColor, name) + } + lines = append(lines, line) + } + } + if len(lines) == 0 { + return m.println("No MCP resources from connected servers. Open /mcp to manage servers.") + } + return m.println("MCP resources (mention with @server:uri)\n" + strings.Join(lines, "\n")) +} + +func lastToken(value string) string { + if value == "" || strings.ContainsAny(value[len(value)-1:], " \t\r\n") { + return "" + } + return value[strings.LastIndexAny(value, " \t\r\n")+1:] +} + +// refreshMentionMenu opens the @server:uri palette for the word being typed. +func (m *TUIModel) refreshMentionMenu(value string) bool { + token := lastToken(value) + if !strings.HasPrefix(token, "@") || m.mcp == nil { + return false + } + query := strings.ToLower(token[1:]) + var prefixed, contains []list.Item + for _, view := range m.liveMCPViews() { + for _, resource := range view.Resources { + mention := resourceRef{server: view.Server.Name, uri: resource.URI}.mention() + item := slashCommandItem{command: sanitizeTerminalText(mention), description: oneLine(resource.Name), mention: true} + switch lower := strings.ToLower(mention[1:]); { + case strings.HasPrefix(lower, query): + prefixed = append(prefixed, item) + case strings.Contains(lower, query): + contains = append(contains, item) + } + } + } + items := make([]list.Item, 0, len(prefixed)+len(contains)) + items = append(items, prefixed...) + items = append(items, contains...) + if len(items) == 0 { + return false + } + m.showCommandMenu(items[:min(len(items), maxMentionSuggestions)]) + return true +} + +func (m *TUIModel) insertMention(mention string) { + value := m.composer.Value() + start := strings.LastIndexAny(value, " \t\r\n") + 1 + m.composer.SetValue(value[:start] + mention + " ") + m.composer.CursorEnd() +} + +func (m *TUIModel) resourceMentions(message string) []resourceRef { + known := map[resourceRef]string{} + for _, view := range m.liveMCPViews() { + for _, resource := range view.Resources { + ref := resourceRef{server: view.Server.Name, uri: resource.URI} + if _, taken := known[ref]; !taken { + known[ref] = view.Server.ID() + } + } + } + if len(known) == 0 { + return nil + } + var refs []resourceRef + seen := map[resourceRef]bool{} + for _, match := range resourceMentionPattern.FindAllStringSubmatch(message, -1) { + ref := resourceRef{server: match[1], uri: strings.TrimRight(match[2], ".,;:!?)")} + if _, ok := known[ref]; !ok { + ref.uri = match[2] + } + serverID, ok := known[ref] + ref.serverID = serverID + if ok && !seen[ref] && len(refs) < maxAttachedResources { + seen[ref] = true + refs = append(refs, ref) + } + } + return refs +} + +// sendWithResources reads mentioned resources before sending; nil when the message mentions none. +func (m *TUIModel) sendWithResources(message string) tea.Cmd { + refs := m.resourceMentions(message) + if len(refs) == 0 { + return nil + } + m.mentionRequest++ + request, manager, ctx := m.mentionRequest, m.mcp, m.ctx + m.status = fmt.Sprintf("Reading %d MCP resource(s)…", len(refs)) + return func() tea.Msg { + return readMentionedResources(ctx, manager, request, message, refs) + } +} + +func readMentionedResources(ctx context.Context, manager MCPManager, request int, message string, refs []resourceRef) mentionsReadyMsg { + result := mentionsReadyMsg{request: request, echo: message, message: message} + var blocks []string + for _, ref := range refs { + text, truncated, err := manager.ReadResource(ctx, ref.serverID, ref.uri, mcp.ResourceByteCap) + if err != nil { + result.notes = append(result.notes, "skipped "+ref.mention()+": "+err.Error()) + continue + } + label := ref.mention() + if truncated { + label += fmt.Sprintf(" (first %d KB)", mcp.ResourceByteCap/1024) + } + result.attached = append(result.attached, label) + blocks = append(blocks, fmt.Sprintf("\n%s\n", ref.server, ref.uri, text)) + } + if len(blocks) > 0 { + result.message = strings.Join(blocks, "\n\n") + "\n\n" + message + } + return result +} + +func (m *TUIModel) handleMentionsReady(msg mentionsReadyMsg) tea.Cmd { + if msg.request != m.mentionRequest { + return nil + } + m.status = "" + var notes []string + if len(msg.attached) > 0 { + notes = append(notes, "attached: "+strings.Join(msg.attached, ", ")) + } + notes = append(notes, msg.notes...) + return m.sendPrompt(msg.echo, msg.message, sanitizeTerminalText(strings.Join(notes, "\n"))) +} diff --git a/internal/agent/mcp_extras_test.go b/internal/agent/mcp_extras_test.go new file mode 100644 index 0000000..3d9e6fd --- /dev/null +++ b/internal/agent/mcp_extras_test.go @@ -0,0 +1,272 @@ +package agent + +import ( + "fmt" + "strings" + "testing" + + "charm.land/bubbles/v2/list" + tea "charm.land/bubbletea/v2" + "github.com/google/uuid" + + "github.com/rootlyhq/rootly-cli/internal/mcp" +) + +func extrasManager() *fakeMCPManager { + resources := []mcp.ResourceInfo{{URI: "file:///runbook.md", Name: "Runbook"}, {URI: "file:///big.log", Name: "Big log"}, {URI: "file:///gone.md", Name: "Gone"}} + for index := range 10 { + resources = append(resources, mcp.ResourceInfo{URI: fmt.Sprintf("file:///extra-%d.md", index), Name: "Extra"}) + } + return &fakeMCPManager{ + views: []mcp.ServerView{ + { + Server: mcp.Server{Name: "docs", Scope: mcp.ScopeUser}, Status: mcp.StatusConnected, + Tools: []mcp.ToolInfo{{Name: "search"}}, + Prompts: []mcp.PromptInfo{{Name: "triage", Description: "Triage an\nalert", Arguments: []mcp.PromptArg{{Name: "service", Required: true}, {Name: "notes"}}}, {Name: "summary"}}, + Resources: resources, + }, + {Server: mcp.Server{Name: "offline", Scope: mcp.ScopeUser}, Status: mcp.StatusError, Prompts: []mcp.PromptInfo{{Name: "hidden"}}}, + }, + resources: map[string]string{ + "docs:file:///runbook.md": "restart the api", + "docs:file:///big.log": strings.Repeat("x", mcp.ResourceByteCap+500), + }, + } +} + +func menuCommands(model *TUIModel) []string { + var commands []string + for _, item := range model.commandMenu.Items() { + commands = append(commands, item.(slashCommandItem).command) + } + return commands +} + +func TestSlashPaletteListsMCPPromptsFromConnectedServers(t *testing.T) { + model := newMCPTestModel(t, extrasManager(), &fakeConnectorAPI{}) + model.composer.SetValue("/") + model.refreshCommandMenu() + model.composer.SetValue("/docs") + model.refreshCommandMenu() + got := menuCommands(model) + if !model.commandMenuOpen || strings.Join(got, ",") != "/docs:triage,/docs:summary" { + t.Fatalf("menu = %v (open %v)", got, model.commandMenuOpen) + } + model.composer.SetValue("/off") + model.refreshCommandMenu() + if model.commandMenuOpen { + t.Fatalf("prompts from disconnected servers listed: %v", menuCommands(model)) + } +} + +func TestPromptItemsLoseToBuiltinsAndSkills(t *testing.T) { + model := newMCPTestModel(t, extrasManager(), &fakeConnectorAPI{}) + items := model.appendPromptItems(nil, "/") + if len(items) != 2 { + t.Fatalf("items = %v", items) + } + skill := slashCommandItem{command: "/docs:triage", description: "skill", skill: true} + items = model.appendPromptItems([]list.Item{skill}, "/docs:t") + if len(items) != 1 || items[0].(slashCommandItem) != skill { + t.Fatalf("skill should win on collision, got %v", items) + } + if !isBuiltinSlashCommand("/prompts") || !isBuiltinSlashCommand("/resources") { + t.Fatal("/prompts and /resources must be built-ins so skills cannot shadow them") + } +} + +func TestRunMCPPromptArgumentsAndUsage(t *testing.T) { + model := newMCPTestModel(t, extrasManager(), &fakeConnectorAPI{}) + cmd := model.runSlashCommand("/docs:triage") + runCmd(model, cmd) + if !strings.Contains(model.Transcript(), "Usage: /docs:triage [notes]") { + t.Fatalf("transcript = %s", model.Transcript()) + } + cmd = model.runSlashCommand("/docs:triage api notes=urgent") + msg, ok := cmd().(mcpPromptMsg) + if !ok || msg.err != nil || msg.text != "prompt triage map[notes:urgent service:api]" { + t.Fatalf("prompt msg = %+v", msg) + } + model.Update(msg) + if !strings.Contains(model.Transcript(), "prompt triage map[notes:urgent service:api]") { + t.Fatalf("prompt not sent: %s", model.Transcript()) + } + if _, missing := promptArguments(mcp.PromptInfo{Arguments: []mcp.PromptArg{{Name: "service", Required: true}, {Name: "notes"}}}, []string{"api", "very", "slow"}); missing != nil { + t.Fatalf("missing = %v", missing) + } + args, _ := promptArguments(mcp.PromptInfo{Arguments: []mcp.PromptArg{{Name: "service"}, {Name: "notes"}}}, []string{"api", "very", "slow"}) + if args["service"] != "api" || args["notes"] != "very slow" { + t.Fatalf("args = %v", args) + } +} + +func TestPromptsAndResourcesListings(t *testing.T) { + model := newMCPTestModel(t, extrasManager(), &fakeConnectorAPI{}) + model.runSlashCommand("/prompts") + model.runSlashCommand("/resources") + transcript := model.Transcript() + for _, want := range []string{"/docs:triage [notes] Triage an alert", "/docs:summary", "@docs:file:///runbook.md Runbook"} { + if !strings.Contains(transcript, want) { + t.Fatalf("missing %q in:\n%s", want, transcript) + } + } + if strings.Contains(transcript, "hidden") { + t.Fatalf("listed prompt from a disconnected server:\n%s", transcript) + } + empty := newMCPTestModel(t, &fakeMCPManager{}, &fakeConnectorAPI{}) + empty.runSlashCommand("/prompts") + if !strings.Contains(empty.Transcript(), "No MCP prompts") { + t.Fatalf("transcript = %s", empty.Transcript()) + } +} + +func TestMentionPaletteShowsTopEightAndTabInserts(t *testing.T) { + model := newMCPTestModel(t, extrasManager(), &fakeConnectorAPI{}) + model.composer.SetValue("check @docs") + model.refreshCommandMenu() + if got := menuCommands(model); !model.commandMenuOpen || len(got) != maxMentionSuggestions { + t.Fatalf("menu = %v", got) + } + model.composer.SetValue("check @run") + model.refreshCommandMenu() + if got := menuCommands(model); len(got) != 1 || got[0] != "@docs:file:///runbook.md" { + t.Fatalf("menu = %v", got) + } + model.handleCommandMenuKey(tea.KeyPressMsg{Code: tea.KeyTab}) + if got := model.composer.Value(); got != "check @docs:file:///runbook.md " { + t.Fatalf("composer = %q", got) + } + model.composer.SetValue("mail me@") + model.refreshCommandMenu() + if model.commandMenuOpen && len(menuCommands(model)) > maxMentionSuggestions { + t.Fatal("menu too long") + } +} + +func TestMentionedResourcesAttachedWithCapAndNotes(t *testing.T) { + manager := extrasManager() + model := newMCPTestModel(t, manager, &fakeConnectorAPI{}) + if model.sendWithResources("email ops@example.com and @docs:unknown") != nil { + t.Fatal("unknown mentions should send as plain text") + } + cmd := model.sendWithResources("why? @docs:file:///runbook.md, @docs:file:///big.log @docs:file:///gone.md @docs:file:///runbook.md") + msg, ok := cmd().(mentionsReadyMsg) + if !ok { + t.Fatal("expected mentionsReadyMsg") + } + if strings.Join(msg.attached, "|") != "@docs:file:///runbook.md|@docs:file:///big.log (first 32 KB)" { + t.Fatalf("attached = %v", msg.attached) + } + if len(msg.notes) != 1 || !strings.Contains(msg.notes[0], "skipped @docs:file:///gone.md: no text content") { + t.Fatalf("notes = %v", msg.notes) + } + if !strings.HasPrefix(msg.message, "\nrestart the api\n") || + !strings.HasSuffix(msg.message, "\n\n"+msg.echo) || strings.Count(msg.message, " mcp.ResourceByteCap+10 { + t.Fatal("resource cap not applied") + } + for _, limit := range manager.readLimits { + if limit != mcp.ResourceByteCap { + t.Fatalf("read limit = %d", limit) + } + } + model.Update(msg) + transcript := model.Transcript() + if !strings.Contains(transcript, "attached: @docs:file:///runbook.md, @docs:file:///big.log (first 32 KB)") || !strings.Contains(transcript, "skipped @docs:file:///gone.md") { + t.Fatalf("transcript = %s", transcript) + } + if strings.Contains(transcript, "restart the api") { + t.Fatal("resource body should not be echoed into the transcript") + } +} + +func TestMCPRowCounts(t *testing.T) { + view := mcp.ServerView{Status: mcp.StatusConnected, Tools: make([]mcp.ToolInfo, 3), Resources: make([]mcp.ResourceInfo, 1), Prompts: make([]mcp.PromptInfo, 2)} + if got := mcpStatusLabel(view); got != "✔ 3 tools · 1 resource · 2 prompts" { + t.Fatalf("label = %q", got) + } + if got := mcpStatusLabel(mcp.ServerView{Status: mcp.StatusConnected, Tools: make([]mcp.ToolInfo, 1)}); got != "✔ 1 tool" { + t.Fatalf("label = %q", got) + } +} + +func TestStalePromptAndMentionLoadsAreDropped(t *testing.T) { + model := newMCPTestModel(t, extrasManager(), &fakeConnectorAPI{}) + pending := model.runSlashCommand("/docs:triage api") + model.startNewSession() + model.Update(pending()) + if strings.Contains(model.Transcript(), "prompt triage") { + t.Fatalf("prompt loaded before /new was sent into the new chat:\n%s", model.Transcript()) + } + pending = model.runSlashCommand("/docs:triage api") + model.sendPrompt("other question", "other question", "") + model.Update(pending()) + if strings.Contains(model.Transcript(), "prompt triage") { + t.Fatalf("prompt loaded before another send replaced it:\n%s", model.Transcript()) + } +} + +func TestSessionSwitchDropsPendingPromptAndMentionLoads(t *testing.T) { + model := newMCPTestModel(t, extrasManager(), &fakeConnectorAPI{}) + pending := model.runSlashCommand("/docs:triage api") + model.selectPickerItem(sessionPickerItem{session: testSession(uuid.NewString(), "Other chat")}) + model.historyPending = false + model.Update(pending()) + if model.state == tuiStreaming || strings.Contains(model.Transcript(), "prompt triage") { + t.Fatal("a prompt loaded before resuming another session was sent into it") + } + model.composer.SetValue("see @docs:file:///runbook.md") + pending = model.submit() + model.Update(bindingResolvedMsg{request: model.actionRequest}) + model.Update(pending()) + if model.state == tuiStreaming { + t.Fatal("a resource load started before switching the bound incident was still sent") + } +} + +func TestCtrlCCancelsPendingPromptAndMentionLoads(t *testing.T) { + model := newMCPTestModel(t, extrasManager(), &fakeConnectorAPI{}) + pending := model.runSlashCommand("/docs:triage api") + model.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + model.Update(pending()) + if model.state == tuiStreaming || strings.Contains(model.Transcript(), "prompt triage") { + t.Fatal("a prompt load cancelled with Ctrl+C was still sent") + } + model.composer.SetValue("see @docs:file:///runbook.md") + pending = model.submit() + model.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + model.Update(pending()) + if model.state == tuiStreaming { + t.Fatal("a resource load cancelled with Ctrl+C was still sent") + } +} + +func TestPromptNamesWithTerminalControlsStayOutOfThePalette(t *testing.T) { + manager := extrasManager() + manager.views[0].Prompts = append(manager.views[0].Prompts, mcp.PromptInfo{Name: "evil\x1b]0;pwned\x07"}, mcp.PromptInfo{Name: "two words"}) + model := newMCPTestModel(t, manager, &fakeConnectorAPI{}) + for _, item := range model.appendPromptItems(nil, "/") { + command := item.(slashCommandItem).command + if strings.Contains(command, "\x1b") || strings.Contains(command, "two words") { + t.Fatalf("unsafe prompt name reached the palette: %q", command) + } + } + if len(model.appendPromptItems(nil, "/docs:")) != 2 { + t.Fatal("safe prompts should still be listed") + } +} + +func TestMCPListRowShowsFullCountsAtTypicalWidth(t *testing.T) { + manager := &fakeMCPManager{views: []mcp.ServerView{{ + Server: mcp.Server{Name: "everything", Scope: mcp.ScopeUser}, Status: mcp.StatusConnected, + Tools: make([]mcp.ToolInfo, 22), Resources: make([]mcp.ResourceInfo, 11), Prompts: make([]mcp.PromptInfo, 4), + }}} + model := newMCPTestModel(t, manager, &fakeConnectorAPI{}) + model.width = 139 + model.mcpPage.open = true + if view := model.mcpPageView(); !strings.Contains(view, "✔ 22 tools · 11 resources · 4 prompts") { + t.Fatalf("list row clipped the capability counts:\n%s", view) + } +} diff --git a/internal/agent/mcp_page.go b/internal/agent/mcp_page.go index bae8893..8b7060d 100644 --- a/internal/agent/mcp_page.go +++ b/internal/agent/mcp_page.go @@ -25,10 +25,13 @@ type MCPManager interface { Changes() <-chan struct{} Connecting() int ToggleSession(string) error + GetPrompt(ctx context.Context, serverID, name string, arguments map[string]string) (string, error) + ReadResource(ctx context.Context, serverID, uri string, limit int) (string, bool, error) Approve(string) error Enable(string) error Reconnect(string) error Login(context.Context, string, mcp.LoginOptions) error + AddFromMarket(mcp.MarketEntry) (mcp.Server, error) } type ConnectorLister interface { @@ -88,6 +91,7 @@ type mcpPage struct { message string loginURL string views []mcp.ServerView + market *marketState } func (m *TUIModel) watchMCP() tea.Cmd { @@ -315,6 +319,10 @@ func (m *TUIModel) handleMCPPageKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { if page.detail != "" { return m.handleMCPDetailKey(msg) } + if page.market != nil { + cmd := m.handleMarketKey(msg) + return m, cmd + } if page.filtering { switch key { case "esc": @@ -360,6 +368,9 @@ func (m *TUIModel) handleMCPPageKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.clampMCPCursor(rows) case "/": page.filtering = true + case "m": + cmd := m.openMarket() + return m, cmd case "enter": row, ok := m.selectedMCPRow() if !ok { @@ -545,7 +556,7 @@ func mcpStatusLabel(view mcp.ServerView) string { } switch view.Status { case mcp.StatusConnected: - return "✔ " + pluralTools(len(view.Tools)) + return "✔ " + mcpCounts(view) case mcp.StatusError: return "✘ error" case mcp.StatusNeedsAuth: @@ -610,6 +621,9 @@ func (m *TUIModel) mcpPageView() string { if m.mcpPage.detail != "" { return m.mcpDetailView() } + if m.mcpPage.market != nil { + return m.marketView() + } width := max(20, m.width-1) bold := lipgloss.NewStyle().Bold(true) lines := []string{m.mcpStyle(bold, "Manage MCP servers"), faint(m.noColor, m.mcpSummary()), ""} @@ -633,13 +647,14 @@ func (m *TUIModel) mcpPageView() string { } page.offset = max(0, min(page.offset, max(0, len(rows)-height))) end := min(len(rows), page.offset+height) + statusWidth := mcpStatusWidth(rows[page.offset:end], width) for index := page.offset; index < end; index++ { - lines = append(lines, ansi.Truncate(m.renderMCPRow(rows[index], index == page.cursor), width, "…")) + lines = append(lines, ansi.Truncate(m.renderMCPRow(rows[index], index == page.cursor, statusWidth), width, "…")) } for len(lines) < height+3 { lines = append(lines, "") } - footer := "↑/↓ move · Enter details · / filter · Esc back to chat · rootly mcp add … to add one" + footer := "↑/↓ move · Enter details · / filter · m marketplace · Esc back to chat" if page.message != "" { footer = sanitizeTerminalText(page.message) } @@ -647,7 +662,17 @@ func (m *TUIModel) mcpPageView() string { return strings.Join(lines, "\n") } -func (m *TUIModel) renderMCPRow(row mcpRow, selected bool) string { +func mcpStatusWidth(rows []mcpRow, width int) int { + statusWidth := 24 + for _, row := range rows { + if row.kind == mcpRowServer { + statusWidth = max(statusWidth, ansi.StringWidth(mcpStatusLabel(*row.view))) + } + } + return min(statusWidth, max(24, width-2-28-1-16)) +} + +func (m *TUIModel) renderMCPRow(row mcpRow, selected bool, statusWidth int) string { prefix := " " if selected { prefix = "❯ " @@ -661,7 +686,7 @@ func (m *TUIModel) renderMCPRow(row mcpRow, selected bool) string { return prefix + row.text case mcpRowCloud: name := sanitizeTerminalText(row.cloud.Name) - return prefix + padRight(name, 28) + " " + padRight(cloudStatusLabel(*row.cloud), 24) + " " + faint(m.noColor, sanitizeTerminalText(row.cloud.ProviderType)) + return prefix + padRight(name, 28) + " " + padRight(cloudStatusLabel(*row.cloud), statusWidth) + " " + faint(m.noColor, sanitizeTerminalText(row.cloud.ProviderType)) case mcpRowServer: view := row.view name := sanitizeTerminalText(view.Server.Name) @@ -672,7 +697,7 @@ func (m *TUIModel) renderMCPRow(row mcpRow, selected bool) string { if view.Server.Scope == mcp.ScopeLocal { origin += " · local" } - return prefix + padRight(name, 28) + " " + padRight(mcpStatusLabel(*view), 24) + " " + faint(m.noColor, origin) + return prefix + padRight(name, 28) + " " + padRight(mcpStatusLabel(*view), statusWidth) + " " + faint(m.noColor, origin) } return "" } @@ -814,17 +839,26 @@ func (m *TUIModel) updateLocalMCP(message tea.Msg) (cmd tea.Cmd, handled bool) { return nil, true case mcpLoginMsg: return m.handleMCPLogin(msg), true + case mcpPromptMsg: + return m.handleMCPPrompt(msg), true + case mentionsReadyMsg: + return m.handleMentionsReady(msg), true + case marketLoadedMsg: + m.handleMarketLoaded(msg) + return nil, true case localToolDoneMsg: - if msg.turn != m.turn { - return nil, true + var cmds []tea.Cmd + if msg.failure != "" && msg.turn == m.turn { + cmds = append(cmds, m.println(m.activityLine(msg.failure))) } - if msg.failure != "" { - m.println(m.activityLine(msg.failure)) + if msg.credHint && !m.credHintShown { + m.credHintShown = true + cmds = append(cmds, m.println(faint(m.noColor, localCredsHint))) } - if msg.err != nil { - return m.println(faint(m.noColor, sanitizeTerminalText(msg.err.Error()))), true + if msg.err != nil && msg.turn == m.turn { + cmds = append(cmds, m.println(faint(m.noColor, sanitizeTerminalText(msg.err.Error())))) } - return nil, true + return tea.Batch(cmds...), true } return nil, false } @@ -838,3 +872,21 @@ func (m *TUIModel) updateOtherMessage(message tea.Msg) (tea.Model, tea.Cmd) { } return m.updatePickerMessage(message) } + +func mcpCounts(view mcp.ServerView) string { + parts := []string{pluralTools(len(view.Tools))} + if count := len(view.Resources); count > 0 { + parts = append(parts, pluralCount(count, "resource")) + } + if count := len(view.Prompts); count > 0 { + parts = append(parts, pluralCount(count, "prompt")) + } + return strings.Join(parts, " · ") +} + +func pluralCount(count int, noun string) string { + if count == 1 { + return "1 " + noun + } + return strconv.Itoa(count) + " " + noun + "s" +} diff --git a/internal/agent/mcp_page_test.go b/internal/agent/mcp_page_test.go index 49ce462..14dfcaf 100644 --- a/internal/agent/mcp_page_test.go +++ b/internal/agent/mcp_page_test.go @@ -2,6 +2,8 @@ package agent import ( "context" + "errors" + "fmt" "strings" "testing" @@ -18,6 +20,10 @@ type fakeMCPManager struct { approved []string problems []string changes chan struct{} + + resources map[string]string + readLimits []int + added []mcp.MarketEntry } func (f *fakeMCPManager) Views() []mcp.ServerView { return f.views } @@ -33,6 +39,39 @@ func (f *fakeMCPManager) ToggleSession(id string) error { func (f *fakeMCPManager) Approve(id string) error { f.approved = append(f.approved, id); return nil } func (f *fakeMCPManager) Login(context.Context, string, mcp.LoginOptions) error { return nil } +func (f *fakeMCPManager) AddFromMarket(entry mcp.MarketEntry) (mcp.Server, error) { + for _, view := range f.views { + if view.Server.Name == entry.Name { + return mcp.Server{}, errors.New("an MCP server named " + entry.Name + " already exists") + } + } + server := mcp.Server{Name: entry.Name, Scope: mcp.ScopeUser, Config: entry.Config} + f.added = append(f.added, entry) + f.views = append(f.views, mcp.ServerView{Server: server, Status: mcp.StatusOff}) + return server, nil +} + +func (f *fakeMCPManager) GetPrompt(_ context.Context, serverID, name string, arguments map[string]string) (string, error) { + if !strings.HasSuffix(serverID, ":docs") { + return "", errors.New("not connected") + } + return fmt.Sprintf("prompt %s %v", name, arguments), nil +} + +func (f *fakeMCPManager) ReadResource(_ context.Context, serverID, uri string, limit int) (text string, truncated bool, err error) { + f.mu.Lock() + f.readLimits = append(f.readLimits, limit) + f.mu.Unlock() + text, ok := f.resources[serverID[strings.LastIndex(serverID, ":")+1:]+":"+uri] + if !ok { + return "", false, errors.New("no text content") + } + if len(text) > limit { + return text[:limit], true, nil + } + return text, false, nil +} + type fakeConnectorAPI struct { fakeTUIAPI pages []int diff --git a/internal/agent/polish_test.go b/internal/agent/polish_test.go index f04e6b6..011dd43 100644 --- a/internal/agent/polish_test.go +++ b/internal/agent/polish_test.go @@ -390,11 +390,11 @@ func TestSendWaitsForPendingHistory(t *testing.T) { if cmd := model.submit(); cmd != nil || model.state == tuiStreaming { t.Fatal("a prompt sent before resumed history loads must wait") } - if model.queuedPrompt != "hello" || model.status != "Loading session history…" { + if model.queuedPrompt.message != "hello" || model.status != "Loading session history…" { t.Fatalf("queued %q status %q", model.queuedPrompt, model.status) } _, cmd := model.Update(pending()) - if model.historyPending || model.queuedPrompt != "" { + if model.historyPending || model.queuedPrompt != (queuedSend{}) { t.Fatal("settled history should clear the pending flag and the queue") } if cmd == nil || model.state != tuiStreaming || model.currentPrompt != "hello" { @@ -411,7 +411,7 @@ func TestSkillPromptWaitsForPendingHistory(t *testing.T) { t.Fatal("skill prompts must wait for resumed history too") } model.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) - if model.queuedPrompt != "" { + if model.queuedPrompt != (queuedSend{}) { t.Fatal("Ctrl+C should drop a queued prompt") } } diff --git a/internal/agent/print.go b/internal/agent/print.go index 7aae54e..2376b7f 100644 --- a/internal/agent/print.go +++ b/internal/agent/print.go @@ -90,20 +90,21 @@ func initialPrintParams(prompt string, options PrintOptions) *rootly.StreamAiCha } type printRun struct { - streamer Streamer - options PrintOptions - params *rootly.StreamAiChatParams - sessionID *uuid.UUID - answer strings.Builder - activities activityTracker - confirmation *rootly.AiChatStreamConfirmationRequired - doneStatus string - doneReceived bool - streamError error - ctx context.Context - sentTools int - toolsOffSeen bool - stdinReader *bufio.Reader + streamer Streamer + options PrintOptions + params *rootly.StreamAiChatParams + sessionID *uuid.UUID + answer strings.Builder + activities activityTracker + confirmation *rootly.AiChatStreamConfirmationRequired + doneStatus string + doneReceived bool + streamError error + ctx context.Context + sentTools int + toolsOffSeen bool + stdinReader *bufio.Reader + credHintShown bool } func (r *printRun) run(ctx context.Context) error { @@ -236,6 +237,10 @@ func (r *printRun) handleClientToolCall(call *ClientToolCall) { result = mcp.CallResult{Output: userDeclinedOutput, IsError: true} } } + if looksLikeExpiredCreds(result) && !r.credHintShown && !r.options.Quiet { + r.credHintShown = true + _, _ = fmt.Fprintln(r.options.Stderr, localCredsHint) + } sessionID := "" if r.sessionID != nil { sessionID = r.sessionID.String() diff --git a/internal/agent/tui.go b/internal/agent/tui.go index 51abf38..fada997 100644 --- a/internal/agent/tui.go +++ b/internal/agent/tui.go @@ -160,7 +160,7 @@ type TUIModel struct { resumedSession bool historyRequest int historyPending bool - queuedPrompt string + queuedPrompt queuedSend promptHistory []string historyIndex int historyDraft string @@ -215,6 +215,9 @@ type TUIModel struct { workingDir string terminalBlurred bool toolsOnShown bool + mentionRequest int + marketRequest int + credHintShown bool mcpStartupReported bool } @@ -224,9 +227,10 @@ type localConfirmation struct { } type localToolDoneMsg struct { - turn int - err error - failure string + turn int + err error + credHint bool + failure string } func NewTUIModel(ctx context.Context, streamer Streamer, options TUIOptions) *TUIModel { @@ -383,6 +387,7 @@ func (m *TUIModel) Update(message tea.Msg) (tea.Model, tea.Cmd) { cmd := m.println(sanitizeTerminalText(msg.err.Error())) return m, cmd } + m.mentionRequest++ m.sessionID = nil m.resetAnswer() m.resetTranscript() @@ -848,7 +853,8 @@ func (m *TUIModel) updateKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m, cmd } m.idleInterrupt = true - m.queuedPrompt = "" + m.queuedPrompt = queuedSend{} + m.mentionRequest++ m.composer.Reset() m.status = "press Ctrl+C again to exit" return m, nil @@ -914,7 +920,9 @@ func (m *TUIModel) handleCommandMenuKey(msg tea.KeyPressMsg) (bool, tea.Cmd) { m.commandMenu, cmd = m.commandMenu.Update(msg) return true, cmd case "tab": - if item, ok := m.commandMenu.SelectedItem().(slashCommandItem); ok { + if item, ok := m.commandMenu.SelectedItem().(slashCommandItem); ok && item.mention { + m.insertMention(item.command) + } else if ok { m.composer.SetValue(item.command + " ") m.composer.CursorEnd() } @@ -923,9 +931,9 @@ func (m *TUIModel) handleCommandMenuKey(msg tea.KeyPressMsg) (bool, tea.Cmd) { return true, nil case "enter": typed := strings.TrimSpace(m.composer.Value()) - if item, ok := m.commandMenu.SelectedItem().(slashCommandItem); ok && + if item, ok := m.commandMenu.SelectedItem().(slashCommandItem); ok && !item.mention && strings.HasPrefix(strings.ToLower(item.command), strings.ToLower(typed)) { - if item.skill { + if item.skill || item.insert { m.composer.SetValue(item.command + " ") m.composer.CursorEnd() m.commandMenuOpen = false @@ -977,17 +985,25 @@ func (m *TUIModel) submit() tea.Cmd { m.commandMenuOpen = false return m.runSlashCommand(message) } + if cmd := m.sendWithResources(message); cmd != nil { + return cmd + } return m.submitPrompt(message) } func (m *TUIModel) submitPrompt(message string) tea.Cmd { + return m.sendPrompt(message, message, "") +} + +func (m *TUIModel) sendPrompt(echo, message, note string) tea.Cmd { if m.historyPending { - m.queuedPrompt = message + m.queuedPrompt = queuedSend{echo: echo, message: message, note: note} m.status = historyLoadingStatus return nil } - m.currentPrompt = message - m.promptHistory = append(m.promptHistory, message) + m.mentionRequest++ + m.currentPrompt = echo + m.promptHistory = append(m.promptHistory, echo) m.historyIndex = -1 m.actionRequest++ m.startNewTurn() @@ -1005,8 +1021,11 @@ func (m *TUIModel) submitPrompt(message string) tea.Cmd { } m.alertUsed = true } - cmd := m.startStream(params) - return sequenceTUICommands(m.println(renderPromptEcho(message, m.noColor)), cmd) + commands := []tea.Cmd{m.println(renderPromptEcho(echo, m.noColor))} + if note != "" { + commands = append(commands, m.println(faint(m.noColor, note))) + } + return sequenceTUICommands(append(commands, m.startStream(params))...) } func (m *TUIModel) scrollViewportKey(msg tea.KeyPressMsg) bool { @@ -1056,6 +1075,10 @@ func (m *TUIModel) runSlashCommand(command string) tea.Cmd { return m.openSessionPicker() case "/mcp", "/connectors": return m.openMCPPage() + case "/prompts": + return m.listMCPPrompts() + case "/resources": + return m.listMCPResources() case "/skills": query := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(command), "/skills")) return m.openSkillPicker(query) @@ -1109,6 +1132,9 @@ func (m *TUIModel) runSlashCommand(command string) tea.Cmd { } return m.resolveBinding(strings.TrimPrefix(parts[0], "/"), parts[1]) default: + if cmd, ok := m.runMCPPrompt(command); ok { + return cmd + } if isSkillInvocationToken(parts[0]) && !isBuiltinSlashCommand(parts[0]) { return m.lookupSkillForPrompt(parts[0], strings.TrimSpace(command)) } @@ -1610,7 +1636,7 @@ func (m *TUIModel) runClientTool(call *ClientToolCall, declined bool) tea.Cmd { if !declined { result = runLocalCall(ctx, tools, call) } - done := localToolDoneMsg{turn: turn, err: postLocalResult(ctx, poster, sessionID, call, result)} + done := localToolDoneMsg{turn: turn, err: postLocalResult(ctx, poster, sessionID, call, result), credHint: looksLikeExpiredCreds(result)} if !declined && result.IsError { done.failure = localToolFailure(tools, call, result.Output) } @@ -1630,10 +1656,14 @@ func (m *TUIModel) onHistoryLoaded(msg historyLoadedMsg) tea.Cmd { m.sessionTitle = title } queued := m.queuedPrompt - m.queuedPrompt = "" + m.queuedPrompt = queuedSend{} m.handleHistoryLoaded(msg) - if queued == "" { + if queued == (queuedSend{}) { return nil } - return m.submitPrompt(queued) + return m.sendPrompt(queued.echo, queued.message, queued.note) +} + +type queuedSend struct { + echo, message, note string } diff --git a/internal/agent/tui_helpers.go b/internal/agent/tui_helpers.go index 1a62cd1..17e8361 100644 --- a/internal/agent/tui_helpers.go +++ b/internal/agent/tui_helpers.go @@ -77,6 +77,8 @@ type slashCommandItem struct { command string description string skill bool + insert bool + mention bool } func (i slashCommandItem) FilterValue() string { return i.command } @@ -188,7 +190,7 @@ var builtinSkillSlugs = map[string]struct{}{ "exit": {}, "quit": {}, "new": {}, "clear": {}, "resume": {}, "sessions": {}, "rename": {}, "pin": {}, "unpin": {}, "archive": {}, "delete": {}, "copy": {}, "export": {}, "incident": {}, "alert": {}, "status": {}, "help": {}, "skills": {}, - "mcp": {}, "connectors": {}, + "mcp": {}, "connectors": {}, "prompts": {}, "resources": {}, } func isSkillInvocationToken(token string) bool { @@ -458,6 +460,7 @@ func (m *TUIModel) helpView() string { "/resume, /sessions: choose a session\n" + "/skills [query]: browse skills\n" + "/mcp, /connectors: manage MCP servers\n" + + "/prompts, /resources: list MCP prompts and resources (@server:uri attaches one)\n" + "/rename [title]: rename or reset title\n" + "/pin, /unpin: change pinned state\n" + "/archive: archive this session\n" + @@ -516,7 +519,7 @@ func (m *TUIModel) statusLines() string { func (m *TUIModel) loadHistory(sessionID uuid.UUID) tea.Cmd { m.historyRequest++ m.historyPending = true - m.queuedPrompt = "" + m.queuedPrompt = queuedSend{} request := m.historyRequest apiClient := m.api ctx := m.ctx @@ -645,6 +648,7 @@ func (m *TUIModel) selectPickerItem(item sessionPickerItem) tea.Cmd { m.pickerOpen = false m.pickerLoading = false m.actionRequest++ + m.mentionRequest++ m.sessionID = &id m.sessionTitle = normalizeSessionTitle(item.Title()) m.resumedSession = true @@ -973,6 +977,9 @@ func (m *TUIModel) refreshCommandMenu() tea.Cmd { request := m.skillSuggestionReq rawValue := m.composer.Value() value := strings.TrimSpace(rawValue) + if m.refreshMentionMenu(rawValue) { + return nil + } if rawValue != value || value == "" || !strings.HasPrefix(value, "/") || strings.ContainsAny(value, " \t\r\n") { m.commandMenuOpen = false return nil @@ -982,7 +989,8 @@ func (m *TUIModel) refreshCommandMenu() tea.Cmd { {command: "/new", description: "new session"}, {command: "/clear", description: "new session"}, {command: "/resume", description: "choose a session"}, {command: "/sessions", description: "browse sessions"}, {command: "/skills", description: "browse skills"}, {command: "/mcp", description: "manage MCP servers"}, - {command: "/connectors", description: "manage MCP servers"}, {command: "/rename", description: "rename session"}, + {command: "/connectors", description: "manage MCP servers"}, {command: "/prompts", description: "list MCP prompts"}, + {command: "/resources", description: "list MCP resources"}, {command: "/rename", description: "rename session"}, {command: "/pin", description: "pin session"}, {command: "/unpin", description: "unpin session"}, {command: "/archive", description: "archive session"}, {command: "/delete", description: "delete session"}, {command: "/copy", description: "copy last answer"}, {command: "/export", description: "export transcript"}, @@ -1011,17 +1019,11 @@ func (m *TUIModel) refreshCommandMenu() tea.Cmd { }) } } + items = m.appendPromptItems(items, value) if len(items) == 0 { m.commandMenuOpen = false } else { - m.commandMenu = list.New(items, slashCommandDelegate{}, max(20, m.width), min(6, len(items))) - m.commandMenu.SetShowTitle(false) - m.commandMenu.SetShowFilter(false) - m.commandMenu.SetShowStatusBar(false) - m.commandMenu.SetShowPagination(false) - m.commandMenu.SetShowHelp(false) - m.commandMenu.SetFilteringEnabled(false) - m.commandMenuOpen = true + m.showCommandMenu(items) } if m.api == nil || m.skillsUnavailable { return nil @@ -1111,8 +1113,9 @@ func (m *TUIModel) startNewSession() { m.actionRequest++ m.pickerRequest++ m.historyRequest++ + m.mentionRequest++ m.historyPending = false - m.queuedPrompt = "" + m.queuedPrompt = queuedSend{} m.sessionID = nil m.sessionTitle = "" m.resetAnswer() @@ -1267,3 +1270,14 @@ func historyPageCount(meta map[string]interface{}) int { func StatusResponseError(status int, body []byte) error { return sanitizeTerminalError(describeError(&rootly.AiChatStreamStatusError{StatusCode: status, Body: body})) } + +func (m *TUIModel) showCommandMenu(items []list.Item) { + m.commandMenu = list.New(items, slashCommandDelegate{}, max(20, m.width), min(8, len(items))) + m.commandMenu.SetShowTitle(false) + m.commandMenu.SetShowFilter(false) + m.commandMenu.SetShowStatusBar(false) + m.commandMenu.SetShowPagination(false) + m.commandMenu.SetShowHelp(false) + m.commandMenu.SetFilteringEnabled(false) + m.commandMenuOpen = true +} diff --git a/internal/cmd/mcp/search.go b/internal/cmd/mcp/search.go new file mode 100644 index 0000000..91ecd52 --- /dev/null +++ b/internal/cmd/mcp/search.go @@ -0,0 +1,83 @@ +package mcp + +import ( + "fmt" + "net/http" + "strings" + "time" + "unicode" + + "github.com/charmbracelet/x/ansi" + "github.com/spf13/cobra" + + localmcp "github.com/rootlyhq/rootly-cli/internal/mcp" +) + +var ( + registryURL = localmcp.RegistryURL + indexPath = localmcp.IndexPath + searchRefresh bool + searchLimit int +) + +var searchCmd = &cobra.Command{ + Use: "search [query]", + Short: "Search the MCP marketplace (official registry + curated list)", + Long: `Search the official MCP registry (registry.modelcontextprotocol.io) merged with a +curated list; Rootly-integrated vendors (Datadog, Sentry, Linear, Notion) come first. +The index is cached in ~/.rootly-cli/mcp-index.json for 7 days (--refresh refetches). +Nothing is installed: each result shows the exact command or URL and the +'rootly mcp add' line that adds it, or add it from /mcp in 'rootly agent'.`, + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + errOut := cmd.ErrOrStderr() + client := &http.Client{Timeout: 20 * time.Second} + catalog, err := localmcp.LoadCatalog(cmd.Context(), client, registryURL, indexPath(), searchRefresh, func(entries, pages int) { + _, _ = fmt.Fprintf(errOut, "\rIndexing MCP registry: %d servers (%d pages)", entries, pages) + }) + _, _ = fmt.Fprint(errOut, "\r\033[K") + if err != nil { + _, _ = fmt.Fprintf(errOut, "warning: %v\n", err) + } + results := localmcp.SearchMarket(catalog, strings.Join(args, " "), max(1, searchLimit)) + out := cmd.OutOrStdout() + if len(results) == 0 { + _, _ = fmt.Fprintln(out, "No MCP servers match.") + return nil + } + for _, entry := range results { + title := entry.Name + if entry.Author != "" { + title += " (" + entry.Author + ")" + } + _, _ = fmt.Fprintf(out, "%s\n %s\n runs: %s\n add: %s\n", oneLine(title), oneLine(entry.Description), plainText(localmcp.InstallLabel(entry)), plainText(localmcp.AddCommand(entry))) + if entry.EnvHint != "" { + _, _ = fmt.Fprintf(out, " needs: %s\n", oneLine(entry.EnvHint)) + } + } + return nil + }, +} + +func plainText(text string) string { + return strings.Map(func(r rune) rune { + if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) { + return ' ' + } + return r + }, ansi.Strip(text)) +} + +func oneLine(text string) string { + text = strings.Join(strings.Fields(plainText(text)), " ") + if runes := []rune(text); len(runes) > 160 { + text = string(runes[:157]) + "..." + } + return text +} + +func init() { + searchCmd.Flags().BoolVar(&searchRefresh, "refresh", false, "Refetch the registry index") + searchCmd.Flags().IntVar(&searchLimit, "limit", 12, "Maximum results") + MCPCmd.AddCommand(searchCmd) +} diff --git a/internal/cmd/mcp/search_test.go b/internal/cmd/mcp/search_test.go new file mode 100644 index 0000000..5dee833 --- /dev/null +++ b/internal/cmd/mcp/search_test.go @@ -0,0 +1,76 @@ +package mcp + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSearchShowsExactInstallAndUserScopeAddLine(t *testing.T) { + test := setupEnv(t) + hits := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits++ + _, _ = w.Write([]byte(`{"servers":[{"server":{"name":"io.github.acme/pager-mcp","description":"Pager tool","packages":[{"registryType":"npm","identifier":"@acme/pager-mcp","transport":{"type":"stdio"}}]},"_meta":{"io.modelcontextprotocol.registry/official":{"isLatest":true}}}],"metadata":{}}`)) + })) + defer server.Close() + previousURL, previousPath := registryURL, indexPath + cache := filepath.Join(test.home, ".rootly-cli", "mcp-index.json") + registryURL, indexPath = server.URL, func() string { return cache } + t.Cleanup(func() { registryURL, indexPath = previousURL, previousPath }) + + out, err := run(t, "search", "pager") + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"pager-mcp (acme)", "runs: npx -y @acme/pager-mcp", "add: rootly mcp add -s user pager-mcp -- npx -y @acme/pager-mcp"} { + if !strings.Contains(out, want) { + t.Fatalf("missing %q in:\n%s", want, out) + } + } + if _, err := os.Stat(cache); err != nil { + t.Fatalf("index not cached: %v", err) + } + if _, err := run(t, "search", "sentry"); err != nil || hits != 1 { + t.Fatalf("second search should use the cache: hits=%d err=%v", hits, err) + } + out, _ = run(t, "search", "sentry") + if !strings.Contains(out, "runs: http https://mcp.sentry.dev/mcp") { + t.Fatalf("curated entry missing:\n%s", out) + } + if _, err := os.Stat(filepath.Join(test.home, ".rootly-cli", "mcp.json")); !os.IsNotExist(err) { + t.Fatal("search must not install anything") + } +} + +func TestSearchFallsBackToCuratedWhenRegistryFails(t *testing.T) { + test := setupEnv(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusBadGateway) })) + defer server.Close() + previousURL, previousPath := registryURL, indexPath + registryURL, indexPath = server.URL, func() string { return filepath.Join(test.home, "idx.json") } + t.Cleanup(func() { registryURL, indexPath = previousURL, previousPath }) + out, err := run(t, "search", "datadog") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "warning: could not refresh the MCP registry index") || !strings.Contains(out, "datadog (datadog)") { + t.Fatalf("out:\n%s", out) + } +} + +func TestSearchStripsTerminalControls(t *testing.T) { + if got := oneLine("evil\x1b]0;pwned\x07 \x1b[2Jtext\u202e\r\nmore"); got != "evil text more" { + t.Fatalf("oneLine = %q", got) + } +} + +func TestSearchKeepsLongAddCommandsWhole(t *testing.T) { + args := strings.Repeat("--flag-value ", 20) + if got := plainText("rootly mcp add -s user x -- npx " + args); !strings.HasSuffix(got, args) { + t.Fatalf("add line truncated: %q", got) + } +} diff --git a/internal/mcp/config.go b/internal/mcp/config.go index 129faa0..46be00a 100644 --- a/internal/mcp/config.go +++ b/internal/mcp/config.go @@ -11,6 +11,7 @@ import ( "regexp" "sort" "strings" + "time" "github.com/rootlyhq/rootly-cli/internal/config" ) @@ -39,6 +40,18 @@ type ServerConfig struct { URL string `json:"url,omitempty"` Headers map[string]string `json:"headers,omitempty"` BearerTokenEnvVar string `json:"bearer_token_env_var,omitempty"` + Timeout int `json:"timeout,omitempty"` +} + +// MaxTimeoutSeconds bounds the optional per-server timeout. +const MaxTimeoutSeconds = 600 + +// CallTimeout returns the per-server timeout, or fallback when unset. +func (c ServerConfig) CallTimeout(fallback time.Duration) time.Duration { + if c.Timeout <= 0 { + return fallback + } + return time.Duration(c.Timeout) * time.Second } func (c ServerConfig) Transport() string { @@ -58,6 +71,9 @@ func (c ServerConfig) Transport() string { } func (c ServerConfig) Validate() error { + if c.Timeout < 0 || c.Timeout > MaxTimeoutSeconds { + return fmt.Errorf("timeout must be between 1 and %d seconds", MaxTimeoutSeconds) + } switch c.Transport() { case TransportStdio: if strings.TrimSpace(c.Command) == "" { diff --git a/internal/mcp/expand.go b/internal/mcp/expand.go index 2408b76..017da0c 100644 --- a/internal/mcp/expand.go +++ b/internal/mcp/expand.go @@ -27,6 +27,7 @@ func (c ServerConfig) Expanded(lookup func(string) (string, bool)) ServerConfig Command: ExpandEnv(c.Command, lookup), URL: ExpandEnv(c.URL, lookup), BearerTokenEnvVar: c.BearerTokenEnvVar, + Timeout: c.Timeout, } for _, arg := range c.Args { expanded.Args = append(expanded.Args, ExpandEnv(arg, lookup)) diff --git a/internal/mcp/freshness_test.go b/internal/mcp/freshness_test.go new file mode 100644 index 0000000..af2e6aa --- /dev/null +++ b/internal/mcp/freshness_test.go @@ -0,0 +1,168 @@ +package mcp + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestServerTimeoutConfig(t *testing.T) { + config := ServerConfig{Command: "srv"} + if got := config.CallTimeout(45 * time.Second); got != 45*time.Second { + t.Errorf("unset timeout = %s, want fallback", got) + } + config.Timeout = 5 + if got := config.CallTimeout(45 * time.Second); got != 5*time.Second { + t.Errorf("timeout = %s, want 5s", got) + } + if err := config.Validate(); err != nil { + t.Errorf("valid timeout rejected: %v", err) + } + for _, bad := range []int{-1, MaxTimeoutSeconds + 1} { + config.Timeout = bad + if err := config.Validate(); err == nil { + t.Errorf("timeout %d accepted", bad) + } + } + var parsed ServerConfig + if err := json.Unmarshal([]byte(`{"command":"srv","timeout":30}`), &parsed); err != nil || parsed.Timeout != 30 { + t.Errorf("timeout not parsed from config: %+v %v", parsed, err) + } +} + +func refreshFixture(t *testing.T, refreshToken string) (*httptest.Server, *TokenStore, *atomic.Int32) { + t.Helper() + var refreshes atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + refreshes.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"access_token":"fresh","refresh_token":"r2","token_type":"Bearer","expires_in":3600}`) + }) + mux.HandleFunc("/mcp", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer fresh" { + w.WriteHeader(http.StatusUnauthorized) + return + } + body, _ := io.ReadAll(r.Body) + _, _ = w.Write(body) + }) + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + store := &TokenStore{Path: filepath.Join(t.TempDir(), "mcp-oauth.json")} + credential := StoredCredential{ClientID: "c", TokenURL: server.URL + "/token", AccessToken: "stale", RefreshToken: refreshToken, TokenType: "Bearer", Expiry: time.Now().Add(time.Hour)} + if err := store.Put(server.URL+"/mcp", credential); err != nil { + t.Fatal(err) + } + return server, store, &refreshes +} + +func TestRemote401RefreshesTokenAndRetriesOnce(t *testing.T) { + server, store, refreshes := refreshFixture(t, "r1") + manager := &Manager{options: ManagerOptions{Tokens: store}} + config := ServerConfig{Type: TransportHTTP, URL: server.URL + "/mcp"} + bearer, refresh := manager.bearerFuncs(config) + probe := &authProbe{} + client := newServerHTTPClientWithRefresh(config, bearer, refresh, probe) + + request, _ := http.NewRequestWithContext(t.Context(), http.MethodPost, config.URL, strings.NewReader(`{"ping":1}`)) + response, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(response.Body) + _ = response.Body.Close() + if response.StatusCode != http.StatusOK || string(body) != `{"ping":1}` { + t.Fatalf("retry = %d %q, want 200 with replayed body", response.StatusCode, body) + } + if refreshes.Load() != 1 || probe.seen() { + t.Errorf("refreshes = %d, needs-login = %v; want one silent refresh", refreshes.Load(), probe.seen()) + } + if stored, _ := store.Get(config.URL); stored.AccessToken != "fresh" { + t.Errorf("refreshed token not persisted: %q", stored.AccessToken) + } +} + +func TestRemote401WithoutRefreshTokenNeedsLogin(t *testing.T) { + server, store, refreshes := refreshFixture(t, "") + manager := &Manager{options: ManagerOptions{Tokens: store}} + config := ServerConfig{Type: TransportHTTP, URL: server.URL + "/mcp"} + bearer, refresh := manager.bearerFuncs(config) + probe := &authProbe{} + client := newServerHTTPClientWithRefresh(config, bearer, refresh, probe) + + request, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, config.URL, http.NoBody) + response, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + _ = response.Body.Close() + if response.StatusCode != http.StatusUnauthorized || !probe.seen() || refreshes.Load() != 0 { + t.Errorf("status %d, needs-login %v, refreshes %d; want 401 + needs-login without refresh", response.StatusCode, probe.seen(), refreshes.Load()) + } +} + +func TestExpandedKeepsTimeout(t *testing.T) { + config := ServerConfig{Command: "x", Timeout: 5}.Expanded(func(string) (string, bool) { return "", false }) + if config.CallTimeout(time.Minute) != 5*time.Second { + t.Fatalf("expanded timeout = %v, want 5s", config.CallTimeout(time.Minute)) + } +} + +func TestRemote401RefreshStopsWithRequestContext(t *testing.T) { + release := make(chan struct{}) + mux := http.NewServeMux() + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + _, _ = io.ReadAll(r.Body) + select { + case <-r.Context().Done(): + case <-release: + } + }) + mux.HandleFunc("/mcp", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }) + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + t.Cleanup(func() { close(release) }) + store := &TokenStore{Path: filepath.Join(t.TempDir(), "mcp-oauth.json")} + if err := store.Put(server.URL+"/mcp", StoredCredential{ClientID: "c", TokenURL: server.URL + "/token", AccessToken: "stale", RefreshToken: "r1", TokenType: "Bearer", Expiry: time.Now().Add(time.Hour)}); err != nil { + t.Fatal(err) + } + manager := &Manager{options: ManagerOptions{Tokens: store}} + config := ServerConfig{Type: TransportHTTP, URL: server.URL + "/mcp"} + bearer, refresh := manager.bearerFuncs(config) + client := newServerHTTPClientWithRefresh(config, bearer, refresh, &authProbe{}) + + ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond) + defer cancel() + request, _ := http.NewRequestWithContext(ctx, http.MethodGet, config.URL, http.NoBody) + started := time.Now() + response, err := client.Do(request) + if err == nil { + _ = response.Body.Close() + } + if elapsed := time.Since(started); elapsed > 3*time.Second { + t.Fatalf("refresh ignored request cancellation, took %v", elapsed) + } +} + +func TestConnectUsesConfiguredTimeout(t *testing.T) { + manager := NewManager(ManagerOptions{CWD: t.TempDir()}) + server := Server{Name: "slow", Config: ServerConfig{Command: "sleep", Args: []string{"30"}, Timeout: 1}} + start := time.Now() + session, _, status, _, _ := manager.connect(context.Background(), server) + if session != nil || status == StatusConnected { + t.Fatalf("hanging server connected: %s", status) + } + if elapsed := time.Since(start); elapsed > 10*time.Second { + t.Fatalf("connect took %s; the 1s server timeout was ignored", elapsed) + } +} diff --git a/internal/mcp/manager.go b/internal/mcp/manager.go index 9715aad..5ccbced 100644 --- a/internal/mcp/manager.go +++ b/internal/mcp/manager.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/http" "net/url" "os" @@ -50,6 +51,8 @@ type ServerView struct { Status Status Detail string Tools []ToolInfo + Prompts []PromptInfo + Resources []ResourceInfo SessionOff bool } @@ -77,6 +80,8 @@ type runtimeServer struct { status Status detail string tools []*sdk.Tool + prompts []PromptInfo + resources []ResourceInfo session *sdk.ClientSession sessionOff bool generation int @@ -240,7 +245,14 @@ func (m *Manager) connectLocked(runtime *runtimeServer) { runtime.cancel = cancel server := runtime.server go func() { - session, tools, status, detail := m.connect(ctx, server) + session, tools, status, detail, probe := m.connect(ctx, server) + var prompts []PromptInfo + var resources []ResourceInfo + if session != nil { + discoverCtx, cancelDiscover := context.WithTimeout(ctx, server.Config.CallTimeout(connectTimeout)) + prompts, resources = discoverExtras(discoverCtx, session) + cancelDiscover() + } m.mu.Lock() defer m.mu.Unlock() if runtime.generation != generation || m.closed { @@ -250,11 +262,34 @@ func (m *Manager) connectLocked(runtime *runtimeServer) { return } runtime.session, runtime.tools, runtime.status, runtime.detail = session, tools, status, detail + runtime.prompts, runtime.resources = prompts, resources + if session != nil { + m.watchAuth(runtime, generation, probe) + } m.notify() }() m.notify() } +// watchAuth must run with m.mu held; a 401 seen while discovering prompts and resources marks the server at once. +func (m *Manager) watchAuth(runtime *runtimeServer, generation int, probe *authProbe) { + if probe.watch(func() { m.loseAuth(runtime, generation) }) { + runtime.close() + runtime.status, runtime.detail = StatusNeedsAuth, "needs authentication, Enter to log in" + } +} + +func (m *Manager) loseAuth(runtime *runtimeServer, generation int) { + m.mu.Lock() + defer m.mu.Unlock() + if runtime.generation != generation || runtime.session == nil { + return + } + runtime.close() + runtime.status, runtime.detail = StatusNeedsAuth, "needs authentication, Enter to log in" + m.notify() +} + func (r *runtimeServer) close() { r.generation++ if r.cancel != nil { @@ -267,33 +302,33 @@ func (r *runtimeServer) close() { go func() { _ = session.Close() }() } r.tools = nil + r.prompts = nil + r.resources = nil } -func (m *Manager) connect(ctx context.Context, server Server) (*sdk.ClientSession, []*sdk.Tool, Status, string) { +func (m *Manager) connect(ctx context.Context, server Server) (session *sdk.ClientSession, tools []*sdk.Tool, status Status, detail string, probe *authProbe) { config := server.Config.Expanded(m.options.Lookup) - connectCtx, cancel := context.WithTimeout(ctx, connectTimeout) + connectCtx, cancel := context.WithTimeout(ctx, config.CallTimeout(connectTimeout)) defer cancel() - transport, stderr, unauthorized, err := m.transport(ctx, server, config) + transport, stderr, probe, err := m.transport(ctx, server, config) if err != nil { - return nil, nil, StatusError, err.Error() + return nil, nil, StatusError, err.Error(), nil } client := sdk.NewClient(&sdk.Implementation{Name: "rootly-cli", Version: m.options.Version}, nil) - session, err := client.Connect(connectCtx, transport, nil) + session, err = client.Connect(connectCtx, transport, nil) if err != nil { - return nil, nil, failureStatus(err, unauthorized), failureDetail(err, stderr, unauthorized) + return nil, nil, failureStatus(err, probe), failureDetail(err, stderr, probe), nil } - var tools []*sdk.Tool for tool, err := range session.Tools(connectCtx, nil) { if err != nil { _ = session.Close() - return nil, nil, failureStatus(err, unauthorized), failureDetail(err, stderr, unauthorized) + return nil, nil, failureStatus(err, probe), failureDetail(err, stderr, probe), nil } tools = append(tools, tool) } sort.SliceStable(tools, func(i, j int) bool { return tools[i].Name < tools[j].Name }) - return session, tools, StatusConnected, "" + return session, tools, StatusConnected, "", probe } - func failureStatus(err error, unauthorized *authProbe) Status { if errors.Is(err, ErrNeedsAuth) || unauthorized.seen() { return StatusNeedsAuth @@ -344,7 +379,8 @@ func (m *Manager) transport(ctx context.Context, server Server, config ServerCon return &sdk.CommandTransport{Command: command, TerminateDuration: 2 * time.Second}, stderr, nil, nil case TransportHTTP, TransportSSE: probe := &authProbe{} - client := newServerHTTPClient(config, m.bearer(config), probe) + bearer, refresh := m.bearerFuncs(config) + client := newServerHTTPClientWithRefresh(config, bearer, refresh, probe) if config.Transport() == TransportSSE { return &sdk.SSEClientTransport{Endpoint: config.URL, HTTPClient: client}, nil, probe, nil } @@ -354,18 +390,18 @@ func (m *Manager) transport(ctx context.Context, server Server, config ServerCon } } -func (m *Manager) bearer(config ServerConfig) func() string { +func (m *Manager) bearerFuncs(config ServerConfig) (bearer func() string, refresh func(context.Context) string) { if config.BearerTokenEnvVar != "" { return func() string { value, _ := m.options.Lookup(config.BearerTokenEnvVar) return value - } + }, nil } tokens := m.options.Tokens serverURL := config.URL var source *persistingTokenSource var mu sync.Mutex - return func() string { + bearer = func() string { mu.Lock() defer mu.Unlock() if source == nil { @@ -381,17 +417,50 @@ func (m *Manager) bearer(config ServerConfig) func() string { } return token.AccessToken } + refresh = func(ctx context.Context) string { + mu.Lock() + defer mu.Unlock() + credential, ok := tokens.Get(serverURL) + if !ok || credential.RefreshToken == "" { + return "" + } + expired := credential.token() + expired.Expiry = time.Now().Add(-time.Minute) + source = nil + refreshing := &persistingTokenSource{store: tokens, serverURL: serverURL, base: credential, source: credential.oauthConfig().TokenSource(ctx, expired), last: expired.AccessToken} + token, err := refreshing.Token() + if err != nil || token.AccessToken == "" || token.AccessToken == credential.AccessToken { + return "" + } + return token.AccessToken + } + return bearer, refresh } type authProbe struct { mu sync.Mutex unauthorized bool + onExpired func() } func (p *authProbe) mark() { p.mu.Lock() p.unauthorized = true + onExpired := p.onExpired p.mu.Unlock() + if onExpired != nil { + onExpired() + } +} + +func (p *authProbe) watch(onExpired func()) (alreadyUnauthorized bool) { + if p == nil { + return false + } + p.mu.Lock() + defer p.mu.Unlock() + p.onExpired = onExpired + return p.unauthorized } func (p *authProbe) seen() bool { @@ -406,12 +475,17 @@ func (p *authProbe) seen() bool { // newServerHTTPClient sends the configured headers and bearer token only to // the server's own origin and refuses redirects to any other origin. func newServerHTTPClient(config ServerConfig, bearer func() string, probe *authProbe) *http.Client { + return newServerHTTPClientWithRefresh(config, bearer, nil, probe) +} + +func newServerHTTPClientWithRefresh(config ServerConfig, bearer func() string, refresh func(context.Context) string, probe *authProbe) *http.Client { return &http.Client{ Transport: &headerTransport{ base: http.DefaultTransport, origin: originOf(config.URL), headers: config.Headers, bearer: bearer, + refresh: refresh, probe: probe, }, CheckRedirect: rejectCrossOriginRedirect, @@ -445,6 +519,7 @@ type headerTransport struct { origin string headers map[string]string bearer func() string + refresh func(context.Context) string probe *authProbe } @@ -456,12 +531,17 @@ func (t *headerTransport) RoundTrip(request *http.Request) (*http.Response, erro for key, value := range t.headers { request.Header.Set(key, value) } + usedBearer := false if request.Header.Get("Authorization") == "" && t.bearer != nil { if token := t.bearer(); token != "" { request.Header.Set("Authorization", "Bearer "+token) + usedBearer = true } } response, err := t.base.RoundTrip(request) + if err == nil && response.StatusCode == http.StatusUnauthorized && usedBearer { + response, err = t.retryWithRefreshedToken(request, response) + } if err == nil && response.StatusCode == http.StatusUnauthorized && t.probe != nil { t.probe.mark() } @@ -498,7 +578,7 @@ func (m *Manager) Views() []ServerView { defer m.mu.Unlock() views := make([]ServerView, 0, len(m.servers)) for _, runtime := range m.servers { - view := ServerView{Server: runtime.server, Status: runtime.status, Detail: runtime.detail, SessionOff: runtime.sessionOff} + view := ServerView{Server: runtime.server, Status: runtime.status, Detail: runtime.detail, SessionOff: runtime.sessionOff, Prompts: runtime.prompts, Resources: runtime.resources} view.Tools = make([]ToolInfo, 0, len(runtime.tools)) for _, tool := range runtime.tools { view.Tools = append(view.Tools, ToolInfo{Name: tool.Name, Description: tool.Description, ReadOnly: readOnly(tool)}) @@ -707,9 +787,11 @@ func (m *Manager) Call(ctx context.Context, name string, arguments map[string]an m.mu.Lock() ref, ok := m.toolRefs[name] var session *sdk.ClientSession + timeout := time.Duration(0) if ok { if runtime := m.byID[ref.ServerID]; runtime != nil && !runtime.sessionOff { session = runtime.session + timeout = runtime.server.Config.CallTimeout(0) } } m.mu.Unlock() @@ -722,7 +804,15 @@ func (m *Manager) Call(ctx context.Context, name string, arguments map[string]an if arguments == nil { arguments = map[string]any{} } + if timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, timeout) + defer cancel() + } result, err := session.CallTool(ctx, &sdk.CallToolParams{Name: ref.Tool, Arguments: arguments}) + if err != nil && timeout > 0 && errors.Is(ctx.Err(), context.DeadlineExceeded) { + return CallResult{Output: fmt.Sprintf("Local tool timed out after %s", timeout), IsError: true} + } if err != nil { return CallResult{Output: TruncateOutput("Local tool failed: " + err.Error()), IsError: true} } @@ -794,3 +884,41 @@ func (m *Manager) Close() { } m.cancel() } + +func (t *headerTransport) retryWithRefreshedToken(request *http.Request, response *http.Response) (*http.Response, error) { + if t.refresh == nil || (request.Body != nil && request.Body != http.NoBody && request.GetBody == nil) { + return response, nil + } + token := t.refresh(request.Context()) + if token == "" { + return response, nil + } + retry := request.Clone(request.Context()) + if request.GetBody != nil { + body, err := request.GetBody() + if err != nil { + return response, nil + } + retry.Body = body + } + retry.Header.Set("Authorization", "Bearer "+token) + _, _ = io.Copy(io.Discard, response.Body) + _ = response.Body.Close() + return t.base.RoundTrip(retry) +} + +// AddFromMarket installs a marketplace entry in USER scope, switched off until the user turns it on. +func (m *Manager) AddFromMarket(entry MarketEntry) (Server, error) { + imported, _ := m.options.Discovery.Imported() + for _, server := range imported { + if server.Name == entry.Name { + return Server{}, fmt.Errorf("an MCP server named %s is already imported from %s", entry.Name, server.Path) + } + } + server, err := AddFromMarket(m.options.Store, m.options.State, entry) + if err != nil { + return Server{}, err + } + _ = m.Load() + return server, nil +} diff --git a/internal/mcp/registry.go b/internal/mcp/registry.go new file mode 100644 index 0000000..3398365 --- /dev/null +++ b/internal/mcp/registry.go @@ -0,0 +1,428 @@ +package mcp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + "github.com/rootlyhq/rootly-cli/internal/config" +) + +const ( + RegistryURL = "https://registry.modelcontextprotocol.io/v0/servers" + indexFile = "mcp-index.json" + registryPageLimit = 100 + registryMaxPages = 200 + IndexMaxAge = 7 * 24 * time.Hour + officialMetaKey = "io.modelcontextprotocol.registry/official" + maxMarketNameBytes = 40 +) + +// MarketEntry is one installable server from the curated list or the official registry. +type MarketEntry struct { + Name string `json:"name"` + RegistryName string `json:"registry_name,omitempty"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + Author string `json:"author,omitempty"` + EnvHint string `json:"env_hint,omitempty"` + Rootly bool `json:"rootly,omitempty"` + Config ServerConfig `json:"config"` +} + +type marketIndex struct { + IndexedAt time.Time `json:"indexed_at"` + Entries []MarketEntry `json:"entries"` +} + +func npxConfig(pkg string, args ...string) ServerConfig { + return ServerConfig{Command: "npx", Args: append([]string{"-y", pkg}, args...)} +} + +func uvxConfig(pkg string) ServerConfig { return ServerConfig{Command: "uvx", Args: []string{pkg}} } + +// CuratedEntries lists vendors Rootly integrates with first, then common servers. +func CuratedEntries() []MarketEntry { + return []MarketEntry{ + {Name: "datadog", Author: "datadog", Rootly: true, Description: "Datadog: monitors, metrics, logs (remote, OAuth).", Config: ServerConfig{Type: TransportHTTP, URL: "https://mcp.datadoghq.com/api/unstable/mcp-server/mcp"}}, + {Name: "sentry", Author: "sentry", Rootly: true, Description: "Sentry: issues and events (remote, OAuth).", Config: ServerConfig{Type: TransportHTTP, URL: "https://mcp.sentry.dev/mcp"}}, + {Name: "linear", Author: "linear", Rootly: true, Description: "Linear: issues and projects (remote, OAuth).", Config: ServerConfig{Type: TransportHTTP, URL: "https://mcp.linear.app/mcp"}}, + {Name: "notion", Author: "notion", Rootly: true, Description: "Notion: pages and databases (remote, OAuth).", Config: ServerConfig{Type: TransportHTTP, URL: "https://mcp.notion.com/mcp"}}, + {Name: "github", Author: "modelcontextprotocol", Description: "GitHub: repos, issues, PRs, code search.", EnvHint: "GITHUB_PERSONAL_ACCESS_TOKEN", Config: npxConfig("@modelcontextprotocol/server-github")}, + {Name: "gitlab", Author: "modelcontextprotocol", Description: "GitLab: projects, issues, MRs.", EnvHint: "GITLAB_PERSONAL_ACCESS_TOKEN", Config: npxConfig("@modelcontextprotocol/server-gitlab")}, + {Name: "filesystem", Author: "modelcontextprotocol", Description: "Local filesystem access, scoped to a path.", Config: npxConfig("@modelcontextprotocol/server-filesystem", "${PWD}")}, + {Name: "postgres", Author: "modelcontextprotocol", Description: "Query a PostgreSQL database (read-only).", EnvHint: "DATABASE_URL", Config: npxConfig("@modelcontextprotocol/server-postgres", "${DATABASE_URL}")}, + {Name: "slack", Author: "modelcontextprotocol", Description: "Slack: channels, messages, users.", EnvHint: "SLACK_BOT_TOKEN", Config: npxConfig("@modelcontextprotocol/server-slack")}, + {Name: "memory", Author: "modelcontextprotocol", Description: "Persistent knowledge-graph memory.", Config: npxConfig("@modelcontextprotocol/server-memory")}, + {Name: "fetch", Author: "modelcontextprotocol", Description: "Fetch web pages as markdown.", Config: uvxConfig("mcp-server-fetch")}, + {Name: "aws-api", Author: "awslabs", Description: "AWS: call AWS APIs with your local credentials.", EnvHint: "AWS_PROFILE", Config: uvxConfig("awslabs.aws-api-mcp-server@latest")}, + {Name: "aws-docs", Author: "awslabs", Description: "AWS documentation: search and read.", Config: uvxConfig("awslabs.aws-documentation-mcp-server@latest")}, + {Name: "aws-cloudwatch", Author: "awslabs", Description: "AWS CloudWatch: metrics, logs, alarms.", EnvHint: "AWS_PROFILE", Config: uvxConfig("awslabs.cloudwatch-mcp-server@latest")}, + } +} + +type registryPage struct { + Servers []struct { + Server registryServer `json:"server"` + Meta map[string]json.RawMessage `json:"_meta"` + } `json:"servers"` + Metadata struct { + NextCursor string `json:"nextCursor"` + } `json:"metadata"` +} + +type registryServer struct { + Name string `json:"name"` + Title string `json:"title"` + Description string `json:"description"` + Repository struct { + URL string `json:"url"` + } `json:"repository"` + Packages []struct { + RegistryType string `json:"registryType"` + Identifier string `json:"identifier"` + Transport struct { + Type string `json:"type"` + } `json:"transport"` + } `json:"packages"` + Remotes []struct { + Type string `json:"type"` + URL string `json:"url"` + } `json:"remotes"` +} + +var nonSlug = regexp.MustCompile(`[^a-z0-9]+`) + +func marketName(registryName string) string { + last := registryName[strings.LastIndex(registryName, "/")+1:] + name := strings.Trim(nonSlug.ReplaceAllString(strings.ToLower(last), "-"), "-") + if len(name) > maxMarketNameBytes { + name = strings.Trim(name[:maxMarketNameBytes], "-") + } + return name +} + +var githubOwner = regexp.MustCompile(`(?i)github\.com/([^/]+)`) + +func registryAuthor(server registryServer) string { + if match := githubOwner.FindStringSubmatch(server.Repository.URL); match != nil { + return match[1] + } + parts := strings.Split(strings.SplitN(server.Name, "/", 2)[0], ".") + if len(parts) > 2 && parts[0] == "io" && parts[1] == "github" { + return parts[2] + } + if len(parts) > 1 { + return parts[1] + } + return parts[0] +} + +// installFromRegistry maps npm→npx -y, pypi→uvx, oci→docker run -i --rm, else the first remote URL. +func installFromRegistry(server registryServer) (ServerConfig, bool) { + for _, pkg := range server.Packages { + if pkg.Identifier == "" || (pkg.Transport.Type != "" && pkg.Transport.Type != "stdio") { + continue + } + switch pkg.RegistryType { + case "npm": + return npxConfig(pkg.Identifier), true + case "pypi": + return uvxConfig(pkg.Identifier), true + case "oci": + return ServerConfig{Command: "docker", Args: []string{"run", "-i", "--rm", pkg.Identifier}}, true + } + } + for _, remote := range server.Remotes { + if remote.URL == "" { + continue + } + if remote.Type == "sse" { + return ServerConfig{Type: TransportSSE, URL: remote.URL}, true + } + return ServerConfig{Type: TransportHTTP, URL: remote.URL}, true + } + return ServerConfig{}, false +} + +func isLatest(meta map[string]json.RawMessage) bool { + raw, ok := meta[officialMetaKey] + if !ok { + return true + } + var official struct { + IsLatest bool `json:"isLatest"` + } + return json.Unmarshal(raw, &official) == nil && official.IsLatest +} + +// FetchRegistry pages through the official registry and keeps the latest version of each installable server. +func FetchRegistry(ctx context.Context, client *http.Client, baseURL string, progress func(entries, pages int)) ([]MarketEntry, error) { + if client == nil { + client = &http.Client{Timeout: 20 * time.Second} + } + var entries []MarketEntry + seen := map[string]bool{} + cursor := "" + for page := 1; page <= registryMaxPages; page++ { + next, err := fetchRegistryPage(ctx, client, baseURL, cursor) + if err != nil { + if len(entries) > 0 { + return entries, fmt.Errorf("registry page %d: %w (index incomplete)", page, err) + } + return nil, err + } + for _, item := range next.Servers { + if item.Server.Name == "" || !isLatest(item.Meta) || seen[item.Server.Name] { + continue + } + install, ok := installFromRegistry(item.Server) + name := marketName(item.Server.Name) + if !ok || name == "" || install.Validate() != nil { + continue + } + seen[item.Server.Name] = true + entries = append(entries, MarketEntry{Name: name, RegistryName: item.Server.Name, Title: item.Server.Title, Description: item.Server.Description, Author: registryAuthor(item.Server), Config: install}) + } + if progress != nil { + progress(len(entries), page) + } + if next.Metadata.NextCursor == "" { + break + } + cursor = next.Metadata.NextCursor + } + return entries, nil +} + +func fetchRegistryPage(ctx context.Context, client *http.Client, baseURL, cursor string) (*registryPage, error) { + endpoint, err := url.Parse(baseURL) + if err != nil { + return nil, err + } + query := endpoint.Query() + query.Set("limit", fmt.Sprint(registryPageLimit)) + if cursor != "" { + query.Set("cursor", cursor) + } + endpoint.RawQuery = query.Encode() + request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), http.NoBody) + if err != nil { + return nil, err + } + request.Header.Set("Accept", "application/json") + response, err := client.Do(request) + if err != nil { + return nil, err + } + defer func() { _ = response.Body.Close() }() + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("registry returned HTTP %d", response.StatusCode) + } + var page registryPage + if err := json.NewDecoder(response.Body).Decode(&page); err != nil { + return nil, fmt.Errorf("decode registry page: %w", err) + } + return &page, nil +} + +func IndexPath() string { return filepath.Join(config.Dir(), indexFile) } + +func SaveIndex(path string, entries []MarketEntry, now time.Time) error { + data, err := json.Marshal(marketIndex{IndexedAt: now, Entries: entries}) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + return os.WriteFile(path, data, 0o600) +} + +func LoadIndex(path string) (entries []MarketEntry, indexedAt time.Time, err error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, time.Time{}, err + } + var index marketIndex + if err := json.Unmarshal(data, &index); err != nil { + return nil, time.Time{}, err + } + return index.Entries, index.IndexedAt, nil +} + +// Catalog merges curated entries (first) with the registry index. A curated entry +// replaces registry entries of the same name; registry entries that share a short +// name get distinct install names so none of them disappear. +func Catalog(indexed []MarketEntry) []MarketEntry { + curated := CuratedEntries() + seen := map[string]bool{} + curatedInstalls := map[string]bool{} + entries := make([]MarketEntry, 0, len(curated)+len(indexed)) + for _, entry := range curated { + seen[entry.Name] = true + curatedInstalls[entry.Name+"\x00"+installKey(entry.Config)] = true + entries = append(entries, entry) + } + for _, entry := range indexed { + if entry.Name == "" || curatedInstalls[entry.Name+"\x00"+installKey(entry.Config)] { + continue + } + if seen[entry.Name] { + entry.Name = distinctMarketName(entry, seen) + } + seen[entry.Name] = true + entries = append(entries, entry) + } + return entries +} + +func installKey(server ServerConfig) string { + return strings.Join(append([]string{server.Transport(), server.URL, server.Command}, server.Args...), "\x00") +} + +func distinctMarketName(entry MarketEntry, seen map[string]bool) string { + base := entry.Name + if publisher, _, ok := strings.Cut(entry.RegistryName, "/"); ok { + if owner := marketName(publisher[strings.LastIndex(publisher, ".")+1:]); owner != "" { + base = marketName(entry.Name + "-" + owner) + } + } + name := base + for suffix := 2; seen[name]; suffix++ { + tail := fmt.Sprintf("-%d", suffix) + name = strings.Trim(base[:min(len(base), maxMarketNameBytes-len(tail))], "-") + tail + } + return name +} + +func fuzzyScore(query, text string) int { + text = strings.ToLower(text) + if index := strings.Index(text, query); index >= 0 { + return 100 - min(index, 99) + } + matched := 0 + for _, char := range text { + if matched < len(query) && byte(char) == query[matched] { + matched++ + } + } + if matched == len(query) { + return 1 + } + return -1 +} + +// SearchMarket ranks entries by name, then title and description; ties keep catalog order. +func SearchMarket(entries []MarketEntry, query string, limit int) []MarketEntry { + query = strings.ToLower(strings.TrimSpace(query)) + if query == "" { + return entries[:min(limit, len(entries))] + } + type scored struct { + entry MarketEntry + score int + } + var matches []scored + for _, entry := range entries { + score := max(fuzzyScore(query, entry.Name)*2, fuzzyScore(query, entry.Title+" "+entry.Description)) + if score >= 0 { + matches = append(matches, scored{entry, score}) + } + } + sort.SliceStable(matches, func(i, j int) bool { return matches[i].score > matches[j].score }) + results := make([]MarketEntry, 0, min(limit, len(matches))) + for _, match := range matches[:min(limit, len(matches))] { + results = append(results, match.entry) + } + return results +} + +// InstallLabel is the exact command or URL that adding the entry will run or contact. +func InstallLabel(entry MarketEntry) string { + if entry.Config.URL != "" { + return entry.Config.Type + " " + entry.Config.URL + } + return strings.TrimSpace(entry.Config.Command + " " + strings.Join(entry.Config.Args, " ")) +} + +// AddFromMarket writes the entry to USER scope switched off, so nothing runs until the user turns it on. +func AddFromMarket(store *Store, states *StateStore, entry MarketEntry) (Server, error) { + if err := ValidateServerName(entry.Name); err != nil { + return Server{}, err + } + if err := entry.Config.Validate(); err != nil { + return Server{}, err + } + existing, err := store.All() + if err != nil { + return Server{}, err + } + for _, server := range existing { + if server.Name == entry.Name { + return Server{}, fmt.Errorf("an MCP server named %s already exists (%s scope)", entry.Name, server.Scope) + } + } + server := Server{Name: entry.Name, Scope: ScopeUser, Path: store.UserPath, Config: entry.Config} + if err := states.Update(func(state *State) { state.SetEnabled(server, false) }); err != nil { + return Server{}, err + } + if err := store.Add(ScopeUser, entry.Name, entry.Config); err != nil { + return Server{}, errors.Join(err, states.Update(func(state *State) { state.SetEnabled(server, true) })) + } + return server, nil +} + +// LoadCatalog uses the cached index, refetching it when missing, stale or forced; on fetch failure it falls back to the cache or the curated list. +func LoadCatalog(ctx context.Context, client *http.Client, registryURL, indexPath string, refresh bool, progress func(entries, pages int)) ([]MarketEntry, error) { + cached, indexedAt, cacheErr := LoadIndex(indexPath) + if !refresh && cacheErr == nil && time.Since(indexedAt) < IndexMaxAge { + return Catalog(cached), nil + } + fetched, err := FetchRegistry(ctx, client, registryURL, progress) + if err != nil { + if cacheErr != nil && len(fetched) > 0 { + cached = fetched + } + return Catalog(cached), fmt.Errorf("could not refresh the MCP registry index: %w", err) + } + if err := SaveIndex(indexPath, fetched, time.Now()); err != nil { + return Catalog(fetched), fmt.Errorf("could not cache the MCP registry index: %w", err) + } + return Catalog(fetched), nil +} + +// AddCommand is the equivalent `rootly mcp add` invocation for an entry, shell-quoted +// so `${VAR}` placeholders and URL query strings survive a copy-paste. +func AddCommand(entry MarketEntry) string { + parts := []string{"rootly", "mcp", "add", "-s", "user"} + if entry.Config.URL != "" { + parts = append(parts, "-t", entry.Config.Type, entry.Name, entry.Config.URL) + } else { + parts = append(parts, entry.Name, "--", entry.Config.Command) + parts = append(parts, entry.Config.Args...) + } + quoted := make([]string, 0, len(parts)) + for _, part := range parts { + quoted = append(quoted, shellQuote(part)) + } + return strings.Join(quoted, " ") +} + +var shellSafe = regexp.MustCompile(`^[A-Za-z0-9_@%+=:,./-]+$`) + +func shellQuote(value string) string { + if shellSafe.MatchString(value) { + return value + } + return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'" +} diff --git a/internal/mcp/registry_test.go b/internal/mcp/registry_test.go new file mode 100644 index 0000000..b4bd262 --- /dev/null +++ b/internal/mcp/registry_test.go @@ -0,0 +1,225 @@ +package mcp + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +const registryPage1 = `{"servers":[ + {"server":{"name":"io.github.acme/pager-mcp","description":"Pager","packages":[{"registryType":"npm","identifier":"@acme/pager-mcp","transport":{"type":"stdio"}}]},"_meta":{"io.modelcontextprotocol.registry/official":{"isLatest":false}}}, + {"server":{"name":"io.github.acme/pager-mcp","description":"Pager latest","packages":[{"registryType":"npm","identifier":"@acme/pager-mcp","transport":{"type":"stdio"}}]},"_meta":{"io.modelcontextprotocol.registry/official":{"isLatest":true}}}, + {"server":{"name":"com.example/py-tool","description":"Py","packages":[{"registryType":"pypi","identifier":"py-tool","transport":{"type":"stdio"}}]},"_meta":{"io.modelcontextprotocol.registry/official":{"isLatest":true}}} +],"metadata":{"nextCursor":"c2"}}` + +const registryPage2 = `{"servers":[ + {"server":{"name":"io.github.box/oci-tool","repository":{"url":"https://github.com/boxco/oci"},"packages":[{"registryType":"oci","identifier":"ghcr.io/box/oci:1","transport":{"type":"stdio"}}]},"_meta":{"io.modelcontextprotocol.registry/official":{"isLatest":true}}}, + {"server":{"name":"dev.remote/events","remotes":[{"type":"sse","url":"https://events.example/sse"}]},"_meta":{"io.modelcontextprotocol.registry/official":{"isLatest":true}}}, + {"server":{"name":"dev.remote/api","remotes":[{"type":"streamable-http","url":"https://api.example/mcp"}]},"_meta":{"io.modelcontextprotocol.registry/official":{"isLatest":true}}}, + {"server":{"name":"dev.empty/nothing"},"_meta":{"io.modelcontextprotocol.registry/official":{"isLatest":true}}} +],"metadata":{}}` + +func TestFetchRegistryPagesAndMapsInstalls(t *testing.T) { + var queries []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + queries = append(queries, r.URL.RawQuery) + if r.URL.Query().Get("cursor") == "c2" { + _, _ = w.Write([]byte(registryPage2)) + return + } + _, _ = w.Write([]byte(registryPage1)) + })) + defer server.Close() + entries, err := FetchRegistry(context.Background(), server.Client(), server.URL+"/v0/servers", nil) + if err != nil { + t.Fatal(err) + } + if len(queries) != 2 || queries[0] != "limit=100" || queries[1] != "cursor=c2&limit=100" { + t.Fatalf("queries = %v", queries) + } + got := map[string]string{} + for _, entry := range entries { + got[entry.Name] = InstallLabel(entry) + } + want := map[string]string{ + "pager-mcp": "npx -y @acme/pager-mcp", + "py-tool": "uvx py-tool", + "oci-tool": "docker run -i --rm ghcr.io/box/oci:1", + "events": "sse https://events.example/sse", + "api": "http https://api.example/mcp", + } + if len(got) != len(want) { + t.Fatalf("entries = %v", got) + } + for name, label := range want { + if got[name] != label { + t.Fatalf("%s = %q, want %q", name, got[name], label) + } + } + for _, entry := range entries { + if entry.Name == "pager-mcp" && entry.Description != "Pager latest" { + t.Fatalf("kept a non-latest version: %+v", entry) + } + if entry.Name == "oci-tool" && entry.Author != "boxco" { + t.Fatalf("author = %q", entry.Author) + } + } +} + +func TestCatalogPutsRootlyVendorsFirstAndSearchRanks(t *testing.T) { + catalog := Catalog([]MarketEntry{{Name: "sentry", Description: "registry duplicate", Config: ServerConfig{Type: TransportHTTP, URL: "https://mcp.sentry.dev/mcp"}}, {Name: "pager-mcp", Description: "Paging tool", Config: npxConfig("@acme/pager-mcp")}}) + var first []string + for _, entry := range catalog[:4] { + first = append(first, entry.Name) + } + if strings.Join(first, ",") != "datadog,sentry,linear,notion" { + t.Fatalf("first = %v", first) + } + for _, entry := range catalog { + if entry.Description == "registry duplicate" { + t.Fatal("curated entry should win over the registry duplicate") + } + } + if results := SearchMarket(catalog, "pager", 5); len(results) == 0 || results[0].Name != "pager-mcp" { + t.Fatalf("search = %+v", results) + } + if results := SearchMarket(catalog, "", 3); len(results) != 3 || results[0].Name != "datadog" { + t.Fatalf("empty search = %+v", results) + } + if results := SearchMarket(catalog, "zzzzqqq", 5); len(results) != 0 { + t.Fatalf("no-match search = %+v", results) + } +} + +func TestIndexCacheRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "mcp-index.json") + now := time.Now().UTC().Truncate(time.Second) + if err := SaveIndex(path, []MarketEntry{{Name: "x", Config: uvxConfig("x")}}, now); err != nil { + t.Fatal(err) + } + entries, indexedAt, err := LoadIndex(path) + if err != nil || len(entries) != 1 || !indexedAt.Equal(now) || entries[0].Config.Command != "uvx" { + t.Fatalf("entries=%v at=%v err=%v", entries, indexedAt, err) + } +} + +func TestAddFromMarketWritesUserScopeSwitchedOff(t *testing.T) { + home := t.TempDir() + cwd := t.TempDir() + store := &Store{UserPath: filepath.Join(home, "mcp.json"), CWD: cwd} + states := &StateStore{Path: filepath.Join(home, "mcp-state.json")} + entry := MarketEntry{Name: "pager-mcp", Config: npxConfig("@acme/pager-mcp")} + server, err := AddFromMarket(store, states, entry) + if err != nil { + t.Fatal(err) + } + users, _ := store.Servers(ScopeUser) + if len(users) != 1 || users[0].Name != "pager-mcp" || users[0].ID() != server.ID() { + t.Fatalf("user servers = %+v", users) + } + if project, _ := store.Servers(ScopeProject); len(project) != 0 { + t.Fatalf("marketplace add leaked into project scope: %+v", project) + } + state, _ := states.Load() + if state.IsEnabled(users[0]) { + t.Fatal("marketplace server must stay off until the user turns it on") + } + if _, err := AddFromMarket(store, states, entry); err == nil { + t.Fatal("duplicate name should fail") + } +} + +func TestPartialRegistryFetchIsNotCached(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("cursor") == "c2" { + w.WriteHeader(http.StatusInternalServerError) + return + } + _, _ = w.Write([]byte(registryPage1)) + })) + defer server.Close() + entries, err := FetchRegistry(context.Background(), server.Client(), server.URL+"/v0/servers", nil) + if err == nil || len(entries) == 0 { + t.Fatalf("partial fetch = %d entries, err %v; want entries and an error", len(entries), err) + } + indexPath := filepath.Join(t.TempDir(), "mcp-index.json") + catalog, err := LoadCatalog(context.Background(), server.Client(), server.URL+"/v0/servers", indexPath, true, nil) + if err == nil || len(catalog) == 0 { + t.Fatalf("LoadCatalog = %d entries, err %v; want partial results and an error", len(catalog), err) + } + if _, statErr := os.Stat(indexPath); !os.IsNotExist(statErr) { + t.Fatalf("partial index was cached: %v", statErr) + } +} + +func TestManagerAddFromMarketRejectsImportedNames(t *testing.T) { + home := t.TempDir() + project := t.TempDir() + if err := os.WriteFile(filepath.Join(home, ".claude.json"), []byte(`{"mcpServers":{"pager-mcp":{"command":"pager"}}}`), 0o600); err != nil { + t.Fatal(err) + } + store := &Store{UserPath: filepath.Join(home, "mcp.json"), CWD: project} + states := &StateStore{Path: filepath.Join(home, "mcp-state.json")} + manager := NewManager(ManagerOptions{CWD: project, Store: store, State: states, Discovery: &Discovery{Home: home, CWD: project}}) + defer manager.Close() + if _, err := manager.AddFromMarket(MarketEntry{Name: "pager-mcp", Config: npxConfig("@acme/pager-mcp")}); err == nil { + t.Fatal("a marketplace entry must not reuse an imported server's name") + } + if users, _ := store.Servers(ScopeUser); len(users) != 0 { + t.Fatalf("user servers = %+v", users) + } +} + +func TestAddCommandIsShellQuoted(t *testing.T) { + remote := MarketEntry{Name: "acme", Config: ServerConfig{Type: "http", URL: "https://example.com/mcp?a=1&b=2"}} + if got := AddCommand(remote); got != "rootly mcp add -s user -t http acme 'https://example.com/mcp?a=1&b=2'" { + t.Fatalf("remote = %s", got) + } + local := MarketEntry{Name: "fs", Config: ServerConfig{Command: "npx", Args: []string{"-y", "@acme/fs", "${PWD}", "it's"}}} + if got := AddCommand(local); got != `rootly mcp add -s user fs -- npx -y @acme/fs '${PWD}' 'it'\''s'` { + t.Fatalf("local = %s", got) + } +} + +func TestCatalogKeepsRegistryEntriesWithTheSameShortName(t *testing.T) { + catalog := Catalog([]MarketEntry{ + {Name: "docs", RegistryName: "io.github.alpha/docs", Config: npxConfig("@alpha/docs")}, + {Name: "docs", RegistryName: "io.github.beta/docs", Config: npxConfig("@beta/docs")}, + {Name: "docs", Config: npxConfig("@gamma/docs")}, + }) + var names []string + for _, entry := range catalog { + if strings.HasPrefix(entry.Name, "docs") { + names = append(names, entry.Name) + } + } + if strings.Join(names, ",") != "docs,docs-beta,docs-2" { + t.Fatalf("names = %v", names) + } + for _, name := range names { + if err := ValidateServerName(name); err != nil { + t.Fatalf("%s: %v", name, err) + } + } +} + +func TestCatalogOnlyLetsCuratedReplaceTheSameServer(t *testing.T) { + catalog := Catalog([]MarketEntry{ + {Name: "linear", RegistryName: "app.linear/linear", Config: ServerConfig{Type: TransportHTTP, URL: "https://mcp.linear.app/mcp"}}, + {Name: "linear", RegistryName: "io.github.someone/linear", Config: npxConfig("@someone/linear-mcp")}, + }) + var linear []MarketEntry + for _, entry := range catalog { + if strings.HasPrefix(entry.Name, "linear") { + linear = append(linear, entry) + } + } + if len(linear) != 2 || linear[0].Name != "linear" || linear[0].RegistryName != "" || linear[1].Name != "linear-someone" { + t.Fatalf("linear entries = %+v, want curated linear plus linear-someone", linear) + } +} diff --git a/internal/mcp/resources.go b/internal/mcp/resources.go new file mode 100644 index 0000000..33d040b --- /dev/null +++ b/internal/mcp/resources.go @@ -0,0 +1,148 @@ +package mcp + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + "unicode/utf8" + + sdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +const ( + maxDiscovered = 500 + // ResourceByteCap bounds how much of one resource is attached to a prompt. + ResourceByteCap = 32 * 1024 +) + +type PromptArg struct { + Name string + Required bool +} + +type PromptInfo struct { + Name string + Description string + Arguments []PromptArg +} + +type ResourceInfo struct { + URI string + Name string + Description string + MIMEType string +} + +// discoverExtras lists prompts and resources, only for the capabilities the server declares. +func discoverExtras(ctx context.Context, session *sdk.ClientSession) (prompts []PromptInfo, resources []ResourceInfo) { + init := session.InitializeResult() + if init == nil || init.Capabilities == nil { + return nil, nil + } + if init.Capabilities.Prompts != nil { + for prompt, err := range session.Prompts(ctx, nil) { + if err != nil || len(prompts) >= maxDiscovered { + break + } + info := PromptInfo{Name: prompt.Name, Description: prompt.Description} + for _, argument := range prompt.Arguments { + info.Arguments = append(info.Arguments, PromptArg{Name: argument.Name, Required: argument.Required}) + } + prompts = append(prompts, info) + } + } + if init.Capabilities.Resources != nil { + for resource, err := range session.Resources(ctx, nil) { + if err != nil || len(resources) >= maxDiscovered { + break + } + resources = append(resources, ResourceInfo{URI: resource.URI, Name: resource.Name, Description: resource.Description, MIMEType: resource.MIMEType}) + } + } + return prompts, resources +} + +func (m *Manager) sessionFor(serverID string) (*sdk.ClientSession, time.Duration, error) { + m.mu.Lock() + defer m.mu.Unlock() + for _, runtime := range m.servers { + if runtime.server.ID() == serverID && runtime.session != nil && !runtime.sessionOff { + return runtime.session, runtime.server.Config.CallTimeout(connectTimeout), nil + } + } + return nil, 0, fmt.Errorf("MCP server %s is not connected", serverID[strings.LastIndex(serverID, ":")+1:]) +} + +// GetPrompt renders an MCP prompt's text messages into one message. +func (m *Manager) GetPrompt(ctx context.Context, serverID, name string, arguments map[string]string) (string, error) { + session, timeout, err := m.sessionFor(serverID) + if err != nil { + return "", err + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + result, err := session.GetPrompt(ctx, &sdk.GetPromptParams{Name: name, Arguments: arguments}) + if err != nil { + return "", err + } + var parts []string + for _, message := range result.Messages { + switch content := message.Content.(type) { + case *sdk.TextContent: + parts = append(parts, content.Text) + case *sdk.EmbeddedResource: + if content.Resource != nil && content.Resource.Text != "" { + parts = append(parts, content.Resource.Text) + } + } + } + text := strings.TrimSpace(strings.Join(parts, "\n\n")) + if text == "" { + return "", errors.New("prompt has no text content") + } + return text, nil +} + +// ReadResource returns a resource's text, cut to limit bytes on a rune boundary. +func (m *Manager) ReadResource(ctx context.Context, serverID, uri string, limit int) (text string, truncated bool, err error) { + session, timeout, err := m.sessionFor(serverID) + if err != nil { + return "", false, err + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + result, err := session.ReadResource(ctx, &sdk.ReadResourceParams{URI: uri}) + if err != nil { + return "", false, err + } + var joined strings.Builder + for _, content := range result.Contents { + if content == nil || content.Text == "" { + continue + } + if joined.Len() > 0 { + joined.WriteByte('\n') + } + if limit > 0 && joined.Len()+len(content.Text) > limit { + joined.WriteString(content.Text[:min(len(content.Text), limit+1-joined.Len())]) + break + } + joined.WriteString(content.Text) + } + if joined.Len() == 0 { + return "", false, errors.New("no text content") + } + return capBytes(joined.String(), limit) +} + +func capBytes(text string, limit int) (capped string, truncated bool, err error) { + if limit <= 0 || len(text) <= limit { + return text, false, nil + } + for limit > 0 && !utf8.RuneStart(text[limit]) { + limit-- + } + return text[:limit], true, nil +} diff --git a/internal/mcp/resources_test.go b/internal/mcp/resources_test.go new file mode 100644 index 0000000..dc9e7dd --- /dev/null +++ b/internal/mcp/resources_test.go @@ -0,0 +1,150 @@ +package mcp + +import ( + "context" + "strings" + "testing" + + sdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func connectInMemory(t *testing.T, server *sdk.Server) *sdk.ClientSession { + t.Helper() + ctx := context.Background() + clientTransport, serverTransport := sdk.NewInMemoryTransports() + serverSession, err := server.Connect(ctx, serverTransport, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = serverSession.Close() }) + client := sdk.NewClient(&sdk.Implementation{Name: "test-client", Version: "1"}, nil) + session, err := client.Connect(ctx, clientTransport, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = session.Close() }) + return session +} + +func docsServer() *sdk.Server { + server := sdk.NewServer(&sdk.Implementation{Name: "docs", Version: "1"}, nil) + server.AddPrompt(&sdk.Prompt{Name: "triage", Description: "Triage an alert", Arguments: []*sdk.PromptArgument{{Name: "service", Required: true}}}, + func(_ context.Context, request *sdk.GetPromptRequest) (*sdk.GetPromptResult, error) { + return &sdk.GetPromptResult{Messages: []*sdk.PromptMessage{{Role: "user", Content: &sdk.TextContent{Text: "Triage " + request.Params.Arguments["service"]}}}}, nil + }) + server.AddResource(&sdk.Resource{URI: "file:///runbook.md", Name: "runbook", MIMEType: "text/markdown"}, + func(_ context.Context, request *sdk.ReadResourceRequest) (*sdk.ReadResourceResult, error) { + return &sdk.ReadResourceResult{Contents: []*sdk.ResourceContents{{URI: request.Params.URI, Text: strings.Repeat("é", 40000)}}}, nil + }) + server.AddResource(&sdk.Resource{URI: "file:///logo.png", Name: "logo", MIMEType: "image/png"}, + func(_ context.Context, request *sdk.ReadResourceRequest) (*sdk.ReadResourceResult, error) { + return &sdk.ReadResourceResult{Contents: []*sdk.ResourceContents{{URI: request.Params.URI, Blob: []byte{1, 2, 3}}}}, nil + }) + return server +} + +func TestDiscoverExtrasListsPromptsAndResources(t *testing.T) { + session := connectInMemory(t, docsServer()) + prompts, resources := discoverExtras(context.Background(), session) + if len(prompts) != 1 || prompts[0].Name != "triage" || len(prompts[0].Arguments) != 1 || !prompts[0].Arguments[0].Required { + t.Fatalf("prompts = %+v", prompts) + } + if len(resources) != 2 || resources[0].URI != "file:///logo.png" && resources[1].URI != "file:///logo.png" { + t.Fatalf("resources = %+v", resources) + } +} + +func TestDiscoverExtrasSkipsUndeclaredCapabilities(t *testing.T) { + server := sdk.NewServer(&sdk.Implementation{Name: "tools-only", Version: "1"}, nil) + session := connectInMemory(t, server) + if caps := session.InitializeResult().Capabilities; caps.Prompts != nil || caps.Resources != nil { + t.Skip("SDK advertises prompts/resources without features") + } + prompts, resources := discoverExtras(context.Background(), session) + if prompts != nil || resources != nil { + t.Fatalf("prompts=%v resources=%v, want none", prompts, resources) + } +} + +func TestGetPromptAndReadResourceWithCap(t *testing.T) { + session := connectInMemory(t, docsServer()) + manager := &Manager{servers: []*runtimeServer{ + {server: Server{Name: "off"}, session: session, sessionOff: true}, + {server: Server{Name: "docs"}, session: session, status: StatusConnected}, + }} + ctx := context.Background() + docsID := Server{Name: "docs"}.ID() + text, err := manager.GetPrompt(ctx, docsID, "triage", map[string]string{"service": "api"}) + if err != nil || text != "Triage api" { + t.Fatalf("GetPrompt = %q, %v", text, err) + } + body, truncated, err := manager.ReadResource(ctx, docsID, "file:///runbook.md", 1001) + if err != nil || !truncated || len(body) != 1000 || !strings.HasPrefix(body, "éé") { + t.Fatalf("ReadResource = len %d truncated %v err %v", len(body), truncated, err) + } + full, truncated, err := manager.ReadResource(ctx, docsID, "file:///runbook.md", 0) + if err != nil || truncated || len(full) != 80000 { + t.Fatalf("uncapped ReadResource = len %d truncated %v err %v", len(full), truncated, err) + } + if _, _, err := manager.ReadResource(ctx, docsID, "file:///logo.png", ResourceByteCap); err == nil { + t.Fatal("binary resource should be unreadable") + } + if _, _, err := manager.ReadResource(ctx, "off", "file:///runbook.md", ResourceByteCap); err == nil { + t.Fatal("session-off server should not be readable") + } + if _, err := manager.GetPrompt(ctx, "missing", "triage", nil); err == nil { + t.Fatal("unknown server should fail") + } +} + +func TestSameNamedServersRouteByServerID(t *testing.T) { + other := connectInMemory(t, sdk.NewServer(&sdk.Implementation{Name: "other"}, nil)) + docs := connectInMemory(t, docsServer()) + imported := Server{Name: "docs", Scope: ScopeImported, Path: "/repo/.cursor/mcp.json"} + user := Server{Name: "docs", Scope: ScopeUser, Path: "/home/u/.rootly-cli/mcp.json"} + manager := &Manager{servers: []*runtimeServer{ + {server: imported, session: other, status: StatusConnected}, + {server: user, session: docs, status: StatusConnected}, + }} + ctx := context.Background() + if text, err := manager.GetPrompt(ctx, user.ID(), "triage", map[string]string{"service": "api"}); err != nil || text != "Triage api" { + t.Fatalf("GetPrompt = %q, %v", text, err) + } + if _, err := manager.GetPrompt(ctx, imported.ID(), "triage", nil); err == nil { + t.Fatal("GetPrompt by the imported server's ID must not reach the user server") + } + if _, _, err := manager.ReadResource(ctx, user.ID(), "file:///runbook.md", ResourceByteCap); err != nil { + t.Fatalf("ReadResource routed to the wrong server: %v", err) + } +} + +func TestLostAuthAfterConnectMarksNeedsAuth(t *testing.T) { + session := connectInMemory(t, docsServer()) + runtime := &runtimeServer{server: Server{Name: "remote"}, session: session, status: StatusConnected, generation: 3, tools: []*sdk.Tool{{Name: "x"}}} + manager := &Manager{servers: []*runtimeServer{runtime}, changes: make(chan struct{}, 1)} + probe := &authProbe{} + probe.watch(func() { manager.loseAuth(runtime, 3) }) + probe.mark() + if runtime.status != StatusNeedsAuth || runtime.session != nil || runtime.tools != nil { + t.Fatalf("runtime after 401 = status %s session %v tools %d", runtime.status, runtime.session, len(runtime.tools)) + } + select { + case <-manager.changes: + default: + t.Fatal("TUI was not notified") + } +} + +func TestAuthLostDuringDiscoveryMarksNeedsAuth(t *testing.T) { + session := connectInMemory(t, docsServer()) + runtime := &runtimeServer{server: Server{Name: "remote"}, session: session, status: StatusConnected, generation: 2} + manager := &Manager{servers: []*runtimeServer{runtime}, changes: make(chan struct{}, 1)} + probe := &authProbe{} + probe.mark() + manager.mu.Lock() + manager.watchAuth(runtime, 2, probe) + manager.mu.Unlock() + if runtime.status != StatusNeedsAuth || runtime.session != nil { + t.Fatalf("runtime after a 401 during discovery = status %s session %v", runtime.status, runtime.session) + } +}