Skip to content

Commit ac9065f

Browse files
cliffhallclaude
andcommitted
Expose live resource subscriptions via a hook (#1325)
Add ResourceSubscriptionsState that mirrors InspectorClient's subscribed URIs as InspectorResourceSubscription[], resolving each URI against the managed resources list (so the subscription tile shows server-supplied name/title) and stamping lastUpdated on notifications/resources/updated. Pair it with useResourceSubscriptions and wire App.tsx so the Resources screen reflects subscribe/unsubscribe actions in real time. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4d74b94 commit ac9065f

6 files changed

Lines changed: 503 additions & 5 deletions

File tree

clients/web/src/App.tsx

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { ManagedPromptsState } from "@inspector/core/mcp/state/managedPromptsSta
1616
import { ManagedResourcesState } from "@inspector/core/mcp/state/managedResourcesState.js";
1717
import { ManagedResourceTemplatesState } from "@inspector/core/mcp/state/managedResourceTemplatesState.js";
1818
import { ManagedRequestorTasksState } from "@inspector/core/mcp/state/managedRequestorTasksState.js";
19+
import { ResourceSubscriptionsState } from "@inspector/core/mcp/state/resourceSubscriptionsState.js";
1920
import { MessageLogState } from "@inspector/core/mcp/state/messageLogState.js";
2021
import { FetchRequestLogState } from "@inspector/core/mcp/state/fetchRequestLogState.js";
2122
import { StderrLogState } from "@inspector/core/mcp/state/stderrLogState.js";
@@ -26,6 +27,7 @@ import { useManagedPrompts } from "@inspector/core/react/useManagedPrompts.js";
2627
import { useManagedResources } from "@inspector/core/react/useManagedResources.js";
2728
import { useManagedResourceTemplates } from "@inspector/core/react/useManagedResourceTemplates.js";
2829
import { useManagedRequestorTasks } from "@inspector/core/react/useManagedRequestorTasks.js";
30+
import { useResourceSubscriptions } from "@inspector/core/react/useResourceSubscriptions.js";
2931
import { useMessageLog } from "@inspector/core/react/useMessageLog.js";
3032
import { InspectorView } from "./components/views/InspectorView/InspectorView";
3133
import type { ToolCallState } from "./components/screens/ToolsScreen/ToolsScreen";
@@ -173,6 +175,8 @@ function App() {
173175
useState<ManagedResourceTemplatesState | null>(null);
174176
const [managedRequestorTasksState, setManagedRequestorTasksState] =
175177
useState<ManagedRequestorTasksState | null>(null);
178+
const [resourceSubscriptionsState, setResourceSubscriptionsState] =
179+
useState<ResourceSubscriptionsState | null>(null);
176180
const [messageLogState, setMessageLogState] =
177181
useState<MessageLogState | null>(null);
178182
const [fetchRequestLogState, setFetchRequestLogState] =
@@ -237,6 +241,9 @@ function App() {
237241
inspectorClient,
238242
managedRequestorTasksState,
239243
);
244+
const { subscriptions } = useResourceSubscriptions(
245+
resourceSubscriptionsState,
246+
);
240247
const { messages } = useMessageLog(messageLogState);
241248

242249
// Capture observed handshake latency at the connecting → connected edge.
@@ -304,6 +311,7 @@ function App() {
304311
managedResourcesState?.destroy();
305312
managedResourceTemplatesState?.destroy();
306313
managedRequestorTasksState?.destroy();
314+
resourceSubscriptionsState?.destroy();
307315
messageLogState?.destroy();
308316
fetchRequestLogState?.destroy();
309317
stderrLogState?.destroy();
@@ -325,11 +333,19 @@ function App() {
325333
setInspectorClient(client);
326334
setManagedToolsState(new ManagedToolsState(client));
327335
setManagedPromptsState(new ManagedPromptsState(client));
328-
setManagedResourcesState(new ManagedResourcesState(client));
336+
const nextResourcesState = new ManagedResourcesState(client);
337+
setManagedResourcesState(nextResourcesState);
329338
setManagedResourceTemplatesState(
330339
new ManagedResourceTemplatesState(client),
331340
);
332341
setManagedRequestorTasksState(new ManagedRequestorTasksState(client));
342+
// ResourceSubscriptionsState consults the managed resources list to
343+
// resolve subscribed URIs to full Resource objects (so the subscription
344+
// tile shows the server-supplied name/title). Pass the freshly created
345+
// state to avoid the React update lag from setManagedResourcesState.
346+
setResourceSubscriptionsState(
347+
new ResourceSubscriptionsState(client, nextResourcesState),
348+
);
333349
setMessageLogState(new MessageLogState(client));
334350
setFetchRequestLogState(new FetchRequestLogState(client));
335351
setStderrLogState(new StderrLogState(client));
@@ -342,6 +358,7 @@ function App() {
342358
managedResourcesState,
343359
managedResourceTemplatesState,
344360
managedRequestorTasksState,
361+
resourceSubscriptionsState,
345362
messageLogState,
346363
fetchRequestLogState,
347364
stderrLogState,
@@ -557,10 +574,7 @@ function App() {
557574
prompts={prompts}
558575
resources={resources}
559576
resourceTemplates={resourceTemplates}
560-
// TODO(#1325): drop the empty fallback once `useResourceSubscriptions`
561-
// surfaces the live subscription list — subscribe/unsubscribe buttons
562-
// currently fire but the screen never reflects the result.
563-
subscriptions={[]}
577+
subscriptions={subscriptions}
564578
logs={logs}
565579
tasks={tasks}
566580
history={messages}
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
2+
import type { Resource } from "@modelcontextprotocol/sdk/types.js";
3+
import { ResourceSubscriptionsState } from "@inspector/core/mcp/state/resourceSubscriptionsState";
4+
import { ManagedResourcesState } from "@inspector/core/mcp/state/managedResourcesState";
5+
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
6+
import type { InspectorResourceSubscription } from "@inspector/core/mcp/types";
7+
8+
function resource(uri: string, extras: Partial<Resource> = {}): Resource {
9+
return { uri, name: uri, ...extras };
10+
}
11+
12+
function waitForSubscriptionsChange(
13+
state: ResourceSubscriptionsState,
14+
): Promise<InspectorResourceSubscription[]> {
15+
return new Promise((resolve) => {
16+
state.addEventListener("subscriptionsChange", (e) => resolve(e.detail), {
17+
once: true,
18+
});
19+
});
20+
}
21+
22+
describe("ResourceSubscriptionsState", () => {
23+
let client: FakeInspectorClient;
24+
25+
beforeEach(() => {
26+
vi.useFakeTimers();
27+
vi.setSystemTime(new Date("2026-05-19T10:00:00Z"));
28+
client = new FakeInspectorClient({ status: "connected" });
29+
});
30+
31+
afterEach(() => {
32+
vi.useRealTimers();
33+
});
34+
35+
it("starts empty and getSubscriptions returns a defensive copy", () => {
36+
const state = new ResourceSubscriptionsState(client);
37+
expect(state.getSubscriptions()).toEqual([]);
38+
const a = state.getSubscriptions();
39+
const b = state.getSubscriptions();
40+
expect(a).not.toBe(b);
41+
});
42+
43+
it("rebuilds subscriptions from resourceSubscriptionsChange events", async () => {
44+
const state = new ResourceSubscriptionsState(client);
45+
const changePromise = waitForSubscriptionsChange(state);
46+
client.dispatchTypedEvent("resourceSubscriptionsChange", [
47+
"file:///a",
48+
"file:///b",
49+
]);
50+
const next = await changePromise;
51+
expect(next).toEqual([
52+
{ resource: { uri: "file:///a", name: "file:///a" } },
53+
{ resource: { uri: "file:///b", name: "file:///b" } },
54+
]);
55+
});
56+
57+
it("resolves Resource references via ManagedResourcesState when provided", async () => {
58+
const resourcesState = new ManagedResourcesState(client);
59+
client.queueResourcePages({
60+
resources: [resource("file:///a", { name: "Alpha", title: "Title A" })],
61+
});
62+
await resourcesState.refresh();
63+
64+
const state = new ResourceSubscriptionsState(client, resourcesState);
65+
const changePromise = waitForSubscriptionsChange(state);
66+
client.dispatchTypedEvent("resourceSubscriptionsChange", [
67+
"file:///a",
68+
"file:///unknown",
69+
]);
70+
const next = await changePromise;
71+
expect(next[0].resource).toEqual({
72+
uri: "file:///a",
73+
name: "Alpha",
74+
title: "Title A",
75+
});
76+
// Unknown URI falls back to a synthetic Resource
77+
expect(next[1].resource).toEqual({
78+
uri: "file:///unknown",
79+
name: "file:///unknown",
80+
});
81+
});
82+
83+
it("stamps lastUpdated on resourceUpdated for a tracked URI", async () => {
84+
const state = new ResourceSubscriptionsState(client);
85+
client.dispatchTypedEvent("resourceSubscriptionsChange", ["file:///a"]);
86+
expect(state.getSubscriptions()[0].lastUpdated).toBeUndefined();
87+
88+
const changePromise = waitForSubscriptionsChange(state);
89+
client.dispatchTypedEvent("resourceUpdated", { uri: "file:///a" });
90+
const next = await changePromise;
91+
expect(next[0].lastUpdated).toEqual(new Date("2026-05-19T10:00:00Z"));
92+
});
93+
94+
it("ignores resourceUpdated for URIs that are not subscribed", () => {
95+
const state = new ResourceSubscriptionsState(client);
96+
client.dispatchTypedEvent("resourceSubscriptionsChange", ["file:///a"]);
97+
const handler = vi.fn();
98+
state.addEventListener("subscriptionsChange", handler);
99+
client.dispatchTypedEvent("resourceUpdated", {
100+
uri: "file:///not-tracked",
101+
});
102+
expect(handler).not.toHaveBeenCalled();
103+
expect(state.getSubscriptions()[0].lastUpdated).toBeUndefined();
104+
});
105+
106+
it("preserves lastUpdated across re-subscribes and drops it on unsubscribe", async () => {
107+
const state = new ResourceSubscriptionsState(client);
108+
client.dispatchTypedEvent("resourceSubscriptionsChange", [
109+
"file:///a",
110+
"file:///b",
111+
]);
112+
client.dispatchTypedEvent("resourceUpdated", { uri: "file:///a" });
113+
expect(state.getSubscriptions()[0].lastUpdated).toBeInstanceOf(Date);
114+
115+
// Unsubscribe from "a", subscribe to "c". lastUpdated for "a" is dropped.
116+
client.dispatchTypedEvent("resourceSubscriptionsChange", [
117+
"file:///b",
118+
"file:///c",
119+
]);
120+
expect(state.getSubscriptions().map((s) => s.resource.uri)).toEqual([
121+
"file:///b",
122+
"file:///c",
123+
]);
124+
expect(state.getSubscriptions().every((s) => !s.lastUpdated)).toBe(true);
125+
126+
// Re-subscribe to "a" — no lastUpdated since the prior entry was dropped.
127+
client.dispatchTypedEvent("resourceSubscriptionsChange", [
128+
"file:///a",
129+
"file:///b",
130+
"file:///c",
131+
]);
132+
expect(state.getSubscriptions()[0].lastUpdated).toBeUndefined();
133+
});
134+
135+
it("preserves lastUpdated when an unrelated URI is added", () => {
136+
const state = new ResourceSubscriptionsState(client);
137+
client.dispatchTypedEvent("resourceSubscriptionsChange", ["file:///a"]);
138+
client.dispatchTypedEvent("resourceUpdated", { uri: "file:///a" });
139+
const stampedAt = state.getSubscriptions()[0].lastUpdated;
140+
expect(stampedAt).toBeInstanceOf(Date);
141+
142+
client.dispatchTypedEvent("resourceSubscriptionsChange", [
143+
"file:///a",
144+
"file:///b",
145+
]);
146+
const subs = state.getSubscriptions();
147+
expect(subs[0].lastUpdated).toEqual(stampedAt);
148+
expect(subs[1].lastUpdated).toBeUndefined();
149+
});
150+
151+
it("re-resolves Resource references when ManagedResourcesState refreshes", async () => {
152+
const resourcesState = new ManagedResourcesState(client);
153+
const state = new ResourceSubscriptionsState(client, resourcesState);
154+
client.dispatchTypedEvent("resourceSubscriptionsChange", ["file:///a"]);
155+
expect(state.getSubscriptions()[0].resource.name).toBe("file:///a");
156+
157+
client.queueResourcePages({
158+
resources: [resource("file:///a", { name: "Resolved Name" })],
159+
});
160+
const changePromise = waitForSubscriptionsChange(state);
161+
await resourcesState.refresh();
162+
const next = await changePromise;
163+
expect(next[0].resource.name).toBe("Resolved Name");
164+
});
165+
166+
it("does not re-emit on resourcesChange when no URIs are subscribed", async () => {
167+
const resourcesState = new ManagedResourcesState(client);
168+
const state = new ResourceSubscriptionsState(client, resourcesState);
169+
const handler = vi.fn();
170+
state.addEventListener("subscriptionsChange", handler);
171+
172+
client.queueResourcePages({ resources: [resource("file:///a")] });
173+
await resourcesState.refresh();
174+
expect(handler).not.toHaveBeenCalled();
175+
});
176+
177+
it("clears subscriptions on statusChange to disconnected", async () => {
178+
const state = new ResourceSubscriptionsState(client);
179+
client.dispatchTypedEvent("resourceSubscriptionsChange", ["file:///a"]);
180+
expect(state.getSubscriptions()).toHaveLength(1);
181+
182+
const changePromise = waitForSubscriptionsChange(state);
183+
client.setStatus("disconnected");
184+
const next = await changePromise;
185+
expect(next).toEqual([]);
186+
expect(state.getSubscriptions()).toEqual([]);
187+
});
188+
189+
it("does not clear subscriptions on non-disconnected status changes", () => {
190+
const state = new ResourceSubscriptionsState(client);
191+
client.dispatchTypedEvent("resourceSubscriptionsChange", ["file:///a"]);
192+
client.setStatus("error");
193+
expect(state.getSubscriptions()).toHaveLength(1);
194+
});
195+
196+
it("destroy unsubscribes from client and resources state events", () => {
197+
const resourcesState = new ManagedResourcesState(client);
198+
const state = new ResourceSubscriptionsState(client, resourcesState);
199+
client.dispatchTypedEvent("resourceSubscriptionsChange", ["file:///a"]);
200+
expect(state.getSubscriptions()).toHaveLength(1);
201+
202+
state.destroy();
203+
expect(state.getSubscriptions()).toEqual([]);
204+
205+
// Further events from the client must not affect the destroyed state.
206+
const handler = vi.fn();
207+
state.addEventListener("subscriptionsChange", handler);
208+
client.dispatchTypedEvent("resourceSubscriptionsChange", [
209+
"file:///a",
210+
"file:///b",
211+
]);
212+
client.dispatchTypedEvent("resourceUpdated", { uri: "file:///a" });
213+
expect(handler).not.toHaveBeenCalled();
214+
expect(state.getSubscriptions()).toEqual([]);
215+
});
216+
217+
it("destroy is idempotent", () => {
218+
const state = new ResourceSubscriptionsState(client);
219+
state.destroy();
220+
expect(() => state.destroy()).not.toThrow();
221+
});
222+
});
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, it, expect, beforeEach } from "vitest";
2+
import { act, renderHook, waitFor } from "@testing-library/react";
3+
import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient";
4+
import { ResourceSubscriptionsState } from "@inspector/core/mcp/state/resourceSubscriptionsState";
5+
import { useResourceSubscriptions } from "@inspector/core/react/useResourceSubscriptions";
6+
7+
describe("useResourceSubscriptions", () => {
8+
let client: FakeInspectorClient;
9+
let state: ResourceSubscriptionsState;
10+
11+
beforeEach(() => {
12+
client = new FakeInspectorClient({ status: "connected" });
13+
state = new ResourceSubscriptionsState(client);
14+
});
15+
16+
it("returns the initial snapshot from the state", () => {
17+
client.dispatchTypedEvent("resourceSubscriptionsChange", ["file:///a"]);
18+
const { result } = renderHook(() => useResourceSubscriptions(state));
19+
expect(result.current.subscriptions.map((s) => s.resource.uri)).toEqual([
20+
"file:///a",
21+
]);
22+
});
23+
24+
it("returns empty subscriptions when state is null", () => {
25+
const { result } = renderHook(() => useResourceSubscriptions(null));
26+
expect(result.current.subscriptions).toEqual([]);
27+
});
28+
29+
it("updates when state dispatches subscriptionsChange", async () => {
30+
const { result } = renderHook(() => useResourceSubscriptions(state));
31+
expect(result.current.subscriptions).toEqual([]);
32+
33+
act(() => {
34+
client.dispatchTypedEvent("resourceSubscriptionsChange", ["file:///a"]);
35+
});
36+
37+
await waitFor(() => {
38+
expect(result.current.subscriptions.map((s) => s.resource.uri)).toEqual([
39+
"file:///a",
40+
]);
41+
});
42+
});
43+
44+
it("resets to empty when the state prop becomes null", async () => {
45+
client.dispatchTypedEvent("resourceSubscriptionsChange", ["file:///a"]);
46+
const { result, rerender } = renderHook(
47+
({ s }: { s: ResourceSubscriptionsState | null }) =>
48+
useResourceSubscriptions(s),
49+
{ initialProps: { s: state as ResourceSubscriptionsState | null } },
50+
);
51+
await waitFor(() => {
52+
expect(result.current.subscriptions).toHaveLength(1);
53+
});
54+
55+
rerender({ s: null });
56+
await waitFor(() => {
57+
expect(result.current.subscriptions).toEqual([]);
58+
});
59+
});
60+
61+
it("unsubscribes from the state on unmount", () => {
62+
const { result, unmount } = renderHook(() =>
63+
useResourceSubscriptions(state),
64+
);
65+
unmount();
66+
client.dispatchTypedEvent("resourceSubscriptionsChange", ["file:///a"]);
67+
expect(result.current.subscriptions).toEqual([]);
68+
});
69+
});

core/mcp/state/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,5 @@ export type {
4848
PagedRequestorTasksStateEventMap,
4949
LoadPageResult as PagedRequestorTasksLoadPageResult,
5050
} from "./pagedRequestorTasksState.js";
51+
export { ResourceSubscriptionsState } from "./resourceSubscriptionsState.js";
52+
export type { ResourceSubscriptionsStateEventMap } from "./resourceSubscriptionsState.js";

0 commit comments

Comments
 (0)