@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.
- package/dist/auth/createOAuthProvider.d.ts +7 -1
- package/dist/auth/createOAuthProvider.d.ts.map +1 -1
- package/dist/auth/createOAuthProvider.js +115 -31
- package/dist/auth/createOAuthProvider.js.map +1 -1
- package/dist/auth/types.d.ts +1 -0
- package/dist/auth/types.d.ts.map +1 -1
- package/dist/hooks/useMcpOAuthCallback.d.ts.map +1 -1
- package/dist/hooks/useMcpOAuthCallback.js +5 -6
- package/dist/hooks/useMcpOAuthCallback.js.map +1 -1
- package/dist/primitives/server/McpServerIcon.js.map +1 -1
- package/dist/primitives/server/McpServerOAuthLink.js.map +1 -1
- package/dist/resources/McpManagerResource.d.ts.map +1 -1
- package/dist/resources/McpManagerResource.js +4 -2
- package/dist/resources/McpManagerResource.js.map +1 -1
- package/dist/resources/McpServerResource.d.ts +2 -1
- package/dist/resources/McpServerResource.d.ts.map +1 -1
- package/dist/resources/McpServerResource.js +107 -21
- package/dist/resources/McpServerResource.js.map +1 -1
- package/dist/resources/storage/McpLocalStorage.d.ts +7 -0
- package/dist/resources/storage/McpLocalStorage.d.ts.map +1 -1
- package/dist/resources/storage/McpLocalStorage.js +132 -37
- package/dist/resources/storage/McpLocalStorage.js.map +1 -1
- package/dist/resources/storage/McpMemoryStorage.d.ts.map +1 -1
- package/dist/resources/storage/McpMemoryStorage.js +9 -6
- package/dist/resources/storage/McpMemoryStorage.js.map +1 -1
- package/dist/resources/storage/types.d.ts +12 -0
- package/dist/resources/storage/types.d.ts.map +1 -1
- package/dist/utils/createMcpId.d.ts +9 -0
- package/dist/utils/createMcpId.d.ts.map +1 -0
- package/dist/utils/createMcpId.js +11 -0
- package/dist/utils/createMcpId.js.map +1 -0
- package/dist/utils/invokeMcpCallback.d.ts.map +1 -1
- package/dist/utils/invokeMcpCallback.js +2 -12
- package/dist/utils/invokeMcpCallback.js.map +1 -1
- package/package.json +8 -8
- package/src/auth/createOAuthProvider.test.ts +407 -2
- package/src/auth/createOAuthProvider.ts +171 -40
- package/src/auth/types.ts +1 -0
- package/src/hooks/useMcpOAuthCallback.test.ts +74 -1
- package/src/hooks/useMcpOAuthCallback.tsx +11 -8
- package/src/resources/McpManagerResource.test.ts +128 -0
- package/src/resources/McpManagerResource.ts +4 -5
- package/src/resources/McpServerResource.test.ts +420 -18
- package/src/resources/McpServerResource.ts +148 -27
- package/src/resources/storage/McpLocalStorage.test.ts +71 -1
- package/src/resources/storage/McpLocalStorage.ts +69 -47
- package/src/resources/storage/McpMemoryStorage.test.ts +99 -0
- package/src/resources/storage/McpMemoryStorage.ts +23 -17
- package/src/resources/storage/types.ts +12 -0
- package/src/utils/createMcpId.test.ts +25 -0
- package/src/utils/createMcpId.ts +10 -0
- 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 } =
|
|
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
|
|
544
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
+
});
|