Skip to content

toolnexus.mcp/elicitation->request

Clojure (JVM) + cljgo · package net.clojars.muthuishere/toolnexus · SPEC §2 · clojure/src/toolnexus/mcp.cljc

;; MCP `elicitation/create` params -> a §10 Request
(toolnexus.mcp/elicitation->request params)
;; => {:id "elc-<mono-ms>-<n>"
;; :kind "input" | "authorization"
;; :prompt "…"
;; :url "…" ; URL mode only
;; :data {:schema {…}}} ; form mode only
;; a resolved §10 Answer -> an MCP ElicitResult
(toolnexus.mcp/answer->elicit-result answer)
;; => {:action "accept" :content {…}} | {:action "decline"} | {:action "cancel"}
;; one server-initiated JSON-RPC request -> the reply to write back, or nil
(toolnexus.mcp/server-request-response wait-for msg)
;; => {:jsonrpc "2.0" :id 77 :result {:action "accept" :content {…}}}
;; | {:jsonrpc "2.0" :id 9 :error {:code -32601 :message "Method not found"}}

elicitation/create is the one place in SPEC §2 where traffic flows server to client: a connected server, in the middle of serving your tools/call, asks the user something. These three functions are the bridge from that reverse request onto §10 — the same suspension contract a tool of your own would use — and back again.

The mapping is pinned, not improvised. Form mode (the default) becomes a kind:"input" Request and carries the server’s requestedSchema at data.schema. URL mode becomes a kind:"authorization" Request carrying the url — and deliberately carries no schema, because a credential must never be collected through a form. The Answer maps back onto MCP’s three actions: ok accepts (with data as the content), reason "declined" declines, and anything else — cancelled, expired, no reason at all — cancels.

Both directions are pure functions, which is what makes them byte-parity testable against the other ports without a live peer. server-request-response is the one that joins them: it takes your wait-for and one inbound message and returns the JSON-RPC frame to write back.

The wiring is automatic. Pass {:wait-for f} to from-config or toolnexus.mcp/connect, and the reader loop answers a server’s elicitation inline, on the reader thread, while the tools/call that triggered it is still in flight. That is what §10 requires: the call resumes when the answer is written, and nothing re-executes. Without a wait-for, initialize never advertises capabilities.elicitation, so a spec-compliant server simply never asks — and one that asks anyway gets a clean -32601 Method not found rather than a hang.

  • You already pass :wait-for and want to know exactly what your callback will receive and what it is allowed to return. That is the mapping table below.
  • Testing your host’s resolver — feed elicitation->request a params map and assert on the Request, with no server, no socket and no timing.
  • Implementing a different transport or host loopserver-request-response is the whole policy in one call, including the refusal and the throw-becomes-cancel rule.

Note the deliberate degradation: advertising a capability you would then have to refuse is worse than not advertising it. Absent a wait-for, the port promises nothing and the server routes around it.

MCP elicitation/create params §10 Request
mode absent, or anything but "url" :kind "input"
mode: "url" :kind "authorization", with :url copied across
message :prompt"" when absent, never nil
requestedSchema, form mode :data {:schema …}
requestedSchema, URL mode dropped — no schema on an authorization request
:id, always: "elc-" plus a monotonic reading plus a counter, unique per process
§10 Answer MCP ElicitResult
{:ok true :data d} {:action "accept" :content d}
{:ok true} {:action "accept" :content {}}
{:ok false :reason "declined"} {:action "decline"}
{:ok false :reason "cancelled"}, or no reason at all {:action "cancel"}
your resolver throws {:action "cancel"} — §0.3 isolation holds at this boundary too
(require '[toolnexus.mcp :as mcp]
'[toolnexus.client :as client])
(defn resolve-request [request]
(case (:kind request)
;; open a browser, wait for the callback, then say yes
"authorization" (client/make-answer (:id request) true)
;; show a form built from (get-in request [:data :schema])
(client/make-answer (:id request) true {:name "Muthu"})))
(def res
(mcp/from-config {:mcpServers {"forms" {:command ["node" "forms-server.js"] :timeout 5000}}}
{:wait-for resolve-request}))
(try
;; a tools/call that triggers an elicitation now completes in ONE call —
;; the answer is written back while the call is still in flight
(:statuses res)
(finally
(mcp/disconnect-all res)))

toolnexus.core/build takes the same :wait-for and threads it into the MCP source for you, so an application normally sets it once, there.

(require '[toolnexus.mcp :as mcp])
;; form mode
(mcp/elicitation->request
{:message "What is your name?"
:requestedSchema {:type "object" :properties {:name {:type "string"}}}})
;; => {:id "elc-…-1"
;; :kind "input"
;; :prompt "What is your name?"
;; :data {:schema {:type "object" :properties {:name {:type "string"}}}}}
;; URL mode — carries the url, and no schema
(mcp/elicitation->request
{:mode "url" :message "Authorize us"
:url "https://example.test/oauth"
:requestedSchema {:type "object"}})
;; => {:id "elc-…-2" :kind "authorization" :prompt "Authorize us"
;; :url "https://example.test/oauth"}
;; a params map with nothing in it still yields a usable Request
(:prompt (mcp/elicitation->request {})) ;; => ""

Ids are opaque and per-process, built from a monotonic reading plus a counter. Uniqueness is the only property anything depends on — and the counter, not the clock, is what guarantees it — so never compare an id across processes or across ports.

(require '[toolnexus.mcp :as mcp])
;; accept
(mcp/server-request-response
(fn [request] {:ok true :data {:name "muthu"}})
{:jsonrpc "2.0" :id 77 :method "elicitation/create" :params {:message "Who are you?"}})
;; => {:jsonrpc "2.0" :id 77 :result {:action "accept" :content {:name "muthu"}}}
;; decline — the session carries on
(mcp/server-request-response
(fn [_] {:ok false :reason "declined"})
{:id 3 :method "elicitation/create" :params {}})
;; => {:jsonrpc "2.0" :id 3 :result {:action "decline"}}
;; a resolver that blows up becomes a cancel, never a dead connection
(mcp/server-request-response
(fn [_] (throw (ex-info "boom" {})))
{:id 4 :method "elicitation/create" :params {}})
;; => {:jsonrpc "2.0" :id 4 :result {:action "cancel"}}
;; anything else the server asks for is refused cleanly
(mcp/server-request-response (fn [_] {:ok true}) {:id 9 :method "sampling/createMessage"})
;; => {:jsonrpc "2.0" :id 9 :error {:code -32601 :message "Method not found"}}
;; and with no resolver there is nothing to answer with
(get-in (mcp/server-request-response nil {:id 1 :method "elicitation/create"}) [:error :code])
;; => -32601

It returns nil for a message that is not a server-initiated request at all — a response to one of our ids, or an id-less notification. Only an inbound message carrying both an id and a method is a reverse request, which is exactly the distinction the reader loop makes before calling this.

The reverse-request channel is wired on the stdio leg, where the reader loop is watching the child’s stdout continuously; the elicitation capability itself is advertised on both transports whenever a wait-for is present.