feat(mcp): the question flow reaches external clients - #1473
Merged
Conversation
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
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.
The incident, one level down
#1472 taught
list_negotiationsto say the park: a client whose negotiation had satinput_requiredfor 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, andread_activity_summaryanswered "pending questions: 0" from the retiredquestionstable 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
get_negotiationcarries the sameparkannotation the listing does (waitingOn: "you" | "counterparty", plusquestion/questionLabelfor 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 inlifecycle. Turns that carry anask_userconsult now project the persistedaskUserandchecklistpayloads verbatim — an external seat can finally see dimensions, answerhood, andsettles.ownUser.principalUnreachableis re-stamped from the liveisPrincipalUnreachableread (both directions), never served stale.list_negotiationsgains theinput_requiredstatus filter.answer_pending_questionroutes the principal's answer over the EXISTINGNegotiatorAnswerToolsHost: samereadOpenQuestionsForIntentnumbering, same serialized consumption queue, same feat(negotiations): consume answers — route a reply to its parked negotiations and resume #1432 resume spine. Nothing re-implemented.reject_opportunity/accept_opportunityare 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 #1471negotiatorVerdictToolsHostverbatim (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 carryask_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' }:reject_opportunity/accept_opportunity— matrix rule{ access: 'human_only', reach: 'network' }:list_negotiations.statusis nowz.enum(['active', 'waiting_for_agent', 'input_required', 'completed', 'all']).Design decisions, with reasons
negotiationId, notintentId. 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. Thequestionnumber passes through untouched. Numbers cannot drift because both the park annotation and the answer host read the samereadOpenQuestionsForIntentenumeration (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.manage:negotiations— the same trustrespond_to_negotiationextends, 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 isprincipal: an open question is meta-network (it indexes the recipient's own signal), like the question aggregates inread_activity_summary. The recipient boundary is the host's: a question parked on the counterparty never enumerates for this caller.mcp.server.tsalready binds owner provenance for session-authenticated MCP (bindOwnerApprovalProvenance, the seam the audit pointed at) — the matrix rule ishuman_only(hidden from and denied to every agent principal, no permission releases it), and the handler re-checks the host-bound provenance (sessionAuthenticated === true, noagentId) 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 toHERMES_AGENT_MCP_TOOL_PERMISSIONS, deliberately.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.ownerApprovalProvenanceFor(no cross-capability import); the answer tool lives in negotiations. Both consume hosts throughToolDeps, injected inmcp.controller.tsbeside the fix(negotiations): the listing must say the park, from the same record the question came from #1472 park host.input_requiredfilters the mid-flight task state. A post-stall park lives on acompletedtask that reportsstatus: "completed"— returning it under a status filter would contradict the filter's own rows. It stays undercompleted, carrying itsparkobject, and the filter description says exactly that.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 authoredask_usergap from the owner's own agent. Keyed asnegotiation_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.askUser/checklistare spread only when present;parkonly 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.read_pending_questionsstays retired for every principal;answer_pending_questionreturns 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 (withsettlesvisible; 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 bothlist_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_requiredfilter maps to the parked state;activeunchanged.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 agentspermission_missing), verdictshuman_onlyeven 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:
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; fullsrc/negotiations/tests/716 pass / 2 fail — both innegotiator-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 at1204075b07, protocol dist rebuilt, same file re-run → the identical 4 failures (database.getOpportunity is not a functionin the owner-proof family) pre-exist on dev. My diff adds zero failures.negotiator-answer.host/negotiation-listing-park.host/user-database/mcp.permission-refreshspecs: 81 pass. CI hermetic pinning files: 72 pass.tsc --noEmit(api) andbun run build(protocol) clean. Boundaries lint: 0 violations; lockfile-versions and subtree-parity checks pass (lockfile synced viabun scripts/sync-lockfile-versions.ts).Deferred / honest disclosure
respondschemas carryingask_user/checklist/dimension,guaranteedrefused 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.consultNegotiationSchema+buildExternalConsultationQuestionerPayload) — untouched.list_negotiations— not made worse.get_negotiationadds at most ONE host read per call (single negotiation), noted where wired.packages/edge-city— the audit flagged scripts there still calling the retiredread_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.tsTODO comment is now slightly stale (it still lists the activity-summary pending counts as aquestions-table reader). A comment-only fix was written and then reverted: the file carries a pre-existingno-restricted-importslint 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.get_negotiationimprovements apply there structurally, buttool.service.tsdoes not injectnegotiationListingPark(same as the fix(negotiations): the listing must say the park, from the same record the question came from #1472 listing) or hit a differentisPrincipalUnreachableport — 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.mcp.spec.tsowner-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