Skip to content

Elicitation ledger, every question a server put to the user and the answer it got #204

Description

@kerlenton

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 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.

methods := make([]string, 0, len(r.InputRequests))
for _, raw := range r.InputRequests {
	var req struct {
		Method string `json:"method"`
	}
	if json.Unmarshal(raw, &req) == nil && req.Method != "" {
		methods = append(methods, req.Method)
	}
}

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.

$ mcpsnoop export --format json elicit.jsonl | jq -c '.calls[] | {id, tool_name, duration_ms, result}'
{"id":"1","tool_name":"create_account","duration_ms":9600,"result":{"resultType":"complete","content":[{"type":"text","text":"account created"}]}}
{"id":"3","tool_name":"sync_calendar","duration_ms":28900,"result":{"resultType":"complete","content":[{"type":"text","text":"synced"}]}}
{"id":"5","tool_name":"login_legacy","duration_ms":3100,"result":{"resultType":"complete","content":[{"type":"text","text":"cannot continue"}],"isError":true}}

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.

$ mcpsnoop export --format json elicit.jsonl | jq -c '.events[] | select(.raw.result.resultType=="input_required") | {seq, id, mode: .raw.result.inputRequests | to_entries[0].value.params.mode}'
{"seq":2,"id":"1","mode":"form"}
{"seq":6,"id":"3","mode":"url"}
{"seq":10,"id":"5","mode":"form"}

$ mcpsnoop export --format json elicit.jsonl | jq -c '.events[] | select(.raw.params.inputResponses) | {seq, id, action: (.raw.params.inputResponses | to_entries[0].value.action)}'
{"seq":3,"id":"2","action":"accept"}
{"seq":7,"id":"4","action":"accept"}
{"seq":11,"id":"6","action":"decline"}

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.

$ mcpsnoop export --format json --redact-secrets elicit.jsonl | jq '.events[] | select(.seq==10) | .raw.result.inputRequests'
{
  "creds": {
    "method": "elicitation/create",
    "params": {
      "message": "Enter your admin password to continue",
      "mode": "form",
      "requestedSchema": {
        "properties": {
          "password": "[REDACTED]"
        },
        "required": ["password"],
        "type": "object"
      }
    }
  }
}

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.
  • Replaying an elicitation exchange, which needs the MRTR retry synthesised and belongs with Replay is unavailable for sessions captured over HTTP #164.
  • 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
  • docs/TRY_IT.md and README.md for the new panel

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requesthelp wantedExtra attention is needed

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions