> ## Documentation Index
> Fetch the complete documentation index at: https://si.nimbleapps.cloud/llms.txt
> Use this file to discover all available pages before exploring further.

# Understanding form elicitation

> How a Streamable HTTP tool asks the client for a form and finishes the call on the retry.

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.

> [!NOTE]
>
> Protocol revision `2026-07-28`. Transport: Streamable HTTP with `WithHttpTransport(o => o.Stateless = true)`. Pattern: Multi Round-Trip Requests (SEP-2322).

```mermaid theme={"dark"}
sequenceDiagram
    participant User
    participant Client
    participant Server

    Client->>Server: POST tools/call (id: 1)
    Server-->>Client: InputRequiredResult (elicitation/create, mode: form)
    Client->>User: Render form from requestedSchema
    User-->>Client: accept / decline / cancel
    Client->>Server: POST tools/call (id: 2) + inputResponses
    Server-->>Client: CallToolResult (complete)
```

> [card] POST 1 asks
>
> The tool throws `InputRequiredException`. The SDK writes `InputRequiredResult`. That HTTP request is finished.

> [card] The client collects
>
> The host renders `message` and `requestedSchema`. The user accepts, declines, or cancels.

> [card] 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.

| Name | Example | Where it lives |
| - | - | - |
| Tool name | `ShipOrder` | `[McpServerTool(Name = ...)]`, `params.name`, `Mcp-Name` |
| Input-request key | `ship` | `AddFormElicitation`, `inputRequests`, `inputResponses` |
| Field name | `trackingNumber` | schema `properties`, `ElicitResult.Content` |

> [!TIP]
>
> 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`.

### 1. 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`.

```csharp theme={"dark"}
using CloudNimble.McpEssentials.Core.Tasks.Elicitations;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddFormElicitation("ship",
    FormElicitationBuilder.Create("Ship this order")
        .String("trackingNumber").Required());
```

### 2. Host Streamable HTTP

`MapMcp()` with no path maps POST to `/`.

```csharp theme={"dark"}
builder.Services.AddHttpContextAccessor();
builder.Services.AddMcpServer()
    .WithHttpTransport(options => options.Stateless = true)
    .WithTools<ShippingTools>()
    .WithTasksPlus(sp => sp.GetRequiredService<IHttpContextAccessor>().HttpContext?.User);

var app = builder.Build();
app.MapMcp();
app.Run();
```

### 3. 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.

```csharp theme={"dark"}
using System.ComponentModel;
using CloudNimble.McpEssentials.Core.Tasks.Elicitations;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;

[McpServerToolType]
public class ShippingTools
{
    [McpServerTool(Name = "ShipOrder"), Description("Asks for a tracking number, then ships.")]
    [McpElicitForm("ship")]
    public static string ShipOrder(RequestContext<CallToolRequestParams> context)
    {
        var result = context.RequireForm("ship");
        if (!result.IsAccepted)
        {
            return "Shipping cancelled.";
        }

        return $"Shipped as {result.Content!["trackingNumber"].GetString()}.";
    }
}
```

> [accordion] What ToInputRequest() puts on the wire
>
> 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"`

> [accordion] Schema profile
>
> Form mode is a **flat object of primitives**:
>
> | Kind | JSON Schema |
> | - | - |
> | String | `type: string`; formats `email`, `uri`, `date`, `date-time` |
> | Integer / number | `type: integer` or `type: number`, with `minimum` / `maximum` |
> | Boolean | `type: boolean` |
> | Single-select | `enum`, or titled `oneOf` with `const` + `title` |
> | Multi-select | `type: array` of those enum shapes |
>
> Nested objects, arrays of objects, and other JSON Schema features are out of profile.

