@assistant-ui/react-mcp 0.1.18 → 0.1.20

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 (192) hide show
  1. package/dist/auth/buildHeaders.d.ts +3 -6
  2. package/dist/auth/buildHeaders.d.ts.map +1 -1
  3. package/dist/auth/createOAuthProvider.d.ts +24 -26
  4. package/dist/auth/createOAuthProvider.d.ts.map +1 -1
  5. package/dist/auth/types.d.ts +13 -16
  6. package/dist/auth/types.d.ts.map +1 -1
  7. package/dist/connector.d.ts +2 -5
  8. package/dist/connector.d.ts.map +1 -1
  9. package/dist/context/McpConnectorByIndexProvider.d.ts +3 -6
  10. package/dist/context/McpConnectorByIndexProvider.d.ts.map +1 -1
  11. package/dist/context/McpConnectorByIndexProvider.js +1 -1
  12. package/dist/context/McpCustomServerByIndexProvider.d.ts +3 -6
  13. package/dist/context/McpCustomServerByIndexProvider.d.ts.map +1 -1
  14. package/dist/context/McpCustomServerByIndexProvider.js +1 -1
  15. package/dist/context/McpServerByIdProvider.d.ts +3 -6
  16. package/dist/context/McpServerByIdProvider.d.ts.map +1 -1
  17. package/dist/context/McpServerByIdProvider.js +1 -1
  18. package/dist/hooks/useMcpOAuthCallback.d.ts +14 -17
  19. package/dist/hooks/useMcpOAuthCallback.d.ts.map +1 -1
  20. package/dist/hooks/useMcpOAuthCallback.js +6 -2
  21. package/dist/hooks/useMcpOAuthCallback.js.map +1 -1
  22. package/dist/index.d.ts +19 -18
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +4 -4
  25. package/dist/mcp-scope.d.ts +124 -127
  26. package/dist/mcp-scope.d.ts.map +1 -1
  27. package/dist/primitives/addForm/McpAddFormAuthFields.d.ts +14 -16
  28. package/dist/primitives/addForm/McpAddFormAuthFields.d.ts.map +1 -1
  29. package/dist/primitives/addForm/McpAddFormAuthFields.js +18 -71
  30. package/dist/primitives/addForm/McpAddFormAuthFields.js.map +1 -1
  31. package/dist/primitives/addForm/McpAddFormAuthSelect.d.ts +5 -8
  32. package/dist/primitives/addForm/McpAddFormAuthSelect.d.ts.map +1 -1
  33. package/dist/primitives/addForm/McpAddFormAuthSelect.js +1 -1
  34. package/dist/primitives/addForm/McpAddFormBearerTokenField.d.ts +8 -0
  35. package/dist/primitives/addForm/McpAddFormBearerTokenField.d.ts.map +1 -0
  36. package/dist/primitives/addForm/McpAddFormBearerTokenField.js +50 -0
  37. package/dist/primitives/addForm/McpAddFormBearerTokenField.js.map +1 -0
  38. package/dist/primitives/addForm/McpAddFormCancel.d.ts +6 -9
  39. package/dist/primitives/addForm/McpAddFormCancel.d.ts.map +1 -1
  40. package/dist/primitives/addForm/McpAddFormCancel.js +1 -1
  41. package/dist/primitives/addForm/McpAddFormError.d.ts +6 -9
  42. package/dist/primitives/addForm/McpAddFormError.d.ts.map +1 -1
  43. package/dist/primitives/addForm/McpAddFormError.js +1 -1
  44. package/dist/primitives/addForm/McpAddFormNameField.d.ts +5 -8
  45. package/dist/primitives/addForm/McpAddFormNameField.d.ts.map +1 -1
  46. package/dist/primitives/addForm/McpAddFormNameField.js +1 -1
  47. package/dist/primitives/addForm/McpAddFormRoot.d.ts +11 -14
  48. package/dist/primitives/addForm/McpAddFormRoot.d.ts.map +1 -1
  49. package/dist/primitives/addForm/McpAddFormRoot.js +2 -2
  50. package/dist/primitives/addForm/McpAddFormScopesField.d.ts +8 -0
  51. package/dist/primitives/addForm/McpAddFormScopesField.d.ts.map +1 -0
  52. package/dist/primitives/addForm/McpAddFormScopesField.js +38 -0
  53. package/dist/primitives/addForm/McpAddFormScopesField.js.map +1 -0
  54. package/dist/primitives/addForm/McpAddFormSubmit.d.ts +6 -9
  55. package/dist/primitives/addForm/McpAddFormSubmit.d.ts.map +1 -1
  56. package/dist/primitives/addForm/McpAddFormSubmit.js +1 -1
  57. package/dist/primitives/addForm/McpAddFormUrlField.d.ts +5 -8
  58. package/dist/primitives/addForm/McpAddFormUrlField.d.ts.map +1 -1
  59. package/dist/primitives/addForm/McpAddFormUrlField.js +1 -1
  60. package/dist/primitives/addForm/context.d.ts +25 -28
  61. package/dist/primitives/addForm/context.d.ts.map +1 -1
  62. package/dist/primitives/addForm.d.ts +10 -13
  63. package/dist/primitives/addForm.d.ts.map +1 -1
  64. package/dist/primitives/addForm.js +5 -1
  65. package/dist/primitives/addForm.js.map +1 -1
  66. package/dist/primitives/elicitation/McpElicitationPrimitiveAccept.d.ts +6 -9
  67. package/dist/primitives/elicitation/McpElicitationPrimitiveAccept.d.ts.map +1 -1
  68. package/dist/primitives/elicitation/McpElicitationPrimitiveAccept.js +2 -2
  69. package/dist/primitives/elicitation/McpElicitationPrimitiveCancel.d.ts +6 -9
  70. package/dist/primitives/elicitation/McpElicitationPrimitiveCancel.d.ts.map +1 -1
  71. package/dist/primitives/elicitation/McpElicitationPrimitiveCancel.js +2 -2
  72. package/dist/primitives/elicitation/McpElicitationPrimitiveDecline.d.ts +6 -9
  73. package/dist/primitives/elicitation/McpElicitationPrimitiveDecline.d.ts.map +1 -1
  74. package/dist/primitives/elicitation/McpElicitationPrimitiveDecline.js +2 -2
  75. package/dist/primitives/elicitation/McpElicitationPrimitiveError.d.ts +6 -9
  76. package/dist/primitives/elicitation/McpElicitationPrimitiveError.d.ts.map +1 -1
  77. package/dist/primitives/elicitation/McpElicitationPrimitiveError.js +1 -1
  78. package/dist/primitives/elicitation/McpElicitationPrimitiveFields.d.ts +8 -10
  79. package/dist/primitives/elicitation/McpElicitationPrimitiveFields.d.ts.map +1 -1
  80. package/dist/primitives/elicitation/McpElicitationPrimitiveFields.js +1 -1
  81. package/dist/primitives/elicitation/McpElicitationPrimitiveItems.d.ts +8 -11
  82. package/dist/primitives/elicitation/McpElicitationPrimitiveItems.d.ts.map +1 -1
  83. package/dist/primitives/elicitation/McpElicitationPrimitiveItems.js +2 -2
  84. package/dist/primitives/elicitation/McpElicitationPrimitiveMessage.d.ts +6 -9
  85. package/dist/primitives/elicitation/McpElicitationPrimitiveMessage.d.ts.map +1 -1
  86. package/dist/primitives/elicitation/McpElicitationPrimitiveMessage.js +1 -1
  87. package/dist/primitives/elicitation/McpElicitationPrimitiveRoot.d.ts +6 -9
  88. package/dist/primitives/elicitation/McpElicitationPrimitiveRoot.d.ts.map +1 -1
  89. package/dist/primitives/elicitation/McpElicitationPrimitiveRoot.js +1 -1
  90. package/dist/primitives/elicitation/context.d.ts +15 -18
  91. package/dist/primitives/elicitation/context.d.ts.map +1 -1
  92. package/dist/primitives/elicitation/initialElicitationDraft.d.ts +1 -4
  93. package/dist/primitives/elicitation/initialElicitationDraft.d.ts.map +1 -1
  94. package/dist/primitives/elicitation/prepareElicitationContent.d.ts +4 -7
  95. package/dist/primitives/elicitation/prepareElicitationContent.d.ts.map +1 -1
  96. package/dist/primitives/elicitation/prepareElicitationContent.js +5 -1
  97. package/dist/primitives/elicitation/prepareElicitationContent.js.map +1 -1
  98. package/dist/primitives/elicitation.d.ts +8 -14
  99. package/dist/primitives/elicitation.d.ts.map +1 -1
  100. package/dist/primitives/manager/McpManagerAddCustomTrigger.d.ts +6 -9
  101. package/dist/primitives/manager/McpManagerAddCustomTrigger.d.ts.map +1 -1
  102. package/dist/primitives/manager/McpManagerAddCustomTrigger.js +1 -1
  103. package/dist/primitives/manager/McpManagerConnectors.d.ts +14 -17
  104. package/dist/primitives/manager/McpManagerConnectors.d.ts.map +1 -1
  105. package/dist/primitives/manager/McpManagerConnectors.js +1 -1
  106. package/dist/primitives/manager/McpManagerCustomServers.d.ts +14 -17
  107. package/dist/primitives/manager/McpManagerCustomServers.d.ts.map +1 -1
  108. package/dist/primitives/manager/McpManagerCustomServers.js +1 -1
  109. package/dist/primitives/manager/McpManagerRoot.d.ts +6 -9
  110. package/dist/primitives/manager/McpManagerRoot.d.ts.map +1 -1
  111. package/dist/primitives/manager/McpManagerRoot.js +2 -2
  112. package/dist/primitives/manager.d.ts +4 -9
  113. package/dist/primitives/manager.d.ts.map +1 -1
  114. package/dist/primitives/server/McpServerConnectButton.d.ts +6 -9
  115. package/dist/primitives/server/McpServerConnectButton.d.ts.map +1 -1
  116. package/dist/primitives/server/McpServerConnectButton.js +2 -2
  117. package/dist/primitives/server/McpServerDisconnectButton.d.ts +6 -9
  118. package/dist/primitives/server/McpServerDisconnectButton.d.ts.map +1 -1
  119. package/dist/primitives/server/McpServerDisconnectButton.js +2 -2
  120. package/dist/primitives/server/McpServerError.d.ts +6 -9
  121. package/dist/primitives/server/McpServerError.d.ts.map +1 -1
  122. package/dist/primitives/server/McpServerError.js +2 -2
  123. package/dist/primitives/server/McpServerIcon.d.ts +12 -15
  124. package/dist/primitives/server/McpServerIcon.d.ts.map +1 -1
  125. package/dist/primitives/server/McpServerIcon.js +2 -2
  126. package/dist/primitives/server/McpServerName.d.ts +6 -9
  127. package/dist/primitives/server/McpServerName.d.ts.map +1 -1
  128. package/dist/primitives/server/McpServerName.js +2 -2
  129. package/dist/primitives/server/McpServerOAuthLink.d.ts +11 -14
  130. package/dist/primitives/server/McpServerOAuthLink.d.ts.map +1 -1
  131. package/dist/primitives/server/McpServerOAuthLink.js +2 -2
  132. package/dist/primitives/server/McpServerRemoveButton.d.ts +6 -9
  133. package/dist/primitives/server/McpServerRemoveButton.d.ts.map +1 -1
  134. package/dist/primitives/server/McpServerRemoveButton.js +2 -2
  135. package/dist/primitives/server/McpServerRoot.d.ts +6 -9
  136. package/dist/primitives/server/McpServerRoot.d.ts.map +1 -1
  137. package/dist/primitives/server/McpServerRoot.js +2 -2
  138. package/dist/primitives/server/McpServerStatus.d.ts +6 -9
  139. package/dist/primitives/server/McpServerStatus.d.ts.map +1 -1
  140. package/dist/primitives/server/McpServerStatus.js +2 -2
  141. package/dist/primitives/server/McpServerToolName.d.ts +6 -9
  142. package/dist/primitives/server/McpServerToolName.d.ts.map +1 -1
  143. package/dist/primitives/server/McpServerToolName.js +1 -1
  144. package/dist/primitives/server/McpServerTools.d.ts +8 -11
  145. package/dist/primitives/server/McpServerTools.d.ts.map +1 -1
  146. package/dist/primitives/server/McpServerTools.js +2 -2
  147. package/dist/primitives/server.d.ts +11 -16
  148. package/dist/primitives/server.d.ts.map +1 -1
  149. package/dist/resources/McpManagerResource.d.ts +13 -16
  150. package/dist/resources/McpManagerResource.d.ts.map +1 -1
  151. package/dist/resources/McpManagerResource.js +95 -64
  152. package/dist/resources/McpManagerResource.js.map +1 -1
  153. package/dist/resources/McpServerRemovalFence.d.ts +3 -6
  154. package/dist/resources/McpServerRemovalFence.d.ts.map +1 -1
  155. package/dist/resources/McpServerResource.d.ts +21 -24
  156. package/dist/resources/McpServerResource.d.ts.map +1 -1
  157. package/dist/resources/McpServerResource.js +55 -47
  158. package/dist/resources/McpServerResource.js.map +1 -1
  159. package/dist/resources/storage/McpCustomStorage.d.ts +2 -5
  160. package/dist/resources/storage/McpCustomStorage.d.ts.map +1 -1
  161. package/dist/resources/storage/McpLocalStorage.d.ts +18 -21
  162. package/dist/resources/storage/McpLocalStorage.d.ts.map +1 -1
  163. package/dist/resources/storage/McpLocalStorage.js +1 -1
  164. package/dist/resources/storage/McpMemoryStorage.d.ts +2 -5
  165. package/dist/resources/storage/McpMemoryStorage.d.ts.map +1 -1
  166. package/dist/resources/storage/types.d.ts +24 -27
  167. package/dist/resources/storage/types.d.ts.map +1 -1
  168. package/dist/resources/validateElicitationContent.d.ts +4 -6
  169. package/dist/resources/validateElicitationContent.d.ts.map +1 -1
  170. package/dist/utils/createMcpId.d.ts +1 -4
  171. package/dist/utils/createMcpId.d.ts.map +1 -1
  172. package/dist/utils/invokeMcpCallback.d.ts +1 -4
  173. package/dist/utils/invokeMcpCallback.d.ts.map +1 -1
  174. package/dist/utils/serverId.d.ts +2 -5
  175. package/dist/utils/serverId.d.ts.map +1 -1
  176. package/dist/utils/serverUrl.d.ts +4 -7
  177. package/dist/utils/serverUrl.d.ts.map +1 -1
  178. package/package.json +11 -10
  179. package/src/hooks/useMcpOAuthCallback.test.ts +53 -0
  180. package/src/hooks/useMcpOAuthCallback.tsx +12 -0
  181. package/src/index.ts +2 -1
  182. package/src/primitives/addForm/McpAddFormAccessibility.test.tsx +49 -0
  183. package/src/primitives/addForm/McpAddFormAuthFields.tsx +7 -15
  184. package/src/primitives/addForm/McpAddFormBearerTokenField.tsx +46 -0
  185. package/src/primitives/addForm/McpAddFormRoot.test.tsx +65 -0
  186. package/src/primitives/addForm/McpAddFormScopesField.tsx +34 -0
  187. package/src/primitives/addForm.ts +2 -0
  188. package/src/primitives/elicitation/prepareElicitationContent.ts +8 -6
  189. package/src/resources/McpManagerResource.test.ts +85 -0
  190. package/src/resources/McpManagerResource.ts +56 -23
  191. package/src/resources/McpServerResource.test.ts +121 -6
  192. package/src/resources/McpServerResource.ts +28 -12
