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.
Unified · Split
| @@ -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-accepts | 128 | // 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 | |||
| 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 | |||
| 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 catch | 624 | // 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; |
| @@ -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 | ||
| 41 | const elicitationRequestToSchema: ( | 38 | type 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 | |||
| 42 | const 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 | ||
| 71 | const makeMcpElicitationHandler = (server: McpServer): ElicitationHandler => | 63 | const 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 { |