Skip to content

mcpx: Phase 2 of #1834 — sampling message + result validation at the broker (pairing, era shape, no silent fill-in) #1836

Closed

Description

@dezren39

Phase 2 of #1834 — sampling message + result validation at the broker

Design: #1834 (mcpx sampling trust boundary). Design doc: files/designs/code-mcpx-sampling-trust-boundary.md in the keep-the-four-repos-healthy goal workspace. Origin: dezren39/darwin-nix#256.

Problem

Sampling messages and results are relayed without validation, in either direction. sampleViaBroker (pkgs/mcpx/internal/daemon/hooks.go) unmarshals only maxTokens/systemPrompt from a request — the messages array is never inspected, so tool-result messages holding non-tool_result content, or a tool_result whose id no earlier tool_use issued, pass through untouched (compare register SMP-06). On return the only checks are JSON-object / not-null, after which a missing role/model is silently filled in (result["role"] = "assistant", result["model"] = "unknown") and the result is never checked against its request: stopReason: "toolUse" or tool_use content against a tools-less request, and era-wrong content shapes (2025-11-25 made message content single-or-array; earlier eras single only — SMP-05), all relayed as-is. Gaps: sampling-both-validate-message-content (client + server rows) in pkgs/mcpx/internal/conformance/coverage_clientfeatures.go.

Scope — one validator at the broker choke point

One validation helper used by sampleViaBroker (the point legacy wire requests and modern input_required answers both converge on); do not copy checks into mcpclient/mcpserver separately.

  • Request side, on open (before a broker question is opened): messages decode as sampling messages for the upstream's era — content single block before 2025-11-25, single-or-array from 2025-11-25; tool-result messages hold only tool_result blocks; every tool_result id pairs with a tool_use in the preceding messages. Violations raise the typed -32602 from Phase 1 back to the upstream immediately — a malformed request must not consume an answerer's attention.
  • Result side, on answer: the result must carry role and model and era-legal content; stopReason: "toolUse" / tool_use content blocks are valid only when the request carried tools — against a tools-less request, -32602. Where the answer arrived through the broker, refuse it at the answer step with the question left open (the pattern the existing null-answer refusal in internal/elicit/elicit.go established); where discovered after acceptance, refuse upstream with -32602.
  • Remove the silent role/model fill-in. It manufactures fields the answerer never supplied and hides exactly the malformation validation exists to catch. This behaviour change is deliberate and must be visible in the PR description.
  • Model-hint mismatch is NOT a failure: model preferences are expressly ignorable, so a result naming a different model than the hint asked for passes.

