@assistant-ui/react-mcp 0.0.16 → 0.0.18

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 (106) 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 +19 -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/McpAddFormCancel.d.ts +0 -1
  28. package/dist/primitives/addForm/McpAddFormCancel.d.ts.map +1 -1
  29. package/dist/primitives/addForm/McpAddFormError.d.ts +0 -1
  30. package/dist/primitives/addForm/McpAddFormError.d.ts.map +1 -1
  31. package/dist/primitives/addForm/McpAddFormNameField.d.ts +0 -1
  32. package/dist/primitives/addForm/McpAddFormNameField.d.ts.map +1 -1
  33. package/dist/primitives/addForm/McpAddFormRoot.d.ts +0 -1
  34. package/dist/primitives/addForm/McpAddFormRoot.d.ts.map +1 -1
  35. package/dist/primitives/addForm/McpAddFormSubmit.d.ts +0 -1
  36. package/dist/primitives/addForm/McpAddFormSubmit.d.ts.map +1 -1
  37. package/dist/primitives/addForm/McpAddFormUrlField.d.ts +0 -1
  38. package/dist/primitives/addForm/McpAddFormUrlField.d.ts.map +1 -1
  39. package/dist/primitives/addForm/context.d.ts +0 -1
  40. package/dist/primitives/addForm/context.d.ts.map +1 -1
  41. package/dist/primitives/addForm.d.ts +0 -2
  42. package/dist/primitives/manager/McpManagerAddCustomTrigger.d.ts +0 -1
  43. package/dist/primitives/manager/McpManagerAddCustomTrigger.d.ts.map +1 -1
  44. package/dist/primitives/manager/McpManagerConnectors.d.ts +0 -1
  45. package/dist/primitives/manager/McpManagerConnectors.d.ts.map +1 -1
  46. package/dist/primitives/manager/McpManagerCustomServers.d.ts +0 -1
  47. package/dist/primitives/manager/McpManagerCustomServers.d.ts.map +1 -1
  48. package/dist/primitives/manager/McpManagerRoot.d.ts +0 -1
  49. package/dist/primitives/manager/McpManagerRoot.d.ts.map +1 -1
  50. package/dist/primitives/manager.d.ts +0 -2
  51. package/dist/primitives/server/McpServerConnectButton.d.ts +0 -1
  52. package/dist/primitives/server/McpServerConnectButton.d.ts.map +1 -1
  53. package/dist/primitives/server/McpServerDisconnectButton.d.ts +0 -1
  54. package/dist/primitives/server/McpServerDisconnectButton.d.ts.map +1 -1
  55. package/dist/primitives/server/McpServerError.d.ts +0 -1
  56. package/dist/primitives/server/McpServerError.d.ts.map +1 -1
  57. package/dist/primitives/server/McpServerIcon.d.ts +4 -3
  58. package/dist/primitives/server/McpServerIcon.d.ts.map +1 -1
  59. package/dist/primitives/server/McpServerName.d.ts +0 -1
  60. package/dist/primitives/server/McpServerName.d.ts.map +1 -1
  61. package/dist/primitives/server/McpServerOAuthLink.d.ts +4 -3
  62. package/dist/primitives/server/McpServerOAuthLink.d.ts.map +1 -1
  63. package/dist/primitives/server/McpServerRemoveButton.d.ts +0 -1
  64. package/dist/primitives/server/McpServerRemoveButton.d.ts.map +1 -1
  65. package/dist/primitives/server/McpServerRoot.d.ts +0 -1
  66. package/dist/primitives/server/McpServerRoot.d.ts.map +1 -1
  67. package/dist/primitives/server/McpServerStatus.d.ts +0 -1
  68. package/dist/primitives/server/McpServerStatus.d.ts.map +1 -1
  69. package/dist/primitives/server/McpServerToolName.d.ts +0 -1
  70. package/dist/primitives/server/McpServerToolName.d.ts.map +1 -1
  71. package/dist/primitives/server/McpServerTools.d.ts +0 -1
  72. package/dist/primitives/server/McpServerTools.d.ts.map +1 -1
  73. package/dist/primitives/server.d.ts +0 -2
  74. package/dist/resources/McpManagerResource.d.ts +6 -3
  75. package/dist/resources/McpManagerResource.d.ts.map +1 -1
  76. package/dist/resources/McpManagerResource.js +122 -107
  77. package/dist/resources/McpManagerResource.js.map +1 -1
  78. package/dist/resources/McpServerResource.d.ts +1 -1
  79. package/dist/resources/McpServerResource.d.ts.map +1 -1
  80. package/dist/resources/McpServerResource.js +28 -5
  81. package/dist/resources/McpServerResource.js.map +1 -1
  82. package/dist/resources/storage/McpCustomStorage.d.ts +0 -1
  83. package/dist/resources/storage/McpCustomStorage.d.ts.map +1 -1
  84. package/dist/resources/storage/McpLocalStorage.d.ts +8 -3
  85. package/dist/resources/storage/McpLocalStorage.d.ts.map +1 -1
  86. package/dist/resources/storage/McpLocalStorage.js +55 -3
  87. package/dist/resources/storage/McpLocalStorage.js.map +1 -1
  88. package/dist/resources/storage/McpMemoryStorage.d.ts +0 -1
  89. package/dist/resources/storage/McpMemoryStorage.d.ts.map +1 -1
  90. package/dist/resources/storage/types.d.ts +0 -1
  91. package/dist/resources/storage/types.d.ts.map +1 -1
  92. package/dist/utils/serverId.d.ts +2 -1
  93. package/dist/utils/serverId.d.ts.map +1 -1
  94. package/dist/utils/serverId.js +8 -1
  95. package/dist/utils/serverId.js.map +1 -1
  96. package/package.json +11 -8
  97. package/src/hooks/useMcpOAuthCallback.test.ts +24 -0
  98. package/src/hooks/useMcpOAuthCallback.tsx +20 -5
  99. package/src/mcp-scope.ts +5 -0
  100. package/src/resources/McpManagerResource.test.ts +40 -0
  101. package/src/resources/McpManagerResource.ts +21 -2
  102. package/src/resources/McpServerResource.test.ts +364 -0
  103. package/src/resources/McpServerResource.ts +51 -3
  104. package/src/resources/storage/McpLocalStorage.test.ts +184 -0
  105. package/src/resources/storage/McpLocalStorage.ts +113 -3
  106. package/src/utils/serverId.ts +12 -0
