Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
39bc427
wip(mcp): per-server timeout and oauth refresh retry
devin-ai-integration[bot] Oct 31, 2026
4d731f0
Merge branch 'devin/1793476918-agent-polish' into devin/1793477450-ag…
devin-ai-integration[bot] Oct 31, 2026
056499c
test(mcp): per-server timeout and silent OAuth refresh retry
devin-ai-integration[bot] Oct 31, 2026
b9d2f22
feat(agent): one-time hint when local tool output looks like expired …
devin-ai-integration[bot] Oct 31, 2026
1afbd90
feat(mcp): capability-gated prompt and resource discovery with capped…
devin-ai-integration[bot] Oct 31, 2026
eaeb2ce
feat(agent): MCP prompts and resources in the palette, @mentions with…
devin-ai-integration[bot] Oct 31, 2026
b4ce3eb
chore(agent): satisfy gocritic on prompts/resources
devin-ai-integration[bot] Oct 31, 2026
21ac726
feat(mcp): official registry index, curated marketplace and user-scop…
devin-ai-integration[bot] Oct 31, 2026
5269e8d
feat(mcp): rootly mcp search over the registry index
devin-ai-integration[bot] Oct 31, 2026
e3c9e4b
feat(agent): MCP marketplace in /mcp adds servers to user scope, swit…
devin-ai-integration[bot] Nov 1, 2026
73e90ae
Merge branch 'devin/1793476918-agent-polish' into devin/1793477450-ag…
devin-ai-integration[bot] Nov 1, 2026
2937730
refactor(agent): evaluate marketplace commands before returning
devin-ai-integration[bot] Nov 1, 2026
f023e84
fix(mcp): honour timeout when connecting, cancel OAuth refresh with t…
devin-ai-integration[bot] Nov 1, 2026
5d0014a
fix(agent): ignore marketplace loads from earlier visits, skip unlist…
devin-ai-integration[bot] Nov 1, 2026
c3c2355
revert(agent): keep resource mentions to listed resources
devin-ai-integration[bot] Nov 1, 2026
bd7c313
Merge remote-tracking branch 'origin/devin/1793476918-agent-polish' i…
devin-ai-integration[bot] Nov 1, 2026
d710de7
Merge remote-tracking branch 'origin/devin/1793476918-agent-polish' i…
devin-ai-integration[bot] Nov 1, 2026
9d9344e
Merge remote-tracking branch 'origin/devin/1793476918-agent-polish' i…
devin-ai-integration[bot] Nov 1, 2026
9b52ef8
fix(agent): only y confirms a marketplace add
devin-ai-integration[bot] Nov 1, 2026
e01d640
fix(agent): drop prompt and resource loads from an earlier conversation
devin-ai-integration[bot] Nov 1, 2026
0c2cc88
fix(mcp): route prompts/resources to the advertising server and mark …
devin-ai-integration[bot] Nov 1, 2026
936d27f
fix(mcp): sanitize registry text, shell-quote add commands, keep coll…
devin-ai-integration[bot] Nov 1, 2026
27b6bbb
fix(mcp): print search install and add lines untruncated
devin-ai-integration[bot] Nov 1, 2026
758e150
Merge remote-tracking branch 'origin/devin/1793476918-agent-polish' i…
devin-ai-integration[bot] Nov 2, 2026
e189ae9
test(mcp): connect honours the per-server timeout
devin-ai-integration[bot] Nov 2, 2026
b084c72
Merge remote-tracking branch 'origin/devin/1793476918-agent-polish' i…
devin-ai-integration[bot] Nov 2, 2026
f721c69
fix(mcp): curated entries only replace the same registry server
devin-ai-integration[bot] Nov 2, 2026
3ac2228
fix(mcp): route prompts and resources by server ID
devin-ai-integration[bot] Nov 2, 2026
5bf7226
Merge branch 'devin/1793476918-agent-polish' into devin/1793477450-ag…
devin-ai-integration[bot] Nov 2, 2026
6f2a025
style(mcp): avoid shadowing the config import
devin-ai-integration[bot] Nov 2, 2026
f9a5d3d
Merge remote-tracking branch 'origin/devin/1793476918-agent-polish' i…
devin-ai-integration[bot] Nov 2, 2026
dfb2a07
Merge branch 'devin/1793476918-agent-polish' into devin/1793477450-ag…
devin-ai-integration[bot] Nov 2, 2026
bd0fa6f
fix(mcp): a 401 during prompt/resource discovery marks needs-auth
devin-ai-integration[bot] Nov 2, 2026
b05bce3
fix(agent): keep prompt names with terminal controls out of the slash…
devin-ai-integration[bot] Nov 2, 2026
ea13384
fix(agent): drop pending MCP prompt/resource loads when switching ses…
devin-ai-integration[bot] Nov 2, 2026
c281622
fix(agent): widen /mcp status column so capability counts aren't clipped
devin-ai-integration[bot] Nov 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions internal/agent/credhint.go
Original file line number Diff line number Diff line change
@@ -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)
}
56 changes: 56 additions & 0 deletions internal/agent/credhint_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
10 changes: 7 additions & 3 deletions internal/agent/localtools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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"])}
}

Expand Down
209 changes: 209 additions & 0 deletions internal/agent/market.go
Original file line number Diff line number Diff line change
@@ -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")
}
Loading