Out of scope: declaring any new sampling sub-capability (that is #1753's ground), rate limits (nix#257), CLI rendering (Phase 3), a genuine pre-delivery staging hold (open question on #1834).

Open seam for triage (recorded on the design)

Validation needs the upstream's negotiated era at the broker, which today receives params, not era. Settle where era comes from (the client connection that raised the question knows it). If it cannot be threaded without new plumbing, the recorded fallback is the permissive union (single-or-array accepted in all eras) with the era-strict half stated on this issue — do not silently shrink the scope.

Equally: this phase is independent of Phase 1 in code, but raises Phase 1's typed -32602; if Phase 1 has not landed, carry the error type with this phase (triage settles the seam — not a hard gate).

Tests (defining)

  • Well-formed exchange in each era relays byte-identical (control case, both eras).
  • Tool-result message mixed with text content → -32602, no broker question opened.
  • tool_result with an unpaired id → -32602, no question opened.
  • Result missing role or model → refused (question left open on the broker path); no fill-in occurs.
  • Result with tool_use content / stopReason: "toolUse" against a tools-less request → -32602; the same result against a tools-carrying request passes.
  • Result naming a model different from the request's hint → passes.

Acceptance criteria

  • The validator lives in one place and is exercised from sampleViaBroker in both directions; no duplicated checks in mcpclient/mcpserver.
  • Request violations are refused with -32602 before a broker question opens; result violations never reach the upstream; the role/model fill-in is removed and the removal is called out in the PR.
  • Gaps sampling-both-validate-message-content (both rows) are retired.
  • The era-source question is settled on this issue (threaded era, or the recorded union fallback with reasons).

Activity

  1. added
    kind/bugSomething isn't working (prefixed form of the classic bug label)
    task/triageQueue packet kind: verify, reproduce, and route this item
    priority/normalOrdinary priority (the default)
    on Oct 28, 2026
  2. developing-today commented on Oct 28, 2026

    @developing-today
    Contributor
  3. added
    queue/queuedHas a queued task in the Supabase agent queue
    app/mcpxPackage/app: mcpx (pkgs/ or apps/ in this repo)
    effort/mMedium: expected under 1 day of work
    on Oct 28, 2026
  4. dezren39 commented on Oct 28, 2026

    @dezren39
    MemberAuthor

    Phase 2 of design #1834, filed by the design architect (2026-10-28). Triage packet enqueued to repo-health-code-pilot as task 2719 (verified: queue=repo-health-code-pilot, status=queued, last_error=null). Independent of Phase 1 in code; raises Phase 1's typed -32602 — if #1835 has not landed, this phase carries the error type (triage settles the seam).

  5. added
    work/assignedQueue worker lifecycle state
    and removed
    queue/queuedHas a queued task in the Supabase agent queue
    on Oct 29, 2026
  6. 42 remaining items

  7. dezren39 commented on Oct 30, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · done — PR #2443 merged, issue closed; Codex finding dispositioned in follow-up PR #2487

    Lane B resumed task 3052: revalidated PR #2443 (CLEAN at c9c82660, checks green, earlier Codex P2 race finding fixed + thread resolved), resolved its merge conflicts against the advanced main (merge ccf85113: modern.go kept Phase 2's rpcErrorFor, which Phase 1's CodedError.RPCCode() was designed for; conformance matrix regenerated; mcpclient/daemon/elicit/conformance suites pass on the laptop, incl. -race sampling tests). The PR was then re-enqueued by another lane and merged 2026-10-30T01:08:19Z as 390e8cad; #1836 auto-closed.

    One Codex P2 round landed on ccf85113 minutes before that merge (block-field validation). I verified it valid and fixed it — follow-up PR #2487 (c882c9b3: typed field validation in decodeSampleBlock + regression tests; thread on #2443 replied and resolved). Task 3052 completes as fixed_merged; #2487's landing is tracked in the backlog.

  8. added a commit that references this issue on Oct 30, 2026
  9. dezren39 commented on Nov 3, 2026

    @dezren39
    MemberAuthor

    agent-note:v1
    state: triaged (coverage pass)
    Coverage pass 2026-11-03: reviewed in the fleet-wide per-issue pass (drift / overlap / already-handled / stale / blockers / ordering). Verdict: still live; queued in repo-health-code-pilot as triage at priority 40 (priority/normal). If this is already handled at current main, the triage closes it with evidence; if it needs splitting, the triage files the split.
    Found via: coverage pass 2026-11-03

  10. dezren39 commented on Nov 3, 2026

    @dezren39
    MemberAuthor

    ✅ Triage verdict — already done. Closing with evidence; no implementation needed.

    Landing evidence

    As-built at current main d3476bce7 (2026-11-03)

    • One validator pair, elicit.ValidateSampleRequest / elicit.ValidateSampleResult (pkgs/mcpx/internal/elicit/sampling.go:160, :218), used only from the broker choke point: sampleViaBroker (internal/daemon/hooks.go:315) validates the request at L329 — before broker.OpenRequest — and the answer at L394, before it is returned upstream. No duplicated checks in mcpclient/mcpserver.
    • Request side: era content shape, tool-result-only messages, and tool_use/tool_result id pairing; violations return elicit.SamplingInvalidParams (typed -32602) with no broker question opened.
    • Result side: role/model required, era-legal content, toolUse only when the request carried tools; the silent role/model fill-in is gone — the code comment at hooks.go:384-388 records the deliberate behaviour change. Model-hint mismatch still passes (hints are ignorable).
    • Era seam (the open question this issue recorded): settled by threading the negotiated era on ctx — mcpclient.SamplingEraFrom(ctx) at hooks.go:325-327; the permissive union applies only when no era reaches the broker (direct handler calls in tests). No signature change to ElicitHandler/pool.Hooks.
    • Conformance: conformance.TestSamplingValidation (internal/conformance/sampling_validation_test.go:13) covers sampling-both-validate-message-content on both rows — coverage_clientfeatures.go:57-62 now maps the gap through SReq/CReq required-coverage forms pointing at that test, so it is no longer an open gap.

    Verification run for this triage

    • cd pkgs/mcpx && go test ./internal/conformance -run TestSamplingValidation -count=1 → ok (0.010s); the tree tested has zero diff in internal/elicit, internal/daemon/hooks.go and internal/conformance against origin/main d3476bce7.
    • Fresh state: issue OPEN, no held / user/owned / do-not-merge labels; no open PR or queue/*1836* branch remains.

    Labels: cleared the stale in-flight markers (queue/queued, status/needs-triage, status:in-review); close follows. Queue task 7747 completes already_done.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

app/mcpxPackage/app: mcpx (pkgs/ or apps/ in this repo)effort/mMedium: expected under 1 day of workkind/bugSomething isn't working (prefixed form of the classic bug label)priority/normalOrdinary priority (the default)source/design-architectFiled by the repo design architect jobtask/triageQueue packet kind: verify, reproduce, and route this item

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions