@assistant-ui/react-mcp 0.1.14 → 0.1.16

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 (52) hide show
  1. package/dist/auth/createOAuthProvider.d.ts +7 -1
  2. package/dist/auth/createOAuthProvider.d.ts.map +1 -1
  3. package/dist/auth/createOAuthProvider.js +115 -31
  4. package/dist/auth/createOAuthProvider.js.map +1 -1
  5. package/dist/auth/types.d.ts +1 -0
  6. package/dist/auth/types.d.ts.map +1 -1
  7. package/dist/hooks/useMcpOAuthCallback.d.ts.map +1 -1
  8. package/dist/hooks/useMcpOAuthCallback.js +5 -6
  9. package/dist/hooks/useMcpOAuthCallback.js.map +1 -1
  10. package/dist/primitives/server/McpServerIcon.js.map +1 -1
  11. package/dist/primitives/server/McpServerOAuthLink.js.map +1 -1
  12. package/dist/resources/McpManagerResource.d.ts.map +1 -1
  13. package/dist/resources/McpManagerResource.js +4 -2
  14. package/dist/resources/McpManagerResource.js.map +1 -1
  15. package/dist/resources/McpServerResource.d.ts +2 -1
  16. package/dist/resources/McpServerResource.d.ts.map +1 -1
  17. package/dist/resources/McpServerResource.js +107 -21
  18. package/dist/resources/McpServerResource.js.map +1 -1
  19. package/dist/resources/storage/McpLocalStorage.d.ts +7 -0
  20. package/dist/resources/storage/McpLocalStorage.d.ts.map +1 -1
  21. package/dist/resources/storage/McpLocalStorage.js +132 -37
  22. package/dist/resources/storage/McpLocalStorage.js.map +1 -1
  23. package/dist/resources/storage/McpMemoryStorage.d.ts.map +1 -1
  24. package/dist/resources/storage/McpMemoryStorage.js +9 -6
  25. package/dist/resources/storage/McpMemoryStorage.js.map +1 -1
  26. package/dist/resources/storage/types.d.ts +12 -0
  27. package/dist/resources/storage/types.d.ts.map +1 -1
  28. package/dist/utils/createMcpId.d.ts +9 -0
  29. package/dist/utils/createMcpId.d.ts.map +1 -0
  30. package/dist/utils/createMcpId.js +11 -0
  31. package/dist/utils/createMcpId.js.map +1 -0
  32. package/dist/utils/invokeMcpCallback.d.ts.map +1 -1
  33. package/dist/utils/invokeMcpCallback.js +2 -12
  34. package/dist/utils/invokeMcpCallback.js.map +1 -1
  35. package/package.json +8 -8
  36. package/src/auth/createOAuthProvider.test.ts +407 -2
  37. package/src/auth/createOAuthProvider.ts +171 -40
  38. package/src/auth/types.ts +1 -0
  39. package/src/hooks/useMcpOAuthCallback.test.ts +74 -1
  40. package/src/hooks/useMcpOAuthCallback.tsx +11 -8
  41. package/src/resources/McpManagerResource.test.ts +128 -0
  42. package/src/resources/McpManagerResource.ts +4 -5
  43. package/src/resources/McpServerResource.test.ts +420 -18
  44. package/src/resources/McpServerResource.ts +148 -27
  45. package/src/resources/storage/McpLocalStorage.test.ts +71 -1
  46. package/src/resources/storage/McpLocalStorage.ts +69 -47
  47. package/src/resources/storage/McpMemoryStorage.test.ts +99 -0
  48. package/src/resources/storage/McpMemoryStorage.ts +23 -17
  49. package/src/resources/storage/types.ts +12 -0
  50. package/src/utils/createMcpId.test.ts +25 -0
  51. package/src/utils/createMcpId.ts +10 -0
  52. package/src/utils/invokeMcpCallback.ts +3 -21
@@ -1,5 +1,5 @@
1
1
  import { useState, useRef, useEffect, useMemo, useEffectEvent } from "react";
