You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Elicitation is the one path in MCP where a human types data into a server. Under MRTR the question and the answer are no longer two halves of one JSON-RPC exchange, they are fields buried inside two different requests. The 2026-07-28 elicitation page is explicit that the request is "delivered inside InputRequiredResult.inputRequests" and the answer comes back "returned inside inputResponses on the retried request" (https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation).
mcpsnoop already does the hard half of that. matchRetry at internal/store/store.go:1378 links a retry back to the operation it continues, Ingest applies the link at internal/store/store.go:493, and the TUI renders it as continues id N at internal/tui/view.go:790. What it never does is read either side of the exchange.
parseInputRequired at internal/store/store.go:1289 opens every inputRequests entry and keeps exactly one field out of it, the method name.
retrySignals at internal/store/store.go:1324 does the mirror thing on the way back. It takes the key set of inputResponses through sortedKeySet (internal/store/store.go:1342) purely so the retry can be matched, and throws the values away, so action never reaches the store at all.
A grep over non-test Go for "mode", "action" and requestedSchema returns a single hit, and it is unrelated, the json:"url" tag on the HAR entry struct at internal/exporter/har.go:53. The word elicitation appears in non-test code twice, in the input-request allowlist at internal/store/conformance.go:29 and in the capability display order at internal/tui/view.go:1785, where it renders as one filled or hollow dot with no room for the form and url sub-capabilities the spec now defines.
What that looks like on a capture
A three-call stdio capture, each call answered with an InputRequiredResult carrying one elicitation/create and then retried. Here is the url-mode pair on the wire.
{"jsonrpc":"2.0","id":3,"result":{"resultType":"input_required","inputRequests":{"auth":{"method":"elicitation/create","params":{"mode":"url","url":"https://mcp.example.com/ui/set_api_key?flow=abc123","message":"Please provide your API key to continue."}}},"requestState":"opaque-state-2"}}
{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"sync_calendar","arguments":{},"inputResponses":{"auth":{"action":"accept"}},"requestState":"opaque-state-2","_meta":{...}}}
Every structured surface keeps the final result and drops the question. The retry folds into the root call, so the interim InputRequiredResult is overwritten.
login_legacy shows the cost of that. It reads as a plain tool error at 3.1 s. What actually happened is that the server asked for an admin password in form mode and the user declined. Nothing in that line says so.
The bytes are on the wire, they are simply unaggregated. Recovering them today means hand-written jq that produces two unpaired streams, because the thing that pairs them is the MRTR link that only mcpsnoop knows about.
Nobody can answer "what did this server ask my user for, in which mode, and did they say yes" without doing that by hand and knowing the MRTR rules well enough to pair the halves correctly.
One decision to make first
Does a ledger row ever carry a submitted value from content.
The recommendation is no, never. The raw frames are already in the JSONL log for anyone who needs the values, and the ledger is a summary surface built to be exported and pasted around, so a row should carry the shape of the question and not its answer. That keeps the feature entirely out of the redaction story, since no new value ever enters a new field, and it means a url-mode row is safe by construction, which matters because the spec puts credentials there on purpose.
Worth knowing before anyone builds this, the redaction key rules already touch the question side. The schema exemption at internal/proxy/redact.go:500 is scoped to positionSchema, and the only way into that position is result.tools[].inputSchema or outputSchema (internal/proxy/redact.go:482). An elicitation requestedSchema is not on that path, so a capture taken or read with --redact-secrets loses the declared type of any property whose name matches a key rule.
The field name survives, which is the column that carries the meaning, so the ledger stays useful under redaction. It should render the type as unknown rather than as the placeholder string. Extending the exemption is a separate change and is out of scope here.
Proposed change
A per-session elicitation ledger, one row for every elicitation/create observed inside an inputRequests map, paired with the answer that came back on the matched retry.
Each row records
the operation that triggered it, the root request id and its method and target name, so a row can jump to the call
the inputRequests key the question was filed under, since one result may carry several
mode, defaulting to form when absent, because the spec says clients "MUST treat requests without a mode field as form mode"
message verbatim, it is written for a human to read
for form mode, the property names under requestedSchema.properties and each one's declared type, names and types only
for url mode, the full url and its host parsed out, since the spec makes the client "show the full URL to the user for examination before consent" and "highlight the domain of the URL"
the action from the identically keyed inputResponses entry on the matched retry, one of accept, decline or cancel
the elapsed time between the InputRequiredResult frame and the retry frame, which is roughly how long the human took
a pending marker when no retry ever arrived
Where it surfaces
a new TUI overlay alongside the existing capabilities and tool-summary panels, following the pattern at internal/tui/model.go:59
a new top-level key in the json export, built in Build at internal/exporter/exporter.go:290, so check and downstream tooling can read it
a section in the text and html exports
Everything the ledger needs is already correlated. The question half is parsed at internal/store/store.go:1289 and thrown away, the answer half at internal/store/store.go:1324 and thrown away, and the link between them is matchRetry at internal/store/store.go:1378, already surfaced as MRTRRoot at internal/store/views.go:107.
Acceptance criteria
parseInputRequired keeps the params of each elicitation/create entry, not only its method, without changing what matchRetry keys on
a request with no mode field is recorded as form mode
form-mode rows list the requestedSchema property names and each declared type, and mark a property whose subschema was replaced by a redaction placeholder as an unknown type rather than printing the placeholder
url-mode rows carry the full url and its host, and never claim a form field list
retrySignals yields the action per key without loosening the existing matching, and the ledger pairs it by the identical inputRequests and inputResponses key
all three actions round-trip, and an unrecognised action string is recorded verbatim rather than dropped
a question with no matching retry shows as pending, with no answer and no elapsed time
one InputRequiredResult carrying several inputRequests produces one row per elicitation/create and no row for a sampling/createMessage or roots/list sibling
a chained exchange, where the retry is itself answered with another InputRequiredResult, produces one row per round and all of them point at the same root call
no value from content appears in any ledger field, asserted by a test that answers with a distinctive string and greps every export format for it
the ledger appears in the json export, in the text and html exports, and in a TUI overlay
a session containing no elicitation adds nothing to any output, and check exit codes are unchanged for every existing fixture
Out of scope
No new check signal. Nothing here warns, and nothing here changes a check exit code. A ledger records what happened, it does not judge it.
The form-mode credential rule stays out. The spec says servers "MUST NOT use form mode elicitation to request sensitive information such as passwords, API keys, access tokens, or payment credentials", but deciding whether a given field is sensitive is a guess about intent, and that heuristic shape was built here once and reverted. The ledger lists the field names and lets a human draw the conclusion.
The mode-versus-capability rule is genuinely decidable from the wire, since the client declares elicitation.form and elicitation.url in _meta and an empty object means form only, so "Servers MUST NOT send elicitation requests with modes that are not supported by the client" could become a real warning. It should be its own issue, filed after mode is parsed.
Extending the redaction schema exemption to requestedSchema.
Splitting the elicitation capability dot into form and url in the capability inspector.
Files
internal/store/store.go, inputRequired at :1275, parseInputRequired at :1289, retrySignals at :1324, matchRetry at :1378, and the link applied in Ingest at :493
internal/store/views.go, the read-side snapshot types, MRTRRoot at :107 and its assignment at :376
internal/exporter/exporter.go, Build at :290 and the export structs from :142
internal/tui/model.go at :59 for the overlay mode, internal/tui/view.go for rendering, internal/tui/keys.go at :50 for the binding next to c and s
internal/proxy/redact.go at :482 and :500, read-only context for the placeholder case above
Context
Elicitation is the one path in MCP where a human types data into a server. Under MRTR the question and the answer are no longer two halves of one JSON-RPC exchange, they are fields buried inside two different requests. The 2026-07-28 elicitation page is explicit that the request is "delivered inside
InputRequiredResult.inputRequests" and the answer comes back "returned insideinputResponseson the retried request" (https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation).mcpsnoop already does the hard half of that.
matchRetryat internal/store/store.go:1378 links a retry back to the operation it continues,Ingestapplies the link at internal/store/store.go:493, and the TUI renders it ascontinues id Nat internal/tui/view.go:790. What it never does is read either side of the exchange.parseInputRequiredat internal/store/store.go:1289 opens everyinputRequestsentry and keeps exactly one field out of it, the method name.retrySignalsat internal/store/store.go:1324 does the mirror thing on the way back. It takes the key set ofinputResponsesthroughsortedKeySet(internal/store/store.go:1342) purely so the retry can be matched, and throws the values away, soactionnever reaches the store at all.A grep over non-test Go for
"mode","action"andrequestedSchemareturns a single hit, and it is unrelated, thejson:"url"tag on the HAR entry struct at internal/exporter/har.go:53. The word elicitation appears in non-test code twice, in the input-request allowlist at internal/store/conformance.go:29 and in the capability display order at internal/tui/view.go:1785, where it renders as one filled or hollow dot with no room for theformandurlsub-capabilities the spec now defines.What that looks like on a capture
A three-call stdio capture, each call answered with an
InputRequiredResultcarrying oneelicitation/createand then retried. Here is the url-mode pair on the wire.{"jsonrpc":"2.0","id":3,"result":{"resultType":"input_required","inputRequests":{"auth":{"method":"elicitation/create","params":{"mode":"url","url":"https://mcp.example.com/ui/set_api_key?flow=abc123","message":"Please provide your API key to continue."}}},"requestState":"opaque-state-2"}} {"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"sync_calendar","arguments":{},"inputResponses":{"auth":{"action":"accept"}},"requestState":"opaque-state-2","_meta":{...}}}Every structured surface keeps the final result and drops the question. The retry folds into the root call, so the interim
InputRequiredResultis overwritten.login_legacyshows the cost of that. It reads as a plain tool error at 3.1 s. What actually happened is that the server asked for an admin password in form mode and the user declined. Nothing in that line says so.The bytes are on the wire, they are simply unaggregated. Recovering them today means hand-written jq that produces two unpaired streams, because the thing that pairs them is the MRTR link that only mcpsnoop knows about.
Nobody can answer "what did this server ask my user for, in which mode, and did they say yes" without doing that by hand and knowing the MRTR rules well enough to pair the halves correctly.
One decision to make first
Does a ledger row ever carry a submitted value from
content.The recommendation is no, never. The raw frames are already in the JSONL log for anyone who needs the values, and the ledger is a summary surface built to be exported and pasted around, so a row should carry the shape of the question and not its answer. That keeps the feature entirely out of the redaction story, since no new value ever enters a new field, and it means a url-mode row is safe by construction, which matters because the spec puts credentials there on purpose.
Worth knowing before anyone builds this, the redaction key rules already touch the question side. The schema exemption at internal/proxy/redact.go:500 is scoped to
positionSchema, and the only way into that position isresult.tools[].inputSchemaoroutputSchema(internal/proxy/redact.go:482). An elicitationrequestedSchemais not on that path, so a capture taken or read with--redact-secretsloses the declared type of any property whose name matches a key rule.The field name survives, which is the column that carries the meaning, so the ledger stays useful under redaction. It should render the type as unknown rather than as the placeholder string. Extending the exemption is a separate change and is out of scope here.
Proposed change
A per-session elicitation ledger, one row for every
elicitation/createobserved inside aninputRequestsmap, paired with the answer that came back on the matched retry.Each row records
inputRequestskey the question was filed under, since one result may carry severalmode, defaulting toformwhen absent, because the spec says clients "MUST treat requests without amodefield as form mode"messageverbatim, it is written for a human to readrequestedSchema.propertiesand each one's declared type, names and types onlyurland its host parsed out, since the spec makes the client "show the full URL to the user for examination before consent" and "highlight the domain of the URL"actionfrom the identically keyedinputResponsesentry on the matched retry, one ofaccept,declineorcancelInputRequiredResultframe and the retry frame, which is roughly how long the human tookWhere it surfaces
Buildat internal/exporter/exporter.go:290, socheckand downstream tooling can read itEverything the ledger needs is already correlated. The question half is parsed at internal/store/store.go:1289 and thrown away, the answer half at internal/store/store.go:1324 and thrown away, and the link between them is
matchRetryat internal/store/store.go:1378, already surfaced asMRTRRootat internal/store/views.go:107.Acceptance criteria
parseInputRequiredkeeps theparamsof eachelicitation/createentry, not only its method, without changing whatmatchRetrykeys onmodefield is recorded as form moderequestedSchemaproperty names and each declared type, and mark a property whose subschema was replaced by a redaction placeholder as an unknown type rather than printing the placeholderretrySignalsyields theactionper key without loosening the existing matching, and the ledger pairs it by the identicalinputRequestsandinputResponseskeyInputRequiredResultcarrying severalinputRequestsproduces one row perelicitation/createand no row for asampling/createMessageorroots/listsiblingInputRequiredResult, produces one row per round and all of them point at the same root callcontentappears in any ledger field, asserted by a test that answers with a distinctive string and greps every export format for itcheckexit codes are unchanged for every existing fixtureOut of scope
checksignal. Nothing here warns, and nothing here changes acheckexit code. A ledger records what happened, it does not judge it.elicitation.formandelicitation.urlin_metaand an empty object means form only, so "Servers MUST NOT send elicitation requests with modes that are not supported by the client" could become a real warning. It should be its own issue, filed aftermodeis parsed.requestedSchema.elicitationcapability dot intoformandurlin the capability inspector.Files
internal/store/store.go,inputRequiredat :1275,parseInputRequiredat :1289,retrySignalsat :1324,matchRetryat :1378, and the link applied inIngestat :493internal/store/views.go, the read-side snapshot types,MRTRRootat :107 and its assignment at :376internal/exporter/exporter.go,Buildat :290 and the export structs from :142internal/tui/model.goat :59 for the overlay mode,internal/tui/view.gofor rendering,internal/tui/keys.goat :50 for the binding next tocandsinternal/proxy/redact.goat :482 and :500, read-only context for the placeholder case abovedocs/TRY_IT.mdandREADME.mdfor the new panel