From 39bc4273b7ad0f2b4895233ec77df9522a785b65 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:12:32 +0000 Subject: [PATCH 01/26] wip(mcp): per-server timeout and oauth refresh retry Co-Authored-By: Quentin Rousseau --- internal/mcp/config.go | 16 +++++++++ internal/mcp/manager.go | 72 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 83 insertions(+), 5 deletions(-) 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/manager.go b/internal/mcp/manager.go index 9715aad..c570a5c 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" @@ -271,7 +272,7 @@ func (r *runtimeServer) close() { func (m *Manager) connect(ctx context.Context, server Server) (*sdk.ClientSession, []*sdk.Tool, Status, string) { 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) if err != nil { @@ -344,7 +345,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 +356,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, refresh func() 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,6 +383,23 @@ func (m *Manager) bearer(config ServerConfig) func() string { } return token.AccessToken } + refresh = func() 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 = tokens.tokenSource(serverURL, credential, credential.oauthConfig(), expired, nil).(*persistingTokenSource) + token, err := source.Token() + if err != nil || token.AccessToken == "" || token.AccessToken == credential.AccessToken { + return "" + } + return token.AccessToken + } + return bearer, refresh } type authProbe struct { @@ -406,12 +425,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, refresh func() 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 +469,7 @@ type headerTransport struct { origin string headers map[string]string bearer func() string + refresh func() string probe *authProbe } @@ -456,12 +481,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() } @@ -707,9 +737,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 +754,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 +834,25 @@ 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() + 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) +} From 056499c5d6d5b5ff24645e813a701a3ea37502b4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:14:06 +0000 Subject: [PATCH 02/26] test(mcp): per-server timeout and silent OAuth refresh retry Co-Authored-By: Quentin Rousseau --- internal/mcp/freshness_test.go | 109 +++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 internal/mcp/freshness_test.go diff --git a/internal/mcp/freshness_test.go b/internal/mcp/freshness_test.go new file mode 100644 index 0000000..8053e0a --- /dev/null +++ b/internal/mcp/freshness_test.go @@ -0,0 +1,109 @@ +package mcp + +import ( + "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()) + } +} From b9d2f225100702d34c62ca4f2c4753fb7234b54a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:15:06 +0000 Subject: [PATCH 03/26] feat(agent): one-time hint when local tool output looks like expired creds Co-Authored-By: Quentin Rousseau --- internal/agent/credhint.go | 25 ++++++++++++++ internal/agent/credhint_test.go | 56 +++++++++++++++++++++++++++++++ internal/agent/localtools_test.go | 8 +++-- internal/agent/mcp_page.go | 9 +++-- internal/agent/print.go | 33 ++++++++++-------- internal/agent/tui.go | 8 +++-- 6 files changed, 118 insertions(+), 21 deletions(-) create mode 100644 internal/agent/credhint.go create mode 100644 internal/agent/credhint_test.go 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..64f80c1 100644 --- a/internal/agent/localtools_test.go +++ b/internal/agent/localtools_test.go @@ -22,8 +22,9 @@ const localTestSessionID = "39fd7596-152c-43d3-afce-48b792ae386d" type fakeLocalTools struct { mu sync.Mutex - tools map[string]mcp.ToolRef - calls []string + 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/mcp_page.go b/internal/agent/mcp_page.go index e13c7ca..1860766 100644 --- a/internal/agent/mcp_page.go +++ b/internal/agent/mcp_page.go @@ -815,10 +815,15 @@ func (m *TUIModel) updateLocalMCP(message tea.Msg) (cmd tea.Cmd, handled bool) { case mcpLoginMsg: return m.handleMCPLogin(msg), true case localToolDoneMsg: + var cmds []tea.Cmd + if msg.credHint && !m.credHintShown { + m.credHintShown = true + cmds = append(cmds, m.println(faint(m.noColor, localCredsHint))) + } if msg.err != nil && msg.turn == m.turn { - return m.println(faint(m.noColor, sanitizeTerminalText(msg.err.Error()))), true + cmds = append(cmds, m.println(faint(m.noColor, sanitizeTerminalText(msg.err.Error())))) } - return nil, true + return tea.Batch(cmds...), true } return nil, false } 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 5fb7b5c..bb38687 100644 --- a/internal/agent/tui.go +++ b/internal/agent/tui.go @@ -213,6 +213,7 @@ type TUIModel struct { workingDir string terminalBlurred bool toolsOnShown bool + credHintShown bool mcpStartupReported bool } @@ -222,8 +223,9 @@ type localConfirmation struct { } type localToolDoneMsg struct { - turn int - err error + turn int + err error + credHint bool } func NewTUIModel(ctx context.Context, streamer Streamer, options TUIOptions) *TUIModel { @@ -1608,6 +1610,6 @@ func (m *TUIModel) runClientTool(call *ClientToolCall, declined bool) tea.Cmd { if !declined { result = runLocalCall(ctx, tools, call) } - return localToolDoneMsg{turn: turn, err: postLocalResult(ctx, poster, sessionID, call, result)} + return localToolDoneMsg{turn: turn, err: postLocalResult(ctx, poster, sessionID, call, result), credHint: looksLikeExpiredCreds(result)} } } From 1afbd90107fe4275701f771f60ab74f2f2eb4496 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:17:20 +0000 Subject: [PATCH 04/26] feat(mcp): capability-gated prompt and resource discovery with capped reads Co-Authored-By: Quentin Rousseau --- internal/mcp/manager.go | 16 +++- internal/mcp/resources.go | 140 +++++++++++++++++++++++++++++++++ internal/mcp/resources_test.go | 97 +++++++++++++++++++++++ 3 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 internal/mcp/resources.go create mode 100644 internal/mcp/resources_test.go diff --git a/internal/mcp/manager.go b/internal/mcp/manager.go index c570a5c..c689522 100644 --- a/internal/mcp/manager.go +++ b/internal/mcp/manager.go @@ -51,6 +51,8 @@ type ServerView struct { Status Status Detail string Tools []ToolInfo + Prompts []PromptInfo + Resources []ResourceInfo SessionOff bool } @@ -78,6 +80,8 @@ type runtimeServer struct { status Status detail string tools []*sdk.Tool + prompts []PromptInfo + resources []ResourceInfo session *sdk.ClientSession sessionOff bool generation int @@ -242,6 +246,13 @@ func (m *Manager) connectLocked(runtime *runtimeServer) { server := runtime.server go func() { session, tools, status, detail := 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 { @@ -251,6 +262,7 @@ 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 m.notify() }() m.notify() @@ -268,6 +280,8 @@ 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) { @@ -528,7 +542,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)}) diff --git a/internal/mcp/resources.go b/internal/mcp/resources.go new file mode 100644 index 0000000..fda5a1d --- /dev/null +++ b/internal/mcp/resources.go @@ -0,0 +1,140 @@ +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(serverName string) (*sdk.ClientSession, time.Duration, error) { + m.mu.Lock() + defer m.mu.Unlock() + for _, runtime := range m.servers { + if runtime.server.Name == serverName && 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", serverName) +} + +// GetPrompt renders an MCP prompt's text messages into one message. +func (m *Manager) GetPrompt(ctx context.Context, serverName, name string, arguments map[string]string) (string, error) { + session, timeout, err := m.sessionFor(serverName) + 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, serverName, uri string, limit int) (text string, truncated bool, err error) { + session, timeout, err := m.sessionFor(serverName) + 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 parts []string + for _, content := range result.Contents { + if content != nil && content.Text != "" { + parts = append(parts, content.Text) + } + } + if len(parts) == 0 { + return "", false, errors.New("no text content") + } + return capBytes(strings.Join(parts, "\n"), limit) +} + +func capBytes(text string, limit int) (string, bool, 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..a8fed36 --- /dev/null +++ b/internal/mcp/resources_test.go @@ -0,0 +1,97 @@ +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() + text, err := manager.GetPrompt(ctx, "docs", "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, "docs", "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, "docs", "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, "docs", "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") + } +} From eaeb2ce23871e06be1dd97a566e50b85a72843a6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:20:03 +0000 Subject: [PATCH 05/26] feat(agent): MCP prompts and resources in the palette, @mentions with capped attachments Co-Authored-By: Quentin Rousseau --- internal/agent/localtools_test.go | 2 +- internal/agent/mcp_extras.go | 338 ++++++++++++++++++++++++++++++ internal/agent/mcp_extras_test.go | 192 +++++++++++++++++ internal/agent/mcp_page.go | 26 ++- internal/agent/mcp_page_test.go | 26 +++ internal/agent/tui.go | 34 ++- internal/agent/tui_helpers.go | 32 ++- 7 files changed, 631 insertions(+), 19 deletions(-) create mode 100644 internal/agent/mcp_extras.go create mode 100644 internal/agent/mcp_extras_test.go diff --git a/internal/agent/localtools_test.go b/internal/agent/localtools_test.go index 64f80c1..2c055a1 100644 --- a/internal/agent/localtools_test.go +++ b/internal/agent/localtools_test.go @@ -21,7 +21,7 @@ import ( const localTestSessionID = "39fd7596-152c-43d3-afce-48b792ae386d" type fakeLocalTools struct { - mu sync.Mutex + mu sync.Mutex tools map[string]mcp.ToolRef calls []string output string diff --git a/internal/agent/mcp_extras.go b/internal/agent/mcp_extras.go new file mode 100644 index 0000000..cab56b5 --- /dev/null +++ b/internal/agent/mcp_extras.go @@ -0,0 +1,338 @@ +package agent + +import ( + "context" + "fmt" + "regexp" + "strings" + + "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 { + 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 +} + +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. +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 { + 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}) + if added++; added == maxPromptSuggestions { + return items + } + } + } + return items +} + +func (m *TUIModel) findPrompt(server, name string) (mcp.PromptInfo, bool) { + for _, view := range m.liveMCPViews() { + if view.Server.Name != server { + continue + } + for _, prompt := range view.Prompts { + if prompt.Name == name { + return prompt, 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, 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 + } + manager, ctx, label := m.mcp, m.ctx, parts[0] + m.status = "Loading prompt " + sanitizeTerminalText(label) + "…" + return func() tea.Msg { + text, err := manager.GetPrompt(ctx, server, name, arguments) + return mcpPromptMsg{label: label, text: text, err: err} + }, true +} + +func (m *TUIModel) handleMCPPrompt(msg mcpPromptMsg) tea.Cmd { + 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 := append(prefixed, 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]bool{} + for _, view := range m.liveMCPViews() { + for _, resource := range view.Resources { + known[resourceRef{server: view.Server.Name, uri: resource.URI}] = true + } + } + 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 !known[ref] { + ref.uri = match[2] + } + if known[ref] && !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.server, 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..43db32b --- /dev/null +++ b/internal/agent/mcp_extras_test.go @@ -0,0 +1,192 @@ +package agent + +import ( + "fmt" + "strings" + "testing" + + "charm.land/bubbles/v2/list" + tea "charm.land/bubbletea/v2" + + "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) + } +} diff --git a/internal/agent/mcp_page.go b/internal/agent/mcp_page.go index 1860766..d1e18f6 100644 --- a/internal/agent/mcp_page.go +++ b/internal/agent/mcp_page.go @@ -25,6 +25,8 @@ type MCPManager interface { Changes() <-chan struct{} Connecting() int ToggleSession(string) error + GetPrompt(context.Context, string, string, map[string]string) (string, error) + ReadResource(context.Context, string, string, int) (string, bool, error) Approve(string) error Enable(string) error Reconnect(string) error @@ -545,7 +547,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: @@ -814,6 +816,10 @@ 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 localToolDoneMsg: var cmds []tea.Cmd if msg.credHint && !m.credHintShown { @@ -837,3 +843,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 554a1d3..9d0b111 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,9 @@ type fakeMCPManager struct { approved []string problems []string changes chan struct{} + + resources map[string]string + readLimits []int } func (f *fakeMCPManager) Views() []mcp.ServerView { return f.views } @@ -33,6 +38,27 @@ 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) GetPrompt(_ context.Context, server, name string, arguments map[string]string) (string, error) { + if server != "docs" { + return "", errors.New("not connected") + } + return fmt.Sprintf("prompt %s %v", name, arguments), nil +} + +func (f *fakeMCPManager) ReadResource(_ context.Context, server, uri string, limit int) (string, bool, error) { + f.mu.Lock() + f.readLimits = append(f.readLimits, limit) + f.mu.Unlock() + text, ok := f.resources[server+":"+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/tui.go b/internal/agent/tui.go index bb38687..2a62f04 100644 --- a/internal/agent/tui.go +++ b/internal/agent/tui.go @@ -213,6 +213,7 @@ type TUIModel struct { workingDir string terminalBlurred bool toolsOnShown bool + mentionRequest int credHintShown bool mcpStartupReported bool } @@ -918,7 +919,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() } @@ -927,9 +930,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 @@ -982,12 +985,19 @@ func (m *TUIModel) submit() tea.Cmd { 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 { - m.currentPrompt = message - m.promptHistory = append(m.promptHistory, message) + return m.sendPrompt(message, message, "") +} + +func (m *TUIModel) sendPrompt(echo, message, note string) tea.Cmd { + m.currentPrompt = echo + m.promptHistory = append(m.promptHistory, echo) m.historyIndex = -1 m.actionRequest++ m.startNewTurn() @@ -1005,8 +1015,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 +1069,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 +1126,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)) } diff --git a/internal/agent/tui_helpers.go b/internal/agent/tui_helpers.go index b0638a8..02fbf17 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" + @@ -971,6 +974,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 @@ -980,7 +986,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"}, @@ -1009,17 +1016,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 @@ -1262,3 +1263,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 +} From b4ce3eb8bf93329e363acb77150cbf9a13d86fcf Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:20:55 +0000 Subject: [PATCH 06/26] chore(agent): satisfy gocritic on prompts/resources Co-Authored-By: Quentin Rousseau --- internal/agent/mcp_extras.go | 7 +++++-- internal/agent/mcp_page_test.go | 2 +- internal/mcp/resources.go | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/internal/agent/mcp_extras.go b/internal/agent/mcp_extras.go index cab56b5..06e3b31 100644 --- a/internal/agent/mcp_extras.go +++ b/internal/agent/mcp_extras.go @@ -81,7 +81,8 @@ func (m *TUIModel) appendPromptItems(items []list.Item, value string) []list.Ite } taken[key] = true items = append(items, slashCommandItem{command: command, description: description, insert: len(prompt.Arguments) > 0}) - if added++; added == maxPromptSuggestions { + added++ + if added == maxPromptSuggestions { return items } } @@ -248,7 +249,9 @@ func (m *TUIModel) refreshMentionMenu(value string) bool { } } } - items := append(prefixed, contains...) + items := make([]list.Item, 0, len(prefixed)+len(contains)) + items = append(items, prefixed...) + items = append(items, contains...) if len(items) == 0 { return false } diff --git a/internal/agent/mcp_page_test.go b/internal/agent/mcp_page_test.go index 9d0b111..076b313 100644 --- a/internal/agent/mcp_page_test.go +++ b/internal/agent/mcp_page_test.go @@ -45,7 +45,7 @@ func (f *fakeMCPManager) GetPrompt(_ context.Context, server, name string, argum return fmt.Sprintf("prompt %s %v", name, arguments), nil } -func (f *fakeMCPManager) ReadResource(_ context.Context, server, uri string, limit int) (string, bool, error) { +func (f *fakeMCPManager) ReadResource(_ context.Context, server, uri string, limit int) (text string, truncated bool, err error) { f.mu.Lock() f.readLimits = append(f.readLimits, limit) f.mu.Unlock() diff --git a/internal/mcp/resources.go b/internal/mcp/resources.go index fda5a1d..c4f7198 100644 --- a/internal/mcp/resources.go +++ b/internal/mcp/resources.go @@ -129,7 +129,7 @@ func (m *Manager) ReadResource(ctx context.Context, serverName, uri string, limi return capBytes(strings.Join(parts, "\n"), limit) } -func capBytes(text string, limit int) (string, bool, error) { +func capBytes(text string, limit int) (capped string, truncated bool, err error) { if limit <= 0 || len(text) <= limit { return text, false, nil } From 21ac72629030d6abd8e7de5b1a97209c3e4f7903 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:22:31 +0000 Subject: [PATCH 07/26] feat(mcp): official registry index, curated marketplace and user-scope installs Co-Authored-By: Quentin Rousseau --- internal/mcp/registry.go | 353 ++++++++++++++++++++++++++++++++++ internal/mcp/registry_test.go | 134 +++++++++++++ 2 files changed, 487 insertions(+) create mode 100644 internal/mcp/registry.go create mode 100644 internal/mcp/registry_test.go diff --git a/internal/mcp/registry.go b/internal/mcp/registry.go new file mode 100644 index 0000000..8f7f321 --- /dev/null +++ b/internal/mcp/registry.go @@ -0,0 +1,353 @@ +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"` + 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, nil + } + 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, 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; the first entry per name wins. +func Catalog(indexed []MarketEntry) []MarketEntry { + seen := map[string]bool{} + var entries []MarketEntry + for _, group := range [][]MarketEntry{CuratedEntries(), indexed} { + for _, entry := range group { + if entry.Name == "" || seen[entry.Name] { + continue + } + seen[entry.Name] = true + entries = append(entries, entry) + } + } + return entries +} + +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.Servers(ScopeUser) + if err != nil { + return Server{}, err + } + for _, server := range existing { + if server.Name == entry.Name { + return Server{}, fmt.Errorf("a user MCP server named %s already exists", entry.Name) + } + } + 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 +} diff --git a/internal/mcp/registry_test.go b/internal/mcp/registry_test.go new file mode 100644 index 0000000..20ebd10 --- /dev/null +++ b/internal/mcp/registry_test.go @@ -0,0 +1,134 @@ +package mcp + +import ( + "context" + "net/http" + "net/http/httptest" + "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"}, {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") + } +} From 5269e8d794db7900396d6b68c367b9a1ffc689c9 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:23:35 +0000 Subject: [PATCH 08/26] feat(mcp): rootly mcp search over the registry index Co-Authored-By: Quentin Rousseau --- internal/cmd/mcp/search.go | 72 +++++++++++++++++++++++++++++++++ internal/cmd/mcp/search_test.go | 63 +++++++++++++++++++++++++++++ internal/mcp/registry.go | 24 +++++++++++ 3 files changed, 159 insertions(+) create mode 100644 internal/cmd/mcp/search.go create mode 100644 internal/cmd/mcp/search_test.go diff --git a/internal/cmd/mcp/search.go b/internal/cmd/mcp/search.go new file mode 100644 index 0000000..bda8c76 --- /dev/null +++ b/internal/cmd/mcp/search.go @@ -0,0 +1,72 @@ +package mcp + +import ( + "fmt" + "net/http" + "strings" + "time" + + "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", title, oneLine(entry.Description), localmcp.InstallLabel(entry), localmcp.AddCommand(entry)) + if entry.EnvHint != "" { + _, _ = fmt.Fprintf(out, " needs: %s\n", entry.EnvHint) + } + } + return nil + }, +} + +func oneLine(text string) string { + text = strings.Join(strings.Fields(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..63f1c91 --- /dev/null +++ b/internal/cmd/mcp/search_test.go @@ -0,0 +1,63 @@ +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) + } +} diff --git a/internal/mcp/registry.go b/internal/mcp/registry.go index 8f7f321..24b159c 100644 --- a/internal/mcp/registry.go +++ b/internal/mcp/registry.go @@ -351,3 +351,27 @@ func AddFromMarket(store *Store, states *StateStore, entry MarketEntry) (Server, } 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 { + 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. +func AddCommand(entry MarketEntry) string { + if entry.Config.URL != "" { + return fmt.Sprintf("rootly mcp add -s user -t %s %s %s", entry.Config.Type, entry.Name, entry.Config.URL) + } + return strings.TrimSpace(fmt.Sprintf("rootly mcp add -s user %s -- %s", entry.Name, InstallLabel(entry))) +} From e3c9e4b6d73e184824aa2ca650482f8731cc364f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:03:20 +0000 Subject: [PATCH 09/26] feat(agent): MCP marketplace in /mcp adds servers to user scope, switched off Co-Authored-By: Quentin Rousseau --- internal/agent/market.go | 208 ++++++++++++++++++++++++++++++++ internal/agent/market_test.go | 93 ++++++++++++++ internal/agent/mcp_page.go | 15 ++- internal/agent/mcp_page_test.go | 13 ++ internal/mcp/manager.go | 10 ++ internal/mcp/registry.go | 4 +- 6 files changed, 340 insertions(+), 3 deletions(-) create mode 100644 internal/agent/market.go create mode 100644 internal/agent/market_test.go diff --git a/internal/agent/market.go b/internal/agent/market.go new file mode 100644 index 0000000..fae8958 --- /dev/null +++ b/internal/agent/market.go @@ -0,0 +1,208 @@ +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 + market.request++ + 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", "enter": + 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..1c50627 --- /dev/null +++ b/internal/agent/market_test.go @@ -0,0 +1,93 @@ +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") + } +} diff --git a/internal/agent/mcp_page.go b/internal/agent/mcp_page.go index d1e18f6..01be197 100644 --- a/internal/agent/mcp_page.go +++ b/internal/agent/mcp_page.go @@ -31,6 +31,7 @@ type MCPManager interface { Enable(string) error Reconnect(string) error Login(context.Context, string, mcp.LoginOptions) error + AddFromMarket(mcp.MarketEntry) (mcp.Server, error) } type ConnectorLister interface { @@ -90,6 +91,7 @@ type mcpPage struct { message string loginURL string views []mcp.ServerView + market *marketState } func (m *TUIModel) watchMCP() tea.Cmd { @@ -317,6 +319,9 @@ func (m *TUIModel) handleMCPPageKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { if page.detail != "" { return m.handleMCPDetailKey(msg) } + if page.market != nil { + return m, m.handleMarketKey(msg) + } if page.filtering { switch key { case "esc": @@ -362,6 +367,8 @@ func (m *TUIModel) handleMCPPageKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.clampMCPCursor(rows) case "/": page.filtering = true + case "m": + return m, m.openMarket() case "enter": row, ok := m.selectedMCPRow() if !ok { @@ -612,6 +619,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()), ""} @@ -641,7 +651,7 @@ func (m *TUIModel) mcpPageView() string { 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) } @@ -820,6 +830,9 @@ func (m *TUIModel) updateLocalMCP(message tea.Msg) (cmd tea.Cmd, handled bool) { return m.handleMCPPrompt(msg), true case mentionsReadyMsg: return m.handleMentionsReady(msg), true + case marketLoadedMsg: + m.handleMarketLoaded(msg) + return nil, true case localToolDoneMsg: var cmds []tea.Cmd if msg.credHint && !m.credHintShown { diff --git a/internal/agent/mcp_page_test.go b/internal/agent/mcp_page_test.go index 076b313..b9a5935 100644 --- a/internal/agent/mcp_page_test.go +++ b/internal/agent/mcp_page_test.go @@ -23,6 +23,7 @@ type fakeMCPManager struct { resources map[string]string readLimits []int + added []mcp.MarketEntry } func (f *fakeMCPManager) Views() []mcp.ServerView { return f.views } @@ -38,6 +39,18 @@ 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, server, name string, arguments map[string]string) (string, error) { if server != "docs" { return "", errors.New("not connected") diff --git a/internal/mcp/manager.go b/internal/mcp/manager.go index c689522..4e66329 100644 --- a/internal/mcp/manager.go +++ b/internal/mcp/manager.go @@ -870,3 +870,13 @@ func (t *headerTransport) retryWithRefreshedToken(request *http.Request, respons _ = 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) { + 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 index 24b159c..da00aff 100644 --- a/internal/mcp/registry.go +++ b/internal/mcp/registry.go @@ -333,13 +333,13 @@ func AddFromMarket(store *Store, states *StateStore, entry MarketEntry) (Server, if err := entry.Config.Validate(); err != nil { return Server{}, err } - existing, err := store.Servers(ScopeUser) + existing, err := store.All() if err != nil { return Server{}, err } for _, server := range existing { if server.Name == entry.Name { - return Server{}, fmt.Errorf("a user MCP server named %s already exists", 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} From 2937730ca494ad6ae4a1ff8fc3a8bcf76500588f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:04:54 +0000 Subject: [PATCH 10/26] refactor(agent): evaluate marketplace commands before returning Co-Authored-By: Quentin Rousseau --- internal/agent/mcp_page.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/agent/mcp_page.go b/internal/agent/mcp_page.go index 01be197..082ae2e 100644 --- a/internal/agent/mcp_page.go +++ b/internal/agent/mcp_page.go @@ -320,7 +320,8 @@ func (m *TUIModel) handleMCPPageKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m.handleMCPDetailKey(msg) } if page.market != nil { - return m, m.handleMarketKey(msg) + cmd := m.handleMarketKey(msg) + return m, cmd } if page.filtering { switch key { @@ -368,7 +369,8 @@ func (m *TUIModel) handleMCPPageKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { case "/": page.filtering = true case "m": - return m, m.openMarket() + cmd := m.openMarket() + return m, cmd case "enter": row, ok := m.selectedMCPRow() if !ok { From f023e8488b78c88e17b32160ba749db2b2132e9a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:21:51 +0000 Subject: [PATCH 11/26] fix(mcp): honour timeout when connecting, cancel OAuth refresh with the request, bound resource reads, never cache partial registry indexes, reject imported names in marketplace adds Co-Authored-By: Quentin Rousseau --- internal/mcp/expand.go | 1 + internal/mcp/freshness_test.go | 46 ++++++++++++++++++++++++++++++++++ internal/mcp/manager.go | 21 ++++++++++------ internal/mcp/registry.go | 5 +++- internal/mcp/registry_test.go | 42 +++++++++++++++++++++++++++++++ internal/mcp/resources.go | 18 +++++++++---- 6 files changed, 120 insertions(+), 13 deletions(-) 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 index 8053e0a..5713f22 100644 --- a/internal/mcp/freshness_test.go +++ b/internal/mcp/freshness_test.go @@ -1,6 +1,7 @@ package mcp import ( + "context" "encoding/json" "io" "net/http" @@ -107,3 +108,48 @@ func TestRemote401WithoutRefreshTokenNeedsLogin(t *testing.T) { 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) + } +} diff --git a/internal/mcp/manager.go b/internal/mcp/manager.go index 4e66329..4d4c9ff 100644 --- a/internal/mcp/manager.go +++ b/internal/mcp/manager.go @@ -370,7 +370,7 @@ func (m *Manager) transport(ctx context.Context, server Server, config ServerCon } } -func (m *Manager) bearerFuncs(config ServerConfig) (bearer, refresh 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) @@ -397,7 +397,7 @@ func (m *Manager) bearerFuncs(config ServerConfig) (bearer, refresh func() strin } return token.AccessToken } - refresh = func() string { + refresh = func(ctx context.Context) string { mu.Lock() defer mu.Unlock() credential, ok := tokens.Get(serverURL) @@ -406,8 +406,9 @@ func (m *Manager) bearerFuncs(config ServerConfig) (bearer, refresh func() strin } expired := credential.token() expired.Expiry = time.Now().Add(-time.Minute) - source = tokens.tokenSource(serverURL, credential, credential.oauthConfig(), expired, nil).(*persistingTokenSource) - token, err := source.Token() + 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 "" } @@ -442,7 +443,7 @@ func newServerHTTPClient(config ServerConfig, bearer func() string, probe *authP return newServerHTTPClientWithRefresh(config, bearer, nil, probe) } -func newServerHTTPClientWithRefresh(config ServerConfig, bearer, refresh func() string, probe *authProbe) *http.Client { +func newServerHTTPClientWithRefresh(config ServerConfig, bearer func() string, refresh func(context.Context) string, probe *authProbe) *http.Client { return &http.Client{ Transport: &headerTransport{ base: http.DefaultTransport, @@ -483,7 +484,7 @@ type headerTransport struct { origin string headers map[string]string bearer func() string - refresh func() string + refresh func(context.Context) string probe *authProbe } @@ -853,7 +854,7 @@ func (t *headerTransport) retryWithRefreshedToken(request *http.Request, respons if t.refresh == nil || (request.Body != nil && request.Body != http.NoBody && request.GetBody == nil) { return response, nil } - token := t.refresh() + token := t.refresh(request.Context()) if token == "" { return response, nil } @@ -873,6 +874,12 @@ func (t *headerTransport) retryWithRefreshedToken(request *http.Request, respons // 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 diff --git a/internal/mcp/registry.go b/internal/mcp/registry.go index da00aff..f50c0c1 100644 --- a/internal/mcp/registry.go +++ b/internal/mcp/registry.go @@ -176,7 +176,7 @@ func FetchRegistry(ctx context.Context, client *http.Client, baseURL string, pro next, err := fetchRegistryPage(ctx, client, baseURL, cursor) if err != nil { if len(entries) > 0 { - return entries, nil + return entries, fmt.Errorf("registry page %d: %w (index incomplete)", page, err) } return nil, err } @@ -360,6 +360,9 @@ func LoadCatalog(ctx context.Context, client *http.Client, registryURL, indexPat } 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 { diff --git a/internal/mcp/registry_test.go b/internal/mcp/registry_test.go index 20ebd10..31c4161 100644 --- a/internal/mcp/registry_test.go +++ b/internal/mcp/registry_test.go @@ -4,6 +4,7 @@ import ( "context" "net/http" "net/http/httptest" + "os" "path/filepath" "strings" "testing" @@ -132,3 +133,44 @@ func TestAddFromMarketWritesUserScopeSwitchedOff(t *testing.T) { 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) + } +} diff --git a/internal/mcp/resources.go b/internal/mcp/resources.go index c4f7198..e8ffed7 100644 --- a/internal/mcp/resources.go +++ b/internal/mcp/resources.go @@ -117,16 +117,24 @@ func (m *Manager) ReadResource(ctx context.Context, serverName, uri string, limi if err != nil { return "", false, err } - var parts []string + var joined strings.Builder for _, content := range result.Contents { - if content != nil && content.Text != "" { - parts = append(parts, content.Text) + 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 len(parts) == 0 { + if joined.Len() == 0 { return "", false, errors.New("no text content") } - return capBytes(strings.Join(parts, "\n"), limit) + return capBytes(joined.String(), limit) } func capBytes(text string, limit int) (capped string, truncated bool, err error) { From 5d0014a97860f6da4f32500146a9ac00e97cc759 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:21:51 +0000 Subject: [PATCH 12/26] fix(agent): ignore marketplace loads from earlier visits, skip unlisted resource mentions with a note Co-Authored-By: Quentin Rousseau --- internal/agent/market.go | 3 ++- internal/agent/market_test.go | 18 ++++++++++++++++++ internal/agent/mcp_extras.go | 8 +++++--- internal/agent/mcp_extras_test.go | 8 ++++++-- internal/agent/tui.go | 1 + 5 files changed, 32 insertions(+), 6 deletions(-) diff --git a/internal/agent/market.go b/internal/agent/market.go index fae8958..8984258 100644 --- a/internal/agent/market.go +++ b/internal/agent/market.go @@ -47,7 +47,8 @@ func (m *TUIModel) openMarket() tea.Cmd { func (m *TUIModel) loadMarket(refresh bool) tea.Cmd { market := m.mcpPage.market - market.request++ + m.marketRequest++ + market.request = m.marketRequest market.loading, market.err = true, "" request, ctx := market.request, m.ctx return func() tea.Msg { diff --git a/internal/agent/market_test.go b/internal/agent/market_test.go index 1c50627..f8ee9d1 100644 --- a/internal/agent/market_test.go +++ b/internal/agent/market_test.go @@ -91,3 +91,21 @@ func TestMarketplaceDeclineAndDuplicate(t *testing.T) { 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) + } +} diff --git a/internal/agent/mcp_extras.go b/internal/agent/mcp_extras.go index 06e3b31..1c77483 100644 --- a/internal/agent/mcp_extras.go +++ b/internal/agent/mcp_extras.go @@ -268,8 +268,10 @@ func (m *TUIModel) insertMention(mention string) { func (m *TUIModel) resourceMentions(message string) []resourceRef { known := map[resourceRef]bool{} + servers := map[string]bool{} for _, view := range m.liveMCPViews() { for _, resource := range view.Resources { + servers[view.Server.Name] = true known[resourceRef{server: view.Server.Name, uri: resource.URI}] = true } } @@ -280,10 +282,10 @@ func (m *TUIModel) resourceMentions(message string) []resourceRef { seen := map[resourceRef]bool{} for _, match := range resourceMentionPattern.FindAllStringSubmatch(message, -1) { ref := resourceRef{server: match[1], uri: strings.TrimRight(match[2], ".,;:!?)")} - if !known[ref] { - ref.uri = match[2] + if raw := (resourceRef{server: match[1], uri: match[2]}); !known[ref] && known[raw] { + ref = raw } - if known[ref] && !seen[ref] && len(refs) < maxAttachedResources { + if servers[ref.server] && !seen[ref] && len(refs) < maxAttachedResources { seen[ref] = true refs = append(refs, ref) } diff --git a/internal/agent/mcp_extras_test.go b/internal/agent/mcp_extras_test.go index 43db32b..7c57d6b 100644 --- a/internal/agent/mcp_extras_test.go +++ b/internal/agent/mcp_extras_test.go @@ -145,8 +145,12 @@ func TestMentionPaletteShowsTopEightAndTabInserts(t *testing.T) { 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") + if model.sendWithResources("email ops@example.com and @nosuch:thing") != nil { + t.Fatal("mentions of unknown servers should send as plain text") + } + unknown, ok := model.sendWithResources("see @docs:file:///missing.md.")().(mentionsReadyMsg) + if !ok || len(unknown.attached) != 0 || len(unknown.notes) != 1 || !strings.HasPrefix(unknown.notes[0], "skipped @docs:file:///missing.md:") { + t.Fatalf("unlisted resource on a known server should be read and skipped with a note: %+v", unknown) } cmd := model.sendWithResources("why? @docs:file:///runbook.md, @docs:file:///big.log @docs:file:///gone.md @docs:file:///runbook.md") msg, ok := cmd().(mentionsReadyMsg) diff --git a/internal/agent/tui.go b/internal/agent/tui.go index 2a62f04..7d5e024 100644 --- a/internal/agent/tui.go +++ b/internal/agent/tui.go @@ -214,6 +214,7 @@ type TUIModel struct { terminalBlurred bool toolsOnShown bool mentionRequest int + marketRequest int credHintShown bool mcpStartupReported bool } From c3c2355b1b4af1e9512307f276f14a8565131edb Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:23:59 +0000 Subject: [PATCH 13/26] revert(agent): keep resource mentions to listed resources Co-Authored-By: Quentin Rousseau --- internal/agent/mcp_extras.go | 8 +++----- internal/agent/mcp_extras_test.go | 8 ++------ 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/internal/agent/mcp_extras.go b/internal/agent/mcp_extras.go index 1c77483..06e3b31 100644 --- a/internal/agent/mcp_extras.go +++ b/internal/agent/mcp_extras.go @@ -268,10 +268,8 @@ func (m *TUIModel) insertMention(mention string) { func (m *TUIModel) resourceMentions(message string) []resourceRef { known := map[resourceRef]bool{} - servers := map[string]bool{} for _, view := range m.liveMCPViews() { for _, resource := range view.Resources { - servers[view.Server.Name] = true known[resourceRef{server: view.Server.Name, uri: resource.URI}] = true } } @@ -282,10 +280,10 @@ func (m *TUIModel) resourceMentions(message string) []resourceRef { seen := map[resourceRef]bool{} for _, match := range resourceMentionPattern.FindAllStringSubmatch(message, -1) { ref := resourceRef{server: match[1], uri: strings.TrimRight(match[2], ".,;:!?)")} - if raw := (resourceRef{server: match[1], uri: match[2]}); !known[ref] && known[raw] { - ref = raw + if !known[ref] { + ref.uri = match[2] } - if servers[ref.server] && !seen[ref] && len(refs) < maxAttachedResources { + if known[ref] && !seen[ref] && len(refs) < maxAttachedResources { seen[ref] = true refs = append(refs, ref) } diff --git a/internal/agent/mcp_extras_test.go b/internal/agent/mcp_extras_test.go index 7c57d6b..43db32b 100644 --- a/internal/agent/mcp_extras_test.go +++ b/internal/agent/mcp_extras_test.go @@ -145,12 +145,8 @@ func TestMentionPaletteShowsTopEightAndTabInserts(t *testing.T) { func TestMentionedResourcesAttachedWithCapAndNotes(t *testing.T) { manager := extrasManager() model := newMCPTestModel(t, manager, &fakeConnectorAPI{}) - if model.sendWithResources("email ops@example.com and @nosuch:thing") != nil { - t.Fatal("mentions of unknown servers should send as plain text") - } - unknown, ok := model.sendWithResources("see @docs:file:///missing.md.")().(mentionsReadyMsg) - if !ok || len(unknown.attached) != 0 || len(unknown.notes) != 1 || !strings.HasPrefix(unknown.notes[0], "skipped @docs:file:///missing.md:") { - t.Fatalf("unlisted resource on a known server should be read and skipped with a note: %+v", unknown) + 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) From 9b52ef81e1b12fb92c39fae7908c63147f01d630 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:41:00 +0000 Subject: [PATCH 14/26] fix(agent): only y confirms a marketplace add Co-Authored-By: Quentin Rousseau --- internal/agent/market.go | 2 +- internal/agent/market_test.go | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/internal/agent/market.go b/internal/agent/market.go index 8984258..a58b77b 100644 --- a/internal/agent/market.go +++ b/internal/agent/market.go @@ -80,7 +80,7 @@ func (m *TUIModel) handleMarketKey(msg tea.KeyPressMsg) tea.Cmd { key := msg.String() if market.preview != nil { switch key { - case "y", "enter": + case "y": m.addFromMarket(*market.preview) case "n", "esc", "backspace": market.preview = nil diff --git a/internal/agent/market_test.go b/internal/agent/market_test.go index f8ee9d1..ac0c4b6 100644 --- a/internal/agent/market_test.go +++ b/internal/agent/market_test.go @@ -109,3 +109,18 @@ func TestMarketplaceIgnoresLoadsFromEarlierVisits(t *testing.T) { 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) + } +} From e01d640f228059360b8c594695a4cde9fdb5bb71 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:41:00 +0000 Subject: [PATCH 15/26] fix(agent): drop prompt and resource loads from an earlier conversation Co-Authored-By: Quentin Rousseau --- internal/agent/mcp_extras.go | 15 ++++++++++----- internal/agent/mcp_extras_test.go | 16 ++++++++++++++++ internal/agent/tui.go | 1 + internal/agent/tui_helpers.go | 1 + 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/internal/agent/mcp_extras.go b/internal/agent/mcp_extras.go index 06e3b31..8c064e6 100644 --- a/internal/agent/mcp_extras.go +++ b/internal/agent/mcp_extras.go @@ -21,9 +21,10 @@ const ( var resourceMentionPattern = regexp.MustCompile(`@([A-Za-z0-9_.-]+):(\S+)`) type mcpPromptMsg struct { - label string - text string - err error + request int + label string + text string + err error } type mentionsReadyMsg struct { @@ -172,15 +173,19 @@ func (m *TUIModel) runMCPPrompt(command string) (tea.Cmd, bool) { if len(missing) > 0 { return m.println("Usage: " + promptUsage(parts[0], prompt)), true } - manager, ctx, label := m.mcp, m.ctx, parts[0] + 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, server, name, arguments) - return mcpPromptMsg{label: label, text: text, err: err} + 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()))) diff --git a/internal/agent/mcp_extras_test.go b/internal/agent/mcp_extras_test.go index 43db32b..4b06ba5 100644 --- a/internal/agent/mcp_extras_test.go +++ b/internal/agent/mcp_extras_test.go @@ -190,3 +190,19 @@ func TestMCPRowCounts(t *testing.T) { 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()) + } +} diff --git a/internal/agent/tui.go b/internal/agent/tui.go index fecb97d..a3af1d6 100644 --- a/internal/agent/tui.go +++ b/internal/agent/tui.go @@ -998,6 +998,7 @@ func (m *TUIModel) submitPrompt(message string) tea.Cmd { } func (m *TUIModel) sendPrompt(echo, message, note string) tea.Cmd { + m.mentionRequest++ m.currentPrompt = echo m.promptHistory = append(m.promptHistory, echo) m.historyIndex = -1 diff --git a/internal/agent/tui_helpers.go b/internal/agent/tui_helpers.go index 87ef884..eac216f 100644 --- a/internal/agent/tui_helpers.go +++ b/internal/agent/tui_helpers.go @@ -1110,6 +1110,7 @@ func (m *TUIModel) startNewSession() { m.actionRequest++ m.pickerRequest++ m.historyRequest++ + m.mentionRequest++ m.sessionID = nil m.sessionTitle = "" m.resetAnswer() From 0c2cc88f2c3256bca0efc14901e923a7d7a363a8 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:41:00 +0000 Subject: [PATCH 16/26] fix(mcp): route prompts/resources to the advertising server and mark lost auth Co-Authored-By: Quentin Rousseau --- internal/mcp/manager.go | 46 ++++++++++++++++++++++++++-------- internal/mcp/resources.go | 40 ++++++++++++++++++++++++++--- internal/mcp/resources_test.go | 35 ++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 14 deletions(-) diff --git a/internal/mcp/manager.go b/internal/mcp/manager.go index 4d4c9ff..0a40370 100644 --- a/internal/mcp/manager.go +++ b/internal/mcp/manager.go @@ -245,7 +245,7 @@ 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 { @@ -263,11 +263,25 @@ func (m *Manager) connectLocked(runtime *runtimeServer) { } runtime.session, runtime.tools, runtime.status, runtime.detail = session, tools, status, detail runtime.prompts, runtime.resources = prompts, resources + if session != nil { + probe.watch(func() { m.loseAuth(runtime, generation) }) + } m.notify() }() m.notify() } +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 { @@ -284,31 +298,29 @@ func (r *runtimeServer) close() { 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, 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 @@ -420,11 +432,25 @@ func (m *Manager) bearerFuncs(config ServerConfig) (bearer func() string, refres 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()) { + if p == nil { + return + } + p.mu.Lock() + p.onExpired = onExpired p.mu.Unlock() } diff --git a/internal/mcp/resources.go b/internal/mcp/resources.go index e8ffed7..bf1f140 100644 --- a/internal/mcp/resources.go +++ b/internal/mcp/resources.go @@ -64,20 +64,52 @@ func discoverExtras(ctx context.Context, session *sdk.ClientSession) (prompts [] return prompts, resources } -func (m *Manager) sessionFor(serverName string) (*sdk.ClientSession, time.Duration, error) { +func (m *Manager) sessionFor(serverName string, advertises func(*runtimeServer) bool) (*sdk.ClientSession, time.Duration, error) { m.mu.Lock() defer m.mu.Unlock() + var fallback *runtimeServer for _, runtime := range m.servers { - if runtime.server.Name == serverName && runtime.session != nil && !runtime.sessionOff { + if runtime.server.Name != serverName || runtime.session == nil || runtime.sessionOff { + continue + } + if advertises(runtime) { return runtime.session, runtime.server.Config.CallTimeout(connectTimeout), nil } + if fallback == nil { + fallback = runtime + } + } + if fallback != nil { + return fallback.session, fallback.server.Config.CallTimeout(connectTimeout), nil } return nil, 0, fmt.Errorf("MCP server %s is not connected", serverName) } +func advertisesPrompt(name string) func(*runtimeServer) bool { + return func(runtime *runtimeServer) bool { + for _, prompt := range runtime.prompts { + if prompt.Name == name { + return true + } + } + return false + } +} + +func advertisesResource(uri string) func(*runtimeServer) bool { + return func(runtime *runtimeServer) bool { + for _, resource := range runtime.resources { + if resource.URI == uri { + return true + } + } + return false + } +} + // GetPrompt renders an MCP prompt's text messages into one message. func (m *Manager) GetPrompt(ctx context.Context, serverName, name string, arguments map[string]string) (string, error) { - session, timeout, err := m.sessionFor(serverName) + session, timeout, err := m.sessionFor(serverName, advertisesPrompt(name)) if err != nil { return "", err } @@ -107,7 +139,7 @@ func (m *Manager) GetPrompt(ctx context.Context, serverName, name string, argume // ReadResource returns a resource's text, cut to limit bytes on a rune boundary. func (m *Manager) ReadResource(ctx context.Context, serverName, uri string, limit int) (text string, truncated bool, err error) { - session, timeout, err := m.sessionFor(serverName) + session, timeout, err := m.sessionFor(serverName, advertisesResource(uri)) if err != nil { return "", false, err } diff --git a/internal/mcp/resources_test.go b/internal/mcp/resources_test.go index a8fed36..9a52196 100644 --- a/internal/mcp/resources_test.go +++ b/internal/mcp/resources_test.go @@ -95,3 +95,38 @@ func TestGetPromptAndReadResourceWithCap(t *testing.T) { t.Fatal("unknown server should fail") } } + +func TestSameNamedServersRouteToTheAdvertisingOne(t *testing.T) { + other := connectInMemory(t, sdk.NewServer(&sdk.Implementation{Name: "other"}, nil)) + docs := connectInMemory(t, docsServer()) + manager := &Manager{servers: []*runtimeServer{ + {server: Server{Name: "docs", SourceLabel: "cursor"}, session: other, status: StatusConnected}, + {server: Server{Name: "docs"}, session: docs, status: StatusConnected, + prompts: []PromptInfo{{Name: "triage"}}, + resources: []ResourceInfo{{URI: "file:///runbook.md"}}}, + }} + ctx := context.Background() + if text, err := manager.GetPrompt(ctx, "docs", "triage", map[string]string{"service": "api"}); err != nil || text != "Triage api" { + t.Fatalf("GetPrompt = %q, %v", text, err) + } + if _, _, err := manager.ReadResource(ctx, "docs", "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") + } +} From 936d27ffcdd6eb8ca2bfc61841b081a2eebff8d6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:41:00 +0000 Subject: [PATCH 17/26] fix(mcp): sanitize registry text, shell-quote add commands, keep colliding names Co-Authored-By: Quentin Rousseau --- internal/cmd/mcp/search.go | 12 ++++- internal/cmd/mcp/search_test.go | 6 +++ internal/mcp/registry.go | 84 +++++++++++++++++++++++++-------- internal/mcp/registry_test.go | 33 +++++++++++++ 4 files changed, 113 insertions(+), 22 deletions(-) diff --git a/internal/cmd/mcp/search.go b/internal/cmd/mcp/search.go index bda8c76..1796a50 100644 --- a/internal/cmd/mcp/search.go +++ b/internal/cmd/mcp/search.go @@ -5,7 +5,9 @@ import ( "net/http" "strings" "time" + "unicode" + "github.com/charmbracelet/x/ansi" "github.com/spf13/cobra" localmcp "github.com/rootlyhq/rootly-cli/internal/mcp" @@ -48,9 +50,9 @@ Nothing is installed: each result shows the exact command or URL and the if entry.Author != "" { title += " (" + entry.Author + ")" } - _, _ = fmt.Fprintf(out, "%s\n %s\n runs: %s\n add: %s\n", title, oneLine(entry.Description), localmcp.InstallLabel(entry), localmcp.AddCommand(entry)) + _, _ = fmt.Fprintf(out, "%s\n %s\n runs: %s\n add: %s\n", oneLine(title), oneLine(entry.Description), oneLine(localmcp.InstallLabel(entry)), oneLine(localmcp.AddCommand(entry))) if entry.EnvHint != "" { - _, _ = fmt.Fprintf(out, " needs: %s\n", entry.EnvHint) + _, _ = fmt.Fprintf(out, " needs: %s\n", oneLine(entry.EnvHint)) } } return nil @@ -58,6 +60,12 @@ Nothing is installed: each result shows the exact command or URL and the } func oneLine(text string) string { + text = strings.Map(func(r rune) rune { + if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) { + return ' ' + } + return r + }, ansi.Strip(text)) text = strings.Join(strings.Fields(text), " ") if runes := []rune(text); len(runes) > 160 { text = string(runes[:157]) + "..." diff --git a/internal/cmd/mcp/search_test.go b/internal/cmd/mcp/search_test.go index 63f1c91..17c4f47 100644 --- a/internal/cmd/mcp/search_test.go +++ b/internal/cmd/mcp/search_test.go @@ -61,3 +61,9 @@ func TestSearchFallsBackToCuratedWhenRegistryFails(t *testing.T) { 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) + } +} diff --git a/internal/mcp/registry.go b/internal/mcp/registry.go index f50c0c1..d5b6e88 100644 --- a/internal/mcp/registry.go +++ b/internal/mcp/registry.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "maps" "net/http" "net/url" "os" @@ -29,13 +30,14 @@ const ( // MarketEntry is one installable server from the curated list or the official registry. type MarketEntry struct { - Name string `json:"name"` - 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"` + 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 { @@ -190,7 +192,7 @@ func FetchRegistry(ctx context.Context, client *http.Client, baseURL string, pro continue } seen[item.Server.Name] = true - entries = append(entries, MarketEntry{Name: name, Title: item.Server.Title, Description: item.Server.Description, Author: registryAuthor(item.Server), Config: install}) + 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) @@ -259,22 +261,46 @@ func LoadIndex(path string) (entries []MarketEntry, indexedAt time.Time, err err return index.Entries, index.IndexedAt, nil } -// Catalog merges curated entries (first) with the registry index; the first entry per name wins. +// 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{} - var entries []MarketEntry - for _, group := range [][]MarketEntry{CuratedEntries(), indexed} { - for _, entry := range group { - if entry.Name == "" || seen[entry.Name] { - continue - } - seen[entry.Name] = true - entries = append(entries, entry) + entries := make([]MarketEntry, 0, len(curated)+len(indexed)) + for _, entry := range curated { + seen[entry.Name] = true + entries = append(entries, entry) + } + curatedNames := maps.Clone(seen) + for _, entry := range indexed { + if entry.Name == "" || curatedNames[entry.Name] { + continue } + if seen[entry.Name] { + entry.Name = distinctMarketName(entry, seen) + } + seen[entry.Name] = true + entries = append(entries, entry) } return entries } +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 { @@ -371,10 +397,28 @@ func LoadCatalog(ctx context.Context, client *http.Client, registryURL, indexPat return Catalog(fetched), nil } -// AddCommand is the equivalent `rootly mcp add` invocation for an entry. +// 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 != "" { - return fmt.Sprintf("rootly mcp add -s user -t %s %s %s", entry.Config.Type, entry.Name, 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.TrimSpace(fmt.Sprintf("rootly mcp add -s user %s -- %s", entry.Name, InstallLabel(entry))) + return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'" } diff --git a/internal/mcp/registry_test.go b/internal/mcp/registry_test.go index 31c4161..a284810 100644 --- a/internal/mcp/registry_test.go +++ b/internal/mcp/registry_test.go @@ -174,3 +174,36 @@ func TestManagerAddFromMarketRejectsImportedNames(t *testing.T) { 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) + } + } +} From 27b6bbb3caf44e4657ff8b6be76c2de4be30b7f3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:43:31 +0000 Subject: [PATCH 18/26] fix(mcp): print search install and add lines untruncated Co-Authored-By: Quentin Rousseau --- internal/cmd/mcp/search.go | 11 +++++++---- internal/cmd/mcp/search_test.go | 7 +++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/internal/cmd/mcp/search.go b/internal/cmd/mcp/search.go index 1796a50..91ecd52 100644 --- a/internal/cmd/mcp/search.go +++ b/internal/cmd/mcp/search.go @@ -50,7 +50,7 @@ Nothing is installed: each result shows the exact command or URL and the if entry.Author != "" { title += " (" + entry.Author + ")" } - _, _ = fmt.Fprintf(out, "%s\n %s\n runs: %s\n add: %s\n", oneLine(title), oneLine(entry.Description), oneLine(localmcp.InstallLabel(entry)), oneLine(localmcp.AddCommand(entry))) + _, _ = 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)) } @@ -59,14 +59,17 @@ Nothing is installed: each result shows the exact command or URL and the }, } -func oneLine(text string) string { - text = strings.Map(func(r rune) rune { +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)) - text = strings.Join(strings.Fields(text), " ") +} + +func oneLine(text string) string { + text = strings.Join(strings.Fields(plainText(text)), " ") if runes := []rune(text); len(runes) > 160 { text = string(runes[:157]) + "..." } diff --git a/internal/cmd/mcp/search_test.go b/internal/cmd/mcp/search_test.go index 17c4f47..5dee833 100644 --- a/internal/cmd/mcp/search_test.go +++ b/internal/cmd/mcp/search_test.go @@ -67,3 +67,10 @@ func TestSearchStripsTerminalControls(t *testing.T) { 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) + } +} From e189ae9bc23cb7f59bbaa89fc41f7ccc759a0c03 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:28:53 +0000 Subject: [PATCH 19/26] test(mcp): connect honours the per-server timeout Co-Authored-By: Quentin Rousseau --- internal/mcp/freshness_test.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/internal/mcp/freshness_test.go b/internal/mcp/freshness_test.go index 5713f22..af2e6aa 100644 --- a/internal/mcp/freshness_test.go +++ b/internal/mcp/freshness_test.go @@ -153,3 +153,16 @@ func TestRemote401RefreshStopsWithRequestContext(t *testing.T) { 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) + } +} From f721c6982c9eac9f26adf588bf47d4f8c2291b7d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:41:01 +0000 Subject: [PATCH 20/26] fix(mcp): curated entries only replace the same registry server Co-Authored-By: Quentin Rousseau --- internal/mcp/registry.go | 10 +++++++--- internal/mcp/registry_test.go | 18 +++++++++++++++++- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/internal/mcp/registry.go b/internal/mcp/registry.go index d5b6e88..dab8a1b 100644 --- a/internal/mcp/registry.go +++ b/internal/mcp/registry.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "maps" "net/http" "net/url" "os" @@ -267,14 +266,15 @@ func LoadIndex(path string) (entries []MarketEntry, indexedAt time.Time, err err 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) } - curatedNames := maps.Clone(seen) for _, entry := range indexed { - if entry.Name == "" || curatedNames[entry.Name] { + if entry.Name == "" || curatedInstalls[entry.Name+"\x00"+installKey(entry.Config)] { continue } if seen[entry.Name] { @@ -286,6 +286,10 @@ func Catalog(indexed []MarketEntry) []MarketEntry { return entries } +func installKey(config ServerConfig) string { + return strings.Join(append([]string{config.Transport(), config.URL, config.Command}, config.Args...), "\x00") +} + func distinctMarketName(entry MarketEntry, seen map[string]bool) string { base := entry.Name if publisher, _, ok := strings.Cut(entry.RegistryName, "/"); ok { diff --git a/internal/mcp/registry_test.go b/internal/mcp/registry_test.go index a284810..b4bd262 100644 --- a/internal/mcp/registry_test.go +++ b/internal/mcp/registry_test.go @@ -72,7 +72,7 @@ func TestFetchRegistryPagesAndMapsInstalls(t *testing.T) { } func TestCatalogPutsRootlyVendorsFirstAndSearchRanks(t *testing.T) { - catalog := Catalog([]MarketEntry{{Name: "sentry", Description: "registry duplicate"}, {Name: "pager-mcp", Description: "Paging tool", Config: npxConfig("@acme/pager-mcp")}}) + 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) @@ -207,3 +207,19 @@ func TestCatalogKeepsRegistryEntriesWithTheSameShortName(t *testing.T) { } } } + +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) + } +} From 3ac222880cf3aabd949a100b8b4843e930a25ab0 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:41:01 +0000 Subject: [PATCH 21/26] fix(mcp): route prompts and resources by server ID Co-Authored-By: Quentin Rousseau --- internal/agent/market_test.go | 18 +++++++++++++ internal/agent/mcp_extras.go | 34 ++++++++++++++---------- internal/agent/mcp_page.go | 4 +-- internal/agent/mcp_page_test.go | 8 +++--- internal/mcp/resources.go | 46 +++++---------------------------- internal/mcp/resources_test.go | 26 +++++++++++-------- 6 files changed, 66 insertions(+), 70 deletions(-) diff --git a/internal/agent/market_test.go b/internal/agent/market_test.go index ac0c4b6..d4608b4 100644 --- a/internal/agent/market_test.go +++ b/internal/agent/market_test.go @@ -124,3 +124,21 @@ func TestMarketplacePreviewIgnoresEnter(t *testing.T) { 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 index 8c064e6..aa053ed 100644 --- a/internal/agent/mcp_extras.go +++ b/internal/agent/mcp_extras.go @@ -36,8 +36,9 @@ type mentionsReadyMsg struct { } type resourceRef struct { - server string - uri string + server string + uri string + serverID string } func (r resourceRef) mention() string { return "@" + r.server + ":" + r.uri } @@ -91,18 +92,18 @@ func (m *TUIModel) appendPromptItems(items []list.Item, value string) []list.Ite return items } -func (m *TUIModel) findPrompt(server, name string) (mcp.PromptInfo, bool) { +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 _, prompt := range view.Prompts { - if prompt.Name == name { - return prompt, true + for _, candidate := range view.Prompts { + if candidate.Name == name { + return candidate, view.Server.ID(), true } } } - return mcp.PromptInfo{}, false + return mcp.PromptInfo{}, "", false } // promptArguments maps key=value tokens by name and fills the rest positionally; extra words join the last argument. @@ -165,7 +166,7 @@ func (m *TUIModel) runMCPPrompt(command string) (tea.Cmd, bool) { if !ok { return nil, false } - prompt, found := m.findPrompt(server, name) + prompt, serverID, found := m.findPrompt(server, name) if !found { return nil, false } @@ -177,7 +178,7 @@ func (m *TUIModel) runMCPPrompt(command string) (tea.Cmd, bool) { 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, server, name, arguments) + text, err := manager.GetPrompt(ctx, serverID, name, arguments) return mcpPromptMsg{request: request, label: label, text: text, err: err} }, true } @@ -272,10 +273,13 @@ func (m *TUIModel) insertMention(mention string) { } func (m *TUIModel) resourceMentions(message string) []resourceRef { - known := map[resourceRef]bool{} + known := map[resourceRef]string{} for _, view := range m.liveMCPViews() { for _, resource := range view.Resources { - known[resourceRef{server: view.Server.Name, uri: resource.URI}] = true + ref := resourceRef{server: view.Server.Name, uri: resource.URI} + if _, taken := known[ref]; !taken { + known[ref] = view.Server.ID() + } } } if len(known) == 0 { @@ -285,10 +289,12 @@ func (m *TUIModel) resourceMentions(message string) []resourceRef { seen := map[resourceRef]bool{} for _, match := range resourceMentionPattern.FindAllStringSubmatch(message, -1) { ref := resourceRef{server: match[1], uri: strings.TrimRight(match[2], ".,;:!?)")} - if !known[ref] { + if _, ok := known[ref]; !ok { ref.uri = match[2] } - if known[ref] && !seen[ref] && len(refs) < maxAttachedResources { + serverID, ok := known[ref] + ref.serverID = serverID + if ok && !seen[ref] && len(refs) < maxAttachedResources { seen[ref] = true refs = append(refs, ref) } @@ -314,7 +320,7 @@ func readMentionedResources(ctx context.Context, manager MCPManager, request int result := mentionsReadyMsg{request: request, echo: message, message: message} var blocks []string for _, ref := range refs { - text, truncated, err := manager.ReadResource(ctx, ref.server, ref.uri, mcp.ResourceByteCap) + 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 diff --git a/internal/agent/mcp_page.go b/internal/agent/mcp_page.go index 1be5f90..3af5aa4 100644 --- a/internal/agent/mcp_page.go +++ b/internal/agent/mcp_page.go @@ -25,8 +25,8 @@ type MCPManager interface { Changes() <-chan struct{} Connecting() int ToggleSession(string) error - GetPrompt(context.Context, string, string, map[string]string) (string, error) - ReadResource(context.Context, string, string, int) (string, bool, 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 diff --git a/internal/agent/mcp_page_test.go b/internal/agent/mcp_page_test.go index ff59b88..14dfcaf 100644 --- a/internal/agent/mcp_page_test.go +++ b/internal/agent/mcp_page_test.go @@ -51,18 +51,18 @@ func (f *fakeMCPManager) AddFromMarket(entry mcp.MarketEntry) (mcp.Server, error return server, nil } -func (f *fakeMCPManager) GetPrompt(_ context.Context, server, name string, arguments map[string]string) (string, error) { - if server != "docs" { +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, server, uri string, limit int) (text string, truncated bool, err error) { +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[server+":"+uri] + text, ok := f.resources[serverID[strings.LastIndex(serverID, ":")+1:]+":"+uri] if !ok { return "", false, errors.New("no text content") } diff --git a/internal/mcp/resources.go b/internal/mcp/resources.go index bf1f140..33d040b 100644 --- a/internal/mcp/resources.go +++ b/internal/mcp/resources.go @@ -64,52 +64,20 @@ func discoverExtras(ctx context.Context, session *sdk.ClientSession) (prompts [] return prompts, resources } -func (m *Manager) sessionFor(serverName string, advertises func(*runtimeServer) bool) (*sdk.ClientSession, time.Duration, error) { +func (m *Manager) sessionFor(serverID string) (*sdk.ClientSession, time.Duration, error) { m.mu.Lock() defer m.mu.Unlock() - var fallback *runtimeServer for _, runtime := range m.servers { - if runtime.server.Name != serverName || runtime.session == nil || runtime.sessionOff { - continue - } - if advertises(runtime) { + if runtime.server.ID() == serverID && runtime.session != nil && !runtime.sessionOff { return runtime.session, runtime.server.Config.CallTimeout(connectTimeout), nil } - if fallback == nil { - fallback = runtime - } - } - if fallback != nil { - return fallback.session, fallback.server.Config.CallTimeout(connectTimeout), nil - } - return nil, 0, fmt.Errorf("MCP server %s is not connected", serverName) -} - -func advertisesPrompt(name string) func(*runtimeServer) bool { - return func(runtime *runtimeServer) bool { - for _, prompt := range runtime.prompts { - if prompt.Name == name { - return true - } - } - return false - } -} - -func advertisesResource(uri string) func(*runtimeServer) bool { - return func(runtime *runtimeServer) bool { - for _, resource := range runtime.resources { - if resource.URI == uri { - return true - } - } - return false } + 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, serverName, name string, arguments map[string]string) (string, error) { - session, timeout, err := m.sessionFor(serverName, advertisesPrompt(name)) +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 } @@ -138,8 +106,8 @@ func (m *Manager) GetPrompt(ctx context.Context, serverName, name string, argume } // ReadResource returns a resource's text, cut to limit bytes on a rune boundary. -func (m *Manager) ReadResource(ctx context.Context, serverName, uri string, limit int) (text string, truncated bool, err error) { - session, timeout, err := m.sessionFor(serverName, advertisesResource(uri)) +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 } diff --git a/internal/mcp/resources_test.go b/internal/mcp/resources_test.go index 9a52196..39913ea 100644 --- a/internal/mcp/resources_test.go +++ b/internal/mcp/resources_test.go @@ -73,19 +73,20 @@ func TestGetPromptAndReadResourceWithCap(t *testing.T) { {server: Server{Name: "docs"}, session: session, status: StatusConnected}, }} ctx := context.Background() - text, err := manager.GetPrompt(ctx, "docs", "triage", map[string]string{"service": "api"}) + 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, "docs", "file:///runbook.md", 1001) + 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, "docs", "file:///runbook.md", 0) + 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, "docs", "file:///logo.png", ResourceByteCap); err == nil { + 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 { @@ -96,20 +97,23 @@ func TestGetPromptAndReadResourceWithCap(t *testing.T) { } } -func TestSameNamedServersRouteToTheAdvertisingOne(t *testing.T) { +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: Server{Name: "docs", SourceLabel: "cursor"}, session: other, status: StatusConnected}, - {server: Server{Name: "docs"}, session: docs, status: StatusConnected, - prompts: []PromptInfo{{Name: "triage"}}, - resources: []ResourceInfo{{URI: "file:///runbook.md"}}}, + {server: imported, session: other, status: StatusConnected}, + {server: user, session: docs, status: StatusConnected}, }} ctx := context.Background() - if text, err := manager.GetPrompt(ctx, "docs", "triage", map[string]string{"service": "api"}); err != nil || text != "Triage api" { + 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.ReadResource(ctx, "docs", "file:///runbook.md", ResourceByteCap); err != nil { + 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) } } From 6f2a02542b202c6028aafa0e29f1eaf8a50690a3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:42:10 +0000 Subject: [PATCH 22/26] style(mcp): avoid shadowing the config import Co-Authored-By: Quentin Rousseau --- internal/mcp/registry.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/mcp/registry.go b/internal/mcp/registry.go index dab8a1b..3398365 100644 --- a/internal/mcp/registry.go +++ b/internal/mcp/registry.go @@ -286,8 +286,8 @@ func Catalog(indexed []MarketEntry) []MarketEntry { return entries } -func installKey(config ServerConfig) string { - return strings.Join(append([]string{config.Transport(), config.URL, config.Command}, config.Args...), "\x00") +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 { From bd0fa6ffcb2b531124ccf8bc141e0539963de3a9 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:54:49 +0000 Subject: [PATCH 23/26] fix(mcp): a 401 during prompt/resource discovery marks needs-auth Co-Authored-By: Quentin Rousseau --- internal/mcp/manager.go | 17 +++++++++++++---- internal/mcp/resources_test.go | 14 ++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/internal/mcp/manager.go b/internal/mcp/manager.go index 0a40370..5ccbced 100644 --- a/internal/mcp/manager.go +++ b/internal/mcp/manager.go @@ -264,13 +264,21 @@ func (m *Manager) connectLocked(runtime *runtimeServer) { runtime.session, runtime.tools, runtime.status, runtime.detail = session, tools, status, detail runtime.prompts, runtime.resources = prompts, resources if session != nil { - probe.watch(func() { m.loseAuth(runtime, generation) }) + 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() @@ -445,13 +453,14 @@ func (p *authProbe) mark() { } } -func (p *authProbe) watch(onExpired func()) { +func (p *authProbe) watch(onExpired func()) (alreadyUnauthorized bool) { if p == nil { - return + return false } p.mu.Lock() + defer p.mu.Unlock() p.onExpired = onExpired - p.mu.Unlock() + return p.unauthorized } func (p *authProbe) seen() bool { diff --git a/internal/mcp/resources_test.go b/internal/mcp/resources_test.go index 39913ea..dc9e7dd 100644 --- a/internal/mcp/resources_test.go +++ b/internal/mcp/resources_test.go @@ -134,3 +134,17 @@ func TestLostAuthAfterConnectMarksNeedsAuth(t *testing.T) { 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) + } +} From b05bce31252536712b76de1ac430625f0608ae30 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:01:45 +0000 Subject: [PATCH 24/26] fix(agent): keep prompt names with terminal controls out of the slash palette Co-Authored-By: Quentin Rousseau --- internal/agent/mcp_extras.go | 9 +++++++++ internal/agent/mcp_extras_test.go | 15 +++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/internal/agent/mcp_extras.go b/internal/agent/mcp_extras.go index aa053ed..1262678 100644 --- a/internal/agent/mcp_extras.go +++ b/internal/agent/mcp_extras.go @@ -5,6 +5,7 @@ import ( "fmt" "regexp" "strings" + "unicode" "charm.land/bubbles/v2/list" tea "charm.land/bubbletea/v2" @@ -61,6 +62,11 @@ func oneLine(text string) string { } // 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 { @@ -72,6 +78,9 @@ func (m *TUIModel) appendPromptItems(items []list.Item, value string) []list.Ite 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) { diff --git a/internal/agent/mcp_extras_test.go b/internal/agent/mcp_extras_test.go index be0c806..7e4f895 100644 --- a/internal/agent/mcp_extras_test.go +++ b/internal/agent/mcp_extras_test.go @@ -223,3 +223,18 @@ func TestCtrlCCancelsPendingPromptAndMentionLoads(t *testing.T) { 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") + } +} From ea1338422c5eac6fe73999c437949efb70e394af Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:24:25 +0000 Subject: [PATCH 25/26] fix(agent): drop pending MCP prompt/resource loads when switching sessions or bindings Co-Authored-By: Quentin Rousseau --- internal/agent/mcp_extras_test.go | 19 +++++++++++++++++++ internal/agent/tui.go | 1 + internal/agent/tui_helpers.go | 1 + 3 files changed, 21 insertions(+) diff --git a/internal/agent/mcp_extras_test.go b/internal/agent/mcp_extras_test.go index 7e4f895..e4429cd 100644 --- a/internal/agent/mcp_extras_test.go +++ b/internal/agent/mcp_extras_test.go @@ -7,6 +7,7 @@ import ( "charm.land/bubbles/v2/list" tea "charm.land/bubbletea/v2" + "github.com/google/uuid" "github.com/rootlyhq/rootly-cli/internal/mcp" ) @@ -207,6 +208,24 @@ func TestStalePromptAndMentionLoadsAreDropped(t *testing.T) { } } +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") diff --git a/internal/agent/tui.go b/internal/agent/tui.go index 4628239..fada997 100644 --- a/internal/agent/tui.go +++ b/internal/agent/tui.go @@ -387,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() diff --git a/internal/agent/tui_helpers.go b/internal/agent/tui_helpers.go index 26820cf..17e8361 100644 --- a/internal/agent/tui_helpers.go +++ b/internal/agent/tui_helpers.go @@ -648,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 From c2816227f7be8cffe92775a6b041ac1bf91d30d2 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:03:33 +0000 Subject: [PATCH 26/26] fix(agent): widen /mcp status column so capability counts aren't clipped Co-Authored-By: Quentin Rousseau --- internal/agent/mcp_extras_test.go | 13 +++++++++++++ internal/agent/mcp_page.go | 19 +++++++++++++++---- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/internal/agent/mcp_extras_test.go b/internal/agent/mcp_extras_test.go index e4429cd..3d9e6fd 100644 --- a/internal/agent/mcp_extras_test.go +++ b/internal/agent/mcp_extras_test.go @@ -257,3 +257,16 @@ func TestPromptNamesWithTerminalControlsStayOutOfThePalette(t *testing.T) { 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 3af5aa4..8b7060d 100644 --- a/internal/agent/mcp_page.go +++ b/internal/agent/mcp_page.go @@ -647,8 +647,9 @@ 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, "") @@ -661,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 = "❯ " @@ -675,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) @@ -686,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 "" }