diff --git a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx
index 108fdc987..13e4fb81c 100644
--- a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx
+++ b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx
@@ -294,9 +294,17 @@ const demoNetwork: FetchRequestEntry[] = [
},
];
+// Advertise every primitive capability so the Connected story shows the full
+// tab list — each header tab is gated on the matching capability field (#1516).
const demoInitializeResult: InitializeResult = {
protocolVersion: "2025-06-18",
- capabilities: {},
+ capabilities: {
+ tools: {},
+ prompts: {},
+ resources: {},
+ logging: {},
+ tasks: {},
+ },
serverInfo: { name: "Local Dev Server", version: "1.2.0" },
};
diff --git a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx
index 18e1be168..102e3da9e 100644
--- a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx
+++ b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx
@@ -5,6 +5,7 @@ import type {
InitializeResult,
Prompt,
Resource,
+ ServerCapabilities,
Task,
Tool,
} from "@modelcontextprotocol/sdk/types.js";
@@ -154,12 +155,34 @@ const sampleServer: ServerEntry = {
connection: { status: "disconnected" },
};
+// A server that advertises every primitive capability. Each header tab is
+// gated on the matching capability field (#1516), so most connected-mode
+// tests use this fixture to make the corresponding tabs present; the
+// capability-gating tests below override `capabilities` to drop or restore
+// individual fields.
+const allCapabilities: ServerCapabilities = {
+ tools: {},
+ prompts: {},
+ resources: {},
+ logging: {},
+ tasks: {},
+};
+
const connectedInit: InitializeResult = {
protocolVersion: "2025-06-18",
- capabilities: {},
+ capabilities: allCapabilities,
serverInfo: { name: "Alpha", version: "1.0.0" },
};
+// Builds an initialize result with a specific capability set, otherwise
+// identical to `connectedInit`. Used by the capability-gating tests to assert
+// a tab appears/disappears purely on the advertised capability.
+function initWithCapabilities(
+ capabilities: ServerCapabilities,
+): InitializeResult {
+ return { ...connectedInit, capabilities };
+}
+
// A tool the `isAppTool` filter recognizes (it carries `_meta.ui.resourceUri`),
// so its presence in the tool list makes the Apps tab available (#1450).
const sampleAppTool: Tool = {
@@ -169,9 +192,9 @@ const sampleAppTool: Tool = {
_meta: { ui: { resourceUri: "ui://apps/ops" } },
};
-// Prompts, Resources, and Tasks tabs are content-gated like Apps (#1450):
-// each is hidden until its list has an entry. These fixtures populate the
-// lists so the associated tab is available.
+// Prompts, Resources, and Tasks tabs are gated on the server's advertised
+// capability (#1516), not on content. These fixtures populate the lists where
+// a test needs an entry rendered on the screen (e.g. list-changed indicator).
const samplePrompt: Prompt = { name: "greet" };
const sampleResource: Resource = {
uri: "file:///readme.md",
@@ -380,6 +403,116 @@ describe("InspectorView", () => {
expect(labels).toContain("Network");
});
+ it("hides the Tools tab when the server does not advertise the tools capability", async () => {
+ renderWithMantine(
+ ,
+ );
+ const radios = await screen.findAllByRole("radio");
+ const labels = radios.map((r) => r.getAttribute("value"));
+ expect(labels).not.toContain("Tools");
+ // Sibling capability is independent — Logs is present, Apps stays hidden
+ // (Apps build on the tools capability).
+ expect(labels).toContain("Logs");
+ expect(labels).not.toContain("Apps");
+ });
+
+ it("shows the Tools tab when the server advertises tools even with an empty list", async () => {
+ renderWithMantine(
+ ,
+ );
+ const radios = await screen.findAllByRole("radio");
+ expect(radios.map((r) => r.getAttribute("value"))).toContain("Tools");
+ });
+
+ it("hides the Logs tab when the server does not advertise the logging capability", async () => {
+ renderWithMantine(
+ ,
+ );
+ const radios = await screen.findAllByRole("radio");
+ const labels = radios.map((r) => r.getAttribute("value"));
+ expect(labels).toContain("Tools");
+ expect(labels).not.toContain("Logs");
+ });
+
+ it("shows the Logs tab when the server advertises the logging capability", async () => {
+ renderWithMantine(
+ ,
+ );
+ const radios = await screen.findAllByRole("radio");
+ expect(radios.map((r) => r.getAttribute("value"))).toContain("Logs");
+ });
+
+ it("keeps History available regardless of advertised server capabilities", async () => {
+ // History is a local client-side log — never gated on server capabilities.
+ renderWithMantine(
+ ,
+ );
+ const radios = await screen.findAllByRole("radio");
+ const labels = radios.map((r) => r.getAttribute("value"));
+ expect(labels).toContain("Servers");
+ expect(labels).toContain("History");
+ expect(labels).not.toContain("Tools");
+ expect(labels).not.toContain("Logs");
+ });
+
+ it("hides the Apps tab when app tools exist but the server omits the tools capability", async () => {
+ renderWithMantine(
+ ,
+ );
+ const radios = await screen.findAllByRole("radio");
+ expect(radios.map((r) => r.getAttribute("value"))).not.toContain("Apps");
+ });
+
it("filters tools to apps and auto-launches a no-fields app on the Apps tab", async () => {
const user = userEvent.setup();
// Plain (non-app) tool plus a tool with a malformed UI resource URI
@@ -535,15 +668,18 @@ describe("InspectorView", () => {
expect(screen.getByDisplayValue("Servers")).toBeInTheDocument();
});
- it("hides the Prompts tab when the server exposes no prompts", async () => {
+ it("hides the Prompts tab when the server does not advertise the prompts capability", async () => {
renderWithMantine(
,
);
@@ -553,48 +689,35 @@ describe("InspectorView", () => {
expect(labels).not.toContain("Prompts");
});
- it("reveals the Prompts tab live when a prompt arrives via list-changed refresh", async () => {
- const { rerender } = renderWithMantine(
+ it("shows the Prompts tab when the server advertises prompts even with an empty list", async () => {
+ renderWithMantine(
,
);
- let radios = await screen.findAllByRole("radio");
- expect(radios.map((r) => r.getAttribute("value"))).not.toContain("Prompts");
-
- rerender(
- ,
- );
- await waitFor(async () => {
- radios = await screen.findAllByRole("radio");
- expect(radios.map((r) => r.getAttribute("value"))).toContain("Prompts");
- });
+ const radios = await screen.findAllByRole("radio");
+ expect(radios.map((r) => r.getAttribute("value"))).toContain("Prompts");
});
- it("hides the Resources tab when the server exposes no resources or templates", async () => {
+ it("hides the Resources tab when the server does not advertise the resources capability", async () => {
renderWithMantine(
,
);
@@ -604,16 +727,16 @@ describe("InspectorView", () => {
expect(labels).not.toContain("Resources");
});
- it("shows the Resources tab when the server exposes only resource templates", async () => {
+ it("shows the Resources tab when the server advertises resources even with empty lists", async () => {
renderWithMantine(
,
);
@@ -621,51 +744,16 @@ describe("InspectorView", () => {
expect(radios.map((r) => r.getAttribute("value"))).toContain("Resources");
});
- it("reveals the Resources tab live when a resource arrives via list-changed refresh", async () => {
- const { rerender } = renderWithMantine(
- ,
- );
- let radios = await screen.findAllByRole("radio");
- expect(radios.map((r) => r.getAttribute("value"))).not.toContain(
- "Resources",
- );
-
- rerender(
- ,
- );
- await waitFor(async () => {
- radios = await screen.findAllByRole("radio");
- expect(radios.map((r) => r.getAttribute("value"))).toContain("Resources");
- });
- });
-
- it("hides the Tasks tab when the server has created no tasks", async () => {
+ it("hides the Tasks tab when the server does not advertise the tasks capability", async () => {
renderWithMantine(
,
);
@@ -675,15 +763,15 @@ describe("InspectorView", () => {
expect(labels).not.toContain("Tasks");
});
- it("shows the Tasks tab when at least one task exists", async () => {
+ it("shows the Tasks tab when the server advertises tasks even with no tasks yet", async () => {
renderWithMantine(
,
);
@@ -691,35 +779,40 @@ describe("InspectorView", () => {
expect(radios.map((r) => r.getAttribute("value"))).toContain("Tasks");
});
- it("reveals the Tasks tab live when a task is created", async () => {
+ it("recomputes tabs from the new capability set when reconnecting to a different server", async () => {
+ // First server advertises tasks but not logging.
const { rerender } = renderWithMantine(
,
);
let radios = await screen.findAllByRole("radio");
- expect(radios.map((r) => r.getAttribute("value"))).not.toContain("Tasks");
+ let labels = radios.map((r) => r.getAttribute("value"));
+ expect(labels).toContain("Tasks");
+ expect(labels).not.toContain("Logs");
+ // Reconnect to a server that advertises logging but not tasks — the tabs
+ // recompute purely from the new capability set.
rerender(
,
);
await waitFor(async () => {
radios = await screen.findAllByRole("radio");
- expect(radios.map((r) => r.getAttribute("value"))).toContain("Tasks");
+ labels = radios.map((r) => r.getAttribute("value"));
+ expect(labels).toContain("Logs");
+ expect(labels).not.toContain("Tasks");
});
});
@@ -1025,7 +1118,8 @@ describe("InspectorView", () => {
activeServer: "alpha",
connectionStatus: "connected",
initializeResult: connectedInit,
- // An app tool is required for the Apps tab to be available (#1450).
+ // An app tool is required for the Apps tab to be available — Apps
+ // keeps a content check on top of the tools capability (#1516).
tools: [sampleAppTool],
toolsListChanged: true,
})}
@@ -1043,7 +1137,8 @@ describe("InspectorView", () => {
activeServer: "alpha",
connectionStatus: "connected",
initializeResult: connectedInit,
- // A prompt is required for the Prompts tab to be available (#1450).
+ // connectedInit advertises prompts, so the tab is available; the
+ // prompt populates the screen so the indicator has a list to mark.
prompts: [samplePrompt],
promptsListChanged: true,
})}
@@ -1061,7 +1156,8 @@ describe("InspectorView", () => {
activeServer: "alpha",
connectionStatus: "connected",
initializeResult: connectedInit,
- // A resource is required for the Resources tab to be available (#1450).
+ // connectedInit advertises resources, so the tab is available; the
+ // resource populates the screen so the indicator has a list to mark.
resources: [sampleResource],
resourcesListChanged: true,
})}
@@ -1079,7 +1175,7 @@ describe("InspectorView", () => {
activeServer: "alpha",
connectionStatus: "connected",
initializeResult: connectedInit,
- // A prompt is required for the Prompts tab to be available (#1450).
+ // connectedInit advertises prompts, so the Prompts tab is available.
prompts: [samplePrompt],
// Tools changed, but Prompts did not — the Prompts screen must
// stay quiet.
diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx
index 98195c54c..124879651 100644
--- a/clients/web/src/components/views/InspectorView/InspectorView.tsx
+++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx
@@ -496,45 +496,60 @@ export function InspectorView({
});
}, [tools]);
- // Only show the non-Servers tabs when actually connected. Network is
- // additionally hidden for stdio servers — there is no HTTP traffic to
- // surface there, so the tab would always be empty. Apps, Prompts,
- // Resources, and Tasks are content-gated (#1450): each is hidden unless its
- // list has at least one entry, so an empty screen is never reachable.
- // Resources is gated on resources OR templates, since a server may expose
- // only templates; Tasks appear once a task-augmented tool call creates one
- // (the "run as task" affordance lives on the Tools screen, gated by the
- // server's task support). These memo dependencies make the tabs
- // appear/disappear live as the lists change (list-changed refresh, server
- // switch) — when app tools exist but the sandbox is unavailable the Apps
- // tab stays visible so its "unavailable" message remains reachable. Users
- // who want to inspect a server's advertised capabilities regardless of
- // current contents can open the Connection Info modal.
+ // Only show the non-Servers tabs when actually connected. Each
+ // server-capability tab is gated on the matching field of the server's
+ // advertised `capabilities` (from the MCP `initialize` result), not on
+ // current content (#1516): a server that advertises a capability but
+ // currently has zero items still shows its tab, and a server that doesn't
+ // advertise the capability never does — even if a stale/optimistic list is
+ // briefly non-empty. The capability object rides along on `initializeResult`
+ // (App builds it from the handshake), which is only truthy while connected.
+ //
+ // Tools → capabilities.tools
+ // Logs → capabilities.logging
+ // Prompts → capabilities.prompts
+ // Resources → capabilities.resources
+ // Tasks → capabilities.tasks (the "run as task" affordance on the
+ // Tools screen separately keys off tasks.requests.tools.call)
+ // Apps → capabilities.tools (MCP Apps build on tools) AND at least one
+ // app tool — Apps is a filtered view of tools, not its own
+ // capability, so it keeps the content check; when app tools
+ // exist but the sandbox is unavailable the tab stays visible so
+ // its "unavailable" message remains reachable.
+ //
+ // Network is hidden for stdio servers (no HTTP traffic to surface).
+ // Servers and History are never capability-gated — History is a local
+ // client-side log, and any future client capabilities (sampling /
+ // elicitation / roots) are inspector-offered, not server-advertised, so
+ // they must not be gated here either. These memo dependencies make the tabs
+ // recompute live as capabilities change (server switch, reconnect).
const availableTabs = useMemo(() => {
if (connectionStatus !== "connected") return [SERVERS_TAB];
const active = serversInput.find((s) => s.id === activeServer);
const isStdio = active ? getServerType(active.config) === "stdio" : false;
- const hasApps = appTools.length > 0;
- const hasPrompts = prompts.length > 0;
- const hasResources = resources.length > 0 || resourceTemplates.length > 0;
- const hasTasks = tasks.length > 0;
+ const capabilities = initializeResult?.capabilities;
+ const hasTools = capabilities?.tools !== undefined;
+ const hasApps = hasTools && appTools.length > 0;
+ const hasPrompts = capabilities?.prompts !== undefined;
+ const hasResources = capabilities?.resources !== undefined;
+ const hasTasks = capabilities?.tasks !== undefined;
+ const hasLogging = capabilities?.logging !== undefined;
return ALL_TABS.filter((t) => {
if (t === NETWORK_TAB && isStdio) return false;
+ if (t === "Tools" && !hasTools) return false;
if (t === "Apps" && !hasApps) return false;
if (t === "Prompts" && !hasPrompts) return false;
if (t === "Resources" && !hasResources) return false;
if (t === "Tasks" && !hasTasks) return false;
+ if (t === "Logs" && !hasLogging) return false;
return true;
});
}, [
connectionStatus,
serversInput,
activeServer,
+ initializeResult,
appTools,
- prompts,
- resources,
- resourceTemplates,
- tasks,
]);
// Clamp the rendered tab to whatever's currently available. If the user