@indexnetwork/protocol 6.15.0-rc.407.1 → 8.0.1-rc.417.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CHANGELOG.md +302 -0
  2. package/IMPLEMENTATION.md +7 -4
  3. package/dist/capabilities/opportunities.facade.d.ts +4 -0
  4. package/dist/capabilities/opportunities.facade.js +4 -0
  5. package/dist/chat/chat-streaming.types.d.ts +4 -5
  6. package/dist/chat/chat.agent.d.ts +0 -19
  7. package/dist/chat/chat.agent.js +1 -70
  8. package/dist/chat/chat.persona.d.ts +0 -6
  9. package/dist/chat/chat.persona.js +0 -1
  10. package/dist/chat/chat.prompt.js +10 -10
  11. package/dist/chat/chat.prompt.modules.js +0 -104
  12. package/dist/chat/chat.tools.js +11 -7
  13. package/dist/chat/negotiator.persona.js +0 -1
  14. package/dist/chat/onboarding.persona.js +0 -1
  15. package/dist/chat/reporter.persona.d.ts +1 -1
  16. package/dist/chat/reporter.persona.js +1 -2
  17. package/dist/chat/reporter.prompt.js +5 -5
  18. package/dist/chat/signal.persona.js +0 -1
  19. package/dist/communities/application/network.tools.js +48 -27
  20. package/dist/contacts/application/contact.tools.js +9 -9
  21. package/dist/index.d.ts +9 -2
  22. package/dist/index.js +5 -0
  23. package/dist/maintenance/maintenance.graph.js +7 -0
  24. package/dist/mcp/mcp.authorization-policy.d.ts +250 -0
  25. package/dist/mcp/mcp.authorization-policy.js +522 -0
  26. package/dist/mcp/mcp.server.d.ts +6 -29
  27. package/dist/mcp/mcp.server.js +202 -228
  28. package/dist/negotiation/application/negotiation.agent.js +29 -5
  29. package/dist/negotiation/application/negotiation.graph.d.ts +2 -5
  30. package/dist/negotiation/application/negotiation.graph.js +53 -23
  31. package/dist/negotiation/domain/index.d.ts +3 -0
  32. package/dist/negotiation/domain/index.js +3 -0
  33. package/dist/negotiation/domain/negotiation.deadlock.d.ts +7 -1
  34. package/dist/negotiation/domain/negotiation.deadlock.js +48 -1
  35. package/dist/negotiation/domain/negotiation.stance.contracts.d.ts +85 -0
  36. package/dist/negotiation/domain/negotiation.stance.contracts.js +140 -0
  37. package/dist/negotiation/domain/negotiation.state.d.ts +2 -3
  38. package/dist/negotiation/domain/negotiation.state.js +2 -3
  39. package/dist/negotiation/public/index.d.ts +2 -0
  40. package/dist/negotiation/public/index.js +2 -0
  41. package/dist/opportunity/application/index.d.ts +0 -3
  42. package/dist/opportunity/application/index.js +0 -3
  43. package/dist/opportunity/application/opportunity.graph.d.ts +0 -43
  44. package/dist/opportunity/application/opportunity.graph.js +7 -113
  45. package/dist/opportunity/application/opportunity.introducer-feature.d.ts +2 -0
  46. package/dist/opportunity/application/opportunity.introducer-feature.js +4 -0
  47. package/dist/opportunity/application/opportunity.owner-approval.d.ts +118 -0
  48. package/dist/opportunity/application/opportunity.owner-approval.js +34 -0
  49. package/dist/opportunity/application/opportunity.owner-provenance.d.ts +6 -0
  50. package/dist/opportunity/application/opportunity.owner-provenance.js +28 -0
  51. package/dist/opportunity/application/opportunity.tools.d.ts +3 -3
  52. package/dist/opportunity/application/opportunity.tools.js +77 -870
  53. package/dist/opportunity/discriminator/discriminator.shadow.js +17 -10
  54. package/dist/opportunity/discriminator/discriminator.types.d.ts +2 -0
  55. package/dist/opportunity/domain/index.d.ts +0 -2
  56. package/dist/opportunity/domain/index.js +0 -2
  57. package/dist/opportunity/domain/opportunity.state.d.ts +2 -70
  58. package/dist/opportunity/domain/opportunity.state.js +2 -55
  59. package/dist/opportunity/opportunity.owner-approval.d.ts +5 -0
  60. package/dist/opportunity/opportunity.owner-approval.js +5 -0
  61. package/dist/opportunity/ports/opportunity.tools.port.d.ts +12 -1
  62. package/dist/opportunity/public/index.d.ts +3 -3
  63. package/dist/opportunity/public/index.js +2 -1
  64. package/dist/participant-agents/application/agent.tools.d.ts +1 -1
  65. package/dist/participant-agents/application/agent.tools.js +44 -6
  66. package/dist/participant-agents/application/index.d.ts +5 -3
  67. package/dist/participant-agents/application/index.js +5 -3
  68. package/dist/questions/application/question.tools.js +31 -2
  69. package/dist/runtime/foreground/composition/tool.factory.d.ts +2 -1
  70. package/dist/runtime/foreground/composition/tool.factory.js +9 -2
  71. package/dist/runtime/foreground/composition/tool.registry.d.ts +16 -2
  72. package/dist/runtime/foreground/composition/tool.registry.js +41 -38
  73. package/dist/shared/agent/activity-projection.d.ts +271 -0
  74. package/dist/shared/agent/activity-projection.js +248 -0
  75. package/dist/shared/agent/canonical-guidance.d.ts +30 -0
  76. package/dist/shared/agent/canonical-guidance.js +249 -0
  77. package/dist/shared/agent/tool.helpers.d.ts +9 -17
  78. package/dist/shared/agent/tool.runtime.js +1 -3
  79. package/dist/shared/agent/utility.tools.d.ts +11 -1
  80. package/dist/shared/agent/utility.tools.js +126 -90
  81. package/dist/shared/interfaces/auth.interface.d.ts +2 -8
  82. package/dist/shared/interfaces/chat-session.interface.js +5 -0
  83. package/dist/shared/interfaces/database.interface.d.ts +12 -5
  84. package/dist/shared/observability/request-context.d.ts +4 -40
  85. package/dist/shared/schemas/mcp-auth.schema.d.ts +87 -15
  86. package/dist/shared/schemas/mcp-auth.schema.js +49 -1
  87. package/dist/signals/application/intent.tools.js +7 -8
  88. package/package.json +10 -9
  89. package/dist/opportunity/application/opportunity.discover.d.ts +0 -215
  90. package/dist/opportunity/application/opportunity.discover.js +0 -793
  91. package/dist/opportunity/application/opportunity.discovery-continuation-finalization.d.ts +0 -34
  92. package/dist/opportunity/application/opportunity.discovery-continuation-finalization.js +0 -74
  93. package/dist/opportunity/application/opportunity.discovery-mcp-lifecycle-finalization.d.ts +0 -44
  94. package/dist/opportunity/application/opportunity.discovery-mcp-lifecycle-finalization.js +0 -92
  95. package/dist/opportunity/domain/opportunity.discovery-run-coalescing.d.ts +0 -33
  96. package/dist/opportunity/domain/opportunity.discovery-run-coalescing.js +0 -33
  97. package/dist/opportunity/domain/opportunity.discovery.contracts.d.ts +0 -45
  98. package/dist/opportunity/domain/opportunity.discovery.contracts.js +0 -1
  99. package/dist/opportunity/opportunity.discover.d.ts +0 -5
  100. package/dist/opportunity/opportunity.discover.js +0 -5
  101. package/dist/opportunity/opportunity.discovery-continuation-finalization.d.ts +0 -5
  102. package/dist/opportunity/opportunity.discovery-continuation-finalization.js +0 -5
  103. package/dist/opportunity/opportunity.discovery-mcp-lifecycle-finalization.d.ts +0 -5
  104. package/dist/opportunity/opportunity.discovery-mcp-lifecycle-finalization.js +0 -5
  105. package/dist/opportunity/opportunity.discovery-run-coalescing.d.ts +0 -5
  106. package/dist/opportunity/opportunity.discovery-run-coalescing.js +0 -5
  107. package/dist/opportunity/opportunity.discovery.contracts.d.ts +0 -5
  108. package/dist/opportunity/opportunity.discovery.contracts.js +0 -5
  109. package/dist/shared/interfaces/discovery-run.interface.d.ts +0 -70
  110. package/dist/shared/interfaces/discovery-run.interface.js +0 -1
