Skip to main content
A form-eliciting tool is a normal tools/call. The first POST returns resultType: "input_required" with an elicitation/create form inside inputRequests. The client shows that form, then POSTs the same tools/call again with the answers in inputResponses. That retry is a new HTTP request. Everything the tool needs is in that body.
Protocol revision 2026-07-28. Transport: Streamable HTTP with WithHttpTransport(o => o.Stateless = true). Pattern: Multi Round-Trip Requests (SEP-2322).

POST 1 asks

The tool throws InputRequiredException. The SDK writes InputRequiredResult. That HTTP request is finished.

The client collects

The host renders message and requestedSchema. The user accepts, declines, or cancels.

POST 2 answers

Same tool name and arguments, new JSON-RPC id, answers under the same key the server used.

Three names

These three strings travel in different places. Mixing them up is the usual first-try failure.
The dictionary key you pass to RequireForm ("ship") is the key on inputRequests / inputResponses. Field names live only inside content.

Build the server

Packages: ModelContextProtocol.AspNetCore 2.x and CloudNimble.McpEssentials.Core.

Register the form

AddFormElicitation("ship", …) stores a keyed singleton IFormElicitationSource. ToInputRequest() seals the builder and wraps Build() as InputRequest.ForElicitation: method is elicitation/create, params.mode is "form", plus message and a flat requestedSchema.

Host Streamable HTTP

MapMcp() with no path maps POST to /.

Write the tool

Return an immediate result (string). Stamp the method with [McpElicitForm("ship")]. Call context.RequireForm("ship").First visit throws InputRequiredException. The SDK writes that as resultType: "input_required". That POST is finished. The retry returns the ElicitResult.IsAccepted is Action == "accept" (ordinal ignore-case). The other actions are "decline" and "cancel". Unset Action defaults to "cancel". If a required answer is still missing, RequireForm throws again. The client retries.
For the "ship" form above:
  • method: elicitation/create (RequestMethods.ElicitationCreate)
  • params.mode: "form"
  • params.message: "Ship this order"
  • params.requestedSchema: a JSON Schema object whose properties.trackingNumber is { "type": "string", "title": "Tracking Number" } and whose required list contains "trackingNumber"
Form mode is a flat object of primitives:Nested objects, arrays of objects, and other JSON Schema features are out of profile.
[McpElicitForm("ship")] is enough. WithTasksPlus selects McpTaskExecutionMode.Synchronous for that tool. [McpElicitUrl] is Optional so a task can outlive the POST. Other tools stay Optional.

Call the tool

Every request carries protocol metadata in the body. Streamable HTTP also mirrors selected fields into headers; those headers must match the body.
The elicitation/create object is nested inside the tools/call result. It is part of InputRequiredResult.inputRequests.
This example omits requestState. The retry omits it as well. If the server does send requestState, the client copies that string onto the retry unchanged.
"elicitation": { "form": {} } and "elicitation": {} both advertise form mode.clientInfo is optional. Origin is optional; if it is sent and invalid, the spec requires HTTP 403.

C# SDK client

CallToolAsync retries input_required through McpClientImpl.SendRequestAsync. The handler is required; without it the client throws InvalidOperationException("Server sent an elicitation input request, but no ElicitationHandler is registered."). That client injects _meta (protocol version and capabilities) on every POST. Raw HTTP clients send it themselves.

Form-mode rules

Fields are a flat object of primitives. Clients that support defaults pre-fill default. The server validates returned content against the schema it sent.
Passwords, API keys, access tokens, and payment credentials use URL mode. Form mode is for ordinary structured values the host may see.
The client UI names the server that asked, and it offers accept, decline, and cancel. Handle all three on the retry.
When used, requestState is an opaque string. If it affects authorization or business logic, integrity-protect it (HMAC/AEAD), bind it to the user, give it a TTL, and reject a mismatch. Treat it as attacker-controlled on the retry.

First-try checklist

Complete this list before the first tools/call.
1

Host

WithHttpTransport(o => o.Stateless = true), WithTools<ShippingTools>(), MapMcp().
2

Form and tool

AddFormElicitation("ship", …) and a [McpServerTool(Name = "ShipOrder")] method that returns string.
3

Tasks, if already registered

[McpElicitForm("ship")] makes WithTasksPlus keep ShipOrder on tools/call.
4

First visit and retry

RequireForm("ship") throws on the first visit. The retry returns the ElicitResult.
5

Client

2026-07-28 in the header and in _meta, form elicitation advertised, and an ElicitationHandler (C# SDK) or an equivalent form UI (raw HTTP).
6

Headers

MCP-Protocol-Version, Mcp-Method, and Mcp-Name match the body.
7

Retry payload

New JSON-RPC id, the same tool name and arguments, and inputResponses.ship.

Form elicitation

Create, From<T>, field kinds, XML, and analyzers.

Analyzers

Compiler diagnostics for From<T> and expression starters.