@@ -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
@@ -19,6 +19,7 @@ export type MCPConnector = {
19
19
  url: string;
20
20
  icon?: string | undefined;
21
21
  auth: MCPAuthConfig;
22
+ connectionTimeout?: number | undefined;
22
23
  };
23
24
 
24
25
  export type MCPCustomServerRecord = {
@@ -26,6 +27,7 @@ export type MCPCustomServerRecord = {
26
27
  name: string;
27
28
  url: string;
28
29
  auth: MCPAuthConfig;
30
+ connectionTimeout?: number | undefined;
29
31
  createdAt: number;
30
32
  };
31
33
 
@@ -70,6 +72,8 @@ export type MCPServerMethods = {
70
72
  disconnect: () => Promise<void>;
71
73
  remove: () => Promise<void>;
72
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>;
73
77
  /** Read a resource by URI. Returns the raw MCP `ReadResourceResult`. */
74
78
  readResource: (uri: string) => Promise<unknown>;
75
79
  /** OAuth only: pass full callback URL (e.g. window.location.href) */
@@ -93,6 +97,7 @@ export type MCPManagerMethods = {
93
97
  name: string;
94
98
  url: string;
95
99
  auth: MCPAuthConfig;
100
+ connectionTimeout?: number | undefined;
96
101
  }) => Promise<string>;
97
102
  removeServer: (id: string) => Promise<void>;
98
103
  };
@@ -0,0 +1,40 @@
1
+ import { createTapRoot, useResource } from "@assistant-ui/tap";
2
+ import { describe, expect, it } from "vitest";
3
+ import { defineConnector } from "../connector";
4
+ import type { MCPConnector } from "../mcp-scope";
5
+ import { assertUniqueServerIds } from "../utils/serverId";
6
+ import { McpManagerResource } from "./McpManagerResource";
7
+ import { McpMemoryStorage } from "./storage/McpMemoryStorage";
8
+
9
+ const connector = (id: string, name = id): MCPConnector =>
10
+ defineConnector({
11
+ id,
12
+ name,
13
+ url: `https://example.com/${id}/mcp`,
14
+ auth: { type: "none" },
15
+ });
16
+
17
+ const mount = (connectors: MCPConnector[]) =>
18
+ createTapRoot(function Root() {
19
+ return useResource(
20
+ McpManagerResource({
21
+ connectors,
22
+ storage: McpMemoryStorage(),
23
+ autoConnect: false,
24
+ }),
25
+ );
26
+ });
27
+
28
+ describe("McpManagerResource server ids", () => {
29
+ it("throws when connectors reuse an id", () => {
30
+ expect(() =>
31
+ mount([connector("docs", "Docs"), connector("docs", "Internal Docs")]),
32
+ ).toThrow(
33
+ 'McpManagerResource received duplicate MCP server id "docs". Server ids must be unique because they are used for lookups, OAuth routing, and tool name prefixes.',
34
+ );
35
+ });
36
+
37
+ it("allows distinct ids", () => {
38
+ expect(() => assertUniqueServerIds(["docs", "linear"])).not.toThrow();
39
+ });
40
+ });
@@ -11,6 +11,7 @@ import type { Tool } from "assistant-stream";
11
11
  import { McpServerResource } from "./McpServerResource";