@@ -92,6 +92,51 @@ describe("McpManagerResource server ids", () => {
92
92
  expect(() => assertUniqueServerIds(["docs", "linear"])).not.toThrow();
93
93
  });
94
94
 
95
+ it("keeps the first persisted custom server when ids are duplicated", async () => {
96
+ const docsServer: MCPCustomServerRecord = {
97
+ id: "docs",
98
+ name: "Docs",
99
+ url: "https://example.com/docs/mcp",
100
+ auth: { type: "none" },
101
+ createdAt: 1,
102
+ };
103
+ const saveCustomServers = vi.fn(async () => {});
104
+ const consoleError = vi
105
+ .spyOn(console, "error")
106
+ .mockImplementation(() => {});
107
+ const root = mount(
108
+ [],
109
+ McpCustomStorage({
110
+ loadCustomServers: vi.fn(async () => [
111
+ docsServer,
112
+ { ...docsServer, name: "Duplicate Docs", createdAt: 2 },
113
+ ]),
114
+ saveCustomServers,
115
+ loadAuthState: vi.fn(async () => null),
116
+ saveAuthState: vi.fn(async () => {}),
117
+ clearAuthState: vi.fn(async () => {}),
118
+ }),
119
+ );
120
+
121
+ try {
122
+ await vi.waitFor(() =>
123
+ expect(root.getValue().getState().isHydrated).toBe(true),
124
+ );
125
+ expect(root.getValue().getState().customServers).toHaveLength(1);
126
+ expect(root.getValue().getState().customServers[0]).toMatchObject({
127
+ id: "docs",
128
+ name: "Docs",
129
+ });
130
+ expect(saveCustomServers).not.toHaveBeenCalled();
131
+ expect(consoleError).toHaveBeenCalledWith(
132
+ '[assistant-ui/react-mcp] ignored duplicate custom server id "docs" loaded from storage',
133
+ );
134
+ } finally {
135
+ consoleError.mockRestore();
136
+ root.unmount();
137
+ }
138
+ });
139
+
95
140
  it("passes connector cache configuration to its client", async () => {
96
141
  mocks.Client.mockClear();
97
142
  const root = mount([
@@ -374,6 +419,46 @@ describe("McpManagerResource storage failures", () => {
374
419
  });
375
420
 
376
421
  describe("McpManagerResource storage ordering", () => {
422
+ it("preserves a removal made before custom server hydration finishes", async () => {
423
+ const docsServer: MCPCustomServerRecord = {
424
+ id: "docs",
425
+ name: "Docs",
426
+ url: "https://example.com/docs/mcp",
427
+ auth: { type: "none" },
428
+ createdAt: 1,
429
+ };
430
+ let resolveLoad!: (records: MCPCustomServerRecord[]) => void;
431
+ const load = new Promise<MCPCustomServerRecord[]>((resolve) => {
432
+ resolveLoad = resolve;
433
+ });
434
+ const saveCustomServers = vi.fn(async () => {});
435
+ const root = mount(
436
+ [],
437
+ McpCustomStorage({
438
+ loadCustomServers: vi.fn(() => load),
439
+ saveCustomServers,
440
+ loadAuthState: vi.fn(async () => null),
441
+ saveAuthState: vi.fn(async () => {}),
442
+ clearAuthState: vi.fn(async () => {}),
443
+ }),
444
+ );
445
+
446
+ try {
447
+ await root.getValue().removeServer("docs");
448
+ resolveLoad([docsServer]);
449
+
450
+ await vi.waitFor(() =>
451
+ expect(root.getValue().getState().isHydrated).toBe(true),
452
+ );
453
+ expect(root.getValue().getState().customServers).toHaveLength(0);
454
+ await vi.waitFor(() =>
455
+ expect(saveCustomServers).toHaveBeenCalledWith([]),
456
+ );
457
+ } finally {
458
+ root.unmount();
459
+ }
460
+ });
461
+
377
462
  it("persists custom server updates in invocation order", async () => {
378
463
  let resolveFirstSave: (() => void) | undefined;
379
464
  const firstSave = new Promise<void>((resolve) => {
@@ -116,6 +116,20 @@ const holdCustomServerPersistence = (
116
116
  return release;
117
117
  };
118
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
+
119
133
  type McpCustomServersResourceProps = {
120
134
  storage: MCPStorage;
121
135
  scopeKey: string;
@@ -137,6 +151,7 @@ const useMcpCustomServersResource = ({
137
151
  "pending",
138
152
  );
139
153
  const hasPendingMutationRef = useRef(false);
154
+ const [removedBeforeHydration] = useState(() => new Set<string>());
140
155
  const reportedBlockedPersistenceRef = useRef(false);
141
156
 
142
157
  const hydrate = useEffectEvent(async (signal: { cancelled: boolean }) => {
@@ -151,7 +166,8 @@ const useMcpCustomServersResource = ({
151
166
 
152
167
  let records: Awaited<ReturnType<typeof storage.loadCustomServers>>;
153
168
  try {
154
- records = await storage.loadCustomServers();
169
+ const loadedRecords = await storage.loadCustomServers();
170
+ records = deduplicateCustomServers(loadedRecords);
155
171
  } catch (error) {
156
172
  if (!signal.cancelled) {
157
173
  reportCustomStorageFailure("load", error);
@@ -164,16 +180,21 @@ const useMcpCustomServersResource = ({
164
180
  }
165
181
  return;
166
182
  }
167
-
168
183
  // Merge rather than replace so any addCustomServer calls that
169
184
  // happened before hydration resolved aren't silently overwritten.
170
185
  // Persisted order wins; pre-hydration locals append.
171
186
  const hadPendingMutation = hasPendingMutationRef.current;
187
+ const hydratedRecords = records.filter(
188
+ (record) => !removedBeforeHydration.has(record.id),
189
+ );
172
190
  const mergedRecords = (() => {
173
191
  const prev = customServersRef.current;
174
- if (prev.length === 0) return records;
175
- const persistedIds = new Set(records.map((r) => r.id));
176
- return [...records, ...prev.filter((r) => !persistedIds.has(r.id))];
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
+ ];
177
198
  })();
178
199
  customServersRef.current = mergedRecords;
179
200
  hydrationStateRef.current = "succeeded";
@@ -232,7 +253,22 @@ const useMcpCustomServersResource = ({
232
253
  [persistenceQueues, scopeKey, storage],
233
254
  );
234
255
 
235
- return { customServers, isHydrated, updateCustomServers };
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
+ };
236
272
  };
237
273
 
238
274
  const McpCustomServersResource = resource(useMcpCustomServersResource);
@@ -252,16 +288,17 @@ const useMcpManagerResource = (
252
288
  );
253
289
  const storageScopeKey =
254
290
  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
- );
291
+ const { customServers, isHydrated, updateCustomServers, removeCustomServer } =
292
+ useResource(
293
+ withKey(
294
+ storageScopeKey,
295
+ McpCustomServersResource({
296
+ storage,
297
+ scopeKey: storageScopeKey,
298
+ persistenceQueues,
299
+ }),
300
+ ),
301
+ );
265
302
 
266
303
  const serverElements = useMemo(() => {
267
304
  assertUniqueServerIds([
@@ -313,9 +350,7 @@ const useMcpManagerResource = (
313
350
  ? { elicitation: s.elicitation }
314
351
  : {}),
315
352
  onRemove: async () => {
316
- updateCustomServers((prev) =>
317
- prev.filter((record) => record.id !== s.id),
318
- );
353
+ removeCustomServer(s.id);
319
354
  },
320
355
  },
321
356
  () =>
@@ -332,7 +367,7 @@ const useMcpManagerResource = (
332
367
  redirectUri,
333
368
  autoConnect,
334
369
  connectionTimeout,
335
- updateCustomServers,
370
+ removeCustomServer,
336
371
  persistenceQueues,
337
372
  storageScopeKey,
338
373
  ]);
@@ -450,9 +485,7 @@ const useMcpManagerResource = (
450
485
  );
451
486
  try {
452
487
  await clearOAuthProviderAuthState(storage, id);
453
- updateCustomServers((prev) =>
454
- prev.filter((record) => record.id !== id),
455
- );
488
+ removeCustomServer(id);
456
489
  } catch (error) {
457
490
  releasePersistence();
458
491
  throw error;
@@ -1,5 +1,6 @@
1
1
  import { createTapRoot, resource, useResource } from "@assistant-ui/tap";
2
2
  import type { ClientOutput } from "@assistant-ui/store";
3
+ import { UnauthorizedError } from "@modelcontextprotocol/client";
3
4
  import { useEffect, useState } from "react";
4
5
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
6
  import type { MCPAuthConfig } from "../mcp-scope";
@@ -113,8 +114,8 @@ const requestElicitation = (
113
114
  client: any,
114
115
  message: string,
115
116
  requestedSchema: unknown,
116
- context: { signal: AbortSignal } = {
117
- signal: new AbortController().signal,
117
+ context: { mcpReq: { signal: AbortSignal } } = {
118
+ mcpReq: { signal: new AbortController().signal },
118
119
  },
119
120
  ) => {
120
121
  const handler = client.requestHandlers.get("elicitation/create");
@@ -156,7 +157,6 @@ const mount = (
156
157
  cache?: { readonly defaultTtlMs?: number } | undefined;
157
158
  elicitation?: boolean | undefined;
158
159
  kind?: "connector" | "custom" | undefined;
159
- storage?: MCPStorage | undefined;
160
160
  onRemove?: (() => Promise<void>) | undefined;
161
161
  },
162
162
  onMount?: (server: ClientOutput<"mcpServer">) => void,
@@ -193,6 +193,15 @@ const mount = (
193
193
  const unboundAuthMessage =
194
194
  'MCP server "docs" has saved authentication for a different URL. Authenticate again to connect to https://example.com/mcp.';
195
195
 
196
+ const getOAuthProvider = (index: number) => {
197
+ const provider =
198
+ mocks.StreamableHTTPClientTransport.mock.calls[index]?.[1]?.authProvider;
199
+ if (!provider) throw new Error("Expected OAuth provider");
200
+ return provider as {
201
+ redirectToAuthorization: (url: URL) => Promise<void>;
202
+ };
203
+ };
204
+
196
205
  describe("McpServerResource automatic authentication", () => {
197
206
  beforeEach(resetMocks);
198
207
 
@@ -292,7 +301,9 @@ describe("McpServerResource automatic authentication", () => {
292
301
  });
293
302
 
294
303
  try {
295
- await waitFor(() => storage.loadAuthState.mock.calls.length > 0);
304
+ await waitFor(
305
+ () => vi.mocked(storage.loadAuthState).mock.calls.length > 0,
306
+ );
296
307
  await flushMacrotask();
297
308
 
298
309
  expect(root.getValue().getState()).toMatchObject({
@@ -338,7 +349,9 @@ describe("McpServerResource automatic authentication", () => {
338
349
  });
339
350
 
340
351
  try {
341
- await waitFor(() => storage.loadAuthState.mock.calls.length > 0);
352
+ await waitFor(
353
+ () => vi.mocked(storage.loadAuthState).mock.calls.length > 0,
354
+ );
342
355
  await flushMacrotask();
343
356
 
344
357
  expect(root.getValue().getState()).toMatchObject({
@@ -629,6 +642,70 @@ describe("McpServerResource connectionTimeout", () => {
629
642
  describe("McpServerResource connection lifecycle", () => {
630
643
  beforeEach(resetMocks);
631
644
 
645
+ it("publishes authorization URLs from the current connection", async () => {
646
+ const root = mount({ auth: { type: "oauth" } });
647
+
648
+ try {
649
+ await root.getValue().connect();
650
+ await getOAuthProvider(0).redirectToAuthorization(
651
+ new URL("https://auth.example.com/current"),
652
+ );
653
+ await waitForResourceUpdate(
654
+ () => root.getValue().getState().authorizationUrl !== null,
655
+ );
656
+
657
+ expect(root.getValue().getState().authorizationUrl).toBe(
658
+ "https://auth.example.com/current",
659
+ );
660
+ } finally {
661
+ root.unmount();
662
+ }
663
+ });
664
+
665
+ it("ignores authorization URLs after disconnect", async () => {
666
+ const root = mount({ auth: { type: "oauth" } });
667
+
668
+ try {
669
+ await root.getValue().connect();
670
+ const provider = getOAuthProvider(0);
671
+ await root.getValue().disconnect();
672
+
673
+ await provider.redirectToAuthorization(
674
+ new URL("https://auth.example.com/stale"),
675
+ );
676
+ await flushMacrotask();
677
+
678
+ expect(root.getValue().getState()).toMatchObject({
679
+ connectionState: "disconnected",
680
+ authorizationUrl: null,
681
+ });
682
+ } finally {
683
+ root.unmount();
684
+ }
685
+ });
686
+
687
+ it("ignores authorization URLs from a superseded connection", async () => {
688
+ const root = mount({ auth: { type: "oauth" } });
689
+
690
+ try {
691
+ await root.getValue().connect();
692
+ const staleProvider = getOAuthProvider(0);
693
+ await root.getValue().connect();
694
+
695
+ await staleProvider.redirectToAuthorization(
696
+ new URL("https://auth.example.com/stale"),
697
+ );
698
+ await flushMacrotask();
699
+
700
+ expect(root.getValue().getState()).toMatchObject({
701
+ connectionState: "connected",
702
+ authorizationUrl: null,
703
+ });
704
+ } finally {
705
+ root.unmount();
706
+ }
707
+ });
708
+
632
709
  it("replaces direct resource connections when the server id changes", async () => {
633
710
  const storage = createStorage();
634
711
  let updateId = (_id: string) => {};
@@ -744,6 +821,44 @@ describe("McpServerResource connection lifecycle", () => {
744
821
  describe("McpServerResource completeAuth", () => {
745
822
  beforeEach(resetMocks);
746
823
 
824
+ it("transfers authorization URL ownership when reusing the auth transport", async () => {
825
+ const storage = createStorage();
826
+ vi.mocked(storage.loadAuthState).mockResolvedValue({
827
+ serverUrl: "https://example.com/mcp",
828
+ state: "expected",
829
+ });
830
+ mocks.connectResults.push(() =>
831
+ Promise.reject(new UnauthorizedError("authorization required")),
832
+ );
833
+ const root = mount({ auth: { type: "oauth" }, storage });
834
+
835
+ try {
836
+ await root.getValue().connect();
837
+ await waitForResourceUpdate(
838
+ () => root.getValue().getState().connectionState === "authRequired",
839
+ );
840
+ const provider = getOAuthProvider(0);
841
+
842
+ await root
843
+ .getValue()
844
+ .completeAuth("https://example.com/callback?code=abc&state=expected");
845
+ await provider.redirectToAuthorization(
846
+ new URL("https://auth.example.com/reauthorize"),
847
+ );
848
+ await waitForResourceUpdate(
849
+ () => root.getValue().getState().authorizationUrl !== null,
850
+ );
851
+
852
+ expect(root.getValue().getState()).toMatchObject({
853
+ connectionState: "connected",
854
+ authorizationUrl: "https://auth.example.com/reauthorize",
855
+ });
856
+ expect(mocks.transports).toHaveLength(1);
857
+ } finally {
858
+ root.unmount();
859
+ }
860
+ });
861
+
747
862
  it("lets callback validation win over mount-time auto-connect", async () => {
748
863
  const pendingLoads: Array<(value: MCPPersistedAuthState | null) => void> =
749
864
  [];
@@ -1474,7 +1589,7 @@ describe("McpServerResource elicitation", () => {
1474
1589
  type: "object",
1475
1590
  properties: {},
1476
1591
  },
1477
- { signal: controller.signal },
1592
+ { mcpReq: { signal: controller.signal } },
1478
1593
  );
1479
1594
  await waitForResourceUpdate(
1480
1595
  () => root.getValue().getState().pendingElicitations.length === 1,
@@ -105,6 +105,9 @@ const useMcpServerResourceInstance = (
105
105
  const pendingTransportRef = useRef<StreamableHTTPClientTransport | null>(
106
106
  null,
107
107
  );
108
+ const transportGenerationRef = useRef(
109
+ new WeakMap<StreamableHTTPClientTransport, { current: number }>(),
110
+ );
108
111
  const connectionGenerationRef = useRef(0);
109
112
  const pendingAuthValidationRef = useRef<{
110
113
  count: number;
@@ -254,19 +257,29 @@ const useMcpServerResourceInstance = (
254
257
  });
255
258
 
256
259
  const buildTransport = useEffectEvent(
257
- async (): Promise<StreamableHTTPClientTransport> => {
260
+ async (generation: number): Promise<StreamableHTTPClientTransport> => {
258
261
  if (props.auth.type === "oauth") {
262
+ const generationOwner = { current: generation };
259
263
  const authProvider = createOAuthProvider({
260
264
  serverId: props.id,
261
265
  serverUrl: props.url,
262
266
  config: props.auth,
263
267
  storage: props.storage,
264
268
  redirectUri: props.redirectUri,
265
- onAuthorizationUrl: (url) => setAuthorizationUrl(url.toString()),
266
- });
267
- return new StreamableHTTPClientTransport(new URL(props.url), {
268
- authProvider,
269
+ onAuthorizationUrl: (url) => {
270
+ if (isCurrentConnection(generationOwner.current)) {
271
+ setAuthorizationUrl(url.toString());
272
+ }
273
+ },
269
274
  });
275
+ const transport = new StreamableHTTPClientTransport(
276
+ new URL(props.url),
277
+ {
278
+ authProvider,
279
+ },
280
+ );
281
+ transportGenerationRef.current.set(transport, generationOwner);
282
+ return transport;
270
283
  }
271
284
  if (props.auth.type === "bearer") {
272
285
  const { state, unbound } = await loadAuthState();
@@ -380,7 +393,7 @@ const useMcpServerResourceInstance = (
380
393
  };
381
394
  elicitationResolversRef.current.set(id, {
382
395
  resolve,
383
- signal: context.signal,
396
+ signal: context.mcpReq.signal,
384
397
  onAbort,
385
398
  requestedSchema,
386
399
  });
@@ -395,10 +408,10 @@ const useMcpServerResourceInstance = (
395
408
  ]);
396
409
  const entry = elicitationResolversRef.current.get(id);
397
410
  if (entry) {
398
- if (context.signal.aborted) {
411
+ if (context.mcpReq.signal.aborted) {
399
412
  entry.onAbort();
400
413
  } else {
401
- context.signal.addEventListener("abort", entry.onAbort, {
414
+ context.mcpReq.signal.addEventListener("abort", entry.onAbort, {
402
415
  once: true,
403
416
  });
404
417
  }
@@ -443,7 +456,7 @@ const useMcpServerResourceInstance = (
443
456
  setTools([]);
444
457
  let transport: StreamableHTTPClientTransport | null = null;
445
458
  try {
446
- transport = await buildTransport();
459
+ transport = await buildTransport(generation);
447
460
  if (!isCurrentConnection(generation)) {
448
461
  await closeQueuedTransports([transport]);
449
462
  return;
@@ -536,12 +549,14 @@ const useMcpServerResourceInstance = (
536
549
  try {
537
550
  let transport = transportRef.current;
538
551
  if (!transport) {
539
- transport = await buildTransport();
552
+ transport = await buildTransport(generation);
540
553
  if (!isCurrentConnection(generation)) {
541
554
  await closeQueuedTransports([transport]);
542
555
  throw createInterruptedAuthError();
543
556
  }
544
557
  }
558
+ const generationOwner = transportGenerationRef.current.get(transport);
559
+ if (generationOwner) generationOwner.current = generation;
545
560
  transportRef.current = null;
546
561
  clientRef.current = null;
547
562
  pendingTransportRef.current = transport;
@@ -712,7 +727,7 @@ const useMcpServerResourceInstance = (
712
727
  ): readonly { property: string; message: string }[] | undefined => {
713
728
  if (response.action === "accept") {
714
729
  const entry = elicitationResolversRef.current.get(id);
715
- if (!entry) return;
730
+ if (!entry) return undefined;
716
731
 
717
732
  if (
718
733
  typeof response.content !== "object" ||
@@ -752,10 +767,11 @@ const useMcpServerResourceInstance = (
752
767
  content: response.content as ElicitResult["content"],
753
768
  };
754
769
  resolvePendingElicitation(id, result);
755
- return;
770
+ return undefined;
756
771
  }
757
772
 
758
773
  resolvePendingElicitation(id, { action: response.action });
774
+ return undefined;
759
775
  },
760
776
  };
761
777
  };