Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
46 changes: 43 additions & 3 deletions internal/cli/generic_table.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 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
// by reflection.
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
51 changes: 51 additions & 0 deletions internal/cli/generic_table_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 Plugins -> MCP",
"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}

Expand Down
Loading