@assistant-ui/react-mcp 0.1.20 → 0.1.22

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 (85) hide show
  1. package/LICENSE +1 -1
  2. package/dist/auth/createOAuthProvider.d.ts.map +1 -1
  3. package/dist/auth/createOAuthProvider.js +7 -1
  4. package/dist/auth/createOAuthProvider.js.map +1 -1
  5. package/dist/context/McpConnectorByIndexProvider.js.map +1 -1
  6. package/dist/context/McpCustomServerByIndexProvider.js.map +1 -1
  7. package/dist/context/McpServerByIdProvider.js.map +1 -1
  8. package/dist/hooks/useMcpOAuthCallback.js.map +1 -1
  9. package/dist/primitives/addForm/McpAddFormAuthFields.js.map +1 -1
  10. package/dist/primitives/addForm/McpAddFormAuthSelect.js.map +1 -1
  11. package/dist/primitives/addForm/McpAddFormBearerTokenField.js.map +1 -1
  12. package/dist/primitives/addForm/McpAddFormCancel.js.map +1 -1
  13. package/dist/primitives/addForm/McpAddFormError.js.map +1 -1
  14. package/dist/primitives/addForm/McpAddFormNameField.js.map +1 -1
  15. package/dist/primitives/addForm/McpAddFormRoot.d.ts.map +1 -1
  16. package/dist/primitives/addForm/McpAddFormRoot.js +14 -5
  17. package/dist/primitives/addForm/McpAddFormRoot.js.map +1 -1
  18. package/dist/primitives/addForm/McpAddFormScopesField.js.map +1 -1
  19. package/dist/primitives/addForm/McpAddFormSubmit.js.map +1 -1
  20. package/dist/primitives/addForm/McpAddFormUrlField.js.map +1 -1
  21. package/dist/primitives/elicitation/McpElicitationPrimitiveAccept.js.map +1 -1
  22. package/dist/primitives/elicitation/McpElicitationPrimitiveCancel.js.map +1 -1
  23. package/dist/primitives/elicitation/McpElicitationPrimitiveDecline.js.map +1 -1
  24. package/dist/primitives/elicitation/McpElicitationPrimitiveError.js.map +1 -1
  25. package/dist/primitives/elicitation/McpElicitationPrimitiveFields.js.map +1 -1
  26. package/dist/primitives/elicitation/McpElicitationPrimitiveItems.js.map +1 -1
  27. package/dist/primitives/elicitation/McpElicitationPrimitiveMessage.js.map +1 -1
  28. package/dist/primitives/elicitation/McpElicitationPrimitiveRoot.js.map +1 -1
  29. package/dist/primitives/manager/McpManagerAddCustomTrigger.js.map +1 -1
  30. package/dist/primitives/manager/McpManagerRoot.js.map +1 -1
  31. package/dist/primitives/server/McpServerConnectButton.js.map +1 -1
  32. package/dist/primitives/server/McpServerDisconnectButton.js.map +1 -1
  33. package/dist/primitives/server/McpServerError.js.map +1 -1
  34. package/dist/primitives/server/McpServerIcon.js.map +1 -1
  35. package/dist/primitives/server/McpServerName.js.map +1 -1
  36. package/dist/primitives/server/McpServerOAuthLink.js.map +1 -1
  37. package/dist/primitives/server/McpServerRemoveButton.js.map +1 -1
  38. package/dist/primitives/server/McpServerRoot.js.map +1 -1
  39. package/dist/primitives/server/McpServerStatus.js.map +1 -1
  40. package/dist/primitives/server/McpServerToolName.js.map +1 -1
  41. package/dist/primitives/server/McpServerTools.js.map +1 -1
  42. package/dist/resources/McpCustomServerPersistence.d.ts +17 -0
  43. package/dist/resources/McpCustomServerPersistence.d.ts.map +1 -0
  44. package/dist/resources/McpCustomServerPersistence.js +192 -0
  45. package/dist/resources/McpCustomServerPersistence.js.map +1 -0
  46. package/dist/resources/McpElicitationLifecycle.d.ts +12 -0
  47. package/dist/resources/McpElicitationLifecycle.d.ts.map +1 -0
  48. package/dist/resources/McpElicitationLifecycle.js +144 -0
  49. package/dist/resources/McpElicitationLifecycle.js.map +1 -0
  50. package/dist/resources/McpManagerResource.d.ts.map +1 -1
  51. package/dist/resources/McpManagerResource.js +28 -205
  52. package/dist/resources/McpManagerResource.js.map +1 -1
  53. package/dist/resources/McpServerResource.d.ts.map +1 -1
  54. package/dist/resources/McpServerResource.js +24 -101
  55. package/dist/resources/McpServerResource.js.map +1 -1
  56. package/dist/resources/storage/McpLocalStorage.d.ts +1 -4
  57. package/dist/resources/storage/McpLocalStorage.d.ts.map +1 -1
  58. package/dist/resources/storage/McpLocalStorage.js +1 -100
  59. package/dist/resources/storage/McpLocalStorage.js.map +1 -1
  60. package/dist/resources/storage/McpStoredDataNormalization.d.ts +5 -0
  61. package/dist/resources/storage/McpStoredDataNormalization.d.ts.map +1 -0
  62. package/dist/resources/storage/McpStoredDataNormalization.js +95 -0
  63. package/dist/resources/storage/McpStoredDataNormalization.js.map +1 -0
  64. package/dist/utils/serverUrl.d.ts +1 -0
  65. package/dist/utils/serverUrl.d.ts.map +1 -1
  66. package/dist/utils/serverUrl.js +15 -1
  67. package/dist/utils/serverUrl.js.map +1 -1
  68. package/package.json +15 -11
  69. package/src/auth/createOAuthProvider.test.ts +30 -1
  70. package/src/auth/createOAuthProvider.ts +12 -4
  71. package/src/primitives/addForm/McpAddFormRoot.test.tsx +66 -0
  72. package/src/primitives/addForm/McpAddFormRoot.tsx +11 -1
  73. package/src/resources/McpCustomServerPersistence.test.ts +56 -0
  74. package/src/resources/McpCustomServerPersistence.ts +233 -0
  75. package/src/resources/McpElicitationLifecycle.test.ts +83 -0
  76. package/src/resources/McpElicitationLifecycle.ts +156 -0
  77. package/src/resources/McpManagerResource.test.ts +36 -0
  78. package/src/resources/McpManagerResource.ts +25 -248
  79. package/src/resources/McpServerResource.test.ts +123 -10
  80. package/src/resources/McpServerResource.ts +29 -149
  81. package/src/resources/storage/McpLocalStorage.ts +9 -209
  82. package/src/resources/storage/McpStoredDataNormalization.test.ts +24 -0
  83. package/src/resources/storage/McpStoredDataNormalization.ts +201 -0
  84. package/src/utils/serverUrl.test.ts +30 -0
  85. package/src/utils/serverUrl.ts +26 -0
