@assistant-ui/react-mcp 0.0.22 → 0.1.0

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 (94) hide show
  1. package/dist/auth/createOAuthProvider.d.ts +1 -1
  2. package/dist/auth/createOAuthProvider.js.map +1 -1
  3. package/dist/auth/types.d.ts +1 -1
  4. package/dist/index.d.ts +3 -2
  5. package/dist/index.js +2 -1
  6. package/dist/mcp-scope.d.ts +32 -1
  7. package/dist/mcp-scope.d.ts.map +1 -1
  8. package/dist/primitives/elicitation/McpElicitationPrimitiveAccept.d.ts +13 -0
  9. package/dist/primitives/elicitation/McpElicitationPrimitiveAccept.d.ts.map +1 -0
  10. package/dist/primitives/elicitation/McpElicitationPrimitiveAccept.js +106 -0
  11. package/dist/primitives/elicitation/McpElicitationPrimitiveAccept.js.map +1 -0
  12. package/dist/primitives/elicitation/McpElicitationPrimitiveCancel.d.ts +13 -0
  13. package/dist/primitives/elicitation/McpElicitationPrimitiveCancel.d.ts.map +1 -0
  14. package/dist/primitives/elicitation/McpElicitationPrimitiveCancel.js +43 -0
  15. package/dist/primitives/elicitation/McpElicitationPrimitiveCancel.js.map +1 -0
  16. package/dist/primitives/elicitation/McpElicitationPrimitiveDecline.d.ts +13 -0
  17. package/dist/primitives/elicitation/McpElicitationPrimitiveDecline.d.ts.map +1 -0
  18. package/dist/primitives/elicitation/McpElicitationPrimitiveDecline.js +43 -0
  19. package/dist/primitives/elicitation/McpElicitationPrimitiveDecline.js.map +1 -0
  20. package/dist/primitives/elicitation/McpElicitationPrimitiveError.d.ts +13 -0
  21. package/dist/primitives/elicitation/McpElicitationPrimitiveError.d.ts.map +1 -0
  22. package/dist/primitives/elicitation/McpElicitationPrimitiveError.js +38 -0
  23. package/dist/primitives/elicitation/McpElicitationPrimitiveError.js.map +1 -0
  24. package/dist/primitives/elicitation/McpElicitationPrimitiveFields.d.ts +12 -0
  25. package/dist/primitives/elicitation/McpElicitationPrimitiveFields.d.ts.map +1 -0
  26. package/dist/primitives/elicitation/McpElicitationPrimitiveFields.js +53 -0
  27. package/dist/primitives/elicitation/McpElicitationPrimitiveFields.js.map +1 -0
  28. package/dist/primitives/elicitation/McpElicitationPrimitiveItems.d.ts +13 -0
  29. package/dist/primitives/elicitation/McpElicitationPrimitiveItems.d.ts.map +1 -0
  30. package/dist/primitives/elicitation/McpElicitationPrimitiveItems.js +96 -0
  31. package/dist/primitives/elicitation/McpElicitationPrimitiveItems.js.map +1 -0
  32. package/dist/primitives/elicitation/McpElicitationPrimitiveMessage.d.ts +13 -0
  33. package/dist/primitives/elicitation/McpElicitationPrimitiveMessage.d.ts.map +1 -0
  34. package/dist/primitives/elicitation/McpElicitationPrimitiveMessage.js +29 -0
  35. package/dist/primitives/elicitation/McpElicitationPrimitiveMessage.js.map +1 -0
  36. package/dist/primitives/elicitation/McpElicitationPrimitiveRoot.d.ts +13 -0
  37. package/dist/primitives/elicitation/McpElicitationPrimitiveRoot.d.ts.map +1 -0
  38. package/dist/primitives/elicitation/McpElicitationPrimitiveRoot.js +28 -0
  39. package/dist/primitives/elicitation/McpElicitationPrimitiveRoot.js.map +1 -0
  40. package/dist/primitives/elicitation/context.d.ts +21 -0
  41. package/dist/primitives/elicitation/context.d.ts.map +1 -0
  42. package/dist/primitives/elicitation/context.js +21 -0
  43. package/dist/primitives/elicitation/context.js.map +1 -0
  44. package/dist/primitives/elicitation/initialElicitationDraft.d.ts +5 -0
  45. package/dist/primitives/elicitation/initialElicitationDraft.d.ts.map +1 -0
  46. package/dist/primitives/elicitation/initialElicitationDraft.js +27 -0
  47. package/dist/primitives/elicitation/initialElicitationDraft.js.map +1 -0
  48. package/dist/primitives/elicitation/prepareElicitationContent.d.ts +9 -0
  49. package/dist/primitives/elicitation/prepareElicitationContent.d.ts.map +1 -0
  50. package/dist/primitives/elicitation/prepareElicitationContent.js +46 -0
  51. package/dist/primitives/elicitation/prepareElicitationContent.js.map +1 -0
  52. package/dist/primitives/elicitation.d.ts +15 -0
  53. package/dist/primitives/elicitation.d.ts.map +1 -0
  54. package/dist/primitives/elicitation.js +27 -0
  55. package/dist/primitives/elicitation.js.map +1 -0
  56. package/dist/resources/McpManagerResource.d.ts.map +1 -1
  57. package/dist/resources/McpManagerResource.js +17 -3
  58. package/dist/resources/McpManagerResource.js.map +1 -1
  59. package/dist/resources/McpServerResource.d.ts +4 -0
  60. package/dist/resources/McpServerResource.d.ts.map +1 -1
  61. package/dist/resources/McpServerResource.js +153 -42
  62. package/dist/resources/McpServerResource.js.map +1 -1
  63. package/dist/resources/storage/McpLocalStorage.js +1 -1
  64. package/dist/resources/storage/McpLocalStorage.js.map +1 -1
  65. package/dist/resources/validateElicitationContent.d.ts +9 -0
  66. package/dist/resources/validateElicitationContent.d.ts.map +1 -0
  67. package/dist/resources/validateElicitationContent.js +47 -0
  68. package/dist/resources/validateElicitationContent.js.map +1 -0
  69. package/package.json +7 -6
  70. package/src/auth/createOAuthProvider.ts +2 -2
  71. package/src/auth/types.ts +1 -1
  72. package/src/index.ts +4 -0
  73. package/src/mcp-scope.ts +32 -0
  74. package/src/primitives/elicitation/McpElicitationPrimitiveAccept.tsx +48 -0
  75. package/src/primitives/elicitation/McpElicitationPrimitiveCancel.tsx +37 -0
  76. package/src/primitives/elicitation/McpElicitationPrimitiveDecline.tsx +37 -0
  77. package/src/primitives/elicitation/McpElicitationPrimitiveError.tsx +31 -0
  78. package/src/primitives/elicitation/McpElicitationPrimitiveFields.tsx +56 -0
  79. package/src/primitives/elicitation/McpElicitationPrimitiveItems.tsx +58 -0
  80. package/src/primitives/elicitation/McpElicitationPrimitiveMessage.tsx +26 -0
  81. package/src/primitives/elicitation/McpElicitationPrimitiveRoot.tsx +24 -0
  82. package/src/primitives/elicitation/context.tsx +45 -0
  83. package/src/primitives/elicitation/initialElicitationDraft.test.ts +34 -0
  84. package/src/primitives/elicitation/initialElicitationDraft.ts +39 -0
  85. package/src/primitives/elicitation/prepareElicitationContent.test.ts +415 -0
  86. package/src/primitives/elicitation/prepareElicitationContent.ts +116 -0
  87. package/src/primitives/elicitation.ts +14 -0
  88. package/src/resources/McpManagerResource.test.ts +173 -3
  89. package/src/resources/McpManagerResource.ts +37 -3
  90. package/src/resources/McpServerResource.test.ts +735 -4
  91. package/src/resources/McpServerResource.ts +237 -29
  92. package/src/resources/storage/McpLocalStorage.ts +1 -1
  93. package/src/resources/validateElicitationContent.test.ts +123 -0
  94. package/src/resources/validateElicitationContent.ts +79 -0
@@ -30,6 +30,14 @@ const mocks = vi.hoisted(() => {
30
30
  this.callTool = vi.fn();
31
31
  this.listResources = vi.fn(() => Promise.resolve({ resources: [] }));
32
32
  this.readResource = vi.fn();
33
+ this.requestHandlers = new Map();
34
+ this.notificationHandlers = new Map();
35
+ this.setRequestHandler = vi.fn((method, handler) => {
36
+ this.requestHandlers.set(method, handler);
37
+ });
38
+ this.setNotificationHandler = vi.fn((method, handler) => {
39
+ this.notificationHandlers.set(method, handler);
40
+ });
33
41
  clients.push(this);
34
42
  });
35
43
 
@@ -56,11 +64,9 @@ const mocks = vi.hoisted(() => {
56
64
  };
57
65
  });
58
66
 
59
- vi.mock("@modelcontextprotocol/sdk/client/index.js", () => ({
67
+ vi.mock("@modelcontextprotocol/client", async (importOriginal) => ({
68
+ ...(await importOriginal()),
60
69
  Client: mocks.Client,
61
- }));
62
-
63
- vi.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
64
70
  StreamableHTTPClientTransport: mocks.StreamableHTTPClientTransport,
65
71
  }));
66
72
 
@@ -92,6 +98,33 @@ const waitFor = async (predicate: () => boolean) => {
92
98
  expect(predicate()).toBe(true);
93
99
  };
94
100
 
101
+ const waitForResourceUpdate = async (predicate: () => boolean) => {
102
+ for (let i = 0; i < 20; i++) {
103
+ if (predicate()) return;
104
+ await flushMacrotask();
105
+ }
106
+ expect(predicate()).toBe(true);
107
+ };
108
+
109
+ const requestElicitation = (
110
+ client: any,
111
+ message: string,
112
+ requestedSchema: unknown,
113
+ context: { signal: AbortSignal } = {
114
+ signal: new AbortController().signal,
115
+ },
116
+ ) => {
117
+ const handler = client.requestHandlers.get("elicitation/create");
118
+ if (!handler) throw new Error("elicitation/create handler not registered");
119
+ return handler(
120
+ {
121
+ method: "elicitation/create",
122
+ params: { message, requestedSchema },
123
+ },
124
+ context,
125
+ );
126
+ };
127
+
95
128
  const createStorage = (): MCPStorage => ({
96
129
  loadCustomServers: vi.fn(async () => []),
97
130
  saveCustomServers: vi.fn(async () => {}),
@@ -115,6 +148,8 @@ const mount = (
115
148
  props?: {
116
149
  auth?: MCPAuthConfig | undefined;
117
150
  connectionTimeout?: number | undefined;
151
+ cache?: { readonly defaultTtlMs?: number } | undefined;
152
+ elicitation?: boolean | undefined;
118
153
  },
119
154
  onMount?: (server: ClientOutput<"mcpServer">) => void,
120
155
  ) => {
@@ -133,6 +168,10 @@ const mount = (
133
168
  redirectUri: "https://example.com/callback",
134
169
  autoConnect: false,
135
170
  connectionTimeout,
171
+ cache: props?.cache,
172
+ ...(props?.elicitation !== undefined
173
+ ? { elicitation: props.elicitation }
174
+ : {}),
136
175
  onRemove: vi.fn(async () => {}),
137
176
  }),
138
177
  );
@@ -469,6 +508,698 @@ describe("McpServerResource completeAuth", () => {
469
508
  });
470
509
  });
471
510
 
