From c17cec96c01d90580bebdcda9bbf9ccde11075c3 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Wed, 1 Jul 2026 21:46:19 -0700 Subject: [PATCH 1/2] fix: render mcp server create details --- internal/cli/generic_table.go | 46 +++++++++++++++++++++++++-- internal/cli/generic_table_test.go | 51 ++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 3 deletions(-) diff --git a/internal/cli/generic_table.go b/internal/cli/generic_table.go index 6b22fd6..89f1062 100644 --- a/internal/cli/generic_table.go +++ b/internal/cli/generic_table.go @@ -20,6 +20,8 @@ const maxHeuristicColumns = 8 // can't blow out the table width. const genericStringMaxWidth = 40 +const mcpPerUserOAuthNotice = "Note: registered but not usable until OAuth is completed in Flashduty Customize -> Connectors; tools will not appear until authorized." + // instantLike mirrors go-flashduty's Timestamp/TimestampMilli (and the output // package's unexported instant) so the renderer can recognise timestamp fields // by reflection. @@ -62,15 +64,18 @@ func renderGenericTable(ctx *RunContext, data any) error { if rows, total, ok := listEnvelope(v); ok { return renderRowTable(ctx, rows, total) } - return renderVertical(ctx, v) + if err := renderVertical(ctx, v); err != nil { + return err + } + return renderMcpPerUserOAuthNotice(ctx, v) default: return jsonFallback(ctx, data) } } // listEnvelope reports whether struct v is a paginated list envelope: exactly -// one exported field that is a slice of structs (the rows), with the remaining -// fields being pagination metadata. It returns the rows value and the total +// one exported field that is a slice of structs (the rows), with any remaining +// fields limited to pagination metadata. It returns the rows value and the total // (the int field named "Total" when present, else the row count). func listEnvelope(v reflect.Value) (rows reflect.Value, total int, ok bool) { t := v.Type() @@ -92,6 +97,9 @@ func listEnvelope(v reflect.Value) (rows reflect.Value, total int, ok bool) { if total < 0 && f.Name == "Total" && fv.CanInt() { total = int(fv.Int()) } + if !isListMetadataField(f, fv) { + return reflect.Value{}, 0, false + } } if !found { return reflect.Value{}, 0, false @@ -102,6 +110,22 @@ func listEnvelope(v reflect.Value) (rows reflect.Value, total int, ok bool) { return rows, total, true } +func isListMetadataField(f reflect.StructField, fv reflect.Value) bool { + if f.Anonymous && f.Name == "ListOptions" { + return true + } + switch f.Name { + case "Total": + return fv.CanInt() + case "HasNextPage": + return fv.Kind() == reflect.Bool + case "SearchAfterCtx", "NextCursor": + return fv.Kind() == reflect.String + default: + return false + } +} + // isRowSlice reports whether t is a slice whose element (after pointer deref) is // a struct — i.e. a table-able row collection. func isRowSlice(t reflect.Type) bool { @@ -210,6 +234,22 @@ func renderVertical(ctx *RunContext, v reflect.Value) error { return ctx.Printer.Print(rows, cols) } +func renderMcpPerUserOAuthNotice(ctx *RunContext, v reflect.Value) error { + if !isMcpPerUserOAuth(v) { + return nil + } + _, err := fmt.Fprintln(ctx.Writer, mcpPerUserOAuthNotice) + return err +} + +func isMcpPerUserOAuth(v reflect.Value) bool { + if v.Type().Name() != "McpServerItem" { + return false + } + auth := v.FieldByName("AuthMode") + return auth.IsValid() && auth.Kind() == reflect.String && auth.String() == "per_user_oauth" +} + // derefStruct dereferences pointer chains and returns the underlying struct // reflect.Value. The second return is false when item is nil, not a struct after // dereferencing, or any pointer in the chain is nil. diff --git a/internal/cli/generic_table_test.go b/internal/cli/generic_table_test.go index eb6262e..a3f5d03 100644 --- a/internal/cli/generic_table_test.go +++ b/internal/cli/generic_table_test.go @@ -144,6 +144,57 @@ func TestRenderGenericTable_DetailVertical(t *testing.T) { } } +func TestRenderGenericTable_McpServerItemWithEmptyToolsRendersDetail(t *testing.T) { + var buf bytes.Buffer + item := &flashduty.McpServerItem{ + ServerID: "mcp_test", + ServerName: "github", + Description: "GitHub connector", + AuthMode: "shared", + Status: "enabled", + Transport: "streamable-http", + URL: "https://mcp.example.com/github", + Tools: []flashduty.McpToolInfo{}, + } + if err := renderGenericTable(tableCtx(&buf), item); err != nil { + t.Fatalf("render: %v", err) + } + got := buf.String() + if strings.Contains(got, "No results.") { + t.Fatalf("single MCP server item with empty tools was rendered as an empty list:\n%s", got) + } + for _, want := range []string{"FIELD", "VALUE", "SERVER_ID", "mcp_test", "SERVER_NAME", "github"} { + if !strings.Contains(got, want) { + t.Errorf("MCP server detail output missing %q\n---\n%s", want, got) + } + } +} + +func TestRenderGenericTable_McpServerPerUserOAuthNotice(t *testing.T) { + var buf bytes.Buffer + item := &flashduty.McpServerItem{ + ServerID: "mcp_oauth", + ServerName: "github", + Description: "GitHub connector", + AuthMode: "per_user_oauth", + Status: "enabled", + Transport: "streamable-http", + URL: "https://mcp.example.com/github", + } + if err := renderGenericTable(tableCtx(&buf), item); err != nil { + t.Fatalf("render: %v", err) + } + got := buf.String() + for _, want := range []string{ + "registered but not usable until OAuth is completed in Flashduty Customize -> Connectors", + "tools will not appear until authorized", + } { + if !strings.Contains(got, want) { + t.Errorf("per-user OAuth notice missing %q\n---\n%s", want, got) + } + } +} + func TestPrintGenericResult_StructuredUnchanged(t *testing.T) { resp := &fakeListResp{Items: []heuristicRow{{Name: "alpha", Count: 7}}, Total: 1} From 2951723675995caa55883d829951c51a03f8a409 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Wed, 1 Jul 2026 22:06:29 -0700 Subject: [PATCH 2/2] fix: point oauth notice to plugins mcp --- internal/cli/generic_table.go | 2 +- internal/cli/generic_table_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/cli/generic_table.go b/internal/cli/generic_table.go index 89f1062..6e33023 100644 --- a/internal/cli/generic_table.go +++ b/internal/cli/generic_table.go @@ -20,7 +20,7 @@ const maxHeuristicColumns = 8 // can't blow out the table width. const genericStringMaxWidth = 40 -const mcpPerUserOAuthNotice = "Note: registered but not usable until OAuth is completed in Flashduty Customize -> Connectors; tools will not appear until authorized." +const mcpPerUserOAuthNotice = "Note: registered but not usable until OAuth is completed in Flashduty Plugins -> MCP; tools will not appear until authorized." // instantLike mirrors go-flashduty's Timestamp/TimestampMilli (and the output // package's unexported instant) so the renderer can recognise timestamp fields diff --git a/internal/cli/generic_table_test.go b/internal/cli/generic_table_test.go index a3f5d03..73afcd4 100644 --- a/internal/cli/generic_table_test.go +++ b/internal/cli/generic_table_test.go @@ -186,7 +186,7 @@ func TestRenderGenericTable_McpServerPerUserOAuthNotice(t *testing.T) { } got := buf.String() for _, want := range []string{ - "registered but not usable until OAuth is completed in Flashduty Customize -> Connectors", + "registered but not usable until OAuth is completed in Flashduty Plugins -> MCP", "tools will not appear until authorized", } { if !strings.Contains(got, want) {