feat(web): gate header tabs on advertised server capabilities (#1516) (#1520)

Header navigation tabs now appear only when the connected server
advertises the corresponding server capability (from the MCP
`initialize` result `capabilities`), instead of being shown
unconditionally or proxied through list content.

Previously the Tools and Logs tabs were always shown when connected,
regardless of whether the server advertised `tools` / `logging`. The
content-gated tabs (Prompts/Resources/Tasks) only appeared correct
because an unsupported capability yields an empty list — but a server
advertising a capability with a currently-empty list would have its
tab wrongly hidden.

InspectorView.availableTabs now gates each server-capability tab on
the matching field of `initializeResult.capabilities` (already
threaded from App via the handshake):

  Tools     -> capabilities.tools
  Logs      -> capabilities.logging
  Prompts   -> capabilities.prompts
  Resources -> capabilities.resources
  Tasks     -> capabilities.tasks
  Apps      -> capabilities.tools AND >=1 app tool (Apps is a filtered
               view of tools, so it keeps its content check)

Client-side sections are never gated on server capabilities: Servers
and History are always available, and Network stays transport-gated
(hidden for stdio). Tabs recompute live when reconnecting to a server
with a different capability set.

Tests: reworked the content-gating component tests into
capability-gating tests (including the Tools-hidden regression, Logs
present/absent, capability-present-but-empty-list, History-always, and
a reconnect-recomputes test). Connected story/fixtures advertise a
full capability set so the Connected story still shows every tab.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-06-26 10:34:06 -04:00 • committed by GitHub
commit 6d07f6ec75
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 230 additions and 111 deletions

View file

@ -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" },
};

View file

@ -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(
<InspectorView
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
connectionStatus: "connected",
// No `tools` capability — only logging is advertised.
initializeResult: initWithCapabilities({ logging: {} }),
// A non-empty tool list must not override the missing capability.
tools: [{ name: "echo", inputSchema: { type: "object" } }],
})}
/>,
);
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(
<InspectorView
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
connectionStatus: "connected",
initializeResult: initWithCapabilities({ tools: {} }),
tools: [],
})}
/>,
);
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(
<InspectorView
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
connectionStatus: "connected",
initializeResult: initWithCapabilities({ tools: {} }),
})}
/>,
);
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(
<InspectorView
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
connectionStatus: "connected",
initializeResult: initWithCapabilities({ logging: {} }),
})}
/>,
);
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(
<InspectorView
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
connectionStatus: "connected",
// Empty capability set: every server-capability tab is hidden.
initializeResult: initWithCapabilities({}),
})}
/>,
);
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(
<InspectorView
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
connectionStatus: "connected",
// Logging only — no tools capability, even though an app tool is
// present in the (stale/optimistic) list.
initializeResult: initWithCapabilities({ logging: {} }),
tools: [sampleAppTool],
})}
/>,
);
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(
<InspectorView
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
connectionStatus: "connected",
initializeResult: connectedInit,
prompts: [],
// Advertise tools but not prompts.
initializeResult: initWithCapabilities({ tools: {} }),
// Content is irrelevant to gating now — even a populated list stays
// hidden when the capability is absent.
prompts: [samplePrompt],
})}
/>,
);
@ -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(
<InspectorView
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
connectionStatus: "connected",
initializeResult: connectedInit,
prompts: [],
})}
/>,
);
let radios = await screen.findAllByRole("radio");
expect(radios.map((r) => r.getAttribute("value"))).not.toContain("Prompts");
rerender(
<InspectorView
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
connectionStatus: "connected",
initializeResult: connectedInit,
prompts: [samplePrompt],
})}
/>,
);
await waitFor(async () => {
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("shows the Prompts tab when the server advertises prompts even with an empty list", async () => {
renderWithMantine(
<InspectorView
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
connectionStatus: "connected",
initializeResult: connectedInit,
resources: [],
resourceTemplates: [],
initializeResult: initWithCapabilities({ prompts: {} }),
// No prompts yet — the tab is still available because the server
// advertises the capability (#1516).
prompts: [],
})}
/>,
);
const radios = await screen.findAllByRole("radio");
expect(radios.map((r) => r.getAttribute("value"))).toContain("Prompts");
});
it("hides the Resources tab when the server does not advertise the resources capability", async () => {
renderWithMantine(
<InspectorView
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
connectionStatus: "connected",
initializeResult: initWithCapabilities({ tools: {} }),
// Populated lists are ignored when the capability is absent.
resources: [sampleResource],
resourceTemplates: [{ uriTemplate: "file:///{path}", name: "Files" }],
})}
/>,
);
@ -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(
<InspectorView
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
connectionStatus: "connected",
initializeResult: connectedInit,
initializeResult: initWithCapabilities({ resources: {} }),
resources: [],
resourceTemplates: [{ uriTemplate: "file:///{path}", name: "Files" }],
resourceTemplates: [],
})}
/>,
);
@ -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(
<InspectorView
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
connectionStatus: "connected",
initializeResult: connectedInit,
resources: [],
resourceTemplates: [],
})}
/>,
);
let radios = await screen.findAllByRole("radio");
expect(radios.map((r) => r.getAttribute("value"))).not.toContain(
"Resources",
);
rerender(
<InspectorView
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
connectionStatus: "connected",
initializeResult: connectedInit,
resources: [sampleResource],
resourceTemplates: [],
})}
/>,
);
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(
<InspectorView
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
connectionStatus: "connected",
initializeResult: connectedInit,
tasks: [],
initializeResult: initWithCapabilities({ tools: {} }),
// An existing task is ignored when the capability is absent.
tasks: [sampleTask],
})}
/>,
);
@ -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(
<InspectorView
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
connectionStatus: "connected",
initializeResult: connectedInit,
tasks: [sampleTask],
initializeResult: initWithCapabilities({ tasks: {} }),
tasks: [],
})}
/>,
);
@ -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(
<InspectorView
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
connectionStatus: "connected",
initializeResult: connectedInit,
tasks: [],
initializeResult: initWithCapabilities({ tools: {}, tasks: {} }),
})}
/>,
);
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(
<InspectorView
{...makeProps({
servers: [sampleServer],
activeServer: "alpha",
connectionStatus: "connected",
initializeResult: connectedInit,
tasks: [sampleTask],
initializeResult: initWithCapabilities({ tools: {}, logging: {} }),
})}
/>,
);
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.

View file

@ -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<string[]>(() => {
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