@assistant-ui/react-mcp 0.0.17 → 0.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/dist/auth/buildHeaders.d.ts +0 -1
  2. package/dist/auth/buildHeaders.d.ts.map +1 -1
  3. package/dist/auth/createOAuthProvider.d.ts +4 -3
  4. package/dist/auth/createOAuthProvider.d.ts.map +1 -1
  5. package/dist/auth/createOAuthProvider.js +2 -1
  6. package/dist/auth/createOAuthProvider.js.map +1 -1
  7. package/dist/auth/types.d.ts +2 -2
  8. package/dist/auth/types.d.ts.map +1 -1
  9. package/dist/connector.d.ts +0 -1
  10. package/dist/connector.d.ts.map +1 -1
  11. package/dist/context/McpConnectorByIndexProvider.d.ts +0 -1
  12. package/dist/context/McpConnectorByIndexProvider.d.ts.map +1 -1
  13. package/dist/context/McpCustomServerByIndexProvider.d.ts +0 -1
  14. package/dist/context/McpCustomServerByIndexProvider.d.ts.map +1 -1
  15. package/dist/context/McpServerByIdProvider.d.ts +0 -1
  16. package/dist/context/McpServerByIdProvider.d.ts.map +1 -1
  17. package/dist/hooks/useMcpOAuthCallback.d.ts +4 -3
  18. package/dist/hooks/useMcpOAuthCallback.d.ts.map +1 -1
  19. package/dist/hooks/useMcpOAuthCallback.js +14 -8
  20. package/dist/hooks/useMcpOAuthCallback.js.map +1 -1
  21. package/dist/mcp-scope.d.ts +16 -6
  22. package/dist/mcp-scope.d.ts.map +1 -1
  23. package/dist/primitives/addForm/McpAddFormAuthFields.d.ts +0 -1
  24. package/dist/primitives/addForm/McpAddFormAuthFields.d.ts.map +1 -1
  25. package/dist/primitives/addForm/McpAddFormAuthSelect.d.ts +0 -1
  26. package/dist/primitives/addForm/McpAddFormAuthSelect.d.ts.map +1 -1
  27. package/dist/primitives/addForm/McpAddFormAuthSelect.js.map +1 -1
  28. package/dist/primitives/addForm/McpAddFormCancel.d.ts +0 -1
  29. package/dist/primitives/addForm/McpAddFormCancel.d.ts.map +1 -1
  30. package/dist/primitives/addForm/McpAddFormError.d.ts +0 -1
  31. package/dist/primitives/addForm/McpAddFormError.d.ts.map +1 -1
  32. package/dist/primitives/addForm/McpAddFormError.js.map +1 -1
  33. package/dist/primitives/addForm/McpAddFormNameField.d.ts +0 -1
  34. package/dist/primitives/addForm/McpAddFormNameField.d.ts.map +1 -1
  35. package/dist/primitives/addForm/McpAddFormRoot.d.ts +0 -1
  36. package/dist/primitives/addForm/McpAddFormRoot.d.ts.map +1 -1
  37. package/dist/primitives/addForm/McpAddFormRoot.js.map +1 -1
  38. package/dist/primitives/addForm/McpAddFormSubmit.d.ts +0 -1
  39. package/dist/primitives/addForm/McpAddFormSubmit.d.ts.map +1 -1
  40. package/dist/primitives/addForm/McpAddFormUrlField.d.ts +0 -1
  41. package/dist/primitives/addForm/McpAddFormUrlField.d.ts.map +1 -1
  42. package/dist/primitives/addForm/context.d.ts +0 -1
  43. package/dist/primitives/addForm/context.d.ts.map +1 -1
  44. package/dist/primitives/addForm.d.ts +0 -2
  45. package/dist/primitives/manager/McpManagerAddCustomTrigger.d.ts +0 -1
  46. package/dist/primitives/manager/McpManagerAddCustomTrigger.d.ts.map +1 -1
  47. package/dist/primitives/manager/McpManagerConnectors.d.ts +0 -1
  48. package/dist/primitives/manager/McpManagerConnectors.d.ts.map +1 -1
  49. package/dist/primitives/manager/McpManagerConnectors.js.map +1 -1
  50. package/dist/primitives/manager/McpManagerCustomServers.d.ts +0 -1
  51. package/dist/primitives/manager/McpManagerCustomServers.d.ts.map +1 -1
  52. package/dist/primitives/manager/McpManagerCustomServers.js.map +1 -1
  53. package/dist/primitives/manager/McpManagerRoot.d.ts +0 -1
  54. package/dist/primitives/manager/McpManagerRoot.d.ts.map +1 -1
  55. package/dist/primitives/manager.d.ts +0 -2
  56. package/dist/primitives/server/McpServerConnectButton.d.ts +0 -1
  57. package/dist/primitives/server/McpServerConnectButton.d.ts.map +1 -1
  58. package/dist/primitives/server/McpServerDisconnectButton.d.ts +0 -1
  59. package/dist/primitives/server/McpServerDisconnectButton.d.ts.map +1 -1
  60. package/dist/primitives/server/McpServerError.d.ts +0 -1
  61. package/dist/primitives/server/McpServerError.d.ts.map +1 -1
  62. package/dist/primitives/server/McpServerError.js.map +1 -1
  63. package/dist/primitives/server/McpServerIcon.d.ts +4 -3
  64. package/dist/primitives/server/McpServerIcon.d.ts.map +1 -1
  65. package/dist/primitives/server/McpServerName.d.ts +0 -1
  66. package/dist/primitives/server/McpServerName.d.ts.map +1 -1
  67. package/dist/primitives/server/McpServerName.js.map +1 -1
  68. package/dist/primitives/server/McpServerOAuthLink.d.ts +4 -3
  69. package/dist/primitives/server/McpServerOAuthLink.d.ts.map +1 -1
  70. package/dist/primitives/server/McpServerRemoveButton.d.ts +0 -1
  71. package/dist/primitives/server/McpServerRemoveButton.d.ts.map +1 -1
  72. package/dist/primitives/server/McpServerRoot.d.ts +0 -1
  73. package/dist/primitives/server/McpServerRoot.d.ts.map +1 -1
  74. package/dist/primitives/server/McpServerStatus.d.ts +0 -1
  75. package/dist/primitives/server/McpServerStatus.d.ts.map +1 -1
  76. package/dist/primitives/server/McpServerStatus.js.map +1 -1
  77. package/dist/primitives/server/McpServerToolName.d.ts +0 -1
  78. package/dist/primitives/server/McpServerToolName.d.ts.map +1 -1
  79. package/dist/primitives/server/McpServerToolName.js.map +1 -1
  80. package/dist/primitives/server/McpServerTools.d.ts +0 -1
  81. package/dist/primitives/server/McpServerTools.d.ts.map +1 -1
  82. package/dist/primitives/server/McpServerTools.js.map +1 -1
  83. package/dist/primitives/server.d.ts +0 -2
  84. package/dist/resources/McpManagerResource.d.ts +6 -4
  85. package/dist/resources/McpManagerResource.d.ts.map +1 -1
  86. package/dist/resources/McpServerResource.d.ts +0 -1
  87. package/dist/resources/McpServerResource.d.ts.map +1 -1
  88. package/dist/resources/McpServerResource.js +99 -34
  89. package/dist/resources/McpServerResource.js.map +1 -1
  90. package/dist/resources/storage/McpCustomStorage.d.ts +0 -1
  91. package/dist/resources/storage/McpCustomStorage.d.ts.map +1 -1
  92. package/dist/resources/storage/McpLocalStorage.d.ts +8 -3
  93. package/dist/resources/storage/McpLocalStorage.d.ts.map +1 -1
  94. package/dist/resources/storage/McpLocalStorage.js +66 -3
  95. package/dist/resources/storage/McpLocalStorage.js.map +1 -1
  96. package/dist/resources/storage/McpMemoryStorage.d.ts +0 -1
  97. package/dist/resources/storage/McpMemoryStorage.d.ts.map +1 -1
  98. package/dist/resources/storage/types.d.ts +0 -1
  99. package/dist/resources/storage/types.d.ts.map +1 -1
  100. package/dist/utils/serverId.d.ts.map +1 -1
  101. package/package.json +9 -9
  102. package/src/hooks/useMcpOAuthCallback.test.ts +24 -0
  103. package/src/hooks/useMcpOAuthCallback.tsx +20 -5
  104. package/src/mcp-scope.ts +2 -0
  105. package/src/resources/McpServerResource.test.ts +327 -11
  106. package/src/resources/McpServerResource.ts +124 -30
  107. package/src/resources/storage/McpLocalStorage.test.ts +240 -0
  108. package/src/resources/storage/McpLocalStorage.ts +134 -3
