Skip to content

feat(#544): render MRTR input_required on the public MCP endpoint - #677

Merged
Weegy merged 3 commits into
mainfrom
feat/544-mrtr-server-half
Aug 13, 2026
Merged

feat(#544): render MRTR input_required on the public MCP endpoint#677
Weegy merged 3 commits into
mainfrom
feat/544-mrtr-server-half

Conversation

@Weegy

@Weegy Weegy commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

What was missing

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.

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 ToolDispatchResult variant. 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 _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.

One vocabulary in both directions. The retry key is inputResponses, the same key REPLAY_ARG_KEY uses on the client half. Field validation reuses parseMcpInputRequests, 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

Case Result
malformed request ordinary tool error naming the reason — never ships our raw sentinel JSON as if it were an answer
already answered (inputResponses present) ordinary tool error, mirroring MCP_INPUT_MAX_REPLAY_DEPTH — 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.

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; secret is 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:

  1. The pure module — parsing, the bounce predicate, the rendered body. Notably: an ordinary JSON result must parse as absent, not unusable, because confusing those two would turn every result that happens to be JSON into a failed call.
  2. The real mounted endpoint through startHarness — the same mountPublicMcp production 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 pass
  • tsc --noEmit (middleware): clean
  • eslint on both changed/added sources: clean
  • npm run typecheck:test: 406 known errors, baseline 406, no regressions

Relationship to #570 / PR #676

Independent code (server path in src/mcp/, vs. the client path in packages/harness-orchestrator/), so this branched off main and 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 --noEmit clean, typecheck:test ratchet 406 = baseline.

Closes #544


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

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
@Weegy Weegy self-assigned this Aug 13, 2026
@Weegy
Weegy merged commit 0a876c3 into main Aug 13, 2026
9 checks passed
@Weegy
Weegy deleted the feat/544-mrtr-server-half branch August 14, 2026 06:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support MRTR (resultType: input_required) — mid-call user input for MCP tools

1 participant