2
- import { resource } from "@assistant-ui/tap";
2
+ import { resource, useResource, withKey } from "@assistant-ui/tap";
3
3
  import type { ClientOutput } from "@assistant-ui/store";
4
4
  import {
5
5
  Client,
@@ -10,7 +10,10 @@ import {
10
10
  type ElicitResult,
11
11
  type StreamableHTTPClientTransportOptions,
12
12
  } from "@modelcontextprotocol/client";
13
- import { createOAuthProvider } from "../auth/createOAuthProvider";
13
+ import {
14
+ clearOAuthProviderAuthState,
15
+ createOAuthProvider,
16
+ } from "../auth/createOAuthProvider";
14
17
  import { buildHeaders } from "../auth/buildHeaders";
15
18
  import { assertValidServerId } from "../utils/serverId";
16
19
  import { validateElicitationContent } from "./validateElicitationContent";
@@ -24,6 +27,7 @@ import type {
24
27
  MCPServerState,
25
28
  MCPToolInfo,
26
29
  } from "../mcp-scope";
30
+ import { createMcpId } from "../utils/createMcpId";
27
31
 
28
32
  export type McpServerResourceProps = {
29
33
  id: string;
@@ -41,8 +45,50 @@ export type McpServerResourceProps = {
41
45
  onRemove: () => Promise<void>;
42
46
  };
43
47
 
44
- const useMcpServerResource = (
48
+ type McpServerResourceInstanceProps = McpServerResourceProps & {
49
+ transportCloseQueueRef: { current: Promise<void> };
50
+ };
51
+
52
+ export const getConnectionDependencies = (
45
53
  props: McpServerResourceProps,
54
+ ): readonly unknown[] => {
55
+ const auth = props.auth;
56
+ const authDependencies =
57
+ auth.type === "bearer"
58
+ ? [auth.type, auth.token, props.storage.scopeId]
59
+ : auth.type === "oauth"
60
+ ? [
61
+ auth.type,
62
+ auth.scopes?.length,
63
+ ...(auth.scopes ?? []),
64
+ auth.authorizationEndpoint,
65
+ auth.tokenEndpoint,
66
+ auth.registrationEndpoint,
67
+ auth.clientId,
68
+ auth.clientSecret,
69
+ props.storage.scopeId,
70
+ ]
71
+ : [auth.type];
72
+
73
+ return [
74
+ props.id,
75
+ props.url,
76
+ ...authDependencies,
77
+ props.redirectUri,
78
+ props.cache?.defaultTtlMs,
79
+ props.elicitation !== false,
80
+ ];
81
+ };
82
+
83
+ const areConnectionDependenciesEqual = (
84
+ left: readonly unknown[],
85
+ right: readonly unknown[],
86
+ ) =>
87
+ left.length === right.length &&
88
+ left.every((value, index) => Object.is(value, right[index]));
89
+
90
+ const useMcpServerResourceInstance = (
91
+ props: McpServerResourceInstanceProps,
46
92
  ): ClientOutput<"mcpServer"> => {
47
93
  assertValidServerId(props.id);
48
94
  const [connectionState, setConnectionState] =
@@ -59,8 +105,12 @@ const useMcpServerResource = (
59
105
  const pendingTransportRef = useRef<StreamableHTTPClientTransport | null>(
60
106
  null,
61
107
  );
62
- const transportCloseQueueRef = useRef(Promise.resolve());
63
108
  const connectionGenerationRef = useRef(0);
109
+ const pendingAuthValidationRef = useRef<{
110
+ count: number;
111
+ promise: Promise<void>;
112
+ resolve: () => void;
113
+ } | null>(null);
64
114
  const elicitationResolversRef = useRef(
65
115
  new Map<
66
116
  string,
@@ -88,10 +138,10 @@ const useMcpServerResource = (
88
138
  const closeQueuedTransports = (
89
139
  transports: StreamableHTTPClientTransport[],
90
140
  ): Promise<void> => {
91
- const task = transportCloseQueueRef.current.then(async () => {
141
+ const task = props.transportCloseQueueRef.current.then(async () => {
92
142
  await Promise.all(transports.map(closeTransportSafely));
93
143
  });
94
- transportCloseQueueRef.current = task;
144
+ props.transportCloseQueueRef.current = task;
95
145
  return task;
96
146
  };
97
147
 
@@ -132,7 +182,7 @@ const useMcpServerResource = (
132
182
  }
133
183
  };
134
184
 
135
- const closeTransports = async () => {
185
+ const detachTransports = () => {
136
186
  cancelPendingElicitations();
137
187
  const pendingTransport = pendingTransportRef.current;
138
188
  const activeTransport = transportRef.current;
@@ -140,13 +190,18 @@ const useMcpServerResource = (
140
190
  transportRef.current = null;
141
191
  clientRef.current = null;
142
192
 
143
- const transports = new Set(
144
- [pendingTransport, activeTransport].filter(
145
- (transport): transport is StreamableHTTPClientTransport =>
146
- transport !== null,
193
+ return [
194
+ ...new Set(
195
+ [pendingTransport, activeTransport].filter(
196
+ (transport): transport is StreamableHTTPClientTransport =>
197
+ transport !== null,
198
+ ),
147
199
  ),
148
- );
149
- await closeQueuedTransports([...transports]);
200
+ ];
201
+ };
202
+
203
+ const closeTransports = async () => {
204
+ await closeQueuedTransports(detachTransports());
150
205
  };
151
206
 
152
207
  const isCurrentConnection = (generation: number) =>
@@ -305,10 +360,7 @@ const useMcpServerResource = (
305
360
  }
306
361
  const { message, requestedSchema } = request.params;
307
362
 
308
- const id =
309
- typeof crypto !== "undefined" && "randomUUID" in crypto
310
- ? crypto.randomUUID()
311
- : `mcp-${Date.now()}-${Math.random().toString(36).slice(2)}`;
363
+ const id = createMcpId();
312
364
  const promise = new Promise<ElicitResult>((resolve) => {
313
365
  const onAbort = () => {
314
366
  resolvePendingElicitation(id, { action: "cancel" });
@@ -421,6 +473,45 @@ const useMcpServerResource = (
421
473
  });
422
474
 
423
475
  const doCompleteAuth = useEffectEvent(async (callbackUrl: string) => {
476
+ const validationGeneration = connectionGenerationRef.current;
477
+ const url = new URL(callbackUrl);
478
+ const state = url.searchParams.get("state");
479
+ if (!state) throw new Error('missing "state" parameter');
480
+ let pendingAuthValidation = pendingAuthValidationRef.current;
481
+ if (!pendingAuthValidation) {
482
+ let resolve!: () => void;
483
+ const promise = new Promise<void>((resolvePromise) => {
484
+ resolve = resolvePromise;
485
+ });
486
+ pendingAuthValidation = { count: 0, promise, resolve };
487
+ pendingAuthValidationRef.current = pendingAuthValidation;
488
+ }
489
+ pendingAuthValidation.count += 1;
490
+ try {
491
+ const persisted = await props.storage.loadAuthState(props.id);
492
+ if (!isCurrentConnection(validationGeneration)) {
493
+ throw createInterruptedAuthError();
494
+ }
495
+ if (!persisted?.state) {
496
+ throw new Error(
497
+ "no pending OAuth authorization request for this server",
498
+ );
499
+ }
500
+ if (persisted.state !== state) {
501
+ throw new Error("OAuth state does not match the authorization request");
502
+ }
503
+ if (!url.searchParams.get("code") && !url.searchParams.get("error")) {
504
+ throw new Error("missing authorization code in callback URL");
505
+ }
506
+ } finally {
507
+ pendingAuthValidation.count -= 1;
508
+ if (pendingAuthValidation.count === 0) {
509
+ pendingAuthValidationRef.current = null;
510
+ pendingAuthValidation.resolve();
511
+ }
512
+ }
513
+
514
+ // Claim the generation before a waiting auto-connect can resume.
424
515
  const generation = ++connectionGenerationRef.current;
425
516
  cancelPendingElicitations();
426
517
  await closePendingTransport();
@@ -429,9 +520,6 @@ const useMcpServerResource = (
429
520
  setConnectionState("authPending");
430
521
  setLastError(null);
431
522
  try {
432
- const url = new URL(callbackUrl);
433
- const code = url.searchParams.get("code");
434
- if (!code) throw new Error("missing authorization code in callback URL");
435
523
  let transport = transportRef.current;
436
524
  if (!transport) {
437
525
  transport = await buildTransport();
@@ -443,7 +531,7 @@ const useMcpServerResource = (
443
531
  transportRef.current = null;
444
532
  clientRef.current = null;
445
533
  pendingTransportRef.current = transport;
446
- await transport.finishAuth(code);
534
+ await transport.finishAuth(url.searchParams);
447
535
  if (!isCurrentConnection(generation)) throw createInterruptedAuthError();
448
536
  setAuthorizationUrl(null);
449
537
  const connected = await finalizeConnect(transport, generation);
@@ -493,12 +581,18 @@ const useMcpServerResource = (
493
581
  } else if (!persisted?.token) {
494
582
  return;
495
583
  }
584
+ const pendingAuthValidation = pendingAuthValidationRef.current;
585
+ if (pendingAuthValidation) {
586
+ await pendingAuthValidation.promise;
587
+ if (signal.cancelled || !isCurrentConnection(generation)) return;
588
+ }
496
589
  void doConnect();
497
590
  },
498
591
  );
499
592
 
500
593
  // Auto-connect on mount when usable auth exists.
501
594
  useEffect(() => {
595
+ const transportCloseQueueRef = props.transportCloseQueueRef;
502
596
  const previousDisposal = pendingDisposalRef.current;
503
597
  if (previousDisposal) previousDisposal.cancelled = true;
504
598
  const pendingDisposal = { cancelled: false };
@@ -509,14 +603,15 @@ const useMcpServerResource = (
509
603
  return () => {
510
604
  mountedRef.current = false;
511
605
  signal.cancelled = true;
512
- // Defer disposal so StrictMode can replay setup before closing the transport.
513
- queueMicrotask(() => {
606
+ const task = transportCloseQueueRef.current.then(async () => {
607
+ await new Promise<void>((resolve) => queueMicrotask(resolve));
514
608
  if (pendingDisposal.cancelled) return;
515
609
  connectionGenerationRef.current += 1;
516
- void closeTransports();
610
+ await Promise.all(detachTransports().map(closeTransportSafely));
517
611
  });
612
+ transportCloseQueueRef.current = task;
518
613
  };
519
- }, []);
614
+ }, [props.transportCloseQueueRef]);
520
615
 
521
616
  const state = useMemo<MCPServerState>(
522
617
  () => ({
@@ -552,7 +647,7 @@ const useMcpServerResource = (
552
647
  remove: async () => {
553
648
  await doDisconnect();
554
649
  try {
555
- await props.storage.clearAuthState(props.id);
650
+ await clearOAuthProviderAuthState(props.storage, props.id);
556
651
  await props.onRemove();
557
652
  } catch (err) {
558
653
  setLastError({
@@ -640,4 +735,30 @@ const useMcpServerResource = (
640
735
  };
641
736
  };
642
737
 
643
- export const McpServerResource = resource(useMcpServerResource);
738
+ const McpServerResourceInstance = resource(useMcpServerResourceInstance);
739
+
740
+ export const McpServerResource = resource(function useMcpServerResource(
741
+ props: McpServerResourceProps,
742
+ ): ClientOutput<"mcpServer"> {
743
+ const transportCloseQueueRef = useRef(Promise.resolve());
744
+ const dependencies = getConnectionDependencies(props);
745
+ const [connection, setConnection] = useState({ dependencies, generation: 0 });
746
+ let currentConnection = connection;
747
+ if (!areConnectionDependenciesEqual(connection.dependencies, dependencies)) {
748
+ currentConnection = {
749
+ dependencies,
750
+ generation: connection.generation + 1,
751
+ };
752
+ setConnection(currentConnection);
753
+ }
754
+
755
+ // Storage keys remounts through its optional scopeId rather than object
756
+ // identity, because a defaulted storage element is rebuilt on ordinary
757
+ // renders.
758
+ return useResource(
759
+ withKey(
760
+ currentConnection.generation,
761
+ McpServerResourceInstance({ ...props, transportCloseQueueRef }),
762
+ ),
763
+ );
764
+ });
@@ -1,8 +1,10 @@
1
- import { createTapRoot, useResource } from "@assistant-ui/tap";
1
+ import { createTapRoot, resource, useResource } from "@assistant-ui/tap";
2
+ import { useState } from "react";
2
3
  import { auth, type FetchLike } from "@modelcontextprotocol/client";
3
4
  import { describe, expect, it } from "vitest";
4
5
  import { createOAuthProvider } from "../../auth/createOAuthProvider";
5
6
 
7
+ import type { MCPStorage } from "./types";
6
8
  import {
7
9
  McpLocalStorage,
8
10
  normalizeCustomServerRecords,
@@ -125,10 +127,12 @@ describe("normalizePersistedAuthState", () => {
125
127
  normalizePersistedAuthState({
126
128
  token: "bearer-token",
127
129
  codeVerifier: "pkce-verifier",
130
+ state: "aui-mcp:ZG9jcw.nonce",
128
131
  }),
129
132
  ).toEqual({
130
133
  token: "bearer-token",
131
134
  codeVerifier: "pkce-verifier",
135
+ state: "aui-mcp:ZG9jcw.nonce",
132
136
  });
133
137
  });
134
138
 
@@ -394,6 +398,7 @@ describe("McpLocalStorage auth state", () => {
394
398
  JSON.parse(storage.getItem("test-mcp:auth:docs") ?? "null"),
395
399
  ).toMatchObject({
396
400
  codeVerifier: expect.any(String),
401
+ state: authorizationUrls[0]!.searchParams.get("state"),
397
402
  discoveryState: {
398
403
  authorizationServerUrl: "https://auth.example.com",
399
404
  },
@@ -418,3 +423,68 @@ describe("McpLocalStorage auth state", () => {
418
423
  });
419
424
  });
420
425
  });
426
+
427
+ describe("McpLocalStorage instance identity", () => {
428
+ it("derives a scope from the prefix for the shared default backing", () => {
429
+ let storage!: MCPStorage;
430
+
431
+ createTapRoot(function McpStorageScopeRoot() {
432
+ storage = useResource(McpLocalStorage({ keyPrefix: "test-mcp" }));
433
+ return storage;
434
+ });
435
+
436
+ expect(storage.scopeId).toBe("local-storage:test-mcp");
437
+ });
438
+
439
+ it("declares no scope for a custom backing store unless one is named", () => {
440
+ const backing = createStorage();
441
+ let unnamed!: MCPStorage;
442
+ let named!: MCPStorage;
443
+
444
+ createTapRoot(function McpStorageCustomScopeRoot() {
445
+ unnamed = useResource(
446
+ McpLocalStorage({ keyPrefix: "test-mcp", storage: backing }),
447
+ );
448
+ return unnamed;
449
+ });
450
+ createTapRoot(function McpStorageNamedScopeRoot() {
451
+ named = useResource(
452
+ McpLocalStorage({
453
+ keyPrefix: "test-mcp",
454
+ storage: backing,
455
+ scopeId: "session:alpha",
456
+ }),
457
+ );
458
+ return named;
459
+ });
460
+
461
+ expect(unnamed.scopeId).toBeUndefined();
462
+ expect(named.scopeId).toBe("session:alpha");
463
+ });
464
+
465
+ it("returns the same instance across re-renders", () => {
466
+ const backing = createStorage();
467
+ const seen: MCPStorage[] = [];
468
+ let rerender!: () => void;
469
+
470
+ const useHost = () => {
471
+ const [, setTick] = useState(0);
472
+ rerender = () => setTick((n) => n + 1);
473
+ const storage = useResource(
474
+ McpLocalStorage({ keyPrefix: "test-mcp", storage: backing }),
475
+ );
476
+ seen.push(storage);
477
+ return storage;
478
+ };
479
+ const Host = resource(useHost);
480
+
481
+ createTapRoot(function McpStorageIdentityRoot() {
482
+ return useResource(Host());
483
+ });
484
+ rerender();
485
+ rerender();
486
+
487
+ expect(seen.length).toBeGreaterThan(1);
488
+ expect(new Set(seen).size).toBe(1);
489
+ });
490
+ });
@@ -1,4 +1,5 @@
1
1
  import { resource } from "@assistant-ui/tap";
2
+ import { useMemo } from "react";
2
3
  import {
3
4
  OAuthMetadataSchema,
4
5
  OAuthClientInformationFullSchema,
@@ -15,6 +16,13 @@ export type McpLocalStorageOptions = {
15
16
  keyPrefix?: string;
16
17
  /** Override the underlying Storage. Defaults to globalThis.localStorage. */
17
18
  storage?: Storage;
19
+ /**
20
+ * Stable identity for the backing data, used to key server reconnects.
21
+ * Required to get reconnect-on-swap behavior when `storage` is overridden;
22
+ * without it a custom backing store declares no scope, since a prefix
23
+ * alone cannot distinguish two different stores.
24
+ */
25
+ scopeId?: string;
18
26
  };
19
27
 
20
28
  function resolveStorage(opts: McpLocalStorageOptions): Storage | null {
@@ -193,6 +201,7 @@ export const normalizePersistedAuthState = (
193
201
  if (isNonEmptyString(value.codeVerifier)) {
194
202
  state.codeVerifier = value.codeVerifier;
195
203
  }
204
+ if (isNonEmptyString(value.state)) state.state = value.state;
196
205
 
197
206
  const tokens = normalizeOAuthTokens(value.tokens);
198
207
  if (tokens) state.tokens = tokens;
@@ -208,54 +217,67 @@ export const normalizePersistedAuthState = (
208
217
 
209
218
  const useMcpLocalStorage = (opts: McpLocalStorageOptions = {}): MCPStorage => {
210
219
  const prefix = opts.keyPrefix ?? "aui-mcp";
211
- const customServersKey = `${prefix}:custom-servers`;
212
- const authKey = (id: string) => `${prefix}:auth:${id}`;
213
220
  const storage = resolveStorage(opts);
214
-
215
- const read = <T>(key: string, fallback: T): T => {
216
- if (!storage) return fallback;
217
- try {
218
- const raw = storage.getItem(key);
219
- if (raw == null) return fallback;
220
- return JSON.parse(raw) as T;
221
- } catch {
222
- return fallback;
223
- }
224
- };
225
-
226
- const write = (key: string, value: unknown): void => {
227
- if (!storage) return;
228
- try {
229
- storage.setItem(key, JSON.stringify(value));
230
- } catch {
231
- // quota or serialization failure — silently drop
232
- }
233
- };
234
-
235
- const remove = (key: string): void => {
236
- if (!storage) return;
237
- try {
238
- storage.removeItem(key);
239
- } catch {
240
- // ignore
241
- }
242
- };
243
-
244
- return {
245
- loadCustomServers: async () =>
246
- normalizeCustomServerRecords(read<unknown>(customServersKey, [])),
247
- saveCustomServers: async (records) => {
248
- write(customServersKey, records);
249
- },
250
- loadAuthState: async (id) =>
251
- normalizePersistedAuthState(read<unknown>(authKey(id), null)),
252
- saveAuthState: async (id, state) => {
253
- write(authKey(id), state);
254
- },
255
- clearAuthState: async (id) => {
256
- remove(authKey(id));
257
- },
258
- };
221
+ // Deriving a scope from the prefix is only honest for the shared
222
+ // globalThis.localStorage; two custom backing stores under one prefix hold
223
+ // different data, so an overridden backing declares no scope unless the
224
+ // caller names one.
225
+ const scopeId =
226
+ opts.scopeId ??
227
+ (opts.storage === undefined ? `local-storage:${prefix}` : undefined);
228
+
229
+ // Callers key per-server coordination state on this instance, so it has to
230
+ // stay referentially stable for as long as the underlying store does.
231
+ return useMemo(() => {
232
+ const customServersKey = `${prefix}:custom-servers`;
233
+ const authKey = (id: string) => `${prefix}:auth:${id}`;
234
+
235
+ const read = <T>(key: string, fallback: T): T => {
236
+ if (!storage) return fallback;
237
+ try {
238
+ const raw = storage.getItem(key);
239
+ if (raw == null) return fallback;
240
+ return JSON.parse(raw) as T;
241
+ } catch {
242
+ return fallback;
243
+ }
244
+ };
245
+
246
+ const write = (key: string, value: unknown): void => {
247
+ if (!storage) return;
248
+ try {
249
+ storage.setItem(key, JSON.stringify(value));
250
+ } catch {
251
+ // quota or serialization failure — silently drop
252
+ }
253
+ };
254
+
255
+ const remove = (key: string): void => {
256
+ if (!storage) return;
257
+ try {
258
+ storage.removeItem(key);
259
+ } catch {
260
+ // ignore
261
+ }
262
+ };
263
+
264
+ return {
265
+ ...(scopeId !== undefined ? { scopeId } : {}),
266
+ loadCustomServers: async () =>
267
+ normalizeCustomServerRecords(read<unknown>(customServersKey, [])),
268
+ saveCustomServers: async (records) => {
269
+ write(customServersKey, records);
270
+ },
271
+ loadAuthState: async (id) =>
272
+ normalizePersistedAuthState(read<unknown>(authKey(id), null)),
273
+ saveAuthState: async (id, state) => {
274
+ write(authKey(id), state);
275
+ },
276
+ clearAuthState: async (id) => {
277
+ remove(authKey(id));
278
+ },
279
+ };
280
+ }, [prefix, storage, scopeId]);
259
281
  };
260
282
 
261
283
  export const McpLocalStorage = resource(useMcpLocalStorage);
@@ -0,0 +1,99 @@
1
+ import { createTapRoot, resource, useResource } from "@assistant-ui/tap";
2
+ import { describe, expect, it } from "vitest";
3
+ import { useState } from "react";
4
+ import type { MCPStorage } from "./types";
5
+ import { McpMemoryStorage } from "./McpMemoryStorage";
6
+
7
+ const mountWithRerender = () => {
8
+ const seen: MCPStorage[] = [];
9
+ let setTick!: (update: (value: number) => number) => void;
10
+
11
+ const useHost = () => {
12
+ const [, setValue] = useState(0);
13
+ setTick = setValue;
14
+ const storage = useResource(McpMemoryStorage());
15
+ seen.push(storage);
16
+ return storage;
17
+ };
18
+ const Host = resource(useHost);
19
+
20
+ createTapRoot(function MemoryStorageRoot() {
21
+ return useResource(Host());
22
+ });
23
+
24
+ return {
25
+ seen,
26
+ latest: () => seen[seen.length - 1]!,
27
+ rerender: () => setTick((value) => value + 1),
28
+ };
29
+ };
30
+
31
+ describe("McpMemoryStorage", () => {
32
+ it("returns the same instance across re-renders", () => {
33
+ const { seen, rerender } = mountWithRerender();
34
+
35
+ rerender();
36
+ rerender();
37
+
38
+ expect(seen.length).toBeGreaterThan(1);
39
+ expect(new Set(seen).size).toBe(1);
40
+ });
41
+
42
+ it("keeps persisted auth state across re-renders", async () => {
43
+ const { seen, latest, rerender } = mountWithRerender();
44
+ await seen[0]!.saveAuthState("docs", { codeVerifier: "pkce-verifier" });
45
+
46
+ rerender();
47
+
48
+ await expect(latest().loadAuthState("docs")).resolves.toEqual({
49
+ codeVerifier: "pkce-verifier",
50
+ });
51
+ });
52
+
53
+ it("keeps custom servers across re-renders", async () => {
54
+ const { seen, latest, rerender } = mountWithRerender();
55
+ await seen[0]!.saveCustomServers([
56
+ {
57
+ id: "docs",
58
+ name: "Docs",
59
+ url: "https://docs.example.com/mcp",
60
+ auth: { type: "none" },
61
+ createdAt: 1,
62
+ },
63
+ ]);
64
+
65
+ rerender();
66
+
67
+ await expect(latest().loadCustomServers()).resolves.toHaveLength(1);
68
+ });
69
+ });
70
+
71
+ describe("McpMemoryStorage scope identity", () => {
72
+ it("scopes each instance uniquely and keeps it stable per instance", () => {
73
+ let first!: MCPStorage;
74
+ let second!: MCPStorage;
75
+ const seen: (string | undefined)[] = [];
76
+ let rerender!: () => void;
77
+
78
+ const Host = resource(function useHost() {
79
+ const [, setTick] = useState(0);
80
+ rerender = () => setTick((n) => n + 1);
81
+ first = useResource(McpMemoryStorage());
82
+ seen.push(first.scopeId);
83
+ return first;
84
+ });
85
+ createTapRoot(function MemoryScopeRootA() {
86
+ return useResource(Host());
87
+ });
88
+ createTapRoot(function MemoryScopeRootB() {
89
+ second = useResource(McpMemoryStorage());
90
+ return second;
91
+ });
92
+ rerender();
93
+
94
+ expect(first.scopeId).toMatch(/^memory:/);
95
+ expect(second.scopeId).toMatch(/^memory:/);
96
+ expect(first.scopeId).not.toBe(second.scopeId);
97
+ expect(new Set(seen).size).toBe(1);
98
+ });
99
+ });
@@ -1,24 +1,30 @@
1
1
  import { resource } from "@assistant-ui/tap";
2
+ import { useMemo } from "react";
3
+ import { generateId } from "@assistant-ui/core";
2
4
  import type { MCPCustomServerRecord } from "../../mcp-scope";
3
5
  import type { MCPPersistedAuthState } from "../../auth/types";
4
6
  import type { MCPStorage } from "./types";
5
7
 
6
- const useMcpMemoryStorage = (): MCPStorage => {
7
- let servers: MCPCustomServerRecord[] = [];
8
- const auth = new Map<string, MCPPersistedAuthState>();
9
- return {
10
- loadCustomServers: async () => [...servers],
11
- saveCustomServers: async (records) => {
12
- servers = [...records];
13
- },
14
- loadAuthState: async (id) => auth.get(id) ?? null,
15
- saveAuthState: async (id, state) => {
16
- auth.set(id, state);
17
- },
18
- clearAuthState: async (id) => {
19
- auth.delete(id);
20
- },
21
- };
22
- };
8
+ const useMcpMemoryStorage = (): MCPStorage =>
9
+ useMemo(() => {
10
+ let servers: MCPCustomServerRecord[] = [];
11
+ const auth = new Map<string, MCPPersistedAuthState>();
12
+ return {
13
+ // Each memory store is its own private data: a distinct instance is a
14
+ // distinct scope, so replacing one keys a reconnect.
15
+ scopeId: `memory:${generateId()}`,
16
+ loadCustomServers: async () => [...servers],
17
+ saveCustomServers: async (records) => {
18
+ servers = [...records];
19
+ },
20
+ loadAuthState: async (id) => auth.get(id) ?? null,
21
+ saveAuthState: async (id, state) => {
22
+ auth.set(id, state);
23
+ },
24
+ clearAuthState: async (id) => {
25
+ auth.delete(id);
26
+ },
27
+ };
28
+ }, []);
23
29
 
24
30
  export const McpMemoryStorage = resource(useMcpMemoryStorage);