@assistant-ui/react-mcp 0.1.15 → 0.1.17

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 (45) hide show
  1. package/dist/auth/createOAuthProvider.d.ts +19 -2
  2. package/dist/auth/createOAuthProvider.d.ts.map +1 -1
  3. package/dist/auth/createOAuthProvider.js +197 -35
  4. package/dist/auth/createOAuthProvider.js.map +1 -1
  5. package/dist/auth/types.d.ts +6 -1
  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/resources/McpManagerResource.d.ts.map +1 -1
  11. package/dist/resources/McpManagerResource.js +2 -1
  12. package/dist/resources/McpManagerResource.js.map +1 -1
  13. package/dist/resources/McpServerResource.d.ts +2 -1
  14. package/dist/resources/McpServerResource.d.ts.map +1 -1
  15. package/dist/resources/McpServerResource.js +79 -19
  16. package/dist/resources/McpServerResource.js.map +1 -1
  17. package/dist/resources/storage/McpLocalStorage.d.ts +7 -0
  18. package/dist/resources/storage/McpLocalStorage.d.ts.map +1 -1
  19. package/dist/resources/storage/McpLocalStorage.js +146 -37
  20. package/dist/resources/storage/McpLocalStorage.js.map +1 -1
  21. package/dist/resources/storage/McpMemoryStorage.d.ts.map +1 -1
  22. package/dist/resources/storage/McpMemoryStorage.js +9 -6
  23. package/dist/resources/storage/McpMemoryStorage.js.map +1 -1
  24. package/dist/resources/storage/types.d.ts +12 -0
  25. package/dist/resources/storage/types.d.ts.map +1 -1
  26. package/dist/utils/serverUrl.d.ts +8 -0
  27. package/dist/utils/serverUrl.d.ts.map +1 -0
  28. package/dist/utils/serverUrl.js +15 -0
  29. package/dist/utils/serverUrl.js.map +1 -0
  30. package/package.json +7 -7
  31. package/src/auth/createOAuthProvider.test.ts +919 -5
  32. package/src/auth/createOAuthProvider.ts +328 -42
  33. package/src/auth/types.ts +6 -1
  34. package/src/hooks/useMcpOAuthCallback.test.ts +74 -1
  35. package/src/hooks/useMcpOAuthCallback.tsx +11 -8
  36. package/src/resources/McpManagerResource.ts +5 -1
  37. package/src/resources/McpServerResource.test.ts +612 -16
  38. package/src/resources/McpServerResource.ts +95 -23
  39. package/src/resources/storage/McpLocalStorage.test.ts +97 -1
  40. package/src/resources/storage/McpLocalStorage.ts +90 -47
  41. package/src/resources/storage/McpMemoryStorage.test.ts +99 -0
  42. package/src/resources/storage/McpMemoryStorage.ts +23 -17
  43. package/src/resources/storage/types.ts +12 -0
  44. package/src/utils/serverUrl.test.ts +66 -0
  45. package/src/utils/serverUrl.ts +23 -0
@@ -1,6 +1,7 @@
1
1
  import { useState, useRef, useEffect, useMemo, useEffectEvent } from "react";
2
2
  import { resource, useResource, withKey } from "@assistant-ui/tap";
3
3
  import type { ClientOutput } from "@assistant-ui/store";
