Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

72 changes: 72 additions & 0 deletions packages/protocol/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,78 @@ went 6.7.1 → 8.0.2 with no 7.x in between because the whole 7.x line shipped a
prereleases between the two promotions. To track every change, read `rc`; to
pin a supported release, use `latest`.

## 23.6.0 - 2026-08-20

### Added

- **The MCP surface can now ACT on the question flow, not just see it.** Since
23.5.1 `list_negotiations` says the park — "open question 3, 'Timing'" — to
an external client that had no tool to answer it: the answer lane was the
negotiator persona's chat-only appended tool, and the owner's verdict levers
did not exist on MCP at all. Three tools close the read-and-act gap, all
registered on the MCP tool registry surface only:

- `answer_pending_question(negotiationId, question, answer)` routes the
principal's answer to the open question a parked negotiation is waiting on,
over the EXISTING `NegotiatorAnswerToolsHost` — the same
`readOpenQuestionsForIntent` numbering, the same serialized consumption
queue, the same #1432 resume spine. MCP has no pinned intent, so the tool
resolves scope from the negotiation the client is looking at: the id the
park annotation sits on → the caller's own actor intent on that pairing →
the host. The `question` number passes through untouched; it and the park
annotation come from one enumeration and cannot drift (the #1470 rule).
- `reject_opportunity` / `accept_opportunity` (intentId, numbered
counterparty, optional reason in the owner's own words) reuse the
negotiator verdict host verbatim — the same Radar Skip/Start-Chat service
call, outcome hooks and question retirement in its wake, positions never
ids. Capability access is `human_only`: exactly the session-authenticated
class the IND-593 owner-provenance binding admits, and the handler
re-checks the host-bound provenance so an API-key agent is refused even if
the tool were ever mis-listed. Hermes negotiator credentials fail closed as
unclassified for all three tools.

- **`get_negotiation` says the park** — the #1472 incident, one level down: it
is the tool the poller prompt says to call FIRST, and on a parked negotiation
it narrated a lifecycle built without the park. The detail reader now runs
the same canonical park predicate the listing runs (`classifyInflightPark` /
`classifyPostStallPark`) over the task and messages it already holds, names
the open question through the same shared host record, projects the park
top-level and into `lifecycle` (superseding the status label), and — for an
external seat that could not see dimensions or `settles` — projects the
persisted `askUser` and `checklist` payloads on turns that carry them.
Non-parked negotiations render byte-for-byte as before.

- **`get_negotiation` re-stamps principal reachability from the live read.**
The persisted `turnContext` is a park-time snapshot; `ownUser.principalUnreachable`
is now corrected in both directions through the host's
`isPrincipalUnreachable` port — the same re-stamp REST pickup applies — so an
external seat is never told it can consult a principal nobody is behind, or
the reverse.

- **`list_negotiations` can filter for the parked state.** The `status` filter
gains `input_required`; `active` and `all` behave exactly as before. A
post-stall park lives on a `completed` negotiation and is documented as such
rather than smuggled into the status filter.

### Fixed

- **`read_activity_summary` stops counting questions from the retired card
table.** Pending question counts now come from the parked negotiations —
a mid-flight `input_required` consult naming the owner as recipient, or a
post-stall park trailing the authored gap — keyed by the same
`negotiation_inflight`/`negotiation` modes the projection already maps to the
negotiations domain, so permission inheritance is unchanged. Leftover
pending rows in the retired `questions` table contribute nothing; answered
history still reads the table, where it legitimately lives.

- **Honest words where external clients read them.** The negotiations guidance
topic, the workflows topic, and `MCP_INSTRUCTIONS` now carry the park stage —
what a park is, what the `park` fields mean, and that answering is the only
thing that resumes a parked negotiation. The three negotiation tool
descriptions state plainly that turns submitted through external surfaces are
NOT run through the conclusion floor, decline law, or copy-loop guard: until
external write parity ships, the docs must not imply otherwise.

## 23.5.1 - 2026-08-20

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion packages/protocol/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@indexnetwork/protocol",
"version": "23.5.1",
"version": "23.6.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
Expand Down
15 changes: 15 additions & 0 deletions packages/protocol/src/mcp/mcp.authorization-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,21 @@ export const CANONICAL_MCP_TOOL_ACCESS_RULES = defineMcpToolAccessRules({
get_negotiation: { access: 'permission', actions: ['manage:negotiations'], reach: 'network' },
respond_to_negotiation: { access: 'permission', actions: ['manage:negotiations'], reach: 'network' },

// The question flow (conversational-questions). `answer_pending_question` is
// the recipient principal's own answer lane: session humans (blanket allow)
// and the user's own agents holding manage:negotiations — the same trust the
// sibling negotiation tools extend, with the host scoping every read to the
// recipient's side. Principal reach: an open question is meta-network (it
// indexes the recipient's own signal), like the question aggregates in
// read_activity_summary. The owner VERDICT tools are `human_only` — exactly
// the session-authenticated class the IND-593 owner-provenance binding
// admits; an API-key agent must never gain an owner-verdict lever. None of
// the three appears in HERMES_AGENT_MCP_TOOL_PERMISSIONS, deliberately:
// Hermes negotiator credentials fail closed as unclassified.
answer_pending_question: { access: 'permission', actions: ['manage:negotiations'], reach: 'principal' },
reject_opportunity: { access: 'human_only', reach: 'network' },
accept_opportunity: { access: 'human_only', reach: 'network' },

// H2A chat history.
list_conversations: { access: 'human_only', reach: 'principal' },
get_conversation: { access: 'human_only', reach: 'principal' },
Expand Down
3 changes: 3 additions & 0 deletions packages/protocol/src/mcp/mcp.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,9 @@ NEVER dump raw JSON or expose IDs (except actionable ones like conversationId).
# Authentication & Opportunity Lifecycle
API key in \`x-api-key\` header. Opportunities: draft → pending → accepted/rejected. Agent acceptance ≠ owner approval. Only call update_opportunity with accepted after explicit user confirmation.

# Parked Negotiations & Answers
A negotiation can PARK waiting on a person's answer while its opportunity still reads \`negotiating\` — opportunity status never answers "is anything waiting on the user?". list_negotiations/get_negotiation annotate a parked negotiation with \`park\` (\`waitingOn: "you" | "counterparty"\`, plus the open question's number and label for the user's own side). When \`park.waitingOn\` is "you": relay the question, and route the user's answer with answer_pending_question using the shown question number — the ONLY thing that resumes a parked negotiation. A park on the counterparty's side names no question content.

# Tool Guidance
Read each tool's description for usage rules (when, prerequisites, follow-ups). Tools contain workflow patterns.

Expand Down
59 changes: 59 additions & 0 deletions packages/protocol/src/mcp/tests/mcp.authorization-policy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -897,3 +897,62 @@ describe('signals read/write split (IND-588)', () => {
}
});
});

describe('MCP question flow (answer lane + owner verdicts)', () => {
const sessionHuman = () => resolveMcpCapabilitySubject({
identity: identity({ isSessionAuth: true }),
});

test('answer_pending_question is the recipient principal lane: humans and manage:negotiations agents', () => {
expect(CANONICAL_MCP_TOOL_ACCESS_RULES.get('answer_pending_question')).toEqual({
access: 'permission',
actions: ['manage:negotiations'],
reach: 'principal',
});

expect(policy.authorize(sessionHuman(), 'answer_pending_question')).toMatchObject({
allowed: true,
reason: 'session_human',
});
expect(policy.authorize(globalAgentSubject(['manage:negotiations']), 'answer_pending_question')).toMatchObject({
allowed: true,
reason: 'permission_granted',
});
expect(policy.authorize(globalAgentSubject(['manage:intents']), 'answer_pending_question')).toMatchObject({
allowed: false,
reason: 'permission_missing',
});
});

test('owner verdicts are human_only: exactly the class the IND-593 provenance binding admits', () => {
for (const tool of ['reject_opportunity', 'accept_opportunity'] as const) {
expect(CANONICAL_MCP_TOOL_ACCESS_RULES.get(tool)).toEqual({ access: 'human_only', reach: 'network' });
expect(policy.authorize(sessionHuman(), tool)).toMatchObject({ allowed: true, reason: 'session_human' });
// Every agent principal class is refused — including one holding every
// canonical permission: no grant releases an owner verdict.
expect(policy.authorize(globalAgentSubject([...MCP_PERMISSION_ACTIONS]), tool)).toMatchObject({
allowed: false,
reason: 'human_only',
});
}
});

test('Hermes negotiator credentials fail closed as unclassified on all three question-flow tools', () => {
const hermes = resolveMcpCapabilitySubject({
identity: identity({ agentId: AGENT_ID, isHermesAgent: true }),
agent: agentSnapshot({
permissions: [{
agentId: AGENT_ID,
userId: USER_ID,
scope: 'global',
scopeId: null,
actions: [...MCP_PERMISSION_ACTIONS],
}],
}),
});
for (const tool of ['answer_pending_question', 'reject_opportunity', 'accept_opportunity']) {
expect(HERMES_AGENT_MCP_TOOL_PERMISSIONS.get(tool)).toBeUndefined();
expect(policy.authorize(hermes, tool)).toEqual({ allowed: false, reason: 'tool_unclassified' });
}
});
});
156 changes: 156 additions & 0 deletions packages/protocol/src/negotiations/negotiation.answer.tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { z } from 'zod';

import type { DefineTool } from '../shared/agent/tool.helpers.js';
import { success, error } from '../shared/agent/tool.helpers.js';
import { focusedNetworkId } from '../shared/agent/tool.scope.js';
import { protocolLogger } from '../shared/observability/protocol.logger.js';
import type { NegotiationToolDeps } from './negotiation.tools.port.js';

/**
* The MCP-surface answer lane (`answer_pending_question`).
*
* `list_negotiations` and `get_negotiation` have said the park since #1472:
* "open question 3, 'Timing'" — to a client that, on this surface, had no tool
* to answer it. This closes the read-and-act gap over the EXISTING host
* (`NegotiatorAnswerToolsHost`): same `readOpenQuestionsForIntent` numbering,
* same serialized consumption queue, same #1432 resume spine underneath.
* Nothing is re-implemented.
*
* MCP has no pinned intent, so the tool resolves its scope from the
* negotiation the client is looking at: `negotiationId` (the id the listing
* and detail hand out next to the park) → the caller's own actor intent on
* that negotiation's opportunity → the host. The `question` number passes
* through untouched — it is the number the park annotation printed, and both
* come from the same enumeration, so they cannot drift.
*
* Registered on the MCP surface only (tool.registry): the chat lane is the
* persona-append tool (#1466) and the REST Tool API deliberately does not
* carry this. Access is decided by the capability matrix
* (`CANONICAL_MCP_TOOL_ACCESS_RULES`); the handler enforces the rest —
* participant admission here, recipient-side scoping in the host (a question
* parked on the counterparty never enumerates for this caller).
*/

const logger = protocolLogger('McpTools:NegotiationAnswer');

const SCOPE_DENIAL = 'Access denied: this negotiation is not in your bound network scope.';

/** Registers the MCP-surface `answer_pending_question` tool. */
export function createNegotiationAnswerTools(defineTool: DefineTool, deps: NegotiationToolDeps) {
const { negotiationDatabase } = deps;

const answerPendingQuestion = defineTool({
name: 'answer_pending_question',
description:
'Route the user\'s answer to the open question a PARKED negotiation is waiting on, resuming it. This is the ONLY thing ' +
'that resumes a parked negotiation — responding, editing the signal, or updating the opportunity does not.\n\n' +
'**When to use:** `list_negotiations` / `get_negotiation` showed a `park` with `waitingOn: "you"` and a `question` number, ' +
'and the user has answered that question. Pass the negotiation\'s id, the exact `park.question` number, and the user\'s answer ' +
'in their own words. Never invent an answer, and never add a preference or constraint the user did not state.\n\n' +
'**Numbering:** the `question` number and the number shown in the park annotation come from the same open-question record, ' +
'so pass the number exactly as shown. If it no longer names an open question you will get `unknown_question` with the current count — ' +
're-read the listing rather than guessing.\n\n' +
'**One answer, every negotiation it unparks:** a question can unblock several parked negotiations; the host resumes them all from one answer.',
querySchema: z.object({
negotiationId: z.string()
.describe('The parked negotiation\'s task id, from list_negotiations or get_negotiation.'),
question: z.number().int().min(1)
.describe('Which open question is being answered — the `park.question` number exactly as the listing or detail showed it.'),
answer: z.string().min(1).max(4000)
.describe('The user\'s answer, in their own words, restated only enough to stand alone.'),
}),
handler: async ({ context, query }) => {
try {
const host = deps.negotiatorAnswerTools;
if (!host) {
return error('The answer lane is not available on this deployment. Tell the user honestly that the answer was not routed.');
}

const task = await negotiationDatabase.getTask(query.negotiationId);
if (!task) {
return error('Negotiation not found.');
}
const meta = task.metadata as {
type?: string;
sourceUserId?: string;
candidateUserId?: string;
opportunityId?: string;
networkId?: string;
turnContext?: { indexContext?: { networkId?: string } };
} | null;
if (meta?.type !== 'negotiation') {
return error('Negotiation not found.');
}

// Network-scope check (mirrors get_negotiation): a network-bound agent
// must not act on negotiations outside its bound network. Run before
// the participant check so no existence-vs-membership signal leaks.
const scopedNetworkId = focusedNetworkId(context);
if (scopedNetworkId) {
const metaNetworkId = typeof meta.networkId === 'string' && meta.networkId.trim()
? meta.networkId
: meta.turnContext?.indexContext?.networkId ?? null;
if (metaNetworkId !== scopedNetworkId) {
return error(SCOPE_DENIAL);
}
}

const isParty = meta.sourceUserId === context.userId || meta.candidateUserId === context.userId;
if (!isParty) {
return error('Access denied: you are not a party to this negotiation.');
}

const opportunityId = typeof meta.opportunityId === 'string' ? meta.opportunityId.trim() : '';
if (!opportunityId) {
return error('This negotiation carries no opportunity, so it has no question to answer.');
}

// The caller's own actor intent on this pairing — the signal whose
// open-question block the number indexes into. A caller who is not the
// question's recipient resolves no open question through the host and
// gets `no_open_question`/`unknown_question`, never a resume.
const intentIds = await negotiationDatabase.getIntentIdsForOpportunities([opportunityId], context.userId);
const intentId = intentIds[opportunityId];
if (!intentId) {
return error('Could not resolve your signal for this negotiation, so the answer was not routed.');
}

const result = await host.answerOpenQuestion(context.userId, {
intentId,
question: query.question,
answer: query.answer,
});

switch (result.status) {
case 'routed':
return success({
status: 'routed',
negotiationId: task.id,
question: result.label,
message:
'The answer is on its way to the negotiation that was waiting on it. Confirm to the user in one short sentence '
+ 'what you took as their answer, and do not also change their signal on the strength of it.',
});
case 'no_open_question':
return error(
'Nothing is waiting on the user for this signal any more — the parked negotiations resolved or expired. '
+ 'Tell them that plainly rather than implying their answer was recorded.',
);
case 'unknown_question':
return error(
`That number does not name an open question (${result.open} currently open). `
+ 'Re-read the park annotations from list_negotiations and call this again with the number shown there.',
);
case 'error':
default:
return error('Could not route that answer. Tell the user honestly that it did not go through.');
}
} catch (err) {
logger.error('Failed to route MCP pending-question answer', { err });
return error('Could not route that answer. Tell the user honestly that it did not go through.');
}
},
});

return [answerPendingQuestion] as const;
}
Loading
Loading