Skip to content

[Server] Serve ClientGateway::elicit() under the modern lifecycle - #466

Merged
chr-hertel merged 1 commit into
modelcontextprotocol:mainfrom
chr-hertel:unify-elicitations
Aug 19, 2026
Merged

[Server] Serve ClientGateway::elicit() under the modern lifecycle#466
chr-hertel merged 1 commit into
modelcontextprotocol:mainfrom
chr-hertel:unify-elicitations

Conversation

@chr-hertel

Copy link
Copy Markdown
Member

Building a server against the SDK meant picking an era: ClientGateway::elicit() on the handshake leg, or returning an InputRequiredResult and reading RequestContext::getInputContext() on the 2026-07-28 leg — where elicit() threw outright. Portable handlers had to be written in the second shape and carry the fork.

elicit() and elicitUrl() now serve both. Same signature, same return type:

$answer = $context->getClientGateway()->elicit('When?', $schema, key: 'when');

Under 2026-07-28 the gateway's fiber suspension is fulfilled from MRTR state instead of a server-initiated request: answered already → the fiber resumes and the handler runs on; not yet → the ask becomes the input_required result and the fiber is abandoned (unwound, so finally runs), and the same call returns the answer when the client re-sends it. New Mcp\Server\Stateless\ElicitationReplay owns the keying, lookup and sealing; ClientGateway gains only the optional $key.

What it costs:

  • One handler entry per ask, so side effects belong after the last question.
  • One round trip per ask. InputRequiredResult stays the explicit form for batching several asks into one round, carrying your own state, or asking for anything else.
  • Earlier rounds' answers travel in the requestState — the client only echoes the round it just answered — so a handler asking twice or more needs Builder::setRequestState(). Without it: a logged internal error, not silent data loss. Single-ask handlers need nothing.
  • key: names an ask across rounds; unnamed asks are keyed by position.

sample() and listRoots() still raise a LogicException there — that revision removed them outright, and the message now says so instead of pointing at InputRequiredResult.

examples/server/elicitation lost its fork entirely: the private ask() is one elicit() call, and every tool dropped its |InputRequiredResult return type and instanceof branch. The pre-existing DualEraElicitationTest drives that example from both eras and passes untouched.

Tests: ElicitationReplayTest plus round-trip, three-round-with-state, missing-key, malformed-answer, undeclared-capability and mid-stream cases in StatelessProtocolTest.

One handler asks the user something on any revision now. Where the client
can be asked mid-request it still is; where it cannot, the ask becomes the
input_required result that revision carries and the same call returns the
answer once the client re-sends it.

Costs one handler entry per ask, so side effects belong after the last
question. Answers from earlier rounds travel in the requestState, which is
why asking more than once needs Builder::setRequestState().

sample() and listRoots() still raise a LogicException there: that revision
removed them outright.
@chr-hertel
chr-hertel merged commit e0b9c1a into modelcontextprotocol:main Aug 19, 2026
51 of 52 checks passed
@chr-hertel
chr-hertel deleted the unify-elicitations branch August 19, 2026 23:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant