Some handlers cannot finish in one go: they need the user to confirm something, fill in a form, name a directory, or have the client's model draft a paragraph. There are two ways to write that: ask for it, or return the ask.
For elicitation, ask and use the answer:
use Mcp\Schema\Elicitation\ElicitationSchema;
use Mcp\Schema\Elicitation\StringSchemaDefinition;
use Mcp\Server\RequestContext;
$schema = new ElicitationSchema(['name' => new StringSchemaDefinition('Your name')], ['name']);
$handler = static function (RequestContext $context) use ($schema): string {
$answer = $context->getClientGateway()->elicit('Your name?', $schema, key: 'who');
if (!$answer->isAccepted()) {
return 'Hello, stranger!';
}
return "Hello, {$answer->content['name']}!";
};key names an ask, so its answer keeps finding the question it belongs to. Leave it out and
asks are keyed by position — elicitation_1, elicitation_2, … — which holds as long as the
handler reaches them in the same order every time.
Write the handler so it can run more than once. Some clients answer inside the open request; others answer by re-sending the whole call, which enters your handler again from the top, once per question. Everything above an ask therefore has to be safe to repeat — put side effects after the last one, and re-derive where you are from the answers rather than from anything you kept.
Over HTTP, a handshake-era client can answer inside the open request only if the server has
sessions. Without them, elicit() throws a
SessionRequiredException.
Answers given in an earlier round travel in the requestState, so a handler
asking more than once needs Builder::setRequestState() configured.
examples/server/elicitation
is written this way.
The explicit form: return an InputRequiredResult naming what you need, and read the
answer off RequestContext when the call comes back. It is more to write, and it is the only
way to ask several things in one round trip or to carry your own state. On revision
2026-07-28, it is also the only way to ask for anything other than elicitation.
Revision
2026-07-28. Multi round-trip requests (MRTR) are that revision's feature, and only there does the protocol itself carry this shape. Over a handshake-era connection the SDK emulates it: the input-required shim sends each ask as the realelicitation/create/sampling/createMessage/roots/listand re-enters your handler with the answers. That is on by default and bounded bysetInputRequiredLimits()— each round holds the originating request open, so it holds a worker for as long as the user takes — andwithoutInputRequiredShim()turns it off. The SDK then sends theInputRequiredResultas it is, and a handshake-era client cannot act on it. See Server builder. Over HTTP, the shim needs sessions.
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\Elicitation\ElicitationSchema;
use Mcp\Schema\Elicitation\StringSchemaDefinition;
use Mcp\Schema\Request\ElicitRequest;
use Mcp\Schema\Result\CallToolResult;
use Mcp\Schema\Result\InputRequiredResult;
use Mcp\Server\RequestContext;
$schema = new ElicitationSchema(['name' => new StringSchemaDefinition('Your name')], ['name']);
$handler = static function (RequestContext $context) use ($schema): CallToolResult|InputRequiredResult {
$answer = $context->getInputContext()?->elicitResult('who');
if (null === $answer) {
return new InputRequiredResult(
['who' => new ElicitRequest('Your name?', $schema)],
requestState: $context->mintRequestState(['asked' => 'who']),
);
}
if (!$answer->isAccepted()) {
return new CallToolResult([new TextContent('Hello, stranger!')]);
}
return new CallToolResult([new TextContent("Hello, {$answer->content['name']}!")]);
};tools/call, prompts/get and resources/read may answer this way; nothing else may.
InputContext hands them back typed — elicitResult(), samplingResult(),
rootsResult() — and returns null for an answer that is absent or malformed. Both mean
the same thing to a handler: ask again. response() is still there for the raw array.
Whatever the server needs to remember between rounds. It travels through the client, so it
is attacker-controlled on return; mintRequestState() seals it with an HMAC and a TTL, and
a state that fails verification never reaches a handler. Configure the key with
Builder::setRequestState():
->setRequestState(getenv('MCP_REQUEST_STATE_KEY'), ttl: 600)When the call comes back, read the verified payload with requestState():
$state = $context->getInputContext()?->requestState(); // e.g. ['asked' => 'who']The same key must reach every process that might serve the retry. A per-process random value works only for a single-process deployment. Nothing secret belongs in the payload — it is signed, not encrypted.
A valid state verifies on any request of the TTL, so a client can echo it on another call, another tool, or under another user's token. The specification recommends binding it to the authenticated principal and the originating request.
A handler that mints its own state can put that identity into the payload and compare it on
the retry. It cannot do so for the state the SDK mints when it carries answers to the next
round, as for a handler that calls elicit() twice. The binder argument of
Builder::setRequestState() binds both on the modern era: the binder's value is covered by
the MAC of every state the request mints, and a retry that binds to another value is refused
like a forged state. The value itself never goes on the wire.
use Mcp\Server\Stateless\OriginatingRequestBinder;
->setRequestState(getenv('MCP_REQUEST_STATE_KEY'), ttl: 600, binder: new OriginatingRequestBinder())OriginatingRequestBinder binds the method, the tool or prompt name and a digest of its
arguments (or the resource URI), and the token's issuer, client and subject.
A token without a subject (sub) identifies no user, so the binder cannot tell two users of
one client apart. It fails closed: such a request binds to a value of its own, and a state it
mints is refused on the retry. If your authorization server identifies users by another claim,
pass a resolver that returns a value stable across a token refresh, or null for a token that
identifies nobody. The issuer and client stay bound either way:
use Mcp\Server\Authorization\AccessToken;
new OriginatingRequestBinder(
subject: static fn (AccessToken $token): ?string => \is_string($oid = $token->getClaim('oid')) ? $oid : null,
)For anything else, implement RequestStateBinderInterface, which receives the request as the client sent
it and its AccessToken. Bind only what stays the same across the rounds of one call;
_meta does not.
States minted before the binder was configured no longer verify, so turning it on fails the calls in flight for at most one TTL.
Pass a list of keys instead of one, and the first key signs while every key verifies:
->setRequestState([getenv('MCP_REQUEST_STATE_KEY'), getenv('MCP_REQUEST_STATE_KEY_PREVIOUS')], ttl: 600)Every entry must be a key: once the rotation is done, go back to a single key instead of leaving an unset variable in the list.
A state names no key, so the wire format does not change and a single key is a ring of one. To rotate without failing the calls in flight:
- Add the new key behind the current one,
[$current, $new], and deploy it to every process. They still sign with the current key but already accept the new one. A single-process deployment can skip this step. - Move the new key to the front,
[$new, $current]. States are signed with the new key from now on, and the ones the old key signed still verify. - Wait one TTL after the last process switched to
[$new, $current]: only then has every state the old key signed expired. Remove it,[$new].
Each key needs at least 32 bytes.
A server must not ask for input the client cannot provide. The SDK checks every ask on
revision 2026-07-28, and every InputRequiredResult on a handshake-era connection, against
the capabilities the client declared. If one is missing, the SDK doesn't send the ask. It
answers -32021, with the missing capabilities in data.requiredCapabilities.
On a handshake-era connection, elicit(), sample() and listRoots() send their request
without this check. Call supportsElicitation(), supportsSampling() or supportsRoots()
first.
Url-mode elicitation needs its own elicitation.url declaration; a bare elicitation means
form mode only.
ClientGateway::elicitUrl() checks this itself. Without elicitation.url, it throws an
InvalidArgumentException inside your handler. If you don't catch it, the call fails like
any other uncaught exception.
On 2026-07-28, supportsSampling() and supportsRoots() read what the request declared.
They can return true there, but sample() and listRoots() still fail (see below).
ClientGateway::sample() and listRoots() belong to the handshake era. Revision
2026-07-28 has no server-initiated requests, so calling one there fails the call with an
internal error (-32603). The error happens outside your handler, so a try/catch around
the call never runs. To sample or list roots on that revision,
return the ask as an InputRequiredResult with a
CreateSamplingMessageRequest or ListRootsRequest, and read the answer with
samplingResult() or rootsResult(). elicit() and elicitUrl() are unaffected: the SDK
turns them into such an ask for you.
Sampling and roots are deprecated since 2026-07-28
(SEP-2577). For new
code, prefer tool arguments, resource URIs or server configuration.
For elicitation, your handler doesn't fork on it. elicit() works on every revision. Only
the mechanics underneath differ, and the SDK picks them. It needs sessions for handshake-era
clients over HTTP, as described in Just asking. The one thing to keep in mind is the rule already
stated: a handler that asks may be entered again from the top, so let it repeat safely.