Skip to content

feat(mcp): the question flow reaches external clients - #1473

Merged
yanekyuk merged 1 commit into
devfrom
feat/mcp-question-flow
Aug 20, 2026
Merged

feat(mcp): the question flow reaches external clients#1473
yanekyuk merged 1 commit into
devfrom
feat/mcp-question-flow

Conversation

@yanekyuk

Copy link
Copy Markdown
Contributor

The incident, one level down

#1472 taught list_negotiations to say the park: a client whose negotiation had sat input_required for two hours now reads "open question 1, 'Timing: This week'" in the tool result. But on the MCP surface that is where the story ended. The client could SEE the question and do NOTHING about it: the answer lane (answer_pending_question, #1466) was a chat-only persona-append tool, the owner's verdict levers (#1471) did not exist on MCP at all, get_negotiation — the tool the poller prompt says to call FIRST — still narrated a parked negotiation from a lifecycle built without the park, the one state that needs a human was the one state the status filter could not name, and read_activity_summary answered "pending questions: 0" from the retired questions table while a live park stood. This PR closes the read-and-act story (protocol 23.6.0).

What external clients can now do in the question flow

  • Seeget_negotiation carries the same park annotation the listing does (waitingOn: "you" | "counterparty", plus question/questionLabel for the caller's own side), built from the same canonical predicate (classifyInflightPark/classifyPostStallPark) over the task and messages it already holds, and the same shared question record (NegotiationListingParkHost). The park supersedes the status label in lifecycle. Turns that carry an ask_user consult now project the persisted askUser and checklist payloads verbatim — an external seat can finally see dimensions, answerhood, and settles. ownUser.principalUnreachable is re-stamped from the live isPrincipalUnreachable read (both directions), never served stale. list_negotiations gains the input_required status filter.
  • Answer — a new MCP-surface answer_pending_question routes the principal's answer over the EXISTING NegotiatorAnswerToolsHost: same readOpenQuestionsForIntent numbering, same serialized consumption queue, same feat(negotiations): consume answers — route a reply to its parked negotiations and resume #1432 resume spine. Nothing re-implemented.
  • Verdictreject_opportunity / accept_opportunity are registered on MCP for exactly the session-authenticated owner class, reusing the feat(negotiations): the owner's verdict needs a lane in their own DM #1471 negotiatorVerdictToolsHost verbatim (the Radar Skip/Start-Chat service path, outcome hooks and question retirement included).

Still waiting on external write parity (own lane, not started here): turns submitted through respond_to_negotiation/REST respond do NOT run the conclusion floor, decline law, or copy-loop guard, and cannot carry ask_user/checklist/dimension. The three negotiation tool descriptions now say this plainly instead of implying otherwise.

The new tools, verbatim

answer_pending_question — matrix rule { access: 'permission', actions: ['manage:negotiations'], reach: 'principal' }:

negotiationId: z.string()                      // the parked negotiation's task id, from list/get_negotiation
question:      z.number().int().min(1)         // the park.question number exactly as shown
answer:        z.string().min(1).max(4000)     // the user's answer, in their own words

reject_opportunity / accept_opportunity — matrix rule { access: 'human_only', reach: 'network' }:

intentId:     z.string()                       // the signal whose counterparty the owner is deciding on
counterparty: z.number().int().min(1)          // 1-based position in the actionable list (oldest first)
reason:       z.string().min(1).max(500).optional()  // the owner's OWN words, never invented

list_negotiations.status is now z.enum(['active', 'waiting_for_agent', 'input_required', 'completed', 'all']).

Design decisions, with reasons

  • The answer tool takes negotiationId, not intentId. MCP has no pinned intent, and the park annotation the client just read sits on a negotiation row — so the tool resolves scope from the object the client is looking at: negotiationId → the caller's own actor intent on that opportunity (getIntentIdsForOpportunities, caller-scoped) → the host. The question number passes through untouched. Numbers cannot drift because both the park annotation and the answer host read the same readOpenQuestionsForIntent enumeration (the fix(negotiations): a question is open because its negotiation is parked, not because its message is newest #1470 rule); the anti-divergence spec drives both surfaces from one fixture record.
  • Answer access = the sibling negotiation rules. Session humans pass on the blanket allow; the user's own agent keys need manage:negotiations — the same trust respond_to_negotiation extends, because relaying the principal's answer from the agent's own surface is exactly the fix(negotiations): a question the model wrote must not die at the schema that asked for it #1466 lane one surface over. Reach is principal: an open question is meta-network (it indexes the recipient's own signal), like the question aggregates in read_activity_summary. The recipient boundary is the host's: a question parked on the counterparty never enumerates for this caller.
  • Verdicts admit exactly the IND-593 class. mcp.server.ts already binds owner provenance for session-authenticated MCP (bindOwnerApprovalProvenance, the seam the audit pointed at) — the matrix rule is human_only (hidden from and denied to every agent principal, no permission releases it), and the handler re-checks the host-bound provenance (sessionAuthenticated === true, no agentId) so the refusal stands even if the tool were ever mis-listed. Hermes negotiator credentials fail closed as unclassified: none of the three tools was added to HERMES_AGENT_MCP_TOOL_PERMISSIONS, deliberately.
  • Registration is surface-gated in tool.registry (MCP only). The chat lane stays on the persona-append path by design, and the REST Tool API must never hand an API-key principal an owner-verdict lever — the tools simply do not exist there. No feature flag; the tool set is static per surface, so the MCP metadata cache key is untouched.
  • The verdict tools live in the opportunities capability, next to ownerApprovalProvenanceFor (no cross-capability import); the answer tool lives in negotiations. Both consume hosts through ToolDeps, injected in mcp.controller.ts beside the fix(negotiations): the listing must say the park, from the same record the question came from #1472 park host.
  • input_required filters the mid-flight task state. A post-stall park lives on a completed task that reports status: "completed" — returning it under a status filter would contradict the filter's own rows. It stays under completed, carrying its park object, and the filter description says exactly that.
  • Activity summary: pending questions ARE the parked negotiations. The adapter's pending counts now mirror the parked-negotiation reader set-wise in SQL — mid-flight: input_required + ask-user binding naming the owner as recipient (not archived); post-stall: a stalled opportunity of the owner whose negotiation's newest message is the authored ask_user gap from the owner's own agent. Keyed as negotiation_inflight/negotiation, so the existing mode→domain→permission projection is byte-identical. Retired-table pending rows contribute nothing; answered counts still read the table, where answered history legitimately lives.
  • Non-parked output is pinned byte-identical. askUser/checklist are spread only when present; park only when live; the reachability stamp only writes when it changes the truth (persisted-absent + live-reachable stays verbatim). The detail fixture pin proves it field-for-field.
  • The retired-tools pin was updated consciously, not deleted: read_pending_questions stays retired for every principal; answer_pending_question returns as a different contract, and the pin's comment says why.

