@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
@@ -2,7 +2,7 @@ import { createTapRoot, resource, useResource } from "@assistant-ui/tap";
2
2
  import { describe, expect, it, vi } from "vitest";
3
3
  import { useState } from "react";
4
4
  import { defineConnector } from "../connector";
5
- import type { MCPConnector } from "../mcp-scope";
5
+ import type { MCPConnector, MCPCustomServerRecord } from "../mcp-scope";
6
6
  import { assertUniqueServerIds } from "../utils/serverId";
7
7
  import { McpManagerResource } from "./McpManagerResource";
8
8
  import { McpCustomStorage } from "./storage/McpCustomStorage";
@@ -278,18 +278,19 @@ describe("McpManagerResource server ids", () => {
278
278
  });
279
279
 
280
280
  describe("McpManagerResource storage failures", () => {
281
- it("handles custom server load failures", async () => {
281
+ it("keeps persistence fenced after custom server load failures", async () => {
282
282
  const error = new Error("load failed");
283
283
  const consoleError = vi
284
284
  .spyOn(console, "error")
285
285
  .mockImplementation(() => {});
286
+ const saveCustomServers = vi.fn(async () => {});
286
287
  const root = mount(
287
288
  [],
288
289
  McpCustomStorage({
289
290
  loadCustomServers: vi.fn(async () => {
290
291
  throw error;
291
292
  }),
292
- saveCustomServers: vi.fn(async () => {}),
293
+ saveCustomServers,
293
294
  loadAuthState: vi.fn(async () => null),
294
295
  saveAuthState: vi.fn(async () => {}),
295
296
  clearAuthState: vi.fn(async () => {}),
@@ -305,6 +306,20 @@ describe("McpManagerResource storage failures", () => {
305
306
  "[assistant-ui/react-mcp] failed to load custom servers:",
306
307
  error,
307
308
  );
309
+
310
+ await root.getValue().addCustomServer({
311
+ name: "Docs",
312
+ url: "https://example.com/docs/mcp",
313
+ auth: { type: "none" },
314
+ });
315
+ await new Promise((resolve) => setTimeout(resolve, 0));
316
+
317
+ expect(root.getValue().getState().customServers).toHaveLength(1);
318
+ expect(saveCustomServers).not.toHaveBeenCalled();
319
+ expect(consoleError).toHaveBeenCalledWith(
320
+ "[assistant-ui/react-mcp] custom server changes remain in memory because loading the persisted list failed; remount the manager to retry",
321
+ );
322
+ expect(consoleError).toHaveBeenCalledTimes(2);
308
323
  } finally {
309
324
  root.unmount();
310
325
  consoleError.mockRestore();
@@ -332,8 +347,7 @@ describe("McpManagerResource storage failures", () => {
332
347
  await vi.waitFor(() =>
333
348
  expect(root.getValue().getState().isHydrated).toBe(true),
334
349
  );
335
- await vi.waitFor(() => expect(saveCustomServers).toHaveBeenCalled());
336
- saveCustomServers.mockClear();
350
+ expect(saveCustomServers).not.toHaveBeenCalled();
337
351
  saveCustomServers.mockRejectedValue(error);
338
352
 
339
353
  await root.getValue().addCustomServer({
@@ -389,8 +403,7 @@ describe("McpManagerResource storage ordering", () => {
389
403
  await vi.waitFor(() =>
390
404
  expect(root.getValue().getState().isHydrated).toBe(true),
391
405
  );
392
- await vi.waitFor(() => expect(saveCustomServers).toHaveBeenCalled());
393
- saveCustomServers.mockClear();
406
+ expect(saveCustomServers).not.toHaveBeenCalled();
394
407
  persistedSnapshots.length = 0;
395
408
  blockNextSave = true;
396
409
 
@@ -452,8 +465,7 @@ describe("McpManagerResource storage ordering", () => {
452
465
  await vi.waitFor(() =>
453
466
  expect(root.getValue().getState().isHydrated).toBe(true),
454
467
  );
455
- await vi.waitFor(() => expect(saveCustomServers).toHaveBeenCalled());
456
- saveCustomServers.mockClear();
468
+ expect(saveCustomServers).not.toHaveBeenCalled();
457
469
 
458
470
  rerender();
459
471
 
@@ -464,3 +476,694 @@ describe("McpManagerResource storage ordering", () => {
464
476
  }
465
477
  });
466
478
  });
479
+
480
+ describe("McpManagerResource storage switching", () => {
481
+ const docsServer = {
482
+ id: "docs",
483
+ name: "Docs",
484
+ url: "https://example.com/docs/mcp",
485
+ auth: { type: "none" as const },
486
+ createdAt: 1,
487
+ };
488
+
489
+ it("rehydrates custom servers without copying records from the previous scope", async () => {
490
+ let resolveStorageB:
491
+ | ((records: MCPCustomServerRecord[]) => void)
492
+ | undefined;
493
+ const storageBLoad = new Promise<MCPCustomServerRecord[]>((resolve) => {
494
+ resolveStorageB = resolve;
495
+ });
496
+ const saveStorageB = vi.fn(async (_records: MCPCustomServerRecord[]) => {});
497
+ const storageA = {
498
+ scopeId: "account:a",
499
+ loadCustomServers: vi.fn(async () => [docsServer]),
500
+ saveCustomServers: vi.fn(async () => {}),
501
+ loadAuthState: vi.fn(async () => null),
502
+ saveAuthState: vi.fn(async () => {}),
503
+ clearAuthState: vi.fn(async () => {}),
504
+ };
505
+ const storageB = {
506
+ scopeId: "account:b",
507
+ loadCustomServers: vi.fn(() => storageBLoad),
508
+ saveCustomServers: saveStorageB,
509
+ loadAuthState: vi.fn(async () => null),
510
+ saveAuthState: vi.fn(async () => {}),
511
+ clearAuthState: vi.fn(async () => {}),
512
+ };
513
+ let switchStorage = () => {};
514
+ const DynamicManager = resource(function useDynamicManager() {
515
+ const [storageKey, setStorageKey] = useState<"a" | "b">("a");
516
+ switchStorage = () => setStorageKey("b");
517
+ return useResource(
518
+ McpManagerResource({
519
+ storage: McpCustomStorage(storageKey === "a" ? storageA : storageB),
520
+ autoConnect: false,
521
+ }),
522
+ );
523
+ });
524
+ const root = createTapRoot(function Root() {
525
+ return useResource(DynamicManager());
526
+ });
527
+
528
+ try {
529
+ await vi.waitFor(() =>
530
+ expect(root.getValue().getState().customServers[0]?.id).toBe("docs"),
531
+ );
532
+ expect(storageA.saveCustomServers).not.toHaveBeenCalled();
533
+
534
+ switchStorage();
535
+
536
+ await vi.waitFor(() =>
537
+ expect(storageB.loadCustomServers).toHaveBeenCalled(),
538
+ );
539
+ expect(root.getValue().getState()).toMatchObject({
540
+ isHydrated: false,
541
+ customServers: [],
542
+ });
543
+
544
+ resolveStorageB?.([]);
545
+ await vi.waitFor(() => {
546
+ expect(root.getValue().getState().isHydrated).toBe(true);
547
+ expect(root.getValue().getState().customServers).toHaveLength(0);
548
+ });
549
+ expect(saveStorageB).not.toHaveBeenCalled();
550
+
551
+ await root.getValue().addCustomServer({
552
+ name: "Linear",
553
+ url: "https://example.com/linear/mcp",
554
+ auth: { type: "none" },
555
+ });
556
+
557
+ await vi.waitFor(() => expect(saveStorageB).toHaveBeenCalledOnce());
558
+ expect(saveStorageB).toHaveBeenCalledWith([
559
+ expect.objectContaining({ name: "Linear" }),
560
+ ]);
561
+ } finally {
562
+ resolveStorageB?.([]);
563
+ root.unmount();
564
+ }
565
+ });
566
+
567
+ it("keeps custom servers when the storage object retains its scope", async () => {
568
+ const storageA = {
569
+ scopeId: "account:a",
570
+ loadCustomServers: vi.fn(async () => [docsServer]),
571
+ saveCustomServers: vi.fn(async () => {}),
572
+ loadAuthState: vi.fn(async () => null),
573
+ saveAuthState: vi.fn(async () => {}),
574
+ clearAuthState: vi.fn(async () => {}),
575
+ };
576
+ const storageAReplacement = {
577
+ scopeId: "account:a",
578
+ loadCustomServers: vi.fn(async () => []),
579
+ saveCustomServers: vi.fn(async () => {}),
580
+ loadAuthState: vi.fn(async () => null),
581
+ saveAuthState: vi.fn(async () => {}),
582
+ clearAuthState: vi.fn(async () => {}),
583
+ };
584
+ let replaceStorage = () => {};
585
+ const DynamicManager = resource(function useDynamicManager() {
586
+ const [replacement, setReplacement] = useState(false);
587
+ replaceStorage = () => setReplacement(true);
588
+ return useResource(
589
+ McpManagerResource({
590
+ storage: McpCustomStorage(
591
+ replacement ? storageAReplacement : storageA,
592
+ ),
593
+ autoConnect: false,
594
+ }),
595
+ );
596
+ });
597
+ const root = createTapRoot(function Root() {
598
+ return useResource(DynamicManager());
599
+ });
600
+
601
+ try {
602
+ await vi.waitFor(() =>
603
+ expect(root.getValue().getState().customServers[0]?.id).toBe("docs"),
604
+ );
605
+
606
+ replaceStorage();
607
+ await new Promise((resolve) => setTimeout(resolve, 0));
608
+
609
+ expect(storageAReplacement.loadCustomServers).not.toHaveBeenCalled();
610
+ expect(root.getValue().getState().customServers[0]?.id).toBe("docs");
611
+ } finally {
612
+ root.unmount();
613
+ }
614
+ });
615
+
616
+ it("keeps persistence independent across storage scopes", async () => {
617
+ let resolveStorageASave: (() => void) | undefined;
618
+ const pendingStorageASave = new Promise<void>((resolve) => {
619
+ resolveStorageASave = resolve;
620
+ });
621
+ let blockStorageASave = false;
622
+ const storageA = {
623
+ scopeId: "account:a",
624
+ loadCustomServers: vi.fn(async () => []),
625
+ saveCustomServers: vi.fn(async () => {
626
+ if (blockStorageASave) await pendingStorageASave;
627
+ }),
628
+ loadAuthState: vi.fn(async () => null),
629
+ saveAuthState: vi.fn(async () => {}),
630
+ clearAuthState: vi.fn(async () => {}),
631
+ };
632
+ const storageB = {
633
+ scopeId: "account:b",
634
+ loadCustomServers: vi.fn(async () => []),
635
+ saveCustomServers: vi.fn(async () => {}),
636
+ loadAuthState: vi.fn(async () => null),
637
+ saveAuthState: vi.fn(async () => {}),
638
+ clearAuthState: vi.fn(async () => {}),
639
+ };
640
+ let switchStorage = () => {};
641
+ const DynamicManager = resource(function useDynamicManager() {
642
+ const [storageKey, setStorageKey] = useState<"a" | "b">("a");
643
+ switchStorage = () => setStorageKey("b");
644
+ return useResource(
645
+ McpManagerResource({
646
+ storage: McpCustomStorage(storageKey === "a" ? storageA : storageB),
647
+ autoConnect: false,
648
+ }),
649
+ );
650
+ });
651
+ const root = createTapRoot(function Root() {
652
+ return useResource(DynamicManager());
653
+ });
654
+
655
+ try {
656
+ await vi.waitFor(() =>
657
+ expect(root.getValue().getState().isHydrated).toBe(true),
658
+ );
659
+ expect(storageA.saveCustomServers).not.toHaveBeenCalled();
660
+ blockStorageASave = true;
661
+
662
+ await root.getValue().addCustomServer({
663
+ name: "Docs",
664
+ url: "https://example.com/docs/mcp",
665
+ auth: { type: "none" },
666
+ });
667
+ await vi.waitFor(() =>
668
+ expect(storageA.saveCustomServers).toHaveBeenCalledOnce(),
669
+ );
670
+
671
+ switchStorage();
672
+
673
+ await vi.waitFor(() =>
674
+ expect(storageB.loadCustomServers).toHaveBeenCalled(),
675
+ );
676
+ await vi.waitFor(() =>
677
+ expect(root.getValue().getState().isHydrated).toBe(true),
678
+ );
679
+ await root.getValue().addCustomServer({
680
+ name: "Linear",
681
+ url: "https://example.com/linear/mcp",
682
+ auth: { type: "none" },
683
+ });
684
+
685
+ await vi.waitFor(() =>
686
+ expect(storageB.saveCustomServers).toHaveBeenCalledWith([
687
+ expect.objectContaining({ name: "Linear" }),
688
+ ]),
689
+ );
690
+ expect(storageA.saveCustomServers).toHaveBeenCalledOnce();
691
+ } finally {
692
+ resolveStorageASave?.();
693
+ root.unmount();
694
+ }
695
+ });
696
+
697
+ it("queues a hydrated mutation before an immediate scope switch", async () => {
698
+ const storageA = {
699
+ scopeId: "account:a",
700
+ loadCustomServers: vi.fn(async () => []),
701
+ saveCustomServers: vi.fn(async () => {}),
702
+ loadAuthState: vi.fn(async () => null),
703
+ saveAuthState: vi.fn(async () => {}),
704
+ clearAuthState: vi.fn(async () => {}),
705
+ };
706
+ const storageB = {
707
+ scopeId: "account:b",
708
+ loadCustomServers: vi.fn(async () => []),
709
+ saveCustomServers: vi.fn(async () => {}),
710
+ loadAuthState: vi.fn(async () => null),
711
+ saveAuthState: vi.fn(async () => {}),
712
+ clearAuthState: vi.fn(async () => {}),
713
+ };
714
+ let switchStorage = () => {};
715
+ const DynamicManager = resource(function useDynamicManager() {
716
+ const [storageKey, setStorageKey] = useState<"a" | "b">("a");
717
+ switchStorage = () => setStorageKey("b");
718
+ return useResource(
719
+ McpManagerResource({
720
+ storage: McpCustomStorage(storageKey === "a" ? storageA : storageB),
721
+ autoConnect: false,
722
+ }),
723
+ );
724
+ });
725
+ const root = createTapRoot(function Root() {
726
+ return useResource(DynamicManager());
727
+ });
728
+
729
+ try {
730
+ await vi.waitFor(() =>
731
+ expect(root.getValue().getState().isHydrated).toBe(true),
732
+ );
733
+
734
+ await root.getValue().addCustomServer({
735
+ name: "Docs",
736
+ url: "https://example.com/docs/mcp",
737
+ auth: { type: "none" },
738
+ });
739
+ switchStorage();
740
+
741
+ await vi.waitFor(() =>
742
+ expect(storageB.loadCustomServers).toHaveBeenCalled(),
743
+ );
744
+ await vi.waitFor(() =>
745
+ expect(storageA.saveCustomServers).toHaveBeenCalledWith([
746
+ expect.objectContaining({ name: "Docs" }),
747
+ ]),
748
+ );
749
+ expect(storageB.saveCustomServers).not.toHaveBeenCalled();
750
+ } finally {
751
+ root.unmount();
752
+ }
753
+ });
754
+
755
+ it("waits for pending persistence before revisiting a scope", async () => {
756
+ let resolveStorageASave: (() => void) | undefined;
757
+ const pendingStorageASave = new Promise<void>((resolve) => {
758
+ resolveStorageASave = resolve;
759
+ });
760
+ let persistedStorageARecords: MCPCustomServerRecord[] = [];
761
+ let blockStorageASave = false;
762
+ const storageA = {
763
+ scopeId: "account:a",
764
+ loadCustomServers: vi.fn(async () => [...persistedStorageARecords]),
765
+ saveCustomServers: vi.fn(async (records: MCPCustomServerRecord[]) => {
766
+ if (blockStorageASave) {
767
+ blockStorageASave = false;
768
+ await pendingStorageASave;
769
+ }
770
+ persistedStorageARecords = records;
771
+ }),
772
+ loadAuthState: vi.fn(async () => null),
773
+ saveAuthState: vi.fn(async () => {}),
774
+ clearAuthState: vi.fn(async () => {}),
775
+ };
776
+ const storageB = {
777
+ scopeId: "account:b",
778
+ loadCustomServers: vi.fn(async () => []),
779
+ saveCustomServers: vi.fn(async () => {}),
780
+ loadAuthState: vi.fn(async () => null),
781
+ saveAuthState: vi.fn(async () => {}),
782
+ clearAuthState: vi.fn(async () => {}),
783
+ };
784
+ let setStorage = (_storageKey: "a" | "b") => {};
785
+ const DynamicManager = resource(function useDynamicManager() {
786
+ const [storageKey, setStorageKey] = useState<"a" | "b">("a");
787
+ setStorage = setStorageKey;
788
+ return useResource(
789
+ McpManagerResource({
790
+ storage: McpCustomStorage(storageKey === "a" ? storageA : storageB),
791
+ autoConnect: false,
792
+ }),
793
+ );
794
+ });
795
+ const root = createTapRoot(function Root() {
796
+ return useResource(DynamicManager());
797
+ });
798
+
799
+ try {
800
+ await vi.waitFor(() =>
801
+ expect(root.getValue().getState().isHydrated).toBe(true),
802
+ );
803
+ expect(storageA.saveCustomServers).not.toHaveBeenCalled();
804
+ blockStorageASave = true;
805
+
806
+ await root.getValue().addCustomServer({
807
+ name: "Docs",
808
+ url: "https://example.com/docs/mcp",
809
+ auth: { type: "none" },
810
+ });
811
+ await vi.waitFor(() =>
812
+ expect(storageA.saveCustomServers).toHaveBeenCalledOnce(),
813
+ );
814
+
815
+ setStorage("b");
816
+ await vi.waitFor(() =>
817
+ expect(storageB.loadCustomServers).toHaveBeenCalled(),
818
+ );
819
+ const storageALoadCount = storageA.loadCustomServers.mock.calls.length;
820
+
821
+ setStorage("a");
822
+ await new Promise((resolve) => setTimeout(resolve, 0));
823
+ expect(storageA.loadCustomServers).toHaveBeenCalledTimes(
824
+ storageALoadCount,
825
+ );
826
+
827
+ resolveStorageASave?.();
828
+ await vi.waitFor(() =>
829
+ expect(storageA.loadCustomServers.mock.calls.length).toBeGreaterThan(
830
+ storageALoadCount,
831
+ ),
832
+ );
833
+ await vi.waitFor(() =>
834
+ expect(root.getValue().getState().customServers[0]?.name).toBe("Docs"),
835
+ );
836
+ } finally {
837
+ resolveStorageASave?.();
838
+ root.unmount();
839
+ }
840
+ });
841
+
842
+ it("ignores hydration that resolves after changing scopes", async () => {
843
+ let resolveStorageA: ((records: (typeof docsServer)[]) => void) | undefined;
844
+ const storageALoad = new Promise<(typeof docsServer)[]>((resolve) => {
845
+ resolveStorageA = resolve;
846
+ });
847
+ const storageA = {
848
+ scopeId: "account:a",
849
+ loadCustomServers: vi.fn(() => storageALoad),
850
+ saveCustomServers: vi.fn(async () => {}),
851
+ loadAuthState: vi.fn(async () => null),
852
+ saveAuthState: vi.fn(async () => {}),
853
+ clearAuthState: vi.fn(async () => {}),
854
+ };
855
+ const storageB = {
856
+ scopeId: "account:b",
857
+ loadCustomServers: vi.fn(async () => []),
858
+ saveCustomServers: vi.fn(async () => {}),
859
+ loadAuthState: vi.fn(async () => null),
860
+ saveAuthState: vi.fn(async () => {}),
861
+ clearAuthState: vi.fn(async () => {}),
862
+ };
863
+ let switchStorage = () => {};
864
+ const DynamicManager = resource(function useDynamicManager() {
865
+ const [storageKey, setStorageKey] = useState<"a" | "b">("a");
866
+ switchStorage = () => setStorageKey("b");
867
+ return useResource(
868
+ McpManagerResource({
869
+ storage: McpCustomStorage(storageKey === "a" ? storageA : storageB),
870
+ autoConnect: false,
871
+ }),
872
+ );
873
+ });
874
+ const root = createTapRoot(function Root() {
875
+ return useResource(DynamicManager());
876
+ });
877
+
878
+ try {
879
+ await vi.waitFor(() =>
880
+ expect(storageA.loadCustomServers).toHaveBeenCalled(),
881
+ );
882
+ switchStorage();
883
+ await vi.waitFor(() =>
884
+ expect(storageB.loadCustomServers).toHaveBeenCalled(),
885
+ );
886
+
887
+ resolveStorageA?.([docsServer]);
888
+
889
+ await vi.waitFor(() =>
890
+ expect(root.getValue().getState().isHydrated).toBe(true),
891
+ );
892
+ expect(root.getValue().getState().customServers).toHaveLength(0);
893
+ } finally {
894
+ resolveStorageA?.([]);
895
+ root.unmount();
896
+ }
897
+ });
898
+
899
+ it("persists a pre-hydration mutation after its load resolves post-switch", async () => {
900
+ let resolveStorageA:
901
+ | ((records: MCPCustomServerRecord[]) => void)
902
+ | undefined;
903
+ const storageALoad = new Promise<MCPCustomServerRecord[]>((resolve) => {
904
+ resolveStorageA = resolve;
905
+ });
906
+ const storageA = {
907
+ scopeId: "account:a",
908
+ loadCustomServers: vi.fn(() => storageALoad),
909
+ saveCustomServers: vi.fn(async () => {}),
910
+ loadAuthState: vi.fn(async () => null),
911
+ saveAuthState: vi.fn(async () => {}),
912
+ clearAuthState: vi.fn(async () => {}),
913
+ };
914
+ const storageB = {
915
+ scopeId: "account:b",
916
+ loadCustomServers: vi.fn(async () => []),
917
+ saveCustomServers: vi.fn(async () => {}),
918
+ loadAuthState: vi.fn(async () => null),
919
+ saveAuthState: vi.fn(async () => {}),
920
+ clearAuthState: vi.fn(async () => {}),
921
+ };
922
+ let switchStorage = () => {};
923
+ const DynamicManager = resource(function useDynamicManager() {
924
+ const [storageKey, setStorageKey] = useState<"a" | "b">("a");
925
+ switchStorage = () => setStorageKey("b");
926
+ return useResource(
927
+ McpManagerResource({
928
+ storage: McpCustomStorage(storageKey === "a" ? storageA : storageB),
929
+ autoConnect: false,
930
+ }),
931
+ );
932
+ });
933
+ const root = createTapRoot(function Root() {
934
+ return useResource(DynamicManager());
935
+ });
936
+
937
+ try {
938
+ await vi.waitFor(() =>
939
+ expect(storageA.loadCustomServers).toHaveBeenCalled(),
940
+ );
941
+ expect(root.getValue().getState().isHydrated).toBe(false);
942
+
943
+ await root.getValue().addCustomServer({
944
+ name: "Docs",
945
+ url: "https://example.com/docs/mcp",
946
+ auth: { type: "none" },
947
+ });
948
+ switchStorage();
949
+
950
+ await vi.waitFor(() =>
951
+ expect(storageB.loadCustomServers).toHaveBeenCalled(),
952
+ );
953
+ resolveStorageA?.([]);
954
+
955
+ await vi.waitFor(() =>
956
+ expect(storageA.saveCustomServers).toHaveBeenCalledWith([
957
+ expect.objectContaining({ name: "Docs" }),
958
+ ]),
959
+ );
960
+ expect(storageB.saveCustomServers).not.toHaveBeenCalled();
961
+ } finally {
962
+ resolveStorageA?.([]);
963
+ root.unmount();
964
+ }
965
+ });
966
+
967
+ it("does not let a delayed removal affect the replacement scope", async () => {
968
+ const workspaceBDocsServer = {
969
+ ...docsServer,
970
+ name: "Workspace B Docs",
971
+ url: "https://example.com/workspace-b/docs/mcp",
972
+ createdAt: 2,
973
+ };
974
+ let resolveStorageAClear: (() => void) | undefined;
975
+ const storageAClear = new Promise<void>((resolve) => {
976
+ resolveStorageAClear = resolve;
977
+ });
978
+ const storageA = {
979
+ scopeId: "account:a",
980
+ loadCustomServers: vi.fn(async () => [docsServer]),
981
+ saveCustomServers: vi.fn(async () => {}),
982
+ loadAuthState: vi.fn(async () => null),
983
+ saveAuthState: vi.fn(async () => {}),
984
+ clearAuthState: vi.fn(() => storageAClear),
985
+ };
986
+ const storageB = {
987
+ scopeId: "account:b",
988
+ loadCustomServers: vi.fn(async () => [workspaceBDocsServer]),
989
+ saveCustomServers: vi.fn(async () => {}),
990
+ loadAuthState: vi.fn(async () => null),
991
+ saveAuthState: vi.fn(async () => {}),
992
+ clearAuthState: vi.fn(async () => {}),
993
+ };
994
+ let switchStorage = () => {};
995
+ const DynamicManager = resource(function useDynamicManager() {
996
+ const [storageKey, setStorageKey] = useState<"a" | "b">("a");
997
+ switchStorage = () => setStorageKey("b");
998
+ return useResource(
999
+ McpManagerResource({
1000
+ storage: McpCustomStorage(storageKey === "a" ? storageA : storageB),
1001
+ autoConnect: false,
1002
+ }),
1003
+ );
1004
+ });
1005
+ const root = createTapRoot(function Root() {
1006
+ return useResource(DynamicManager());
1007
+ });
1008
+
1009
+ try {
1010
+ await vi.waitFor(() =>
1011
+ expect(root.getValue().getState().customServers[0]?.id).toBe("docs"),
1012
+ );
1013
+ const removal = root.getValue().server({ id: "docs" }).remove();
1014
+ await vi.waitFor(() =>
1015
+ expect(storageA.clearAuthState).toHaveBeenCalledWith("docs"),
1016
+ );
1017
+
1018
+ switchStorage();
1019
+
1020
+ await vi.waitFor(() =>
1021
+ expect(root.getValue().getState().customServers[0]?.name).toBe(
1022
+ "Workspace B Docs",
1023
+ ),
1024
+ );
1025
+ resolveStorageAClear?.();
1026
+ await removal;
1027
+
1028
+ expect(root.getValue().getState().customServers).toEqual([
1029
+ expect.objectContaining({
1030
+ id: "docs",
1031
+ name: "Workspace B Docs",
1032
+ }),
1033
+ ]);
1034
+ } finally {
1035
+ resolveStorageAClear?.();
1036
+ root.unmount();
1037
+ }
1038
+ });
1039
+
1040
+ it("waits for an in-flight removal before rehydrating a revisited scope", async () => {
1041
+ let persistedStorageARecords: MCPCustomServerRecord[] = [docsServer];
1042
+ let resolveStorageAClear: (() => void) | undefined;
1043
+ const storageAClear = new Promise<void>((resolve) => {
1044
+ resolveStorageAClear = resolve;
1045
+ });
1046
+ const storageA = {
1047
+ scopeId: "account:a",
1048
+ loadCustomServers: vi.fn(async () => [...persistedStorageARecords]),
1049
+ saveCustomServers: vi.fn(async (records: MCPCustomServerRecord[]) => {
1050
+ persistedStorageARecords = records;
1051
+ }),
1052
+ loadAuthState: vi.fn(async () => null),
1053
+ saveAuthState: vi.fn(async () => {}),
1054
+ clearAuthState: vi.fn(() => storageAClear),
1055
+ };
1056
+ const storageB = {
1057
+ scopeId: "account:b",
1058
+ loadCustomServers: vi.fn(async () => []),
1059
+ saveCustomServers: vi.fn(async () => {}),
1060
+ loadAuthState: vi.fn(async () => null),
1061
+ saveAuthState: vi.fn(async () => {}),
1062
+ clearAuthState: vi.fn(async () => {}),
1063
+ };
1064
+ let setStorage = (_storageKey: "a" | "b") => {};
1065
+ const DynamicManager = resource(function useDynamicManager() {
1066
+ const [storageKey, setStorageKey] = useState<"a" | "b">("a");
1067
+ setStorage = setStorageKey;
1068
+ return useResource(
1069
+ McpManagerResource({
1070
+ storage: McpCustomStorage(storageKey === "a" ? storageA : storageB),
1071
+ autoConnect: false,
1072
+ }),
1073
+ );
1074
+ });
1075
+ const root = createTapRoot(function Root() {
1076
+ return useResource(DynamicManager());
1077
+ });
1078
+
1079
+ try {
1080
+ await vi.waitFor(() =>
1081
+ expect(root.getValue().getState().customServers[0]?.id).toBe("docs"),
1082
+ );
1083
+ const removal = root.getValue().server({ id: "docs" }).remove();
1084
+ await vi.waitFor(() =>
1085
+ expect(storageA.clearAuthState).toHaveBeenCalledWith("docs"),
1086
+ );
1087
+
1088
+ setStorage("b");
1089
+ await vi.waitFor(() =>
1090
+ expect(storageB.loadCustomServers).toHaveBeenCalled(),
1091
+ );
1092
+ const storageALoadCount = storageA.loadCustomServers.mock.calls.length;
1093
+
1094
+ setStorage("a");
1095
+ await new Promise((resolve) => setTimeout(resolve, 0));
1096
+ expect(storageA.loadCustomServers).toHaveBeenCalledTimes(
1097
+ storageALoadCount,
1098
+ );
1099
+
1100
+ resolveStorageAClear?.();
1101
+ await removal;
1102
+ await vi.waitFor(() =>
1103
+ expect(storageA.loadCustomServers.mock.calls.length).toBeGreaterThan(
1104
+ storageALoadCount,
1105
+ ),
1106
+ );
1107
+ await vi.waitFor(() =>
1108
+ expect(root.getValue().getState().customServers).toHaveLength(0),
1109
+ );
1110
+ expect(persistedStorageARecords).toEqual([]);
1111
+ } finally {
1112
+ resolveStorageAClear?.();
1113
+ root.unmount();
1114
+ }
1115
+ });
1116
+
1117
+ it("disposes a no-auth custom server from the previous scope", async () => {
1118
+ mocks.StreamableHTTPClientTransport.mockClear();
1119
+ const storageA = {
1120
+ scopeId: "account:a",
1121
+ loadCustomServers: vi.fn(async () => [docsServer]),
1122
+ saveCustomServers: vi.fn(async () => {}),
1123
+ loadAuthState: vi.fn(async () => null),
1124
+ saveAuthState: vi.fn(async () => {}),
1125
+ clearAuthState: vi.fn(async () => {}),
1126
+ };
1127
+ const storageB = {
1128
+ scopeId: "account:b",
1129
+ loadCustomServers: vi.fn(async () => []),
1130
+ saveCustomServers: vi.fn(async () => {}),
1131
+ loadAuthState: vi.fn(async () => null),
1132
+ saveAuthState: vi.fn(async () => {}),
1133
+ clearAuthState: vi.fn(async () => {}),
1134
+ };
1135
+ let switchStorage = () => {};
1136
+ const DynamicManager = resource(function useDynamicManager() {
1137
+ const [storageKey, setStorageKey] = useState<"a" | "b">("a");
1138
+ switchStorage = () => setStorageKey("b");
1139
+ return useResource(
1140
+ McpManagerResource({
1141
+ storage: McpCustomStorage(storageKey === "a" ? storageA : storageB),
1142
+ }),
1143
+ );
1144
+ });
1145
+ const root = createTapRoot(function Root() {
1146
+ return useResource(DynamicManager());
1147
+ });
1148
+
1149
+ try {
1150
+ await vi.waitFor(() =>
1151
+ expect(
1152
+ root.getValue().customServer({ index: 0 }).getState().connectionState,
1153
+ ).toBe("connected"),
1154
+ );
1155
+ const firstTransport = mocks.StreamableHTTPClientTransport.mock
1156
+ .instances[0] as { close: ReturnType<typeof vi.fn> };
1157
+
1158
+ switchStorage();
1159
+
1160
+ await vi.waitFor(() =>
1161
+ expect(storageB.loadCustomServers).toHaveBeenCalled(),
1162
+ );
1163
+ await vi.waitFor(() => expect(firstTransport.close).toHaveBeenCalled());
1164
+ expect(root.getValue().getState().customServers).toHaveLength(0);
1165
+ } finally {
1166
+ root.unmount();
1167
+ }
1168
+ });
1169
+ });