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 | 84 | const { client, close } = await connect(engine, { |
| 85 | − elicitation: { form: {} }, | |
| 85 | + elicitation: { form: {}, url: {} }, | |
| 86 | 86 | }); |
| 87 | 87 | |
| 88 | 88 | try { |
| @@ -122,7 +122,7 @@ describe("MCP host server — client with elicitation", () => { · 33 unmodified lines | ||
| 122 | 122 | }); |
| 123 | 123 | |
| 124 | 124 | const { client, close } = await connect(engine, { |
| 125 | − elicitation: { form: {} }, | |
| 125 | + elicitation: { form: {}, url: {} }, | |
| 126 | 126 | }); |
| 127 | 127 | |
| 128 | 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 | 162 | const { client, close } = await connect(engine, { |
| 163 | − elicitation: { form: {} }, | |
| 163 | + elicitation: { form: {}, url: {} }, | |
| 164 | 164 | }); |
| 165 | 165 | |
| 166 | 166 | client.setRequestHandler(ElicitRequestSchema, async () => ({ |
| @@ -201,7 +201,7 @@ describe("MCP host server — client with elicitation", () => { · 34 unmodified lines | ||
| 201 | 201 | }); |
| 202 | 202 | |
| 203 | 203 | const { client, close } = await connect(engine, { |
| 204 | − elicitation: { form: {} }, | |
| 204 | + elicitation: { form: {}, url: {} }, | |
| 205 | 205 | }); |
| 206 | 206 | |
| 207 | 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 () => { | |
| 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; | |
| 232 | 231 | |
| 233 | 232 | const engine = makeStubEngine({ |
| 234 | 233 | execute: async (_code, { onElicitation }) => { |
| @@ -248,15 +247,11 @@ describe("MCP host server — client with elicitation", () => { · 13 unmodified lines | ||
| 248 | 247 | }); |
| 249 | 248 | |
| 250 | 249 | const { client, close } = await connect(engine, { |
| 251 | − elicitation: { form: {} }, | |
| 250 | + elicitation: { form: {}, url: {} }, | |
| 252 | 251 | }); |
| 253 | 252 | |
| 254 | 253 | 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>; | |
| 260 | 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 | 260 | name: "execute", |
| 266 | 261 | arguments: { code: "oauth" }, |
| 267 | 262 | }); |
| 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"); | |
| 280 | 267 | } finally { |
| 281 | 268 | await close(); |
| 282 | 269 | } |
| @@ -292,7 +279,7 @@ describe("MCP host server — client with elicitation", () => { · 9 unmodified lines | ||
| 292 | 279 | }); |
| 293 | 280 | |
| 294 | 281 | const { client, close } = await connect(engine, { |
| 295 | − elicitation: { form: {} }, | |
| 282 | + elicitation: { form: {}, url: {} }, | |
| 296 | 283 | }); |
| 297 | 284 | |
| 298 | 285 | try { |
| @@ -311,7 +298,7 @@ describe("MCP host server — client with elicitation", () => { · 12 unmodified lines | ||
| 311 | 298 | it("resume tool is hidden when client supports elicitation", async () => { |
| 312 | 299 | const engine = makeStubEngine({}); |
| 313 | 300 | const { client, close } = await connect(engine, { |
| 314 | − elicitation: { form: {} }, | |
| 301 | + elicitation: { form: {}, url: {} }, | |
| 315 | 302 | }); |
| 316 | 303 | |
| 317 | 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 | 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 | 620 | const { client, close } = await connect(engine, { |
| 589 | − elicitation: { form: {} }, | |
| 621 | + elicitation: { form: {}, url: {} }, | |
| 590 | 622 | }); |
| 591 | 623 | |
| 592 | 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 | 751 | const { client, close } = await connect(engine, { |
| 720 | − elicitation: { form: {} }, | |
| 752 | + elicitation: { form: {}, url: {} }, | |
| 721 | 753 | }); |
| 722 | 754 | |
| 723 | 755 | let callCount = 0; |
| @@ -32,28 +32,20 @@ const supportsManagedElicitation = (server: McpServer): boolean => { · 31 unmodified lines | ||
| 32 | 32 | const capabilities = server.server.getClientCapabilities(); |
| 33 | 33 | if (capabilities === undefined || !capabilities.elicitation) return false; |
| 34 | 34 | 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); | |
| 39 | 36 | }; |
| 40 | 37 | |
| 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 = | |
| 44 | 43 | Match.type<ElicitationRequest>().pipe( |
| 45 | 44 | Match.tag("UrlElicitation", (req) => ({ |
| 45 | + mode: "url" as const, | |
| 46 | 46 | 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, | |
| 57 | 49 | })), |
| 58 | 50 | Match.tag("FormElicitation", (req) => ({ |
| 59 | 51 | message: req.message, |
| @@ -70,12 +62,12 @@ const elicitationRequestToSchema: ( · 10 unmodified lines | ||
| 70 | 62 | |
| 71 | 63 | const makeMcpElicitationHandler = (server: McpServer): ElicitationHandler => |
| 72 | 64 | (ctx: ElicitationContext): Effect.Effect<typeof ElicitationResponse.Type> => { |
| 73 | − const { message, requestedSchema } = elicitationRequestToSchema(ctx.request); | |
| 65 | + const params = elicitationRequestToParams(ctx.request); | |
| 74 | 66 | |
| 75 | 67 | return Effect.promise(async (): Promise<typeof ElicitationResponse.Type> => { |
| 76 | 68 | try { |
| 77 | 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 | 73 | return { |