@@ -0,0 +1,233 @@
1
+ import {
2
+ useState,
3
+ useEffect,
4
+ useEffectEvent,
5
+ useRef,
6
+ useCallback,
7
+ } from "react";
8
+ import { resource } from "@assistant-ui/tap";
9
+ import type { MCPCustomServerRecord } from "../mcp-scope";
10
+ import type { MCPStorage } from "./storage/types";
11
+
12
+ const reportCustomStorageFailure = (
13
+ operation: "load" | "save",
14
+ error: unknown,
15
+ ) => {
16
+ console.error(
17
+ `[assistant-ui/react-mcp] failed to ${operation} custom servers:`,
18
+ error,
19
+ );
20
+ };
21
+
22
+ const reportBlockedCustomServerPersistence = () => {
23
+ console.error(
24
+ "[assistant-ui/react-mcp] custom server changes remain in memory because loading the persisted list failed; remount the manager to retry",
25
+ );
26
+ };
27
+
28
+ const persistCustomServers = async (
29
+ storage: MCPStorage,
30
+ records: MCPCustomServerRecord[],
31
+ ) => {
32
+ try {
33
+ await storage.saveCustomServers(records);
34
+ } catch (error) {
35
+ reportCustomStorageFailure("save", error);
36
+ }
37
+ };
38
+
39
+ export type CustomServerPersistenceQueues = Map<string, Promise<void>>;
40
+
41
+ const enqueueCustomServerTask = (
42
+ persistenceQueues: CustomServerPersistenceQueues,
43
+ scopeKey: string,
44
+ task: () => Promise<void>,
45
+ ) => {
46
+ const previous = persistenceQueues.get(scopeKey);
47
+ const next = (previous ?? Promise.resolve()).then(task);
48
+ persistenceQueues.set(scopeKey, next);
49
+ void next.then(() => {
50
+ if (persistenceQueues.get(scopeKey) === next) {
51
+ persistenceQueues.delete(scopeKey);
52
+ }
53
+ });
54
+ };
55
+
56
+ const enqueueCustomServerPersistence = (
57
+ persistenceQueues: CustomServerPersistenceQueues,
58
+ scopeKey: string,
59
+ storage: MCPStorage,
60
+ records: MCPCustomServerRecord[],
61
+ ) =>
62
+ enqueueCustomServerTask(persistenceQueues, scopeKey, () =>
63
+ persistCustomServers(storage, records),
64
+ );
65
+
66
+ export const holdCustomServerPersistence = (
67
+ persistenceQueues: CustomServerPersistenceQueues,
68
+ scopeKey: string,
69
+ ) => {
70
+ let release = () => {};
71
+ const gate = new Promise<void>((resolve) => {
72
+ release = resolve;
73
+ });
74
+ enqueueCustomServerTask(persistenceQueues, scopeKey, () => gate);
75
+ return release;
76
+ };
77
+
78
+ const deduplicateCustomServers = (records: MCPCustomServerRecord[]) => {
79
+ const seen = new Set<string>();
80
+ return records.filter((record) => {
81
+ if (seen.has(record.id)) {
82
+ console.error(
83
+ `[assistant-ui/react-mcp] ignored duplicate custom server id "${record.id}" loaded from storage`,
84
+ );
85
+ return false;
86
+ }
87
+ seen.add(record.id);
88
+ return true;
89
+ });
90
+ };
91
+
92
+ type McpCustomServersResourceProps = {
93
+ storage: MCPStorage;
94
+ scopeKey: string;
95
+ persistenceQueues: CustomServerPersistenceQueues;
96
+ };
97
+
98
+ const useMcpCustomServersResource = ({
99
+ storage,
100
+ scopeKey,
101
+ persistenceQueues,
102
+ }: McpCustomServersResourceProps) => {
103
+ const [customServers, setCustomServers] = useState<MCPCustomServerRecord[]>(
104
+ [],
105
+ );
106
+ const [isHydrated, setIsHydrated] = useState(false);
107
+
108
+ const customServersRef = useRef<MCPCustomServerRecord[]>([]);
109
+ const hydrationStateRef = useRef<"pending" | "succeeded" | "failed">(
110
+ "pending",
111
+ );
112
+ const hasPendingMutationRef = useRef(false);
113
+ const [removedBeforeHydration] = useState(() => new Set<string>());
114
+ const reportedBlockedPersistenceRef = useRef(false);
115
+
116
+ const hydrate = useEffectEvent(async (signal: { cancelled: boolean }) => {
117
+ // A revisited scope must not read behind writes still queued against it.
118
+ while (true) {
119
+ const pendingPersistence = persistenceQueues.get(scopeKey);
120
+ if (!pendingPersistence) break;
121
+ await pendingPersistence;
122
+ if (signal.cancelled) return;
123
+ if (persistenceQueues.get(scopeKey) === pendingPersistence) break;
124
+ }
125
+
126
+ let records: Awaited<ReturnType<typeof storage.loadCustomServers>>;
127
+ try {
128
+ const loadedRecords = await storage.loadCustomServers();
129
+ records = deduplicateCustomServers(loadedRecords);
130
+ } catch (error) {
131
+ if (!signal.cancelled) {
132
+ reportCustomStorageFailure("load", error);
133
+ hydrationStateRef.current = "failed";
134
+ if (hasPendingMutationRef.current) {
135
+ reportBlockedCustomServerPersistence();
136
+ reportedBlockedPersistenceRef.current = true;
137
+ }
138
+ setIsHydrated(true);
139
+ }
140
+ return;
141
+ }
142
+ // Merge rather than replace so any addCustomServer calls that
143
+ // happened before hydration resolved aren't silently overwritten.
144
+ // Persisted order wins; pre-hydration locals append.
145
+ const hadPendingMutation = hasPendingMutationRef.current;
146
+ const hydratedRecords = records.filter(
147
+ (record) => !removedBeforeHydration.has(record.id),
148
+ );
149
+ const mergedRecords = (() => {
150
+ const prev = customServersRef.current;
151
+ if (prev.length === 0) return hydratedRecords;
152
+ const persistedIds = new Set(hydratedRecords.map((r) => r.id));
153
+ return [
154
+ ...hydratedRecords,
155
+ ...prev.filter((r) => !persistedIds.has(r.id)),
156
+ ];
157
+ })();
158
+ customServersRef.current = mergedRecords;
159
+ hydrationStateRef.current = "succeeded";
160
+ hasPendingMutationRef.current = false;
161
+ if (hadPendingMutation) {
162
+ enqueueCustomServerPersistence(
163
+ persistenceQueues,
164
+ scopeKey,
165
+ storage,
166
+ mergedRecords,
167
+ );
168
+ }
169
+ if (signal.cancelled) return;
170
+ setCustomServers(mergedRecords);
171
+ setIsHydrated(true);
172
+ });
173
+
174
+ useEffect(() => {
175
+ const signal = { cancelled: false };
176
+ // Hydration reads persisted records asynchronously; there is no earlier
177
+ // point than mount at which to start it.
178
+ // eslint-disable-next-line react-hooks/set-state-in-effect
179
+ void hydrate(signal);
180
+ return () => {
181
+ signal.cancelled = true;
182
+ };
183
+ }, []);
184
+
185
+ const updateCustomServers = useCallback(
186
+ (
187
+ updater: (records: MCPCustomServerRecord[]) => MCPCustomServerRecord[],
188
+ ) => {
189
+ const next = updater(customServersRef.current);
190
+ customServersRef.current = next;
191
+ setCustomServers(next);
192
+
193
+ if (hydrationStateRef.current === "succeeded") {
194
+ enqueueCustomServerPersistence(
195
+ persistenceQueues,
196
+ scopeKey,
197
+ storage,
198
+ next,
199
+ );
200
+ return;
201
+ }
202
+
203
+ hasPendingMutationRef.current = true;
204
+ if (
205
+ hydrationStateRef.current === "failed" &&
206
+ !reportedBlockedPersistenceRef.current
207
+ ) {
208
+ reportBlockedCustomServerPersistence();
209
+ reportedBlockedPersistenceRef.current = true;
210
+ }
211
+ },
212
+ [persistenceQueues, scopeKey, storage],
213
+ );
214
+
215
+ const removeCustomServer = useCallback(
216
+ (id: string) => {
217
+ if (hydrationStateRef.current === "pending") {
218
+ removedBeforeHydration.add(id);
219
+ }
220
+ updateCustomServers((prev) => prev.filter((record) => record.id !== id));
221
+ },
222
+ [removedBeforeHydration, updateCustomServers],
223
+ );
224
+
225
+ return {
226
+ customServers,
227
+ isHydrated,
228
+ updateCustomServers,
229
+ removeCustomServer,
230
+ };
231
+ };
232
+
233
+ export const McpCustomServersResource = resource(useMcpCustomServersResource);
@@ -0,0 +1,83 @@
1
+ import { createTapRoot } from "@assistant-ui/tap";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { useMcpElicitationLifecycle } from "./McpElicitationLifecycle";
4
+
5
+ describe("useMcpElicitationLifecycle", () => {
6
+ it("validates accepted content before resolving the request", async () => {
7
+ const root = createTapRoot(function Root() {
8
+ return useMcpElicitationLifecycle();
9
+ });
10
+ const requestedSchema = {
11
+ type: "object" as const,
12
+ properties: { answer: { type: "string" as const } },
13
+ };
14
+
15
+ try {
16
+ const response = root.getValue().requestElicitation(
17
+ {
18
+ method: "elicitation/create",
19
+ params: { message: "Answer", requestedSchema },
20
+ },
21
+ new AbortController().signal,
22
+ () => true,
23
+ );
24
+ await vi.waitFor(() =>
25
+ expect(root.getValue().pendingElicitations).toHaveLength(1),
26
+ );
27
+ const id = root.getValue().pendingElicitations[0]!.id;
28
+
29
+ expect(
30
+ root.getValue().answerElicitation(id, {
31
+ action: "accept",
32
+ content: { answer: 42 },
33
+ }),
34
+ ).toEqual([{ property: "answer", message: "Expected a string." }]);
35
+ await vi.waitFor(() =>
36
+ expect(
37
+ root.getValue().pendingElicitations[0]?.error?.properties,
38
+ ).toEqual(["answer"]),
39
+ );
40
+
41
+ expect(
42
+ root.getValue().answerElicitation(id, {
43
+ action: "accept",
44
+ content: { answer: "yes" },
45
+ }),
46
+ ).toBeUndefined();
47
+ await expect(response).resolves.toEqual({
48
+ action: "accept",
49
+ content: { answer: "yes" },
50
+ });
51
+ } finally {
52
+ root.unmount();
53
+ }
54
+ });
55
+
56
+ it("cancels a request when its signal aborts", async () => {
57
+ const root = createTapRoot(function Root() {
58
+ return useMcpElicitationLifecycle();
59
+ });
60
+ const controller = new AbortController();
61
+
62
+ try {
63
+ const response = root.getValue().requestElicitation(
64
+ {
65
+ method: "elicitation/create",
66
+ params: {
67
+ message: "Answer",
68
+ requestedSchema: { type: "object", properties: {} },
69
+ },
70
+ },
71
+ controller.signal,
72
+ () => true,
73
+ );
74
+ controller.abort();
75
+ await expect(response).resolves.toEqual({ action: "cancel" });
76
+ await vi.waitFor(() =>
77
+ expect(root.getValue().pendingElicitations).toHaveLength(0),
78
+ );
79
+ } finally {
80
+ root.unmount();
81
+ }
82
+ });
83
+ });
@@ -0,0 +1,156 @@
1
+ import { useRef, useState } from "react";
2
+ import type { ElicitRequest, ElicitResult } from "@modelcontextprotocol/client";
3
+ import type { MCPElicitation, MCPElicitationResponse } from "../mcp-scope";
4
+ import { createMcpId } from "../utils/createMcpId";
5
+ import { validateElicitationContent } from "./validateElicitationContent";
6
+
7
+ export const useMcpElicitationLifecycle = () => {
8
+ const [pendingElicitations, setPendingElicitations] = useState<
9
+ MCPElicitation[]
10
+ >([]);
11
+ const elicitationResolversRef = useRef(
12
+ new Map<
13
+ string,
14
+ {
15
+ resolve: (result: ElicitResult) => void;
16
+ signal: AbortSignal;
17
+ onAbort: () => void;
18
+ requestedSchema: unknown;
19
+ }
20
+ >(),
21
+ );
22
+ const resolvePendingElicitation = (id: string, result: ElicitResult) => {
23
+ const entry = elicitationResolversRef.current.get(id);
24
+ if (!entry) return false;
25
+ elicitationResolversRef.current.delete(id);
26
+ entry.signal.removeEventListener("abort", entry.onAbort);
27
+ setPendingElicitations((current) =>
28
+ current.filter((elicitation) => elicitation.id !== id),
29
+ );
30
+ entry.resolve(result);
31
+ return true;
32
+ };
33
+
34
+ const setPendingElicitationError = (
35
+ id: string,
36
+ error: NonNullable<MCPElicitation["error"]>,
37
+ ) => {
38
+ if (!elicitationResolversRef.current.has(id)) return false;
39
+ setPendingElicitations((current) =>
40
+ current.map((elicitation) =>
41
+ elicitation.id === id ? { ...elicitation, error } : elicitation,
42
+ ),
43
+ );
44
+ return true;
45
+ };
46
+
47
+ const cancelPendingElicitations = () => {
48
+ for (const [id] of elicitationResolversRef.current) {
49
+ resolvePendingElicitation(id, { action: "cancel" });
50
+ }
51
+ };
52
+
53
+ const requestElicitation = (
54
+ request: ElicitRequest,
55
+ signal: AbortSignal,
56
+ isCurrentConnection: () => boolean,
57
+ ): Promise<ElicitResult> => {
58
+ if (!isCurrentConnection()) {
59
+ return Promise.resolve({ action: "cancel" });
60
+ }
61
+ if (!("requestedSchema" in request.params)) {
62
+ return Promise.resolve({ action: "cancel" });
63
+ }
64
+ const { message, requestedSchema } = request.params;
65
+
66
+ const id = createMcpId();
67
+ const promise = new Promise<ElicitResult>((resolve) => {
68
+ const onAbort = () => {
69
+ resolvePendingElicitation(id, { action: "cancel" });
70
+ };
71
+ elicitationResolversRef.current.set(id, {
72
+ resolve,
73
+ signal,
74
+ onAbort,
75
+ requestedSchema,
76
+ });
77
+ });
78
+ setPendingElicitations((current) => [
79
+ ...current,
80
+ {
81
+ id,
82
+ message,
83
+ requestedSchema,
84
+ },
85
+ ]);
86
+ const entry = elicitationResolversRef.current.get(id);
87
+ if (entry) {
88
+ if (signal.aborted) {
89
+ entry.onAbort();
90
+ } else {
91
+ signal.addEventListener("abort", entry.onAbort, {
92
+ once: true,
93
+ });
94
+ }
95
+ }
96
+ return promise;
97
+ };
98
+
99
+ return {
100
+ pendingElicitations,
101
+ cancelPendingElicitations,
102
+ requestElicitation,
103
+ answerElicitation: (
104
+ id: string,
105
+ response: MCPElicitationResponse,
106
+ ): readonly { property: string; message: string }[] | undefined => {
107
+ if (response.action === "accept") {
108
+ const entry = elicitationResolversRef.current.get(id);
109
+ if (!entry) return undefined;
110
+
111
+ if (
112
+ typeof response.content !== "object" ||
113
+ response.content === null ||
114
+ Array.isArray(response.content)
115
+ ) {
116
+ const errors = [
117
+ {
118
+ property: "content",
119
+ message: "Response content must be an object.",
120
+ },
121
+ ];
122
+ setPendingElicitationError(id, {
123
+ message: "Invalid elicitation content: content.",
124
+ properties: ["content"],
125
+ });
126
+ return errors;
127
+ }
128
+
129
+ const errors = validateElicitationContent(
130
+ entry.requestedSchema,
131
+ response.content,
132
+ );
133
+ if (errors.length > 0) {
134
+ const properties = [
135
+ ...new Set(errors.map((error) => error.property)),
136
+ ];
137
+ setPendingElicitationError(id, {
138
+ message: `Invalid elicitation content: ${properties.join(", ")}.`,
139
+ properties,
140
+ });
141
+ return errors;
142
+ }
143
+
144
+ const result: ElicitResult = {
145
+ action: "accept",
146
+ content: response.content as ElicitResult["content"],
147
+ };
148
+ resolvePendingElicitation(id, result);
149
+ return undefined;
150
+ }
151
+
152
+ resolvePendingElicitation(id, { action: response.action });
153
+ return undefined;
154
+ },
155
+ };
156
+ };
@@ -416,6 +416,42 @@ describe("McpManagerResource storage failures", () => {
416
416
  consoleError.mockRestore();
417
417
  }
418
418
  });
