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) => (