@assistant-ui/react-mcp 0.1.15 → 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 (39) 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/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 +50 -11
  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 +132 -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/package.json +7 -7
  27. package/src/auth/createOAuthProvider.test.ts +407 -2
  28. package/src/auth/createOAuthProvider.ts +171 -40
  29. package/src/auth/types.ts +1 -0
  30. package/src/hooks/useMcpOAuthCallback.test.ts +74 -1
  31. package/src/hooks/useMcpOAuthCallback.tsx +11 -8
  32. package/src/resources/McpManagerResource.ts +2 -1
  33. package/src/resources/McpServerResource.test.ts +377 -16
  34. package/src/resources/McpServerResource.ts +61 -10
  35. package/src/resources/storage/McpLocalStorage.test.ts +71 -1
  36. package/src/resources/storage/McpLocalStorage.ts +69 -47
  37. package/src/resources/storage/McpMemoryStorage.test.ts +99 -0
  38. package/src/resources/storage/McpMemoryStorage.ts +23 -17
  39. package/src/resources/storage/types.ts +12 -0
@@ -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);
@@ -3,6 +3,18 @@ import type { MCPCustomServerRecord } from "../../mcp-scope";
3
3
  import type { MCPPersistedAuthState } from "../../auth/types";
4
4
 
5
5
  export type MCPStorage = {
6
+ /**
7
+ * Stable identity of the backing store. Two storages with the same scopeId
8
+ * must read and write the same persisted data. When present, server
9
+ * connections and the OAuth write fence key on it, so swapping to a
10
+ * differently-scoped storage reconnects instead of leaving a live OAuth flow
11
+ * on the replaced store, and clearing through a same-scoped replacement still
12
+ * waits for writes queued against the storage it replaced. When absent, the
13
+ * fence falls back to object identity while connections never re-key at all,
14
+ * so a storage rebuilt on every render has to declare a scopeId; without one
15
+ * a clear runs unfenced against the writes queued by the object it replaced.
16
+ */
17
+ scopeId?: string;
6
18
  loadCustomServers: () => Promise<MCPCustomServerRecord[]>;
7
19
  saveCustomServers: (records: MCPCustomServerRecord[]) => Promise<void>;
8
20
  loadAuthState: (serverId: string) => Promise<MCPPersistedAuthState | null>;