@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.
- package/README.md +6 -1
- package/dist/examples/calibrated-auto-accept.d.ts +22 -15
- package/dist/examples/calibrated-auto-accept.js +40 -36
- package/dist/src/agent-utterance.d.ts +87 -11
- package/dist/src/agent-utterance.js +135 -44
- package/dist/src/calibration.d.ts +48 -21
- package/dist/src/calibration.js +72 -33
- package/dist/src/console/review-console-server.d.ts +3 -1
- package/dist/src/console/review-console-server.js +203 -50
- package/dist/src/extraction-envelope.d.ts +22 -0
- package/dist/src/extraction-envelope.js +25 -4
- package/dist/src/index.d.ts +9 -8
- package/dist/src/index.js +2 -2
- package/dist/src/inquiry-mapping.d.ts +15 -1
- package/dist/src/inquiry-mapping.js +10 -2
- package/dist/src/mcp/review-mcp.js +219 -279
- package/dist/src/producer-profile.d.ts +41 -2
- package/dist/src/producer-profile.js +29 -2
- package/dist/src/review-session-file.d.ts +64 -0
- package/dist/src/review-session-file.js +320 -0
- package/dist/src/review-workbench/edited-value.d.ts +70 -0
- package/dist/src/review-workbench/edited-value.js +147 -0
- package/dist/src/review-workbench/review-presentation.d.ts +44 -0
- package/dist/src/review-workbench/review-presentation.js +49 -0
- package/dist/src/review-workbench/review-queue-session.js +8 -1
- package/dist/src/review-workbench/review-session-replay.d.ts +35 -1
- package/dist/src/review-workbench/review-session-replay.js +77 -0
- package/dist/src/review-workbench/review-workbench.d.ts +8 -4
- package/dist/src/review-workbench/review-workbench.js +19 -7
- package/dist/src/review-workbench/server-review-session.d.ts +3 -1
- package/dist/src/review-workbench/server-review-session.js +1 -0
- package/dist/src/reviewed-candidate-resolution.js +13 -7
- package/dist/src/schema-mapping.d.ts +23 -0
- package/dist/src/schema-mapping.js +30 -20
- package/dist/src/to-surface.d.ts +30 -6
- package/dist/src/to-surface.js +196 -18
- package/dist/src/types.d.ts +39 -0
- package/package.json +8 -4
|
@@ -1,30 +1,17 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
...current
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
|
458
|
-
//
|
|
459
|
-
|
|
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
|
|
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
|
-
// ----
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
484
|
-
|
|
557
|
+
return server;
|
|
558
|
+
}
|
|
559
|
+
async function runReviewTool(operation) {
|
|
485
560
|
try {
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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
|
|
698
|
-
|
|
699
|
-
|
|
632
|
+
const inputClosed = new Promise((resolveClosed) => {
|
|
633
|
+
process.stdin.once("end", resolveClosed);
|
|
634
|
+
process.stdin.once("close", resolveClosed);
|
|
700
635
|
});
|
|
701
|
-
|
|
702
|
-
|
|
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
|
-
/**
|
|
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.
|
|
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
|
*
|