Skip to content

Commit fe24801

Browse files
committed
test(core): cover MCP form elicitation round trips
Form mode elicitation had no coverage: the existing tests covered an empty form and URL mode only. Exercise a schema with string, integer, enum, and boolean fields in both protocol eras. The 2026-07-28 test runs the multi-round-trip flow: input_required, then a retry with the inputResponses the SDK attaches. Refs anomalyco#51856
1 parent a070e11 commit fe24801

1 file changed

Lines changed: 160 additions & 3 deletions

File tree

‎packages/core/test/mcp.test.ts‎

Lines changed: 160 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { describe, expect, test } from "bun:test"
55
import { Client, InMemoryTransport, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"
66
import {
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+
14461603
test("loads and reads MCP resources", async () => {
14471604
await Effect.runPromise(
14481605
Effect.scoped(

0 commit comments

Comments
 (0)