419
+
420
+ it("preserves a custom server when its removal fails", async () => {
421
+ const docsServer: MCPCustomServerRecord = {
422
+ id: "docs",
423
+ name: "Docs",
424
+ url: "https://example.com/docs/mcp",
425
+ auth: { type: "none" },
426
+ createdAt: 1,
427
+ };
428
+ const error = new Error("storage unavailable");
429
+ const clearAuthState = vi.fn(async () => {});
430
+ clearAuthState.mockRejectedValueOnce(error);
431
+ const root = mount(
432
+ [],
433
+ McpCustomStorage({
434
+ loadCustomServers: vi.fn(async () => [docsServer]),
435
+ saveCustomServers: vi.fn(async () => {}),
436
+ loadAuthState: vi.fn(async () => null),
437
+ saveAuthState: vi.fn(async () => {}),
438
+ clearAuthState,
439
+ }),
440
+ );
441
+
442
+ try {
443
+ await vi.waitFor(() =>
444
+ expect(root.getValue().getState().customServers[0]?.id).toBe("docs"),
445
+ );
446
+
447
+ await expect(root.getValue().removeServer("docs")).rejects.toBe(error);
448
+ expect(root.getValue().getState().customServers).toMatchObject([
449
+ { id: "docs", name: "Docs" },
450
+ ]);
451
+ } finally {
452
+ root.unmount();
453
+ }
454
+ });
419
455
  });
420
456
 
421
457
  describe("McpManagerResource storage ordering", () => {