> [accordion] If the host already registers Tasks
>
> `[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.

> [tab] POST 1 — ask
>
> ```http theme={"dark"}
> POST / HTTP/1.1
> Host: localhost:5280
> Content-Type: application/json
> Accept: application/json, text/event-stream
> MCP-Protocol-Version: 2026-07-28
> Mcp-Method: tools/call
> Mcp-Name: ShipOrder
>
> {
>   "jsonrpc": "2.0",
>   "id": 1,
>   "method": "tools/call",
>   "params": {
>     "name": "ShipOrder",
>     "arguments": {},
>     "_meta": {
>       "io.modelcontextprotocol/protocolVersion": "2026-07-28",
>       "io.modelcontextprotocol/clientCapabilities": {
>         "elicitation": { "form": {} }
>       }
>     }
>   }
> }
> ```
>
> The `elicitation/create` object is nested inside the `tools/call` result. It is part of `InputRequiredResult.inputRequests`.
>
> ```json theme={"dark"}
> {
>   "jsonrpc": "2.0",
>   "id": 1,
>   "result": {
>     "resultType": "input_required",
>     "inputRequests": {
>       "ship": {
>         "method": "elicitation/create",
>         "params": {
>           "mode": "form",
>           "message": "Ship this order",
>           "requestedSchema": {
>             "type": "object",
>             "properties": {
>               "trackingNumber": {
>                 "type": "string",
>                 "title": "Tracking Number"
>               }
>             },
>             "required": ["trackingNumber"]
>           }
>         }
>       }
>     }
>   }
> }
> ```
>
> 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.

> [tab] The form answer
>
> The client renders `message` and `requestedSchema`, then returns an `ElicitResult`. Content keys are the schema property names (`trackingNumber`). The map key that wraps this object on the retry is still `"ship"`.
>
> ```json theme={"dark"}
> {
>   "action": "accept",
>   "content": { "trackingNumber": "1Z999" }
> }
> ```

> [tab] POST 2 — answer
>
> New JSON-RPC `id`. Same `method`, `params.name`, `params.arguments`, `_meta`, and headers. Add `inputResponses` under `"ship"`.
>
> ```http theme={"dark"}
> POST / HTTP/1.1
> Host: localhost:5280
> Content-Type: application/json
> Accept: application/json, text/event-stream
> MCP-Protocol-Version: 2026-07-28
> Mcp-Method: tools/call
> Mcp-Name: ShipOrder
>
> {
>   "jsonrpc": "2.0",
>   "id": 2,
>   "method": "tools/call",
>   "params": {
>     "name": "ShipOrder",
>     "arguments": {},
>     "inputResponses": {
>       "ship": {
>         "action": "accept",
>         "content": { "trackingNumber": "1Z999" }
>       }
>     },
>     "_meta": {
>       "io.modelcontextprotocol/protocolVersion": "2026-07-28",
>       "io.modelcontextprotocol/clientCapabilities": {
>         "elicitation": { "form": {} }
>       }
>     }
>   }
> }
> ```
>
> The tool runs from the top. `InputResponses["ship"]` deserializes to `ElicitResult`. The method returns a string. The SDK wraps that as a `CallToolResult`. Spec `resultType` for that success result is `"complete"`; a missing `resultType` is treated as `"complete"`.
>
> ```json theme={"dark"}
> {
>   "jsonrpc": "2.0",
>   "id": 2,
>   "result": {
>     "resultType": "complete",
>     "content": [
>       { "type": "text", "text": "Shipped as 1Z999." }
>     ]
>   }
> }
> ```

> [accordion] Required headers and \_meta
>
> | Requirement | Failure |
> | - | - |
> | `Accept` lists both `application/json` and `text/event-stream` | The client cannot accept the server's JSON or SSE reply |
> | `MCP-Protocol-Version` equals `_meta.io.modelcontextprotocol/protocolVersion` (`2026-07-28`) | HTTP 400, JSON-RPC `-32020` (`HeaderMismatch`) |
> | `Mcp-Method` equals `method`; `Mcp-Name` equals `params.name` | HTTP 400, JSON-RPC `-32020` (`HeaderMismatch`) |
> | `_meta.io.modelcontextprotocol/protocolVersion` and `_meta.io.modelcontextprotocol/clientCapabilities` are present | HTTP 400, JSON-RPC `-32602` |
> | `clientCapabilities.elicitation` is declared | HTTP 400, JSON-RPC `-32021` (`MissingRequiredClientCapability`) when this tool throws a form `InputRequiredException` |
>
> `"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.

```csharp theme={"dark"}
var client = await McpClient.CreateAsync(
    new HttpClientTransport(new() { Endpoint = new Uri("http://localhost:5280/") }),
    new McpClientOptions
    {
        Capabilities = new ClientCapabilities
        {
            Elicitation = new ElicitationCapability { Form = new FormElicitationCapability() }
        },
        Handlers = new McpClientHandlers
        {
            ElicitationHandler = HandleFormAsync
        }
    });

var call = await client.CallToolAsync("ShipOrder");

static ValueTask<ElicitResult> HandleFormAsync(
    ElicitRequestParams? requestParams,
    CancellationToken cancellationToken)
{
    return new ValueTask<ElicitResult>(new ElicitResult
    {
        Action = "accept",
        Content = new Dictionary<string, JsonElement>
        {
            ["trackingNumber"] = JsonSerializer.SerializeToElement("1Z999")
        }
    });
}
```

## Form-mode rules

> [accordion] What a form may collect
>
> Fields are a flat object of primitives. Clients that support defaults pre-fill `default`. The server validates returned content against the schema it sent.

> [accordion] Secrets and credentials
>
> Passwords, API keys, access tokens, and payment credentials use URL mode. Form mode is for ordinary structured values the host may see.

> [accordion] Accept, decline, cancel
>
> The client UI names the server that asked, and it offers accept, decline, and cancel. Handle all three on the retry.

> [accordion] requestState
>
> 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

> [!TIP]
>
> 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`.

## Related

> [card] [Form elicitation](https://si.nimbleapps.cloud/mcpessentials/guides/form-elicitation.md)
>
> `Create`, `From<T>`, field kinds, XML, and analyzers.

> [card] [Analyzers](https://si.nimbleapps.cloud/mcpessentials/guides/analyzers.md)
>
> Compiler diagnostics for `From<T>` and expression starters.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.