@assistant-ui/react-mcp 0.1.14 → 0.1.16

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 (52) hide show
  1. package/dist/auth/createOAuthProvider.d.ts +7 -1
  2. package/dist/auth/createOAuthProvider.d.ts.map +1 -1
  3. package/dist/auth/createOAuthProvider.js +115 -31
  4. package/dist/auth/createOAuthProvider.js.map +1 -1
  5. package/dist/auth/types.d.ts +1 -0
  6. package/dist/auth/types.d.ts.map +1 -1
  7. package/dist/hooks/useMcpOAuthCallback.d.ts.map +1 -1
  8. package/dist/hooks/useMcpOAuthCallback.js +5 -6
  9. package/dist/hooks/useMcpOAuthCallback.js.map +1 -1
  10. package/dist/primitives/server/McpServerIcon.js.map +1 -1
  11. package/dist/primitives/server/McpServerOAuthLink.js.map +1 -1
  12. package/dist/resources/McpManagerResource.d.ts.map +1 -1
  13. package/dist/resources/McpManagerResource.js +4 -2
  14. package/dist/resources/McpManagerResource.js.map +1 -1
  15. package/dist/resources/McpServerResource.d.ts +2 -1
  16. package/dist/resources/McpServerResource.d.ts.map +1 -1
  17. package/dist/resources/McpServerResource.js +107 -21
  18. package/dist/resources/McpServerResource.js.map +1 -1
  19. package/dist/resources/storage/McpLocalStorage.d.ts +7 -0
  20. package/dist/resources/storage/McpLocalStorage.d.ts.map +1 -1
  21. package/dist/resources/storage/McpLocalStorage.js +132 -37
  22. package/dist/resources/storage/McpLocalStorage.js.map +1 -1
  23. package/dist/resources/storage/McpMemoryStorage.d.ts.map +1 -1
  24. package/dist/resources/storage/McpMemoryStorage.js +9 -6
  25. package/dist/resources/storage/McpMemoryStorage.js.map +1 -1
  26. package/dist/resources/storage/types.d.ts +12 -0
  27. package/dist/resources/storage/types.d.ts.map +1 -1
  28. package/dist/utils/createMcpId.d.ts +9 -0
  29. package/dist/utils/createMcpId.d.ts.map +1 -0
  30. package/dist/utils/createMcpId.js +11 -0
  31. package/dist/utils/createMcpId.js.map +1 -0
  32. package/dist/utils/invokeMcpCallback.d.ts.map +1 -1
  33. package/dist/utils/invokeMcpCallback.js +2 -12
  34. package/dist/utils/invokeMcpCallback.js.map +1 -1
  35. package/package.json +8 -8
  36. package/src/auth/createOAuthProvider.test.ts +407 -2
  37. package/src/auth/createOAuthProvider.ts +171 -40
  38. package/src/auth/types.ts +1 -0
  39. package/src/hooks/useMcpOAuthCallback.test.ts +74 -1
  40. package/src/hooks/useMcpOAuthCallback.tsx +11 -8
  41. package/src/resources/McpManagerResource.test.ts +128 -0
  42. package/src/resources/McpManagerResource.ts +4 -5
  43. package/src/resources/McpServerResource.test.ts +420 -18
  44. package/src/resources/McpServerResource.ts +148 -27
  45. package/src/resources/storage/McpLocalStorage.test.ts +71 -1
  46. package/src/resources/storage/McpLocalStorage.ts +69 -47
  47. package/src/resources/storage/McpMemoryStorage.test.ts +99 -0
  48. package/src/resources/storage/McpMemoryStorage.ts +23 -17
  49. package/src/resources/storage/types.ts +12 -0
  50. package/src/utils/createMcpId.test.ts +25 -0
  51. package/src/utils/createMcpId.ts +10 -0
  52. package/src/utils/invokeMcpCallback.ts +3 -21
@@ -1,9 +1,10 @@
1
- import { createTapRoot, useResource } from "@assistant-ui/tap";
1
+ import { createTapRoot, resource, useResource } from "@assistant-ui/tap";
2
2
  import type { ClientOutput } from "@assistant-ui/store";
3
- import { useEffect } from "react";
3
+ import { useEffect, useState } from "react";
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import type { MCPAuthConfig } from "../mcp-scope";
6
6
  import type { MCPStorage } from "./storage/types";
7
+ import type { McpServerResourceProps } from "./McpServerResource";
7
8
 
8
9
  const mocks = vi.hoisted(() => {
9
10
  const clients: any[] = [];
@@ -70,7 +71,8 @@ vi.mock("@modelcontextprotocol/client", async (importOriginal) => ({
70
71
  StreamableHTTPClientTransport: mocks.StreamableHTTPClientTransport,
71
72
  }));
72
73
 
73
- const { McpServerResource } = await import("./McpServerResource");
74
+ const { McpServerResource, getConnectionDependencies } =
75
+ await import("./McpServerResource");
74
76
 
75
77
  const never = <T>() => new Promise<T>(() => {});
76
78
 
@@ -444,6 +446,47 @@ describe("McpServerResource connectionTimeout", () => {
444
446
  describe("McpServerResource connection lifecycle", () => {
445
447
  beforeEach(resetMocks);
446
448
 
449
+ it("replaces direct resource connections when the server id changes", async () => {
450
+ const storage = createStorage();
451
+ let updateId = (_id: string) => {};
452
+ const DynamicServer = resource(function useDynamicServer() {
453
+ const [id, setId] = useState("docs");
454
+ updateId = setId;
455
+ return useResource(
456
+ McpServerResource({
457
+ id,
458
+ kind: "connector",
459
+ name: "Docs",
460
+ url: "https://example.com/mcp",
461
+ auth: { type: "none" },
462
+ storage,
463
+ redirectUri: "https://example.com/callback",
464
+ autoConnect: true,
465
+ onRemove: vi.fn(async () => {}),
466
+ }),
467
+ );
468
+ });
469
+ const root = createTapRoot(function Root() {
470
+ return useResource(DynamicServer());
471
+ });
472
+
473
+ try {
474
+ await waitForResourceUpdate(() => mocks.transports.length === 1);
475
+ const firstTransport = mocks.transports[0];
476
+
477
+ updateId("internal-docs");
478
+
479
+ await waitForResourceUpdate(
480
+ () =>
481
+ firstTransport.close.mock.calls.length === 1 &&
482
+ mocks.transports.length === 2,
483
+ );
484
+ expect(root.getValue().getState().id).toBe("internal-docs");
485
+ } finally {
486
+ root.unmount();
487
+ }
488
+ });
489
+
447
490
  it("closes a pending connection when the resource unmounts", async () => {
448
491
  let resolveConnect!: () => void;
449
492
  mocks.connectResults.push(
@@ -518,14 +561,107 @@ describe("McpServerResource connection lifecycle", () => {
518
561
  describe("McpServerResource completeAuth", () => {
519
562
  beforeEach(resetMocks);
520
563
 
564
+ it("lets callback validation win over mount-time auto-connect", async () => {
565
+ const pendingLoads: Array<
566
+ (value: { state?: string; tokens?: { access_token: string } }) => void
567
+ > = [];
568
+ const storage = createStorage();
569
+ vi.mocked(storage.loadAuthState).mockImplementation(
570
+ () =>
571
+ new Promise((resolve) => {
572
+ pendingLoads.push(resolve);
573
+ }),
574
+ );
575
+ const root = mount({
576
+ auth: { type: "oauth" },
577
+ storage,
578
+ autoConnect: true,
579
+ });
580
+
581
+ try {
582
+ await waitFor(() => pendingLoads.length > 1);
583
+ const callbackLoadIndex = pendingLoads.length;
584
+ const completeAuth = root
585
+ .getValue()
586
+ .completeAuth("https://example.com/callback?code=abc&state=expected");
587
+ await waitFor(() => pendingLoads.length > callbackLoadIndex);
588
+
589
+ for (const resolve of pendingLoads.slice(0, callbackLoadIndex)) {
590
+ resolve({ tokens: { access_token: "persisted" } });
591
+ }
592
+ await flushMacrotask();
593
+
594
+ expect(mocks.StreamableHTTPClientTransport).not.toHaveBeenCalled();
595
+
596
+ pendingLoads[callbackLoadIndex]!({ state: "expected" });
597
+ await expect(completeAuth).resolves.toBeUndefined();
598
+ await flushMacrotask();
599
+
600
+ expect(mocks.transports).toHaveLength(1);
601
+ expect(mocks.transports[0].finishAuth).toHaveBeenCalledTimes(1);
602
+ expect(root.getValue().getState().connectionState).toBe("connected");
603
+ } finally {
604
+ root.unmount();
605
+ }
606
+ });
607
+
608
+ it("resumes auto-connect when callback validation fails", async () => {
609
+ const pendingLoads: Array<
610
+ (value: { state?: string; tokens?: { access_token: string } }) => void
611
+ > = [];
612
+ const storage = createStorage();
613
+ vi.mocked(storage.loadAuthState).mockImplementation(
614
+ () =>
615
+ new Promise((resolve) => {
616
+ pendingLoads.push(resolve);
617
+ }),
618
+ );
619
+ const root = mount({
620
+ auth: { type: "oauth" },
621
+ storage,
622
+ autoConnect: true,
623
+ });
624
+
625
+ try {
626
+ await waitFor(() => pendingLoads.length > 1);
627
+ const callbackLoadIndex = pendingLoads.length;
628
+ const completeAuth = root
629
+ .getValue()
630
+ .completeAuth("https://example.com/callback?code=abc&state=expected");
631
+ await waitFor(() => pendingLoads.length > callbackLoadIndex);
632
+
633
+ for (const resolve of pendingLoads.slice(0, callbackLoadIndex)) {
634
+ resolve({ tokens: { access_token: "persisted" } });
635
+ }
636
+ await flushMacrotask();
637
+
638
+ expect(mocks.StreamableHTTPClientTransport).not.toHaveBeenCalled();
639
+
640
+ pendingLoads[callbackLoadIndex]!({ state: "different" });
641
+ await expect(completeAuth).rejects.toThrow(
642
+ "OAuth state does not match the authorization request",
643
+ );
644
+ await waitFor(() => mocks.transports.length === 1);
645
+ await waitForResourceUpdate(
646
+ () => root.getValue().getState().connectionState === "connected",
647
+ );
648
+ } finally {
649
+ root.unmount();
650
+ }
651
+ });
652
+
521
653
  it("completes auth across the StrictMode effect replay", async () => {
654
+ const storage = createStorage();
655
+ vi.mocked(storage.loadAuthState).mockResolvedValue({
656
+ state: "aui-mcp:ZG9jcw.nonce",
657
+ });
522
658
  let completeAuth: Promise<void> | undefined;
523
659
  let started = false;
524
- const root = mount({ auth: { type: "oauth" } }, (server) => {
660
+ const root = mount({ auth: { type: "oauth" }, storage }, (server) => {
525
661
  if (started) return;
526
662
  started = true;
527
663
  completeAuth = server.completeAuth(
528
- "https://example.com/callback?code=abc",
664
+ "https://example.com/callback?code=abc&state=aui-mcp%3AZG9jcw.nonce&iss=https%3A%2F%2Fauth.example.com",
529
665
  );
530
666
  });
531
667
 
@@ -534,26 +670,149 @@ describe("McpServerResource completeAuth", () => {
534
670
  await flushMacrotask();
535
671
 
536
672
  expect(mocks.transports[0].finishAuth).toHaveBeenCalledTimes(1);
673
+ const params = mocks.transports[0].finishAuth.mock.calls[0][0];
674
+ expect(params).toBeInstanceOf(URLSearchParams);
675
+ expect(params.get("code")).toBe("abc");
676
+ expect(params.get("iss")).toBe("https://auth.example.com");
537
677
  expect(root.getValue().getState().connectionState).toBe("connected");
538
678
  } finally {
539
679
  root.unmount();
540
680
  }
541
681
  });
542
682
 
543
- it("rejects when the callback URL has no authorization code", async () => {
544
- const root = mount();
683
+ it("rejects before transport setup without a usable authorization code", async () => {
684
+ const storage = createStorage();
685
+ vi.mocked(storage.loadAuthState).mockResolvedValue({ state: "abc" });
686
+ const root = mount({ auth: { type: "oauth" }, storage });
545
687
 
546
688
  try {
547
689
  await expect(
548
- root.getValue().completeAuth("https://example.com/callback?state=abc"),
690
+ root
691
+ .getValue()
692
+ .completeAuth("https://example.com/callback?state=abc&code="),
549
693
  ).rejects.toThrow("missing authorization code in callback URL");
550
694
  await flushMacrotask();
551
695
 
696
+ expect(root.getValue().getState()).toMatchObject({
697
+ connectionState: "disconnected",
698
+ lastError: null,
699
+ });
700
+ expect(mocks.transports).toHaveLength(0);
701
+ } finally {
702
+ root.unmount();
703
+ }
704
+ });
705
+
706
+ it("forwards OAuth error callbacks to the transport", async () => {
707
+ const storage = createStorage();
708
+ vi.mocked(storage.loadAuthState).mockResolvedValue({ state: "expected" });
709
+ mocks.finishAuthResults.push(() =>
710
+ Promise.reject(new Error("access_denied: Denied")),
711
+ );
712
+ const root = mount({ auth: { type: "oauth" }, storage });
713
+
714
+ try {
715
+ await expect(
716
+ root
717
+ .getValue()
718
+ .completeAuth(
719
+ "https://example.com/callback?error=access_denied&error_description=Denied&state=expected&iss=https%3A%2F%2Fauth.example.com",
720
+ ),
721
+ ).rejects.toThrow("access_denied: Denied");
722
+ await flushMacrotask();
723
+
724
+ const params = mocks.transports[0].finishAuth.mock.calls[0][0];
725
+ expect(params).toBeInstanceOf(URLSearchParams);
726
+ expect(params.get("error")).toBe("access_denied");
727
+ expect(params.get("error_description")).toBe("Denied");
728
+ expect(params.get("iss")).toBe("https://auth.example.com");
552
729
  expect(root.getValue().getState()).toMatchObject({
553
730
  connectionState: "error",
554
- lastError: {
555
- message: "missing authorization code in callback URL",
556
- },
731
+ lastError: { message: "access_denied: Denied" },
732
+ });
733
+ } finally {
734
+ root.unmount();
735
+ }
736
+ });
737
+
738
+ it("rejects callbacks without a pending authorization request", async () => {
739
+ const storage = createStorage();
740
+ vi.mocked(storage.loadAuthState).mockResolvedValue(null);
741
+ const root = mount({ auth: { type: "oauth" }, storage });
742
+
743
+ try {
744
+ await expect(
745
+ root
746
+ .getValue()
747
+ .completeAuth("https://example.com/callback?code=abc&state=expected"),
748
+ ).rejects.toThrow(
749
+ "no pending OAuth authorization request for this server",
750
+ );
751
+
752
+ expect(mocks.transports).toHaveLength(0);
753
+ } finally {
754
+ root.unmount();
755
+ }
756
+ });
757
+
758
+ it("rejects callbacks whose state does not match the authorization request", async () => {
759
+ const storage = createStorage();
760
+ vi.mocked(storage.loadAuthState).mockResolvedValue({
761
+ state: "aui-mcp:ZG9jcw.expected",
762
+ });
763
+ const root = mount({ auth: { type: "oauth" }, storage });
764
+
765
+ try {
766
+ await root.getValue().connect();
767
+ const transport = mocks.transports[0];
768
+
769
+ await expect(
770
+ root
771
+ .getValue()
772
+ .completeAuth(
773
+ "https://example.com/callback?code=abc&state=aui-mcp%3AZG9jcw.forged",
774
+ ),
775
+ ).rejects.toThrow("OAuth state does not match the authorization request");
776
+ await flushMacrotask();
777
+
778
+ expect(mocks.transports).toHaveLength(1);
779
+ expect(transport.close).not.toHaveBeenCalled();
780
+ expect(root.getValue().getState()).toMatchObject({
781
+ connectionState: "connected",
782
+ lastError: null,
783
+ });
784
+ } finally {
785
+ root.unmount();
786
+ }
787
+ });
788
+
789
+ it("does not resume authorization after disconnecting during validation", async () => {
790
+ let resolveAuthState!: (state: { state: string }) => void;
791
+ const storage = createStorage();
792
+ vi.mocked(storage.loadAuthState).mockImplementation(
793
+ () =>
794
+ new Promise((resolve) => {
795
+ resolveAuthState = resolve;
796
+ }),
797
+ );
798
+ const root = mount({ auth: { type: "oauth" }, storage });
799
+
800
+ try {
801
+ const completeAuth = root
802
+ .getValue()
803
+ .completeAuth("https://example.com/callback?code=abc&state=expected");
804
+ await waitFor(() => resolveAuthState !== undefined);
805
+
806
+ await root.getValue().disconnect();
807
+ resolveAuthState({ state: "expected" });
808
+
809
+ await expect(completeAuth).rejects.toThrow(
810
+ 'MCP server "docs" authorization was interrupted before completion.',
811
+ );
812
+ expect(mocks.transports).toHaveLength(0);
813
+ expect(root.getValue().getState()).toMatchObject({
814
+ connectionState: "disconnected",
815
+ lastError: null,
557
816
  });
558
817
  } finally {
559
818
  root.unmount();
@@ -564,11 +823,15 @@ describe("McpServerResource completeAuth", () => {
564
823
  mocks.finishAuthResults.push(() =>
565
824
  Promise.reject(new Error("invalid_grant")),
566
825
  );
567
- const root = mount({ auth: { type: "oauth" } });
826
+ const storage = createStorage();
827
+ vi.mocked(storage.loadAuthState).mockResolvedValue({ state: "expected" });
828
+ const root = mount({ auth: { type: "oauth" }, storage });
568
829
 
569
830
  try {
570
831
  await expect(
571
- root.getValue().completeAuth("https://example.com/callback?code=abc"),
832
+ root
833
+ .getValue()
834
+ .completeAuth("https://example.com/callback?code=abc&state=expected"),
572
835
  ).rejects.toThrow("invalid_grant");
573
836
  await flushMacrotask();
574
837
 
@@ -578,7 +841,9 @@ describe("McpServerResource completeAuth", () => {
578
841
  message: "invalid_grant",
579
842
  },
580
843
  });
581
- expect(mocks.transports[0].finishAuth).toHaveBeenCalledWith("abc");
844
+ expect(mocks.transports[0].finishAuth).toHaveBeenCalledWith(
845
+ expect.any(URLSearchParams),
846
+ );
582
847
  expect(mocks.transports[0].close).toHaveBeenCalledTimes(1);
583
848
  } finally {
584
849
  root.unmount();
@@ -593,13 +858,15 @@ describe("McpServerResource completeAuth", () => {
593
858
  resolveFinishAuth = resolve;
594
859
  }),
595
860
  );
596
- const root = mount({ auth: { type: "oauth" } });
861
+ const storage = createStorage();
862
+ vi.mocked(storage.loadAuthState).mockResolvedValue({ state: "expected" });
863
+ const root = mount({ auth: { type: "oauth" }, storage });
597
864
  let didUnmount = false;
598
865
 
599
866
  try {
600
867
  const completeAuth = root
601
868
  .getValue()
602
- .completeAuth("https://example.com/callback?code=abc");
869
+ .completeAuth("https://example.com/callback?code=abc&state=expected");
603
870
  await waitFor(
604
871
  () => mocks.transports[0]?.finishAuth.mock.calls.length === 1,
605
872
  );
@@ -624,13 +891,15 @@ describe("McpServerResource completeAuth", () => {
624
891
  rejectFinishAuth = reject;
625
892
  }),
626
893
  );
627
- const root = mount({ auth: { type: "oauth" } });
894
+ const storage = createStorage();
895
+ vi.mocked(storage.loadAuthState).mockResolvedValue({ state: "expected" });
896
+ const root = mount({ auth: { type: "oauth" }, storage });
628
897
  let didUnmount = false;
629
898
 
630
899
  try {
631
900
  const completeAuth = root
632
901
  .getValue()
633
- .completeAuth("https://example.com/callback?code=abc");
902
+ .completeAuth("https://example.com/callback?code=abc&state=expected");
634
903
  await waitFor(
635
904
  () => mocks.transports[0]?.finishAuth.mock.calls.length === 1,
636
905
  );
@@ -1440,3 +1709,136 @@ describe("McpServerResource resource methods", () => {
1440
1709
  }
1441
1710
  });
1442
1711
  });
1712
+
1713
+ describe("getConnectionDependencies storage scope", () => {
1714
+ const propsWith = (storage: MCPStorage): McpServerResourceProps => ({
1715
+ id: "docs",
1716
+ kind: "connector",
1717
+ name: "Docs",
1718
+ url: "https://example.com/mcp",
1719
+ auth: { type: "oauth" },
1720
+ storage,
1721
+ redirectUri: "https://example.com/callback",
1722
+ autoConnect: false,
1723
+ onRemove: async () => {},
1724
+ });
1725
+
1726
+ it("keys the connection on a declared storage scopeId", () => {
1727
+ const a = { ...createStorage(), scopeId: "local-storage:a" };
1728
+ const b = { ...createStorage(), scopeId: "local-storage:b" };
1729
+
1730
+ expect(getConnectionDependencies(propsWith(a))).not.toEqual(
1731
+ getConnectionDependencies(propsWith(b)),
1732
+ );
1733
+ });
1734
+
1735
+ it("treats storages sharing a scopeId as the same connection target", () => {
1736
+ const a = { ...createStorage(), scopeId: "local-storage:same" };
1737
+ const b = { ...createStorage(), scopeId: "local-storage:same" };
1738
+
1739
+ expect(getConnectionDependencies(propsWith(a))).toEqual(
1740
+ getConnectionDependencies(propsWith(b)),
1741
+ );
1742
+ });
1743
+
1744
+ it("does not key the connection on storage identity when no scopeId is declared", () => {
1745
+ expect(getConnectionDependencies(propsWith(createStorage()))).toEqual(
1746
+ getConnectionDependencies(propsWith(createStorage())),
1747
+ );
1748
+ });
1749
+
1750
+ it("ignores the storage scope for none-auth servers", () => {
1751
+ const a = { ...createStorage(), scopeId: "local-storage:a" };
1752
+ const b = { ...createStorage(), scopeId: "local-storage:b" };
1753
+ const noneProps = (storage: MCPStorage): McpServerResourceProps => ({
1754
+ ...propsWith(storage),
1755
+ auth: { type: "none" },
1756
+ });
1757
+
1758
+ expect(getConnectionDependencies(noneProps(a))).toEqual(
1759
+ getConnectionDependencies(noneProps(b)),
1760
+ );
1761
+ });
1762
+ });
1763
+
1764
+ describe("McpServerResource oauth storage swap", () => {
1765
+ beforeEach(resetMocks);
1766
+
1767
+ it("reconnects onto the replacement storage when the scope changes", async () => {
1768
+ const persisted = {
1769
+ tokens: { access_token: "tok", token_type: "bearer" },
1770
+ };
1771
+ const storageA = {
1772
+ ...createStorage(),
1773
+ scopeId: "scope:a",
1774
+ loadAuthState: vi.fn(async () => persisted),
1775
+ };
1776
+ const storageB = {
1777
+ ...createStorage(),
1778
+ scopeId: "scope:b",
1779
+ loadAuthState: vi.fn(async () => persisted),
1780
+ };
1781
+ let setStorage!: (s: MCPStorage) => void;
1782
+
1783
+ const Host = resource(function useHost() {
1784
+ const [storage, set] = useState<MCPStorage>(storageA);
1785
+ setStorage = set;
1786
+ return useResource(
1787
+ McpServerResource({
1788
+ id: "docs",
1789
+ kind: "connector",
1790
+ name: "Docs",
1791
+ url: "https://example.com/mcp",
1792
+ auth: { type: "oauth" },
1793
+ storage,
1794
+ redirectUri: "https://example.com/callback",
1795
+ autoConnect: true,
1796
+ connectionTimeout: 10_000,
1797
+ onRemove: vi.fn(async () => {}),
1798
+ }),
1799
+ );
1800
+ });
1801
+
1802
+ const root = createTapRoot(function SwapRoot() {
1803
+ return useResource(Host());
1804
+ });
1805
+
1806
+ try {
1807
+ await waitFor(() => mocks.transports.length === 1);
1808
+ const authProviderA =
1809
+ mocks.StreamableHTTPClientTransport.mock.calls[0]?.[1]?.authProvider;
1810
+ await authProviderA.tokens();
1811
+ expect(storageA.loadAuthState).toHaveBeenCalledWith("docs");
1812
+ expect(storageB.loadAuthState).not.toHaveBeenCalled();
1813
+
1814
+ setStorage(storageB);
1815
+ await waitForResourceUpdate(() => mocks.transports.length === 2);
1816
+ await waitForResourceUpdate(
1817
+ () => vi.mocked(mocks.transports[0]!.close).mock.calls.length > 0,
1818
+ );
1819
+
1820
+ const authProviderB =
1821
+ mocks.StreamableHTTPClientTransport.mock.calls[1]?.[1]?.authProvider;
1822
+ const callsBefore = vi.mocked(storageA.loadAuthState).mock.calls.length;
1823
+ await authProviderB.tokens();
1824
+ expect(storageB.loadAuthState).toHaveBeenCalledWith("docs");
1825
+ expect(vi.mocked(storageA.loadAuthState).mock.calls.length).toBe(
1826
+ callsBefore,
1827
+ );
1828
+
1829
+ await authProviderB.saveTokens({
1830
+ access_token: "fresh",
1831
+ token_type: "bearer",
1832
+ });
1833
+ expect(storageB.saveAuthState).toHaveBeenCalledWith(
1834
+ "docs",
1835
+ expect.objectContaining({
1836
+ tokens: expect.objectContaining({ access_token: "fresh" }),
1837
+ }),
1838
+ );
1839
+ expect(storageA.saveAuthState).not.toHaveBeenCalled();
1840
+ } finally {
1841
+ root.unmount();
1842
+ }
1843
+ });
1844
+ });