From 9ca18f87bd0b4b53f824f87c39a4b29f0f6dc4ca Mon Sep 17 00:00:00 2001 From: Jihad Irfansyah Date: Sat, 1 Aug 2026 10:52:39 +0700 Subject: [PATCH] Prevent tools page crash on ungrouped tools --- internal/tools/register.go | 4 +++- internal/tools/register_test.go | 20 ++++++++++++++++++++ web/src/lib/toolPayload.test.mjs | 15 +++++++++++++++ web/src/lib/toolPayload.ts | 3 +++ web/src/pages/ToolsPage.tsx | 13 +++++++------ 5 files changed, 48 insertions(+), 7 deletions(-) create mode 100644 internal/tools/register_test.go create mode 100644 web/src/lib/toolPayload.test.mjs create mode 100644 web/src/lib/toolPayload.ts diff --git a/internal/tools/register.go b/internal/tools/register.go index 2b1827be..11058464 100644 --- a/internal/tools/register.go +++ b/internal/tools/register.go @@ -75,7 +75,9 @@ func init() { // ToolsetsFor reports which named toolsets include a tool. func ToolsetsFor(name string) []string { - var out []string + // Keep this non-nil so JSON clients receive [] rather than null for dynamic + // tools (for example MCP tools) that are not members of a named preset. + out := []string{} for set, members := range Toolsets { if set == "all" { continue diff --git a/internal/tools/register_test.go b/internal/tools/register_test.go new file mode 100644 index 00000000..8658b073 --- /dev/null +++ b/internal/tools/register_test.go @@ -0,0 +1,20 @@ +package tools + +import ( + "encoding/json" + "testing" +) + +func TestToolsetsForUnknownToolEncodesAsEmptyArray(t *testing.T) { + sets := ToolsetsFor("mcp__dynamic__not-in-a-preset") + if sets == nil || len(sets) != 0 { + t.Fatalf("ToolsetsFor unknown tool = %#v, want non-nil empty slice", sets) + } + got, err := json.Marshal(sets) + if err != nil { + t.Fatal(err) + } + if string(got) != "[]" { + t.Fatalf("JSON = %s, want []", got) + } +} diff --git a/web/src/lib/toolPayload.test.mjs b/web/src/lib/toolPayload.test.mjs new file mode 100644 index 00000000..4d541593 --- /dev/null +++ b/web/src/lib/toolPayload.test.mjs @@ -0,0 +1,15 @@ +import { describe, expect, test } from 'bun:test' +import { toolsetsOrEmpty } from './toolPayload.ts' + +describe('tool payload normalization', () => { + test('turns null or missing toolsets into an empty array', () => { + expect(toolsetsOrEmpty(null)).toEqual([]) + expect(toolsetsOrEmpty(undefined)).toEqual([]) + }) + + test('keeps valid toolset arrays', () => { + const sets = ['default', 'coding'] + expect(toolsetsOrEmpty(sets)).toBe(sets) + expect(toolsetsOrEmpty(sets).slice(0, 4)).toEqual(sets) + }) +}) diff --git a/web/src/lib/toolPayload.ts b/web/src/lib/toolPayload.ts new file mode 100644 index 00000000..fac85d42 --- /dev/null +++ b/web/src/lib/toolPayload.ts @@ -0,0 +1,3 @@ +export function toolsetsOrEmpty(value: string[] | null | undefined): string[] { + return Array.isArray(value) ? value : [] +} diff --git a/web/src/pages/ToolsPage.tsx b/web/src/pages/ToolsPage.tsx index 7133cb79..bb948b10 100644 --- a/web/src/pages/ToolsPage.tsx +++ b/web/src/pages/ToolsPage.tsx @@ -7,18 +7,19 @@ import { PageLayout } from '@/components/layout/PageLayout' import { Badge, EmptyState, Input, Switch } from '@/components/ui/primitives' import { SkeletonList } from '@/components/ui/skeleton' import { useI18n } from '@/lib/i18n' +import { toolsetsOrEmpty } from '@/lib/toolPayload' interface ToolInfo { name: string description: string enabled: boolean requires_approval: boolean - toolsets: string[] + toolsets: string[] | null } interface ToolsResponse { toolset: string - toolsets: string[] + toolsets: string[] | null tools: ToolInfo[] } @@ -71,7 +72,7 @@ export default function ToolsPage() { {t('tools.activeToolset')}

- {data.toolsets.map((preset) => ( + {toolsetsOrEmpty(data.toolsets).map((preset) => (