feat(#544): render MRTR input_required on the public MCP endpoint - #677
Merged
Conversation
The client half of MRTR shipped in PR #550: when a REMOTE MCP server answers a `tools/call` with `resultType: "input_required"`, `McpManager` parks the call and omadia renders an input card. Nothing in omadia's OWN MCP server path ever produced that shape, so the public endpoint could only ever answer with a result or an error. A tool that needed one more value from the human had two options, both bad: fail with prose no machine can act on, or guess. This is the missing direction. A dispatched tool signals "I need these fields" in-band, and the endpoint renders it as MRTR so an ordinary MCP client can collect the values and retry. Why in-band rather than a third `ToolDispatchResult` variant: that type is shared by every dispatch surface — chat, routines, sub-agents, this endpoint — and a new variant would force all of them to grow a branch for a case only this endpoint can render. So the signal rides the result string as a JSON sentinel (`_pendingInputRequest`), the same convention `_pendingUserChoice` already uses for plugin-emitted choice cards. A surface that does not understand it shows the tool's own message and is no worse off than before. Why the retry needs no server-side state: MRTR has the CLIENT retry the original request with `inputResponses` added, so the arguments come back from the caller. That is what keeps this working on a deliberately stateless endpoint — omadia parks nothing, holds no correlation id, and any instance behind the load balancer can serve the retry. The retry key is `inputResponses`, the SAME key `REPLAY_ARG_KEY` uses on the client half, so both directions speak one vocabulary. Field validation reuses `parseMcpInputRequests`, so a request omadia SENDS and one it RECEIVES are clamped identically and neither direction is the lenient one. Three outcomes when the sentinel is present: - malformed request → an ordinary tool error naming the reason, rather than shipping our raw sentinel JSON to the caller as if it were an answer; - already answered → an ordinary tool error, mirroring `MCP_INPUT_MAX_REPLAY_DEPTH` on the client half: one round trip, not a loop; - otherwise → the MRTR body. The MRTR body never carries `isError` — the client half's `isInputRequiredResult` refuses to read an `isError` result as a card, so flagging it would make omadia's own endpoint unreadable by omadia's own client. A dispatch that genuinely failed is excluded for the same reason a failure has no pending continuation. `content` is populated alongside `resultType` so a pre-MRTR client still shows the human what is being asked instead of an empty result, and the #647 provenance `_meta` rides the new body shape unchanged. Documented in the endpoint README (a public API gaining a response shape without documentation is a gap, not a detail). Tests (`test/publicMcp/publicMcpInputRequired.test.ts`, 11): the pure module (parse/bounce/render) plus the real mounted endpoint through `startHarness` — the same `mountPublicMcp` production calls. The round trip is asserted whole: ask → retry with `inputResponses` → the tool receives them verbatim and finishes. A test that only proved the ASK would pass against a broken retry leg. Mutation check, verified red: disabling the rendering kills 4 of the 6 endpoint tests. The two that stay green are the ones asserting the feature must NOT fire (a failed call, an ordinary result), which is the correct signature. Verification: `test/publicMcp/*` 203/203 pass, `tsc --noEmit` clean, eslint clean, `typecheck:test` ratchet 406 = baseline. Depends on #570 (PR #676) for the flow to be observable end-to-end in a default configuration — the client half is interned without it. Closes #544
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What was missing
The client half of MRTR shipped in PR #550: when a remote MCP server answers a
tools/callwithresultType: "input_required",McpManagerparks the call and omadia renders an input card. Nothing in omadia's own MCP server path ever produced that shape, so the public endpoint could only ever answer with a result or an error. A tool that needed one more value from the human had two options, both bad: fail with prose no machine can act on, or guess.This is the missing direction.
Design
A dispatched tool signals "I need these fields" in-band; the endpoint renders it as MRTR so an ordinary MCP client can collect the values and retry.
Why in-band, not a third
ToolDispatchResultvariant. That type is shared by every dispatch surface — chat, routines, sub-agents, this endpoint. A new variant would force all of them to grow a branch for a case only this endpoint can render. So the signal rides the result string as a JSON sentinel (_pendingInputRequest), the same convention_pendingUserChoicealready uses for plugin-emitted choice cards. A surface that does not understand it shows the tool's ownmessageand is no worse off than before.Why the retry needs no server-side state. MRTR has the client retry the original request with
inputResponsesadded, so the arguments come back from the caller. That is what keeps this working on a deliberately stateless endpoint — omadia parks nothing, holds no correlation id, and any instance behind the load balancer can serve the retry.One vocabulary in both directions. The retry key is
inputResponses, the same keyREPLAY_ARG_KEYuses on the client half. Field validation reusesparseMcpInputRequests, so a request omadia sends and one it receives are clamped identically (max 8 fields, names ≤64, labels ≤120) and neither direction is the lenient one.Outcomes when the sentinel is present
inputResponsespresent)MCP_INPUT_MAX_REPLAY_DEPTH— one round trip, not a loopThe MRTR body never carries
isError: the client half'sisInputRequiredResultrefuses to read anisErrorresult as a card, so flagging it would make omadia's own endpoint unreadable by omadia's own client. A dispatch that genuinely failed is excluded for the same reason — a failure has no pending continuation.contentis populated alongsideresultTypeso a pre-MRTR client still shows the human what is being asked instead of an empty result, and the #647 provenance_metarides the new body shape unchanged.Documentation
The endpoint README gains a section: the response shape, the retry, and the four things a consumer has to know before building on it (nothing is parked; one round trip only;
message/labels are tool-authored and must be rendered as untrusted text;secretis advisory about display, not transport). A public API gaining a response shape without documentation is a gap, not a detail.Tests
test/publicMcp/publicMcpInputRequired.test.ts— 11 tests across two layers:absent, notunusable, because confusing those two would turn every result that happens to be JSON into a failed call.startHarness— the samemountPublicMcpproduction calls, not a hand-built app. (The harness doc comment records why that distinction has already bitten this repo once.)The round trip is asserted whole: ask → the caller retries with
inputResponses→ the tool receives them verbatim and finishes. A test that only proved the ask would pass against an endpoint whose retry leg is broken, which is the half that makes the feature usable.Mutation check (verified red)
Disabling the rendering kills 4 of the 6 endpoint tests. The two that stay green are exactly the ones asserting the feature must not fire (a failed call carrying the sentinel; an ordinary result) — which is the correct signature for this mutation, not a gap.
Verification
test/publicMcp/*: 203/203 passtsc --noEmit(middleware): cleaneslinton both changed/added sources: cleannpm run typecheck:test: 406 known errors, baseline 406, no regressionsRelationship to #570 / PR #676
Independent code (server path in
src/mcp/, vs. the client path inpackages/harness-orchestrator/), so this branched offmainand does not stack.#676 has since merged (
1c243e0d) and is merged into this branch, so the MRTR flow is now observable end-to-end in a default configuration — previously the Privacy Shield interned the client half's sentinel and the card never rendered. Both halves were re-verified together against the merged tree:test/publicMcp/*+test/orchestrator/*+test/mcpPendingInput.test.ts= 478/478 pass,tsc --noEmitclean,typecheck:testratchet 406 = baseline.Closes #544
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.