@assistant-ui/react-mcp 0.1.17 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/primitives/addForm/McpAddFormAuthFields.js +71 -28
  2. package/dist/primitives/addForm/McpAddFormAuthFields.js.map +1 -1
  3. package/dist/primitives/addForm/McpAddFormError.d.ts.map +1 -1
  4. package/dist/primitives/addForm/McpAddFormError.js +30 -10
  5. package/dist/primitives/addForm/McpAddFormError.js.map +1 -1
  6. package/dist/primitives/addForm/McpAddFormNameField.js +31 -18
  7. package/dist/primitives/addForm/McpAddFormNameField.js.map +1 -1
  8. package/dist/primitives/addForm/McpAddFormRoot.d.ts.map +1 -1
  9. package/dist/primitives/addForm/McpAddFormRoot.js +32 -11
  10. package/dist/primitives/addForm/McpAddFormRoot.js.map +1 -1
  11. package/dist/primitives/addForm/McpAddFormUrlField.js +31 -18
  12. package/dist/primitives/addForm/McpAddFormUrlField.js.map +1 -1
  13. package/dist/primitives/addForm/context.d.ts +9 -1
  14. package/dist/primitives/addForm/context.d.ts.map +1 -1
  15. package/dist/primitives/addForm/context.js.map +1 -1
  16. package/dist/resources/McpManagerResource.d.ts.map +1 -1
  17. package/dist/resources/McpManagerResource.js +330 -247
  18. package/dist/resources/McpManagerResource.js.map +1 -1
  19. package/dist/resources/McpServerRemovalFence.d.ts +7 -0
  20. package/dist/resources/McpServerRemovalFence.d.ts.map +1 -0
  21. package/dist/resources/McpServerRemovalFence.js +11 -0
  22. package/dist/resources/McpServerRemovalFence.js.map +1 -0
  23. package/dist/resources/McpServerResource.d.ts.map +1 -1
  24. package/dist/resources/McpServerResource.js +5 -1
  25. package/dist/resources/McpServerResource.js.map +1 -1
  26. package/dist/resources/storage/McpLocalStorage.d.ts.map +1 -1
  27. package/dist/resources/storage/McpLocalStorage.js +2 -1
  28. package/dist/resources/storage/McpLocalStorage.js.map +1 -1
  29. package/dist/resources/storage/types.d.ts +10 -8
  30. package/dist/resources/storage/types.d.ts.map +1 -1
  31. package/package.json +5 -5
  32. package/src/primitives/addForm/McpAddFormAccessibility.test.tsx +261 -0
  33. package/src/primitives/addForm/McpAddFormAuthFields.tsx +33 -15
  34. package/src/primitives/addForm/McpAddFormError.tsx +17 -3
  35. package/src/primitives/addForm/McpAddFormNameField.tsx +13 -1
  36. package/src/primitives/addForm/McpAddFormRoot.tsx +51 -8
  37. package/src/primitives/addForm/McpAddFormUrlField.tsx +12 -1
  38. package/src/primitives/addForm/context.tsx +10 -0
  39. package/src/resources/McpManagerResource.test.ts +712 -9
  40. package/src/resources/McpManagerResource.ts +205 -56
  41. package/src/resources/McpServerRemovalFence.ts +21 -0
  42. package/src/resources/McpServerResource.ts +5 -1
  43. package/src/resources/storage/McpLocalStorage.test.ts +2 -0
  44. package/src/resources/storage/McpLocalStorage.ts +2 -1
  45. package/src/resources/storage/types.ts +10 -8
@@ -1,4 +1,11 @@
1
- import { useState, useEffect, useMemo, useEffectEvent, useRef } from "react";
1
+ import {
2
+ useState,
3
+ useEffect,
4
+ useMemo,
5
+ useEffectEvent,
6
+ useRef,
7
+ useCallback,
8
+ } from "react";
2
9
  import { useResource, resource, withKey } from "@assistant-ui/tap";
3
10
  import {
4
11
  useClientLookup,
@@ -12,6 +19,7 @@ import { createMcpId } from "../utils/createMcpId";
12
19
  import { clearOAuthProviderAuthState } from "../auth/createOAuthProvider";
13
20
  import type { Tool } from "assistant-stream";
14
21
  import { McpServerResource } from "./McpServerResource";
22
+ import { withMcpServerRemovalFence } from "./McpServerRemovalFence";
15
23
  import { McpLocalStorage } from "./storage/McpLocalStorage";
16
24
  import type { MCPStorage, MCPStorageElement } from "./storage/types";
17
25
  import { assertUniqueServerIds } from "../utils/serverId";
@@ -52,6 +60,12 @@ const reportCustomStorageFailure = (
52
60
  );
53
61
  };
