@@ -5,6 +5,7 @@ import { describe, expect, test } from "bun:test"
55import { Client , InMemoryTransport , StreamableHTTPClientTransport } from "@modelcontextprotocol/client"
66import {
77 createMcpHandler ,
8+ type ElicitRequestFormParams ,
89 inputRequired ,
910 inputResponse ,
1011 Server ,
@@ -104,6 +105,7 @@ function resourceServer(
104105 malformedTool ?: boolean
105106 /** Legacy servers give up on the URL elicitation after this many milliseconds. */
106107 elicitationTimeout ?: number
108+ formElicitation ?: boolean
107109 respond ?: ( request : Request ) => Response | undefined | Promise < Response | undefined >
108110 } = { } ,
109111) {
@@ -166,7 +168,9 @@ function resourceServer(
166168 ? [ { name : "empty-elicitation" , inputSchema : { type : "object" as const , properties : { } } } ]
167169 : input . urlElicitation
168170 ? [ { name : "url-elicitation" , inputSchema : { type : "object" as const , properties : { } } } ]
169- : [ { name : "echo" , inputSchema : { type : "object" as const , properties : { } } } ] ) ,
171+ : input . formElicitation
172+ ? [ { name : "form-elicitation" , inputSchema : { type : "object" as const , properties : { } } } ]
173+ : [ { name : "echo" , inputSchema : { type : "object" as const , properties : { } } } ] ) ,
170174 ] ,
171175 } )
172176 } )
@@ -208,7 +212,51 @@ function resourceServer(
208212 }
209213 } )
210214 }
211- if ( ! input . emptyElicitation && ! input . urlElicitation ) {
215+ if ( input . formElicitation ) {
216+ // The primitive schema kinds the elicitation specification allows (client/elicitation#requested-schema):
217+ // string with constraints and a default, integer with bounds, a single-select enum, and a boolean.
218+ const requestedSchema = {
219+ type : "object" ,
220+ properties : {
221+ name : {
222+ type : "string" ,
223+ title : "Full name" ,
224+ description : "Your display name" ,
225+ minLength : 2 ,
226+ maxLength : 40 ,
227+ default : "Octocat" ,
228+ } ,
229+ age : { type : "integer" , title : "Age" , minimum : 0 , maximum : 130 } ,
230+ colour : { type : "string" , enum : [ "Red" , "Green" , "Blue" ] , default : "Green" } ,
231+ newsletter : { type : "boolean" , title : "Newsletter" , default : true } ,
232+ } ,
233+ required : [ "name" ] ,
234+ } satisfies ElicitRequestFormParams [ "requestedSchema" ]
235+ const message = "Tell us about yourself"
236+ // Modern servers cannot call elicitInput; they return input_required and the SDK client
237+ // retries the call with inputResponses (basic/patterns/mrtr).
238+ protocol . setRequestHandler ( "tools/call" , async ( request , ctx ) => {
239+ const responses = ctx . mcpReq . inputResponses
240+ if ( input . modern && ! responses ) {
241+ return inputRequired ( {
242+ inputRequests : { profile : inputRequired . elicit ( { message, requestedSchema } ) } ,
243+ } )
244+ }
245+ const result = input . modern
246+ ? ( ( ) => {
247+ const response = inputResponse ( responses , "profile" )
248+ return response . kind === "elicit"
249+ ? { action : response . action , content : response . content }
250+ : { action : "cancel" }
251+ } ) ( )
252+ : await protocol . elicitInput ( { mode : "form" , message, requestedSchema } )
253+ return {
254+ content : [ { type : "text" , text : JSON . stringify ( result ) } ] ,
255+ structuredContent : result ,
256+ }
257+ } )
258+ }
259+ if ( ! input . emptyElicitation && ! input . urlElicitation && ! input . formElicitation ) {
212260 protocol . setRequestHandler ( "tools/call" , ( request ) => {
213261 state . toolCalls . push ( {
214262 name : request . params . name ,
@@ -356,7 +404,10 @@ function resourceMcpLayer(
356404 } as Payload < typeof definition >
357405 overrides ?. published ?. push ( event . type )
358406 if ( event . type !== Form . Event . Created . type || ! onFormCreated ) return Effect . succeed ( event )
359- return onFormCreated ( Schema . decodeUnknownSync ( Form . Event . Created . data ) ( data ) . form ) . pipe ( Effect . as ( event ) )
407+ // Subscribers receive events after a JSON hop, where undefined-valued optional members drop.
408+ // Roundtrip so this mock decodes the same shape a real subscriber sees.
409+ const encoded = Schema . encodeUnknownSync ( Form . Event . Created . data ) ( data as { readonly form : Form . Info } )
410+ return onFormCreated ( Schema . decodeUnknownSync ( Form . Event . Created . data ) ( encoded ) . form ) . pipe ( Effect . as ( event ) )
360411 } ,
361412 } ) ,
362413 Layer . mock ( Integration . Service , {
@@ -1443,6 +1494,112 @@ test("settles modern MCP URL elicitations when the user confirms", async () => {
14431494 )
14441495} )
14451496
1497+ // Shared by the form elicitation tests below. The form service maps the requested schema onto these
1498+ // fields, preserving titles, constraints, enum options, and defaults (`toElicitationField`).
1499+ const profileFormFields : Form . Fields = [
1500+ {
1501+ key : "name" ,
1502+ title : "Full name" ,
1503+ description : "Your display name" ,
1504+ required : true ,
1505+ type : "string" ,
1506+ minLength : 2 ,
1507+ maxLength : 40 ,
1508+ default : "Octocat" ,
1509+ } ,
1510+ { key : "age" , title : "Age" , type : "integer" , minimum : 0 , maximum : 130 } ,
1511+ {
1512+ key : "colour" ,
1513+ title : "colour" ,
1514+ type : "string" ,
1515+ default : "Green" ,
1516+ options : [
1517+ { value : "Red" , label : "Red" } ,
1518+ { value : "Green" , label : "Green" } ,
1519+ { value : "Blue" , label : "Blue" } ,
1520+ ] ,
1521+ custom : false ,
1522+ } ,
1523+ { key : "newsletter" , title : "Newsletter" , type : "boolean" , default : true } ,
1524+ ]
1525+
1526+ test ( "answers a modern MCP form elicitation through the multi-round-trip flow" , async ( ) => {
1527+ await Effect . runPromise (
1528+ Effect . scoped (
1529+ Effect . gen ( function * ( ) {
1530+ const server = yield * resourceServer ( { modern : true , resources : false , formElicitation : true } )
1531+ const created = yield * Deferred . make < Form . Info > ( )
1532+ const result = yield * Effect . gen ( function * ( ) {
1533+ const service = yield * Mcp . Service
1534+ const forms = yield * Form . Service
1535+ const call = yield * service
1536+ . callTool ( { server : "resources" , name : "form-elicitation" } )
1537+ . pipe ( Effect . forkScoped )
1538+
1539+ const form = yield * Deferred . await ( created )
1540+ expect ( form . metadata ) . toEqual ( {
1541+ kind : "mcp-elicitation" ,
1542+ server : "resources" ,
1543+ message : "Tell us about yourself" ,
1544+ } )
1545+ expect ( form . fields ) . toEqual ( profileFormFields )
1546+
1547+ yield * forms . reply ( {
1548+ id : form . id ,
1549+ answer : { name : "Mona" , age : 30 , colour : "Blue" , newsletter : false } ,
1550+ } )
1551+ return yield * Fiber . join ( call )
1552+ } ) . pipe (
1553+ Effect . provide (
1554+ resourceMcpLayer (
1555+ new ConfigMCP . Remote ( { type : "remote" , url : server . url , oauth : false , protocol : "2026-07-28" } ) ,
1556+ ( form ) => Deferred . succeed ( created , form ) . pipe ( Effect . asVoid ) ,
1557+ ) ,
1558+ ) ,
1559+ )
1560+
1561+ expect ( result . structured ) . toEqual ( {
1562+ action : "accept" ,
1563+ content : { name : "Mona" , age : 30 , colour : "Blue" , newsletter : false } ,
1564+ } )
1565+ } ) ,
1566+ ) ,
1567+ )
1568+ } )
1569+
1570+ test ( "answers a legacy MCP form elicitation with fields" , async ( ) => {
1571+ await Effect . runPromise (
1572+ Effect . scoped (
1573+ Effect . gen ( function * ( ) {
1574+ const server = yield * resourceServer ( { resources : false , formElicitation : true } )
1575+ const created = yield * Deferred . make < Form . Info > ( )
1576+ const result = yield * Effect . gen ( function * ( ) {
1577+ const service = yield * Mcp . Service
1578+ const forms = yield * Form . Service
1579+ const call = yield * service
1580+ . callTool ( { server : "resources" , name : "form-elicitation" } )
1581+ . pipe ( Effect . forkScoped )
1582+
1583+ const form = yield * Deferred . await ( created )
1584+ expect ( form . fields ) . toEqual ( profileFormFields )
1585+ yield * forms . reply ( {
1586+ id : form . id ,
1587+ answer : { name : "Mona" , age : 30 , colour : "Blue" , newsletter : false } ,
1588+ } )
1589+ return yield * Fiber . join ( call )
1590+ } ) . pipe (
1591+ Effect . provide ( resourceMcpLayer ( server . url , ( form ) => Deferred . succeed ( created , form ) . pipe ( Effect . asVoid ) ) ) ,
1592+ )
1593+
1594+ expect ( result . structured ) . toEqual ( {
1595+ action : "accept" ,
1596+ content : { name : "Mona" , age : 30 , colour : "Blue" , newsletter : false } ,
1597+ } )
1598+ } ) ,
1599+ ) ,
1600+ )
1601+ } )
1602+
14461603test ( "loads and reads MCP resources" , async ( ) => {
14471604 await Effect . runPromise (
14481605 Effect . scoped (
0 commit comments