@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
@@ -46,6 +46,64 @@ const useMcpServerResource = (
46
46
 
47
47
  const clientRef = useRef<Client | null>(null);
48
48
  const transportRef = useRef<StreamableHTTPClientTransport | null>(null);
49
+ const pendingTransportRef = useRef<StreamableHTTPClientTransport | null>(
50
+ null,
51
+ );
52
+ const transportCloseQueueRef = useRef(Promise.resolve());
53
+ const connectionGenerationRef = useRef(0);
54
+ const pendingDisposalRef = useRef<{ cancelled: boolean } | null>(null);
55
+ const mountedRef = useRef(true);
56
+
57
+ const closeTransportSafely = async (
58
+ transport: StreamableHTTPClientTransport,
59
+ ): Promise<void> => {
60
+ try {
61
+ await transport.close();
62
+ } catch {
63
+ // ignore close errors
64
+ }
65
+ };
66
+
67
+ const closeQueuedTransports = (
68
+ transports: StreamableHTTPClientTransport[],
69
+ ): Promise<void> => {
70
+ const task = transportCloseQueueRef.current.then(async () => {
71
+ await Promise.all(transports.map(closeTransportSafely));
72
+ });
73
+ transportCloseQueueRef.current = task;
74
+ return task;
75
+ };
76
+
77
+ const closePendingTransport = async () => {
78
+ const transport = pendingTransportRef.current;
79
+ pendingTransportRef.current = null;
80
+ await closeQueuedTransports(transport ? [transport] : []);
81
+ };
82
+
83
+ const closeTransports = async () => {
84
+ const pendingTransport = pendingTransportRef.current;
85
+ const activeTransport = transportRef.current;
86
+ pendingTransportRef.current = null;
87
+ transportRef.current = null;
88
+ clientRef.current = null;
89
+
90
+ const transports = new Set(
91
+ [pendingTransport, activeTransport].filter(
92
+ (transport): transport is StreamableHTTPClientTransport =>
93
+ transport !== null,
94
+ ),
95
+ );
96
+ await closeQueuedTransports([...transports]);
97
+ };
98
+
99
+ const isCurrentConnection = (generation: number) =>
100
+ mountedRef.current && generation === connectionGenerationRef.current;
101
+
102
+ const createInterruptedAuthError = (cause?: unknown) =>
103
+ new Error(
104
+ `MCP server "${props.id}" authorization was interrupted before completion.`,
105
+ cause === undefined ? undefined : { cause },
106
+ );
49
107
 
50
108
  const withConnectionTimeout = useEffectEvent(
51
109
  async <T>(
@@ -105,7 +163,10 @@ const useMcpServerResource = (
105
163
  );
106
164
 
107
165
  const finalizeConnect = useEffectEvent(
108
- async (transport: StreamableHTTPClientTransport) => {
166
+ async (
167
+ transport: StreamableHTTPClientTransport,
168
+ generation: number,
169
+ ): Promise<boolean> => {
109
170
  const client = new Client({
110
171
  name: "assistant-ui-mcp",
111
172
  version: "0.0.0",
@@ -120,6 +181,7 @@ const useMcpServerResource = (
120
181
  "connecting",
121
182
  startedAt,
122
183
  );
184
+ if (!isCurrentConnection(generation)) return false;
123
185
  // Defer ref assignment until listTools() also succeeds — otherwise a
124
186
  // post-connect failure leaves stale refs that `callTool()` would
125
187
  // happily walk into, producing confusing SDK errors instead of
@@ -129,6 +191,9 @@ const useMcpServerResource = (
129
191
  "listing tools",
130
192
  startedAt,
131
193
  );
194
+ if (!isCurrentConnection(generation)) return false;
195
+
196
+ pendingTransportRef.current = null;
132
197
  clientRef.current = client;
133
198
  transportRef.current = transport;
134
199
  setTools(
@@ -142,25 +207,16 @@ const useMcpServerResource = (
142
207
  }),
143
208
  );
144
209
  setConnectionState("connected");
210
+ return true;
145
211
  },
146
212
  );
147
213
 
148
- const closeTransport = async () => {
149
- const t = transportRef.current;
150
- transportRef.current = null;
151
- clientRef.current = null;
152
- if (t) {
153
- try {
154
- await t.close();
155
- } catch {
156
- // ignore close errors
157
- }
158
- }
159
- };
160
-
161
214
  const doConnect = useEffectEvent(async () => {
215
+ const generation = ++connectionGenerationRef.current;
162
216
  // Close any prior transport/client so a re-connect doesn't leak.
163
- await closeTransport();
217
+ await closeTransports();
218
+ if (!isCurrentConnection(generation)) return;
219
+
164
220
  setConnectionState("connecting");
165
221
  setLastError(null);
166
222
  setAuthorizationUrl(null);
@@ -170,24 +226,29 @@ const useMcpServerResource = (
170
226
  let transport: StreamableHTTPClientTransport | null = null;
171
227
  try {
172
228
  transport = await buildTransport();
229
+ if (!isCurrentConnection(generation)) {
230
+ await closeQueuedTransports([transport]);
231
+ return;
232
+ }
233
+ pendingTransportRef.current = transport;
173
234
  // Don't assign to transportRef until connect succeeds — otherwise a
174
235
  // failed `listTools()` leaves an orphaned transport that future
175
236
  // doConnect / doDisconnect calls treat as live.
176
- await finalizeConnect(transport);
237
+ await finalizeConnect(transport, generation);
177
238
  } catch (err) {
239
+ if (!isCurrentConnection(generation)) return;
240
+
178
241
  if (err instanceof UnauthorizedError) {
179
242
  // OAuth: keep the transport alive so completeAuth can call
180
243
  // finishAuth on it. Closing it before storing would leave a
181
244
  // closed transport on transportRef.
245
+ pendingTransportRef.current = null;
182
246
  transportRef.current = transport;
183
247
  setConnectionState("authRequired");
184
248
  } else {
185
249
  if (transport) {
186
- try {
187
- await transport.close();
188
- } catch {
189
- // ignore close errors
190
- }
250
+ pendingTransportRef.current = null;
251
+ await closeQueuedTransports([transport]);
191
252
  }
192
253
  setLastError({
193
254
  message: err instanceof Error ? err.message : String(err),
@@ -198,13 +259,18 @@ const useMcpServerResource = (
198
259
  });
199
260
 
200
261
  const doDisconnect = useEffectEvent(async () => {
262
+ connectionGenerationRef.current += 1;
201
263
  setTools([]);
202
264
  setAuthorizationUrl(null);
203
265
  setConnectionState("disconnected");
204
- await closeTransport();
266
+ await closeTransports();
205
267
  });
206
268
 
207
269
  const doCompleteAuth = useEffectEvent(async (callbackUrl: string) => {
270
+ const generation = ++connectionGenerationRef.current;
271
+ await closePendingTransport();
272
+ if (!isCurrentConnection(generation)) throw createInterruptedAuthError();
273
+
208
274
  setConnectionState("authPending");
209
275
  setLastError(null);
210
276
  try {
@@ -214,17 +280,30 @@ const useMcpServerResource = (
214
280
  let transport = transportRef.current;
215
281
  if (!transport) {
216
282
  transport = await buildTransport();
217
- transportRef.current = transport;
283
+ if (!isCurrentConnection(generation)) {
284
+ await closeQueuedTransports([transport]);
285
+ throw createInterruptedAuthError();
286
+ }
218
287
  }
288
+ transportRef.current = null;
289
+ clientRef.current = null;
290
+ pendingTransportRef.current = transport;
219
291
  await transport.finishAuth(code);
292
+ if (!isCurrentConnection(generation)) throw createInterruptedAuthError();
220
293
  setAuthorizationUrl(null);
221
- await finalizeConnect(transport);
294
+ const connected = await finalizeConnect(transport, generation);
295
+ if (!connected) throw createInterruptedAuthError();
222
296
  } catch (err) {
223
- await closeTransport();
297
+ const error = err instanceof Error ? err : new Error(String(err));
298
+ if (!isCurrentConnection(generation))
299
+ throw createInterruptedAuthError(error);
300
+
301
+ await closeTransports();
224
302
  setLastError({
225
- message: err instanceof Error ? err.message : String(err),
303
+ message: error.message,
226
304
  });
227
305
  setConnectionState("error");
306
+ throw error;
228
307
  }
229
308
  });
230
309
 
@@ -253,14 +332,22 @@ const useMcpServerResource = (
253
332
 
254
333
  // Auto-connect on mount when usable auth exists.
255
334
  useEffect(() => {
335
+ const previousDisposal = pendingDisposalRef.current;
336
+ if (previousDisposal) previousDisposal.cancelled = true;
337
+ const pendingDisposal = { cancelled: false };
338
+ pendingDisposalRef.current = pendingDisposal;
339
+ mountedRef.current = true;
256
340
  const signal = { cancelled: false };
257
341
  void tryAutoConnect(signal);
258
342
  return () => {
343
+ mountedRef.current = false;
259
344
  signal.cancelled = true;
260
- const t = transportRef.current;
261
- transportRef.current = null;
262
- clientRef.current = null;
263
- if (t) t.close().catch(() => {});
345
+ // Defer disposal so StrictMode can replay setup before closing the transport.
346
+ queueMicrotask(() => {
347
+ if (pendingDisposal.cancelled) return;
348
+ connectionGenerationRef.current += 1;
349
+ void closeTransports();
350
+ });
264
351
  };
265
352
  }, []);
266
353
 
@@ -308,6 +395,13 @@ const useMcpServerResource = (
308
395
  arguments: args as Record<string, unknown> | undefined,
309
396
  });
310
397
  },
398
+ listResources: async (params) => {
399
+ const client = clientRef.current;
400
+ if (!client) {
401
+ throw new Error(`MCP server "${props.id}" is not connected`);
402
+ }
403
+ return await client.listResources(params);
404
+ },
311
405
  readResource: async (uri) => {
312
406
  const client = clientRef.current;
313
407
  if (!client) {
@@ -0,0 +1,240 @@
1
+ import { createTapRoot, useResource } from "@assistant-ui/tap";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import {
5
+ McpLocalStorage,
6
+ normalizeCustomServerRecords,
7
+ normalizePersistedAuthState,
8
+ } from "./McpLocalStorage";
9
+
10
+ const validRecord = {
11
+ id: "docs",
12
+ name: "Docs",
13
+ url: "https://docs.example.com/mcp",
14
+ auth: { type: "none" },
15
+ createdAt: 1,
16
+ } as const;
17
+
18
+ describe("normalizeCustomServerRecords", () => {
19
+ it("returns an empty list when the persisted value is not an array", () => {
20
+ expect(normalizeCustomServerRecords({ bad: true })).toEqual([]);
21
+ expect(normalizeCustomServerRecords(null)).toEqual([]);
22
+ });
23
+
24
+ it("filters malformed custom server entries", () => {
25
+ expect(
26
+ normalizeCustomServerRecords([
27
+ validRecord,
28
+ null,
29
+ { ...validRecord, id: "" },
30
+ { ...validRecord, id: "docs__search" },
31
+ { ...validRecord, name: "" },
32
+ { ...validRecord, url: " " },
33
+ { ...validRecord, name: 123 },
34
+ { ...validRecord, url: null },
35
+ { ...validRecord, createdAt: Number.NaN },
36
+ { ...validRecord, auth: { type: "bearer", token: "" } },
37
+ { ...validRecord, auth: { type: "bearer", token: 123 } },
38
+ ]),
39
+ ).toEqual([validRecord]);
40
+ });
41
+
42
+ it("strips malformed connection timeouts without dropping servers", () => {
43
+ expect(
44
+ normalizeCustomServerRecords([
45
+ { ...validRecord, id: "string-timeout", connectionTimeout: "10000" },
46
+ { ...validRecord, id: "null-timeout", connectionTimeout: null },
47
+ { ...validRecord, id: "negative-timeout", connectionTimeout: -1 },
48
+ { ...validRecord, id: "nan-timeout", connectionTimeout: Number.NaN },
49
+ {
50
+ ...validRecord,
51
+ id: "infinite-timeout",
52
+ connectionTimeout: Number.POSITIVE_INFINITY,
53
+ },
54
+ ]),
55
+ ).toEqual([
56
+ { ...validRecord, id: "string-timeout" },
57
+ { ...validRecord, id: "null-timeout" },
58
+ { ...validRecord, id: "negative-timeout" },
59
+ { ...validRecord, id: "nan-timeout" },
60
+ { ...validRecord, id: "infinite-timeout" },
61
+ ]);
62
+ });
63
+
64
+ it("accepts finite non-negative connection timeouts", () => {
65
+ expect(
66
+ normalizeCustomServerRecords([
67
+ { ...validRecord, connectionTimeout: 0 },
68
+ { ...validRecord, connectionTimeout: 10_000 },
69
+ ]),
70
+ ).toEqual([
71
+ { ...validRecord, connectionTimeout: 0 },
72
+ { ...validRecord, connectionTimeout: 10_000 },
73
+ ]);
74
+ });
75
+
76
+ it("accepts persisted bearer and oauth auth configs", () => {
77
+ expect(
78
+ normalizeCustomServerRecords([
79
+ {
80
+ ...validRecord,
81
+ id: "private-docs",
82
+ auth: { type: "bearer", token: "token" },
83
+ },
84
+ {
85
+ ...validRecord,
86
+ id: "oauth-docs",
87
+ auth: {
88
+ type: "oauth",
89
+ scopes: ["docs.read"],
90
+ authorizationEndpoint: "https://docs.example.com/oauth/authorize",
91
+ tokenEndpoint: "https://docs.example.com/oauth/token",
92
+ },
93
+ },
94
+ ]),
95
+ ).toHaveLength(2);
96
+ });
97
+ });
98
+
99
+ describe("normalizePersistedAuthState", () => {
100
+ it("returns null when the persisted value is not an object", () => {
101
+ expect(normalizePersistedAuthState(null)).toBeNull();
102
+ expect(normalizePersistedAuthState("bad")).toBeNull();
103
+ expect(normalizePersistedAuthState([])).toBeNull();
104
+ });
105
+
106
+ it("drops malformed auth state fields", () => {
107
+ expect(
108
+ normalizePersistedAuthState({
109
+ token: "",
110
+ codeVerifier: 123,
111
+ tokens: {
112
+ access_token: "access-token",
113
+ token_type: "Bearer",
114
+ expires_in: Number.POSITIVE_INFINITY,
115
+ },
116
+ clientInformation: { client_id: "client-id" },
117
+ }),
118
+ ).toBeNull();
119
+ });
120
+
121
+ it("keeps valid bearer and OAuth callback state", () => {
122
+ expect(
123
+ normalizePersistedAuthState({
124
+ token: "bearer-token",
125
+ codeVerifier: "pkce-verifier",
126
+ }),
127
+ ).toEqual({
128
+ token: "bearer-token",
129
+ codeVerifier: "pkce-verifier",
130
+ });
131
+ });
132
+
133
+ it("keeps valid OAuth tokens and client information", () => {
134
+ const tokens = {
135
+ access_token: "access-token",
136
+ token_type: "Bearer",
137
+ refresh_token: "refresh-token",
138
+ expires_in: 3600,
139
+ scope: "docs.read",
140
+ };
141
+ const clientInformation = {
142
+ client_id: "client-id",
143
+ client_secret: "client-secret",
144
+ redirect_uris: ["http://localhost/callback"],
145
+ };
146
+
147
+ expect(
148
+ normalizePersistedAuthState({
149
+ tokens,
150
+ clientInformation,
151
+ }),
152
+ ).toEqual({
153
+ tokens,
154
+ clientInformation,
155
+ });
156
+ });
157
+
158
+ it("keeps valid fields when neighboring fields are malformed", () => {
159
+ expect(
160
+ normalizePersistedAuthState({
161
+ token: "bearer-token",
162
+ codeVerifier: 123,
163
+ tokens: { access_token: "access-token" },
164
+ clientInformation: "not-client-info",
165
+ }),
166
+ ).toEqual({
167
+ token: "bearer-token",
168
+ });
169
+ });
170
+ });
171
+
172
+ const createStorage = (): Storage => {
173
+ const data = new Map<string, string>();
174
+ return {
175
+ get length() {
176
+ return data.size;
177
+ },
178
+ clear: () => {
179
+ data.clear();
180
+ },
181
+ getItem: (key) => data.get(key) ?? null,
182
+ key: (index) => Array.from(data.keys())[index] ?? null,
183
+ removeItem: (key) => {
184
+ data.delete(key);
185
+ },
186
+ setItem: (key, value) => {
187
+ data.set(key, value);
188
+ },
189
+ };
190
+ };
191
+
192
+ const loadStorage = (storage: Storage) =>
193
+ createTapRoot(function McpStorageRoot() {
194
+ return useResource(
195
+ McpLocalStorage({
196
+ keyPrefix: "test-mcp",
197
+ storage,
198
+ }),
199
+ );
200
+ }).getValue();
201
+
202
+ describe("McpLocalStorage custom servers", () => {
203
+ it("strips malformed connection timeouts when loading", async () => {
204
+ const storage = createStorage();
205
+ storage.setItem(
206
+ "test-mcp:custom-servers",
207
+ JSON.stringify([
208
+ validRecord,
209
+ {
210
+ ...validRecord,
211
+ id: "bad-timeout",
212
+ connectionTimeout: "immediately",
213
+ },
214
+ ]),
215
+ );
216
+
217
+ await expect(loadStorage(storage).loadCustomServers()).resolves.toEqual([
218
+ validRecord,
219
+ { ...validRecord, id: "bad-timeout" },
220
+ ]);
221
+ });
222
+ });
223
+
224
+ describe("McpLocalStorage auth state", () => {
225
+ it("normalizes loaded auth state from localStorage", async () => {
226
+ const storage = createStorage();
227
+ storage.setItem(
228
+ "test-mcp:auth:docs",
229
+ JSON.stringify({
230
+ token: "bearer-token",
231
+ codeVerifier: 123,
232
+ tokens: "not-tokens",
233
+ }),
234
+ );
235
+
236
+ await expect(loadStorage(storage).loadAuthState("docs")).resolves.toEqual({
237
+ token: "bearer-token",
238
+ });
239
+ });
240
+ });
@@ -1,6 +1,11 @@
1
1
  import { resource } from "@assistant-ui/tap";
2
- import type { MCPCustomServerRecord } from "../../mcp-scope";
2
+ import {
3
+ OAuthClientInformationFullSchema,
4
+ OAuthTokensSchema,
5
+ } from "@modelcontextprotocol/sdk/shared/auth.js";
6
+ import type { MCPAuthConfig, MCPCustomServerRecord } from "../../mcp-scope";
3
7
  import type { MCPPersistedAuthState } from "../../auth/types";
8
+ import { assertValidServerId } from "../../utils/serverId";
4
9
  import type { MCPStorage } from "./types";
5
10
 
6
11
  export type McpLocalStorageOptions = {
@@ -22,6 +27,132 @@ function resolveStorage(opts: McpLocalStorageOptions): Storage | null {
22
27
  return null;
23
28
  }
24
29
 
30
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
31
+ typeof value === "object" && value !== null && !Array.isArray(value);
32
+
33
+ const isOptionalString = (value: unknown): value is string | undefined =>
34
+ value === undefined || typeof value === "string";
35
+
36
+ const isNonEmptyString = (value: unknown): value is string =>
37
+ typeof value === "string" && value.trim().length > 0;
38
+
39
+ const isOptionalNonEmptyString = (
40
+ value: unknown,
41
+ ): value is string | undefined =>
42
+ value === undefined || isNonEmptyString(value);
43
+
44
+ const isOptionalStringArray = (value: unknown): value is string[] | undefined =>
45
+ value === undefined ||
46
+ (Array.isArray(value) && value.every((item) => typeof item === "string"));
47
+
48
+ const isOptionalConnectionTimeout = (
49
+ value: unknown,
50
+ ): value is number | undefined =>
51
+ value === undefined ||
52
+ (typeof value === "number" && Number.isFinite(value) && value >= 0);
53
+
54
+ const isValidServerId = (id: string): boolean => {
55
+ try {
56
+ assertValidServerId(id);
57
+ return true;
58
+ } catch {
59
+ return false;
60
+ }
61
+ };
62
+
63
+ const isMCPAuthConfig = (auth: unknown): auth is MCPAuthConfig => {
64
+ if (!isRecord(auth)) return false;
65
+
66
+ switch (auth.type) {
67
+ case "none":
68
+ return true;
69
+ case "bearer":
70
+ return isOptionalNonEmptyString(auth.token);
71
+ case "oauth":
72
+ return (
73
+ isOptionalStringArray(auth.scopes) &&
74
+ isOptionalString(auth.authorizationEndpoint) &&
75
+ isOptionalString(auth.tokenEndpoint) &&
76
+ isOptionalString(auth.registrationEndpoint) &&
77
+ isOptionalString(auth.clientId) &&
78
+ isOptionalString(auth.clientSecret)
79
+ );
80
+ default:
81
+ return false;
82
+ }
83
+ };
84
+
85
+ const isCustomServerRecord = (
86
+ value: unknown,
87
+ ): value is MCPCustomServerRecord => {
88
+ if (!isRecord(value)) return false;
89
+ if (typeof value.id !== "string" || !isValidServerId(value.id)) {
90
+ return false;
91
+ }
92
+ return (
93
+ isNonEmptyString(value.name) &&
94
+ isNonEmptyString(value.url) &&
95
+ Number.isFinite(value.createdAt) &&
96
+ isMCPAuthConfig(value.auth) &&
97
+ isOptionalConnectionTimeout(value.connectionTimeout)
98
+ );
99
+ };
100
+
101
+ const normalizeCustomServerRecord = (
102
+ value: unknown,
103
+ ): MCPCustomServerRecord | null => {
104
+ if (isCustomServerRecord(value)) return value;
105
+ if (!isRecord(value)) return null;
106
+
107
+ const record = { ...value };
108
+ delete record.connectionTimeout;
109
+ return isCustomServerRecord(record) ? record : null;
110
+ };
111
+
112
+ export const normalizeCustomServerRecords = (
113
+ value: unknown,
114
+ ): MCPCustomServerRecord[] => {
115
+ if (!Array.isArray(value)) return [];
116
+ return value.flatMap((item) => {
117
+ const record = normalizeCustomServerRecord(item);
118
+ return record === null ? [] : [record];
119
+ });
120
+ };
121
+
122
+ const normalizeOAuthTokens = (
123
+ value: unknown,
124
+ ): MCPPersistedAuthState["tokens"] | undefined => {
125
+ const result = OAuthTokensSchema.safeParse(value);
126
+ return result.success ? result.data : undefined;
127
+ };
128
+
129
+ const normalizeClientInformation = (
130
+ value: unknown,
131
+ ): MCPPersistedAuthState["clientInformation"] | undefined => {
132
+ const result = OAuthClientInformationFullSchema.safeParse(value);
133
+ return result.success ? result.data : undefined;
134
+ };
135
+
136
+ export const normalizePersistedAuthState = (
137
+ value: unknown,
138
+ ): MCPPersistedAuthState | null => {
139
+ if (!isRecord(value)) return null;
140
+
141
+ const state: MCPPersistedAuthState = {};
142
+ if (isNonEmptyString(value.token)) state.token = value.token;
143
+ if (isNonEmptyString(value.codeVerifier)) {
144
+ state.codeVerifier = value.codeVerifier;
145
+ }
146
+
147
+ const tokens = normalizeOAuthTokens(value.tokens);
148
+ if (tokens) state.tokens = tokens;
149
+
150
+ const clientInformation = normalizeClientInformation(value.clientInformation);
151
+ if (clientInformation) state.clientInformation = clientInformation;
152
+
153
+ return Object.keys(state).length > 0 ? state : null;
154
+ };
155
+
25
156
  const useMcpLocalStorage = (opts: McpLocalStorageOptions = {}): MCPStorage => {
26
157
  const prefix = opts.keyPrefix ?? "aui-mcp";
27
158
  const customServersKey = `${prefix}:custom-servers`;
@@ -59,12 +190,12 @@ const useMcpLocalStorage = (opts: McpLocalStorageOptions = {}): MCPStorage => {
59
190
 
60
191
  return {
61
192
  loadCustomServers: async () =>
62
- read<MCPCustomServerRecord[]>(customServersKey, []),
193
+ normalizeCustomServerRecords(read<unknown>(customServersKey, [])),
63
194
  saveCustomServers: async (records) => {
64
195
  write(customServersKey, records);
65
196
  },
66
197
  loadAuthState: async (id) =>
67
- read<MCPPersistedAuthState | null>(authKey(id), null),
198
+ normalizePersistedAuthState(read<unknown>(authKey(id), null)),
68
199
  saveAuthState: async (id, state) => {
69
200
  write(authKey(id), state);
70
201
  },