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
82 });82 });
83 83
84 const { client, close } = await connect(engine, {84 const { client, close } = await connect(engine, {
85 elicitation: { form: {} },85 elicitation: { form: {}, url: {} },
86 });86 });
87 87
88 try {88 try {
@@ -122,7 +122,7 @@ describe("MCP host server — client with elicitation", () => { · 33 unmodified lines
122 });122 });
123 123
124 const { client, close } = await connect(engine, {124 const { client, close } = await connect(engine, {
125 elicitation: { form: {} },125 elicitation: { form: {}, url: {} },
126 });126 });
127 127
128 // Register a client-side handler that auto-accepts128 // Register a client-side handler that auto-accepts
@@ -160,7 +160,7 @@ describe("MCP host server — client with elicitation", () => { · 31 unmodified lines
160 });160 });
161 161
162 const { client, close } = await connect(engine, {162 const { client, close } = await connect(engine, {
163 elicitation: { form: {} },163 elicitation: { form: {}, url: {} },
164 });164 });
165 165
166 client.setRequestHandler(ElicitRequestSchema, async () => ({166 client.setRequestHandler(ElicitRequestSchema, async () => ({
@@ -201,7 +201,7 @@ describe("MCP host server — client with elicitation", () => { · 34 unmodified lines
201 });201 });
202 202
203 const { client, close } = await connect(engine, {203 const { client, close } = await connect(engine, {
204 elicitation: { form: {} },204 elicitation: { form: {}, url: {} },
205 });205 });
206 206
207 client.setRequestHandler(ElicitRequestSchema, async (request) => {207 client.setRequestHandler(ElicitRequestSchema, async (request) => {
@@ -226,9 +226,8 @@ describe("MCP host server — client with elicitation", () => { · 18 unmodified lines
226 }226 }
227 });227 });
228 228
229 it("UrlElicitation is converted to a form with _url_hint property", async () => {229 it("UrlElicitation is sent as native mode:url elicitation", async () => {
230 let receivedMessage: string | undefined;230 let receivedParams: Record<string, unknown> | undefined;
231 let receivedSchema: Record<string, unknown> | undefined;
232 231
233 const engine = makeStubEngine({232 const engine = makeStubEngine({
234 execute: async (_code, { onElicitation }) => {233 execute: async (_code, { onElicitation }) => {
@@ -248,15 +247,11 @@ describe("MCP host server — client with elicitation", () => { · 13 unmodified lines
248 });247 });
249 248
250 const { client, close } = await connect(engine, {249 const { client, close } = await connect(engine, {
251 elicitation: { form: {} },250 elicitation: { form: {}, url: {} },
252 });251 });
253 252
254 client.setRequestHandler(ElicitRequestSchema, async (request) => {253 client.setRequestHandler(ElicitRequestSchema, async (request) => {
255 receivedMessage = request.params.message;254 receivedParams = request.params as Record<string, unknown>;
256 const params = request.params;
257 if ("requestedSchema" in params) {
258 receivedSchema = params.requestedSchema as Record<string, unknown>;
259 }
260 return { action: "accept" as const, content: {} };255 return { action: "accept" as const, content: {} };
261 });256 });
262 257
@@ -265,18 +260,10 @@ describe("MCP host server — client with elicitation", () => { · 2 unmodified lines
265 name: "execute",260 name: "execute",
266 arguments: { code: "oauth" },261 arguments: { code: "oauth" },
267 });262 });
268 expect(receivedMessage).toBe("Please authenticate");263 expect(receivedParams?.mode).toBe("url");
269 expect(receivedSchema).toEqual({264 expect(receivedParams?.message).toBe("Please authenticate");
270 type: "object",265 expect(receivedParams?.url).toBe("https://example.com/oauth");
271 properties: {266 expect(receivedParams?.elicitationId).toBe("elic-1");
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 });
280 } finally {267 } finally {
281 await close();268 await close();
282 }269 }
@@ -292,7 +279,7 @@ describe("MCP host server — client with elicitation", () => { · 9 unmodified lines
292 });279 });
293 280
294 const { client, close } = await connect(engine, {281 const { client, close } = await connect(engine, {
295 elicitation: { form: {} },282 elicitation: { form: {}, url: {} },
296 });283 });
297 284
298 try {285 try {
@@ -311,7 +298,7 @@ describe("MCP host server — client with elicitation", () => { · 12 unmodified lines
311 it("resume tool is hidden when client supports elicitation", async () => {298 it("resume tool is hidden when client supports elicitation", async () => {
312 const engine = makeStubEngine({});299 const engine = makeStubEngine({});
313 const { client, close } = await connect(engine, {300 const { client, close } = await connect(engine, {
314 elicitation: { form: {} },301 elicitation: { form: {}, url: {} },
315 });302 });
316 303
317 try {304 try {
@@ -325,6 +312,51 @@ describe("MCP host server — client with elicitation", () => { · 7 unmodified lines
325 });312 });
326});313});
327 314
315// ---------------------------------------------------------------------------
316// Tests — client with form-only elicitation (falls back to pause/resume)
317// ---------------------------------------------------------------------------
318
319describe("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
328// ---------------------------------------------------------------------------360// ---------------------------------------------------------------------------
329// Tests — client WITHOUT elicitation (pause/resume path)361// Tests — client WITHOUT elicitation (pause/resume path)
330// ---------------------------------------------------------------------------362// ---------------------------------------------------------------------------
@@ -586,7 +618,7 @@ describe("MCP host server — elicitation error handling", () => { · 255 unmodified lines
586 });618 });
587 619
588 const { client, close } = await connect(engine, {620 const { client, close } = await connect(engine, {
589 elicitation: { form: {} },621 elicitation: { form: {}, url: {} },
590 });622 });
591 623
592 // Client throws when it receives the elicitation — server should catch624 // Client throws when it receives the elicitation — server should catch
@@ -717,7 +749,7 @@ describe("MCP host server — multiple elicitations", () => { · 124 unmodified lines
717 });749 });
718 750
719 const { client, close } = await connect(engine, {751 const { client, close } = await connect(engine, {
720 elicitation: { form: {} },752 elicitation: { form: {}, url: {} },
721 });753 });
722 754
723 let callCount = 0;755 let callCount = 0;
packages/hosts/mcp/src/server.ts+11−19
@@ -32,28 +32,20 @@ const supportsManagedElicitation = (server: McpServer): boolean => { · 31 unmodified lines
32 const capabilities = server.server.getClientCapabilities();32 const capabilities = server.server.getClientCapabilities();
33 if (capabilities === undefined || !capabilities.elicitation) return false;33 if (capabilities === undefined || !capabilities.elicitation) return false;
34 const elicitation = capabilities.elicitation as Record<string, unknown>;34 const elicitation = capabilities.elicitation as Record<string, unknown>;
35 // The MCP SDK requires `elicitation.form` for form-based elicitation.35 return Boolean(elicitation.form) && Boolean(elicitation.url);
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);
39};36};
40 37
41const elicitationRequestToSchema: (38type ElicitInputParams =
42 request: ElicitationRequest,39 | { mode?: "form"; message: string; requestedSchema: { readonly [key: string]: unknown } }
43) => { message: string; requestedSchema: { readonly [key: string]: unknown } } =40 | { mode: "url"; message: string; url: string; elicitationId: string };
41
42const elicitationRequestToParams: (request: ElicitationRequest) => ElicitInputParams =
44 Match.type<ElicitationRequest>().pipe(43 Match.type<ElicitationRequest>().pipe(
45 Match.tag("UrlElicitation", (req) => ({44 Match.tag("UrlElicitation", (req) => ({
45 mode: "url" as const,
46 message: req.message,46 message: req.message,
47 requestedSchema: {47 url: req.url,
48 type: "object",48 elicitationId: req.elicitationId,
49 properties: {
50 _url_hint: {
51 type: "string",
52 description: `Please open this URL: ${req.url}`,
53 default: req.url,
54 },
55 },
56 },
57 })),49 })),
58 Match.tag("FormElicitation", (req) => ({50 Match.tag("FormElicitation", (req) => ({
59 message: req.message,51 message: req.message,
@@ -70,12 +62,12 @@ const elicitationRequestToSchema: ( · 10 unmodified lines
70 62
71const makeMcpElicitationHandler = (server: McpServer): ElicitationHandler =>63const makeMcpElicitationHandler = (server: McpServer): ElicitationHandler =>
72 (ctx: ElicitationContext): Effect.Effect<typeof ElicitationResponse.Type> => {64 (ctx: ElicitationContext): Effect.Effect<typeof ElicitationResponse.Type> => {
73 const { message, requestedSchema } = elicitationRequestToSchema(ctx.request);65 const params = elicitationRequestToParams(ctx.request);
74 66
75 return Effect.promise(async (): Promise<typeof ElicitationResponse.Type> => {67 return Effect.promise(async (): Promise<typeof ElicitationResponse.Type> => {
76 try {68 try {
77 const response = await server.server.elicitInput(69 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],
79 );71 );
80 72
81 return {73 return {