Reverse calls
Server-initiated sampling, elicitation, and roots/list — and how the handler context gates them.
Reverse-call mechanics ¶
How a server handler synchronously asks the client for something — sampling, elicitation, or roots — and what dies when. Six questions.
Kind: root (FAQ-style) · Prerequisites: request-anatomy, transport-mechanics
Reachable from: README, request-anatomy Next-to-read, transport-mechanics Next-to-read, mrtr Next-to-read, extension-mechanisms Next-to-read
Branches into: elicitation (stub, leaf), sampling (stub, leaf), roots-list (stub, leaf)
Spec: Client features (sampling, elicitation, roots) · Code:core/handler_context.go(BaseContext.Sample,BaseContext.Elicit,BaseContext.AllowedRoots) ·core/session.go(sessionCtx.request) ·core/request.go(RequestFunc,ErrNoRequestFunc) ·core/sampling.go·core/elicitation.go·core/roots_allowed.go·core/background.go(DetachForBackground,ReplaceSessionRequestFunc) ·client/client.go(HandleServerRequest,HandleServerRequestWithContext) ·server/session_wiring.go(makeRequestFunc, push wiring) ·server/roots.go
Prerequisites ¶
- You know how a
tools/calldispatches and what the handler context provides. → If not, read request-anatomy. - You know the per-direction pending-id table and the spec’s “server-initiated requests must be in association with a forward request” rule. → If not, read transport mechanics.
- (Useful contrast) You know MRTR’s stateless-round-trip alternative. → See mrtr.
Context ¶
A tool handler often needs information from the client mid-execution: the user’s confirmation, an LLM completion, the host’s filesystem roots. MCP defines three client-features that surface this — sampling (sampling/createMessage), elicitation (elicitation/create), and roots (roots/list). When the server originates one of these during a handler, it’s a reverse call: server→client request, gated by handler context, parked on the server’s pending-id table until the client responds.
This page walks the mechanics: how a handler originates one (Q3), how the client receives and dispatches it (Q4), how capability gating prevents misuse, why the three reverse-call types aren’t all alike on the inside (Q5), and the lifetime trap that makes handlers care about DetachForBackground (Q6).
Q1 — What is a reverse call and why does it exist? ¶
The protocol is point-to-point and bidirectional. Either side may originate JSON-RPC requests; the other must respond. Forward calls flow client→server (every tools/call, prompts/get, resources/read); reverse calls flow server→client. The three reverse-call methods MCP defines today are all during-tool-execution hooks:
| Method | What the server is asking for | Capability the client must declare |
|---|---|---|
sampling/createMessage |
“Run an LLM completion for me” | capabilities.sampling |
elicitation/create |
“Ask the user for structured input” | capabilities.elicitation |
roots/list |
“What filesystem roots am I allowed to see?” | capabilities.roots |
Why originate from the server at all (vs. asking the host to bake all this info into the original tools/call arguments)? Three reasons, in order of force:
- The server doesn’t know up front what it’ll need. A “summarize this PR” tool might decide mid-execution that it needs the user to disambiguate between two PR numbers. Pre-baking every conceivable input into the call would explode the schema.
- Privacy and policy. The host (not the server) should mediate sensitive interactions — model invocations cost money; user input flows through host-controlled UI; filesystem roots define the security boundary. Reverse calls put the host in control of each instance, not just the initial setup.
- Composition. A handler that wants to compose a model call with user confirmation can do both inline, in code that reads top-to-bottom. The alternative (MRTR) makes composition explicit and stateless but adds round-trips.
[!IMPORTANT]
The spec requires server-initiated requests to be in association with an originating client request — see transport-mechanics → reverse-call origination. The server can’t push asampling/createMessageout of the blue; only during a forward-request handler. mcpkit enforces this structurally: the only way to originate a reverse call is through the handler’sBaseContext, which only exists inside a forward-request dispatch.
Q2 — Worked example: a handler invokes elicitation/create ¶
A confirm_destructive_action tool wants the user to confirm before proceeding. One round-trip during the handler.
1. Forward call arrives. Client POSTs tools/call:
→ tools/call
{
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": {
"name": "confirm_destructive_action",
"arguments": { "what": "drop database" }
}
}
Server dispatches to the registered handler. The handler runs.
2. Handler originates a reverse call. Inside the handler:
result, err := ctx.Elicit(core.ElicitationRequest{
Message: "Confirm: drop the production database?",
RequestedSchema: confirmSchema,
})
mcpkit’s BaseContext.Elicit checks clientCaps.Elicitation != nil, then calls sc.request(ctx, "elicitation/create", req). That request hook (installed by session_wiring.go’s makeRequestFunc at session setup) does three things:
- Allocates a fresh server-side JSON-RPC id (server’s id-space, independent of the client’s).
- Registers a pending-response slot keyed by that id.
- Writes the JSON-RPC request through
pushFunconto whatever channel the transport gives it (the SSE response stream of the in-flight POST for streamable HTTP, the stdio pipe for stdio).
On the wire (interleaved with the in-flight tools/call’s SSE stream, if HTTP):
← server-initiated request (on POST #7's SSE stream)
{
"jsonrpc": "2.0",
"id": 42,
"method": "elicitation/create",
"params": {
"message": "Confirm: drop the production database?",
"requestedSchema": {...}
}
}
3. Client dispatches. mcpkit’s Client.HandleServerRequest sees method: "elicitation/create", finds the host’s registered elicitationHandler, calls it. The host shows the form, the user types “yes”, the handler returns:
→ client response (back on the same channel)
{
"jsonrpc": "2.0",
"id": 42,
"result": {
"action": "accept",
"content": { "confirmed": true }
}
}
4. Server resolves the pending entry. Server’s mrtr (in the per-direction-id-table sense — see transport-mechanics § Per-direction ID space) looks up pending[42], hands the result to the goroutine parked in sc.request(...). BaseContext.Elicit returns the unmarshaled ElicitationResult to the handler. Handler proceeds.
5. Forward response goes out.
← tools/call response
{
"jsonrpc": "2.0",
"id": 7,
"result": {
"resultType": "complete",
"content": [{ "type": "text", "text": "Database dropped." }]
}
}
Two ids on the wire, one logical operation. id=7 is the forward tools/call; id=42 is the reverse elicitation/create. They share no namespace; they share no pending-table entry. The only link between them is in handler-context state on the server — see transport-mechanics → reverse-call origination for the back-pointer machinery that propagates cancellation.
Q3 — Server-side: bc.Sample / bc.Elicit and the sessionCtx.request hook ¶
mcpkit packages reverse-call origination as ordinary Go method calls on the handler context. The relevant API surface (core/handler_context.go):
func (bc BaseContext) Sample(req CreateMessageRequest) (CreateMessageResult, error)
func (bc BaseContext) Elicit(req ElicitationRequest) (ElicitationResult, error)
Each does three things in order:
- Capability gate.
bc.SamplereturnsErrSamplingNotSupportedifclientCaps.Sampling == nil.bc.ElicitreturnsErrElicitationNotSupportedifclientCaps.Elicitation == nil. The capability is what the client declared during bring-up; if the client never said it’d handle the method, the server can’t originate it. (Handlers are expected to fall back gracefully — typically by returning a result that asks the user to provide the input through the original tool arguments.) - Optional schema reshaping.
bc.Elicitstrips thex-mcp-fileJSON-Schema keyword if the client didn’t declarecapabilities.fileInputs(per SEP-2356). The server is responsible for not sending the client schema features it can’t render. - Origination via the request hook. Calls
bc.sc.request(bc.Context, "<method>", req). The hook is aRequestFuncinstalled during session wiring; it returnsjson.RawMessage(or error) whichbc.Sample/bc.Elicitunmarshal into the typed result.
The request hook is the choke point. sessionCtx.request is set by makeRequestFunc(pushFunc) during dispatch setup. It encapsulates id allocation, pending-table registration, JSON-RPC encoding, and the wire-write through pushFunc (which writes to the SSE stream / stdio pipe / wherever).
If sc.request == nil (e.g., a transport that doesn’t support server-to-client requests, or a session past the request boundary) the call returns ErrNoRequestFunc. Handlers should treat this as “reverse calls aren’t available right now” rather than as a programming error.
[!IMPORTANT]
Only handler context has access tosc.request. That’s how the spec’s “in association with an originating client request” constraint is enforced in mcpkit. A goroutine that wants to keep originating reverse calls after the forward handler returns has to escape viaDetachForBackground— see Q6.
Q4 — Client-side: HandleServerRequest as the canonical dispatcher ¶
mcpkit’s client receives reverse calls through whatever transport it’s bound to (stdio pipe, streamable HTTP standing GET, in-process). Each transport calls a single dispatcher function: Client.HandleServerRequestWithContext.
func (c *Client) HandleServerRequestWithContext(ctx context.Context, req *core.Request) *core.Response {
switch req.Method {
case "sampling/createMessage":
if c.samplingHandler == nil {
return core.NewErrorResponse(req.ID, core.ErrCodeMethodNotFound, "sampling not supported")
}
// unmarshal params, call samplingHandler, marshal result, return response
case "elicitation/create":
// ditto with elicitationHandler
case "roots/list":
// ditto with rootsHandler
default:
return core.NewErrorResponse(req.ID, core.ErrCodeMethodNotFound, ...)
}
}
Three things to internalize:
- One dispatcher, one switch. The client doesn’t have a per-method registry the way the server does (there’s a small fixed set of reverse-call methods MCP defines). Adding a new reverse-call method means extending the switch, not registering a handler.
- Host code plugs in handlers, not transports. When you build a host (Claude Desktop, an IDE, a CLI agent), you set
samplingHandler,elicitationHandler,rootsHandleron the Client at construction. The transport doesn’t see them; the dispatcher routes to them by method name. - MRTR reuses this exact dispatcher.
dispatchMRTRInputRequest(see mrtr Q4) synthesizes a fakecore.Requestfrom each MRTRInputRequestand routes it throughHandleServerRequestWithContext. So a host that handles real reverse calls handles MRTR’s inputs for free — same code, same UI, same authorization.
Q5 — The three reverse-call types: sampling, elicitation, roots/list ¶
Mechanically the three look the same on the wire — server-initiated request, client-side dispatch, response back. But on the server’s side, only sampling and elicitation are originated by handlers. Roots is different.
| Method | Handler API | Originated by | Cached on server? |
|---|---|---|---|
sampling/createMessage |
bc.Sample(req) |
Per-handler call | No — each handler call is its own reverse call |
elicitation/create |
bc.Elicit(req) |
Per-handler call | No — each handler call is its own reverse call |
roots/list |
bc.AllowedRoots() |
Server infrastructure (background) | Yes — fetched once, refreshed on notifications/roots/list_changed |
Roots is infrastructure-managed. The server doesn’t originate roots/list from inside a handler. Instead (server/roots.go):
- After bring-up, the server eagerly issues a single
roots/listreverse call to populate its cachedallowedRoots. - When the client sends
notifications/roots/list_changed, the server re-issuesroots/listto refresh the cache. - Handlers read from the cache via
bc.AllowedRoots(), which is a synchronous lookup — no reverse call, no parked goroutine.
Why the asymmetry? Roots are session-scoped policy state, not per-handler-call data. Every handler in the session shares the same allowed-roots view; making it a synchronous read off a session-level cache is faster, simpler, and matches how filesystem boundaries actually work. Sampling and elicitation are per-call asks — different message each time, no useful cache.
This means: when reading mcpkit code, “reverse call” usually means sampling or elicitation. Roots-related code lives in server/roots.go and is largely invisible to tool handlers.
[!NOTE]
Capability declarations. The client declaressampling,elicitation,roots, and (for SEP-2356)fileInputscapabilities at bring-up. The server gates origination on these —bc.SamplereturnsErrSamplingNotSupportedifclientCaps.Sampling == nil, etc. The host implementor is responsible for advertising only the capabilities they actually handle; the server is responsible for failing soft when a capability is missing.
Q6 — Lifetime: when the forward request dies, and DetachForBackground as an escape ¶
The hard part of reverse calls isn’t the wire shape; it’s the lifetime. The handler context is bound to a specific in-flight forward request. When the forward request finishes (response sent, error returned, client disconnects), the request hook the handler was holding becomes invalid:
- The transport-scoped
pushFuncwrites to the SSE stream of that specific POST. After the POST completes, the stream is closed; writes are no-ops or panics. - Pending-table entries for any reverse calls outstanding when the forward request dies will never resolve — the handler is gone, there’s no goroutine to deliver the response to.
- The forward request id (the back-pointer for cancellation propagation) is unregistered.
This is why handlers shouldn’t spawn goroutines that try to keep originating reverse calls after returning. The pattern doesn’t work; the request hook in the captured ctx is dead.
The escape: DetachForBackground(ctx). This is mcpkit’s supported way to keep doing server-to-client work after the forward request returns. It produces a new context.Context whose attached sessionCtx.request is the session-level persistent-push function — bound to the session’s standing GET back-channel, not the dying POST.
Mechanism (core/background.go):
- The dispatch layer registers a
DetachStrategyon the request context viaSetDetachStrategyat handler setup. The strategy knows how to swap the POST-scoped functions for session-scoped ones. - When a handler calls
core.DetachForBackground(ctx), the strategy fires: it produces a new context withsessionCtx.requestreplaced viaReplaceSessionRequestFunc(andsessionCtx.notifysimilarly viaReplaceSessionNotifyFunc). - Goroutines started with the detached context can still call
bc.Sample/bc.Elicit/bc.EmitLog— they go through the session-level push, which lives as long as the session does. - If no detach strategy is registered (e.g. stdio, in-process transports without session-level push),
DetachForBackgroundfalls back tocontext.WithoutCancel— preserves values but doesn’t fix the dead push functions. Calls will still fail.
[!IMPORTANT]
Always usecore.DetachForBackground(ctx)instead ofcontext.WithoutCancel(ctx)when spawning goroutines that need to send server-to-client requests. CLAUDE.md flags this as one of the recurring gotchas.context.WithoutCancelonly handles cancellation;DetachForBackgroundhandles the dispatch hooks too.
[!CAUTION]
The spec’s “in association with an originating client request” rule technically still applies to detached goroutines — they’re descendants of an originating call, even after that call returned. mcpkit’s runtime trusts the user code on this; a malicious or buggy handler couldDetachForBackgroundand originate reverse calls indefinitely against a session it has no ongoing forward call for. Hosts that care about strict enforcement should layer policy in the client-side dispatcher.
Q7 — Reverse calls vs MRTR: when to use which ¶
Both deliver “server gets data from client mid-call.” Different tradeoffs. (Full table in mrtr Q1.)
Reverse calls are simpler: one synchronous Go call inside the handler, the result comes back, the handler proceeds. The server holds in-flight state (parked goroutine, open transport channel) for the duration. Fast inputs are great. Long waits or transport drops are bad.
MRTR (tools/call → InputRequiredResult → retry with inputResponses) is more complex on the wire but stateless on the server. The handler returns; the next round is a fresh dispatch. Multi-round-trip; survives disconnects via the signed requestState token. The right shape for long waits, detachable workloads, and idempotent retries.
The composition trick: mcpkit’s MRTR DefaultInputHandler bridges MRTR input requests through HandleServerRequestWithContext — the same dispatcher reverse calls use. So a host wired up for sampling/elicitation/roots handles MRTR’s inputs too, automatically. You don’t have to choose between supporting reverse calls or MRTR; supporting reverse calls is enough infrastructure to do both.
graph LR
H[server handler] -->|bc.Sample / bc.Elicit| RC[reverse call<br/>synchronous, in-flight]
H -->|ctx.RequestInput| MR[MRTR InputRequiredResult<br/>stateless, handler returns]
RC -->|wire: server→client request| Disp[Client.HandleServerRequest]
MR -.token-signed retry.-> H2[handler re-runs, sees inputResponses]
H2 -->|ctx.RequestInput.* OR<br/>bc.Sample/bc.Elicit| Disp
Disp -->|client.samplingHandler<br/>elicitationHandler<br/>rootsHandler| Host[host code]
Use reverse calls when latency matters and the wait is short. Use MRTR when the wait might be long, the workload is detachable, or you want the call to survive disconnects.
End-state (what downstream pages can assume) ¶
After reading this page, downstream pages can assume:
- You know what a reverse call is: server→client request originated during a forward-request handler, gated by handler context, parked on the server’s pending-id table until the client responds.
- You know the three reverse-call methods (
sampling/createMessage,elicitation/create,roots/list) and that roots is special — infrastructure-managed cache, not per-handler origination, exposed via synchronousbc.AllowedRoots(). - You know how to originate from a handler (
bc.Sample,bc.Elicit) and what gates them (client-declared capability + thesessionCtx.requesthook installed at session wiring). - You know how the client dispatches incoming reverse calls through
HandleServerRequestWithContextand that MRTR shares the same dispatcher — host code wired for reverse calls handles MRTR for free. - You know the lifetime trap (handler context dies with the forward request) and the escape (
core.DetachForBackgroundswaps in session-level push functions;context.WithoutCancelis not enough). - You know the MRTR contrast: sync-but-fragile vs. stateless-but-multi-round;
DefaultInputHandlerbridges them in the host.
Next to read ¶
- Elicitation (stub, leaf) — Form mode vs. URL mode, security implications, host-approval flows.
- Sampling (stub, leaf) — Model selection hints, context inclusion modes, host-approval loop.
- Roots/list (stub, leaf) — Filesystem roots security model, the
notifications/roots/list_changedrefresh path, why this one is infrastructure-managed. - MRTR — The stateless-server alternative; full comparison table in Q1.
- Cancellation deep-dive (stub, leaf) — How cancellation propagates from a forward call into outstanding reverse calls via the handler-context back-pointer.
- Tracing (SEP-414) (root, FAQ-style) — outbound
_meta.traceparentinjection on a server-originatedsampling/elicitation/rootsrequest is what keeps a distributed trace connected across the reverse-call hop; the chained client→server→client trace in its Q5 passes straight through this machinery.