@assistant-ui/react-mcp 0.0.22 → 0.1.1
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 +1 -1
- package/dist/auth/createOAuthProvider.js.map +1 -1
- package/dist/auth/types.d.ts +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/mcp-scope.d.ts +32 -1
- package/dist/mcp-scope.d.ts.map +1 -1
- package/dist/primitives/elicitation/McpElicitationPrimitiveAccept.d.ts +13 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveAccept.d.ts.map +1 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveAccept.js +106 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveAccept.js.map +1 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveCancel.d.ts +13 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveCancel.d.ts.map +1 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveCancel.js +43 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveCancel.js.map +1 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveDecline.d.ts +13 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveDecline.d.ts.map +1 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveDecline.js +43 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveDecline.js.map +1 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveError.d.ts +13 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveError.d.ts.map +1 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveError.js +38 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveError.js.map +1 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveFields.d.ts +12 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveFields.d.ts.map +1 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveFields.js +53 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveFields.js.map +1 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveItems.d.ts +13 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveItems.d.ts.map +1 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveItems.js +96 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveItems.js.map +1 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveMessage.d.ts +13 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveMessage.d.ts.map +1 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveMessage.js +29 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveMessage.js.map +1 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveRoot.d.ts +13 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveRoot.d.ts.map +1 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveRoot.js +28 -0
- package/dist/primitives/elicitation/McpElicitationPrimitiveRoot.js.map +1 -0
- package/dist/primitives/elicitation/context.d.ts +21 -0
- package/dist/primitives/elicitation/context.d.ts.map +1 -0
- package/dist/primitives/elicitation/context.js +21 -0
- package/dist/primitives/elicitation/context.js.map +1 -0
- package/dist/primitives/elicitation/initialElicitationDraft.d.ts +5 -0
- package/dist/primitives/elicitation/initialElicitationDraft.d.ts.map +1 -0
- package/dist/primitives/elicitation/initialElicitationDraft.js +27 -0
- package/dist/primitives/elicitation/initialElicitationDraft.js.map +1 -0
- package/dist/primitives/elicitation/prepareElicitationContent.d.ts +9 -0
- package/dist/primitives/elicitation/prepareElicitationContent.d.ts.map +1 -0
- package/dist/primitives/elicitation/prepareElicitationContent.js +46 -0
- package/dist/primitives/elicitation/prepareElicitationContent.js.map +1 -0
- package/dist/primitives/elicitation.d.ts +15 -0
- package/dist/primitives/elicitation.d.ts.map +1 -0
- package/dist/primitives/elicitation.js +27 -0
- package/dist/primitives/elicitation.js.map +1 -0
- package/dist/resources/McpManagerResource.d.ts.map +1 -1
- package/dist/resources/McpManagerResource.js +17 -3
- package/dist/resources/McpManagerResource.js.map +1 -1
- package/dist/resources/McpServerResource.d.ts +4 -0
- package/dist/resources/McpServerResource.d.ts.map +1 -1
- package/dist/resources/McpServerResource.js +153 -42
- package/dist/resources/McpServerResource.js.map +1 -1
- package/dist/resources/storage/McpLocalStorage.js +1 -1
- package/dist/resources/storage/McpLocalStorage.js.map +1 -1
- package/dist/resources/validateElicitationContent.d.ts +9 -0
- package/dist/resources/validateElicitationContent.d.ts.map +1 -0
- package/dist/resources/validateElicitationContent.js +47 -0
- package/dist/resources/validateElicitationContent.js.map +1 -0
- package/package.json +7 -6
- package/src/auth/createOAuthProvider.ts +2 -2
- package/src/auth/types.ts +1 -1
- package/src/index.ts +4 -0
- package/src/mcp-scope.ts +32 -0
- package/src/primitives/elicitation/McpElicitationPrimitiveAccept.tsx +48 -0
- package/src/primitives/elicitation/McpElicitationPrimitiveCancel.tsx +37 -0
- package/src/primitives/elicitation/McpElicitationPrimitiveDecline.tsx +37 -0
- package/src/primitives/elicitation/McpElicitationPrimitiveError.tsx +31 -0
- package/src/primitives/elicitation/McpElicitationPrimitiveFields.tsx +56 -0
- package/src/primitives/elicitation/McpElicitationPrimitiveItems.tsx +58 -0
- package/src/primitives/elicitation/McpElicitationPrimitiveMessage.tsx +26 -0
- package/src/primitives/elicitation/McpElicitationPrimitiveRoot.tsx +24 -0
- package/src/primitives/elicitation/context.tsx +45 -0
- package/src/primitives/elicitation/initialElicitationDraft.test.ts +34 -0
- package/src/primitives/elicitation/initialElicitationDraft.ts +39 -0
- package/src/primitives/elicitation/prepareElicitationContent.test.ts +415 -0
- package/src/primitives/elicitation/prepareElicitationContent.ts +116 -0
- package/src/primitives/elicitation.ts +14 -0
- package/src/resources/McpManagerResource.test.ts +173 -3
- package/src/resources/McpManagerResource.ts +37 -3
- package/src/resources/McpServerResource.test.ts +735 -4
- package/src/resources/McpServerResource.ts +237 -29
- package/src/resources/storage/McpLocalStorage.ts +1 -1
- package/src/resources/validateElicitationContent.test.ts +123 -0
- 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/
|
|
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;
|