Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
5bfdf56
feat(health): add status vocabulary (status/usable/actions)
Dumbris Sep 25, 2026
5a96b1d
chore(types): regenerate contracts.ts and swagger for health status
Dumbris Sep 25, 2026
4a0c63b
feat(web): render health.status label, never level, as text
Dumbris Sep 25, 2026
7880d8a
feat(cli): upstream list STATUS/ACTION from status vocabulary, --stat…
Dumbris Sep 25, 2026
58906b3
feat(macos): decode health status vocabulary, stop rendering level as…
Dumbris Sep 25, 2026
9b1d88b
docs(api): document health.status/usable/actions (Spec 109 FR-010-012)
Dumbris Sep 25, 2026
b13eaf2
fix: address review round 1
Dumbris Sep 25, 2026
22de7d6
fix: address review round 2
Dumbris Sep 25, 2026
aa3c0a8
fix: address review round 3
Dumbris Sep 25, 2026
9d28723
fix: address review round 4
Dumbris Sep 25, 2026
ee92498
fix: address review round 5
Dumbris Sep 25, 2026
69a4505
fix: address review round 6
Dumbris Sep 25, 2026
00a2c68
feat(runtime): compute the one needs-attention list (Spec 109 FR-001/…
Dumbris Sep 25, 2026
3bc32bb
feat(httpapi): serve GET /api/v1/attention, scope SSE attention.changed
Dumbris Sep 25, 2026
97c4ee2
chore(types): regenerate swagger and contracts.ts for /attention
Dumbris Sep 25, 2026
4bd378d
feat(cli): add 'mcpproxy attention', status/doctor lines (Spec 109 FR…
Dumbris Sep 25, 2026
b0f27c7
feat(web): Home page needs-attention list, header pill, sidebar badge…
Dumbris Sep 25, 2026
e9a0e78
feat(macos): needs-attention list in tray and Home (Spec 109 FR-001/F…
Dumbris Sep 25, 2026
40a7a25
docs(features): document the needs-attention list (Spec 109)
Dumbris Sep 25, 2026
c859a1d
fix: address review round 1 on needs-attention (Spec 109 FR-001..007)
Dumbris Sep 26, 2026
944dc6f
fix: address review round 5
Dumbris Sep 26, 2026
33a0ab3
fix(ci): mock bulk-op methods and exempt attention Detail from doorscan
Dumbris Sep 26, 2026
7defdd7
merge: bring main into 109-d-needs-attention (retargeted from 109-c)
Dumbris Sep 26, 2026
6b4cbc7
fix: address round-1 review findings on needs-attention (F1,F2,F3,F5,…
Dumbris Sep 26, 2026
0b16e6a
Merge remote-tracking branch 'origin/main' into 109-d-needs-attention
Dumbris Sep 26, 2026
ba2470d
chore(109-d): sync Needs Attention with main
Dumbris Sep 27, 2026
494233c
fix(attention): close review and home gaps (Spec 109-d)
Dumbris Sep 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions cmd/generate-types/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
107 changes: 107 additions & 0 deletions cmd/mcpproxy/attention_cmd.go
Original file line number Diff line number Diff line change
@@ -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
}
172 changes: 172 additions & 0 deletions cmd/mcpproxy/attention_cmd_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading