@assistant-ui/react-mcp 0.1.17 → 0.1.19

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 (170) hide show
  1. package/dist/auth/buildHeaders.d.ts +1 -2
  2. package/dist/auth/buildHeaders.d.ts.map +1 -1
  3. package/dist/auth/createOAuthProvider.d.ts +5 -6
  4. package/dist/auth/createOAuthProvider.d.ts.map +1 -1
  5. package/dist/auth/types.d.ts +1 -2
  6. package/dist/auth/types.d.ts.map +1 -1
  7. package/dist/connector.d.ts +1 -2
  8. package/dist/connector.d.ts.map +1 -1
  9. package/dist/context/McpConnectorByIndexProvider.d.ts +1 -2
  10. package/dist/context/McpConnectorByIndexProvider.d.ts.map +1 -1
  11. package/dist/context/McpCustomServerByIndexProvider.d.ts +1 -2
  12. package/dist/context/McpCustomServerByIndexProvider.d.ts.map +1 -1
  13. package/dist/context/McpServerByIdProvider.d.ts +1 -2
  14. package/dist/context/McpServerByIdProvider.d.ts.map +1 -1
  15. package/dist/hooks/useMcpOAuthCallback.d.ts +5 -6
  16. package/dist/hooks/useMcpOAuthCallback.d.ts.map +1 -1
  17. package/dist/hooks/useMcpOAuthCallback.js +4 -0
  18. package/dist/hooks/useMcpOAuthCallback.js.map +1 -1
  19. package/dist/mcp-scope.d.ts +14 -15
  20. package/dist/mcp-scope.d.ts.map +1 -1
  21. package/dist/primitives/addForm/McpAddFormAuthFields.d.ts +4 -4
  22. package/dist/primitives/addForm/McpAddFormAuthFields.d.ts.map +1 -1
  23. package/dist/primitives/addForm/McpAddFormAuthFields.js +28 -38
  24. package/dist/primitives/addForm/McpAddFormAuthFields.js.map +1 -1
  25. package/dist/primitives/addForm/McpAddFormAuthSelect.d.ts +2 -3
  26. package/dist/primitives/addForm/McpAddFormAuthSelect.d.ts.map +1 -1
  27. package/dist/primitives/addForm/McpAddFormBearerTokenField.d.ts +10 -0
  28. package/dist/primitives/addForm/McpAddFormBearerTokenField.d.ts.map +1 -0
  29. package/dist/primitives/addForm/McpAddFormBearerTokenField.js +50 -0
  30. package/dist/primitives/addForm/McpAddFormBearerTokenField.js.map +1 -0
  31. package/dist/primitives/addForm/McpAddFormCancel.d.ts +2 -3
  32. package/dist/primitives/addForm/McpAddFormCancel.d.ts.map +1 -1
  33. package/dist/primitives/addForm/McpAddFormError.d.ts +2 -3
  34. package/dist/primitives/addForm/McpAddFormError.d.ts.map +1 -1
  35. package/dist/primitives/addForm/McpAddFormError.js +30 -10
  36. package/dist/primitives/addForm/McpAddFormError.js.map +1 -1
  37. package/dist/primitives/addForm/McpAddFormNameField.d.ts +2 -3
  38. package/dist/primitives/addForm/McpAddFormNameField.d.ts.map +1 -1
  39. package/dist/primitives/addForm/McpAddFormNameField.js +31 -18
  40. package/dist/primitives/addForm/McpAddFormNameField.js.map +1 -1
  41. package/dist/primitives/addForm/McpAddFormRoot.d.ts +2 -3
  42. package/dist/primitives/addForm/McpAddFormRoot.d.ts.map +1 -1
  43. package/dist/primitives/addForm/McpAddFormRoot.js +32 -11
  44. package/dist/primitives/addForm/McpAddFormRoot.js.map +1 -1
  45. package/dist/primitives/addForm/McpAddFormScopesField.d.ts +10 -0
  46. package/dist/primitives/addForm/McpAddFormScopesField.d.ts.map +1 -0
  47. package/dist/primitives/addForm/McpAddFormScopesField.js +38 -0
  48. package/dist/primitives/addForm/McpAddFormScopesField.js.map +1 -0
  49. package/dist/primitives/addForm/McpAddFormSubmit.d.ts +2 -3
  50. package/dist/primitives/addForm/McpAddFormSubmit.d.ts.map +1 -1
  51. package/dist/primitives/addForm/McpAddFormUrlField.d.ts +2 -3
  52. package/dist/primitives/addForm/McpAddFormUrlField.d.ts.map +1 -1
  53. package/dist/primitives/addForm/McpAddFormUrlField.js +31 -18
  54. package/dist/primitives/addForm/McpAddFormUrlField.js.map +1 -1
  55. package/dist/primitives/addForm/context.d.ts +13 -6
  56. package/dist/primitives/addForm/context.d.ts.map +1 -1
  57. package/dist/primitives/addForm/context.js.map +1 -1
  58. package/dist/primitives/addForm.d.ts +4 -2
  59. package/dist/primitives/addForm.js +5 -1
  60. package/dist/primitives/addForm.js.map +1 -1
  61. package/dist/primitives/elicitation/McpElicitationPrimitiveAccept.d.ts +2 -3
  62. package/dist/primitives/elicitation/McpElicitationPrimitiveAccept.d.ts.map +1 -1
  63. package/dist/primitives/elicitation/McpElicitationPrimitiveCancel.d.ts +2 -3
  64. package/dist/primitives/elicitation/McpElicitationPrimitiveCancel.d.ts.map +1 -1
  65. package/dist/primitives/elicitation/McpElicitationPrimitiveDecline.d.ts +2 -3
  66. package/dist/primitives/elicitation/McpElicitationPrimitiveDecline.d.ts.map +1 -1
  67. package/dist/primitives/elicitation/McpElicitationPrimitiveError.d.ts +2 -3
  68. package/dist/primitives/elicitation/McpElicitationPrimitiveError.d.ts.map +1 -1
  69. package/dist/primitives/elicitation/McpElicitationPrimitiveFields.d.ts +3 -3
  70. package/dist/primitives/elicitation/McpElicitationPrimitiveFields.d.ts.map +1 -1
  71. package/dist/primitives/elicitation/McpElicitationPrimitiveItems.d.ts +3 -3
  72. package/dist/primitives/elicitation/McpElicitationPrimitiveItems.d.ts.map +1 -1
  73. package/dist/primitives/elicitation/McpElicitationPrimitiveMessage.d.ts +2 -3
  74. package/dist/primitives/elicitation/McpElicitationPrimitiveMessage.d.ts.map +1 -1
  75. package/dist/primitives/elicitation/McpElicitationPrimitiveRoot.d.ts +2 -3
  76. package/dist/primitives/elicitation/McpElicitationPrimitiveRoot.d.ts.map +1 -1
  77. package/dist/primitives/elicitation/context.d.ts +7 -8
  78. package/dist/primitives/elicitation/context.d.ts.map +1 -1
  79. package/dist/primitives/elicitation/initialElicitationDraft.d.ts +1 -2
  80. package/dist/primitives/elicitation/initialElicitationDraft.d.ts.map +1 -1
  81. package/dist/primitives/elicitation/prepareElicitationContent.d.ts +1 -2
  82. package/dist/primitives/elicitation/prepareElicitationContent.d.ts.map +1 -1
  83. package/dist/primitives/elicitation/prepareElicitationContent.js +5 -1
  84. package/dist/primitives/elicitation/prepareElicitationContent.js.map +1 -1
  85. package/dist/primitives/manager/McpManagerAddCustomTrigger.d.ts +2 -3
  86. package/dist/primitives/manager/McpManagerAddCustomTrigger.d.ts.map +1 -1
  87. package/dist/primitives/manager/McpManagerConnectors.d.ts +2 -3
  88. package/dist/primitives/manager/McpManagerConnectors.d.ts.map +1 -1
  89. package/dist/primitives/manager/McpManagerCustomServers.d.ts +2 -3
  90. package/dist/primitives/manager/McpManagerCustomServers.d.ts.map +1 -1
  91. package/dist/primitives/manager/McpManagerRoot.d.ts +2 -3
  92. package/dist/primitives/manager/McpManagerRoot.d.ts.map +1 -1
  93. package/dist/primitives/server/McpServerConnectButton.d.ts +2 -3
  94. package/dist/primitives/server/McpServerConnectButton.d.ts.map +1 -1
  95. package/dist/primitives/server/McpServerDisconnectButton.d.ts +2 -3
  96. package/dist/primitives/server/McpServerDisconnectButton.d.ts.map +1 -1
  97. package/dist/primitives/server/McpServerError.d.ts +2 -3
  98. package/dist/primitives/server/McpServerError.d.ts.map +1 -1
  99. package/dist/primitives/server/McpServerIcon.d.ts +2 -3
  100. package/dist/primitives/server/McpServerIcon.d.ts.map +1 -1
  101. package/dist/primitives/server/McpServerName.d.ts +2 -3
  102. package/dist/primitives/server/McpServerName.d.ts.map +1 -1
  103. package/dist/primitives/server/McpServerOAuthLink.d.ts +2 -3
  104. package/dist/primitives/server/McpServerOAuthLink.d.ts.map +1 -1
  105. package/dist/primitives/server/McpServerRemoveButton.d.ts +2 -3
  106. package/dist/primitives/server/McpServerRemoveButton.d.ts.map +1 -1
  107. package/dist/primitives/server/McpServerRoot.d.ts +2 -3
  108. package/dist/primitives/server/McpServerRoot.d.ts.map +1 -1
  109. package/dist/primitives/server/McpServerStatus.d.ts +2 -3
  110. package/dist/primitives/server/McpServerStatus.d.ts.map +1 -1
  111. package/dist/primitives/server/McpServerToolName.d.ts +2 -3
  112. package/dist/primitives/server/McpServerToolName.d.ts.map +1 -1
  113. package/dist/primitives/server/McpServerTools.d.ts +3 -4
  114. package/dist/primitives/server/McpServerTools.d.ts.map +1 -1
  115. package/dist/resources/McpManagerResource.d.ts +2 -3
  116. package/dist/resources/McpManagerResource.d.ts.map +1 -1
  117. package/dist/resources/McpManagerResource.js +358 -244
  118. package/dist/resources/McpManagerResource.js.map +1 -1
  119. package/dist/resources/McpServerRemovalFence.d.ts +6 -0
  120. package/dist/resources/McpServerRemovalFence.d.ts.map +1 -0
  121. package/dist/resources/McpServerRemovalFence.js +11 -0
  122. package/dist/resources/McpServerRemovalFence.js.map +1 -0
  123. package/dist/resources/McpServerResource.d.ts +3 -4
  124. package/dist/resources/McpServerResource.d.ts.map +1 -1
  125. package/dist/resources/McpServerResource.js +9 -5
  126. package/dist/resources/McpServerResource.js.map +1 -1
  127. package/dist/resources/storage/McpCustomStorage.d.ts +1 -2
  128. package/dist/resources/storage/McpCustomStorage.d.ts.map +1 -1
  129. package/dist/resources/storage/McpLocalStorage.d.ts +4 -5
  130. package/dist/resources/storage/McpLocalStorage.d.ts.map +1 -1
  131. package/dist/resources/storage/McpLocalStorage.js +2 -1
  132. package/dist/resources/storage/McpLocalStorage.js.map +1 -1
  133. package/dist/resources/storage/McpMemoryStorage.d.ts +1 -2
  134. package/dist/resources/storage/McpMemoryStorage.d.ts.map +1 -1
  135. package/dist/resources/storage/types.d.ts +12 -11
  136. package/dist/resources/storage/types.d.ts.map +1 -1
  137. package/dist/resources/validateElicitationContent.d.ts +1 -2
  138. package/dist/resources/validateElicitationContent.d.ts.map +1 -1
  139. package/dist/utils/createMcpId.d.ts +1 -2
  140. package/dist/utils/createMcpId.d.ts.map +1 -1
  141. package/dist/utils/invokeMcpCallback.d.ts +1 -2
  142. package/dist/utils/invokeMcpCallback.d.ts.map +1 -1
  143. package/dist/utils/serverId.d.ts +2 -3
  144. package/dist/utils/serverId.d.ts.map +1 -1
  145. package/dist/utils/serverUrl.d.ts +3 -4
  146. package/dist/utils/serverUrl.d.ts.map +1 -1
  147. package/package.json +11 -10
  148. package/src/hooks/useMcpOAuthCallback.test.ts +53 -0
  149. package/src/hooks/useMcpOAuthCallback.tsx +12 -0
  150. package/src/index.ts +2 -1
  151. package/src/primitives/addForm/McpAddFormAccessibility.test.tsx +310 -0
  152. package/src/primitives/addForm/McpAddFormAuthFields.tsx +26 -16
  153. package/src/primitives/addForm/McpAddFormBearerTokenField.tsx +46 -0
  154. package/src/primitives/addForm/McpAddFormError.tsx +17 -3
  155. package/src/primitives/addForm/McpAddFormNameField.tsx +13 -1
  156. package/src/primitives/addForm/McpAddFormRoot.test.tsx +65 -0
  157. package/src/primitives/addForm/McpAddFormRoot.tsx +51 -8
  158. package/src/primitives/addForm/McpAddFormScopesField.tsx +34 -0
  159. package/src/primitives/addForm/McpAddFormUrlField.tsx +12 -1
  160. package/src/primitives/addForm/context.tsx +10 -0
  161. package/src/primitives/addForm.ts +2 -0
  162. package/src/primitives/elicitation/prepareElicitationContent.ts +8 -6
  163. package/src/resources/McpManagerResource.test.ts +797 -9
  164. package/src/resources/McpManagerResource.ts +242 -60
  165. package/src/resources/McpServerRemovalFence.ts +21 -0
  166. package/src/resources/McpServerResource.test.ts +9 -6
  167. package/src/resources/McpServerResource.ts +11 -6
  168. package/src/resources/storage/McpLocalStorage.test.ts +2 -0
  169. package/src/resources/storage/McpLocalStorage.ts +2 -1
  170. 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,59 +77,139 @@ 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
+ };
118
+
119
+ const deduplicateCustomServers = (records: MCPCustomServerRecord[]) => {
120
+ const seen = new Set<string>();
121
+ return records.filter((record) => {
122
+ if (seen.has(record.id)) {
123
+ console.error(
124
+ `[assistant-ui/react-mcp] ignored duplicate custom server id "${record.id}" loaded from storage`,
125
+ );
126
+ return false;
127
+ }
128
+ seen.add(record.id);
129
+ return true;
130
+ });
131
+ };
132
+
133
+ type McpCustomServersResourceProps = {
134
+ storage: MCPStorage;
135
+ scopeKey: string;
136
+ persistenceQueues: CustomServerPersistenceQueues;
137
+ };
76
138
 
139
+ const useMcpCustomServersResource = ({
140
+ storage,
141
+ scopeKey,
142
+ persistenceQueues,
143
+ }: McpCustomServersResourceProps) => {
77
144
  const [customServers, setCustomServers] = useState<MCPCustomServerRecord[]>(
78
145
  [],
79
146
  );
80
147
  const [isHydrated, setIsHydrated] = useState(false);
81
148
 
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]);
149
+ const customServersRef = useRef<MCPCustomServerRecord[]>([]);
150
+ const hydrationStateRef = useRef<"pending" | "succeeded" | "failed">(
151
+ "pending",
152
+ );
153
+ const hasPendingMutationRef = useRef(false);
154
+ const [removedBeforeHydration] = useState(() => new Set<string>());
155
+ const reportedBlockedPersistenceRef = useRef(false);
89
156
 
90
157
  const hydrate = useEffectEvent(async (signal: { cancelled: boolean }) => {
91
- const markHydrated = () => {
92
- if (!signal.cancelled) {
93
- hydratedRef.current = true;
94
- setIsHydrated(true);
95
- }
96
- };
158
+ // A revisited scope must not read behind writes still queued against it.
159
+ while (true) {
160
+ const pendingPersistence = persistenceQueues.get(scopeKey);
161
+ if (!pendingPersistence) break;
162
+ await pendingPersistence;
163
+ if (signal.cancelled) return;
164
+ if (persistenceQueues.get(scopeKey) === pendingPersistence) break;
165
+ }
97
166
 
98
167
  let records: Awaited<ReturnType<typeof storage.loadCustomServers>>;
99
168
  try {
100
- records = await storage.loadCustomServers();
169
+ const loadedRecords = await storage.loadCustomServers();
170
+ records = deduplicateCustomServers(loadedRecords);
101
171
  } catch (error) {
102
172
  if (!signal.cancelled) {
103
173
  reportCustomStorageFailure("load", error);
174
+ hydrationStateRef.current = "failed";
175
+ if (hasPendingMutationRef.current) {
176
+ reportBlockedCustomServerPersistence();
177
+ reportedBlockedPersistenceRef.current = true;
178
+ }
179
+ setIsHydrated(true);
104
180
  }
105
- markHydrated();
106
181
  return;
107
182
  }
108
-
109
- if (signal.cancelled) return;
110
183
  // Merge rather than replace so any addCustomServer calls that
111
184
  // happened before hydration resolved aren't silently overwritten.
112
185
  // Persisted order wins; pre-hydration locals append.
113
- setCustomServers((prev) => {
114
- if (prev.length === 0) return records;
115
- const persistedIds = new Set(records.map((r) => r.id));
116
- return [...records, ...prev.filter((r) => !persistedIds.has(r.id))];
117
- });
118
- markHydrated();
186
+ const hadPendingMutation = hasPendingMutationRef.current;
187
+ const hydratedRecords = records.filter(
188
+ (record) => !removedBeforeHydration.has(record.id),
189
+ );
190
+ const mergedRecords = (() => {
191
+ const prev = customServersRef.current;
192
+ if (prev.length === 0) return hydratedRecords;
193
+ const persistedIds = new Set(hydratedRecords.map((r) => r.id));
194
+ return [
195
+ ...hydratedRecords,
196
+ ...prev.filter((r) => !persistedIds.has(r.id)),
197
+ ];
198
+ })();
199
+ customServersRef.current = mergedRecords;
200
+ hydrationStateRef.current = "succeeded";
201
+ hasPendingMutationRef.current = false;
202
+ if (hadPendingMutation) {
203
+ enqueueCustomServerPersistence(
204
+ persistenceQueues,
205
+ scopeKey,
206
+ storage,
207
+ mergedRecords,
208
+ );
209
+ }
210
+ if (signal.cancelled) return;
211
+ setCustomServers(mergedRecords);
212
+ setIsHydrated(true);
119
213
  });
120
214
 
121
215
  useEffect(() => {
@@ -129,13 +223,82 @@ const useMcpManagerResource = (
129
223
  };
130
224
  }, []);
131
225
 
132
- useEffect(() => {
133
- if (!hydratedRef.current) return;
134
- const targetStorage = storageRef.current;
135
- persistenceQueueRef.current = persistenceQueueRef.current.then(() =>
136
- persistCustomServers(targetStorage, customServers),
226
+ const updateCustomServers = useCallback(
227
+ (
228
+ updater: (records: MCPCustomServerRecord[]) => MCPCustomServerRecord[],
229
+ ) => {
230
+ const next = updater(customServersRef.current);
231
+ customServersRef.current = next;
232
+ setCustomServers(next);
233
+
234
+ if (hydrationStateRef.current === "succeeded") {
235
+ enqueueCustomServerPersistence(
236
+ persistenceQueues,
237
+ scopeKey,
238
+ storage,
239
+ next,
240
+ );
241
+ return;
242
+ }
243
+
244
+ hasPendingMutationRef.current = true;
245
+ if (
246
+ hydrationStateRef.current === "failed" &&
247
+ !reportedBlockedPersistenceRef.current
248
+ ) {
249
+ reportBlockedCustomServerPersistence();
250
+ reportedBlockedPersistenceRef.current = true;
251
+ }
252
+ },
253
+ [persistenceQueues, scopeKey, storage],
254
+ );
255
+
256
+ const removeCustomServer = useCallback(
257
+ (id: string) => {
258
+ if (hydrationStateRef.current === "pending") {
259
+ removedBeforeHydration.add(id);
260
+ }
261
+ updateCustomServers((prev) => prev.filter((record) => record.id !== id));
262
+ },
263
+ [removedBeforeHydration, updateCustomServers],
264
+ );
265
+
266
+ return {
267
+ customServers,
268
+ isHydrated,
269
+ updateCustomServers,
270
+ removeCustomServer,
271
+ };
272
+ };
273
+
274
+ const McpCustomServersResource = resource(useMcpCustomServersResource);
275
+
276
+ const useMcpManagerResource = (
277
+ props: McpManagerResourceProps,
278
+ ): ClientOutput<"mcp"> => {
279
+ const connectors = props.connectors ?? NO_CONNECTORS;
280
+ const autoConnect = props.autoConnect ?? true;
281
+ const redirectUri = props.oauthRedirectUri ?? defaultRedirectUri();
282
+ const connectionTimeout = props.connectionTimeout;
283
+
284
+ const storageElement = props.storage ?? McpLocalStorage();
285
+ const storage = useResource(storageElement);
286
+ const [persistenceQueues] = useState<CustomServerPersistenceQueues>(
287
+ () => new Map(),
288
+ );
289
+ const storageScopeKey =
290
+ storage.scopeId === undefined ? "unscoped" : `scoped:${storage.scopeId}`;
291
+ const { customServers, isHydrated, updateCustomServers, removeCustomServer } =
292
+ useResource(
293
+ withKey(
294
+ storageScopeKey,
295
+ McpCustomServersResource({
296
+ storage,
297
+ scopeKey: storageScopeKey,
298
+ persistenceQueues,
299
+ }),
300
+ ),
137
301
  );
138
- }, [customServers]);
139
302
 
140
303
  const serverElements = useMemo(() => {
141
304
  assertUniqueServerIds([
@@ -170,24 +333,30 @@ const useMcpManagerResource = (
170
333
  const customElements = customServers.map((s) =>
171
334
  withKey(
172
335
  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
- }),
336
+ McpServerResource(
337
+ withMcpServerRemovalFence(
338
+ {
339
+ id: s.id,
340
+ kind: "custom",
341
+ name: s.name,
342
+ url: s.url,
343
+ auth: s.auth,
344
+ storage,
345
+ redirectUri,
346
+ autoConnect,
347
+ connectionTimeout: s.connectionTimeout ?? connectionTimeout,
348
+ ...(s.cache !== undefined ? { cache: s.cache } : {}),
349
+ ...(s.elicitation !== undefined
350
+ ? { elicitation: s.elicitation }
351
+ : {}),
352
+ onRemove: async () => {
353
+ removeCustomServer(s.id);
354
+ },
355
+ },
356
+ () =>
357
+ holdCustomServerPersistence(persistenceQueues, storageScopeKey),
358
+ ),
359
+ ),
191
360
  ),
192
361
  );
193
362
  return [...connectorElements, ...customElements];
@@ -198,6 +367,9 @@ const useMcpManagerResource = (
198
367
  redirectUri,
199
368
  autoConnect,
200
369
  connectionTimeout,
370
+ removeCustomServer,
371
+ persistenceQueues,
372
+ storageScopeKey,
201
373
  ]);
202
374
 
203
375
  const lookup = useClientLookup(serverElements);
@@ -289,7 +461,7 @@ const useMcpManagerResource = (
289
461
  ...(elicitation !== undefined ? { elicitation } : {}),
290
462
  createdAt: Date.now(),
291
463
  };
292
- setCustomServers((prev) => [...prev, record]);
464
+ updateCustomServers((prev) => [...prev, record]);
293
465
  return record.id;
294
466
  },
295
467
  removeServer: async (id) => {
@@ -307,8 +479,18 @@ const useMcpManagerResource = (
307
479
  try {
308
480
  await lookup.get({ key: id }).remove();
309
481
  } catch {
310
- await clearOAuthProviderAuthState(storage, id);
311
- setCustomServers((prev) => prev.filter((s) => s.id !== id));
482
+ const releasePersistence = holdCustomServerPersistence(
483
+ persistenceQueues,
484
+ storageScopeKey,
485
+ );
486
+ try {
487
+ await clearOAuthProviderAuthState(storage, id);
488
+ removeCustomServer(id);
489
+ } catch (error) {
490
+ releasePersistence();
491
+ throw error;
492
+ }
493
+ releasePersistence();
312
494
  }
313
495
  },
314
496
  };
@@ -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]?.();
@@ -113,8 +113,8 @@ const requestElicitation = (
113
113
  client: any,
114
114
  message: string,
115
115
  requestedSchema: unknown,
116
- context: { signal: AbortSignal } = {
117
- signal: new AbortController().signal,
116
+ context: { mcpReq: { signal: AbortSignal } } = {
117
+ mcpReq: { signal: new AbortController().signal },
118
118
  },
119
119
  ) => {
120
120
  const handler = client.requestHandlers.get("elicitation/create");
@@ -156,7 +156,6 @@ const mount = (
156
156
  cache?: { readonly defaultTtlMs?: number } | undefined;
157
157
  elicitation?: boolean | undefined;
158
158
  kind?: "connector" | "custom" | undefined;
159
- storage?: MCPStorage | undefined;
160
159
  onRemove?: (() => Promise<void>) | undefined;
161
160
  },
162
161
  onMount?: (server: ClientOutput<"mcpServer">) => void,
@@ -292,7 +291,9 @@ describe("McpServerResource automatic authentication", () => {
292
291
  });
293
292
 
294
293
  try {
295
- await waitFor(() => storage.loadAuthState.mock.calls.length > 0);
294
+ await waitFor(
295
+ () => vi.mocked(storage.loadAuthState).mock.calls.length > 0,
296
+ );
296
297
  await flushMacrotask();
297
298
 
298
299
  expect(root.getValue().getState()).toMatchObject({
@@ -338,7 +339,9 @@ describe("McpServerResource automatic authentication", () => {
338
339
  });
339
340
 
340
341
  try {
341
- await waitFor(() => storage.loadAuthState.mock.calls.length > 0);
342
+ await waitFor(
343
+ () => vi.mocked(storage.loadAuthState).mock.calls.length > 0,
344
+ );
342
345
  await flushMacrotask();
343
346
 
344
347
  expect(root.getValue().getState()).toMatchObject({
@@ -1474,7 +1477,7 @@ describe("McpServerResource elicitation", () => {
1474
1477
  type: "object",
1475
1478
  properties: {},
1476
1479
  },
1477
- { signal: controller.signal },
1480
+ { mcpReq: { signal: controller.signal } },
1478
1481
  );
1479
1482
  await waitForResourceUpdate(
1480
1483
  () => root.getValue().getState().pendingElicitations.length === 1,
@@ -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;
@@ -379,7 +380,7 @@ const useMcpServerResourceInstance = (
379
380
  };
380
381
  elicitationResolversRef.current.set(id, {
381
382
  resolve,
382
- signal: context.signal,
383
+ signal: context.mcpReq.signal,
383
384
  onAbort,
384
385
  requestedSchema,
385
386
  });
@@ -394,10 +395,10 @@ const useMcpServerResourceInstance = (
394
395
  ]);
395
396
  const entry = elicitationResolversRef.current.get(id);
396
397
  if (entry) {
397
- if (context.signal.aborted) {
398
+ if (context.mcpReq.signal.aborted) {
398
399
  entry.onAbort();
399
400
  } else {
400
- context.signal.addEventListener("abort", entry.onAbort, {
401
+ context.mcpReq.signal.addEventListener("abort", entry.onAbort, {
401
402
  once: true,
402
403
  });
403
404
  }
@@ -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;
@@ -708,7 +712,7 @@ const useMcpServerResourceInstance = (
708
712
  ): readonly { property: string; message: string }[] | undefined => {
709
713
  if (response.action === "accept") {
710
714
  const entry = elicitationResolversRef.current.get(id);
711
- if (!entry) return;
715
+ if (!entry) return undefined;
712
716
 
713
717
  if (
714
718
  typeof response.content !== "object" ||
@@ -748,10 +752,11 @@ const useMcpServerResourceInstance = (
748
752
  content: response.content as ElicitResult["content"],
749
753
  };
750
754
  resolvePendingElicitation(id, result);
751
- return;
755
+ return undefined;
752
756
  }
753
757
 
754
758
  resolvePendingElicitation(id, { action: response.action });
759
+ return undefined;
755
760
  },
756
761
  };
757
762
  };
@@ -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[]>;