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)
+ }
+}