@assistant-ui/react-mcp 0.1.16 → 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 (61) hide show
  1. package/dist/auth/createOAuthProvider.d.ts +13 -2
  2. package/dist/auth/createOAuthProvider.d.ts.map +1 -1
  3. package/dist/auth/createOAuthProvider.js +109 -31
  4. package/dist/auth/createOAuthProvider.js.map +1 -1
  5. package/dist/auth/types.d.ts +5 -1
  6. package/dist/auth/types.d.ts.map +1 -1
  7. package/dist/primitives/addForm/McpAddFormAuthFields.js +71 -28
  8. package/dist/primitives/addForm/McpAddFormAuthFields.js.map +1 -1
  9. package/dist/primitives/addForm/McpAddFormError.d.ts.map +1 -1
  10. package/dist/primitives/addForm/McpAddFormError.js +30 -10
  11. package/dist/primitives/addForm/McpAddFormError.js.map +1 -1
  12. package/dist/primitives/addForm/McpAddFormNameField.js +31 -18
  13. package/dist/primitives/addForm/McpAddFormNameField.js.map +1 -1
  14. package/dist/primitives/addForm/McpAddFormRoot.d.ts.map +1 -1
  15. package/dist/primitives/addForm/McpAddFormRoot.js +32 -11
  16. package/dist/primitives/addForm/McpAddFormRoot.js.map +1 -1
  17. package/dist/primitives/addForm/McpAddFormUrlField.js +31 -18
  18. package/dist/primitives/addForm/McpAddFormUrlField.js.map +1 -1
  19. package/dist/primitives/addForm/context.d.ts +9 -1
  20. package/dist/primitives/addForm/context.d.ts.map +1 -1
  21. package/dist/primitives/addForm/context.js.map +1 -1
  22. package/dist/resources/McpManagerResource.d.ts.map +1 -1
  23. package/dist/resources/McpManagerResource.js +330 -247
  24. package/dist/resources/McpManagerResource.js.map +1 -1
  25. package/dist/resources/McpServerRemovalFence.d.ts +7 -0
  26. package/dist/resources/McpServerRemovalFence.d.ts.map +1 -0
  27. package/dist/resources/McpServerRemovalFence.js +11 -0
  28. package/dist/resources/McpServerRemovalFence.js.map +1 -0
  29. package/dist/resources/McpServerResource.d.ts.map +1 -1
  30. package/dist/resources/McpServerResource.js +41 -16
  31. package/dist/resources/McpServerResource.js.map +1 -1
  32. package/dist/resources/storage/McpLocalStorage.d.ts.map +1 -1
  33. package/dist/resources/storage/McpLocalStorage.js +16 -1
  34. package/dist/resources/storage/McpLocalStorage.js.map +1 -1
  35. package/dist/resources/storage/types.d.ts +10 -8
  36. package/dist/resources/storage/types.d.ts.map +1 -1
  37. package/dist/utils/serverUrl.d.ts +8 -0
  38. package/dist/utils/serverUrl.d.ts.map +1 -0
  39. package/dist/utils/serverUrl.js +15 -0
  40. package/dist/utils/serverUrl.js.map +1 -0
  41. package/package.json +7 -7
  42. package/src/auth/createOAuthProvider.test.ts +544 -35
  43. package/src/auth/createOAuthProvider.ts +201 -46
  44. package/src/auth/types.ts +5 -1
  45. package/src/primitives/addForm/McpAddFormAccessibility.test.tsx +261 -0
  46. package/src/primitives/addForm/McpAddFormAuthFields.tsx +33 -15
  47. package/src/primitives/addForm/McpAddFormError.tsx +17 -3
  48. package/src/primitives/addForm/McpAddFormNameField.tsx +13 -1
  49. package/src/primitives/addForm/McpAddFormRoot.tsx +51 -8
  50. package/src/primitives/addForm/McpAddFormUrlField.tsx +12 -1
  51. package/src/primitives/addForm/context.tsx +10 -0
  52. package/src/resources/McpManagerResource.test.ts +712 -9
  53. package/src/resources/McpManagerResource.ts +208 -56
  54. package/src/resources/McpServerRemovalFence.ts +21 -0
  55. package/src/resources/McpServerResource.test.ts +252 -17
  56. package/src/resources/McpServerResource.ts +40 -15
  57. package/src/resources/storage/McpLocalStorage.test.ts +28 -0
  58. package/src/resources/storage/McpLocalStorage.ts +23 -1
  59. package/src/resources/storage/types.ts +10 -8
  60. package/src/utils/serverUrl.test.ts +66 -0
  61. package/src/utils/serverUrl.ts +23 -0
@@ -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,38 +155,113 @@ 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(() => {
122
195
  const signal = { cancelled: false };
196
+ // Hydration reads persisted records asynchronously; there is no earlier
197
+ // point than mount at which to start it.
198
+ // eslint-disable-next-line react-hooks/set-state-in-effect
123
199
  void hydrate(signal);
124
200
  return () => {
125
201
  signal.cancelled = true;
126
202
  };
127
203
  }, []);
128
204
 
129
- useEffect(() => {
130
- if (!hydratedRef.current) return;
131
- const targetStorage = storageRef.current;
132
- persistenceQueueRef.current = persistenceQueueRef.current.then(() =>
133
- persistCustomServers(targetStorage, customServers),
134
- );
135
- }, [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
+ );
136
265
 
137
266
  const serverElements = useMemo(() => {
138
267
  assertUniqueServerIds([
@@ -167,24 +296,32 @@ const useMcpManagerResource = (
167
296
  const customElements = customServers.map((s) =>
168
297
  withKey(
169
298
  s.id,
170
- McpServerResource({
171
- id: s.id,
172
- kind: "custom",
173
- name: s.name,
174
- url: s.url,
175
- auth: s.auth,
176
- storage,
177
- redirectUri,
178
- autoConnect,
179
- connectionTimeout: s.connectionTimeout ?? connectionTimeout,
180
- ...(s.cache !== undefined ? { cache: s.cache } : {}),
181
- ...(s.elicitation !== undefined
182
- ? { elicitation: s.elicitation }
183
- : {}),
184
- onRemove: async () => {
185
- setCustomServers((prev) => prev.filter((x) => x.id !== s.id));
186
- },
187
- }),
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
+ ),
188
325
  ),
189
326
  );
190
327
  return [...connectorElements, ...customElements];
@@ -195,6 +332,9 @@ const useMcpManagerResource = (
195
332
  redirectUri,
196
333
  autoConnect,
197
334
  connectionTimeout,
335
+ updateCustomServers,
336
+ persistenceQueues,
337
+ storageScopeKey,
198
338
  ]);
199
339
 
200
340
  const lookup = useClientLookup(serverElements);
@@ -286,7 +426,7 @@ const useMcpManagerResource = (
286
426
  ...(elicitation !== undefined ? { elicitation } : {}),
287
427
  createdAt: Date.now(),
288
428
  };
289
- setCustomServers((prev) => [...prev, record]);
429
+ updateCustomServers((prev) => [...prev, record]);
290
430
  return record.id;
291
431
  },
292
432
  removeServer: async (id) => {
@@ -304,8 +444,20 @@ const useMcpManagerResource = (
304
444
  try {
305
445
  await lookup.get({ key: id }).remove();
306
446
  } catch {
307
- await clearOAuthProviderAuthState(storage, id);
308
- 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();
309
461
  }
310
462
  },
311
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]?.();