12
12
  import { McpLocalStorage } from "./storage/McpLocalStorage";
13
13
  import type { MCPStorageElement } from "./storage/types";
14
+ import { assertUniqueServerIds } from "../utils/serverId";
14
15
  import type {
15
16
  MCPAuthConfig,
16
17
  MCPConnector,
@@ -25,6 +26,8 @@ export type McpManagerResourceProps = {
25
26
  oauthRedirectUri?: string | undefined;
26
27
  /** Connect on mount when usable auth exists. Default true. */
27
28
  autoConnect?: boolean | undefined;
29
+ /** Optional timeout in milliseconds for connect/listTools calls. Disabled by default. */
30
+ connectionTimeout?: number | undefined;
28
31
  };
29
32
 
30
33
  function defaultRedirectUri(): string {
@@ -42,6 +45,7 @@ const useMcpManagerResource = (
42
45
  const connectors = props.connectors ?? NO_CONNECTORS;
43
46
  const autoConnect = props.autoConnect ?? true;
44
47
  const redirectUri = props.oauthRedirectUri ?? defaultRedirectUri();
48
+ const connectionTimeout = props.connectionTimeout;
45
49
 
46
50
  const storageElement = props.storage ?? McpLocalStorage();
47
51
  const storage = useResource(storageElement);
@@ -101,6 +105,11 @@ const useMcpManagerResource = (
101
105
  }, [customServers]);
102
106
 
103
107
  const serverElements = useMemo(() => {
108
+ assertUniqueServerIds([
109
+ ...connectors.map((c) => c.id),
110
+ ...customServers.map((s) => s.id),
111
+ ]);
112
+
104
113
  const connectorElements = connectors.map((c) =>
105
114
  withKey(
106
115
  c.id,
@@ -114,6 +123,7 @@ const useMcpManagerResource = (
114
123
  storage,
115
124
  redirectUri,
116
125
  autoConnect,
126
+ connectionTimeout: c.connectionTimeout ?? connectionTimeout,
117
127
  onRemove: async () => {
118
128
  // connectors cannot be removed
119
129
  },
@@ -132,6 +142,7 @@ const useMcpManagerResource = (
132
142
  storage,
133
143
  redirectUri,
134
144
  autoConnect,
145
+ connectionTimeout: s.connectionTimeout ?? connectionTimeout,
135
146
  onRemove: async () => {
136
147
  setCustomServers((prev) => prev.filter((x) => x.id !== s.id));
137
148
  },
@@ -139,7 +150,14 @@ const useMcpManagerResource = (
139
150
  ),
140
151
  );
141
152
  return [...connectorElements, ...customElements];
142
- }, [connectors, customServers, storage, redirectUri, autoConnect]);
153
+ }, [
154
+ connectors,
155
+ customServers,
156
+ storage,
157
+ redirectUri,
158
+ autoConnect,
159
+ connectionTimeout,
160
+ ]);
143
161
 
144
162
  const lookup = useClientLookup(serverElements);
145
163
 
@@ -208,7 +226,7 @@ const useMcpManagerResource = (
208
226
  },
209
227
  connector: ({ index }) => serverByKind("connector", index),
210
228
  customServer: ({ index }) => serverByKind("custom", index),
211
- addCustomServer: async ({ name, url, auth }) => {
229
+ addCustomServer: async ({ name, url, auth, connectionTimeout }) => {
212
230
  const record: MCPCustomServerRecord = {
213
231
  id:
214
232
  typeof crypto !== "undefined" && "randomUUID" in crypto
@@ -217,6 +235,7 @@ const useMcpManagerResource = (
217
235
  name,
218
236
  url,
219
237
  auth: auth as MCPAuthConfig,
238
+ connectionTimeout,
220
239
  createdAt: Date.now(),
221
240
  };
222
241
  setCustomServers((prev) => [...prev, record]);
@@ -0,0 +1,364 @@
1
+ import { createTapRoot, useResource } from "@assistant-ui/tap";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import type { MCPAuthConfig } from "../mcp-scope";
4
+ import type { MCPStorage } from "./storage/types";
5
+
6
+ const mocks = vi.hoisted(() => {
7
+ const clients: any[] = [];
8
+ const transports: any[] = [];
9
+ const connectResults: Array<() => Promise<void>> = [];
10
+ const listToolsResults: Array<
11
+ () => Promise<{
12
+ tools: Array<{
13
+ name: string;
14
+ description?: string;
15
+ inputSchema: unknown;
16
+ }>;
17
+ }>
18
+ > = [];
19
+ const finishAuthResults: Array<() => Promise<void>> = [];
20
+
21
+ const Client = vi.fn().mockImplementation(function Client(this: any) {
22
+ const index = clients.length;
23
+ this.connect = vi.fn(() => connectResults[index]?.() ?? Promise.resolve());
24
+ this.listTools = vi.fn(
25
+ () => listToolsResults[index]?.() ?? Promise.resolve({ tools: [] }),
26
+ );
27
+ this.callTool = vi.fn();
28
+ this.listResources = vi.fn(() => Promise.resolve({ resources: [] }));
29
+ this.readResource = vi.fn();
30
+ clients.push(this);
31
+ });
32
+
33
+ const StreamableHTTPClientTransport = vi
34
+ .fn()
35
+ .mockImplementation(function StreamableHTTPClientTransport(this: any) {
36
+ const index = transports.length;
37
+ this.close = vi.fn(() => Promise.resolve());
38
+ this.finishAuth = vi.fn(
39
+ () => finishAuthResults[index]?.() ?? Promise.resolve(),
40
+ );
41
+ transports.push(this);
42
+ });
43
+
44
+ return {
45
+ Client,
46
+ StreamableHTTPClientTransport,
47
+ clients,
48
+ transports,
49
+ connectResults,
50
+ listToolsResults,
51
+ finishAuthResults,
52
+ };
53
+ });
54
+
55
+ vi.mock("@modelcontextprotocol/sdk/client/index.js", () => ({
56
+ Client: mocks.Client,
57
+ }));
58
+
59
+ vi.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
60
+ StreamableHTTPClientTransport: mocks.StreamableHTTPClientTransport,
61
+ }));
62
+
63
+ const { McpServerResource } = await import("./McpServerResource");
64
+
65
+ const never = <T>() => new Promise<T>(() => {});
66
+
67
+ const tick = async () => {
68
+ await Promise.resolve();
69
+ };
70
+
71
+ const flushMacrotask = async () => {
72
+ await new Promise<void>((resolve) => {
73
+ const channel = new MessageChannel();
74
+ channel.port1.onmessage = () => {
75
+ channel.port1.close();
76
+ channel.port2.close();
77
+ resolve();
78
+ };
79
+ channel.port2.postMessage(null);
80
+ });
81
+ };
82
+
83
+ const waitFor = async (predicate: () => boolean) => {
84
+ for (let i = 0; i < 20; i++) {
85
+ if (predicate()) return;
86
+ await tick();
87
+ }
88
+ expect(predicate()).toBe(true);
89
+ };
90
+
91
+ const createStorage = (): MCPStorage => ({
92
+ loadCustomServers: vi.fn(async () => []),
93
+ saveCustomServers: vi.fn(async () => {}),
94
+ loadAuthState: vi.fn(async () => null),
95
+ saveAuthState: vi.fn(async () => {}),
96
+ clearAuthState: vi.fn(async () => {}),
97
+ });
98
+
99
+ const resetMocks = () => {
100
+ mocks.clients.length = 0;
101
+ mocks.transports.length = 0;
102
+ mocks.connectResults.length = 0;
103
+ mocks.listToolsResults.length = 0;
104
+ mocks.finishAuthResults.length = 0;
105
+ mocks.Client.mockClear();
106
+ mocks.StreamableHTTPClientTransport.mockClear();
107
+ };
108
+
109
+ const mount = (props?: {
110
+ auth?: MCPAuthConfig | undefined;
111
+ connectionTimeout?: number | undefined;
112
+ }) => {
113
+ const connectionTimeout =
114
+ props && "connectionTimeout" in props ? props.connectionTimeout : 10_000;
115
+
116
+ return createTapRoot(function Root() {
117
+ return useResource(
118
+ McpServerResource({
119
+ id: "docs",
120
+ kind: "connector",
121
+ name: "Docs",
122
+ url: "https://example.com/mcp",
123
+ auth: props?.auth ?? { type: "none" },
124
+ storage: createStorage(),
125
+ redirectUri: "https://example.com/callback",
126
+ autoConnect: false,
127
+ connectionTimeout,
128
+ onRemove: vi.fn(async () => {}),
129
+ }),
130
+ );
131
+ });
132
+ };
133
+
134
+ describe("McpServerResource connectionTimeout", () => {
135
+ beforeEach(() => {
136
+ vi.useFakeTimers();
137
+ resetMocks();
138
+ });
139
+
140
+ afterEach(() => {
141
+ vi.useRealTimers();
142
+ });
143
+
144
+ it("fails the connection when client.connect hangs", async () => {
145
+ mocks.connectResults.push(() => never());
146
+ const root = mount();
147
+
148
+ try {
149
+ const connectPromise = root.getValue().connect();
150
+ await waitFor(() => mocks.clients[0]?.connect.mock.calls.length === 1);
151
+
152
+ await vi.advanceTimersByTimeAsync(10_000);
153
+ await connectPromise;
154
+
155
+ expect(root.getValue().getState()).toMatchObject({
156
+ connectionState: "error",
157
+ tools: [],
158
+ lastError: {
159
+ message:
160
+ 'MCP server "docs" timed out while connecting after 10000ms.',
161
+ },
162
+ });
163
+ expect(mocks.transports[0].close).toHaveBeenCalledTimes(1);
164
+ } finally {
165
+ root.unmount();
166
+ }
167
+ });
168
+
169
+ it("fails the connection when client.listTools hangs", async () => {
170
+ mocks.listToolsResults.push(() => never());
171
+ const root = mount();
172
+
173
+ try {
174
+ const connectPromise = root.getValue().connect();
175
+ await waitFor(() => mocks.clients[0]?.listTools.mock.calls.length === 1);
176
+
177
+ await vi.advanceTimersByTimeAsync(10_000);
178
+ await connectPromise;
179
+
180
+ expect(root.getValue().getState()).toMatchObject({
181
+ connectionState: "error",
182
+ tools: [],
183
+ lastError: {
184
+ message:
185
+ 'MCP server "docs" timed out while listing tools after 10000ms.',
186
+ },
187
+ });
188
+ expect(mocks.transports[0].close).toHaveBeenCalledTimes(1);
189
+ } finally {
190
+ root.unmount();
191
+ }
192
+ });
193
+
194
+ it("uses one timeout budget for connect and listTools", async () => {
195
+ mocks.connectResults.push(
196
+ () => new Promise<void>((resolve) => setTimeout(resolve, 9_000)),
197
+ );
198
+ mocks.listToolsResults.push(() => never());
199
+ const root = mount();
200
+
201
+ try {
202
+ const connectPromise = root.getValue().connect();
203
+ await waitFor(() => mocks.clients[0]?.connect.mock.calls.length === 1);
204
+
205
+ await vi.advanceTimersByTimeAsync(9_000);
206
+ await waitFor(() => mocks.clients[0]?.listTools.mock.calls.length === 1);
207
+ await vi.advanceTimersByTimeAsync(999);
208
+ await tick();
209
+ expect(root.getValue().getState().connectionState).toBe("connecting");
210
+
211
+ await vi.advanceTimersByTimeAsync(1);
212
+ await connectPromise;
213
+
214
+ expect(root.getValue().getState()).toMatchObject({
215
+ connectionState: "error",
216
+ tools: [],
217
+ lastError: {
218
+ message:
219
+ 'MCP server "docs" timed out while listing tools after 10000ms.',
220
+ },
221
+ });
222
+ expect(mocks.transports[0].close).toHaveBeenCalledTimes(1);
223
+ } finally {
224
+ root.unmount();
225
+ }
226
+ });
227
+
228
+ it("keeps waiting when connectionTimeout is undefined", async () => {
229
+ mocks.connectResults.push(() => never());
230
+ const root = mount({ connectionTimeout: undefined });
231
+
232
+ try {
233
+ void root.getValue().connect();
234
+ await waitFor(() => mocks.clients[0]?.connect.mock.calls.length === 1);
235
+ await vi.advanceTimersByTimeAsync(10_000);
236
+ await tick();
237
+
238
+ expect(root.getValue().getState()).toMatchObject({
239
+ connectionState: "connecting",
240
+ tools: [],
241
+ lastError: null,
242
+ });
243
+ expect(mocks.transports[0].close).not.toHaveBeenCalled();
244
+ } finally {
245
+ root.unmount();
246
+ }
247
+ });
248
+ });
249
+
250
+ describe("McpServerResource completeAuth", () => {
251
+ beforeEach(resetMocks);
252
+
253
+ it("rejects when the callback URL has no authorization code", async () => {
254
+ const root = mount();
255
+
256
+ try {
257
+ await expect(
258
+ root.getValue().completeAuth("https://example.com/callback?state=abc"),
259
+ ).rejects.toThrow("missing authorization code in callback URL");
260
+ await flushMacrotask();
261
+
262
+ expect(root.getValue().getState()).toMatchObject({
263
+ connectionState: "error",
264
+ lastError: {
265
+ message: "missing authorization code in callback URL",
266
+ },
267
+ });
268
+ } finally {
269
+ root.unmount();
270
+ }
271
+ });
272
+
273
+ it("rejects after storing finishAuth failures on the server state", async () => {
274
+ mocks.finishAuthResults.push(() =>
275
+ Promise.reject(new Error("invalid_grant")),
276
+ );
277
+ const root = mount({ auth: { type: "oauth" } });
278
+
279
+ try {
280
+ await expect(
281
+ root.getValue().completeAuth("https://example.com/callback?code=abc"),
282
+ ).rejects.toThrow("invalid_grant");
283
+ await flushMacrotask();
284
+
285
+ expect(root.getValue().getState()).toMatchObject({
286
+ connectionState: "error",
287
+ lastError: {
288
+ message: "invalid_grant",
289
+ },
290
+ });
291
+ expect(mocks.transports[0].finishAuth).toHaveBeenCalledWith("abc");
292
+ expect(mocks.transports[0].close).toHaveBeenCalledTimes(1);
293
+ } finally {
294
+ root.unmount();
295
+ }
296
+ });
297
+ });
298
+
299
+ describe("McpServerResource resource methods", () => {
300
+ beforeEach(() => {
301
+ mocks.clients.length = 0;
302
+ mocks.transports.length = 0;
303
+ mocks.connectResults.length = 0;
304
+ mocks.listToolsResults.length = 0;
305
+ mocks.Client.mockClear();
306
+ mocks.StreamableHTTPClientTransport.mockClear();
307
+ });
308
+
309
+ it("lists resources from a connected server", async () => {
310
+ const result = {
311
+ resources: [
312
+ {
313
+ uri: "docs://intro",
314
+ name: "Intro",
315
+ mimeType: "text/markdown",
316
+ },
317
+ ],
318
+ };
319
+ const root = mount();
320
+
321
+ try {
322
+ await root.getValue().connect();
323
+ mocks.clients[0].listResources.mockResolvedValueOnce(result);
324
+
325
+ await expect(root.getValue().listResources()).resolves.toBe(result);
326
+ expect(mocks.clients[0].listResources).toHaveBeenCalledTimes(1);
327
+ } finally {
328
+ root.unmount();
329
+ }
330
+ });
331
+
332
+ it("forwards the resource pagination cursor", async () => {
333
+ const result = {
334
+ resources: [{ uri: "docs://page-two", name: "Page two" }],
335
+ };
336
+ const root = mount();
337
+
338
+ try {
339
+ await root.getValue().connect();
340
+ mocks.clients[0].listResources.mockResolvedValueOnce(result);
341
+
342
+ await expect(
343
+ root.getValue().listResources({ cursor: "next-page" }),
344
+ ).resolves.toBe(result);
345
+ expect(mocks.clients[0].listResources).toHaveBeenCalledWith({
346
+ cursor: "next-page",
347
+ });
348
+ } finally {
349
+ root.unmount();
350
+ }
351
+ });
352
+
353
+ it("rejects listResources when the server is disconnected", async () => {
354
+ const root = mount();
355
+
356
+ try {
357
+ await expect(root.getValue().listResources()).rejects.toThrow(
358
+ 'MCP server "docs" is not connected',
359
+ );
360
+ } finally {
361
+ root.unmount();
362
+ }
363
+ });
364
+ });
@@ -30,6 +30,7 @@ export type McpServerResourceProps = {
30
30
  storage: MCPStorage;
31
31
  redirectUri: string;
32
32
  autoConnect: boolean;
33
+ connectionTimeout?: number | undefined;
33
34
  onRemove: () => Promise<void>;
34
35
  };
35
36
 
@@ -46,6 +47,35 @@ const useMcpServerResource = (
46
47
  const clientRef = useRef<Client | null>(null);
47
48
  const transportRef = useRef<StreamableHTTPClientTransport | null>(null);
48
49
 
50
+ const withConnectionTimeout = useEffectEvent(
51
+ async <T>(
52
+ promise: Promise<T>,
53
+ phase: "connecting" | "listing tools",
54
+ startedAt: number,
55
+ ): Promise<T> => {
56
+ const timeoutMs = props.connectionTimeout;
57
+ if (timeoutMs === undefined) return await promise;
58
+ const remainingMs = timeoutMs - (Date.now() - startedAt);
59
+ const timeoutError = () =>
60
+ new Error(
61
+ `MCP server "${props.id}" timed out while ${phase} after ${timeoutMs}ms.`,
62
+ );
63
+ if (remainingMs <= 0) throw timeoutError();
64
+
65
+ let timeout: ReturnType<typeof setTimeout> | undefined;
66
+ try {
67
+ return await Promise.race([
68
+ promise,
69
+ new Promise<never>((_, reject) => {
70
+ timeout = setTimeout(() => reject(timeoutError()), remainingMs);
71
+ }),
72
+ ]);
73
+ } finally {
74
+ if (timeout !== undefined) clearTimeout(timeout);
75
+ }
76
+ },
77
+ );
78
+
49
79
  const buildTransport = useEffectEvent(
50
80
  async (): Promise<StreamableHTTPClientTransport> => {
51
81
  if (props.auth.type === "oauth") {
@@ -80,16 +110,25 @@ const useMcpServerResource = (
80
110
  name: "assistant-ui-mcp",
81
111
  version: "0.0.0",
82
112
  });
113
+ const startedAt = Date.now();
83
114
  // SDK's StreamableHTTPClientTransport.sessionId is `string | undefined`
84
115
  // but Transport.sessionId is declared `string?` — under
85
116
  // exactOptionalPropertyTypes the SDK's own classes don't satisfy its
86
117
  // Transport interface. Cast to bridge the gap.
87
- await client.connect(transport as unknown as Transport);
118
+ await withConnectionTimeout(
119
+ client.connect(transport as unknown as Transport),
120
+ "connecting",
121
+ startedAt,
122
+ );
88
123
  // Defer ref assignment until listTools() also succeeds — otherwise a
89
124
  // post-connect failure leaves stale refs that `callTool()` would
90
125
  // happily walk into, producing confusing SDK errors instead of
91
126
  // "not connected".
92
- const list = await client.listTools();
127
+ const list = await withConnectionTimeout(
128
+ client.listTools(),
129
+ "listing tools",
130
+ startedAt,
131
+ );
93
132
  clientRef.current = client;
94
133
  transportRef.current = transport;
95
134
  setTools(
@@ -182,10 +221,12 @@ const useMcpServerResource = (
182
221
  await finalizeConnect(transport);
183
222
  } catch (err) {
184
223
  await closeTransport();
224
+ const error = err instanceof Error ? err : new Error(String(err));
185
225
  setLastError({
186
- message: err instanceof Error ? err.message : String(err),
226
+ message: error.message,
187
227
  });
188
228
  setConnectionState("error");
229
+ throw error;
189
230
  }
190
231
  });
191
232
 
@@ -269,6 +310,13 @@ const useMcpServerResource = (
269
310
  arguments: args as Record<string, unknown> | undefined,
270
311
  });
271
312
  },
313
+ listResources: async (params) => {
314
+ const client = clientRef.current;
315
+ if (!client) {
316
+ throw new Error(`MCP server "${props.id}" is not connected`);
317
+ }
318
+ return await client.listResources(params);
319
+ },
272
320
  readResource: async (uri) => {
273
321
  const client = clientRef.current;
274
322
  if (!client) {