Skip to content
Guides

Handle elicitation directly with the SDK ​

Without a plan, your code can receive the server's InputRequiredResult and answer it. Call the same tool again with the returned request_state and one response for each requested key.

Requirements ​

Use Python 3.10 or later and a project set up with m3 init and m3 setup, as in Write your first MCP test. The test uses an in-process server and needs no agent harness, network service, or credentials.

Example ​

The book_shipment tool asks for a shipping_address form, then books the shipment and returns the address it received.

Save as shipping_server.py:

python
from __future__ import annotations

from mcp import types
from mcp.server.lowlevel import Server

ADDRESS_SCHEMA = {
    "type": "object",
    "properties": {"street": {"type": "string"}, "city": {"type": "string"}},
    "required": ["street", "city"],
}


async def list_tools(_context: object, _params: object) -> types.ListToolsResult:
    return types.ListToolsResult(
        tools=[
            types.Tool(
                name="book_shipment",
                description="Book a shipment after confirming its address",
                input_schema={
                    "type": "object",
                    "properties": {"weight_kg": {"type": "number"}},
                    "required": ["weight_kg"],
                },
            )
        ]
    )


async def call_tool(
    _context: object, params: types.CallToolRequestParams
) -> types.CallToolResult | types.InputRequiredResult:
    if params.request_state is None:
        return types.InputRequiredResult(
            input_requests={
                "shipping_address": types.ElicitRequest(
                    params=types.ElicitRequestFormParams(
                        message="Enter the delivery address.",
                        requested_schema=ADDRESS_SCHEMA,
                    )
                )
            },
            request_state="awaiting-address",
        )

    response = (params.input_responses or {}).get("shipping_address")
    if (
        params.request_state != "awaiting-address"
        or not isinstance(response, types.ElicitResult)
        or response.action != "accept"
    ):
        return types.CallToolResult(
            content=[types.TextContent(text="address response did not match")],
            is_error=True,
        )
    return types.CallToolResult(
        content=[types.TextContent(text="Shipment booked.")],
        structured_content={"status": "booked", "address": response.content},
    )


def build_server() -> Server:
    return Server("shipping-manual", on_list_tools=list_tools, on_call_tool=call_tool)

Save as test_manual.py beside it:

python
from __future__ import annotations

import pytest
from mcp import types
from shipping_server import build_server

from m3 import Config, InProcessServer, MCPTestKit
from m3.sync_api import InputRequiredResult

pytestmark = pytest.mark.m3(suite_name="elicitation")

ADDRESS = {"street": "1 Main Street", "city": "Pune"}


def test_answer_the_returned_request_yourself() -> None:
    server = InProcessServer(name="shipping", factory=build_server)
    config = Config(protocol_revision="2026-07-28")
    with MCPTestKit(config=config, env={}) as kit, kit.direct(server) as client:
        pending = client.call_tool(
            "book_shipment", {"weight_kg": 2}, allow_input_required=True
        )
        assert isinstance(pending, InputRequiredResult)
        assert set(pending.input_requests) == {"shipping_address"}

        result = client.call_tool(
            "book_shipment",
            {"weight_kg": 2},
            request_state=pending.request_state,
            input_responses={
                "shipping_address": types.ElicitResult(action="accept", content=ADDRESS)
            },
        )

    assert result.is_error is False
    assert result.structured_content == {"status": "booked", "address": ADDRESS}

Run:

sh
m3 test -- test_manual.py

The summary ends with:

text
M3 verdicts: 1 passed

allow_input_required=True makes the first call return the InputRequiredResult. Without it, a direct call that receives a request and has no plan raises ElicitationExpectationError. The second call passes pending.request_state back unchanged and answers under the key from pending.input_requests.

Treat request_state as opaque. The server decides what it means, so pass back the value you received rather than building one. M3 sends the state, keys, and content you provide without checking them against the request, so rejecting bad input is up to the server. This one returns is_error=True for a wrong state or a response that isn't accept; it doesn't check the address itself.

If the server may ask again, pass allow_input_required=True on the retry as well. The retry then returns a new InputRequiredResult; read the keys from it instead of reusing the previous ones.

You can't combine elicitation= with allow_input_required, request_state, or input_responses on one call; M3 raises ModelValidationError.

The complete project is in sdk/examples/docs/elicitation-manual. To let M3 answer for you, see Plan answers to elicitation requests.