History

support native url elicitation mode in MCP host

Previously UrlElicitation was converted to a form with a _url_hint property workaround. Now the server uses the MCP SDK's native mode:"url" elicitation and requires both form and url client capabilities before enabling inline elicitation. Clients with only form support fall back to the pause/resume path.

Rhys Sullivan committed 7 months ago

Commit 7616ccf1 parent 5bca7c92 files · +72 −48Browse files

Unified · Split

2 files
  1. packages/hosts/mcp/src/server.test.ts +61 −29
  2. packages/hosts/mcp/src/server.ts +11 −19
packages/hosts/mcp/src/server.test.ts+61−29
@@ -82,7 +82,7 @@ describe("MCP host server — client with elicitation", () => { · 81 unmodified lines
8282 });
8383
8484 const { client, close } = await connect(engine, {
85− elicitation: { form: {} },
85+ elicitation: { form: {}, url: {} },
8686 });
8787
8888 try {
@@ -122,7 +122,7 @@ describe("MCP host server — client with elicitation", () => { · 33 unmodified lines
122122 });
123123
124124 const { client, close } = await connect(engine, {
125− elicitation: { form: {} },
125+ elicitation: { form: {}, url: {} },
126126 });
127127
128128 // Register a client-side handler that auto-accepts
@@ -160,7 +160,7 @@ describe("MCP host server — client with elicitation", () => { · 31 unmodified lines
160160 });
161161
162162 const { client, close } = await connect(engine, {
163− elicitation: { form: {} },
163+ elicitation: { form: {}, url: {} },
164164 });
165165
166166 client.setRequestHandler(ElicitRequestSchema, async () => ({
@@ -201,7 +201,7 @@ describe("MCP host server — client with elicitation", () => { · 34 unmodified lines
201201 });
202202
203203 const { client, close } = await connect(engine, {
204− elicitation: { form: {} },
204+ elicitation: { form: {}, url: {} },
205205 });
206206
207207 client.setRequestHandler(ElicitRequestSchema, async (request) => {
@@ -226,9 +226,8 @@ describe("MCP host server — client with elicitation", () => { · 18 unmodified lines
226226 }
227227 });
228228
229− it("UrlElicitation is converted to a form with _url_hint property", async () => {
230− let receivedMessage: string | undefined;
231− let receivedSchema: Record<string, unknown> | undefined;
229+ it("UrlElicitation is sent as native mode:url elicitation", async () => {
230+ let receivedParams: Record<string, unknown> | undefined;
232231
233232 const engine = makeStubEngine({
234233 execute: async (_code, { onElicitation }) => {
@@ -248,15 +247,11 @@ describe("MCP host server — client with elicitation", () => { · 13 unmodified lines
248247 });
249248
250249 const { client, close } = await connect(engine, {
251− elicitation: { form: {} },
250+ elicitation: { form: {}, url: {} },
252251 });
253252
254253 client.setRequestHandler(ElicitRequestSchema, async (request) => {
255− receivedMessage = request.params.message;
256− const params = request.params;
257− if ("requestedSchema" in params) {
258− receivedSchema = params.requestedSchema as Record<string, unknown>;
259− }
254+ receivedParams = request.params as Record<string, unknown>;
260255 return { action: "accept" as const, content: {} };
261256 });
262257
@@ -265,18 +260,10 @@ describe("MCP host server — client with elicitation", () => { · 2 unmodified lines
265260 name: "execute",
266261 arguments: { code: "oauth" },
267262 });
268− expect(receivedMessage).toBe("Please authenticate");
269− expect(receivedSchema).toEqual({
270− type: "object",
271− properties: {
272− _url_hint: {
273− type: "string",
274− description:
275− "Please open this URL: https://example.com/oauth",
276− default: "https://example.com/oauth",
277− },
278− },
279− });
263+ expect(receivedParams?.mode).toBe("url");
264+ expect(receivedParams?.message).toBe("Please authenticate");
265+ expect(receivedParams?.url).toBe("https://example.com/oauth");
266+ expect(receivedParams?.elicitationId).toBe("elic-1");
280267 } finally {
281268 await close();
282269 }
@@ -292,7 +279,7 @@ describe("MCP host server — client with elicitation", () => { · 9 unmodified lines
292279 });
293280
294281 const { client, close } = await connect(engine, {
295− elicitation: { form: {} },
282+ elicitation: { form: {}, url: {} },
296283 });
297284
298285 try {
@@ -311,7 +298,7 @@ describe("MCP host server — client with elicitation", () => { · 12 unmodified lines
311298 it("resume tool is hidden when client supports elicitation", async () => {
312299 const engine = makeStubEngine({});
313300 const { client, close } = await connect(engine, {
314− elicitation: { form: {} },
301+ elicitation: { form: {}, url: {} },
315302 });
316303
317304 try {
@@ -325,6 +312,51 @@ describe("MCP host server — client with elicitation", () => { · 7 unmodified lines
325312 });
326313 });
327314
315+// ---------------------------------------------------------------------------
316+// Tests — client with form-only elicitation (falls back to pause/resume)
317+// ---------------------------------------------------------------------------
318+
319+describe("MCP host server — client with form-only elicitation", () => {
320+ it("resume tool is visible when client only supports form elicitation", async () => {
321+ const engine = makeStubEngine({});
322+ const { client, close } = await connect(engine, {
323+ elicitation: { form: {} },
324+ });
325+
326+ try {
327+ const { tools } = await client.listTools();
328+ const names = tools.map((t) => t.name);
329+ expect(names).toContain("execute");
330+ expect(names).toContain("resume");
331+ } finally {
332+ await close();
333+ }
334+ });
335+
336+ it("uses pause/resume path when client only supports form", async () => {
337+ const engine = makeStubEngine({
338+ executeWithPause: async () => ({
339+ status: "completed",
340+ result: { result: "form-only-path" },
341+ }),
342+ });
343+
344+ const { client, close } = await connect(engine, {
345+ elicitation: { form: {} },
346+ });
347+
348+ try {
349+ const result = await client.callTool({
350+ name: "execute",
351+ arguments: { code: "test" },
352+ });
353+ expect(result.content).toEqual([{ type: "text", text: "form-only-path" }]);
354+ } finally {
355+ await close();
356+ }
357+ });
358+});
359+
328360 // ---------------------------------------------------------------------------
329361 // Tests — client WITHOUT elicitation (pause/resume path)
330362 // ---------------------------------------------------------------------------
@@ -586,7 +618,7 @@ describe("MCP host server — elicitation error handling", () => { · 255 unmodified lines
586618 });
587619
588620 const { client, close } = await connect(engine, {
589− elicitation: { form: {} },
621+ elicitation: { form: {}, url: {} },
590622 });
591623
592624 // Client throws when it receives the elicitation — server should catch
@@ -717,7 +749,7 @@ describe("MCP host server — multiple elicitations", () => { · 124 unmodified lines
717749 });
718750
719751 const { client, close } = await connect(engine, {
720− elicitation: { form: {} },
752+ elicitation: { form: {}, url: {} },
721753 });
722754
723755 let callCount = 0;
packages/hosts/mcp/src/server.ts+11−19
@@ -32,28 +32,20 @@ const supportsManagedElicitation = (server: McpServer): boolean => { · 31 unmodified lines
3232 const capabilities = server.server.getClientCapabilities();
3333 if (capabilities === undefined || !capabilities.elicitation) return false;
3434 const elicitation = capabilities.elicitation as Record<string, unknown>;
35− // The MCP SDK requires `elicitation.form` for form-based elicitation.
36− // An empty `elicitation: {}` is auto-upgraded to `{ form: {} }` by the SDK's
37− // schema preprocess, but we guard explicitly in case that doesn't apply.
38− return Boolean(elicitation.form);
35+ return Boolean(elicitation.form) && Boolean(elicitation.url);
3936 };
4037
41−const elicitationRequestToSchema: (
42− request: ElicitationRequest,
43−) => { message: string; requestedSchema: { readonly [key: string]: unknown } } =
38+type ElicitInputParams =
39+ | { mode?: "form"; message: string; requestedSchema: { readonly [key: string]: unknown } }
40+ | { mode: "url"; message: string; url: string; elicitationId: string };
41+
42+const elicitationRequestToParams: (request: ElicitationRequest) => ElicitInputParams =
4443 Match.type<ElicitationRequest>().pipe(
4544 Match.tag("UrlElicitation", (req) => ({
45+ mode: "url" as const,
4646 message: req.message,
47− requestedSchema: {
48− type: "object",
49− properties: {
50− _url_hint: {
51− type: "string",
52− description: `Please open this URL: ${req.url}`,
53− default: req.url,
54− },
55− },
56− },
47+ url: req.url,
48+ elicitationId: req.elicitationId,
5749 })),
5850 Match.tag("FormElicitation", (req) => ({
5951 message: req.message,
@@ -70,12 +62,12 @@ const elicitationRequestToSchema: ( · 10 unmodified lines
7062
7163 const makeMcpElicitationHandler = (server: McpServer): ElicitationHandler =>
7264 (ctx: ElicitationContext): Effect.Effect<typeof ElicitationResponse.Type> => {
73− const { message, requestedSchema } = elicitationRequestToSchema(ctx.request);
65+ const params = elicitationRequestToParams(ctx.request);
7466
7567 return Effect.promise(async (): Promise<typeof ElicitationResponse.Type> => {
7668 try {
7769 const response = await server.server.elicitInput(
78− { message, requestedSchema } as Parameters<typeof server.server.elicitInput>[0],
70+ params as Parameters<typeof server.server.elicitInput>[0],
7971 );
8072
8173 return {