diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx
index 3489f6fe..a482bee5 100644
--- a/clients/web/src/App.tsx
+++ b/clients/web/src/App.tsx
@@ -2316,6 +2316,7 @@ function App() {
onRefreshResources={onRefreshResources}
onCompleteArgument={onCompleteArgument}
completionsSupported={capabilities?.completions !== undefined}
+ subscriptionsSupported={capabilities?.resources?.subscribe === true}
onTasksUiChange={setTasksUi}
onCancelTask={(taskId) => {
void onCancelTask(taskId);
diff --git a/clients/web/src/components/groups/ResourceControls/ResourceControls.stories.tsx b/clients/web/src/components/groups/ResourceControls/ResourceControls.stories.tsx
index a35420d8..81a581b0 100644
--- a/clients/web/src/components/groups/ResourceControls/ResourceControls.stories.tsx
+++ b/clients/web/src/components/groups/ResourceControls/ResourceControls.stories.tsx
@@ -145,6 +145,25 @@ export const EmptySectionCollapsed: Story = {
},
};
+// Server does not advertise resources.subscribe: the Subscriptions section is
+// omitted entirely, leaving only URIs and Templates (#1478).
+export const SubscriptionsUnsupported: Story = {
+ args: {
+ resources: sampleResources,
+ templates: sampleTemplates,
+ subscriptions: sampleSubscriptions,
+ subscriptionsSupported: false,
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ expect(canvas.queryByText(/Subscriptions/)).not.toBeInTheDocument();
+ // Only the two remaining sections render, both open → both chevrons down.
+ const transforms = chevronTransforms(canvasElement);
+ expect(transforms).toHaveLength(2);
+ for (const t of transforms) expect(t).toBe(ROTATED_DOWN);
+ },
+};
+
// Many URIs with sparse Templates/Subscriptions: under the old equal `/ n`
// height split, URIs scrolled while the others left their share unused. Now the
// sections size to content inside one bounded scroll region.
diff --git a/clients/web/src/components/groups/ResourceControls/ResourceControls.test.tsx b/clients/web/src/components/groups/ResourceControls/ResourceControls.test.tsx
index 261833d4..c56e231a 100644
--- a/clients/web/src/components/groups/ResourceControls/ResourceControls.test.tsx
+++ b/clients/web/src/components/groups/ResourceControls/ResourceControls.test.tsx
@@ -267,6 +267,58 @@ describe("ResourceControls", () => {
expect(onOpenSectionsChange.mock.calls[0][0]).not.toContain("templates");
});
+ it("hides the Subscriptions section when subscriptionsSupported is false", () => {
+ renderWithMantine(
+ ,
+ );
+ expect(screen.getByText("URIs (2)")).toBeInTheDocument();
+ expect(screen.getByText("Templates (1)")).toBeInTheDocument();
+ expect(screen.queryByText(/Subscriptions/)).not.toBeInTheDocument();
+ });
+
+ it("shows the Subscriptions section by default (subscriptionsSupported omitted)", () => {
+ renderWithMantine();
+ expect(screen.getByText("Subscriptions (1)")).toBeInTheDocument();
+ });
+
+ it("reads 'Collapse all' with subscriptions hidden when the two visible sections are open", () => {
+ // allSections drops "subscriptions", so the remaining two open sections
+ // must still count as fully expanded — even if persisted openSections
+ // still carries a stale "subscriptions" entry.
+ renderWithMantine(
+ ,
+ );
+ expect(
+ screen.getByRole("button", { name: "Collapse all" }),
+ ).toBeInTheDocument();
+ });
+
+ it("drops a stale 'subscriptions' entry from persisted state when subscriptions are unsupported", async () => {
+ // A "subscriptions" value persisted from a prior subscription-capable
+ // session must not be perpetually re-appended once the section is no longer
+ // rendered — toggling a visible section should emit it out of the open set.
+ const user = userEvent.setup();
+ const onOpenSectionsChange = vi.fn();
+ renderWithMantine(
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: /Templates \(1\)/ }));
+ expect(onOpenSectionsChange).toHaveBeenCalledTimes(1);
+ expect(onOpenSectionsChange.mock.calls[0][0]).not.toContain(
+ "subscriptions",
+ );
+ });
+
it("filters by resource title when title is set", async () => {
const user = userEvent.setup();
const resourcesWithTitle: Resource[] = [
diff --git a/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx b/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx
index 108aff35..7c71a0db 100644
--- a/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx
+++ b/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx
@@ -21,6 +21,13 @@ export interface ResourceControlsProps {
resources: Resource[];
templates: ResourceTemplate[];
subscriptions: InspectorResourceSubscription[];
+ /**
+ * Whether the connected server advertises the `resources.subscribe`
+ * capability. When false, the Subscriptions accordion section is hidden
+ * entirely. Defaults to true so the section renders unless a caller
+ * explicitly marks subscriptions unsupported.
+ */
+ subscriptionsSupported?: boolean;
selectedUri?: string;
selectedTemplateUri?: string;
// Search text + accordion open-sections are controlled by the parent (App,
@@ -63,6 +70,7 @@ export function ResourceControls({
resources,
templates,
subscriptions,
+ subscriptionsSupported = true,
selectedUri,
selectedTemplateUri,
searchText = "",
@@ -97,15 +105,25 @@ export function ResourceControls({
s.resource.uri.toLowerCase().includes(query),
);
- const allSections = ["resources", "templates", "subscriptions"];
+ // Subscriptions are only meaningful when the server advertises the
+ // `resources.subscribe` capability; otherwise the section is omitted
+ // entirely (no header, no panel) — see #1478.
+ const allSections = subscriptionsSupported
+ ? ["resources", "templates", "subscriptions"]
+ : ["resources", "templates"];
// Open-sections is parent-controlled (persists across navigation). When the
// parent hasn't set it yet (undefined), fall back to the persisted `compact`
- // preference: empty when last left compact, all three open when expanded.
+ // preference: empty when last left compact, all sections open when expanded.
// Per-section accordion clicks update the lifted value but don't change the
// persisted preference.
const openSections =
controlledOpenSections ?? (initialCompact ? [] : [...allSections]);
- const allExpanded = openSections.length === allSections.length;
+ // Persisted open-sections may still carry "subscriptions" from a prior
+ // subscription-capable session, so compare only the sections we actually
+ // render when deciding whether everything is expanded.
+ const allExpanded =
+ openSections.filter((section) => allSections.includes(section)).length ===
+ allSections.length;
// Empty sections have a disabled control and nothing to show, so keep them
// out of the accordion's open set — they render collapsed (chevron points
@@ -118,15 +136,20 @@ export function ResourceControls({
subscriptions: filteredSubscriptions.length,
};
const visibleOpenSections = openSections.filter(
- (section) => (sectionItemCounts[section] ?? 0) > 0,
+ (section) =>
+ allSections.includes(section) && (sectionItemCounts[section] ?? 0) > 0,
);
// Open-in-intent but currently empty (so excluded from the accordion's
// `value`). Mantine derives the next open-array by toggling the clicked
// section against the `value` we hand it, which omits these — so without
// merging them back, toggling any populated section would silently drop an
// empty section's intent and it wouldn't reappear once it has items again.
+ // Restricted to `allSections` so a stale "subscriptions" entry persisted from
+ // a prior subscription-capable session isn't perpetually re-appended once the
+ // section is no longer rendered — it's dropped from persisted state instead.
const intendedButEmptySections = openSections.filter(
- (section) => !visibleOpenSections.includes(section),
+ (section) =>
+ allSections.includes(section) && !visibleOpenSections.includes(section),
);
function handleOpenSectionsChange(next: string[]) {
// Safe to append unconditionally: empty-section controls are `disabled`, so
@@ -240,28 +263,35 @@ export function ResourceControls({
-
-
- {formatSectionCount("Subscriptions", filteredSubscriptions.length)}
-
-
-
- {filteredSubscriptions.map((sub) => (
- onUnsubscribeResource(sub.resource.uri)}
- />
- ))}
-
-
-
+ {subscriptionsSupported && (
+
+
+ {formatSectionCount(
+ "Subscriptions",
+ filteredSubscriptions.length,
+ )}
+
+
+
+ {filteredSubscriptions.map((sub) => (
+
+ onUnsubscribeResource(sub.resource.uri)
+ }
+ />
+ ))}
+
+
+
+ )}
);
diff --git a/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.stories.tsx b/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.stories.tsx
index b4ce17e8..84b83209 100644
--- a/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.stories.tsx
+++ b/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.stories.tsx
@@ -89,6 +89,25 @@ export const NotSubscribed: Story = {
},
};
+export const SubscriptionsUnsupported: Story = {
+ args: {
+ resource: {
+ name: "data.json",
+ uri: "file:///data.json",
+ },
+ contents: [
+ {
+ uri: "file:///data.json",
+ mimeType: "application/json",
+ text: JSON.stringify({ status: "active" }, null, 2),
+ },
+ ],
+ isSubscribed: false,
+ // Server does not advertise resources.subscribe — only Refresh shows.
+ subscriptionsSupported: false,
+ },
+};
+
export const WithAnnotations: Story = {
args: {
resource: {
diff --git a/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.test.tsx b/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.test.tsx
index 82528b79..75ae8e87 100644
--- a/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.test.tsx
+++ b/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.test.tsx
@@ -109,6 +109,30 @@ describe("ResourcePreviewPanel", () => {
expect(onUnsubscribe).toHaveBeenCalledTimes(1);
});
+ it("hides the Subscribe button when subscriptionsSupported is false", () => {
+ renderWithMantine(
+ ,
+ );
+ expect(
+ screen.queryByRole("button", { name: "Subscribe" }),
+ ).not.toBeInTheDocument();
+ // Refresh stays available regardless of subscription support.
+ expect(screen.getByRole("button", { name: "Refresh" })).toBeInTheDocument();
+ });
+
+ it("hides the Unsubscribe button when subscriptionsSupported is false even if subscribed", () => {
+ renderWithMantine(
+ ,
+ );
+ expect(
+ screen.queryByRole("button", { name: "Unsubscribe" }),
+ ).not.toBeInTheDocument();
+ });
+
it("invokes onRefresh when Refresh is clicked", async () => {
const user = userEvent.setup();
const onRefresh = vi.fn();
diff --git a/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.tsx b/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.tsx
index 181cd1d8..7c5ce5f7 100644
--- a/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.tsx
+++ b/clients/web/src/components/groups/ResourcePreviewPanel/ResourcePreviewPanel.tsx
@@ -24,6 +24,12 @@ export interface ResourcePreviewPanelProps {
contents: (TextResourceContents | BlobResourceContents)[];
lastUpdated?: Date;
isSubscribed: boolean;
+ /**
+ * Whether the connected server advertises the `resources.subscribe`
+ * capability. When false, the Subscribe/Unsubscribe button is hidden.
+ * Defaults to true so the button renders unless explicitly unsupported.
+ */
+ subscriptionsSupported?: boolean;
onRefresh: () => void;
onSubscribe: () => void;
onUnsubscribe: () => void;
@@ -168,6 +174,7 @@ export function ResourcePreviewPanel({
contents,
lastUpdated,
isSubscribed,
+ subscriptionsSupported = true,
onRefresh,
onSubscribe,
onUnsubscribe,
@@ -223,10 +230,12 @@ export function ResourcePreviewPanel({
-
+ {subscriptionsSupported && (
+
+ )}
diff --git a/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx b/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx
index f60d57b4..127f43c6 100644
--- a/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx
+++ b/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx
@@ -63,7 +63,7 @@ export interface AppsUiState {
const ScreenLayout = Flex.withProps({
variant: "screen",
- h: "calc(100vh - var(--app-shell-header-height, 0px))",
+ h: "calc(100dvh - var(--app-shell-header-height, 0px))",
gap: "md",
p: "xl",
align: "flex-start",
diff --git a/clients/web/src/components/screens/HistoryScreen/HistoryScreen.tsx b/clients/web/src/components/screens/HistoryScreen/HistoryScreen.tsx
index a9199587..65fd4407 100644
--- a/clients/web/src/components/screens/HistoryScreen/HistoryScreen.tsx
+++ b/clients/web/src/components/screens/HistoryScreen/HistoryScreen.tsx
@@ -39,7 +39,7 @@ export interface HistoryUiState {
const ScreenLayout = Flex.withProps({
variant: "screen",
- h: "calc(100vh - var(--app-shell-header-height, 0px))",
+ h: "calc(100dvh - var(--app-shell-header-height, 0px))",
gap: "md",
p: "xl",
});
diff --git a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx
index 284043d6..60754e3f 100644
--- a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx
+++ b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx
@@ -27,7 +27,7 @@ export interface LogsUiState {
const ScreenLayout = Flex.withProps({
variant: "screen",
- h: "calc(100vh - var(--app-shell-header-height, 0px))",
+ h: "calc(100dvh - var(--app-shell-header-height, 0px))",
gap: "md",
p: "xl",
});
diff --git a/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx
index ebc0996f..65b88df2 100644
--- a/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx
+++ b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx
@@ -32,7 +32,7 @@ export interface NetworkUiState {
const ScreenLayout = Flex.withProps({
variant: "screen",
- h: "calc(100vh - var(--app-shell-header-height, 0px))",
+ h: "calc(100dvh - var(--app-shell-header-height, 0px))",
gap: "md",
p: "xl",
});
diff --git a/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx b/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx
index d10f1686..aa0ff10b 100644
--- a/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx
+++ b/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx
@@ -59,7 +59,7 @@ export interface PromptsUiState {
const ScreenLayout = Flex.withProps({
variant: "screen",
- h: "calc(100vh - var(--app-shell-header-height, 0px))",
+ h: "calc(100dvh - var(--app-shell-header-height, 0px))",
gap: "md",
p: "xl",
});
@@ -104,7 +104,7 @@ const EmptyState = Text.withProps({
});
const SCROLL_MAX_HEIGHT =
- "calc(100vh - var(--app-shell-header-height, 0px) - var(--mantine-spacing-xl) * 2)";
+ "calc(100dvh - var(--app-shell-header-height, 0px) - var(--mantine-spacing-xl) * 2)";
function hasArguments(prompt: Prompt): boolean {
return !!prompt.arguments && prompt.arguments.length > 0;
diff --git a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.stories.tsx b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.stories.tsx
index 6d0095a4..b7da682e 100644
--- a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.stories.tsx
+++ b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.stories.tsx
@@ -222,6 +222,21 @@ export const AllSections: Story = {
},
};
+// Server without the resources.subscribe capability: the Subscriptions
+// accordion section is omitted and no Subscribe button appears (#1478).
+export const SubscriptionsUnsupported: Story = {
+ args: {
+ resources: sampleResources,
+ templates: sampleTemplates,
+ subscriptions: sampleSubscriptions,
+ subscriptionsSupported: false,
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ expect(canvas.queryByText(/Subscriptions/)).not.toBeInTheDocument();
+ },
+};
+
const manyResources: Resource[] = Array.from({ length: 40 }, (_, i) => ({
name: `resource-${String(i + 1).padStart(2, "0")}.wav`,
uri: `file:///kit/resource-${i + 1}.wav`,
diff --git a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.test.tsx b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.test.tsx
index ebc4b5bf..dd88e908 100644
--- a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.test.tsx
+++ b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.test.tsx
@@ -317,4 +317,27 @@ describe("ResourcesScreen", () => {
await user.click(screen.getByRole("button", { name: "Unsubscribe" }));
expect(onUnsubscribeResource).toHaveBeenCalledWith("file:///x");
});
+
+ it("hides the Subscriptions section and Subscribe button when subscriptionsSupported is false", async () => {
+ const user = userEvent.setup();
+ renderWithMantine(
+ ,
+ );
+ // Sidebar Subscriptions accordion section is gone.
+ expect(screen.queryByText(/Subscriptions/)).not.toBeInTheDocument();
+ // Opening a resource preview shows Refresh but no Subscribe button.
+ await user.click(screen.getByText("x.txt"));
+ expect(screen.getByRole("button", { name: "Refresh" })).toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: "Subscribe" }),
+ ).not.toBeInTheDocument();
+ });
});
diff --git a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx
index d98bed36..fc38f38c 100644
--- a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx
+++ b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx
@@ -35,6 +35,13 @@ export interface ResourcesScreenProps {
ui: ResourcesUiState;
listChanged: boolean;
completionsSupported?: boolean;
+ /**
+ * Whether the connected server advertises the `resources.subscribe`
+ * capability. When false, the Subscribe/Unsubscribe button and the
+ * Subscriptions accordion section are hidden. Defaults to true so the
+ * controls render unless a caller explicitly marks them unsupported.
+ */
+ subscriptionsSupported?: boolean;
onUiChange: (next: ResourcesUiState) => void;
onRefreshList: () => void;
onReadResource: (uri: string) => void;
@@ -67,7 +74,7 @@ export interface ResourcesUiState {
const ScreenLayout = Flex.withProps({
variant: "screen",
- h: "calc(100vh - var(--app-shell-header-height, 0px))",
+ h: "calc(100dvh - var(--app-shell-header-height, 0px))",
gap: "md",
p: "xl",
});
@@ -119,7 +126,7 @@ const EmptyState = Text.withProps({
});
const SCROLL_MAX_HEIGHT =
- "calc(100vh - var(--app-shell-header-height, 0px) - var(--mantine-spacing-xl) * 2)";
+ "calc(100dvh - var(--app-shell-header-height, 0px) - var(--mantine-spacing-xl) * 2)";
export function ResourcesScreen({
resources,
@@ -129,6 +136,7 @@ export function ResourcesScreen({
ui,
listChanged,
completionsSupported,
+ subscriptionsSupported = true,
onUiChange,
onRefreshList,
onReadResource,
@@ -255,6 +263,7 @@ export function ResourcesScreen({
contents={readState.result.contents}
lastUpdated={readState.lastUpdated}
isSubscribed={readState.isSubscribed ?? false}
+ subscriptionsSupported={subscriptionsSupported}
onRefresh={() => handleReadResource(readResource.uri)}
onSubscribe={() => onSubscribeResource(readResource.uri)}
onUnsubscribe={() => onUnsubscribeResource(readResource.uri)}
@@ -275,6 +284,7 @@ export function ResourcesScreen({
resources={resources}
templates={templates}
subscriptions={subscriptions}
+ subscriptionsSupported={subscriptionsSupported}
selectedUri={selectedResourceUri}
selectedTemplateUri={selectedTemplateUri}
searchText={search}
diff --git a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.stories.tsx b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.stories.tsx
index 8173df7e..b69fba59 100644
--- a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.stories.tsx
+++ b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.stories.tsx
@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import type { ServerEntry } from "@inspector/core/mcp/types.js";
+import { expectScrollbarGutterIdleHidden } from "../../../test/scrollAreaStoryAssertions";
import { ServerListScreen } from "./ServerListScreen";
const meta: Meta = {
@@ -79,6 +80,11 @@ export const MultipleServers: Story = {
args: {
servers: [connectedStdioServer, disconnectedStdioServer, failedHttpServer],
},
+ // The server grid scroll region reserves a scrollbar gutter and hides the
+ // bar when idle, matching the History/Network/Logging list panels (#1474).
+ play: async ({ canvasElement }) => {
+ expectScrollbarGutterIdleHidden(canvasElement);
+ },
};
export const SingleServer: Story = {
diff --git a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx
index 393d1264..b0504149 100644
--- a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx
+++ b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx
@@ -137,7 +137,15 @@ export function ServerListScreen({
onExport={onExport}
/>
-
+
{servers.length === 0 ? (
No servers configured. Add a server to get started.
diff --git a/clients/web/src/components/screens/TasksScreen/TasksScreen.tsx b/clients/web/src/components/screens/TasksScreen/TasksScreen.tsx
index a027f459..379fc7a5 100644
--- a/clients/web/src/components/screens/TasksScreen/TasksScreen.tsx
+++ b/clients/web/src/components/screens/TasksScreen/TasksScreen.tsx
@@ -23,7 +23,7 @@ export interface TasksUiState {
const ScreenLayout = Flex.withProps({
variant: "screen",
- h: "calc(100vh - var(--app-shell-header-height, 0px))",
+ h: "calc(100dvh - var(--app-shell-header-height, 0px))",
gap: "md",
p: "xl",
});
diff --git a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx
index 32f006e6..698d4753 100644
--- a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx
+++ b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx
@@ -61,7 +61,7 @@ export interface ToolsScreenProps {
// viewport minus the app-shell header and the screen's top+bottom xl padding,
// leaving the bottom margin the overflow used to eat.
const SCROLL_MAX_HEIGHT =
- "calc(100vh - var(--app-shell-header-height, 0px) - var(--mantine-spacing-xl) * 2)";
+ "calc(100dvh - var(--app-shell-header-height, 0px) - var(--mantine-spacing-xl) * 2)";
// No `align` override: children stretch to the row's full height, giving each
// column pane a definite height. That definite height is what lets a column's
@@ -69,7 +69,7 @@ const SCROLL_MAX_HEIGHT =
// see the Prompts/Resources preview panes this mirrors).
const ScreenLayout = Flex.withProps({
variant: "screen",
- h: "calc(100vh - var(--app-shell-header-height, 0px))",
+ h: "calc(100dvh - var(--app-shell-header-height, 0px))",
gap: "md",
p: "xl",
});
diff --git a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx
index c48f32bc..af95fc26 100644
--- a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx
+++ b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx
@@ -14,7 +14,7 @@ import type {
ServerEntry,
} from "@inspector/core/mcp/types.js";
import type { Meta, StoryObj } from "@storybook/react-vite";
-import { fn } from "storybook/test";
+import { expect, fn } from "storybook/test";
import { InspectorView } from "./InspectorView";
import {
EMPTY_TOOLS_UI,
@@ -397,7 +397,24 @@ const meta: Meta = {
export default meta;
type Story = StoryObj;
-export const Default: Story = {};
+export const Default: Story = {
+ // The InspectorView shell must never scroll as a whole — only the inner
+ // ScrollArea regions within each screen do. Guard the structural invariant:
+ // AppShell.Main is clamped to the viewport height and clips its overflow, so
+ // no amount of screen content can surface a page-level scrollbar.
+ play: async ({ canvasElement }) => {
+ const main = canvasElement.querySelector(".mantine-AppShell-main");
+ if (!(main instanceof HTMLElement)) {
+ throw new Error("AppShell main not found");
+ }
+ expect(getComputedStyle(main).overflowY).toBe("hidden");
+ // Clamped to the viewport (within a pixel of rounding), so the shell can't
+ // grow past it and push the page into scrolling.
+ expect(main.getBoundingClientRect().height).toBeLessThanOrEqual(
+ window.innerHeight + 1,
+ );
+ },
+};
export const NoServers: Story = {
args: {
@@ -405,6 +422,42 @@ export const NoServers: Story = {
},
};
+// A server list long enough to overflow the viewport (as in the reported
+// screenshot). The shell stays clamped to the viewport and the overflow is
+// confined to the inner server-grid ScrollArea — the page never scrolls.
+const manyServers: ServerEntry[] = Array.from({ length: 24 }, (_, i) => ({
+ id: `00000000-0000-4000-8000-${String(i).padStart(12, "0")}`,
+ name: `Server ${i + 1}`,
+ config: { command: `npx -y @modelcontextprotocol/server-${i + 1}` },
+ info: { name: `Server ${i + 1}`, version: "1.0.0" },
+ connection: { status: "disconnected" },
+}));
+
+export const ManyServers: Story = {
+ args: {
+ servers: manyServers,
+ },
+ play: async ({ canvasElement }) => {
+ // Even under enough content to overflow, the shell stays viewport-clamped
+ // and clips — so the page can't scroll.
+ const main = canvasElement.querySelector(".mantine-AppShell-main");
+ if (!(main instanceof HTMLElement)) {
+ throw new Error("AppShell main not found");
+ }
+ expect(getComputedStyle(main).overflowY).toBe("hidden");
+ expect(main.getBoundingClientRect().bottom).toBeLessThanOrEqual(
+ window.innerHeight + 1,
+ );
+ // The overflow lives in the inner server-grid ScrollArea, which is actually
+ // scrollable here — confirming the grid (not the page) absorbs it.
+ const viewport = main.querySelector(".mantine-ScrollArea-viewport");
+ if (!(viewport instanceof HTMLElement)) {
+ throw new Error("server-grid scroll viewport not found");
+ }
+ expect(viewport.scrollHeight).toBeGreaterThan(viewport.clientHeight);
+ },
+};
+
// Renders the connected-state shell (full tab list, ViewHeader in connected
// mode). The other tabs still render their disconnected fixtures because
// the lists are passed through as static data — that's fine for visual
diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx
index fd06cad7..fbb99ed8 100644
--- a/clients/web/src/components/views/InspectorView/InspectorView.tsx
+++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx
@@ -310,6 +310,12 @@ export interface InspectorViewProps {
context: Record,
) => Promise;
completionsSupported?: boolean;
+ /**
+ * Whether the connected server advertises the `resources.subscribe`
+ * capability. When false, the Resources screen hides the Subscribe/
+ * Unsubscribe button and the Subscriptions accordion section.
+ */
+ subscriptionsSupported?: boolean;
onTasksUiChange: (next: TasksUiState) => void;
onCancelTask: (taskId: string) => void;
@@ -407,6 +413,7 @@ export function InspectorView({
onRefreshResources,
onCompleteArgument,
completionsSupported,
+ subscriptionsSupported,
onTasksUiChange,
onCancelTask,
onClearCompletedTasks,
@@ -554,7 +561,13 @@ export function InspectorView({
);
return (
-
+ // padding={0}: each screen fills `calc(100dvh - header)` and supplies its
+ // own `xl` padding, so Main must contribute only the fixed-header offset.
+ // Mantine's default `padding="md"` added an extra inset that pushed content
+ // past the viewport and made the whole InspectorView scroll — the theme's
+ // Main-slot height clamp + overflow:hidden keep that scroll on the inner
+ // ScrollArea regions only.
+
{connectionStatus === "connected" && initializeResult ? (