Tests

New (all green):

  • negotiation.tools.detail-park.spec.ts — 10 tests: park named with the shared number, counterparty park unquoted, post-stall park, askUser/checklist projection (with settles visible; plain turns keyless), reachability re-stamped both directions + verbatim-without-resolver, non-parked byte-identical fixture pin.
  • negotiation.answer.tools.spec.ts — 9 tests: one shared question record drives both list_negotiations' park number and the answer route (the fix(negotiations): a question is open because its negotiation is parked, not because its message is newest #1470 pattern); intent resolved from the caller's own actor; non-party refused before the host; network-scope denial; unknown-number honesty; missing-host honesty; unresolvable-intent refusal; input_required filter maps to the parked state; active unchanged.
  • opportunity.verdict.tools.spec.ts — 6 tests: session-authed owner executes through the host (result names WHO the write landed on), API-key agent refused before the host, forged/missing provenance refused, unknown-counterparty re-lists, missing-host honesty.
  • mcp.authorization-policy.spec.ts — +3 tests: answer-lane rule (human allowed, manage:negotiations agent allowed, other agents permission_missing), verdicts human_only even for an all-permissions agent, Hermes unclassified on all three.
  • services/api/tests/mcp.spec.ts — retired pin updated + a new inventory/denial test: verdict tools listed for session humans only; a manage:negotiations+opportunities agent neither sees nor reaches them (MCP_CAPABILITY_DENIED, no scoped DB); the answer lane IS that agent's.
  • agent-activity.database.adapter.isolated.ts (real test DB) — parked fixtures added: mid-flight park counts, counterparty-side park does not, post-stall gap counts, terminal stall does not, retired-table pending rows contribute nothing (3 tests, green against the test database).

Suites:

  • Protocol CI isolated suite (bun run test:isolated, credential-free, the actual CI gate): 2464 pass, 0 fail across 213 files — includes every new spec.
  • src/mcp/tests/ 114 pass; targeted negotiation tool suites 99 pass; full src/negotiations/tests/ 716 pass / 2 fail — both in negotiator-discovery-query.spec.ts, the documented live-LLM eval flake (unrelated, fails on whole-dir runs).
  • services/api/tests/mcp.spec.ts: 67 pass / 4 fail — baselined with the feat(negotiations): a seed persona someone is logged in as is reachable #1462 method: detached HEAD at 1204075b07, protocol dist rebuilt, same file re-run → the identical 4 failures (database.getOpportunity is not a function in the owner-proof family) pre-exist on dev. My diff adds zero failures.
  • negotiator-answer.host / negotiation-listing-park.host / user-database / mcp.permission-refresh specs: 81 pass. CI hermetic pinning files: 72 pass. tsc --noEmit (api) and bun run build (protocol) clean. Boundaries lint: 0 violations; lockfile-versions and subtree-parity checks pass (lockfile synced via bun scripts/sync-lockfile-versions.ts).

Deferred / honest disclosure

  • External write parity (floor + decline law + copy-loop on external turns; respond schemas carrying ask_user/checklist/dimension, guaranteed refused per fix(negotiations): a question the model wrote must not die at the schema that asked for it #1466) — named follow-up, not started. The docs no longer imply it exists.
  • The consult path's dimensionless park (consultNegotiationSchema + buildExternalConsultationQuestionerPayload) — untouched.
  • The N+1 open-question reads for unpinned list_negotiations — not made worse. get_negotiation adds at most ONE host read per call (single negotiation), noted where wired.
  • packages/edge-city — the audit flagged scripts there still calling the retired read_pending_questions; in this worktree the edge-city subprojects (agentvillage*/) are EMPTY directories (uninitialized subprojects), so the callers could not be verified or fixed here. The follow-up stands, in that package's own repo.
  • questioner.adapter.ts TODO comment is now slightly stale (it still lists the activity-summary pending counts as a questions-table reader). A comment-only fix was written and then reverted: the file carries a pre-existing no-restricted-imports lint error that the pre-commit hook fails on for ANY edit to the file. Fix the import violation and the comment together in a dedicated change.
  • REST Tool API: the shared get_negotiation improvements apply there structurally, but tool.service.ts does not inject negotiationListingPark (same as the fix(negotiations): the listing must say the park, from the same record the question came from #1472 listing) or hit a different isPrincipalUnreachable port — so REST detail says the park without the question number. Wiring REST's composition was left out of scope, matching fix(negotiations): the listing must say the park, from the same record the question came from #1472's precedent.
  • Pre-existing failures not chased (per the feat(negotiations): a seed persona someone is logged in as is reachable #1462 discipline): the 4 mcp.spec.ts owner-proof failures and the whole-dir protocol flake families reproduce identically on dev at the base commit.

🤖 Generated with Claude Code

https://claude.ai/code/session_01N1FQ3gV1oWWXQHMGGroqcV

Since #1472 an MCP client can SEE a park — "open question 3, 'Timing'" —
and could do nothing about it. This closes the read-and-act story:
get_negotiation says the park (same predicate, same shared record as the
listing), answer_pending_question routes the answer over the existing
#1466 host, reject/accept_opportunity give session-authenticated owners
the #1471 verdict lane, the status filter gains input_required, the
activity summary counts parked negotiations instead of the retired
questions table, and the guidance/tool descriptions say all of it —
including what external turns are NOT protected by.

Claude-Session: https://claude.ai/code/session_01N1FQ3gV1oWWXQHMGGroqcV
@yanekyuk
yanekyuk merged commit 4b56e04 into dev Aug 20, 2026
7 checks passed
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.

1 participant