diff --git a/cmd/generate-types/main.go b/cmd/generate-types/main.go
index 29d258f64..d7ba2f1de 100644
--- a/cmd/generate-types/main.go
+++ b/cmd/generate-types/main.go
@@ -147,6 +147,56 @@ export interface HealthStatus {
actions: HealthAction[];
}
+// Needs-attention list (Spec 109 FR-001-007) - generated from
+// internal/contracts/attention.go. One list, one count, every surface (Web
+// UI, macOS tray/Home, CLI) reads from GET /api/v1/attention.
+export const AttentionKindSignInRequired = 'sign_in_required' as const;
+export const AttentionKindMissingSecret = 'missing_secret' as const;
+export const AttentionKindConfigError = 'config_error' as const;
+export const AttentionKindServerError = 'server_error' as const;
+export const AttentionKindServerReview = 'server_review' as const;
+export const AttentionKindToolReview = 'tool_review' as const;
+export const AttentionKindClientNeverSeen = 'client_never_seen' as const;
+
+export type AttentionKind =
+ | typeof AttentionKindSignInRequired
+ | typeof AttentionKindMissingSecret
+ | typeof AttentionKindConfigError
+ | typeof AttentionKindServerError
+ | typeof AttentionKindServerReview
+ | typeof AttentionKindToolReview
+ | typeof AttentionKindClientNeverSeen;
+
+export interface AttentionSubject {
+ type: 'server' | 'tool' | 'client';
+ id: string;
+ name: string;
+}
+
+export interface AttentionFix {
+ verb: string;
+ label: string;
+ target: string;
+}
+
+export interface AttentionItem {
+ /** Stable: kind:type:subject[:state]. */
+ id: string;
+ kind: AttentionKind;
+ rank: number;
+ subject: AttentionSubject;
+ summary: string;
+ detail?: string;
+ fix: AttentionFix;
+ since: string; // ISO date string
+}
+
+export interface AttentionResponse {
+ count: number;
+ generated_at: string; // ISO date string
+ items: AttentionItem[];
+}
+
`)
// Activity status vocabulary - generated from internal/storage/activity_models.go
diff --git a/cmd/mcpproxy/attention_cmd.go b/cmd/mcpproxy/attention_cmd.go
new file mode 100644
index 000000000..8e1c50fd3
--- /dev/null
+++ b/cmd/mcpproxy/attention_cmd.go
@@ -0,0 +1,107 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ clioutput "github.com/smart-mcp-proxy/mcpproxy-go/internal/cli/output"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/cliclient"
+)
+
+// GetAttentionCommand returns the `mcpproxy attention` cobra command
+// (Spec 109 FR-001/FR-003): the one needs-attention list, read verbatim from
+// GET /api/v1/attention — the same list the Web UI Home page, the macOS tray
+// and Home section, and the first line of `status`/first section of `doctor`
+// all render.
+func GetAttentionCommand() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "attention",
+ Short: "Show the needs-attention list",
+ Long: `Show the one needs-attention list every MCPProxy surface reads from:
+sign-in prompts, quarantine/tool reviews, connection errors, missing secrets,
+configuration errors, and clients that connected but were never seen.
+
+This is a report, not a health check: it always exits 0, whether the list is
+empty or not. Use 'mcpproxy doctor' for a full diagnostic pass.
+
+Examples:
+ mcpproxy attention
+ mcpproxy attention -o json`,
+ RunE: runAttention,
+ }
+ return cmd
+}
+
+func runAttention(_ *cobra.Command, _ []string) error {
+ cfg, err := loadCLIConfig(configFile)
+ if err != nil {
+ return fmt.Errorf("failed to load config: %w", err)
+ }
+
+ client, ok := newDaemonClient(cfg, nil)
+ if !ok {
+ return fmt.Errorf("attention requires running daemon. Start with: mcpproxy serve")
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ resp, err := client.GetAttention(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to get attention list from daemon: %w", err)
+ }
+
+ return printAttentionOutput(resp)
+}
+
+func printAttentionOutput(resp *cliclient.AttentionResponse) error {
+ format := ResolveOutputFormat()
+
+ // Review finding F5: the formatter must be constructed (and its format
+ // validated) unconditionally, before either output path below — not only
+ // on the non-empty branch. Building it here first means an invalid/typo'd
+ // -o value is rejected the same way whether the daemon has 0 or 100
+ // attention items, instead of a typo silently succeeding with "All clear"
+ // whenever the instance happens to be healthy.
+ formatter, err := GetOutputFormatter()
+ if err != nil {
+ return clioutput.NewStructuredError(clioutput.ErrCodeInvalidOutputFormat, err.Error()).
+ WithGuidance("Use -o table, -o json, or -o yaml")
+ }
+
+ if format == "json" || format == "yaml" {
+ out, err := formatter.Format(resp)
+ if err != nil {
+ return fmt.Errorf("failed to format output: %w", err)
+ }
+ fmt.Println(out)
+ return nil
+ }
+
+ if resp.Count == 0 {
+ fmt.Println("All clear")
+ return nil
+ }
+
+ headers := []string{"#", "KIND", "SUBJECT", "SUMMARY", "FIX"}
+ rows := make([][]string, len(resp.Items))
+ for i, item := range resp.Items {
+ rows[i] = []string{
+ fmt.Sprintf("%d", i+1),
+ item.Kind,
+ item.Subject.Name,
+ item.Summary,
+ item.Fix.Label,
+ }
+ }
+
+ out, err := formatter.FormatTable(headers, rows)
+ if err != nil {
+ return fmt.Errorf("failed to format table: %w", err)
+ }
+ fmt.Print(out)
+ return nil
+}
diff --git a/cmd/mcpproxy/attention_cmd_test.go b/cmd/mcpproxy/attention_cmd_test.go
new file mode 100644
index 000000000..d1914f609
--- /dev/null
+++ b/cmd/mcpproxy/attention_cmd_test.go
@@ -0,0 +1,172 @@
+package main
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/cliclient"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts"
+)
+
+func sampleAttentionResponse() *cliclient.AttentionResponse {
+ return &cliclient.AttentionResponse{
+ Count: 2,
+ GeneratedAt: "2026-09-25T06:12:03Z",
+ Items: []contracts.AttentionItem{
+ {
+ ID: "sign_in_required:server:github",
+ Kind: "sign_in_required",
+ Rank: 10,
+ Subject: contracts.AttentionSubject{Type: "server", ID: "github", Name: "github"},
+ Summary: "github: sign in required",
+ Fix: contracts.AttentionFix{Verb: "login", Label: "Sign in", Target: "/servers/github"},
+ },
+ {
+ ID: "server_review:server:github",
+ Kind: "server_review",
+ Rank: 50,
+ Subject: contracts.AttentionSubject{Type: "server", ID: "github", Name: "github"},
+ Summary: "github: waiting for review",
+ Fix: contracts.AttentionFix{Verb: "review", Label: "Review", Target: "/review/github"},
+ },
+ },
+ }
+}
+
+// TestAttentionCmd_TableOutput pins T056: `mcpproxy attention` table columns
+// are exactly `# KIND SUBJECT SUMMARY FIX` (contracts/cli.md).
+func TestAttentionCmd_TableOutput(t *testing.T) {
+ setOutputGlobals(t, "table", false)
+ output := captureOutput(func() {
+ if err := printAttentionOutput(sampleAttentionResponse()); err != nil {
+ t.Fatalf("printAttentionOutput: %v", err)
+ }
+ })
+
+ for _, want := range []string{"KIND", "SUBJECT", "SUMMARY", "FIX", "sign_in_required", "github", "Sign in", "server_review", "Review"} {
+ if !strings.Contains(output, want) {
+ t.Errorf("table output missing %q, got:\n%s", want, output)
+ }
+ }
+}
+
+// TestAttentionCmd_JSONOutput pins T056: `-o json` round-trips the exact
+// contracts/rest-api.md#attention shape.
+func TestAttentionCmd_JSONOutput(t *testing.T) {
+ setOutputGlobals(t, "json", false)
+ output := captureOutput(func() {
+ if err := printAttentionOutput(sampleAttentionResponse()); err != nil {
+ t.Fatalf("printAttentionOutput: %v", err)
+ }
+ })
+
+ var decoded cliclient.AttentionResponse
+ if err := json.Unmarshal([]byte(output), &decoded); err != nil {
+ t.Fatalf("json output did not parse: %v\noutput: %s", err, output)
+ }
+ if decoded.Count != 2 || len(decoded.Items) != 2 {
+ t.Errorf("unexpected decoded response: %+v", decoded)
+ }
+ if decoded.Items[0].Fix.Verb != "login" {
+ t.Errorf("expected first item's fix verb 'login', got %q", decoded.Items[0].Fix.Verb)
+ }
+}
+
+// TestAttentionCmd_AllClear pins T056: an empty list prints "All clear" and
+// the command still succeeds (it is a report, not a check — FR-001).
+func TestAttentionCmd_AllClear(t *testing.T) {
+ setOutputGlobals(t, "table", false)
+ output := captureOutput(func() {
+ if err := printAttentionOutput(&cliclient.AttentionResponse{Count: 0, Items: []contracts.AttentionItem{}}); err != nil {
+ t.Fatalf("printAttentionOutput: %v", err)
+ }
+ })
+ if strings.TrimSpace(output) != "All clear" {
+ t.Errorf("expected exactly %q, got %q", "All clear", strings.TrimSpace(output))
+ }
+}
+
+// TestAttentionCmd_InvalidFormatRejectedEvenWhenAllClear pins review finding
+// F5: printAttentionOutput's "All clear" early-return (0 items) used to run
+// before any formatter was constructed, so a typo'd -o value silently
+// succeeded with exit 0 and "All clear" — while the identical typo against a
+// non-empty list correctly failed. Format validation must not depend on
+// whether there happen to be any items.
+func TestAttentionCmd_InvalidFormatRejectedEvenWhenAllClear(t *testing.T) {
+ setOutputGlobals(t, "jso", false)
+ err := printAttentionOutput(&cliclient.AttentionResponse{Count: 0, Items: []contracts.AttentionItem{}})
+ if err == nil {
+ t.Fatal("expected an error for an invalid output format, got nil")
+ }
+ if !strings.Contains(err.Error(), "unknown output format") {
+ t.Errorf("expected 'unknown output format' error, got: %v", err)
+ }
+}
+
+// TestStatusTable_NeedsAttentionFirstLine pins T056/contracts/cli.md: the
+// first line after the "MCPProxy Status" header is the FR-001 attention
+// count, in both the non-zero and "none" shapes.
+func TestStatusTable_NeedsAttentionFirstLine(t *testing.T) {
+ nonZero := 3
+ output := captureOutput(func() {
+ printStatusTable(&StatusInfo{State: "Running", Edition: "personal", NeedsAttention: &nonZero})
+ })
+ lines := strings.Split(strings.TrimRight(output, "\n"), "\n")
+ if len(lines) < 2 || lines[0] != "MCPProxy Status" {
+ t.Fatalf("expected header first, got: %q", lines)
+ }
+ if lines[1] != "Needs attention: 3 (run 'mcpproxy attention')" {
+ t.Errorf("unexpected second line: %q", lines[1])
+ }
+
+ zero := 0
+ output = captureOutput(func() {
+ printStatusTable(&StatusInfo{State: "Running", Edition: "personal", NeedsAttention: &zero})
+ })
+ lines = strings.Split(strings.TrimRight(output, "\n"), "\n")
+ if len(lines) < 2 || lines[1] != "Needs attention: none" {
+ t.Errorf("unexpected second line for zero count: %q", lines)
+ }
+
+ // An older daemon / unreachable endpoint (nil) omits the line entirely
+ // rather than lying with a count of 0.
+ output = captureOutput(func() {
+ printStatusTable(&StatusInfo{State: "Running", Edition: "personal"})
+ })
+ if strings.Contains(output, "Needs attention") {
+ t.Errorf("expected no attention line when NeedsAttention is nil, got: %q", output)
+ }
+}
+
+// TestDoctorAttentionSection_FirstAndDiagnosticsWording pins T056: doctor's
+// first section is "Needs attention (N)" from GET /attention, and the old
+// "Found N issues that need attention" line is now "Diagnostics: N findings"
+// (FR-004 — "need attention" refers only to the FR-001 count).
+func TestDoctorAttentionSection_FirstAndDiagnosticsWording(t *testing.T) {
+ doctorOutput = "pretty"
+ diag := map[string]interface{}{
+ "total_issues": 1,
+ "oauth_required": []interface{}{"github"},
+ }
+ output := captureOutput(func() {
+ if err := outputDiagnostics(diag, nil, nil, "", sampleAttentionResponse()); err != nil {
+ t.Fatalf("outputDiagnostics: %v", err)
+ }
+ })
+
+ attentionIdx := strings.Index(output, "Needs attention (2)")
+ diagnosticsIdx := strings.Index(output, "Diagnostics: 1 findings")
+ if attentionIdx == -1 {
+ t.Fatalf("missing 'Needs attention (2)' section, got:\n%s", output)
+ }
+ if diagnosticsIdx == -1 {
+ t.Fatalf("missing 'Diagnostics: 1 findings' line, got:\n%s", output)
+ }
+ if attentionIdx > diagnosticsIdx {
+ t.Errorf("attention section must come first, got attention@%d diagnostics@%d", attentionIdx, diagnosticsIdx)
+ }
+ if strings.Contains(output, "Found 1") {
+ t.Errorf("stale 'Found N issues' wording must not appear, got:\n%s", output)
+ }
+}
diff --git a/cmd/mcpproxy/doctor_cmd.go b/cmd/mcpproxy/doctor_cmd.go
index b6be15864..30a3afdc1 100644
--- a/cmd/mcpproxy/doctor_cmd.go
+++ b/cmd/mcpproxy/doctor_cmd.go
@@ -13,6 +13,7 @@ import (
"github.com/smart-mcp-proxy/mcpproxy-go/internal/cliclient"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/updatecheck"
)
@@ -128,6 +129,15 @@ func runDoctorClientMode(ctx context.Context, client *cliclient.Client, logger *
return fmt.Errorf("failed to get diagnostics from daemon: %w", err)
}
+ // Spec 109 FR-004: doctor's first section is the one needs-attention list
+ // every surface reads (FR-001). Non-fatal: an older or unreachable
+ // endpoint just omits the section rather than failing the whole command.
+ attention, attErr := client.GetAttention(ctx)
+ if attErr != nil {
+ logger.Debug("Failed to get attention list from daemon", zap.Error(attErr))
+ attention = nil
+ }
+
// Collect quarantine stats from servers
quarantineStats := collectQuarantineStats(ctx, client, logger)
@@ -156,9 +166,26 @@ func runDoctorClientMode(ctx context.Context, client *cliclient.Client, logger *
if doctorServerFilter != "" {
diag = filterDiagnosticsByServer(diag, doctorServerFilter)
quarantineStats = filterQuarantineStatsByServer(quarantineStats, doctorServerFilter)
+ attention = filterAttentionByServer(attention, doctorServerFilter)
}
- return outputDiagnostics(diag, info, quarantineStats, envHint)
+ return outputDiagnostics(diag, info, quarantineStats, envHint, attention)
+}
+
+// filterAttentionByServer narrows an attention response to the items whose
+// subject is the requested server. A single-server doctor run is not
+// interested in another server's attention items, or in client items at all.
+func filterAttentionByServer(resp *cliclient.AttentionResponse, serverName string) *cliclient.AttentionResponse {
+ if resp == nil {
+ return nil
+ }
+ items := make([]contracts.AttentionItem, 0, len(resp.Items))
+ for _, it := range resp.Items {
+ if it.Subject.Type == "server" && it.Subject.ID == serverName {
+ items = append(items, it)
+ }
+ }
+ return &cliclient.AttentionResponse{Count: len(items), GeneratedAt: resp.GeneratedAt, Items: items}
}
// filterDiagnosticsByServer returns a shallow copy of the diagnostics
@@ -271,7 +298,27 @@ func collectQuarantineStats(ctx context.Context, client *cliclient.Client, logge
return stats
}
-func outputDiagnostics(diag map[string]interface{}, info map[string]interface{}, quarantineStats []quarantineServerStats, envHint string) error {
+// printDoctorAttentionSection prints doctor's first section (FR-004): the
+// same needs-attention list FR-001 computes, rendered identically to
+// `mcpproxy attention`'s table rows. nil (endpoint unreachable, e.g. an
+// older daemon) prints nothing — the diagnostics sections below still run.
+func printDoctorAttentionSection(attention *cliclient.AttentionResponse) {
+ if attention == nil {
+ return
+ }
+ fmt.Printf("📋 Needs attention (%d)\n", attention.Count)
+ fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
+ if attention.Count == 0 {
+ fmt.Println("All clear")
+ } else {
+ for _, item := range attention.Items {
+ fmt.Printf(" [%s] %s (%s)\n", item.Kind, item.Summary, item.Fix.Label)
+ }
+ }
+ fmt.Println()
+}
+
+func outputDiagnostics(diag map[string]interface{}, info map[string]interface{}, quarantineStats []quarantineServerStats, envHint string, attention *cliclient.AttentionResponse) error {
switch doctorOutput {
case "json":
// Combine diagnostics with info for JSON output
@@ -287,6 +334,9 @@ func outputDiagnostics(diag map[string]interface{}, info map[string]interface{},
if envHint != "" {
combined["environment_warnings"] = []string{envHint}
}
+ if attention != nil {
+ combined["attention"] = attention
+ }
output, err := json.MarshalIndent(combined, "", " ")
if err != nil {
return fmt.Errorf("failed to format output: %w", err)
@@ -336,7 +386,17 @@ func outputDiagnostics(diag map[string]interface{}, info map[string]interface{},
}
fmt.Println()
- if totalIssues == 0 {
+ // Spec 109 FR-004: doctor's first section, from the same GET
+ // /attention every other surface reads (FR-001/FR-003).
+ printDoctorAttentionSection(attention)
+
+ // Fix review finding: the "all clear" verdict must also require the
+ // FR-001 attention list (a separate counter from `total_issues`) to
+ // be empty, or a quarantined server / tool awaiting review with no
+ // other diagnostics findings prints two contradicting verdicts back
+ // to back — exactly what FR-004 exists to prevent.
+ attentionClear := attention == nil || attention.Count == 0
+ if totalIssues == 0 && attentionClear {
fmt.Println("✅ All systems operational! No issues detected.")
fmt.Println()
@@ -359,12 +419,10 @@ func outputDiagnostics(diag map[string]interface{}, info map[string]interface{},
return nil
}
- // Show issue summary
- issueWord := "issue"
- if totalIssues > 1 {
- issueWord = "issues"
- }
- fmt.Printf("⚠️ Found %d %s that need attention\n", totalIssues, issueWord)
+ // Show issue summary. FR-004: "need attention" refers only to the
+ // FR-001 attention count above; the diagnostics count below is a
+ // different, separately named number.
+ fmt.Printf("Diagnostics: %d findings\n", totalIssues)
fmt.Println()
// 1. Upstream Connection Errors
diff --git a/cmd/mcpproxy/doctor_cmd_test.go b/cmd/mcpproxy/doctor_cmd_test.go
index c9be6c25d..a23d81322 100644
--- a/cmd/mcpproxy/doctor_cmd_test.go
+++ b/cmd/mcpproxy/doctor_cmd_test.go
@@ -8,7 +8,9 @@ import (
"strings"
"testing"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/cliclient"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/socket"
)
@@ -31,7 +33,7 @@ func TestOutputDiagnostics_JSONFormat(t *testing.T) {
defer func() { os.Stdout = oldStdout }()
doctorOutput = "json"
- err := outputDiagnostics(diag, nil, nil, "")
+ err := outputDiagnostics(diag, nil, nil, "", nil)
w.Close()
var buf bytes.Buffer
@@ -70,7 +72,7 @@ func TestOutputDiagnostics_PrettyFormat_NoIssues(t *testing.T) {
defer func() { os.Stdout = oldStdout }()
doctorOutput = "pretty"
- err := outputDiagnostics(diag, nil, nil, "")
+ err := outputDiagnostics(diag, nil, nil, "", nil)
w.Close()
var buf bytes.Buffer
@@ -90,6 +92,61 @@ func TestOutputDiagnostics_PrettyFormat_NoIssues(t *testing.T) {
}
}
+// TestOutputDiagnostics_PrettyFormat_ZeroDiagnosticsButAttentionPending
+// covers a review finding on Spec 109 FR-004: doctor must not print the
+// "All systems operational" verdict when the FR-001 attention list (a
+// separate counter from the diagnostics `total_issues`) still has pending
+// items, e.g. a quarantined server awaiting review with no other
+// diagnostics findings. It must also still print the "Diagnostics: N
+// findings" section rather than skipping it via the zero-issue early
+// return.
+func TestOutputDiagnostics_PrettyFormat_ZeroDiagnosticsButAttentionPending(t *testing.T) {
+ diag := map[string]interface{}{
+ "total_issues": 0,
+ }
+ attention := &cliclient.AttentionResponse{
+ Count: 1,
+ Items: []contracts.AttentionItem{
+ {
+ Kind: "server_review",
+ Summary: "github: waiting for review",
+ Fix: contracts.AttentionFix{Label: "Review"},
+ },
+ },
+ }
+
+ // Capture stdout
+ oldStdout := os.Stdout
+ r, w, _ := os.Pipe()
+ os.Stdout = w
+ defer func() { os.Stdout = oldStdout }()
+
+ doctorOutput = "pretty"
+ err := outputDiagnostics(diag, nil, nil, "", attention)
+
+ w.Close()
+ var buf bytes.Buffer
+ buf.ReadFrom(r)
+ output := buf.String()
+
+ if err != nil {
+ t.Errorf("outputDiagnostics() returned error: %v", err)
+ }
+
+ if strings.Contains(output, "All systems operational") {
+ t.Error("doctor must not claim all-clear while the attention list has pending items")
+ }
+ if !strings.Contains(output, "📋 Needs attention (1)") {
+ t.Error("Expected the FR-004 attention section header")
+ }
+ if !strings.Contains(output, "server_review") {
+ t.Error("Expected the pending attention item to be printed")
+ }
+ if !strings.Contains(output, "Diagnostics: 0 findings") {
+ t.Error("Expected the Diagnostics section to still print even with zero diagnostics findings")
+ }
+}
+
func TestOutputDiagnostics_PrettyFormat_WithUpstreamErrors(t *testing.T) {
diag := map[string]interface{}{
"total_issues": 2,
@@ -112,7 +169,7 @@ func TestOutputDiagnostics_PrettyFormat_WithUpstreamErrors(t *testing.T) {
defer func() { os.Stdout = oldStdout }()
doctorOutput = "pretty"
- err := outputDiagnostics(diag, nil, nil, "")
+ err := outputDiagnostics(diag, nil, nil, "", nil)
w.Close()
var buf bytes.Buffer
@@ -168,7 +225,7 @@ func TestOutputDiagnostics_PrettyFormat_WithOAuthRequired(t *testing.T) {
defer func() { os.Stdout = oldStdout }()
doctorOutput = "pretty"
- err := outputDiagnostics(diag, nil, nil, "")
+ err := outputDiagnostics(diag, nil, nil, "", nil)
w.Close()
var buf bytes.Buffer
@@ -214,7 +271,7 @@ func TestOutputDiagnostics_PrettyFormat_WithMissingSecrets(t *testing.T) {
defer func() { os.Stdout = oldStdout }()
doctorOutput = "pretty"
- err := outputDiagnostics(diag, nil, nil, "")
+ err := outputDiagnostics(diag, nil, nil, "", nil)
w.Close()
var buf bytes.Buffer
@@ -256,7 +313,7 @@ func TestOutputDiagnostics_PrettyFormat_WithRuntimeWarnings(t *testing.T) {
defer func() { os.Stdout = oldStdout }()
doctorOutput = "pretty"
- err := outputDiagnostics(diag, nil, nil, "")
+ err := outputDiagnostics(diag, nil, nil, "", nil)
w.Close()
var buf bytes.Buffer
@@ -310,7 +367,7 @@ func TestOutputDiagnostics_PrettyFormat_MultipleIssueTypes(t *testing.T) {
defer func() { os.Stdout = oldStdout }()
doctorOutput = "pretty"
- err := outputDiagnostics(diag, nil, nil, "")
+ err := outputDiagnostics(diag, nil, nil, "", nil)
w.Close()
var buf bytes.Buffer
@@ -335,12 +392,14 @@ func TestOutputDiagnostics_PrettyFormat_MultipleIssueTypes(t *testing.T) {
t.Error("Missing warnings section")
}
- // Verify issue count
+ // Verify issue count. Spec 109 FR-004: "need attention" refers only to
+ // the FR-001 attention count; doctor's own diagnostics count is now
+ // worded "Diagnostics: N findings" (no singular/plural distinction).
if !strings.Contains(output, "5") {
t.Error("Missing total issue count")
}
- if !strings.Contains(output, "issues") {
- t.Error("Should use plural 'issues' for count > 1")
+ if !strings.Contains(output, "Diagnostics: 5 findings") {
+ t.Error("Should print 'Diagnostics: N findings'")
}
}
@@ -357,7 +416,7 @@ func TestOutputDiagnostics_PrettyFormat_SingleIssue(t *testing.T) {
defer func() { os.Stdout = oldStdout }()
doctorOutput = "pretty"
- err := outputDiagnostics(diag, nil, nil, "")
+ err := outputDiagnostics(diag, nil, nil, "", nil)
w.Close()
var buf bytes.Buffer
@@ -368,9 +427,9 @@ func TestOutputDiagnostics_PrettyFormat_SingleIssue(t *testing.T) {
t.Errorf("outputDiagnostics() returned error: %v", err)
}
- // Should use singular "issue" not "issues"
- if !strings.Contains(output, "1 issue") {
- t.Error("Should use singular 'issue' for count = 1")
+ // Spec 109 FR-004: "Diagnostics: N findings", not "issue(s)".
+ if !strings.Contains(output, "Diagnostics: 1 findings") {
+ t.Error("Should print 'Diagnostics: 1 findings'")
}
}
@@ -387,7 +446,7 @@ func TestOutputDiagnostics_EmptyFormat(t *testing.T) {
// Empty string should default to pretty format
doctorOutput = ""
- err := outputDiagnostics(diag, nil, nil, "")
+ err := outputDiagnostics(diag, nil, nil, "", nil)
w.Close()
var buf bytes.Buffer
@@ -552,7 +611,7 @@ func TestOutputDiagnostics_WarningWithoutTitle(t *testing.T) {
defer func() { os.Stdout = oldStdout }()
doctorOutput = "pretty"
- err := outputDiagnostics(diag, nil, nil, "")
+ err := outputDiagnostics(diag, nil, nil, "", nil)
w.Close()
var buf bytes.Buffer
@@ -587,7 +646,7 @@ func TestOutputDiagnostics_HighSeverityWarning(t *testing.T) {
defer func() { os.Stdout = oldStdout }()
doctorOutput = "pretty"
- err := outputDiagnostics(diag, nil, nil, "")
+ err := outputDiagnostics(diag, nil, nil, "", nil)
w.Close()
var buf bytes.Buffer
@@ -625,7 +684,7 @@ func TestOutputDiagnostics_SecretWithoutOptionalFields(t *testing.T) {
defer func() { os.Stdout = oldStdout }()
doctorOutput = "pretty"
- err := outputDiagnostics(diag, nil, nil, "")
+ err := outputDiagnostics(diag, nil, nil, "", nil)
w.Close()
var buf bytes.Buffer
@@ -666,7 +725,7 @@ func TestOutputDiagnostics_MissingSecretsRealJSON(t *testing.T) {
defer func() { os.Stdout = oldStdout }()
doctorOutput = "pretty"
- err := outputDiagnostics(diag, nil, nil, "")
+ err := outputDiagnostics(diag, nil, nil, "", nil)
w.Close()
var buf bytes.Buffer
diff --git a/cmd/mcpproxy/main.go b/cmd/mcpproxy/main.go
index c7742ac4b..30f671986 100644
--- a/cmd/mcpproxy/main.go
+++ b/cmd/mcpproxy/main.go
@@ -185,6 +185,9 @@ func main() {
// Add status command
statusCmd := GetStatusCommand()
+ // Add attention command (Spec 109 FR-001/FR-003)
+ attentionCmd := GetAttentionCommand()
+
// Add token command (Spec 028: Agent tokens)
tokenCmd := GetTokenCommand()
@@ -218,6 +221,7 @@ func main() {
rootCmd.AddCommand(activityCmd)
rootCmd.AddCommand(tuiCmd)
rootCmd.AddCommand(statusCmd)
+ rootCmd.AddCommand(attentionCmd)
rootCmd.AddCommand(tokenCmd)
rootCmd.AddCommand(telemetryCmd)
rootCmd.AddCommand(dbCmd)
diff --git a/cmd/mcpproxy/status_cmd.go b/cmd/mcpproxy/status_cmd.go
index 574efad8f..cf6c032d3 100644
--- a/cmd/mcpproxy/status_cmd.go
+++ b/cmd/mcpproxy/status_cmd.go
@@ -38,6 +38,11 @@ type StatusInfo struct {
LaunchedBy string `json:"launched_by,omitempty"` // Spec 092 FR-001a; empty = user-launched/unknown or older daemon
Update *StatusUpdateInfo `json:"update,omitempty"`
ServerEditionInfo *ServerEditionStatusInfo `json:"server_edition,omitempty"`
+ // NeedsAttention is the FR-001 attention count (Spec 109), the same
+ // number `mcpproxy attention` and every other surface reads. nil when the
+ // daemon could not be reached or is too old to serve GET /attention —
+ // contracts/cli.md's first line is then omitted rather than printed as 0.
+ NeedsAttention *int `json:"needs_attention,omitempty"`
// TokenSavings is nil when the daemon could not be reached, the caller is
// not an administrator (agent token), or the tokenizer is unavailable —
// the "Token savings:" summary line (Spec 109-k, contracts/cli.md) is then
@@ -264,6 +269,14 @@ func collectStatusFromDaemon(cfg *config.Config, client *cliclient.Client, socke
}
}
+ // Spec 109 FR-001/FR-003: the same needs-attention count every surface
+ // reads. Non-fatal — an older daemon (or a transient failure) just omits
+ // the first line rather than failing the whole status call.
+ if attResp, attErr := client.GetAttention(ctx); attErr == nil {
+ count := attResp.Count
+ info.NeedsAttention = &count
+ }
+
// Get info data (version, web_ui_url)
infoData, err := client.GetInfo(ctx)
if err == nil {
@@ -579,6 +592,16 @@ func printStatusJSON(info *StatusInfo) error {
func printStatusTable(info *StatusInfo) {
fmt.Println("MCPProxy Status")
+ // Spec 109 FR-001/FR-003 (contracts/cli.md): fixed first line after the
+ // header, so parallel PRs' own lines land at a stable position.
+ if info.NeedsAttention != nil {
+ if *info.NeedsAttention > 0 {
+ fmt.Printf("Needs attention: %d (run 'mcpproxy attention')\n", *info.NeedsAttention)
+ } else {
+ fmt.Println("Needs attention: none")
+ }
+ }
+
fmt.Printf(" %-12s %s\n", "State:", info.State)
fmt.Printf(" %-12s %s\n", "Edition:", info.Edition)
diff --git a/docs/features/needs-attention.md b/docs/features/needs-attention.md
new file mode 100644
index 000000000..98864c007
--- /dev/null
+++ b/docs/features/needs-attention.md
@@ -0,0 +1,97 @@
+---
+id: needs-attention
+title: Needs Attention
+sidebar_label: Needs Attention
+sidebar_position: 15
+description: The one needs-attention list every MCPProxy surface reads from — sign-in prompts, reviews, errors, missing secrets, configuration problems and unseen clients.
+keywords: [attention, needs attention, health, quarantine, review, sign in, dashboard, home, tray, cli, doctor]
+---
+
+# Needs Attention
+
+MCPProxy computes **one** needs-attention list from in-memory state — server
+health, tool approval counts, connect status, session history — and every
+surface renders it verbatim: the Web UI's Home page, the macOS tray's "Needs
+Attention" group and Home section, and the CLI's `attention` command, the
+first line of `status`, and the first section of `doctor`.
+
+No surface derives its own version of this list. Before this list existed,
+the Web UI Dashboard, the macOS tray and the CLI each had their own predicate
+over server health, and the three disagreed about what counted as "needs
+attention" — a quarantined server with a transport fault, for example, could
+show as an error on one surface and a review item on another. This page is
+the parity table for how the list is computed, its kinds, ranks and fixes.
+
+## Where it lives
+
+| Surface | Location |
+|---|---|
+| REST API | `GET /api/v1/attention` |
+| Web UI | Home page (`/`) attention list, header pill (hidden at 0), sidebar Home badge |
+| macOS tray | "Needs Attention (N)" menu group, Home section (first, above the topology) |
+| CLI | `mcpproxy attention`; first line of `mcpproxy status`; first section of `mcpproxy doctor` |
+| SSE | `attention.changed` event (`{count, ids}`, narrowed per subscriber) |
+
+## Kinds, ranks and fixes
+
+The list is sorted by rank ascending, then by subject name. Each item's `id`
+is stable (`kind:type:subject[:state]`), so a surface can diff successive
+lists instead of re-rendering the whole thing on every change.
+
+| Kind | Rank | Condition | Fix |
+|---|---|---|---|
+| `sign_in_required` | 10 | `health.status == sign_in_required` (includes a quarantined OAuth server, or a failed token refresh) | `login` → opens the server, which offers Sign in |
+| `missing_secret` | 20 | `health.status == needs_secret` | `set_secret` → opens the server's secret form |
+| `config_error` | 30 | `health.status == needs_config` | `configure`/`edit_url` → opens the server's Configuration tab, field focused |
+| `server_error` | 40 | Not quarantined, and `health.status` has been `error` or `connecting` for ≥ 60 seconds | `restart` → the server menu action; logs are one click away |
+| `server_review` | 50 | `admin_state == quarantined` (and enabled) | `review` → opens the review location — never a one-click approve |
+| `tool_review` | 60 (changed) / 61 (pending) | A trusted server with ≥ 1 tool in that approval state; one item per state, so a server with both is two items | `review` → opens the review location, filtered to that state |
+| `client_never_seen` | 70 | A client MCPProxy recorded as connected ≥ 5 minutes ago, with no MCP session observed since | `reload_hint` → shows how to restart the client |
+
+Never an item: a disabled server, a server `connecting` for under 60 seconds,
+a `ready` server whatever its proactive `actions` (a login nudge for a token
+expiring soon is not a blocking state), and update availability (that has its
+own nudge). A quarantined server is *only* a `server_review` item, never also
+`server_error` — restarting a server before it is reviewed fixes nothing, and
+the review screen already shows the transport error.
+
+Conditions key on `health.status` and `admin_state`, never on the presence of
+an entry in `actions` — `actions` also carries proactive nudges on a
+perfectly usable server (the same `status`/`usable`/`actions` vocabulary every
+surface renders `health` through) and cannot by itself distinguish a blocking
+state from a hint.
+
+## Fixes are never a one-click approve
+
+A `review` fix always **opens a screen**; it never calls an approve or
+unquarantine endpoint directly from the list. Reviewing a quarantined server
+or a changed/pending tool is a decision a person makes on that server's own
+review screen, with the diff or the transport error in front of them — not a
+button on a summary row. This is the same rule the macOS tray already
+enforced for its per-server actions, extended to every surface.
+
+## Live updates
+
+The backend recomputes the list (debounced) whenever the underlying state
+changes, and separately arms a timer for the earliest pending time-based
+threshold — a server crossing from `connecting` to `server_error` at 60
+seconds, or a client crossing into `client_never_seen` at 5 minutes — so the
+list is correct even on a quiet instance where nothing else happens to
+trigger a recompute. Every surface holding a live connection (the Web UI and
+the macOS tray, both over Server-Sent Events) receives an `attention.changed`
+notification and refreshes; the CLI and `GET /api/v1/attention` always read
+the current computed list.
+
+Testing the `client_never_seen` threshold live means waiting out the real 5
+minutes unless it is shrunk first: set the test-only environment variable
+`MCPPROXY_ATTENTION_NEVER_SEEN_AFTER` (e.g. `5s`) before starting the daemon
+to override the threshold for that run. An unset or unparseable value keeps
+the 5-minute default; there is no equivalent hook for the 60-second
+`server_error` threshold.
+
+## Scoped callers
+
+An administrator (the API key, the local socket, or a server-edition admin
+user) sees every item. A scoped caller — an agent token, or a non-admin
+server-edition user session — sees only items whose subject is a server it
+may enumerate, and never sees a client item at all.
diff --git a/frontend/src/components/AttentionList.vue b/frontend/src/components/AttentionList.vue
new file mode 100644
index 000000000..593c7f334
--- /dev/null
+++ b/frontend/src/components/AttentionList.vue
@@ -0,0 +1,55 @@
+
+
+
+ Needs attention ({{ attentionStore.items.length }})
+
+
+
+
- No upstream MCP servers are configured yet, so there is nothing to chart. - Add one and this page will show call volume, token sinks, error rates and a timeline. -
-