@@ -2,6 +2,20 @@ import { type FC, type ReactNode, useEffect, useRef, useState } from "react";
2
2
  import { useAui } from "@assistant-ui/store";
3
3
  import { decodeServerIdFromState } from "../auth/createOAuthProvider";
4
4
 
5
+ export const createMcpOAuthCallbackError = (
6
+ err: unknown,
7
+ serverId: string | null,
8
+ ): Error => {
9
+ const message = err instanceof Error ? err.message : String(err);
10
+ if (serverId) {
11
+ return new Error(
12
+ `MCP OAuth callback for server "${serverId}" failed: ${message}`,
13
+ { cause: err },
14
+ );
15
+ }
16
+ return new Error(`MCP OAuth callback failed: ${message}`, { cause: err });
17
+ };
18
+
5
19
  export type UseMcpOAuthCallbackOptions = {
6
20
  /** Defaults to `window.location.href`. */
7
21
  url?: string;
@@ -39,27 +53,28 @@ export function useMcpOAuthCallback(
39
53
  startedRef.current = url;
40
54
 
41
55
  (async () => {
56
+ let serverId: string | null = null;
42
57
  try {
43
58
  const parsed = new URL(url);
44
59
  const state = parsed.searchParams.get("state");
60
+ if (state) serverId = decodeServerIdFromState(state);
45
61
  const error = parsed.searchParams.get("error");
46
62
  if (error) {
47
63
  throw new Error(
48
64
  parsed.searchParams.get("error_description") ?? error,
49
65
  );
50
66
  }
51
- if (!state) throw new Error("missing state parameter in callback URL");
52
- const serverId = decodeServerIdFromState(state);
67
+ if (!state) throw new Error('missing "state" parameter');
53
68
  if (!serverId) {
54
- throw new Error("callback state does not match an MCP server");
69
+ throw new Error("state was not created by assistant-ui MCP");
55
70
  }
56
71
  setResult({ status: "running", serverId, error: null });
57
72
  await aui.mcp().server({ id: serverId }).completeAuth(url);
58
73
  setResult({ status: "done", serverId, error: null });
59
74
  optsRef.current.onComplete?.(serverId);
60
75
  } catch (err) {
61
- const e = err instanceof Error ? err : new Error(String(err));
62
- setResult((prev) => ({ ...prev, status: "error", error: e }));
76
+ const e = createMcpOAuthCallbackError(err, serverId);
77
+ setResult({ status: "error", serverId, error: e });
63
78
  optsRef.current.onError?.(e);
64
79
  }
65
80
  })();
package/src/mcp-scope.ts CHANGED
@@ -72,6 +72,8 @@ export type MCPServerMethods = {
72
72
  disconnect: () => Promise<void>;
73
73
  remove: () => Promise<void>;
74
74
  callTool: (name: string, args: unknown) => Promise<unknown>;
75
+ /** List resources exposed by the server. Returns the raw MCP `ListResourcesResult`. */
76
+ listResources: (params?: { cursor?: string | undefined }) => Promise<unknown>;
75
77
  /** Read a resource by URI. Returns the raw MCP `ReadResourceResult`. */
76
78
  readResource: (uri: string) => Promise<unknown>;
77
79
  /** OAuth only: pass full callback URL (e.g. window.location.href) */
@@ -1,5 +1,8 @@
1
1
  import { createTapRoot, useResource } from "@assistant-ui/tap";
2
+ import type { ClientOutput } from "@assistant-ui/store";
3
+ import { useEffect } from "react";
2
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import type { MCPAuthConfig } from "../mcp-scope";
3
6
  import type { MCPStorage } from "./storage/types";
4
7
 
5
8
  const mocks = vi.hoisted(() => {
@@ -15,6 +18,8 @@ const mocks = vi.hoisted(() => {
15
18
  }>;
16
19
  }>
17
20
  > = [];
21
+ const finishAuthResults: Array<() => Promise<void>> = [];
22
+ const closeResults: Array<() => Promise<void>> = [];
18
23
 
19
24
  const Client = vi.fn().mockImplementation(function Client(this: any) {
20
25
  const index = clients.length;
@@ -23,6 +28,7 @@ const mocks = vi.hoisted(() => {
23
28
  () => listToolsResults[index]?.() ?? Promise.resolve({ tools: [] }),
24
29
  );
25
30
  this.callTool = vi.fn();
31
+ this.listResources = vi.fn(() => Promise.resolve({ resources: [] }));
26
32
  this.readResource = vi.fn();
27
33
  clients.push(this);
28
34
  });
@@ -30,8 +36,11 @@ const mocks = vi.hoisted(() => {
30
36
  const StreamableHTTPClientTransport = vi
31
37
  .fn()
32
38
  .mockImplementation(function StreamableHTTPClientTransport(this: any) {
33
- this.close = vi.fn(() => Promise.resolve());
34
- this.finishAuth = vi.fn(() => Promise.resolve());
39
+ const index = transports.length;
40
+ this.close = vi.fn(() => closeResults[index]?.() ?? Promise.resolve());
41
+ this.finishAuth = vi.fn(
42
+ () => finishAuthResults[index]?.() ?? Promise.resolve(),
43
+ );
35
44
  transports.push(this);
36
45
  });
37
46
 
@@ -42,6 +51,8 @@ const mocks = vi.hoisted(() => {
42
51
  transports,
43
52
  connectResults,
44
53
  listToolsResults,
54
+ finishAuthResults,
55
+ closeResults,
45
56
  };
46
57
  });
47
58
 
@@ -61,6 +72,18 @@ const tick = async () => {
61
72
  await Promise.resolve();
62
73
  };
63
74
 
75
+ const flushMacrotask = async () => {
76
+ await new Promise<void>((resolve) => {
77
+ const channel = new MessageChannel();
78
+ channel.port1.onmessage = () => {
79
+ channel.port1.close();
80
+ channel.port2.close();
81
+ resolve();
82
+ };
83
+ channel.port2.postMessage(null);
84
+ });
85
+ };
86
+
64
87
  const waitFor = async (predicate: () => boolean) => {
65
88
  for (let i = 0; i < 20; i++) {
66
89
  if (predicate()) return;
@@ -77,18 +100,35 @@ const createStorage = (): MCPStorage => ({
77
100
  clearAuthState: vi.fn(async () => {}),
78
101
  });
79
102
 
80
- const mount = (props?: { connectionTimeout?: number | undefined }) => {
103
+ const resetMocks = () => {
104
+ mocks.clients.length = 0;
105
+ mocks.transports.length = 0;
106
+ mocks.connectResults.length = 0;
107
+ mocks.listToolsResults.length = 0;
108
+ mocks.finishAuthResults.length = 0;
109
+ mocks.closeResults.length = 0;
110
+ mocks.Client.mockClear();
111
+ mocks.StreamableHTTPClientTransport.mockClear();
112
+ };
113
+
114
+ const mount = (
115
+ props?: {
116
+ auth?: MCPAuthConfig | undefined;
117
+ connectionTimeout?: number | undefined;
118
+ },
119
+ onMount?: (server: ClientOutput<"mcpServer">) => void,
120
+ ) => {
81
121
  const connectionTimeout =
82
122
  props && "connectionTimeout" in props ? props.connectionTimeout : 10_000;
83
123
 
84
124
  return createTapRoot(function Root() {
85
- return useResource(
125
+ const server = useResource(
86
126
  McpServerResource({
87
127
  id: "docs",
88
128
  kind: "connector",
89
129
  name: "Docs",
90
130
  url: "https://example.com/mcp",
91
- auth: { type: "none" },
131
+ auth: props?.auth ?? { type: "none" },
92
132
  storage: createStorage(),
93
133
  redirectUri: "https://example.com/callback",
94
134
  autoConnect: false,
@@ -96,18 +136,17 @@ const mount = (props?: { connectionTimeout?: number | undefined }) => {
96
136
  onRemove: vi.fn(async () => {}),
97
137
  }),
98
138
  );
139
+ useEffect(() => {
140
+ onMount?.(server);
141
+ }, [server]);
142
+ return server;
99
143
  });
100
144
  };
101
145
 
102
146
  describe("McpServerResource connectionTimeout", () => {
103
147
  beforeEach(() => {
104
148
  vi.useFakeTimers();
105
- mocks.clients.length = 0;
106
- mocks.transports.length = 0;
107
- mocks.connectResults.length = 0;
108
- mocks.listToolsResults.length = 0;
109
- mocks.Client.mockClear();
110
- mocks.StreamableHTTPClientTransport.mockClear();
149
+ resetMocks();
111
150
  });
112
151
 
113
152
  afterEach(() => {
@@ -219,3 +258,280 @@ describe("McpServerResource connectionTimeout", () => {
219
258
  }
220
259
  });
221
260
  });
261
+
262
+ describe("McpServerResource connection lifecycle", () => {
263
+ beforeEach(resetMocks);
264
+
265
+ it("closes a pending connection when the resource unmounts", async () => {
266
+ let resolveConnect!: () => void;
267
+ mocks.connectResults.push(
268
+ () =>
269
+ new Promise<void>((resolve) => {
270
+ resolveConnect = resolve;
271
+ }),
272
+ );
273
+ const root = mount({ connectionTimeout: undefined });
274
+ let didUnmount = false;
275
+ try {
276
+ const connectPromise = root.getValue().connect();
277
+ await waitFor(() => mocks.clients[0]?.connect.mock.calls.length === 1);
278
+
279
+ root.unmount();
280
+ didUnmount = true;
281
+ await flushMacrotask();
282
+
283
+ expect(mocks.transports[0].close).toHaveBeenCalledTimes(1);
284
+
285
+ resolveConnect();
286
+ await connectPromise;
287
+
288
+ expect(mocks.clients[0].listTools).not.toHaveBeenCalled();
289
+ expect(mocks.transports[0].close).toHaveBeenCalledTimes(1);
290
+ } finally {
291
+ if (!didUnmount) root.unmount();
292
+ }
293
+ });
294
+
295
+ it("waits for pending transports to close before a newer reconnect", async () => {
296
+ let resolveFirstConnect!: () => void;
297
+ let resolveFirstClose!: () => void;
298
+ mocks.connectResults.push(
299
+ () =>
300
+ new Promise<void>((resolve) => {
301
+ resolveFirstConnect = resolve;
302
+ }),
303
+ );
304
+ mocks.closeResults.push(
305
+ () =>
306
+ new Promise<void>((resolve) => {
307
+ resolveFirstClose = resolve;
308
+ }),
309
+ );
310
+ const root = mount({ connectionTimeout: undefined });
311
+
312
+ try {
313
+ const firstConnect = root.getValue().connect();
314
+ await waitFor(() => mocks.clients[0]?.connect.mock.calls.length === 1);
315
+
316
+ const supersededReconnect = root.getValue().connect();
317
+ await waitFor(() => mocks.transports[0]?.close.mock.calls.length === 1);
318
+
319
+ const latestReconnect = root.getValue().connect();
320
+ await flushMacrotask();
321
+ expect(mocks.transports).toHaveLength(1);
322
+
323
+ resolveFirstClose();
324
+ await supersededReconnect;
325
+ await waitFor(() => mocks.clients[1]?.connect.mock.calls.length === 1);
326
+ await latestReconnect;
327
+
328
+ resolveFirstConnect();
329
+ await firstConnect;
330
+ } finally {
331
+ root.unmount();
332
+ }
333
+ });
334
+ });
335
+
336
+ describe("McpServerResource completeAuth", () => {
337
+ beforeEach(resetMocks);
338
+
339
+ it("completes auth across the StrictMode effect replay", async () => {
340
+ let completeAuth: Promise<void> | undefined;
341
+ let started = false;
342
+ const root = mount({ auth: { type: "oauth" } }, (server) => {
343
+ if (started) return;
344
+ started = true;
345
+ completeAuth = server.completeAuth(
346
+ "https://example.com/callback?code=abc",
347
+ );
348
+ });
349
+
350
+ try {
351
+ await expect(completeAuth).resolves.toBeUndefined();
352
+ await flushMacrotask();
353
+
354
+ expect(mocks.transports[0].finishAuth).toHaveBeenCalledTimes(1);
355
+ expect(root.getValue().getState().connectionState).toBe("connected");
356
+ } finally {
357
+ root.unmount();
358
+ }
359
+ });
360
+
361
+ it("rejects when the callback URL has no authorization code", async () => {
362
+ const root = mount();
363
+
364
+ try {
365
+ await expect(
366
+ root.getValue().completeAuth("https://example.com/callback?state=abc"),
367
+ ).rejects.toThrow("missing authorization code in callback URL");
368
+ await flushMacrotask();
369
+
370
+ expect(root.getValue().getState()).toMatchObject({
371
+ connectionState: "error",
372
+ lastError: {
373
+ message: "missing authorization code in callback URL",
374
+ },
375
+ });
376
+ } finally {
377
+ root.unmount();
378
+ }
379
+ });
380
+
381
+ it("rejects after storing finishAuth failures on the server state", async () => {
382
+ mocks.finishAuthResults.push(() =>
383
+ Promise.reject(new Error("invalid_grant")),
384
+ );
385
+ const root = mount({ auth: { type: "oauth" } });
386
+
387
+ try {
388
+ await expect(
389
+ root.getValue().completeAuth("https://example.com/callback?code=abc"),
390
+ ).rejects.toThrow("invalid_grant");
391
+ await flushMacrotask();
392
+
393
+ expect(root.getValue().getState()).toMatchObject({
394
+ connectionState: "error",
395
+ lastError: {
396
+ message: "invalid_grant",
397
+ },
398
+ });
399
+ expect(mocks.transports[0].finishAuth).toHaveBeenCalledWith("abc");
400
+ expect(mocks.transports[0].close).toHaveBeenCalledTimes(1);
401
+ } finally {
402
+ root.unmount();
403
+ }
404
+ });
405
+
406
+ it("rejects when the resource unmounts during auth completion", async () => {
407
+ let resolveFinishAuth!: () => void;
408
+ mocks.finishAuthResults.push(
409
+ () =>
410
+ new Promise<void>((resolve) => {
411
+ resolveFinishAuth = resolve;
412
+ }),
413
+ );
414
+ const root = mount({ auth: { type: "oauth" } });
415
+ let didUnmount = false;
416
+
417
+ try {
418
+ const completeAuth = root
419
+ .getValue()
420
+ .completeAuth("https://example.com/callback?code=abc");
421
+ await waitFor(
422
+ () => mocks.transports[0]?.finishAuth.mock.calls.length === 1,
423
+ );
424
+
425
+ root.unmount();
426
+ didUnmount = true;
427
+ resolveFinishAuth();
428
+
429
+ await expect(completeAuth).rejects.toThrow(
430
+ 'MCP server "docs" authorization was interrupted before completion.',
431
+ );
432
+ } finally {
433
+ if (!didUnmount) root.unmount();
434
+ }
435
+ });
436
+
437
+ it("normalizes late auth failures after unmount", async () => {
438
+ let rejectFinishAuth!: (error: Error) => void;
439
+ mocks.finishAuthResults.push(
440
+ () =>
441
+ new Promise<void>((_, reject) => {
442
+ rejectFinishAuth = reject;
443
+ }),
444
+ );
445
+ const root = mount({ auth: { type: "oauth" } });
446
+ let didUnmount = false;
447
+
448
+ try {
449
+ const completeAuth = root
450
+ .getValue()
451
+ .completeAuth("https://example.com/callback?code=abc");
452
+ await waitFor(
453
+ () => mocks.transports[0]?.finishAuth.mock.calls.length === 1,
454
+ );
455
+
456
+ root.unmount();
457
+ didUnmount = true;
458
+ const finishAuthError = new Error("Connection closed");
459
+ rejectFinishAuth(finishAuthError);
460
+
461
+ await expect(completeAuth).rejects.toMatchObject({
462
+ message:
463
+ 'MCP server "docs" authorization was interrupted before completion.',
464
+ cause: finishAuthError,
465
+ });
466
+ } finally {
467
+ if (!didUnmount) root.unmount();
468
+ }
469
+ });
470
+ });
471
+
472
+ describe("McpServerResource resource methods", () => {
473
+ beforeEach(() => {
474
+ mocks.clients.length = 0;
475
+ mocks.transports.length = 0;
476
+ mocks.connectResults.length = 0;
477
+ mocks.listToolsResults.length = 0;
478
+ mocks.Client.mockClear();
479
+ mocks.StreamableHTTPClientTransport.mockClear();
480
+ });
481
+
482
+ it("lists resources from a connected server", async () => {
483
+ const result = {
484
+ resources: [
485
+ {
486
+ uri: "docs://intro",
487
+ name: "Intro",
488
+ mimeType: "text/markdown",
489
+ },
490
+ ],
491
+ };
492
+ const root = mount();
493
+
494
+ try {
495
+ await root.getValue().connect();
496
+ mocks.clients[0].listResources.mockResolvedValueOnce(result);
497
+
498
+ await expect(root.getValue().listResources()).resolves.toBe(result);
499
+ expect(mocks.clients[0].listResources).toHaveBeenCalledTimes(1);
500
+ } finally {
501
+ root.unmount();
502
+ }
503
+ });
504
+
505
+ it("forwards the resource pagination cursor", async () => {
506
+ const result = {
507
+ resources: [{ uri: "docs://page-two", name: "Page two" }],
508
+ };
509
+ const root = mount();
510
+
511
+ try {
512
+ await root.getValue().connect();
513
+ mocks.clients[0].listResources.mockResolvedValueOnce(result);
514
+
515
+ await expect(
516
+ root.getValue().listResources({ cursor: "next-page" }),
517
+ ).resolves.toBe(result);
518
+ expect(mocks.clients[0].listResources).toHaveBeenCalledWith({
519
+ cursor: "next-page",
520
+ });
521
+ } finally {
522
+ root.unmount();
523
+ }
524
+ });
525
+
526
+ it("rejects listResources when the server is disconnected", async () => {
527
+ const root = mount();
528
+
529
+ try {
530
+ await expect(root.getValue().listResources()).rejects.toThrow(
531
+ 'MCP server "docs" is not connected',
532
+ );
533
+ } finally {
534
+ root.unmount();
535
+ }
536
+ });
537
+ });