@kontourai/survey 2.5.0 → 4.0.0

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 (38) hide show
  1. package/README.md +6 -1
  2. package/dist/examples/calibrated-auto-accept.d.ts +22 -15
  3. package/dist/examples/calibrated-auto-accept.js +40 -36
  4. package/dist/src/agent-utterance.d.ts +87 -11
  5. package/dist/src/agent-utterance.js +135 -44
  6. package/dist/src/calibration.d.ts +48 -21
  7. package/dist/src/calibration.js +72 -33
  8. package/dist/src/console/review-console-server.d.ts +3 -1
  9. package/dist/src/console/review-console-server.js +203 -50
  10. package/dist/src/extraction-envelope.d.ts +22 -0
  11. package/dist/src/extraction-envelope.js +25 -4
  12. package/dist/src/index.d.ts +9 -8
  13. package/dist/src/index.js +2 -2
  14. package/dist/src/inquiry-mapping.d.ts +15 -1
  15. package/dist/src/inquiry-mapping.js +10 -2
  16. package/dist/src/mcp/review-mcp.js +219 -279
  17. package/dist/src/producer-profile.d.ts +41 -2
  18. package/dist/src/producer-profile.js +29 -2
  19. package/dist/src/review-session-file.d.ts +64 -0
  20. package/dist/src/review-session-file.js +320 -0
  21. package/dist/src/review-workbench/edited-value.d.ts +70 -0
  22. package/dist/src/review-workbench/edited-value.js +147 -0
  23. package/dist/src/review-workbench/review-presentation.d.ts +44 -0
  24. package/dist/src/review-workbench/review-presentation.js +49 -0
  25. package/dist/src/review-workbench/review-queue-session.js +8 -1
  26. package/dist/src/review-workbench/review-session-replay.d.ts +35 -1
  27. package/dist/src/review-workbench/review-session-replay.js +77 -0
  28. package/dist/src/review-workbench/review-workbench.d.ts +8 -4
  29. package/dist/src/review-workbench/review-workbench.js +19 -7
  30. package/dist/src/review-workbench/server-review-session.d.ts +3 -1
  31. package/dist/src/review-workbench/server-review-session.js +1 -0
  32. package/dist/src/reviewed-candidate-resolution.js +13 -7
  33. package/dist/src/schema-mapping.d.ts +23 -0
  34. package/dist/src/schema-mapping.js +30 -20
  35. package/dist/src/to-surface.d.ts +30 -6
  36. package/dist/src/to-surface.js +196 -18
  37. package/dist/src/types.d.ts +39 -0
  38. package/package.json +8 -4
@@ -1,30 +1,17 @@
1
- import { createInterface } from "node:readline";
2
- import { readFile, writeFile, rename } from "node:fs/promises";
1
+ import { readFile } from "node:fs/promises";
3
2
  import { resolve, dirname } from "node:path";
3
+ import { McpServer } from "@modelcontextprotocol/server";
4
+ import { serveStdio } from "@modelcontextprotocol/server/stdio";
5
+ import { z } from "zod";
4
6
  import { buildReviewSessionEvents, currentReviewItem, deriveQueueRowStatus, nextUnresolvedItemName, reviewSessionSummary, workbenchDecisionDefinitions, } from "../review-workbench/review-workbench.js";
5
7
  import { createServerReviewSessionRecord, currentSessionState, deriveServerReviewSessionApplyResult, } from "../review-workbench/server-review-session.js";
6
- /**
7
- * Minimal Model Context Protocol server over stdio for review-queue inspection
8
- * and decision-making against a session JSON file.
9
- *
10
- * Implemented without an SDK dependency — newline-delimited JSON-RPC 2.0 with
11
- * the MCP lifecycle (initialize / ping / tools) and an optional embedded UI
12
- * resource per tool call. The session file is the durable store; decisions
13
- * append events and write back atomically (write temp + rename).
14
- */
15
- const PROTOCOL_VERSION = "2025-06-18";
8
+ import { appendReviewSessionEvents, readReviewSessionFile, storedReviewSessionName, updateReviewSessionFile, } from "../review-session-file.js";
16
9
  const SESSION_NAME = "mcp-review-session";