54
62
 
63
+ const reportBlockedCustomServerPersistence = () => {
64
+ console.error(
65
+ "[assistant-ui/react-mcp] custom server changes remain in memory because loading the persisted list failed; remount the manager to retry",
66
+ );
67
+ };
68
+
55
69
  const persistCustomServers = async (
56
70
  storage: MCPStorage,
57
71
  records: MCPCustomServerRecord[],
@@ -63,37 +77,77 @@ const persistCustomServers = async (
63
77
  }
64
78
  };
65
79
 
66
- const useMcpManagerResource = (
67
- props: McpManagerResourceProps,
68
- ): ClientOutput<"mcp"> => {
69
- const connectors = props.connectors ?? NO_CONNECTORS;
70
- const autoConnect = props.autoConnect ?? true;
71
- const redirectUri = props.oauthRedirectUri ?? defaultRedirectUri();
72
- const connectionTimeout = props.connectionTimeout;
80
+ type CustomServerPersistenceQueues = Map<string, Promise<void>>;
73
81
 
74
- const storageElement = props.storage ?? McpLocalStorage();
75
- const storage = useResource(storageElement);
82
+ const enqueueCustomServerTask = (
83
+ persistenceQueues: CustomServerPersistenceQueues,
84
+ scopeKey: string,
85
+ task: () => Promise<void>,
86
+ ) => {
87
+ const previous = persistenceQueues.get(scopeKey);
88
+ const next = (previous ?? Promise.resolve()).then(task);
89
+ persistenceQueues.set(scopeKey, next);
90
+ void next.then(() => {
91
+ if (persistenceQueues.get(scopeKey) === next) {
92
+ persistenceQueues.delete(scopeKey);
93
+ }
94
+ });
95
+ };
96
+
97
+ const enqueueCustomServerPersistence = (
98
+ persistenceQueues: CustomServerPersistenceQueues,
99
+ scopeKey: string,
100
+ storage: MCPStorage,
101
+ records: MCPCustomServerRecord[],
102
+ ) =>
103
+ enqueueCustomServerTask(persistenceQueues, scopeKey, () =>
104
+ persistCustomServers(storage, records),
105
+ );
106
+
107
+ const holdCustomServerPersistence = (
108
+ persistenceQueues: CustomServerPersistenceQueues,
109
+ scopeKey: string,
110
+ ) => {
111
+ let release = () => {};
112
+ const gate = new Promise<void>((resolve) => {
113
+ release = resolve;
114
+ });
115
+ enqueueCustomServerTask(persistenceQueues, scopeKey, () => gate);
116
+ return release;
117
+ };
76
118
 
119
+ type McpCustomServersResourceProps = {
120
+ storage: MCPStorage;
121
+ scopeKey: string;
122
+ persistenceQueues: CustomServerPersistenceQueues;
123
+ };
124
+
125
+ const useMcpCustomServersResource = ({
126
+ storage,
127
+ scopeKey,
128
+ persistenceQueues,
129
+ }: McpCustomServersResourceProps) => {
77
130
  const [customServers, setCustomServers] = useState<MCPCustomServerRecord[]>(
78
131
  [],
79
132
  );
80
133
  const [isHydrated, setIsHydrated] = useState(false);
81
134
 
82
- const hydratedRef = useRef(false);
83
- const storageRef = useRef(storage);
84
- const persistenceQueueRef = useRef(Promise.resolve());
85
-
86
- useEffect(() => {
87
- storageRef.current = storage;
88
- }, [storage]);
135
+ const customServersRef = useRef<MCPCustomServerRecord[]>([]);
136
+ const hydrationStateRef = useRef<"pending" | "succeeded" | "failed">(
137
+ "pending",
138
+ );
139
+ const hasPendingMutationRef = useRef(false);
140
+ const reportedBlockedPersistenceRef = useRef(false);
89
141
 
90
142
  const hydrate = useEffectEvent(async (signal: { cancelled: boolean }) => {
91
- const markHydrated = () => {
92
- if (!signal.cancelled) {
93
- hydratedRef.current = true;
94
- setIsHydrated(true);
95
- }
96
- };
143
+ // A revisited scope must not read behind writes still queued against it.
144
+ while (true) {
145
+ const pendingPersistence = persistenceQueues.get(scopeKey);
146
+ if (!pendingPersistence) break;
147
+ await pendingPersistence;
148
+ if (signal.cancelled) return;
149
+ if (persistenceQueues.get(scopeKey) === pendingPersistence) break;
150
+ }
97
151
 
98
152
  let records: Awaited<ReturnType<typeof storage.loadCustomServers>>;
99
153
  try {
@@ -101,21 +155,40 @@ const useMcpManagerResource = (
101
155
  } catch (error) {
102
156
  if (!signal.cancelled) {
103
157
  reportCustomStorageFailure("load", error);
158
+ hydrationStateRef.current = "failed";
159
+ if (hasPendingMutationRef.current) {
160
+ reportBlockedCustomServerPersistence();
161
+ reportedBlockedPersistenceRef.current = true;
162
+ }
163
+ setIsHydrated(true);
104
164
  }
105
- markHydrated();
106
165
  return;
107
166
  }
108
167
 
109
- if (signal.cancelled) return;
110
168
  // Merge rather than replace so any addCustomServer calls that
111
169
  // happened before hydration resolved aren't silently overwritten.
112
170
  // Persisted order wins; pre-hydration locals append.
113
- setCustomServers((prev) => {
171
+ const hadPendingMutation = hasPendingMutationRef.current;
172
+ const mergedRecords = (() => {
173
+ const prev = customServersRef.current;
114
174
  if (prev.length === 0) return records;
115
175
  const persistedIds = new Set(records.map((r) => r.id));
116
176
  return [...records, ...prev.filter((r) => !persistedIds.has(r.id))];
117
- });
118
- markHydrated();
177
+ })();
178
+ customServersRef.current = mergedRecords;
179
+ hydrationStateRef.current = "succeeded";
180
+ hasPendingMutationRef.current = false;
181
+ if (hadPendingMutation) {
182
+ enqueueCustomServerPersistence(
183
+ persistenceQueues,
184
+ scopeKey,
185
+ storage,
186
+ mergedRecords,
187
+ );
188
+ }
189
+ if (signal.cancelled) return;
190
+ setCustomServers(mergedRecords);
191
+ setIsHydrated(true);
119
192
  });
120
193
 
121
194
  useEffect(() => {
@@ -129,13 +202,66 @@ const useMcpManagerResource = (
129
202
  };
130
203
  }, []);
131
204
 
132
- useEffect(() => {
133
- if (!hydratedRef.current) return;
134
- const targetStorage = storageRef.current;
135
- persistenceQueueRef.current = persistenceQueueRef.current.then(() =>
136
- persistCustomServers(targetStorage, customServers),
137
- );
138
- }, [customServers]);
205
+ const updateCustomServers = useCallback(
206
+ (
207
+ updater: (records: MCPCustomServerRecord[]) => MCPCustomServerRecord[],
208
+ ) => {
209
+ const next = updater(customServersRef.current);
210
+ customServersRef.current = next;
211
+ setCustomServers(next);
212
+
213
+ if (hydrationStateRef.current === "succeeded") {
214
+ enqueueCustomServerPersistence(
215
+ persistenceQueues,
216
+ scopeKey,
217
+ storage,
218
+ next,
219
+ );
220
+ return;
221
+ }
222
+
223
+ hasPendingMutationRef.current = true;
224
+ if (
225
+ hydrationStateRef.current === "failed" &&
226
+ !reportedBlockedPersistenceRef.current
227
+ ) {
228
+ reportBlockedCustomServerPersistence();
229
+ reportedBlockedPersistenceRef.current = true;
230
+ }
231
+ },
232
+ [persistenceQueues, scopeKey, storage],
233
+ );
234
+
235
+ return { customServers, isHydrated, updateCustomServers };
236
+ };
237
+
238
+ const McpCustomServersResource = resource(useMcpCustomServersResource);
239
+
240
+ const useMcpManagerResource = (
241
+ props: McpManagerResourceProps,
242
+ ): ClientOutput<"mcp"> => {
243
+ const connectors = props.connectors ?? NO_CONNECTORS;
244
+ const autoConnect = props.autoConnect ?? true;
245
+ const redirectUri = props.oauthRedirectUri ?? defaultRedirectUri();
246
+ const connectionTimeout = props.connectionTimeout;
247
+
248
+ const storageElement = props.storage ?? McpLocalStorage();
249
+ const storage = useResource(storageElement);
250
+ const [persistenceQueues] = useState<CustomServerPersistenceQueues>(
251
+ () => new Map(),
252
+ );
253
+ const storageScopeKey =
254
+ storage.scopeId === undefined ? "unscoped" : `scoped:${storage.scopeId}`;
255
+ const { customServers, isHydrated, updateCustomServers } = useResource(
256
+ withKey(
257
+ storageScopeKey,
258
+ McpCustomServersResource({
259
+ storage,
260
+ scopeKey: storageScopeKey,
261
+ persistenceQueues,
262
+ }),
263
+ ),
264
+ );
139
265
 
140
266
  const serverElements = useMemo(() => {
141
267
  assertUniqueServerIds([
@@ -170,24 +296,32 @@ const useMcpManagerResource = (
170
296
  const customElements = customServers.map((s) =>
171
297
  withKey(
172
298
  s.id,
173
- McpServerResource({
174
- id: s.id,
175
- kind: "custom",
176
- name: s.name,
177
- url: s.url,
178
- auth: s.auth,
179
- storage,
180
- redirectUri,
181
- autoConnect,
182
- connectionTimeout: s.connectionTimeout ?? connectionTimeout,
183
- ...(s.cache !== undefined ? { cache: s.cache } : {}),
184
- ...(s.elicitation !== undefined
185
- ? { elicitation: s.elicitation }
186
- : {}),
187
- onRemove: async () => {
188
- setCustomServers((prev) => prev.filter((x) => x.id !== s.id));
189
- },
190
- }),
299
+ McpServerResource(
300
+ withMcpServerRemovalFence(
301
+ {
302
+ id: s.id,
303
+ kind: "custom",
304
+ name: s.name,
305
+ url: s.url,
306
+ auth: s.auth,
307
+ storage,
308
+ redirectUri,
309
+ autoConnect,
310
+ connectionTimeout: s.connectionTimeout ?? connectionTimeout,
311
+ ...(s.cache !== undefined ? { cache: s.cache } : {}),
312
+ ...(s.elicitation !== undefined
313
+ ? { elicitation: s.elicitation }
314
+ : {}),
315
+ onRemove: async () => {
316
+ updateCustomServers((prev) =>
317
+ prev.filter((record) => record.id !== s.id),
318
+ );
319
+ },
320
+ },
321
+ () =>
322
+ holdCustomServerPersistence(persistenceQueues, storageScopeKey),
323
+ ),
324
+ ),
191
325
  ),
192
326
  );
193
327
  return [...connectorElements, ...customElements];
@@ -198,6 +332,9 @@ const useMcpManagerResource = (
198
332
  redirectUri,
199
333
  autoConnect,
200
334
  connectionTimeout,
335
+ updateCustomServers,
336
+ persistenceQueues,
337
+ storageScopeKey,
201
338
  ]);
202
339
 
203
340
  const lookup = useClientLookup(serverElements);
@@ -289,7 +426,7 @@ const useMcpManagerResource = (
289
426
  ...(elicitation !== undefined ? { elicitation } : {}),
290
427
  createdAt: Date.now(),
291
428
  };
292
- setCustomServers((prev) => [...prev, record]);
429
+ updateCustomServers((prev) => [...prev, record]);
293
430
  return record.id;
294
431
  },
295
432
  removeServer: async (id) => {
@@ -307,8 +444,20 @@ const useMcpManagerResource = (
307
444
  try {
308
445
  await lookup.get({ key: id }).remove();
309
446
  } catch {
310
- await clearOAuthProviderAuthState(storage, id);
311
- setCustomServers((prev) => prev.filter((s) => s.id !== id));
447
+ const releasePersistence = holdCustomServerPersistence(
448
+ persistenceQueues,
449
+ storageScopeKey,
450
+ );
451
+ try {
452
+ await clearOAuthProviderAuthState(storage, id);
453
+ updateCustomServers((prev) =>
454
+ prev.filter((record) => record.id !== id),
455
+ );
456
+ } catch (error) {
457
+ releasePersistence();
458
+ throw error;
459
+ }
460
+ releasePersistence();
312
461
  }
313
462
  },
314
463
  };
@@ -0,0 +1,21 @@
1
+ import type { McpServerResourceProps } from "./McpServerResource";
2
+
3
+ const beginRemovalFenceSymbol = Symbol();
4
+
5
+ type McpServerResourceInternalProps = McpServerResourceProps & {
6
+ [beginRemovalFenceSymbol]?: () => () => void;
7
+ };
8
+
9
+ export const withMcpServerRemovalFence = (
10
+ props: McpServerResourceProps,
11
+ beginRemovalFence: () => () => void,
12
+ ): McpServerResourceProps =>
13
+ ({
14
+ ...props,
15
+ [beginRemovalFenceSymbol]: beginRemovalFence,
16
+ }) as McpServerResourceProps;
17
+
18
+ export const beginMcpServerRemovalFence = (
19
+ props: McpServerResourceProps,
20
+ ): (() => void) | undefined =>
21
+ (props as McpServerResourceInternalProps)[beginRemovalFenceSymbol]?.();
@@ -34,6 +34,7 @@ import type {
34
34
  MCPToolInfo,
35
35
  } from "../mcp-scope";
36
36
  import { createMcpId } from "../utils/createMcpId";
37
+ import { beginMcpServerRemovalFence } from "./McpServerRemovalFence";
37
38
 
38
39
  export type McpServerResourceProps = {
39
40
  id: string;
@@ -666,16 +667,19 @@ const useMcpServerResourceInstance = (
666
667
  connect: doConnect,
667
668
  disconnect: doDisconnect,
668
669
  remove: async () => {
669
- await doDisconnect();
670
+ const releaseRemovalFence = beginMcpServerRemovalFence(props);
670
671
  try {
672
+ await doDisconnect();
671
673
  await clearOAuthProviderAuthState(props.storage, props.id);
672
674
  await props.onRemove();
673
675
  } catch (err) {
676
+ releaseRemovalFence?.();
674
677
  setLastError({
675
678
  message: err instanceof Error ? err.message : String(err),
676
679
  });
677
680
  throw err;
678
681
  }
682
+ releaseRemovalFence?.();
679
683
  },
680
684
  callTool: async (name, args) => {
681
685
  const client = clientRef.current;
@@ -239,6 +239,8 @@ describe("normalizePersistedAuthState", () => {
239
239
 
240
240
  it.each([
241
241
  "http://auth.example.com",
242
+ "http://127.example.com",
243
+ "http://127.0.0.1.example.com",
242
244
  "data:text/plain,auth",
243
245
  "file:///tmp/auth",
244
246
  ])("drops discovery state with an unsafe URL: %s", (url) => {
@@ -148,12 +148,13 @@ const isSecureNetworkUrl = (value: unknown): value is string => {
148
148
  if (!isNonEmptyString(value)) return false;
149
149
  try {
150
150
  const url = new URL(value);
151
+ const isIpv4Loopback = /^127(?:\.\d{1,3}){3}$/.test(url.hostname);
151
152
  return (
152
153
  url.protocol === "https:" ||
153
154
  (url.protocol === "http:" &&
154
155
  (url.hostname === "localhost" ||
155
156
  url.hostname.endsWith(".localhost") ||
156
- url.hostname.startsWith("127.") ||
157
+ isIpv4Loopback ||
157
158
  url.hostname === "[::1]"))
158
159
  );
159
160
  } catch {
@@ -5,14 +5,16 @@ import type { MCPPersistedAuthState } from "../../auth/types";
5
5
  export type MCPStorage = {
6
6
  /**
7
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.
8
+ * must read and write the same persisted data. When present, custom servers,
9
+ * server connections, and the OAuth write fence key on it. Swapping to a
10
+ * differently-scoped storage rehydrates its custom servers and reconnects
11
+ * authenticated servers instead of retaining data or a live OAuth flow from
12
+ * the replaced store. Clearing through a same-scoped replacement still waits
13
+ * for writes queued against the storage it replaced. When absent, the fence
14
+ * falls back to object identity while custom servers and connections never
15
+ * re-key. Replacing an unscoped storage can therefore persist the previous
16
+ * store's custom servers into the replacement, and a clear runs unfenced
17
+ * against writes queued by the object it replaced.
16
18
  */
17
19
  scopeId?: string;
18
20
  loadCustomServers: () => Promise<MCPCustomServerRecord[]>;