@@ -6,13 +6,15 @@
6
6
  */
7
7
  import { z } from 'zod';
8
8
  import { McpServer, fromJsonSchema } from '@modelcontextprotocol/server';
9
+ import { McpResolvedIdentitySchema } from '../shared/schemas/mcp-auth.schema.js';
10
+ import { CANONICAL_GUIDANCE_SUMMARY } from '../shared/agent/canonical-guidance.js';
9
11
  import { resolveChatContext } from '../shared/agent/tool.helpers.js';
10
12
  import { deriveAllowedNetworkIds, scopeFromNetworkId } from '../shared/agent/tool.scope.js';
11
- import { QuestionSchema } from '../shared/schemas/question.schema.js';
12
- import { dispatchElicitations } from './elicitation.dispatcher.js';
13
13
  import { createToolRegistry } from '../runtime/foreground/composition/tool.registry.js';
14
+ import { bindOwnerApprovalProvenance } from '../opportunity/application/opportunity.owner-provenance.js';
14
15
  import { ToolRuntimeError, invokeToolRuntime, toolRuntimeErrorToResult } from '../shared/agent/tool.runtime.js';
15
16
  import { protocolLogger } from '../shared/observability/protocol.logger.js';
17
+ import { buildMcpAuthorizationDenialEvent, McpCapabilityPolicy, ONBOARDING_ALLOWED, resolveMcpActivityCaller, resolveMcpCapabilitySubject } from './mcp.authorization-policy.js';
16
18
  const logger = protocolLogger('McpServer');
17
19
  const mcpToolMetadataCache = new Map();
18
20
  /**
@@ -21,8 +23,10 @@ const mcpToolMetadataCache = new Map();
21
23
  * the cached metadata set is automatically invalidated.
22
24
  */