511
+ describe("McpServerResource elicitation", () => {
512
+ beforeEach(resetMocks);
513
+
514
+ it("surfaces pending elicitation requests in server state", async () => {
515
+ const root = mount();
516
+ const requestedSchema = {
517
+ type: "object",
518
+ properties: {
519
+ answer: { type: "string" },
520
+ },
521
+ };
522
+
523
+ try {
524
+ await root.getValue().connect();
525
+ const response = requestElicitation(
526
+ mocks.clients[0],
527
+ "Provide an answer",
528
+ requestedSchema,
529
+ );
530
+ await waitForResourceUpdate(
531
+ () => root.getValue().getState().pendingElicitations.length === 1,
532
+ );
533
+
534
+ const [elicitation] = root.getValue().getState().pendingElicitations;
535
+ expect(elicitation).toEqual({
536
+ id: expect.any(String),
537
+ message: "Provide an answer",
538
+ requestedSchema,
539
+ });
540
+
541
+ root.getValue().answerElicitation(elicitation!.id, {
542
+ action: "cancel",
543
+ });
544
+ await expect(response).resolves.toEqual({ action: "cancel" });
545
+ } finally {
546
+ root.unmount();
547
+ }
548
+ });
549
+
550
+ it("answers pending elicitations with accepted content", async () => {
551
+ const root = mount();
552
+
553
+ try {
554
+ await root.getValue().connect();
555
+ const response = requestElicitation(mocks.clients[0], "Choose a color", {
556
+ type: "object",
557
+ properties: {
558
+ color: { type: "string" },
559
+ },
560
+ });
561
+ await waitForResourceUpdate(
562
+ () => root.getValue().getState().pendingElicitations.length === 1,
563
+ );
564
+ const [elicitation] = root.getValue().getState().pendingElicitations;
565
+ const content = { color: "blue" };
566
+
567
+ expect(
568
+ root
569
+ .getValue()
570
+ .answerElicitation(elicitation!.id, { action: "accept", content }),
571
+ ).toBeUndefined();
572
+
573
+ await expect(response).resolves.toEqual({
574
+ action: "accept",
575
+ content,
576
+ });
577
+ await waitForResourceUpdate(
578
+ () => root.getValue().getState().pendingElicitations.length === 0,
579
+ );
580
+ } finally {
581
+ root.unmount();
582
+ }
583
+ });
584
+
585
+ it("keeps invalid accepted content pending until a valid response arrives", async () => {
586
+ const root = mount();
587
+
588
+ try {
589
+ await root.getValue().connect();
590
+ const response = requestElicitation(mocks.clients[0], "Choose a color", {
591
+ type: "object",
592
+ required: ["color"],
593
+ properties: {
594
+ color: { type: "string" },
595
+ },
596
+ });
597
+ let resolved = false;
598
+ void response.then(() => {
599
+ resolved = true;
600
+ });
601
+ await waitForResourceUpdate(
602
+ () => root.getValue().getState().pendingElicitations.length === 1,
603
+ );
604
+ const [elicitation] = root.getValue().getState().pendingElicitations;
605
+
606
+ expect(
607
+ root.getValue().answerElicitation(elicitation!.id, {
608
+ action: "accept",
609
+ content: { color: 1 },
610
+ }),
611
+ ).toEqual([{ property: "color", message: "Expected a string." }]);
612
+
613
+ await waitForResourceUpdate(
614
+ () =>
615
+ root.getValue().getState().pendingElicitations[0]?.error?.message ===
616
+ "Invalid elicitation content: color.",
617
+ );
618
+ expect(root.getValue().getState().pendingElicitations).toEqual([
619
+ expect.objectContaining({
620
+ id: elicitation!.id,
621
+ error: {
622
+ message: "Invalid elicitation content: color.",
623
+ properties: ["color"],
624
+ },
625
+ }),
626
+ ]);
627
+ await tick();
628
+ expect(resolved).toBe(false);
629
+
630
+ expect(
631
+ root.getValue().answerElicitation(elicitation!.id, {
632
+ action: "accept",
633
+ content: { color: "blue" },
634
+ }),
635
+ ).toBeUndefined();
636
+
637
+ await expect(response).resolves.toEqual({
638
+ action: "accept",
639
+ content: { color: "blue" },
640
+ });
641
+ await waitForResourceUpdate(
642
+ () => root.getValue().getState().pendingElicitations.length === 0,
643
+ );
644
+ } finally {
645
+ root.unmount();
646
+ }
647
+ });
648
+
649
+ it("declines an elicitation that carries a validation error", async () => {
650
+ const root = mount();
651
+
652
+ try {
653
+ await root.getValue().connect();
654
+ const response = requestElicitation(mocks.clients[0], "Choose a color", {
655
+ type: "object",
656
+ required: ["color"],
657
+ properties: {
658
+ color: { type: "string" },
659
+ },
660
+ });
661
+ await waitForResourceUpdate(
662
+ () => root.getValue().getState().pendingElicitations.length === 1,
663
+ );
664
+ const [elicitation] = root.getValue().getState().pendingElicitations;
665
+
666
+ root.getValue().answerElicitation(elicitation!.id, {
667
+ action: "accept",
668
+ content: {},
669
+ });
670
+ await waitForResourceUpdate(
671
+ () =>
672
+ root.getValue().getState().pendingElicitations[0]?.error !==
673
+ undefined,
674
+ );
675
+
676
+ root.getValue().answerElicitation(elicitation!.id, { action: "decline" });
677
+
678
+ await expect(response).resolves.toEqual({ action: "decline" });
679
+ await waitForResourceUpdate(
680
+ () => root.getValue().getState().pendingElicitations.length === 0,
681
+ );
682
+ } finally {
683
+ root.unmount();
684
+ }
685
+ });
686
+
687
+ it("declines pending elicitations", async () => {
688
+ const root = mount();
689
+
690
+ try {
691
+ await root.getValue().connect();
692
+ const response = requestElicitation(mocks.clients[0], "Confirm access", {
693
+ type: "object",
694
+ properties: {},
695
+ });
696
+ await waitForResourceUpdate(
697
+ () => root.getValue().getState().pendingElicitations.length === 1,
698
+ );
699
+ const [elicitation] = root.getValue().getState().pendingElicitations;
700
+
701
+ root.getValue().answerElicitation(elicitation!.id, {
702
+ action: "decline",
703
+ });
704
+
705
+ await expect(response).resolves.toEqual({ action: "decline" });
706
+ await waitForResourceUpdate(
707
+ () => root.getValue().getState().pendingElicitations.length === 0,
708
+ );
709
+ } finally {
710
+ root.unmount();
711
+ }
712
+ });
713
+
714
+ it("cancels pending elicitations", async () => {
715
+ const root = mount();
716
+
717
+ try {
718
+ await root.getValue().connect();
719
+ const response = requestElicitation(mocks.clients[0], "Confirm access", {
720
+ type: "object",
721
+ properties: {},
722
+ });
723
+ await waitForResourceUpdate(
724
+ () => root.getValue().getState().pendingElicitations.length === 1,
725
+ );
726
+ const [elicitation] = root.getValue().getState().pendingElicitations;
727
+
728
+ root.getValue().answerElicitation(elicitation!.id, {
729
+ action: "cancel",
730
+ });
731
+
732
+ await expect(response).resolves.toEqual({ action: "cancel" });
733
+ await waitForResourceUpdate(
734
+ () => root.getValue().getState().pendingElicitations.length === 0,
735
+ );
736
+ } finally {
737
+ root.unmount();
738
+ }
739
+ });
740
+
741
+ it("ignores answers for unknown elicitations", () => {
742
+ const root = mount();
743
+
744
+ try {
745
+ expect(
746
+ root
747
+ .getValue()
748
+ .answerElicitation("missing-elicitation", { action: "cancel" }),
749
+ ).toBeUndefined();
750
+ expect(root.getValue().getState().pendingElicitations).toEqual([]);
751
+ } finally {
752
+ root.unmount();
753
+ }
754
+ });
755
+
756
+ it("returns validation errors for accepted elicitation content that is not an object", async () => {
757
+ const root = mount();
758
+
759
+ try {
760
+ await root.getValue().connect();
761
+ const response = requestElicitation(mocks.clients[0], "Confirm access", {
762
+ type: "object",
763
+ properties: {},
764
+ });
765
+ await waitForResourceUpdate(
766
+ () => root.getValue().getState().pendingElicitations.length === 1,
767
+ );
768
+ const [elicitation] = root.getValue().getState().pendingElicitations;
769
+
770
+ expect(
771
+ root.getValue().answerElicitation(elicitation!.id, {
772
+ action: "accept",
773
+ content: null as never,
774
+ }),
775
+ ).toEqual([
776
+ { property: "content", message: "Response content must be an object." },
777
+ ]);
778
+ await waitForResourceUpdate(
779
+ () =>
780
+ root.getValue().getState().pendingElicitations[0]?.error?.message ===
781
+ "Invalid elicitation content: content.",
782
+ );
783
+ expect(root.getValue().getState().pendingElicitations).toEqual([
784
+ expect.objectContaining({
785
+ id: elicitation!.id,
786
+ error: {
787
+ message: "Invalid elicitation content: content.",
788
+ properties: ["content"],
789
+ },
790
+ }),
791
+ ]);
792
+
793
+ await root.getValue().disconnect();
794
+ await expect(response).resolves.toEqual({ action: "cancel" });
795
+ } finally {
796
+ root.unmount();
797
+ }
798
+ });
799
+
800
+ it("cancels pending elicitations on disconnect", async () => {
801
+ const root = mount();
802
+
803
+ try {
804
+ await root.getValue().connect();
805
+ const response = requestElicitation(mocks.clients[0], "Confirm access", {
806
+ type: "object",
807
+ properties: {},
808
+ });
809
+ await waitForResourceUpdate(
810
+ () => root.getValue().getState().pendingElicitations.length === 1,
811
+ );
812
+
813
+ await root.getValue().disconnect();
814
+
815
+ await expect(response).resolves.toEqual({ action: "cancel" });
816
+ await waitForResourceUpdate(
817
+ () => root.getValue().getState().pendingElicitations.length === 0,
818
+ );
819
+ } finally {
820
+ root.unmount();
821
+ }
822
+ });
823
+
824
+ it("cancels pending elicitations when the server aborts the request", async () => {
825
+ const root = mount();
826
+ const controller = new AbortController();
827
+
828
+ try {
829
+ await root.getValue().connect();
830
+ const response = requestElicitation(
831
+ mocks.clients[0],
832
+ "Confirm access",
833
+ {
834
+ type: "object",
835
+ properties: {},
836
+ },
837
+ { signal: controller.signal },
838
+ );
839
+ await waitForResourceUpdate(
840
+ () => root.getValue().getState().pendingElicitations.length === 1,
841
+ );
842
+
843
+ controller.abort();
844
+
845
+ await expect(response).resolves.toEqual({ action: "cancel" });
846
+ await waitForResourceUpdate(
847
+ () => root.getValue().getState().pendingElicitations.length === 0,
848
+ );
849
+ } finally {
850
+ root.unmount();
851
+ }
852
+ });
853
+
854
+ it("cancels pending elicitations on unmount", async () => {
855
+ const root = mount();
856
+ let didUnmount = false;
857
+
858
+ try {
859
+ await root.getValue().connect();
860
+ const response = requestElicitation(mocks.clients[0], "Confirm access", {
861
+ type: "object",
862
+ properties: {},
863
+ });
864
+ await waitForResourceUpdate(
865
+ () => root.getValue().getState().pendingElicitations.length === 1,
866
+ );
867
+
868
+ root.unmount();
869
+ didUnmount = true;
870
+ await flushMacrotask();
871
+
872
+ await expect(response).resolves.toEqual({ action: "cancel" });
873
+ } finally {
874
+ if (!didUnmount) root.unmount();
875
+ }
876
+ });
877
+
878
+ it("cancels requests from stale connections without surfacing them", async () => {
879
+ const root = mount();
880
+
881
+ try {
882
+ await root.getValue().connect();
883
+ await root.getValue().connect();
884
+
885
+ await expect(
886
+ requestElicitation(mocks.clients[0], "Confirm access", {
887
+ type: "object",
888
+ properties: {},
889
+ }),
890
+ ).resolves.toEqual({ action: "cancel" });
891
+ expect(root.getValue().getState().pendingElicitations).toEqual([]);
892
+ } finally {
893
+ root.unmount();
894
+ }
895
+ });
896
+
897
+ it("advertises form elicitation capability and registers its handler by default", async () => {
898
+ const root = mount();
899
+
900
+ try {
901
+ await root.getValue().connect();
902
+
903
+ expect(mocks.Client).toHaveBeenCalledWith(
904
+ {
905
+ name: "assistant-ui-mcp",
906
+ version: "0.0.0",
907
+ },
908
+ {
909
+ capabilities: {
910
+ elicitation: {},
911
+ },
912
+ listChanged: {
913
+ tools: {
914
+ autoRefresh: true,
915
+ debounceMs: 300,
916
+ onChanged: expect.any(Function),
917
+ },
918
+ },
919
+ },
920
+ );
921
+ expect(mocks.clients[0].setRequestHandler).toHaveBeenCalledWith(
922
+ "elicitation/create",
923
+ expect.any(Function),
924
+ );
925
+ } finally {
926
+ root.unmount();
927
+ }
928
+ });
929
+
930
+ it("does not advertise or handle elicitation when opted out", async () => {
931
+ const root = mount({ elicitation: false });
932
+
933
+ try {
934
+ await root.getValue().connect();
935
+
936
+ const clientOptions = mocks.Client.mock.calls[0]?.[1];
937
+ expect(clientOptions).not.toHaveProperty("capabilities.elicitation");
938
+ expect(mocks.clients[0].setRequestHandler).not.toHaveBeenCalledWith(
939
+ "elicitation/create",
940
+ expect.any(Function),
941
+ );
942
+ } finally {
943
+ root.unmount();
944
+ }
945
+ });
946
+ });
947
+
948
+ describe("McpServerResource tools listChanged", () => {
949
+ beforeEach(resetMocks);
950
+
951
+ it("opts into automatic listChanged tool refreshes", async () => {
952
+ const root = mount();
953
+
954
+ try {
955
+ await root.getValue().connect();
956
+
957
+ expect(mocks.Client).toHaveBeenCalledWith(
958
+ {
959
+ name: "assistant-ui-mcp",
960
+ version: "0.0.0",
961
+ },
962
+ {
963
+ capabilities: {
964
+ elicitation: {},
965
+ },
966
+ listChanged: {
967
+ tools: {
968
+ autoRefresh: true,
969
+ debounceMs: 300,
970
+ onChanged: expect.any(Function),
971
+ },
972
+ },
973
+ },
974
+ );
975
+ expect(mocks.clients[0].setNotificationHandler).not.toHaveBeenCalled();
976
+ } finally {
977
+ root.unmount();
978
+ }
979
+ });
980
+
981
+ it("applies refreshed tools from the listChanged callback", async () => {
982
+ mocks.listToolsResults.push(async () => ({
983
+ tools: [
984
+ {
985
+ name: "search",
986
+ description: "Search docs",
987
+ inputSchema: { type: "object" },
988
+ },
989
+ ],
990
+ }));
991
+ const root = mount();
992
+
993
+ try {
994
+ await root.getValue().connect();
995
+ await waitForResourceUpdate(
996
+ () => root.getValue().getState().tools.length === 1,
997
+ );
998
+ expect(root.getValue().getState().tools).toEqual([
999
+ {
1000
+ name: "search",
1001
+ description: "Search docs",
1002
+ inputSchema: { type: "object" },
1003
+ },
1004
+ ]);
1005
+
1006
+ const onChanged =
1007
+ mocks.Client.mock.calls[0]?.[1]?.listChanged?.tools?.onChanged;
1008
+ if (!onChanged) throw new Error("Expected tools listChanged callback");
1009
+ onChanged(null, [
1010
+ {
1011
+ name: "search",
1012
+ description: "Search docs",
1013
+ inputSchema: { type: "object" },
1014
+ },
1015
+ {
1016
+ name: "summarize",
1017
+ inputSchema: { type: "object", properties: {} },
1018
+ },
1019
+ ]);
1020
+ await waitForResourceUpdate(
1021
+ () => root.getValue().getState().tools.length === 2,
1022
+ );
1023
+
1024
+ expect(mocks.clients[0].listTools).toHaveBeenCalledTimes(1);
1025
+ expect(root.getValue().getState().tools).toEqual([
1026
+ {
1027
+ name: "search",
1028
+ description: "Search docs",
1029
+ inputSchema: { type: "object" },
1030
+ },
1031
+ {
1032
+ name: "summarize",
1033
+ inputSchema: { type: "object", properties: {} },
1034
+ },
1035
+ ]);
1036
+ } finally {
1037
+ root.unmount();
1038
+ }
1039
+ });
1040
+
1041
+ it("stores listChanged errors without replacing the current tools", async () => {
1042
+ mocks.listToolsResults.push(async () => ({
1043
+ tools: [
1044
+ {
1045
+ name: "search",
1046
+ description: "Search docs",
1047
+ inputSchema: { type: "object" },
1048
+ },
1049
+ ],
1050
+ }));
1051
+ const root = mount();
1052
+
1053
+ try {
1054
+ await root.getValue().connect();
1055
+ await waitForResourceUpdate(
1056
+ () => root.getValue().getState().tools.length === 1,
1057
+ );
1058
+ const onChanged =
1059
+ mocks.Client.mock.calls[0]?.[1]?.listChanged?.tools?.onChanged;
1060
+ if (!onChanged) throw new Error("Expected tools listChanged callback");
1061
+
1062
+ onChanged(new Error("tool update failed"), null);
1063
+ await waitForResourceUpdate(
1064
+ () =>
1065
+ root.getValue().getState().lastError?.message ===
1066
+ "tool update failed",
1067
+ );
1068
+
1069
+ expect(root.getValue().getState()).toMatchObject({
1070
+ connectionState: "connected",
1071
+ lastError: { message: "tool update failed" },
1072
+ tools: [
1073
+ {
1074
+ name: "search",
1075
+ description: "Search docs",
1076
+ inputSchema: { type: "object" },
1077
+ },
1078
+ ],
1079
+ });
1080
+
1081
+ onChanged(null, [
1082
+ {
1083
+ name: "summarize",
1084
+ inputSchema: { type: "object", properties: {} },
1085
+ },
1086
+ ]);
1087
+ await waitForResourceUpdate(
1088
+ () => root.getValue().getState().lastError === null,
1089
+ );
1090
+
1091
+ expect(root.getValue().getState().tools).toEqual([
1092
+ {
1093
+ name: "summarize",
1094
+ inputSchema: { type: "object", properties: {} },
1095
+ },
1096
+ ]);
1097
+ } finally {
1098
+ root.unmount();
1099
+ }
1100
+ });
1101
+
1102
+ it("ignores listChanged callbacks from a stale connection generation", async () => {
1103
+ mocks.listToolsResults.push(async () => ({
1104
+ tools: [
1105
+ {
1106
+ name: "first",
1107
+ inputSchema: { type: "object" },
1108
+ },
1109
+ ],
1110
+ }));
1111
+ mocks.listToolsResults.push(async () => ({
1112
+ tools: [
1113
+ {
1114
+ name: "second",
1115
+ inputSchema: { type: "object" },
1116
+ },
1117
+ ],
1118
+ }));
1119
+ const root = mount();
1120
+
1121
+ try {
1122
+ await root.getValue().connect();
1123
+ const staleOnChanged =
1124
+ mocks.Client.mock.calls[0]?.[1]?.listChanged?.tools?.onChanged;
1125
+ if (!staleOnChanged)
1126
+ throw new Error("Expected tools listChanged callback");
1127
+ await root.getValue().connect();
1128
+ await waitForResourceUpdate(
1129
+ () => root.getValue().getState().tools[0]?.name === "second",
1130
+ );
1131
+
1132
+ expect(root.getValue().getState().tools).toEqual([
1133
+ {
1134
+ name: "second",
1135
+ inputSchema: { type: "object" },
1136
+ },
1137
+ ]);
1138
+
1139
+ staleOnChanged(null, [
1140
+ {
1141
+ name: "stale-tool",
1142
+ inputSchema: { type: "object" },
1143
+ },
1144
+ ]);
1145
+ await flushMacrotask();
1146
+
1147
+ expect(root.getValue().getState().tools).toEqual([
1148
+ {
1149
+ name: "second",
1150
+ inputSchema: { type: "object" },
1151
+ },
1152
+ ]);
1153
+ expect(mocks.clients[0].listTools).toHaveBeenCalledTimes(1);
1154
+ } finally {
1155
+ root.unmount();
1156
+ }
1157
+ });
1158
+
1159
+ it("passes defaultCacheTtlMs when cache.defaultTtlMs is configured", async () => {
1160
+ const root = mount({ cache: { defaultTtlMs: 5_000 } });
1161
+
1162
+ try {
1163
+ await root.getValue().connect();
1164
+
1165
+ expect(mocks.Client).toHaveBeenCalledWith(
1166
+ {
1167
+ name: "assistant-ui-mcp",
1168
+ version: "0.0.0",
1169
+ },
1170
+ {
1171
+ capabilities: {
1172
+ elicitation: {},
1173
+ },
1174
+ listChanged: {
1175
+ tools: {
1176
+ autoRefresh: true,
1177
+ debounceMs: 300,
1178
+ onChanged: expect.any(Function),
1179
+ },
1180
+ },
1181
+ defaultCacheTtlMs: 5_000,
1182
+ },
1183
+ );
1184
+ } finally {
1185
+ root.unmount();
1186
+ }
1187
+ });
1188
+
1189
+ it("omits defaultCacheTtlMs when cache is not configured", async () => {
1190
+ const root = mount();
1191
+
1192
+ try {
1193
+ await root.getValue().connect();
1194
+
1195
+ const options = mocks.Client.mock.calls[0]?.[1];
1196
+ expect(options).not.toHaveProperty("defaultCacheTtlMs");
1197
+ } finally {
1198
+ root.unmount();
1199
+ }
1200
+ });
1201
+ });
1202
+
472
1203
  describe("McpServerResource resource methods", () => {
473
1204
  beforeEach(() => {
474
1205
  mocks.clients.length = 0;