4
+ import { shallowEqual } from "@assistant-ui/store/internal";
4
5
  import {
5
6
  Client,
6
7
  StreamableHTTPClientTransport,
@@ -10,9 +11,17 @@ import {
10
11
  type ElicitResult,
11
12
  type StreamableHTTPClientTransportOptions,
12
13
  } from "@modelcontextprotocol/client";
13
- import { createOAuthProvider } from "../auth/createOAuthProvider";
14
+ import {
15
+ clearOAuthProviderAuthState,
16
+ createOAuthProvider,
17
+ hasUsableOAuthTokens,
18
+ } from "../auth/createOAuthProvider";
14
19
  import { buildHeaders } from "../auth/buildHeaders";
15
20
  import { assertValidServerId } from "../utils/serverId";
21
+ import {
22
+ hasPersistedCredentials,
23
+ isAuthStateForServerUrl,
24
+ } from "../utils/serverUrl";
16
25
  import { validateElicitationContent } from "./validateElicitationContent";
17
26
  import type { MCPStorage } from "./storage/types";
18
27
  import type {
@@ -46,13 +55,13 @@ type McpServerResourceInstanceProps = McpServerResourceProps & {
46
55
  transportCloseQueueRef: { current: Promise<void> };
47
56
  };
48
57
 
49
- const getConnectionDependencies = (
58
+ export const getConnectionDependencies = (
50
59
  props: McpServerResourceProps,
51
60
  ): readonly unknown[] => {
52
61
  const auth = props.auth;
53
62
  const authDependencies =
54
63
  auth.type === "bearer"
55
- ? [auth.type, auth.token]
64
+ ? [auth.type, auth.token, props.storage.scopeId]
56
65
  : auth.type === "oauth"
57
66
  ? [
58
67
  auth.type,
@@ -63,6 +72,7 @@ const getConnectionDependencies = (
63
72
  auth.registrationEndpoint,
64
73
  auth.clientId,
65
74
  auth.clientSecret,
75
+ props.storage.scopeId,
66
76
  ]
67
77
  : [auth.type];
68
78
 
@@ -76,13 +86,6 @@ const getConnectionDependencies = (
76
86
  ];
77
87
  };
78
88
 
79
- const areConnectionDependenciesEqual = (
80
- left: readonly unknown[],
81
- right: readonly unknown[],
82
- ) =>
83
- left.length === right.length &&
84
- left.every((value, index) => Object.is(value, right[index]));
85
-
86
89
  const useMcpServerResourceInstance = (
87
90
  props: McpServerResourceInstanceProps,
88
91
  ): ClientOutput<"mcpServer"> => {
@@ -102,6 +105,11 @@ const useMcpServerResourceInstance = (
102
105
  null,
103
106
  );
104
107
  const connectionGenerationRef = useRef(0);
108
+ const pendingAuthValidationRef = useRef<{
109
+ count: number;
110
+ promise: Promise<void>;
111
+ resolve: () => void;
112
+ } | null>(null);
105
113
  const elicitationResolversRef = useRef(
106
114
  new Map<
107
115
  string,
@@ -233,11 +241,23 @@ const useMcpServerResourceInstance = (
233
241
  },
234
242
  );
235
243
 
244
+ const unboundAuthMessage = () =>
245
+ `MCP server "${props.id}" has saved authentication for a different URL. Authenticate again to connect to ${props.url}.`;
246
+
247
+ const loadAuthState = useEffectEvent(async () => {
248
+ const state = await props.storage.loadAuthState(props.id);
249
+ if (isAuthStateForServerUrl(state, props.url)) {
250
+ return { state, unbound: false };
251
+ }
252
+ return { state: null, unbound: hasPersistedCredentials(state) };
253
+ });
254
+
236
255
  const buildTransport = useEffectEvent(
237
256
  async (): Promise<StreamableHTTPClientTransport> => {
238
257
  if (props.auth.type === "oauth") {
239
258
  const authProvider = createOAuthProvider({
240
259
  serverId: props.id,
260
+ serverUrl: props.url,
241
261
  config: props.auth,
242
262
  storage: props.storage,
243
263
  redirectUri: props.redirectUri,
@@ -248,8 +268,9 @@ const useMcpServerResourceInstance = (
248
268
  });
249
269
  }
250
270
  if (props.auth.type === "bearer") {
251
- const persisted = await props.storage.loadAuthState(props.id);
252
- const headers = buildHeaders(props.auth, persisted);
271
+ const { state, unbound } = await loadAuthState();
272
+ const headers = buildHeaders(props.auth, state);
273
+ if (!headers && unbound) throw new Error(unboundAuthMessage());
253
274
  const transportOpts: StreamableHTTPClientTransportOptions = {};
254
275
  if (headers) transportOpts.requestInit = { headers };
255
276
  return new StreamableHTTPClientTransport(
@@ -464,6 +485,46 @@ const useMcpServerResourceInstance = (
464
485
  });
465
486
 
466
487
  const doCompleteAuth = useEffectEvent(async (callbackUrl: string) => {
488
+ const validationGeneration = connectionGenerationRef.current;
489
+ const url = new URL(callbackUrl);
490
+ const state = url.searchParams.get("state");
491
+ if (!state) throw new Error('missing "state" parameter');
492
+ let pendingAuthValidation = pendingAuthValidationRef.current;
493
+ if (!pendingAuthValidation) {
494
+ let resolve!: () => void;
495
+ const promise = new Promise<void>((resolvePromise) => {
496
+ resolve = resolvePromise;
497
+ });
498
+ pendingAuthValidation = { count: 0, promise, resolve };
499
+ pendingAuthValidationRef.current = pendingAuthValidation;
500
+ }
501
+ pendingAuthValidation.count += 1;
502
+ try {
503
+ const { state: persisted, unbound } = await loadAuthState();
504
+ if (!isCurrentConnection(validationGeneration)) {
505
+ throw createInterruptedAuthError();
506
+ }
507
+ if (unbound) throw new Error(unboundAuthMessage());
508
+ if (!persisted?.state) {
509
+ throw new Error(
510
+ "no pending OAuth authorization request for this server",
511
+ );
512
+ }
513
+ if (persisted.state !== state) {
514
+ throw new Error("OAuth state does not match the authorization request");
515
+ }
516
+ if (!url.searchParams.get("code") && !url.searchParams.get("error")) {
517
+ throw new Error("missing authorization code in callback URL");
518
+ }
519
+ } finally {
520
+ pendingAuthValidation.count -= 1;
521
+ if (pendingAuthValidation.count === 0) {
522
+ pendingAuthValidationRef.current = null;
523
+ pendingAuthValidation.resolve();
524
+ }
525
+ }
526
+
527
+ // Claim the generation before a waiting auto-connect can resume.
467
528
  const generation = ++connectionGenerationRef.current;
468
529
  cancelPendingElicitations();
469
530
  await closePendingTransport();
@@ -472,9 +533,6 @@ const useMcpServerResourceInstance = (
472
533
  setConnectionState("authPending");
473
534
  setLastError(null);
474
535
  try {
475
- const url = new URL(callbackUrl);
476
- const code = url.searchParams.get("code");
477
- if (!code) throw new Error("missing authorization code in callback URL");
478
536
  let transport = transportRef.current;
479
537
  if (!transport) {
480
538
  transport = await buildTransport();
@@ -486,7 +544,7 @@ const useMcpServerResourceInstance = (
486
544
  transportRef.current = null;
487
545
  clientRef.current = null;
488
546
  pendingTransportRef.current = transport;
489
- await transport.finishAuth(code);
547
+ await transport.finishAuth(url.searchParams);
490
548
  if (!isCurrentConnection(generation)) throw createInterruptedAuthError();
491
549
  setAuthorizationUrl(null);
492
550
  const connected = await finalizeConnect(transport, generation);
@@ -518,9 +576,9 @@ const useMcpServerResourceInstance = (
518
576
  return;
519
577
  }
520
578
  const generation = connectionGenerationRef.current;
521
- let persisted: Awaited<ReturnType<MCPStorage["loadAuthState"]>>;
579
+ let loaded: Awaited<ReturnType<typeof loadAuthState>>;
522
580
  try {
523
- persisted = await props.storage.loadAuthState(props.id);
581
+ loaded = await loadAuthState();
524
582
  } catch (error) {
525
583
  if (signal.cancelled || !isCurrentConnection(generation)) return;
526
584
  const message = error instanceof Error ? error.message : String(error);
@@ -531,11 +589,21 @@ const useMcpServerResourceInstance = (
531
589
  return;
532
590
  }
533
591
  if (signal.cancelled || !isCurrentConnection(generation)) return;
592
+ if (loaded.unbound) {
593
+ setLastError({ message: unboundAuthMessage() });
594
+ return;
595
+ }
596
+ const persisted = loaded.state;
534
597
  if (props.auth.type === "oauth") {
535
- if (!persisted?.tokens) return;
598
+ if (!hasUsableOAuthTokens(persisted, props.auth)) return;
536
599
  } else if (!persisted?.token) {
537
600
  return;
538
601
  }
602
+ const pendingAuthValidation = pendingAuthValidationRef.current;
603
+ if (pendingAuthValidation) {
604
+ await pendingAuthValidation.promise;
605
+ if (signal.cancelled || !isCurrentConnection(generation)) return;
606
+ }
539
607
  void doConnect();
540
608
  },
541
609
  );
@@ -549,6 +617,9 @@ const useMcpServerResourceInstance = (
549
617
  pendingDisposalRef.current = pendingDisposal;
550
618
  mountedRef.current = true;
551
619
  const signal = { cancelled: false };
620
+ // Auto-connect opens a transport, so it belongs to the same effect as the
621
+ // disposal that closes it.
622
+ // eslint-disable-next-line react-hooks/set-state-in-effect
552
623
  void tryAutoConnect(signal);
553
624
  return () => {
554
625
  mountedRef.current = false;
@@ -597,7 +668,7 @@ const useMcpServerResourceInstance = (
597
668
  remove: async () => {
598
669
  await doDisconnect();
599
670
  try {
600
- await props.storage.clearAuthState(props.id);
671
+ await clearOAuthProviderAuthState(props.storage, props.id);
601
672
  await props.onRemove();
602
673
  } catch (err) {
603
674
  setLastError({
@@ -694,7 +765,7 @@ export const McpServerResource = resource(function useMcpServerResource(
694
765
  const dependencies = getConnectionDependencies(props);
695
766
  const [connection, setConnection] = useState({ dependencies, generation: 0 });
696
767
  let currentConnection = connection;
697
- if (!areConnectionDependenciesEqual(connection.dependencies, dependencies)) {
768
+ if (!shallowEqual(connection.dependencies, dependencies)) {
698
769
  currentConnection = {
699
770
  dependencies,
700
771
  generation: connection.generation + 1,
@@ -702,8 +773,9 @@ export const McpServerResource = resource(function useMcpServerResource(
702
773
  setConnection(currentConnection);
703
774
  }
704
775
 
705
- // Storage resources do not expose a stable scope identity and may return a
706
- // fresh client on ordinary renders, so storage changes cannot key remounts.
776
+ // Storage keys remounts through its optional scopeId rather than object
777
+ // identity, because a defaulted storage element is rebuilt on ordinary
778
+ // renders.
707
779
  return useResource(
708
780
  withKey(
709
781
  currentConnection.generation,
@@ -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,13 +127,36 @@ 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
 
139
+ it("keeps valid server URL bindings", () => {
140
+ expect(
141
+ normalizePersistedAuthState({
142
+ serverUrl: "http://mcp.example.com/docs",
143
+ token: "bearer-token",
144
+ }),
145
+ ).toEqual({
146
+ serverUrl: "http://mcp.example.com/docs",
147
+ token: "bearer-token",
148
+ });
149
+ });
150
+
151
+ it("rejects auth state with an unsafe server URL binding", () => {
152
+ expect(
153
+ normalizePersistedAuthState({
154
+ serverUrl: "javascript:alert(1)",
155
+ token: "bearer-token",
156
+ }),
157
+ ).toBeNull();
158
+ });
159
+
135
160
  it("keeps valid OAuth tokens and client information", () => {
136
161
  const tokens = {
137
162
  access_token: "access-token",
@@ -149,11 +174,15 @@ describe("normalizePersistedAuthState", () => {
149
174
  expect(
150
175
  normalizePersistedAuthState({
151
176
  tokens,
177
+ tokensClientId: "client-id",
152
178
  clientInformation,
179
+ clientInformationSource: "registered",
153
180
  }),
154
181
  ).toEqual({
155
182
  tokens,
183
+ tokensClientId: "client-id",
156
184
  clientInformation,
185
+ clientInformationSource: "registered",
157
186
  });
158
187
  });
159
188
 
@@ -373,6 +402,7 @@ describe("McpLocalStorage auth state", () => {
373
402
  const createProvider = () =>
374
403
  createOAuthProvider({
375
404
  serverId: "docs",
405
+ serverUrl: "https://mcp.example.com/mcp",
376
406
  config: { type: "oauth", clientId: "client-id" },
377
407
  storage: loadStorage(storage),
378
408
  redirectUri: "http://localhost/callback",
@@ -394,6 +424,7 @@ describe("McpLocalStorage auth state", () => {
394
424
  JSON.parse(storage.getItem("test-mcp:auth:docs") ?? "null"),
395
425
  ).toMatchObject({
396
426
  codeVerifier: expect.any(String),
427
+ state: authorizationUrls[0]!.searchParams.get("state"),
397
428
  discoveryState: {
398
429
  authorizationServerUrl: "https://auth.example.com",
399
430
  },
@@ -418,3 +449,68 @@ describe("McpLocalStorage auth state", () => {
418
449
  });
419
450
  });
420
451
  });
452
+
453
+ describe("McpLocalStorage instance identity", () => {
454
+ it("derives a scope from the prefix for the shared default backing", () => {
455
+ let storage!: MCPStorage;
456
+
457
+ createTapRoot(function McpStorageScopeRoot() {
458
+ storage = useResource(McpLocalStorage({ keyPrefix: "test-mcp" }));
459
+ return storage;
460
+ });
461
+
462
+ expect(storage.scopeId).toBe("local-storage:test-mcp");
463
+ });
464
+
465
+ it("declares no scope for a custom backing store unless one is named", () => {
466
+ const backing = createStorage();
467
+ let unnamed!: MCPStorage;
468
+ let named!: MCPStorage;
469
+
470
+ createTapRoot(function McpStorageCustomScopeRoot() {
471
+ unnamed = useResource(
472
+ McpLocalStorage({ keyPrefix: "test-mcp", storage: backing }),
473
+ );
474
+ return unnamed;
475
+ });
476
+ createTapRoot(function McpStorageNamedScopeRoot() {
477
+ named = useResource(
478
+ McpLocalStorage({
479
+ keyPrefix: "test-mcp",
480
+ storage: backing,
481
+ scopeId: "session:alpha",
482
+ }),
483
+ );
484
+ return named;
485
+ });
486
+
487
+ expect(unnamed.scopeId).toBeUndefined();
488
+ expect(named.scopeId).toBe("session:alpha");
489
+ });
490
+
491
+ it("returns the same instance across re-renders", () => {
492
+ const backing = createStorage();
493
+ const seen: MCPStorage[] = [];
494
+ let rerender!: () => void;
495
+
496
+ const useHost = () => {
497
+ const [, setTick] = useState(0);
498
+ rerender = () => setTick((n) => n + 1);
499
+ const storage = useResource(
500
+ McpLocalStorage({ keyPrefix: "test-mcp", storage: backing }),
501
+ );
502
+ seen.push(storage);
503
+ return storage;
504
+ };
505
+ const Host = resource(useHost);
506
+
507
+ createTapRoot(function McpStorageIdentityRoot() {
508
+ return useResource(Host());
509
+ });
510
+ rerender();
511
+ rerender();
512
+
513
+ expect(seen.length).toBeGreaterThan(1);
514
+ expect(new Set(seen).size).toBe(1);
515
+ });
516
+ });
@@ -1,10 +1,12 @@
1
1
  import { resource } from "@assistant-ui/tap";
2
+ import { useMemo } from "react";
2
3
  import {
3
4
  OAuthMetadataSchema,
4
5
  OAuthClientInformationFullSchema,
5
6
  OAuthProtectedResourceMetadataSchema,
6
7
  OAuthTokensSchema,
7
8
  } from "@modelcontextprotocol/core";
9
+ import { normalizeMcpServerUrl } from "../../utils/serverUrl";
8
10
  import type { MCPAuthConfig, MCPCustomServerRecord } from "../../mcp-scope";
9
11
  import type { MCPPersistedAuthState } from "../../auth/types";
10
12
  import { assertValidServerId } from "../../utils/serverId";
@@ -15,6 +17,13 @@ export type McpLocalStorageOptions = {
15
17
  keyPrefix?: string;
16
18
  /** Override the underlying Storage. Defaults to globalThis.localStorage. */
17
19
  storage?: Storage;
20
+ /**
21
+ * Stable identity for the backing data, used to key server reconnects.
22
+ * Required to get reconnect-on-swap behavior when `storage` is overridden;
23
+ * without it a custom backing store declares no scope, since a prefix
24
+ * alone cannot distinguish two different stores.
25
+ */
26
+ scopeId?: string;
18
27
  };
19
28
 
20
29
  function resolveStorage(opts: McpLocalStorageOptions): Storage | null {
@@ -152,6 +161,16 @@ const isSecureNetworkUrl = (value: unknown): value is string => {
152
161
  }
153
162
  };
154
163
 
164
+ const isMcpServerUrl = (value: unknown): value is string => {
165
+ if (typeof value !== "string") return false;
166
+ try {
167
+ const url = new URL(value);
168
+ return url.protocol === "https:" || url.protocol === "http:";
169
+ } catch {
170
+ return false;
171
+ }
172
+ };
173
+
155
174
  const normalizeDiscoveryState = (
156
175
  value: unknown,
157
176
  ): MCPPersistedAuthState["discoveryState"] | undefined => {
@@ -187,18 +206,29 @@ export const normalizePersistedAuthState = (
187
206
  value: unknown,
188
207
  ): MCPPersistedAuthState | null => {
189
208
  if (!isRecord(value)) return null;
209
+ if ("serverUrl" in value && !isMcpServerUrl(value.serverUrl)) return null;
190
210
 
191
211
  const state: MCPPersistedAuthState = {};
212
+ if (isMcpServerUrl(value.serverUrl)) {
213
+ state.serverUrl = normalizeMcpServerUrl(value.serverUrl);
214
+ }
192
215
  if (isNonEmptyString(value.token)) state.token = value.token;
216
+ if (isNonEmptyString(value.tokensClientId)) {
217
+ state.tokensClientId = value.tokensClientId;
218
+ }
193
219
  if (isNonEmptyString(value.codeVerifier)) {
194
220
  state.codeVerifier = value.codeVerifier;
195
221
  }
222
+ if (isNonEmptyString(value.state)) state.state = value.state;
196
223
 
197
224
  const tokens = normalizeOAuthTokens(value.tokens);
198
225
  if (tokens) state.tokens = tokens;
199
226
 
200
227
  const clientInformation = normalizeClientInformation(value.clientInformation);
201
228
  if (clientInformation) state.clientInformation = clientInformation;
229
+ if (value.clientInformationSource === "registered") {
230
+ state.clientInformationSource = value.clientInformationSource;
231
+ }
202
232
 
203
233
  const discoveryState = normalizeDiscoveryState(value.discoveryState);
204
234
  if (discoveryState) state.discoveryState = discoveryState;
@@ -208,54 +238,67 @@ export const normalizePersistedAuthState = (
208
238
 
209
239
  const useMcpLocalStorage = (opts: McpLocalStorageOptions = {}): MCPStorage => {
210
240
  const prefix = opts.keyPrefix ?? "aui-mcp";
211
- const customServersKey = `${prefix}:custom-servers`;
212
- const authKey = (id: string) => `${prefix}:auth:${id}`;
213
241
  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
- };
242
+ // Deriving a scope from the prefix is only honest for the shared
243
+ // globalThis.localStorage; two custom backing stores under one prefix hold
244
+ // different data, so an overridden backing declares no scope unless the
245
+ // caller names one.
246
+ const scopeId =
247
+ opts.scopeId ??
248
+ (opts.storage === undefined ? `local-storage:${prefix}` : undefined);
249
+
250
+ // Callers key per-server coordination state on this instance, so it has to
251
+ // stay referentially stable for as long as the underlying store does.
252
+ return useMemo(() => {
253
+ const customServersKey = `${prefix}:custom-servers`;
254
+ const authKey = (id: string) => `${prefix}:auth:${id}`;
255
+
256
+ const read = <T>(key: string, fallback: T): T => {
257
+ if (!storage) return fallback;
258
+ try {
259
+ const raw = storage.getItem(key);
260
+ if (raw == null) return fallback;
261
+ return JSON.parse(raw) as T;
262
+ } catch {
263
+ return fallback;
264
+ }
265
+ };
266
+
267
+ const write = (key: string, value: unknown): void => {
268
+ if (!storage) return;
269
+ try {
270
+ storage.setItem(key, JSON.stringify(value));
271
+ } catch {
272
+ // quota or serialization failure — silently drop
273
+ }
274
+ };
275
+
276
+ const remove = (key: string): void => {
277
+ if (!storage) return;
278
+ try {
279
+ storage.removeItem(key);
280
+ } catch {
281
+ // ignore
282
+ }
283
+ };
284
+
285
+ return {
286
+ ...(scopeId !== undefined ? { scopeId } : {}),
287
+ loadCustomServers: async () =>
288
+ normalizeCustomServerRecords(read<unknown>(customServersKey, [])),
289
+ saveCustomServers: async (records) => {
290
+ write(customServersKey, records);
291
+ },
292
+ loadAuthState: async (id) =>
293
+ normalizePersistedAuthState(read<unknown>(authKey(id), null)),
294
+ saveAuthState: async (id, state) => {
295
+ write(authKey(id), state);
296
+ },
297
+ clearAuthState: async (id) => {
298
+ remove(authKey(id));
299
+ },
300
+ };
301
+ }, [prefix, storage, scopeId]);
259
302
  };
260
303
 
261
304
  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
+ });