Skip to content

Toolnexus.Mcp.Protocol.elicitation_to_request

Elixir · package toolnexus · SPEC §2 · elixir/lib/toolnexus/mcp/protocol.ex

@spec elicitation_to_request(map()) :: Toolnexus.Request.t()
def elicitation_to_request(params)
@spec answer_to_elicit_result(Toolnexus.Answer.t()) :: map()
def answer_to_elicit_result(answer)

The MCP spec lets a server pause a tools/call and ask the connecting client something — fill in a form, or go complete an OAuth flow at a URL. toolnexus doesn’t invent a second suspension mechanism for that: elicitation_to_request/1 maps the server’s elicitation/create request onto the exact same §10 Toolnexus.Request your own tools use to suspend (e.g. the question builtin), and answer_to_elicit_result/1 maps the resolved Toolnexus.Answer back onto what the MCP server expects.

  • Writing your own :wait_for for Mcp.load/2 — your function receives a Request built by this mapping; understanding the two shapes ("input" vs "authorization") tells you what to branch on.
  • Building a host that surfaces MCP elicitation the same way it surfaces your own tool suspensions — a form UI, a Telegram/Slack prompt, a CLI question — one code path handles both, because both arrive as the same Request struct.
  • Testing — construct a fake elicitation/create payload and assert what Request it becomes, without a real server or network connection.

1. Form-mode elicitation becomes a kind: "input" request

Section titled “1. Form-mode elicitation becomes a kind: "input" request”
alias Toolnexus.Mcp.Protocol
params = %{
"message" => "What is your deployment target?",
"requestedSchema" => %{
"type" => "object",
"properties" => %{"target" => %{"type" => "string", "enum" => ["staging", "prod"]}}
}
}
req = Protocol.elicitation_to_request(params)
true = req.kind == "input"
true = req.prompt == "What is your deployment target?"
true = req.url == nil
true = req.data["schema"]["properties"]["target"]["enum"] == ["staging", "prod"]
true = String.starts_with?(req.id, "elc-")
IO.puts("ok: #{req.kind}#{req.prompt}")

2. URL-mode elicitation becomes a kind: "authorization" request

Section titled “2. URL-mode elicitation becomes a kind: "authorization" request”
alias Toolnexus.Mcp.Protocol
params = %{
"mode" => "url",
"message" => "Authorize toolnexus to access your calendar",
"url" => "https://example.com/oauth/authorize?state=abc123"
}
req = Protocol.elicitation_to_request(params)
true = req.kind == "authorization"
true = req.prompt == "Authorize toolnexus to access your calendar"
true = req.url == "https://example.com/oauth/authorize?state=abc123"
true = req.data == nil
IO.puts("ok: #{req.kind}#{req.url}")

3. Round-tripping an Answer back to the MCP result shape

Section titled “3. Round-tripping an Answer back to the MCP result shape”

Three outcomes: accepted (with data), declined, or anything else treated as a cancel.

alias Toolnexus.{Answer, Mcp.Protocol}
accepted = Protocol.answer_to_elicit_result(%Answer{id: "a1", ok: true, data: %{"target" => "prod"}})
true = accepted == %{"action" => "accept", "content" => %{"target" => "prod"}}
# ok: true with no data still accepts, with empty content.
accepted_empty = Protocol.answer_to_elicit_result(%Answer{id: "a2", ok: true})
true = accepted_empty == %{"action" => "accept", "content" => %{}}
declined = Protocol.answer_to_elicit_result(%Answer{id: "a3", ok: false, reason: "declined"})
true = declined == %{"action" => "decline"}
# Anything that isn't an explicit decline is a cancel — e.g. a timeout upstream.
cancelled = Protocol.answer_to_elicit_result(%Answer{id: "a4", ok: false, reason: "timeout"})
true = cancelled == %{"action" => "cancel"}
IO.puts("ok: accept/decline/cancel all mapped")
MCP elicitation/create params §10 Request
message prompt
mode == "url" kind: "authorization", and url carries params["url"]
mode != "url" (form) kind: "input", and data.schema carries params["requestedSchema"] (when present)
id is freshly generated ("elc-<ts36>-<seq>")
§10 Answer MCP elicit result
ok: true %{"action" => "accept", "content" => data || %{}}
ok: false, reason: "declined" %{"action" => "decline"}
ok: false, any other reason %{"action" => "cancel"}