@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.
- package/dist/auth/createOAuthProvider.d.ts +13 -2
- package/dist/auth/createOAuthProvider.d.ts.map +1 -1
- package/dist/auth/createOAuthProvider.js +109 -31
- package/dist/auth/createOAuthProvider.js.map +1 -1
- package/dist/auth/types.d.ts +5 -1
- package/dist/auth/types.d.ts.map +1 -1
- package/dist/primitives/addForm/McpAddFormAuthFields.js +71 -28
- package/dist/primitives/addForm/McpAddFormAuthFields.js.map +1 -1
- package/dist/primitives/addForm/McpAddFormError.d.ts.map +1 -1
- package/dist/primitives/addForm/McpAddFormError.js +30 -10
- package/dist/primitives/addForm/McpAddFormError.js.map +1 -1
- package/dist/primitives/addForm/McpAddFormNameField.js +31 -18
- package/dist/primitives/addForm/McpAddFormNameField.js.map +1 -1
- package/dist/primitives/addForm/McpAddFormRoot.d.ts.map +1 -1
- package/dist/primitives/addForm/McpAddFormRoot.js +32 -11
- package/dist/primitives/addForm/McpAddFormRoot.js.map +1 -1
- package/dist/primitives/addForm/McpAddFormUrlField.js +31 -18
- package/dist/primitives/addForm/McpAddFormUrlField.js.map +1 -1
- package/dist/primitives/addForm/context.d.ts +9 -1
- package/dist/primitives/addForm/context.d.ts.map +1 -1
- package/dist/primitives/addForm/context.js.map +1 -1
- package/dist/resources/McpManagerResource.d.ts.map +1 -1
- package/dist/resources/McpManagerResource.js +330 -247
- package/dist/resources/McpManagerResource.js.map +1 -1
- package/dist/resources/McpServerRemovalFence.d.ts +7 -0
- package/dist/resources/McpServerRemovalFence.d.ts.map +1 -0
- package/dist/resources/McpServerRemovalFence.js +11 -0
- package/dist/resources/McpServerRemovalFence.js.map +1 -0
- package/dist/resources/McpServerResource.d.ts.map +1 -1
- package/dist/resources/McpServerResource.js +41 -16
- package/dist/resources/McpServerResource.js.map +1 -1
- package/dist/resources/storage/McpLocalStorage.d.ts.map +1 -1
- package/dist/resources/storage/McpLocalStorage.js +16 -1
- package/dist/resources/storage/McpLocalStorage.js.map +1 -1
- package/dist/resources/storage/types.d.ts +10 -8
- package/dist/resources/storage/types.d.ts.map +1 -1
- package/dist/utils/serverUrl.d.ts +8 -0
- package/dist/utils/serverUrl.d.ts.map +1 -0
- package/dist/utils/serverUrl.js +15 -0
- package/dist/utils/serverUrl.js.map +1 -0
- package/package.json +7 -7
- package/src/auth/createOAuthProvider.test.ts +544 -35
- package/src/auth/createOAuthProvider.ts +201 -46
- package/src/auth/types.ts +5 -1
- package/src/primitives/addForm/McpAddFormAccessibility.test.tsx +261 -0
- package/src/primitives/addForm/McpAddFormAuthFields.tsx +33 -15
- package/src/primitives/addForm/McpAddFormError.tsx +17 -3
- package/src/primitives/addForm/McpAddFormNameField.tsx +13 -1
- package/src/primitives/addForm/McpAddFormRoot.tsx +51 -8
- package/src/primitives/addForm/McpAddFormUrlField.tsx +12 -1
- package/src/primitives/addForm/context.tsx +10 -0
- package/src/resources/McpManagerResource.test.ts +712 -9
- package/src/resources/McpManagerResource.ts +208 -56
- package/src/resources/McpServerRemovalFence.ts +21 -0
- package/src/resources/McpServerResource.test.ts +252 -17
- package/src/resources/McpServerResource.ts +40 -15
- package/src/resources/storage/McpLocalStorage.test.ts +28 -0
- package/src/resources/storage/McpLocalStorage.ts +23 -1
- package/src/resources/storage/types.ts +10 -8
- package/src/utils/serverUrl.test.ts +66 -0
- 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("
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|