diff --git a/clients/launcher/README.md b/clients/launcher/README.md
index 280f5e75f7..c4707dad72 100644
--- a/clients/launcher/README.md
+++ b/clients/launcher/README.md
@@ -35,8 +35,8 @@ transport and sets the era that connection negotiates, so a non-legacy era
needs no `mcp.json` entry.
**Seed contents are web-specific.** When the web backend creates a missing
-writable catalog it seeds `DEFAULT_SEED_CONFIG` (`core/mcp/serverList.ts`) — a
-`filesystem-server-default` scoped to `/tmp`, the canonical
+writable catalog it seeds `defaultSeedConfig` (`core/mcp/serverList.ts`) — a
+`filesystem-server-default` scoped to the platform temp dir (`os.tmpdir()`), the canonical
`everything-server-default`, and `example-server-default`, the MCP org's
remote feature-reference server (Streamable HTTP, no local process and no API
key needed) — so a first launch has something to connect to.
diff --git a/clients/tui/__tests__/App.test.tsx b/clients/tui/__tests__/App.test.tsx
index 0d15556b35..712df66bf8 100644
--- a/clients/tui/__tests__/App.test.tsx
+++ b/clients/tui/__tests__/App.test.tsx
@@ -1164,6 +1164,49 @@ describe("App (status, layout, modals)", () => {
expect(f).toContain("alpha");
});
+ it("lists tools alphabetically by name, as the web sidebar does (#2827)", async () => {
+ h.ctrl.status = "connected";
+ h.ctrl.tools = [
+ { ...sampleTool, name: "zeta" },
+ { ...sampleTool, name: "tool_10" },
+ { ...sampleTool, name: "Beta" },
+ { ...sampleTool, name: "tool_2" },
+ ];
+ const r = await mount(oneStdio());
+ await press(r, ["t"]);
+ await expectFrame(r, "Tools (4)");
+ const f = r.lastFrame() ?? "";
+ const order = ["Beta", "tool_2", "tool_10", "zeta"].map((n) =>
+ f.indexOf(n),
+ );
+ expect(order.every((i) => i >= 0)).toBe(true);
+ expect(order).toEqual([...order].sort((a, b) => a - b));
+ });
+
+ it("keeps the tool selection across an unrelated update such as a stderr line (#2827)", async () => {
+ h.ctrl.status = "connected";
+ h.ctrl.tools = [
+ { ...sampleTool, name: "zeta" },
+ { ...sampleTool, name: "beta" },
+ ];
+ const r = await mount(oneStdio());
+ await press(r, ["t", TAB, DOWN]);
+ await expectFrame(r, "▶ zeta");
+ // A new stderr array re-derives the server state; the sorted tools must
+ // keep their identity, or ToolsTab resets the cursor to the top.
+ h.ctrl.stderrLogs = [stderrLog];
+ r.rerender(
+ ,
+ );
+ await tick();
+ await tick();
+ expect(r.lastFrame() ?? "").toContain("▶ zeta");
+ });
+
it("opens the tool test modal with Enter from the list pane", async () => {
h.ctrl.status = "connected";
h.ctrl.tools = [sampleTool];
diff --git a/clients/tui/__tests__/formHeader.test.ts b/clients/tui/__tests__/formHeader.test.ts
new file mode 100644
index 0000000000..f196390504
--- /dev/null
+++ b/clients/tui/__tests__/formHeader.test.ts
@@ -0,0 +1,22 @@
+import { describe, it, expect } from "vitest";
+import { untitledForm } from "../src/utils/formHeader.js";
+import { schemaToForm } from "../src/utils/schemaToForm.js";
+
+describe("untitledForm (#2827)", () => {
+ it("drops the title the modal header already shows, keeping the sections", () => {
+ const form = schemaToForm(
+ { type: "object", properties: { q: { type: "string" } } },
+ "search",
+ );
+ expect(form.title).toBe("Test Tool: search");
+ const body = untitledForm(form);
+ expect(body.title).toBeUndefined();
+ expect(body.sections).toBe(form.sections);
+ });
+
+ it("does not mutate the form the header reads", () => {
+ const form = schemaToForm(null, "noParams");
+ untitledForm(form);
+ expect(form.title).toBe("Test Tool: noParams");
+ });
+});
diff --git a/clients/tui/__tests__/tui-servers.test.ts b/clients/tui/__tests__/tui-servers.test.ts
index 00ace5fccd..cb59652f9a 100644
--- a/clients/tui/__tests__/tui-servers.test.ts
+++ b/clients/tui/__tests__/tui-servers.test.ts
@@ -75,8 +75,8 @@ describe("loadTuiServers", () => {
it("builds a single ad-hoc server from a positional target", async () => {
const servers = await loadTuiServers({ target: ["my-server", "--flag"] });
- expect(Object.keys(servers)).toEqual(["default"]);
- expect(servers.default?.config).toMatchObject({
+ expect(Object.keys(servers)).toEqual(["my-server"]);
+ expect(servers["my-server"]?.config).toMatchObject({
type: "stdio",
command: "my-server",
args: ["--flag"],
diff --git a/clients/tui/src/App.tsx b/clients/tui/src/App.tsx
index 1960a222ca..cd88fa4829 100644
--- a/clients/tui/src/App.tsx
+++ b/clients/tui/src/App.tsx
@@ -44,6 +44,7 @@ import {
} from "@inspector/core/mcp/node/index.js";
import { useInspectorClient } from "@inspector/core/react/useInspectorClient.js";
import { useManagedTools } from "@inspector/core/react/useManagedTools.js";
+import { compareToolsByName } from "@inspector/core/mcp/toolOrder.js";
import { useManagedResources } from "@inspector/core/react/useManagedResources.js";
import { useManagedResourceTemplates } from "@inspector/core/react/useManagedResourceTemplates.js";
import { useManagedPrompts } from "@inspector/core/react/useManagedPrompts.js";
@@ -429,9 +430,10 @@ function App({
}),
...(savedSettings && { serverSettings: savedSettings }),
// Advertise the roots configured for this server in mcp.json, as web
- // and the CLI do. The TUI has no roots editor, but a user who set
- // roots in the web UI expects them to apply to the same server here
- // (#1797).
+ // and the CLI do (#1797). This is the starting list: the roots
+ // editor (#2432, `e` on the Info tab) changes it for this session
+ // through `InspectorClient.setRoots`, but never writes mcp.json, so
+ // these configured roots return on the next launch.
roots: cleanRoots(savedSettings?.roots ?? []),
// Per-server protocol era (SEP §7.8) from mcp.json → SDK
// versionNegotiation; absent era defaults to legacy (#1626).
@@ -697,6 +699,15 @@ function App({
selectedInspectorClient,
selectedManagedToolsState,
);
+ // Alphabetical by the name each row shows, the order the web Tools sidebar
+ // uses (#2782, #2827) — not the server's `tools/list` order. A copy (the
+ // hook's array is the store's snapshot), memoized on that snapshot alone:
+ // ToolsTab resets its selection when its list's identity changes, so a
+ // fresh array on an unrelated update (a stderr line) would lose the cursor.
+ const sortedTools = useMemo(
+ () => [...managedTools].sort(compareToolsByName),
+ [managedTools],
+ );
// Resources, resource templates, prompts from managed state managers
const selectedManagedResourcesState = useMemo(
@@ -1272,7 +1283,7 @@ function App({
resources: managedResources,
resourceTemplates: managedResourceTemplates,
prompts: managedPrompts,
- tools: managedTools,
+ tools: sortedTools,
stderrLogs: inspectorStderrLogs, // InspectorClient manages this
};
}, [
@@ -1286,7 +1297,7 @@ function App({
managedResources,
managedResourceTemplates,
managedPrompts,
- managedTools,
+ sortedTools,
inspectorStderrLogs,
]);
diff --git a/clients/tui/src/components/PromptTestModal.tsx b/clients/tui/src/components/PromptTestModal.tsx
index a78eec4927..317623bb31 100644
--- a/clients/tui/src/components/PromptTestModal.tsx
+++ b/clients/tui/src/components/PromptTestModal.tsx
@@ -7,6 +7,7 @@ import type { Prompt, GetPromptResult } from "@modelcontextprotocol/client";
import { promptArgsToForm } from "../utils/promptArgsToForm.js";
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
import { redactErrorText, redactedJson } from "../utils/errorText.js";
+import { untitledForm } from "../utils/formHeader.js";
// Helper to extract error message from various error types
function getErrorMessage(error: unknown): string {
@@ -221,7 +222,7 @@ export function PromptTestModal({
)}