23
25
  export function getMcpToolMetadataCacheKey(deps) {
26
+ // CONTACTS_ENABLED deliberately does NOT shape the MCP registry: contact and
27
+ // Gmail-import tools are omitted from the MCP surface entirely (IND-596), so
28
+ // the flag can never change the MCP tool set.
24
29
  return [
25
- `contacts:${deps.contactsEnabled === true ? '1' : '0'}`,
26
30
  `chat:${deps.chatSession ? '1' : '0'}`,
27
31
  `agent:${deps.agentDatabase ? '1' : '0'}`,
28
32
  `negotiation:${deps.agentDispatcher ? '1' : '0'}`,
@@ -49,7 +53,7 @@ export function getCachedMcpToolMetadata(deps) {
49
53
  const cached = mcpToolMetadataCache.get(cacheKey);
50
54
  if (cached)
51
55
  return cached;
52
- const registry = createToolRegistry(deps);
56
+ const registry = createToolRegistry(deps, { surface: 'mcp' });
53
57
  const metadata = Array.from(registry.values()).map((toolDef) => {
54
58
  const jsonSchema = zodToJsonSchema(toolDef.schema);
55
59
  return {
@@ -188,47 +192,6 @@ export function sanitizeMcpResult(text) {
188
192
  return { text, isError: false };
189
193
  }
190
194
  }
191
- /** Spec cap on the number of decision questions surfaced per turn. */
192
- const MAX_DECISION_QUESTIONS = 3;
193
- /**
194
- * Extracts decision questions from a parsed tool-result text, if present.
195
- * Validates each entry against `QuestionSchema` and drops malformed items;
196
- * caps the array at `MAX_DECISION_QUESTIONS` (defense-in-depth — Slice 2's
197
- * generator already caps at 3, but we don't trust the cast here).
198
- *
199
- * Returns null when the text isn't JSON, has no `data.questions`, or
200
- * contains zero valid questions after validation.
201
- */
202
- export function extractDecisionQuestions(text) {
203
- let parsed;
204
- try {
205
- parsed = JSON.parse(text);
206
- }
207
- catch {
208
- return null;
209
- }
210
- const rawQs = parsed?.data?.questions;
211
- if (!Array.isArray(rawQs) || rawQs.length === 0)
212
- return null;
213
- const valid = [];
214
- for (const raw of rawQs) {
215
- const result = QuestionSchema.safeParse(raw);
216
- if (result.success)
217
- valid.push(result.data);
218
- if (valid.length === MAX_DECISION_QUESTIONS)
219
- break;
220
- }
221
- return valid.length > 0 ? valid : null;
222
- }
223
- /**
224
- * Renders the JSON-envelope text block appended to the tool result content
225
- * when decision questions are present. The leading sentinel string lets the
226
- * LLM client recognize and surface the questions in prose for clients
227
- * without elicitation support.
228
- */
229
- export function renderQuestionsEnvelope(questions) {
230
- return `Decision questions (structured): ${JSON.stringify({ questions })}`;
231
- }
232
195
  /**
233
196
  * Promotes a network-scoped agent's bound network into the resolved tool
234
197
  * context as the implicit chat scope. Every tool derives its focused network
@@ -261,37 +224,7 @@ export const applyNetworkScopeToContext = (context, networkScopeId) => {
261
224
  context.scopedMembershipRole = isOwner ? 'owner' : 'member';
262
225
  context.isOwner = isOwner;
263
226
  };
264
- /**
265
- * Tools allowed during onboarding — everything else is gated until
266
- * complete_onboarding is called. Includes the agent-gate-exempt tools
267
- * (register_agent, read_docs, scrape_url) because they are informational /
268
- * registration primitives needed at every lifecycle stage.
269
- */
270
- export const ONBOARDING_ALLOWED = new Set([
271
- 'register_agent',
272
- 'read_docs',
273
- 'scrape_url',
274
- 'record_onboarding_privacy_consent',
275
- // Canonical *_user_context / *_enrichment_run names (IND-371)
276
- 'preview_user_context',
277
- 'get_enrichment_run',
278
- 'cancel_enrichment_run',
279
- 'confirm_user_context',
280
- 'create_user_context',
281
- 'read_user_contexts',
282
- // Deprecated aliases retained so mid-migration clients can still onboard (IND-373 removes them)
283
- 'preview_user_profile',
284
- 'get_profile_run',
285
- 'cancel_profile_run',
286
- 'confirm_user_profile',
287
- 'create_user_profile',
288
- 'read_user_profiles',
289
- 'complete_onboarding',
290
- 'import_gmail_contacts',
291
- 'read_networks',
292
- 'create_network_membership',
293
- 'create_intent',
294
- ]);
227
+ export { ONBOARDING_ALLOWED } from './mcp.authorization-policy.js';
295
228
  /**
296
229
  * Builds the onboarding gate message for MCP callers. Condensed from the
297
230
  * chat orchestrator's 8-step flow (chat.prompt.ts buildOnboarding) into a
@@ -316,7 +249,7 @@ export function buildMcpOnboardingMessage(ctx) {
316
249
  `5. Present the profile draft and ask "Does that look right?" On approval/correction, call confirm_user_context(...).\n` +
317
250
  `${communityStep}\n` +
318
251
  `6. Ask what the user is looking for and call create_intent(description="...", autoApprove=true) so the first signal is persisted.\n` +
319
- `7. Call complete_onboarding() to finish setup. Gmail/contact import and discovery are optional after onboarding, never mandatory.`);
252
+ `7. Call complete_onboarding() to finish setup. Discovery is optional after onboarding, never mandatory.`);
320
253
  }
321
254
  /**
322
255
  * Creates an MCP server with all protocol tools registered.
@@ -343,8 +276,6 @@ function createMcpTraceEmitter(toolName, ctx) {
343
276
  return `${toolName}: ${event.name} agent started`;
344
277
  if (event.type === 'agent_end')
345
278
  return `${toolName}: ${event.name} agent finished${event.durationMs != null ? ` in ${event.durationMs}ms` : ''}`;
346
- if (event.type === 'opportunity_draft_ready')
347
- return `${toolName}: opportunity draft ready`;
348
279
  return `${toolName}: progress`;
349
280
  })();
350
281
  const notification = {
@@ -360,61 +291,19 @@ function createMcpTraceEmitter(toolName, ctx) {
360
291
  };
361
292
  }
362
293
  export const MCP_INSTRUCTIONS = `
363
- Index Network is a private, intent-driven discovery protocol. You help users find the right people and help the right people find them, via Index Network MCP tools.
364
-
365
- # Voice
366
- Calm, direct, analytical, concise. Preferred vocabulary: opportunity, overlap, signal, pattern, emerging, relevant, adjacency.
367
-
368
- # Banned vocabulary
369
- NEVER use "search" in any form. Use "looking up" for indexed data, "find" / "look for" for discovery, "check" for verification, "discover" for exploration. Banned: leverage, unlock, optimize, scale, disrupt, revolutionary, AI-powered, maximize value, act fast, networking, match.
370
-
371
- # Entity model
372
- - User — has one Profile, many Memberships, many Intents.
373
- - Profile — identity (bio, skills, interests, location).
374
- - Index — community with title, prompt (purpose), join policy. Has Members.
375
- - Membership — User↔Index junction. \`isPersonal: true\` marks the user's personal network (contacts).
376
- - Intent — what a user is looking for (signal). Description, summary, embedding.
377
- - IntentIndex — Intent↔Index junction (auto-assigned).
378
- - Opportunity — discovered connection between users. Roles, status, reasoning.
294
+ ${CANONICAL_GUIDANCE_SUMMARY}
379
295
 
380
- # Output rules
381
- - NEVER expose internal IDs, UUIDs, field names, or tool names EXCEPT when an ID is actionable for the user (e.g. a \`conversationId\` they need to open a chat). Surface such IDs verbatim when the tool returns them.
382
- - NEVER use internal vocabulary — say "signal" not "intent", "community" not "index".
383
- - NEVER dump raw JSON. Synthesize in natural language.
384
- - Surface top 1–3 relevant points unless asked for the full list.
385
- - Prefer first names; use full names only to disambiguate.
386
- - Translate statuses: draft/latent → "draft", pending → "awaiting review/response", accepted → "accepted opportunity". Agent acceptance is not "connected".
387
- - NEVER fabricate data. If you don't have it, call the appropriate tool.
296
+ # Voice & Output Rules
297
+ Calm, analytical, concise. Say "signal" not "intent", "community" not "index". Never use "search" use "discover" or "find". Banned: leverage, optimize, unlock, scale, disrupt, AI-powered, act fast.
388
298
 
389
- # Tool guidance
390
- Each tool's description contains its own usage rules (when to call, when NOT to call, required prerequisites, post-call follow-ups). Read the description of every tool you call — that is where the per-tool workflow patterns live.
299
+ NEVER dump raw JSON or expose IDs (except actionable ones like conversationId). Synthesize in natural language; surface top 1–3 points unless asked for full list. Fabricate nothing.
391
300
 
392
- # Authentication
393
- Pass your API key in the \`x-api-key\` request header (not \`Authorization: Bearer\`).
301
+ # Authentication & Opportunity Lifecycle
302
+ 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.
394
303
 
395
- # Opportunity lifecycle
396
- Opportunities move through: draft pending accepted (or rejected).
304
+ # Tool Guidance
305
+ Read each tool's description for usage rules (when, prerequisites, follow-ups). Tools contain workflow patterns.
397
306
 
398
- - **draft** (you created it, not yet sent): offer to send it; confirm before calling update_opportunity with pending.
399
- - **pending, you sent it**: waiting for the other side — nothing to do.
400
- - **pending, you received it**: the other person is waiting for your response. Surface it to the user and ask if they want to start a chat. Only call update_opportunity with accepted after explicit user confirmation.
401
- - **accepted**: an owner accepted. Status alone never proves an H2H thread; mention one only when this result returns H2H evidence.
402
-
403
- A **completed** negotiation means only that agents concluded. Agent \`accept\` may leave a \`pending\` match awaiting owner review; it is not owner acceptance or an H2H thread. Keep rejected, stalled, draft, expired, and pending distinct.
404
-
405
- Never accept a received opportunity without explicit user approval in the current conversation.
406
-
407
- # Decision questions after discovery
408
-
409
- After \`discover_opportunities\`, the tool result may include a second text block starting with \`Decision questions (structured): ...\`. This means the discovery engine ran negotiations but needs human input to sharpen the next turn — e.g. clarify timing, role, stage, or location.
410
-
411
- **When this block is present:**
412
- 1. Parse the \`questions\` array from the JSON after the sentinel.
413
- 2. Each question has \`title\` (decision domain, ≤12 chars), \`prompt\` (ends in \`?\`), \`options\` (2–4 items, each with \`label\` and \`description\`), and \`multiSelect\`. The safest option is labeled \`... (Recommended)\`.
414
- 3. Present each question in natural language: ask the \`prompt\`, list options as \`**{label}** — {description}\`. Never expose the JSON or technical field names.
415
- 4. Wait for the user's answer, then fold it into the next \`discover_opportunities(searchQuery=...)\` call.
416
-
417
- **Elicitation-capable clients** (those that declared \`elicitation\` support in \`initialize\`): the server dispatches \`elicitation/create\` requests directly — answers are written back to the chat session automatically. You will not see the envelope as a follow-up task in that case.
418
307
  `.trim();
419
308
  /**
420
309
  * Extracts a Bearer token from an HTTP Authorization header.
@@ -449,11 +338,136 @@ export function parseClientSurface(raw) {
449
338
  }
450
339
  return 'web';
451
340
  }
452
- export function createMcpServer(deps, authResolver, scopedDepsFactory) {
453
- // Tools exempt from the agent-registration gate — available before registration is complete.
454
- const AGENT_GATE_EXEMPT = new Set(['register_agent', 'read_docs', 'scrape_url']);
341
+ export function createMcpServer(deps, authResolver, scopedDepsFactory, policyOptions = {}, authorizationObserver) {
455
342
  const server = new McpServer({ name: 'index-network', version: '1.0.0' }, { instructions: MCP_INSTRUCTIONS });
456
343
  const toolMetadata = getCachedMcpToolMetadata(deps);
344
+ const capabilityPolicy = new McpCapabilityPolicy(policyOptions);
345
+ // Fail-closed authorization observability: emit a safe, secret-free denial
346
+ // event at the host boundary. Never let an observer failure change the
347
+ // decision or surface a credential — the denial stands regardless.
348
+ const observeDenial = (input) => {
349
+ if (!authorizationObserver)
350
+ return;
351
+ try {
352
+ authorizationObserver.onCapabilityDenied(buildMcpAuthorizationDenialEvent(input));
353
+ }
354
+ catch (err) {
355
+ logger.debug('MCP authorization observer threw — ignoring', {
356
+ toolName: input.toolName,
357
+ error: err instanceof Error ? err.message : String(err),
358
+ });
359
+ }
360
+ };
361
+ // Both snapshots are scoped to this MCP server/connection. Permission and
362
+ // onboarding changes therefore apply only after the caller reconnects or
363
+ // refreshes its session. They are never shared with the static tool metadata
364
+ // cache, so a different server/principal cannot inherit decisions.
365
+ let authenticatedRequest;
366
+ let resolvedRequest;
367
+ const extractAuthInput = (httpReq) => ({
368
+ bearerToken: extractBearerToken(httpReq),
369
+ apiKey: httpReq.headers.get('x-api-key') ?? undefined,
370
+ clientSurface: parseClientSurface(httpReq.headers.get('x-index-surface')),
371
+ telegramHandle: httpReq.headers.get('x-index-telegram-handle') ?? undefined,
372
+ telegramUsername: httpReq.headers.get('x-index-telegram-username') ?? undefined,
373
+ });
374
+ const getAuthenticatedRequest = (httpReq) => {
375
+ if (authenticatedRequest)
376
+ return authenticatedRequest;
377
+ authenticatedRequest = (async () => {
378
+ const identity = McpResolvedIdentitySchema.parse(await authResolver.resolveIdentity(extractAuthInput(httpReq)));
379
+ const agentRecord = identity.agentId
380
+ ? await deps.agentDatabase?.getAgentWithRelations(identity.agentId) ?? null
381
+ : null;
382
+ const agent = agentRecord
383
+ ? {
384
+ id: agentRecord.id,
385
+ ownerId: agentRecord.ownerId,
386
+ type: agentRecord.type,
387
+ status: agentRecord.status,
388
+ permissions: agentRecord.permissions.map((permission) => ({
389
+ agentId: permission.agentId,
390
+ userId: permission.userId,
391
+ scope: permission.scope,
392
+ scopeId: permission.scopeId,
393
+ actions: [...permission.actions],
394
+ })),
395
+ }
396
+ : null;
397
+ return {
398
+ identity,
399
+ agent,
400
+ // Onboarding can only reduce access, so this snapshot is sufficient to
401
+ // reject forged/hidden calls before resolving chat context.
402
+ preliminarySubject: resolveMcpCapabilitySubject({
403
+ identity,
404
+ isOnboarding: false,
405
+ agent,
406
+ }),
407
+ };
408
+ })();
409
+ return authenticatedRequest;
410
+ };
411
+ const getResolvedRequest = (httpReq) => {
412
+ if (resolvedRequest)
413
+ return resolvedRequest;
414
+ resolvedRequest = (async () => {
415
+ const authenticated = await getAuthenticatedRequest(httpReq);
416
+ const context = await resolveChatContext({
417
+ database: deps.database,
418
+ userId: authenticated.identity.userId,
419
+ contactsEnabled: deps.contactsEnabled,
420
+ });
421
+ context.isMcp = true;
422
+ if (authenticated.identity.agentId) {
423
+ context.agentId = authenticated.identity.agentId;
424
+ }
425
+ // Trusted provenance seam (IND-593): only the server-resolved session
426
+ // identity — never a caller-supplied field — marks a direct MCP owner
427
+ // interaction for the opportunity owner-approval boundary. The
428
+ // capability-local extension deliberately keeps this field out of the
429
+ // shared helper's negotiation/question cycle.
430
+ bindOwnerApprovalProvenance(context, {
431
+ surface: 'mcp',
432
+ sessionAuthenticated: authenticated.identity.isSessionAuth === true,
433
+ });
434
+ if (authenticated.identity.clientSurface) {
435
+ context.clientSurface = authenticated.identity.clientSurface;
436
+ }
437
+ applyNetworkScopeToContext(context, authenticated.identity.networkScopeId);
438
+ const subject = resolveMcpCapabilitySubject({
439
+ identity: authenticated.identity,
440
+ isOnboarding: context.isOnboarding,
441
+ agent: authenticated.agent,
442
+ });
443
+ // Bind the typed resolved caller context so tools with
444
+ // permission-projected output (read_activity_summary) can apply the
445
+ // centralized projection without re-deriving principal state.
446
+ context.mcpCaller = resolveMcpActivityCaller(subject);
447
+ return {
448
+ ...authenticated,
449
+ context,
450
+ subject,
451
+ };
452
+ })();
453
+ return resolvedRequest;
454
+ };
455
+ const capabilityDeniedResult = (decision, context) => {
456
+ const message = decision.reason === 'onboarding_required' && context
457
+ ? buildMcpOnboardingMessage(context)
458
+ : 'This capability is not available to the authenticated principal. Reconnect or refresh the session after an administrator changes permissions.';
459
+ return {
460
+ content: [{
461
+ type: 'text',
462
+ text: JSON.stringify({
463
+ error: 'Capability not authorized',
464
+ code: 'MCP_CAPABILITY_DENIED',
465
+ message,
466
+ }),
467
+ }],
468
+ isError: true,
469
+ };
470
+ };
457
471
  for (const toolDef of toolMetadata) {
458
472
  const toolName = toolDef.name;
459
473
  server.registerTool(toolName, {
@@ -472,16 +486,33 @@ export function createMcpServer(deps, authResolver, scopedDepsFactory) {
472
486
  isError: true,
473
487
  };
474
488
  }
475
- // Extract transport-neutral auth input DTO from the HTTP request
476
- const mcpAuthInput = {
477
- bearerToken: extractBearerToken(httpReq),
478
- apiKey: httpReq.headers.get('x-api-key') ?? undefined,
479
- clientSurface: parseClientSurface(httpReq.headers.get('x-index-surface')),
480
- telegramHandle: httpReq.headers.get('x-index-telegram-handle') ?? undefined,
481
- telegramUsername: httpReq.headers.get('x-index-telegram-username') ?? undefined,
482
- };
483
- // Resolve authenticated identity from the auth input DTO
484
- const { userId, agentId, isSessionAuth, networkScopeId, clientSurface } = await authResolver.resolveIdentity(mcpAuthInput);
489
+ // Resolve the request-local auth/agent snapshot, then repeat the exact
490
+ // tools/list policy decision before chat context, scoped DB, registry,
491
+ // or handler work. This is the forged-call fail-closed boundary.
492
+ const authenticated = await getAuthenticatedRequest(httpReq);
493
+ const preliminaryDecision = capabilityPolicy.authorize(authenticated.preliminarySubject, toolName);
494
+ if (!preliminaryDecision.allowed) {
495
+ observeDenial({
496
+ phase: 'tools/call',
497
+ toolName,
498
+ subject: authenticated.preliminarySubject,
499
+ decision: preliminaryDecision,
500
+ });
501
+ return capabilityDeniedResult(preliminaryDecision);
502
+ }
503
+ const resolved = await getResolvedRequest(httpReq);
504
+ const decision = capabilityPolicy.authorize(resolved.subject, toolName);
505
+ if (!decision.allowed) {
506
+ observeDenial({
507
+ phase: 'tools/call',
508
+ toolName,
509
+ subject: resolved.subject,
510
+ decision,
511
+ });
512
+ return capabilityDeniedResult(decision, resolved.context);
513
+ }
514
+ const { identity, context } = resolved;
515
+ const { userId, agentId } = identity;
485
516
  reportUserId = userId;
486
517
  // Per-principal MCP throttle. Runs BEFORE any DB work so a throttled
487
518
  // call short-circuits cheaply. The /mcp transport bypasses the
@@ -513,10 +544,7 @@ export function createMcpServer(deps, authResolver, scopedDepsFactory) {
513
544
  type: 'text',
514
545
  text: JSON.stringify({
515
546
  error: 'Rate limit exceeded',
516
- message: `Too many ${toolName} calls in a short period. Wait ${retryAfterSec}s before retrying.` +
517
- (toolName === 'discover_opportunities'
518
- ? ` If a discovery run is in progress, poll get_discovery_run instead of calling discover_opportunities again.`
519
- : ''),
547
+ message: `Too many ${toolName} calls in a short period. Wait ${retryAfterSec}s before retrying.`,
520
548
  retryAfterSec,
521
549
  }),
522
550
  }],
@@ -524,54 +552,7 @@ export function createMcpServer(deps, authResolver, scopedDepsFactory) {
524
552
  };
525
553
  }
526
554
  }
527
- // Resolve chat context for the user (mark as MCP — no interactive UI available)
528
- const context = await resolveChatContext({ database: deps.database, userId, contactsEnabled: deps.contactsEnabled });
529
555
  reportContext = context;
530
- context.isMcp = true;
531
- if (agentId) {
532
- context.agentId = agentId;
533
- }
534
- if (clientSurface) {
535
- context.clientSurface = clientSurface;
536
- }
537
- // Network-scoped agents inherit their bound network as the implicit chat
538
- // scope. Tools consume the scope envelope and derive any concrete
539
- // allowed network IDs from it plus the user's memberships.
540
- applyNetworkScopeToContext(context, networkScopeId);
541
- // Gate: API-key callers (background agents) must register before using most tools.
542
- // OAuth/JWT session callers (human MCP clients such as Claude Code) are exempt —
543
- // their identity is already established via the auth flow and they have no agent entity.
544
- if (!isSessionAuth && !context.agentId && !AGENT_GATE_EXEMPT.has(toolName)) {
545
- return {
546
- content: [{
547
- type: 'text',
548
- text: JSON.stringify({
549
- error: 'Agent not registered',
550
- message: 'You must register as an agent before using Index tools. ' +
551
- 'Call register_agent with your agent name to establish an identity. ' +
552
- 'The tools register_agent, read_docs, and scrape_url are available without registration.',
553
- }),
554
- }],
555
- isError: true,
556
- };
557
- }
558
- // Gate: non-onboarded users can only use onboarding-related tools.
559
- // Mirrors the chat orchestrator's ONBOARDING MODE — the MCP client must
560
- // walk the user through profile creation, Gmail connect, intent capture,
561
- // and complete_onboarding() before full tool access is granted.
562
- if (context.isOnboarding && !ONBOARDING_ALLOWED.has(toolName)) {
563
- const onboardingSteps = buildMcpOnboardingMessage(context);
564
- return {
565
- content: [{
566
- type: 'text',
567
- text: JSON.stringify({
568
- error: 'Onboarding required',
569
- message: onboardingSteps,
570
- }),
571
- }],
572
- isError: true,
573
- };
574
- }
575
556
  // Build per-request scoped databases via injected factory.
576
557
  // Network-scoped agents are clamped to their bound network plus the user's
577
558
  // personal network — they cannot reach other networks even when the user is
@@ -589,8 +570,9 @@ export function createMcpServer(deps, authResolver, scopedDepsFactory) {
589
570
  reportDeps = requestDeps;
590
571
  // Re-create registry with per-request deps for scoped database access.
591
572
  // Do not use cached registration metadata handlers here: tool handlers
592
- // close over userDb/systemDb when the registry is created.
593
- const requestRegistry = createToolRegistry(requestDeps);
573
+ // close over userDb/systemDb when the registry is created. The MCP
574
+ // surface profile keeps the tools/call lookup identical to tools/list.
575
+ const requestRegistry = createToolRegistry(requestDeps, { surface: 'mcp' });
594
576
  const requestTool = requestRegistry.get(toolName);
595
577
  if (!requestTool) {
596
578
  return {
@@ -619,39 +601,6 @@ export function createMcpServer(deps, authResolver, scopedDepsFactory) {
619
601
  traceEmitter: createMcpTraceEmitter(toolName, ctx),
620
602
  });
621
603
  const { text: sanitizedText, isError: toolIsError } = sanitizeMcpResult(result);
622
- // Slice 5: decision questions post-processing for discover_opportunities only.
623
- if (toolName === "discover_opportunities" && !toolIsError) {
624
- const questions = extractDecisionQuestions(sanitizedText);
625
- if (questions) {
626
- const envelopeBlock = {
627
- type: "text",
628
- text: renderQuestionsEnvelope(questions),
629
- };
630
- const supportsElicitation = !!server.server.getClientCapabilities()?.elicitation;
631
- // Capture into a local const so TS preserves the narrowing
632
- // inside the callback below. Optional chains don't survive
633
- // across closure boundaries under strict mode.
634
- const elicitInput = ctx.mcpReq?.elicitInput;
635
- if (supportsElicitation && elicitInput) {
636
- // Sequential — never parallel (day-one rule). We await the loop
637
- // before returning the tool result so test harnesses can observe
638
- // the dispatched calls deterministically.
639
- await dispatchElicitations({
640
- userId,
641
- questions,
642
- elicitInput: (params) => elicitInput(params),
643
- chatMessageWriter: deps.chatMessageWriter,
644
- });
645
- }
646
- return {
647
- content: [
648
- { type: "text", text: sanitizedText },
649
- envelopeBlock,
650
- ],
651
- ...(toolIsError ? { isError: true } : {}),
652
- };
653
- }
654
- }
655
604
  return {
656
605
  content: [{ type: 'text', text: sanitizedText }],
657
606
  ...(toolIsError ? { isError: true } : {}),
@@ -685,6 +634,31 @@ export function createMcpServer(deps, authResolver, scopedDepsFactory) {
685
634
  }
686
635
  });
687
636
  }
637
+ // McpServer's default tools/list handler exposes every registered tool.
638
+ // Replace it with a principal-aware inventory built from the same static
639
+ // metadata and the same policy used above for tools/call.
640
+ server.server.setRequestHandler('tools/list', async (_request, ctx) => {
641
+ const httpReq = ctx.http?.req;
642
+ if (!httpReq) {
643
+ throw new Error('No HTTP request available in MCP context');
644
+ }
645
+ const resolved = await getResolvedRequest(httpReq);
646
+ const visibleNames = new Set(capabilityPolicy.visibleToolNames(resolved.subject, toolMetadata.map((tool) => tool.name)));
647
+ return {
648
+ tools: toolMetadata
649
+ .filter((tool) => visibleNames.has(tool.name))
650
+ .map((tool) => ({
651
+ name: tool.name,
652
+ description: tool.description,
653
+ // `jsonSchema` is the zod-derived JSON Schema for the tool's object
654
+ // input, so it always has `type: 'object'`. The SDK describes the same
655
+ // value with two different types — `JsonSchemaType` (json-schema-typed,
656
+ // used by `fromJsonSchema`) and the wire-level `Tool['inputSchema']` —
657
+ // which are structurally incompatible, so bridge them at this boundary.
658
+ inputSchema: tool.jsonSchema,
659
+ })),
660
+ };
661
+ });
688
662
  logger.verbose('MCP server created', { toolCount: toolMetadata.length });
689
663
  return server;
690
664
  }
@@ -4,6 +4,7 @@ import { SystemNegotiationTurnSchema, FinalNegotiationTurnSchema } from "../doma
4
4
  import { turnSchemaFor, fallbackActionFor } from "../domain/negotiation.protocol.js";
5
5
  import { renderNegotiatorMemorySection } from "../domain/negotiation.memory.js";
6
6
  import { renderBargainingShiftSection } from "../domain/negotiation.deadlock.js";
7
+ import { configuredNegotiatorStance, stanceActionRules, stanceJobFraming, stanceQuerySatisfiedRule } from "../domain/negotiation.stance.contracts.js";
7
8
  import { attributedDialogueIsEmpty, renderAttributedPriorDialogue } from "../negotiation.attribution.js";
8
9
  import { protocolLogger } from "../../shared/observability/protocol.logger.js";
9
10
  const agentLog = protocolLogger("IndexNegotiator");
@@ -14,7 +15,7 @@ const SYSTEM_PROMPT = `You are the Index Negotiator, an AI agent acting on behal
14
15
  Your user's role in this connection: {role}
15
16
  Network context: {networkContext}
16
17
 
17
- Your job: Evaluate whether this connection genuinely serves {userName}'s interests given their role. Argue their case honestly — acknowledge weaknesses, but advocate for genuine fit.
18
+ Your job: Evaluate whether this connection genuinely serves {userName}'s interests given their role. {stanceFraming}
18
19
 
19
20
  Rules:
20
21
  {actionRules}
@@ -28,13 +29,27 @@ const V1_ACTION_RULES = `- On the FIRST turn: Propose the connection case. Expla
28
29
  - "counter" if you have specific objections but see potential
29
30
  - "accept" if the match genuinely benefits {userName}
30
31
  - "reject" if the match does not serve {userName}'s needs`;
31
- /** v2 initiator seat: reaching stance — accept is structurally unavailable. */
32
+ /**
33
+ * v2 initiator seat: reaching stance — accept is structurally unavailable.
34
+ *
35
+ * The closing rule (IND-570) is the initiator-only decision link between
36
+ * clarification and walking away: this seat is the one that can end a match it
37
+ * started, so once information arriving through EITHER clarification channel —
38
+ * the counterparty's answer to a `question`, or the acting user's own answers /
39
+ * private consultation surfaced between sessions — disqualifies the match, the
40
+ * seat should `withdraw` instead of countering indefinitely. It is worded
41
+ * channel-neutrally (it never names the `ask_user` action) so it renders
42
+ * correctly whether or not the caller granted `canAskUser`; the answer context
43
+ * it refers to is injected independently of that grant. It must never reach
44
+ * `V2_COUNTERPARTY_RULES` (that seat has no `withdraw`) or `V1_ACTION_RULES`.
45
+ */
32
46
  const V2_INITIATOR_RULES = `- You hold the INITIATING seat: your user's side surfaced this match and you are reaching out. Only the counterparty may accept — "accept" is NOT available to you.
33
47
  - On the FIRST turn: Make the outreach case. Explain why the connection would benefit both parties. Set action to "outreach".
34
48
  - On SUBSEQUENT turns: Evaluate the counterparty's arguments. Either:
35
49
  - "counter" if you have specific objections but see potential
36
50
  - "question" if you need a specific clarification from the counterparty
37
- - "withdraw" if the match does not serve {userName}'s needs`;
51
+ - "withdraw" if the match does not serve {userName}'s needs
52
+ - WITHDRAW ON DISQUALIFYING INFORMATION: clarification exists to resolve uncertainty, so act on what it returns. When something you have learned since reaching out — the counterparty's answer to one of your questions, or {userName}'s own answers or private consultation provided between sessions — reveals a reason this match no longer serves {userName}, choose "withdraw" rather than countering or questioning again. Once a disqualifying reason is on the table, do not keep negotiating the match.`;
38
53
  /**
39
54
  * v2 client-consult pause rule (P3.2). Appended to either seat's rules only
40
55
  * when the caller granted `canAskUser` — the action never appears in the
@@ -96,6 +111,11 @@ export class IndexNegotiator {
96
111
  // Deadlock→bargaining stance (IND-428): v2 only — defense in depth on top
97
112
  // of the graph-side gating, mirroring the canAskUser guard above.
98
113
  const bargainingActive = input.bargaining != null && version === "v2";
114
+ // Negotiator stance (IND-611). Resolved from the environment once per turn
115
+ // via the domain contract, exactly like `configuredScreenMode()`. Under the
116
+ // `advocate` default every stance fragment below is the legacy string, so
117
+ // the rendered prompt is byte-identical to the pre-IND-611 build.
118
+ const stance = configuredNegotiatorStance();
99
119
  const schema = turnSchemaFor(version, seat, isFinalTurn, {
100
120
  system: SystemNegotiationTurnSchema,
101
121
  final: FinalNegotiationTurnSchema,
@@ -106,7 +126,7 @@ export class IndexNegotiator {
106
126
  const networkContext = input.indexContext.prompt || "General discovery";
107
127
  const actionRules = (version === "v2"
108
128
  ? (seat === "initiator" ? V2_INITIATOR_RULES : V2_COUNTERPARTY_RULES)
109
- : V1_ACTION_RULES) + (canAskUser ? ASK_USER_RULE : "");
129
+ : V1_ACTION_RULES) + stanceActionRules(stance) + (canAskUser ? ASK_USER_RULE : "");
110
130
  const finalTurnInstruction = input.isFinalTurn
111
131
  ? (version === "v2"
112
132
  ? (seat === "initiator"
@@ -123,10 +143,13 @@ export class IndexNegotiator {
123
143
  QUERY PRIORITY RULE: This search query is the PRIMARY criterion for this negotiation. Before evaluating intents or profile overlap, first answer: does ${otherName} satisfy the search query "${input.discoveryQuery}"?
124
144
  - If the query is a role or identity term (e.g. "samurai", "investors", "designers"): check whether ${otherName} IS that thing based on their profile. Subject-matter adjacency does not count (drawing samurai ≠ being a samurai, raising funding ≠ being an investor).
125
145
  - If ${otherName} does NOT satisfy the query: REJECT the match. Background intents cannot rescue a query mismatch.
126
- - If ${otherName} DOES satisfy the query: PROPOSE or ACCEPT the connection and evaluate fit normally using intents and profile data.`
146
+ ${stanceQuerySatisfiedRule(stance, otherName, userName)}`
127
147
  : '';
128
148
  const systemPrompt = SYSTEM_PROMPT
129
149
  .replace("{actionRules}", actionRules)
150
+ // Stance framing is substituted BEFORE the global {userName} replace so
151
+ // its own {userName} placeholders resolve in the same pass.
152
+ .replace("{stanceFraming}", stanceJobFraming(stance))
130
153
  .replace(/{userName}/g, userName)
131
154
  .replace("{discoveryContext}", discoveryContext)
132
155
  .replace("{discoveryQueryContext}", discoveryQueryContext)
@@ -138,6 +161,7 @@ QUERY PRIORITY RULE: This search query is the PRIMARY criterion for this negotia
138
161
  userName,
139
162
  canAskUser,
140
163
  consecutiveNonConvergent: input.bargaining?.consecutiveNonConvergent ?? 0,
164
+ stance,
141
165
  }))
142
166
  .replace("{negotiatorMemory}", renderNegotiatorMemorySection(input.memory ?? []));
143
167
  const formatTurnLine = (t, i) => {
@@ -619,10 +619,8 @@ export interface NegotiationResult {
619
619
  }
620
620
  /**
621
621
  * Per-candidate resolution hook — fires as each negotiation settles, before
622
- * Promise.all aggregates. Used by the orchestrator branch to progressively
623
- * stream `opportunity_draft_ready` events as each candidate resolves, rather
624
- * than emitting all at once after the full fan-out completes. Awaited so the
625
- * caller can run async work (DB update, event emit) before the next settle.
622
+ * Promise.all aggregates. Awaited so the caller can run async work before the
623
+ * next settle.
626
624
  *
627
625
  * `turns` and `outcome` are passed through from the underlying negotiation
628
626
  * graph so consumers can build per-candidate decision-question inputs without
@@ -650,7 +648,6 @@ export declare function negotiateCandidates(negotiationGraph: NegotiationGraphLi
650
648
  indexContextOverrides?: Map<string, string>;
651
649
  timeoutMs?: number;
652
650
  onCandidateResolved?: OnNegotiationResolved;
653
- trigger?: "orchestrator" | "ambient";
654
651
  /**
655
652
  * Initiator seat for every candidate session in this fan-out (v2 stamp).
656
653
  * Passed through to the negotiation graph, which may still override it by