17
- // MCP Apps extension (SEP-1865). The review card is offered under both UI
18
- // conventions so one server renders across hosts: the existing mcp-ui.dev
19
- // embedded resource in tool results, AND a declared `ui://` resource that the
20
- // official Apps hosts (ChatGPT/Claude) and Station's SEP-1865 resolver read via
21
- // resources/read. The canonical pointer is the FLAT `_meta["ui/resourceUri"]`
22
- // key (what registerAppTool emits); the nested `_meta.ui.resourceUri` is the
23
- // convenience shape some hosts read — we emit both.
24
10
  const UI_RESOURCE_URI_META_KEY = "ui/resourceUri";
25
11
  const UI_CAPABILITY_EXTENSION = "io.modelcontextprotocol/ui";
26
12
  const QUEUE_PANEL_URI = "ui://survey/review-card/queue";
27
13
  const UI_RESOURCE_MIME = "text/html;profile=mcp-app";
14
+ const SERVER_INSTRUCTIONS = "Use survey_review_queue to inspect the queue, survey_review_item to drill into one item, and survey_review_decide to record a decision. Decisions are validated and persisted to the session file and are irreversible within this session.";
28
15
  // MCP tool decision strings → ReviewWorkbenchDecision
29
16
  const MCP_DECISION_MAP = {
30
17
  accept: "accept-proposed",
@@ -33,13 +20,7 @@ const MCP_DECISION_MAP = {
33
20
  "could-not-confirm": "could-not-confirm",
34
21
  };
35
22
  async function readSessionFile(path) {
36
- const raw = await readFile(path, "utf8");
37
- return JSON.parse(raw);
38
- }
39
- async function writeSessionFileAtomic(path, content) {
40
- const tmp = `${path}.tmp`;
41
- await writeFile(tmp, JSON.stringify(content, null, 2), "utf8");
42
- await rename(tmp, path);
23
+ return readReviewSessionFile(path);
43
24
  }
44
25
  // ---- Queue helpers -------------------------------------------------------
45
26
  function queueSummaryText(snapshot, events) {
@@ -367,64 +348,74 @@ async function toolDecide(itemName, mcpDecision, note, attemptEvidenceIds, optio
367
348
  if (wbDecision === "could-not-confirm" && !note?.trim()) {
368
349
  throw new DomainError("survey_review_decide requires a non-empty reason for could-not-confirm");
369
350
  }
370
- const file = await readSessionFile(options.sessionPath);
371
- const { snapshot, events } = file;
372
- const current = currentSessionState(snapshot, events);
373
- const item = current.items.find((i) => i.metadata.name === itemName);
374
- if (!item) {
375
- throw new DomainError(`Unknown review item: ${itemName}`);
376
- }
377
- const existingDecision = current.decisionsByItemName[item.metadata.name];
378
- if (existingDecision) {
379
- throw new DomainError(`Item ${itemName} already has a decision: ${existingDecision}. Use a new session to re-decide.`);
380
- }
381
- // Build the updated session state with the decision
382
- const sessionWithDecision = {
383
- ...current,
384
- decisionsByItemName: {
385
- ...current.decisionsByItemName,
386
- [itemName]: wbDecision,
387
- },
388
- ...(note !== undefined
389
- ? {
390
- notesByItemName: {
391
- ...current.notesByItemName,
392
- [itemName]: note,
393
- },
394
- }
395
- : {}),
396
- ...(attemptEvidenceIds?.length
397
- ? {
398
- attemptEvidenceIdsByItemName: {
399
- ...current.attemptEvidenceIdsByItemName,
400
- [itemName]: [...attemptEvidenceIds],
401
- },
402
- }
403
- : {}),
404
- };
405
- // Use the server session APIs for apply-path validation
406
- const record = createServerReviewSessionRecord({
407
- sessionName: SESSION_NAME,
408
- snapshot,
409
- eventCount: events.length,
410
- updatedAt: new Date(),
411
- });
412
- const newEvents = buildReviewSessionEvents(sessionWithDecision, SESSION_NAME);
413
- const applyResult = deriveServerReviewSessionApplyResult({
414
- record,
415
- events: newEvents,
416
- requiredResolvedItems: "none",
351
+ // Read, validate and write inside the shared session lock so a concurrent
352
+ // decide or console save cannot interleave and drop this decision (#281).
353
+ const { snapshot, sessionWithDecision, newEvents } = await updateReviewSessionFile(options.sessionPath, (file) => {
354
+ const { snapshot, events } = file;
355
+ const current = currentSessionState(snapshot, events);
356
+ const item = current.items.find((i) => i.metadata.name === itemName);
357
+ if (!item) {
358
+ throw new DomainError(`Unknown review item: ${itemName}`);
359
+ }
360
+ const existingDecision = current.decisionsByItemName[item.metadata.name];
361
+ if (existingDecision) {
362
+ throw new DomainError(`Item ${itemName} already has a decision: ${existingDecision}. Use a new session to re-decide.`);
363
+ }
364
+ // Build the updated session state with the decision
365
+ const sessionWithDecision = {
366
+ ...current,
367
+ decisionsByItemName: {
368
+ ...current.decisionsByItemName,
369
+ [itemName]: wbDecision,
370
+ },
371
+ ...(note !== undefined
372
+ ? {
373
+ notesByItemName: {
374
+ ...current.notesByItemName,
375
+ [itemName]: note,
376
+ },
377
+ }
378
+ : {}),
379
+ ...(attemptEvidenceIds?.length
380
+ ? {
381
+ attemptEvidenceIdsByItemName: {
382
+ ...current.attemptEvidenceIdsByItemName,
383
+ [itemName]: [...attemptEvidenceIds],
384
+ },
385
+ }
386
+ : {}),
387
+ };
388
+ // Append only this decision's events (its note, then the decision) to the
389
+ // stored log. Regenerating the whole log from state would erase earlier
390
+ // reversals and note changes recorded by the console (#281).
391
+ const sessionName = storedReviewSessionName(file, SESSION_NAME);
392
+ const decisionEvents = buildReviewSessionEvents(sessionWithDecision, sessionName).filter((event) => event.spec.reviewItemName === itemName
393
+ && (event.spec.eventType === "decision-changed"
394
+ || event.spec.eventType === "decision-submitted"
395
+ || (event.spec.eventType === "note-changed" && note !== undefined)));
396
+ const newEvents = appendReviewSessionEvents(file, decisionEvents);
397
+ // Use the server session APIs for apply-path validation
398
+ const record = createServerReviewSessionRecord({
399
+ sessionName,
400
+ snapshot,
401
+ eventCount: events.length,
402
+ updatedAt: new Date(),
403
+ });
404
+ const applyResult = deriveServerReviewSessionApplyResult({
405
+ record,
406
+ events: newEvents,
407
+ requiredResolvedItems: "none",
408
+ });
409
+ if (!applyResult.ok) {
410
+ throw new DomainError(`Decision validation failed: ${applyResult.issues.map((issue) => "message" in issue ? issue.message : String(issue)).join("; ")}`);
411
+ }
412
+ const updatedFile = {
413
+ session: file.session,
414
+ snapshot,
415
+ events: newEvents,
416
+ };
417
+ return { next: updatedFile, result: { snapshot, sessionWithDecision, newEvents } };
417
418
  });
418
- if (!applyResult.ok) {
419
- throw new DomainError(`Decision validation failed: ${applyResult.issues.map((issue) => "message" in issue ? issue.message : String(issue)).join("; ")}`);
420
- }
421
- // Persist atomically
422
- const updatedFile = {
423
- session: file.session,
424
- snapshot,
425
- events: newEvents,
426
- };
427
- await writeSessionFileAtomic(options.sessionPath, updatedFile);
428
419
  // Summarize the result
429
420
  const updatedItem = sessionWithDecision.items.find((i) => i.metadata.name === itemName);
430
421
  const itemText = updatedItem ? itemDetailText(updatedItem, snapshot, newEvents) : `Item: ${itemName}`;
@@ -449,213 +440,160 @@ function buildUiResource(item, snapshot, events, instance) {
449
440
  mimeType: "text/html;profile=mcp-app",
450
441
  text: buildReviewCardHtml(item, snapshot, events),
451
442
  _meta: {
443
+ ui: {
444
+ csp: {
445
+ connectDomains: [],
446
+ resourceDomains: [],
447
+ },
448
+ },
452
449
  "mcpui.dev/ui-preferred-frame-size": ["420px", "560px"],
453
450
  },
454
451
  },
455
452
  };
456
453
  }
457
- // Render the SEP-1865 declared review card: load the configured session, replay
458
- // to current state, and render the active item's card HTML (the same HTML the
459
- // embedded `queue` resource carries — here served via resources/read).
460
- async function readQueuePanelHtml(options) {
454
+ // Render the declared review card from the same function used by embedded tool
455
+ // results, so Apps and text-first hosts cannot drift.
456
+ async function readQueuePanelResource(options) {
461
457
  const { snapshot, events } = await readSessionFile(options.sessionPath);
462
458
  const current = currentSessionState(snapshot, events);
463
459
  const activeItem = currentReviewItem(current);
464
- return buildReviewCardHtml(activeItem, snapshot, events);
460
+ return buildUiResource(activeItem, snapshot, events, "queue").resource;
465
461
  }
466
462
  // ---- Domain error (maps to isError:true, not a JSON-RPC error) -----------
467
463
  class DomainError extends Error {
468
464
  isDomainError = true;
469
465
  }
470
- // ---- JSON-RPC dispatch ---------------------------------------------------
471
- async function handleLine(line, options, serverVersion) {
472
- const trimmed = line.trim();
473
- if (trimmed === "")
474
- return;
475
- let message;
476
- try {
477
- message = JSON.parse(trimmed);
478
- }
479
- catch {
480
- send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
481
- return;
466
+ // ---- Official dual-era MCP server ---------------------------------------
467
+ function uiResourceMeta(resourceUri) {
468
+ return {
469
+ ui: { resourceUri, visibility: ["model", "app"] },
470
+ [UI_RESOURCE_URI_META_KEY]: resourceUri,
471
+ };
472
+ }
473
+ function createReviewMcpServer(options, serverVersion) {
474
+ const server = new McpServer({
475
+ name: "survey-review-mcp",
476
+ title: "Survey Review MCP",
477
+ version: serverVersion,
478
+ }, {
479
+ instructions: SERVER_INSTRUCTIONS,
480
+ capabilities: options.noUi
481
+ ? {}
482
+ : {
483
+ extensions: {
484
+ [UI_CAPABILITY_EXTENSION]: {},
485
+ },
486
+ },
487
+ cacheHints: {
488
+ "server/discover": { ttlMs: 0, cacheScope: "private" },
489
+ "tools/list": { ttlMs: 0, cacheScope: "private" },
490
+ "resources/list": { ttlMs: 0, cacheScope: "private" },
491
+ "resources/read": { ttlMs: 0, cacheScope: "private" },
492
+ },
493
+ });
494
+ server.registerTool("survey_review_queue", {
495
+ title: "Review queue",
496
+ description: "Return a text summary and JSON of the current review queue: all items with their status, the active item, resolved/total counts, and session summary totals.",
497
+ inputSchema: z.object({}),
498
+ ...(options.noUi ? {} : { _meta: uiResourceMeta(QUEUE_PANEL_URI) }),
499
+ }, async () => runReviewTool(() => toolQueue(options)));
500
+ server.registerTool("survey_review_item", {
501
+ title: "Review item detail",
502
+ description: "Return full detail for one review item: current and proposed values, confidence, source references, excerpts, and any current decision.",
503
+ inputSchema: z.object({
504
+ itemName: z.string().min(1).describe("The ReviewItem name to inspect."),
505
+ }),
506
+ }, async ({ itemName }) => runReviewTool(() => toolItem(itemName, options)));
507
+ server.registerTool("survey_review_decide", {
508
+ title: "Record a review decision",
509
+ description: "Apply a decision to a review item and persist it through Survey's server-owned validation boundary. Decision must be accept, hold, reject, or could-not-confirm. Could-not-confirm requires a reason. Domain failures return isError:true.",
510
+ inputSchema: z.discriminatedUnion("decision", [
511
+ z.object({
512
+ itemName: z.string().min(1).describe("The ReviewItem name to decide."),
513
+ decision: z
514
+ .enum(["accept", "hold", "reject"])
515
+ .describe("accept = accept-proposed, hold = keep-current, reject = reject-proposed."),
516
+ note: z.string().optional().describe("Optional reviewer note or rationale."),
517
+ }),
518
+ z.object({
519
+ itemName: z.string().min(1).describe("The ReviewItem name to decide."),
520
+ decision: z
521
+ .literal("could-not-confirm")
522
+ .describe("Record a terminal non-answer after evidence attempts are exhausted."),
523
+ reason: z
524
+ .string()
525
+ .trim()
526
+ .min(1)
527
+ .describe("Required non-empty reason for the could-not-confirm decision."),
528
+ attemptEvidenceIds: z
529
+ .array(z.string())
530
+ .optional()
531
+ .describe("Evidence ids attempted before a could-not-confirm decision."),
532
+ }),
533
+ ]),
534
+ }, async (input) => runReviewTool(() => input.decision === "could-not-confirm"
535
+ ? toolDecide(input.itemName, input.decision, input.reason, input.attemptEvidenceIds, options)
536
+ : toolDecide(input.itemName, input.decision, input.note, undefined, options)));
537
+ if (!options.noUi) {
538
+ server.registerResource("survey-review-workbench", QUEUE_PANEL_URI, {
539
+ title: "Survey review workbench",
540
+ description: "Interactive review card for the active item in the configured review session.",
541
+ mimeType: UI_RESOURCE_MIME,
542
+ cacheHint: { ttlMs: 0, cacheScope: "private" },
543
+ }, async () => {
544
+ const resource = await readQueuePanelResource(options);
545
+ return {
546
+ contents: [
547
+ {
548
+ uri: QUEUE_PANEL_URI,
549
+ mimeType: resource.mimeType,
550
+ text: sanitizeProtocolText(resource.text),
551
+ _meta: resource._meta,
552
+ },
553
+ ],
554
+ };
555
+ });
482
556
  }
483
- const { id, method, params } = message;
484
- const isNotification = id === undefined;
557
+ return server;
558
+ }
559
+ async function runReviewTool(operation) {
485
560
  try {
486
- if (method === "initialize") {
487
- send({
488
- jsonrpc: "2.0",
489
- id,
490
- result: {
491
- protocolVersion: PROTOCOL_VERSION,
492
- capabilities: {
493
- tools: { listChanged: false },
494
- // Resources back the SEP-1865 ui:// review card (unless --no-ui).
495
- ...(options.noUi ? {} : { resources: { listChanged: false } }),
496
- ...(options.noUi
497
- ? {}
498
- : { extensions: { [UI_CAPABILITY_EXTENSION]: {} } }),
499
- },
500
- serverInfo: { name: "survey-review-mcp", title: "Survey Review MCP", version: serverVersion },
501
- instructions: "Use survey_review_queue to inspect the queue, survey_review_item to drill into a single item, and survey_review_decide to record a decision. Decisions are persisted to the session file and are irreversible within this session.",
502
- },
503
- });
504
- }
505
- else if (method === "ping") {
506
- send({ jsonrpc: "2.0", id, result: {} });
507
- }
508
- else if (method === "tools/list") {
509
- send({
510
- jsonrpc: "2.0",
511
- id,
512
- result: {
513
- tools: [
514
- {
515
- name: "survey_review_queue",
516
- title: "Review queue",
517
- description: "Return a text summary and JSON of the current review queue: all items with their status (pending, in-review, resolved, rejected, escalated), the active item, resolved/total counts, and session summary totals.",
518
- inputSchema: { type: "object", properties: {} },
519
- // SEP-1865 UI pointer (both flat canonical + nested), unless --no-ui.
520
- ...(options.noUi
521
- ? {}
522
- : {
523
- _meta: {
524
- [UI_RESOURCE_URI_META_KEY]: QUEUE_PANEL_URI,
525
- ui: { resourceUri: QUEUE_PANEL_URI, visibility: ["model", "app"] },
526
- },
527
- }),
528
- },
529
- {
530
- name: "survey_review_item",
531
- title: "Review item detail",
532
- description: "Return full detail for a single review item: current vs proposed values, confidence, source references, excerpts, and the current decision (if any).",
533
- inputSchema: {
534
- type: "object",
535
- properties: {
536
- itemName: { type: "string", description: "The ReviewItem name to inspect." },
537
- },
538
- required: ["itemName"],
539
- },
540
- },
541
- {
542
- name: "survey_review_decide",
543
- title: "Record a review decision",
544
- description: "Apply a decision to a review item and persist it to the session file. Decision must be accept, hold, reject, or could-not-confirm. Could-not-confirm requires a reason. Domain failures return isError:true.",
545
- inputSchema: {
546
- type: "object",
547
- properties: {
548
- itemName: { type: "string", description: "The ReviewItem name to decide." },
549
- decision: {
550
- type: "string",
551
- enum: ["accept", "hold", "reject", "could-not-confirm"],
552
- description: "accept = accept-proposed, hold = keep-current, reject = reject-proposed, could-not-confirm = terminal non-answer.",
553
- },
554
- note: { type: "string", description: "Optional reviewer note / rationale." },
555
- reason: { type: "string", minLength: 1, description: "Required non-empty reason when decision is could-not-confirm." },
556
- attemptEvidenceIds: {
557
- type: "array",
558
- items: { type: "string" },
559
- description: "Optional evidence ids recording what was attempted before could-not-confirm.",
560
- },
561
- },
562
- required: ["itemName", "decision"],
563
- allOf: [{
564
- if: { properties: { decision: { const: "could-not-confirm" } }, required: ["decision"] },
565
- then: { required: ["reason"] },
566
- }],
567
- },
568
- },
569
- ],
570
- },
571
- });
572
- }
573
- else if (method === "resources/list") {
574
- send({
575
- jsonrpc: "2.0",
576
- id,
577
- result: {
578
- resources: options.noUi
579
- ? []
580
- : [
581
- {
582
- uri: QUEUE_PANEL_URI,
583
- name: "Survey review workbench",
584
- description: "Interactive review card for the active item in the configured review session (MCP Apps UI resource).",
585
- mimeType: UI_RESOURCE_MIME,
586
- },
587
- ],
588
- },
589
- });
590
- }
591
- else if (method === "resources/read") {
592
- const uri = typeof params?.uri === "string" ? params.uri : "";
593
- if (options.noUi || uri !== QUEUE_PANEL_URI) {
594
- send({ jsonrpc: "2.0", id, error: { code: -32602, message: `Unknown resource: ${uri || "(missing uri)"}` } });
595
- return;
596
- }
597
- const html = await readQueuePanelHtml(options);
598
- send({
599
- jsonrpc: "2.0",
600
- id,
601
- result: { contents: [{ uri: QUEUE_PANEL_URI, mimeType: UI_RESOURCE_MIME, text: html }] },
602
- });
603
- }
604
- else if (method === "tools/call") {
605
- const name = typeof params?.name === "string" ? params.name : "";
606
- const toolArgs = (params?.arguments ?? {});
607
- try {
608
- let content;
609
- if (name === "survey_review_queue") {
610
- content = await toolQueue(options);
611
- }
612
- else if (name === "survey_review_item") {
613
- const itemName = typeof toolArgs.itemName === "string" ? toolArgs.itemName : "";
614
- if (!itemName) {
615
- throw new DomainError("survey_review_item requires itemName");
616
- }
617
- content = await toolItem(itemName, options);
618
- }
619
- else if (name === "survey_review_decide") {
620
- const itemName = typeof toolArgs.itemName === "string" ? toolArgs.itemName : "";
621
- const decision = typeof toolArgs.decision === "string" ? toolArgs.decision : "";
622
- const note = typeof toolArgs.note === "string" ? toolArgs.note : undefined;
623
- const reason = typeof toolArgs.reason === "string" ? toolArgs.reason : undefined;
624
- const attemptEvidenceIds = Array.isArray(toolArgs.attemptEvidenceIds)
625
- && toolArgs.attemptEvidenceIds.every((value) => typeof value === "string")
626
- ? toolArgs.attemptEvidenceIds
627
- : undefined;
628
- if (!itemName)
629
- throw new DomainError("survey_review_decide requires itemName");
630
- if (!decision)
631
- throw new DomainError("survey_review_decide requires decision");
632
- content = await toolDecide(itemName, decision, decision === "could-not-confirm" ? reason : note, attemptEvidenceIds, options);
633
- }
634
- else {
635
- send({ jsonrpc: "2.0", id, error: { code: -32602, message: `Unknown tool: ${name || "(missing name)"}` } });
636
- return;
637
- }
638
- send({ jsonrpc: "2.0", id, result: { content, isError: false } });
639
- }
640
- catch (error) {
641
- const text = error instanceof Error ? error.message : String(error);
642
- send({ jsonrpc: "2.0", id, result: { content: [{ type: "text", text }], isError: true } });
643
- }
644
- }
645
- else if (isNotification) {
646
- // Lifecycle notifications such as notifications/initialized need no reply.
647
- }
648
- else {
649
- send({ jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${method ?? "(none)"}` } });
650
- }
561
+ return {
562
+ content: (await operation()).map(sanitizeContentItem),
563
+ isError: false,
564
+ };
651
565
  }
652
566
  catch (error) {
653
- if (!isNotification) {
654
- const messageText = error instanceof Error ? error.message : String(error);
655
- send({ jsonrpc: "2.0", id, error: { code: -32603, message: messageText } });
656
- }
567
+ return {
568
+ content: [
569
+ {
570
+ type: "text",
571
+ text: sanitizeProtocolText(error instanceof Error ? error.message : String(error)),
572
+ },
573
+ ],
574
+ isError: true,
575
+ };
657
576
  }
658
577
  }
578
+ const UNSAFE_TEXT_CHARS_RE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u0080-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u206f]/g;
579
+ function sanitizeProtocolText(text) {
580
+ return text.replace(UNSAFE_TEXT_CHARS_RE, "");
581
+ }
582
+ function sanitizeContentItem(item) {
583
+ if (item.type === "text") {
584
+ return { ...item, text: sanitizeProtocolText(item.text) };
585
+ }
586
+ return {
587
+ ...item,
588
+ resource: {
589
+ ...item.resource,
590
+ text: sanitizeProtocolText(item.resource.text),
591
+ },
592
+ };
593
+ }
594
+ function sanitizeDiagnostic(text) {
595
+ return sanitizeProtocolText(text).replaceAll(/\s*\r?\n\s*/g, " ").trim();
596
+ }
659
597
  // ---- Entry point ---------------------------------------------------------
660
598
  function parseMcpArgs(args) {
661
599
  const defaultSession = resolve(dirname(new URL(import.meta.url).pathname), "../../../example-data/mcp-review-session.json");
@@ -688,17 +626,19 @@ async function readPackageVersion() {
688
626
  return "0.0.0";
689
627
  }
690
628
  }
691
- function send(message) {
692
- process.stdout.write(`${JSON.stringify(message)}\n`);
693
- }
694
629
  export async function runReviewMcp(args) {
695
630
  const options = parseMcpArgs(args);
696
631
  const serverVersion = await readPackageVersion();
697
- const rl = createInterface({ input: process.stdin, terminal: false });
698
- rl.on("line", (line) => {
699
- void handleLine(line, options, serverVersion);
632
+ const inputClosed = new Promise((resolveClosed) => {
633
+ process.stdin.once("end", resolveClosed);
634
+ process.stdin.once("close", resolveClosed);
700
635
  });
701
- await new Promise((resolveClosed) => {
702
- rl.on("close", resolveClosed);
636
+ const handle = serveStdio(() => createReviewMcpServer(options, serverVersion), {
637
+ legacy: "serve",
638
+ onerror: (error) => {
639
+ process.stderr.write(`survey-review-mcp: ${sanitizeDiagnostic(error.message)}\n`);
640
+ },
703
641
  });
642
+ await inputClosed;
643
+ await handle.close();
704
644
  }
@@ -129,6 +129,20 @@ export declare const AUTO_ACCEPT_WITHIN_COMFORT_ZONE: true;
129
129
  * candidate's evidence gets passed into that decision.
130
130
  */
131
131
  export declare function meetsAutoAcceptThreshold(confidence: number, minConfidence: number): boolean;
132
+ /**
133
+ * Refuse an auto-accept policy threshold that cannot express a comfort zone:
134
+ * `minConfidence` must be a finite number in (0, 1]. A threshold of 0 (or
135
+ * below) would accept every proposal, and one above 1 accepts only
136
+ * out-of-range self-reports, so either makes `withinComfortZone: true` a
137
+ * false statement. Throws `RangeError`.
138
+ */
139
+ export declare function assertValidAutoAcceptThreshold(minConfidence: number): void;
140
+ /**
141
+ * Whether a proposal's self-reported confidence is usable by the auto-accept
142
+ * gate: a finite number in [0, 1]. Anything else (7, -5, NaN) is never
143
+ * auto-accepted; the proposal stays in human review.
144
+ */
145
+ export declare function isAutoAcceptConfidenceInRange(confidence: number): boolean;
132
146
  /**
133
147
  * The accepted-candidate-shaped evidence `evaluateAutoAccept` decides over.
134
148
  * Deliberately narrow: only the fields the auto-accept policy itself reads,
@@ -157,6 +171,18 @@ export interface AutoAcceptEvidence {
157
171
  */
158
172
  proposedAt?: string;
159
173
  }
174
+ /**
175
+ * A proposal the auto-accept policy refused because its self-reported
176
+ * confidence is not a finite number in [0, 1]. The proposal is left for human
177
+ * review; the warning records why it was not auto-accepted.
178
+ */
179
+ export interface AutoAcceptWarning {
180
+ code: "confidence-out-of-range";
181
+ /** The refused proposal's id. */
182
+ proposalId: string;
183
+ /** The out-of-range confidence exactly as reported. */
184
+ confidence: number;
185
+ }
160
186
  /** The auto-accept policy `evaluateAutoAccept` gates against. */
161
187
  export interface AutoAcceptPolicy {
162
188
  /** Minimum confidence (inclusive) a proposal must clear to auto-accept. */
@@ -164,8 +190,17 @@ export interface AutoAcceptPolicy {
164
190
  }
165
191
  /** The unified auto-accept decision `evaluateAutoAccept` returns. */
166
192
  export interface AutoAcceptDecision {
167
- /** `true` iff there is no conflict and `evidence.confidence` clears `policy.minConfidence`. */
193
+ /**
194
+ * `true` iff there is no conflict, `evidence.confidence` is a finite number
195
+ * in [0, 1], and it clears `policy.minConfidence`.
196
+ */
168
197
  accepted: boolean;
198
+ /**
199
+ * Set when the proposal was refused because its self-reported confidence
200
+ * is not a finite number in [0, 1]. The proposal is not auto-accepted and
201
+ * stays in human review.
202
+ */
203
+ warning?: "confidence-out-of-range";
169
204
  /** The confidence value that was gated on (== `evidence.confidence`). */
170
205
  confidence: number;
171
206
  /** Composed rationale — always computed; callers only use it when `accepted`. */
@@ -192,7 +227,11 @@ export interface AutoAcceptDecision {
192
227
  * back to `fallbackTimestamp` (and reporting which source was used via
193
228
  * `reviewedAtSource`) when a profile's evidence carries no timestamp of
194
229
  * its own.
195
- * 4. Always report `actor: AUTO_ACCEPT_ACTOR` and
230
+ * 4. Refuse out-of-range inputs: throw `RangeError` unless
231
+ * `policy.minConfidence` is a finite number in (0, 1], and never accept a
232
+ * proposal whose confidence is not a finite number in [0, 1] (reported via
233
+ * `warning`). This is what keeps `withinComfortZone: true` truthful.
234
+ * 5. Always report `actor: AUTO_ACCEPT_ACTOR` and
196
235
  * `withinComfortZone: AUTO_ACCEPT_WITHIN_COMFORT_ZONE` (ADR 0003 §4:
197
236
  * auto-accept only ever yields "assumed" with the comfort-zone posture).
198
237
  *