@kontourai/survey 0.5.2 → 0.7.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 +34 -7
- package/bin/survey-review-mcp.mjs +7 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +1 -0
- package/dist/src/mcp/review-mcp-bin.d.ts +1 -0
- package/dist/src/mcp/review-mcp-bin.js +2 -0
- package/dist/src/mcp/review-mcp.d.ts +1 -0
- package/dist/src/mcp/review-mcp.js +599 -0
- package/dist/src/oversight-metrics.d.ts +146 -0
- package/dist/src/oversight-metrics.js +296 -0
- package/dist/src/review-workbench/review-workbench-css.generated.d.ts +2 -0
- package/dist/src/review-workbench/review-workbench-css.generated.js +1795 -0
- package/dist/src/review-workbench/review-workbench-element.d.ts +45 -0
- package/dist/src/review-workbench/review-workbench-element.js +215 -0
- package/dist/src/review-workbench/review-workbench.css +196 -14
- package/dist/src/review-workbench/review-workbench.js +83 -19
- package/dist/src/review-workbench/review-workbench.standalone.css +196 -14
- package/example-data/mcp-review-session.json +878 -0
- package/package.json +14 -4
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
|
+
import { readFile, writeFile, rename } from "node:fs/promises";
|
|
3
|
+
import { resolve, dirname } from "node:path";
|
|
4
|
+
import { buildReviewSessionEvents, currentReviewItem, deriveQueueRowStatus, nextUnresolvedItemName, replayReviewSessionEvents, reviewSessionSummary, workbenchDecisionDefinitions, } from "../review-workbench/review-workbench.js";
|
|
5
|
+
import { createServerReviewSessionRecord, 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";
|
|
16
|
+
const SESSION_NAME = "mcp-review-session";
|
|
17
|
+
// MCP tool decision strings → ReviewWorkbenchDecision
|
|
18
|
+
const MCP_DECISION_MAP = {
|
|
19
|
+
accept: "accept-proposed",
|
|
20
|
+
hold: "keep-current",
|
|
21
|
+
reject: "reject-proposed",
|
|
22
|
+
};
|
|
23
|
+
async function readSessionFile(path) {
|
|
24
|
+
const raw = await readFile(path, "utf8");
|
|
25
|
+
return JSON.parse(raw);
|
|
26
|
+
}
|
|
27
|
+
async function writeSessionFileAtomic(path, content) {
|
|
28
|
+
const tmp = `${path}.tmp`;
|
|
29
|
+
await writeFile(tmp, JSON.stringify(content, null, 2), "utf8");
|
|
30
|
+
await rename(tmp, path);
|
|
31
|
+
}
|
|
32
|
+
// ---- Queue helpers -------------------------------------------------------
|
|
33
|
+
function queueSummaryText(snapshot, events) {
|
|
34
|
+
const current = events.length > 0 ? replayReviewSessionEvents(snapshot, events) : snapshot;
|
|
35
|
+
const summary = reviewSessionSummary(current);
|
|
36
|
+
const total = current.items.length;
|
|
37
|
+
const resolved = total - summary.unresolved;
|
|
38
|
+
const activeItem = currentReviewItem(current);
|
|
39
|
+
const nextItem = nextUnresolvedItemName(current);
|
|
40
|
+
const rows = current.items.map((item) => {
|
|
41
|
+
const status = deriveQueueRowStatus(item, current);
|
|
42
|
+
const isActive = item.metadata.name === current.activeItemName;
|
|
43
|
+
const marker = isActive ? " [active]" : "";
|
|
44
|
+
return ` ${item.metadata.name} — ${item.spec.target} — ${status}${marker}`;
|
|
45
|
+
});
|
|
46
|
+
return [
|
|
47
|
+
`Review queue: ${resolved}/${total} resolved`,
|
|
48
|
+
`Active item: ${activeItem.metadata.name} (${activeItem.spec.target})`,
|
|
49
|
+
...(nextItem ? [`Next unresolved: ${nextItem}`] : ["All items resolved."]),
|
|
50
|
+
``,
|
|
51
|
+
`Session summary: accepted=${summary.accepted} keptCurrent=${summary.keptCurrent} rejected=${summary.rejected} escalated=${summary.escalated} unresolved=${summary.unresolved}`,
|
|
52
|
+
``,
|
|
53
|
+
`Items:`,
|
|
54
|
+
...rows,
|
|
55
|
+
].join("\n");
|
|
56
|
+
}
|
|
57
|
+
function itemDetailText(item, snapshot, events) {
|
|
58
|
+
const current = events.length > 0 ? replayReviewSessionEvents(snapshot, events) : snapshot;
|
|
59
|
+
const status = deriveQueueRowStatus(item, current);
|
|
60
|
+
const decision = current.decisionsByItemName[item.metadata.name];
|
|
61
|
+
const note = current.notesByItemName[item.metadata.name];
|
|
62
|
+
const currentCandidate = item.spec.candidates.find((c) => c.role === "current");
|
|
63
|
+
const proposedCandidate = item.spec.candidates.find((c) => c.role === "proposed");
|
|
64
|
+
const valueStr = (v) => typeof v === "string" ? v : JSON.stringify(v);
|
|
65
|
+
const confStr = (c) => c !== undefined ? `${Math.round(c * 100)}%` : "unknown";
|
|
66
|
+
const lines = [
|
|
67
|
+
`Item: ${item.metadata.name}`,
|
|
68
|
+
`Target: ${item.spec.target}`,
|
|
69
|
+
`Status: ${status}`,
|
|
70
|
+
`Candidate set status: ${item.spec.candidateSetStatus ?? "unknown"}`,
|
|
71
|
+
...(decision ? [`Decision: ${decision}`] : []),
|
|
72
|
+
...(note ? [`Note: ${note}`] : []),
|
|
73
|
+
``,
|
|
74
|
+
`Current value: ${valueStr(currentCandidate?.value ?? "(none)")}`,
|
|
75
|
+
` confidence: ${confStr(currentCandidate?.extraction?.confidence ?? currentCandidate?.confidence)}`,
|
|
76
|
+
` source: ${currentCandidate?.source?.sourceRef ?? "none"}`,
|
|
77
|
+
...(currentCandidate?.locator?.excerpt ? [` excerpt: ${currentCandidate.locator.excerpt}`] : []),
|
|
78
|
+
``,
|
|
79
|
+
`Proposed value: ${valueStr(proposedCandidate?.value ?? "(none)")}`,
|
|
80
|
+
` confidence: ${confStr(proposedCandidate?.extraction?.confidence ?? proposedCandidate?.confidence)}`,
|
|
81
|
+
` source: ${proposedCandidate?.source?.sourceRef ?? "none"}`,
|
|
82
|
+
...(proposedCandidate?.locator?.excerpt ? [` excerpt: ${proposedCandidate.locator.excerpt}`] : []),
|
|
83
|
+
];
|
|
84
|
+
if (item.spec.rationale) {
|
|
85
|
+
lines.push(``, `Rationale: ${item.spec.rationale}`);
|
|
86
|
+
}
|
|
87
|
+
return lines.join("\n");
|
|
88
|
+
}
|
|
89
|
+
// ---- UI card -------------------------------------------------------------
|
|
90
|
+
function escapeJsonInHtml(value) {
|
|
91
|
+
return JSON.stringify(value)
|
|
92
|
+
.replace(/&/g, "\\u0026")
|
|
93
|
+
.replace(/</g, "\\u003c")
|
|
94
|
+
.replace(/>/g, "\\u003e");
|
|
95
|
+
}
|
|
96
|
+
function escapeHtml(text) {
|
|
97
|
+
return text
|
|
98
|
+
.replace(/&/g, "&")
|
|
99
|
+
.replace(/</g, "<")
|
|
100
|
+
.replace(/>/g, ">")
|
|
101
|
+
.replace(/"/g, """);
|
|
102
|
+
}
|
|
103
|
+
function buildReviewCardHtml(item, snapshot, events) {
|
|
104
|
+
const current = events.length > 0 ? replayReviewSessionEvents(snapshot, events) : snapshot;
|
|
105
|
+
const summary = reviewSessionSummary(current);
|
|
106
|
+
const total = current.items.length;
|
|
107
|
+
const resolved = total - summary.unresolved;
|
|
108
|
+
const currentCandidate = item.spec.candidates.find((c) => c.role === "current");
|
|
109
|
+
const proposedCandidate = item.spec.candidates.find((c) => c.role === "proposed");
|
|
110
|
+
const decision = current.decisionsByItemName[item.metadata.name];
|
|
111
|
+
const status = deriveQueueRowStatus(item, current);
|
|
112
|
+
const valueStr = (v) => typeof v === "string" ? v : JSON.stringify(v, null, 2);
|
|
113
|
+
const confStr = (c) => c !== undefined ? `${Math.round(c * 100)}%` : "—";
|
|
114
|
+
const currentValue = valueStr(currentCandidate?.value ?? "—");
|
|
115
|
+
const proposedValue = valueStr(proposedCandidate?.value ?? "—");
|
|
116
|
+
const currentConf = confStr(currentCandidate?.extraction?.confidence ?? currentCandidate?.confidence);
|
|
117
|
+
const proposedConf = confStr(proposedCandidate?.extraction?.confidence ?? proposedCandidate?.confidence);
|
|
118
|
+
const currentSource = currentCandidate?.source?.sourceRef ?? "—";
|
|
119
|
+
const proposedSource = proposedCandidate?.source?.sourceRef ?? "—";
|
|
120
|
+
const currentExcerpt = currentCandidate?.locator?.excerpt ?? "";
|
|
121
|
+
const proposedExcerpt = proposedCandidate?.locator?.excerpt ?? "";
|
|
122
|
+
const itemNameJson = escapeJsonInHtml(item.metadata.name);
|
|
123
|
+
const decisionBadge = decision
|
|
124
|
+
? `<span class="badge badge-${decision === "accept-proposed" ? "accept" : decision === "reject-proposed" ? "reject" : "hold"}">${escapeHtml(workbenchDecisionDefinitions[decision].label)}</span>`
|
|
125
|
+
: `<span class="badge badge-pending">${escapeHtml(status)}</span>`;
|
|
126
|
+
return `<!doctype html>
|
|
127
|
+
<html lang="en">
|
|
128
|
+
<head>
|
|
129
|
+
<meta charset="utf-8">
|
|
130
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
131
|
+
<title>Review: ${escapeHtml(item.spec.target)}</title>
|
|
132
|
+
<style>
|
|
133
|
+
:root {
|
|
134
|
+
color-scheme: dark;
|
|
135
|
+
--k-bg: #0a0e13;
|
|
136
|
+
--k-panel: #111824;
|
|
137
|
+
--k-panel-raised: #16202d;
|
|
138
|
+
--k-line: rgba(150,180,210,0.12);
|
|
139
|
+
--k-line-strong: rgba(150,180,210,0.22);
|
|
140
|
+
--k-text: #eef3f8;
|
|
141
|
+
--k-text-muted: #aebccb;
|
|
142
|
+
--k-text-faint: #72869b;
|
|
143
|
+
--k-brand: #5ce0c6;
|
|
144
|
+
--k-positive: #34d399;
|
|
145
|
+
--k-caution: #f3b14b;
|
|
146
|
+
--k-negative: #ff6f6f;
|
|
147
|
+
--k-active: #7aa2ff;
|
|
148
|
+
--k-radius-sm: 9px;
|
|
149
|
+
--k-radius-md: 14px;
|
|
150
|
+
--k-font-ui: "Hanken Grotesk",ui-sans-serif,system-ui,-apple-system,sans-serif;
|
|
151
|
+
--k-font-mono: "IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
|
|
152
|
+
}
|
|
153
|
+
@media (prefers-color-scheme: light) {
|
|
154
|
+
:root {
|
|
155
|
+
color-scheme: light;
|
|
156
|
+
--k-bg: #f5f4ef;
|
|
157
|
+
--k-panel: #ffffff;
|
|
158
|
+
--k-panel-raised: #fbfaf7;
|
|
159
|
+
--k-line: rgba(36,40,46,0.12);
|
|
160
|
+
--k-line-strong: rgba(36,40,46,0.20);
|
|
161
|
+
--k-text: #202124;
|
|
162
|
+
--k-text-muted: #5b626b;
|
|
163
|
+
--k-text-faint: #707782;
|
|
164
|
+
--k-positive: #168257;
|
|
165
|
+
--k-caution: #8a5a00;
|
|
166
|
+
--k-negative: #c83b3b;
|
|
167
|
+
--k-active: #3f6fd6;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
*,*::before,*::after{box-sizing:border-box}
|
|
171
|
+
body{margin:0;font-family:var(--k-font-ui);font-size:13px;background:var(--k-bg);color:var(--k-text);padding:14px}
|
|
172
|
+
h1{font-size:15px;font-weight:700;margin:0 0 4px}
|
|
173
|
+
.eyebrow{font-family:var(--k-font-mono);font-size:10px;color:var(--k-brand);text-transform:uppercase;letter-spacing:.06em;margin:0 0 6px}
|
|
174
|
+
.meta{font-size:11px;color:var(--k-text-muted);margin:0 0 12px;display:flex;gap:10px;flex-wrap:wrap}
|
|
175
|
+
.progress{font-family:var(--k-font-mono);font-size:10px;color:var(--k-text-faint)}
|
|
176
|
+
.card-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:12px}
|
|
177
|
+
.card{background:var(--k-panel);border:1px solid var(--k-line);border-radius:var(--k-radius-sm);padding:10px}
|
|
178
|
+
.card-label{font-family:var(--k-font-mono);font-size:10px;color:var(--k-text-faint);text-transform:uppercase;letter-spacing:.06em;margin-bottom:4px}
|
|
179
|
+
.card.is-proposed .card-label{color:var(--k-active)}
|
|
180
|
+
.value{font-size:14px;font-weight:700;margin:0 0 6px;word-break:break-word}
|
|
181
|
+
.value pre{font-family:var(--k-font-mono);font-size:11px;margin:0;white-space:pre-wrap;word-break:break-word}
|
|
182
|
+
.conf{font-size:11px;color:var(--k-text-muted)}
|
|
183
|
+
.source-ref{font-size:10px;color:var(--k-brand);word-break:break-all;margin-top:4px}
|
|
184
|
+
.excerpt{font-size:11px;color:var(--k-text-faint);font-style:italic;margin-top:3px}
|
|
185
|
+
.divider{height:1px;background:var(--k-line);margin:12px 0}
|
|
186
|
+
.badge{display:inline-block;font-family:var(--k-font-mono);font-size:10px;padding:2px 7px;border-radius:4px;font-weight:600;letter-spacing:.04em}
|
|
187
|
+
.badge-pending{background:color-mix(in srgb,var(--k-active) 14%,transparent);color:var(--k-active)}
|
|
188
|
+
.badge-accept{background:color-mix(in srgb,var(--k-positive) 14%,transparent);color:var(--k-positive)}
|
|
189
|
+
.badge-hold{background:color-mix(in srgb,var(--k-caution) 14%,transparent);color:var(--k-caution)}
|
|
190
|
+
.badge-reject{background:color-mix(in srgb,var(--k-negative) 14%,transparent);color:var(--k-negative)}
|
|
191
|
+
.note-label{font-size:11px;color:var(--k-text-muted);margin-bottom:4px}
|
|
192
|
+
.note-input{width:100%;background:var(--k-panel-raised);border:1px solid var(--k-line-strong);border-radius:var(--k-radius-sm);color:var(--k-text);font:inherit;font-size:12px;padding:7px 10px;resize:vertical;min-height:52px}
|
|
193
|
+
.note-input:focus{outline:2px solid var(--k-brand);outline-offset:1px;border-color:transparent}
|
|
194
|
+
.btn-row{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-top:10px}
|
|
195
|
+
.btn{padding:9px 4px;border:1px solid var(--k-line-strong);border-radius:var(--k-radius-sm);background:var(--k-panel-raised);color:var(--k-text-muted);font:inherit;font-size:12px;font-weight:600;cursor:pointer;transition:background .12s,color .12s,border-color .12s}
|
|
196
|
+
.btn:hover{background:var(--k-panel);border-color:var(--k-brand);color:var(--k-text)}
|
|
197
|
+
.btn-accept:hover,.btn-accept.active{background:color-mix(in srgb,var(--k-positive) 16%,transparent);border-color:var(--k-positive);color:var(--k-positive)}
|
|
198
|
+
.btn-hold:hover,.btn-hold.active{background:color-mix(in srgb,var(--k-caution) 16%,transparent);border-color:var(--k-caution);color:var(--k-caution)}
|
|
199
|
+
.btn-reject:hover,.btn-reject.active{background:color-mix(in srgb,var(--k-negative) 16%,transparent);border-color:var(--k-negative);color:var(--k-negative)}
|
|
200
|
+
.feedback{font-size:11px;color:var(--k-text-faint);margin-top:8px;min-height:16px}
|
|
201
|
+
</style>
|
|
202
|
+
</head>
|
|
203
|
+
<body>
|
|
204
|
+
<p class="eyebrow">Survey Review</p>
|
|
205
|
+
<h1>${escapeHtml(item.spec.target)}</h1>
|
|
206
|
+
<div class="meta">
|
|
207
|
+
<span>${escapeHtml(item.metadata.name)}</span>
|
|
208
|
+
${decisionBadge}
|
|
209
|
+
<span class="progress">${resolved}/${total} resolved</span>
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
<div class="card-grid">
|
|
213
|
+
<div class="card">
|
|
214
|
+
<div class="card-label">Current</div>
|
|
215
|
+
<div class="value">${currentValue.includes("\n") ? `<pre>${escapeHtml(currentValue)}</pre>` : escapeHtml(currentValue)}</div>
|
|
216
|
+
<div class="conf">confidence ${currentConf}</div>
|
|
217
|
+
<div class="source-ref">${escapeHtml(currentSource)}</div>
|
|
218
|
+
${currentExcerpt ? `<div class="excerpt">${escapeHtml(currentExcerpt)}</div>` : ""}
|
|
219
|
+
</div>
|
|
220
|
+
<div class="card is-proposed">
|
|
221
|
+
<div class="card-label">Proposed</div>
|
|
222
|
+
<div class="value">${proposedValue.includes("\n") ? `<pre>${escapeHtml(proposedValue)}</pre>` : escapeHtml(proposedValue)}</div>
|
|
223
|
+
<div class="conf">confidence ${proposedConf}</div>
|
|
224
|
+
<div class="source-ref">${escapeHtml(proposedSource)}</div>
|
|
225
|
+
${proposedExcerpt ? `<div class="excerpt">${escapeHtml(proposedExcerpt)}</div>` : ""}
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
<div class="divider"></div>
|
|
230
|
+
|
|
231
|
+
<div class="note-label">Reviewer note (optional)</div>
|
|
232
|
+
<textarea class="note-input" id="note" placeholder="Add a rationale for this decision...">${escapeHtml(current.notesByItemName[item.metadata.name] ?? "")}</textarea>
|
|
233
|
+
|
|
234
|
+
<div class="btn-row">
|
|
235
|
+
<button class="btn btn-accept${decision === "accept-proposed" ? " active" : ""}" id="btn-accept">Accept proposed</button>
|
|
236
|
+
<button class="btn btn-hold${decision === "keep-current" ? " active" : ""}" id="btn-hold">Hold / Keep current</button>
|
|
237
|
+
<button class="btn btn-reject${decision === "reject-proposed" ? " active" : ""}" id="btn-reject">Reject proposed</button>
|
|
238
|
+
</div>
|
|
239
|
+
<div class="feedback" id="feedback"></div>
|
|
240
|
+
|
|
241
|
+
<script>
|
|
242
|
+
(function () {
|
|
243
|
+
var itemName = ${itemNameJson};
|
|
244
|
+
var msgId = 1;
|
|
245
|
+
|
|
246
|
+
function postDecision(decision) {
|
|
247
|
+
var note = document.getElementById('note').value;
|
|
248
|
+
window.parent.postMessage({
|
|
249
|
+
jsonrpc: "2.0",
|
|
250
|
+
id: msgId++,
|
|
251
|
+
method: "tools/call",
|
|
252
|
+
params: {
|
|
253
|
+
name: "survey_review_decide",
|
|
254
|
+
arguments: { itemName: itemName, decision: decision, note: note || undefined }
|
|
255
|
+
}
|
|
256
|
+
}, "*");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
document.getElementById('btn-accept').addEventListener('click', function () { postDecision('accept'); document.getElementById('feedback').textContent = 'Submitting accept…'; });
|
|
260
|
+
document.getElementById('btn-hold').addEventListener('click', function () { postDecision('hold'); document.getElementById('feedback').textContent = 'Submitting hold…'; });
|
|
261
|
+
document.getElementById('btn-reject').addEventListener('click', function () { postDecision('reject'); document.getElementById('feedback').textContent = 'Submitting reject…'; });
|
|
262
|
+
|
|
263
|
+
window.addEventListener('message', function (evt) {
|
|
264
|
+
var data = evt.data;
|
|
265
|
+
if (data && data.jsonrpc === '2.0' && data.result) {
|
|
266
|
+
if (data.result.isError) {
|
|
267
|
+
document.getElementById('feedback').textContent = 'Error: ' + (data.result.content && data.result.content[0] && data.result.content[0].text || 'unknown');
|
|
268
|
+
} else {
|
|
269
|
+
document.getElementById('feedback').textContent = 'Decision recorded.';
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
}());
|
|
274
|
+
</script>
|
|
275
|
+
</body>
|
|
276
|
+
</html>`;
|
|
277
|
+
}
|
|
278
|
+
// ---- Tool implementations ------------------------------------------------
|
|
279
|
+
async function toolQueue(options) {
|
|
280
|
+
const file = await readSessionFile(options.sessionPath);
|
|
281
|
+
const { snapshot, events } = file;
|
|
282
|
+
const text = queueSummaryText(snapshot, events);
|
|
283
|
+
const queueData = {
|
|
284
|
+
items: snapshot.items.map((item) => {
|
|
285
|
+
const current = events.length > 0 ? replayReviewSessionEvents(snapshot, events) : snapshot;
|
|
286
|
+
return {
|
|
287
|
+
name: item.metadata.name,
|
|
288
|
+
target: item.spec.target,
|
|
289
|
+
status: deriveQueueRowStatus(item, current),
|
|
290
|
+
decision: current.decisionsByItemName[item.metadata.name],
|
|
291
|
+
candidateSetStatus: item.spec.candidateSetStatus,
|
|
292
|
+
};
|
|
293
|
+
}),
|
|
294
|
+
summary: reviewSessionSummary(events.length > 0 ? replayReviewSessionEvents(snapshot, events) : snapshot),
|
|
295
|
+
activeItemName: (events.length > 0 ? replayReviewSessionEvents(snapshot, events) : snapshot).activeItemName,
|
|
296
|
+
};
|
|
297
|
+
const content = [
|
|
298
|
+
{ type: "text", text: `${text}\n\n${JSON.stringify(queueData, null, 2)}` },
|
|
299
|
+
];
|
|
300
|
+
if (!options.noUi) {
|
|
301
|
+
const activeItem = currentReviewItem(events.length > 0 ? replayReviewSessionEvents(snapshot, events) : snapshot);
|
|
302
|
+
content.push(buildUiResource(activeItem, snapshot, events, "queue"));
|
|
303
|
+
}
|
|
304
|
+
return content;
|
|
305
|
+
}
|
|
306
|
+
async function toolItem(itemName, options) {
|
|
307
|
+
const file = await readSessionFile(options.sessionPath);
|
|
308
|
+
const { snapshot, events } = file;
|
|
309
|
+
const current = events.length > 0 ? replayReviewSessionEvents(snapshot, events) : snapshot;
|
|
310
|
+
const item = current.items.find((i) => i.metadata.name === itemName);
|
|
311
|
+
if (!item) {
|
|
312
|
+
throw new DomainError(`Unknown review item: ${itemName}`);
|
|
313
|
+
}
|
|
314
|
+
const text = itemDetailText(item, snapshot, events);
|
|
315
|
+
const itemData = {
|
|
316
|
+
name: item.metadata.name,
|
|
317
|
+
target: item.spec.target,
|
|
318
|
+
status: deriveQueueRowStatus(item, current),
|
|
319
|
+
decision: current.decisionsByItemName[item.metadata.name],
|
|
320
|
+
note: current.notesByItemName[item.metadata.name],
|
|
321
|
+
candidateSetStatus: item.spec.candidateSetStatus,
|
|
322
|
+
candidates: item.spec.candidates.map((c) => ({
|
|
323
|
+
id: c.id,
|
|
324
|
+
role: c.role,
|
|
325
|
+
value: c.value,
|
|
326
|
+
confidence: c.extraction?.confidence ?? c.confidence,
|
|
327
|
+
sourceRef: c.source?.sourceRef,
|
|
328
|
+
excerpt: c.locator?.excerpt,
|
|
329
|
+
})),
|
|
330
|
+
};
|
|
331
|
+
const content = [
|
|
332
|
+
{ type: "text", text: `${text}\n\n${JSON.stringify(itemData, null, 2)}` },
|
|
333
|
+
];
|
|
334
|
+
if (!options.noUi) {
|
|
335
|
+
content.push(buildUiResource(item, snapshot, events, itemName));
|
|
336
|
+
}
|
|
337
|
+
return content;
|
|
338
|
+
}
|
|
339
|
+
async function toolDecide(itemName, mcpDecision, note, options) {
|
|
340
|
+
const wbDecision = MCP_DECISION_MAP[mcpDecision];
|
|
341
|
+
if (!wbDecision) {
|
|
342
|
+
throw new DomainError(`Invalid decision: ${mcpDecision}. Must be accept, hold, or reject.`);
|
|
343
|
+
}
|
|
344
|
+
const file = await readSessionFile(options.sessionPath);
|
|
345
|
+
const { snapshot, events } = file;
|
|
346
|
+
const current = events.length > 0 ? replayReviewSessionEvents(snapshot, events) : snapshot;
|
|
347
|
+
const item = current.items.find((i) => i.metadata.name === itemName);
|
|
348
|
+
if (!item) {
|
|
349
|
+
throw new DomainError(`Unknown review item: ${itemName}`);
|
|
350
|
+
}
|
|
351
|
+
const existingDecision = current.decisionsByItemName[item.metadata.name];
|
|
352
|
+
if (existingDecision) {
|
|
353
|
+
throw new DomainError(`Item ${itemName} already has a decision: ${existingDecision}. Use a new session to re-decide.`);
|
|
354
|
+
}
|
|
355
|
+
// Build the updated session state with the decision
|
|
356
|
+
const sessionWithDecision = {
|
|
357
|
+
...current,
|
|
358
|
+
decisionsByItemName: {
|
|
359
|
+
...current.decisionsByItemName,
|
|
360
|
+
[itemName]: wbDecision,
|
|
361
|
+
},
|
|
362
|
+
...(note !== undefined
|
|
363
|
+
? {
|
|
364
|
+
notesByItemName: {
|
|
365
|
+
...current.notesByItemName,
|
|
366
|
+
[itemName]: note,
|
|
367
|
+
},
|
|
368
|
+
}
|
|
369
|
+
: {}),
|
|
370
|
+
};
|
|
371
|
+
// Use the server session APIs for apply-path validation
|
|
372
|
+
const record = createServerReviewSessionRecord({
|
|
373
|
+
sessionName: SESSION_NAME,
|
|
374
|
+
snapshot,
|
|
375
|
+
eventCount: events.length,
|
|
376
|
+
updatedAt: new Date(),
|
|
377
|
+
});
|
|
378
|
+
const newEvents = buildReviewSessionEvents(sessionWithDecision, SESSION_NAME);
|
|
379
|
+
const applyResult = deriveServerReviewSessionApplyResult({
|
|
380
|
+
record,
|
|
381
|
+
events: newEvents,
|
|
382
|
+
requiredResolvedItems: "none",
|
|
383
|
+
});
|
|
384
|
+
if (!applyResult.ok) {
|
|
385
|
+
throw new DomainError(`Decision validation failed: ${applyResult.issues.map((issue) => "message" in issue ? issue.message : String(issue)).join("; ")}`);
|
|
386
|
+
}
|
|
387
|
+
// Persist atomically
|
|
388
|
+
const updatedFile = {
|
|
389
|
+
session: file.session,
|
|
390
|
+
snapshot,
|
|
391
|
+
events: newEvents,
|
|
392
|
+
};
|
|
393
|
+
await writeSessionFileAtomic(options.sessionPath, updatedFile);
|
|
394
|
+
// Summarize the result
|
|
395
|
+
const updatedItem = sessionWithDecision.items.find((i) => i.metadata.name === itemName);
|
|
396
|
+
const itemText = updatedItem ? itemDetailText(updatedItem, snapshot, newEvents) : `Item: ${itemName}`;
|
|
397
|
+
const remainingText = queueSummaryText(snapshot, newEvents);
|
|
398
|
+
const definition = workbenchDecisionDefinitions[wbDecision];
|
|
399
|
+
const text = [
|
|
400
|
+
`Decision recorded: ${definition.label}`,
|
|
401
|
+
`Effect: ${definition.effect}`,
|
|
402
|
+
"",
|
|
403
|
+
itemText,
|
|
404
|
+
"",
|
|
405
|
+
"--- Updated queue ---",
|
|
406
|
+
remainingText,
|
|
407
|
+
].join("\n");
|
|
408
|
+
return [{ type: "text", text }];
|
|
409
|
+
}
|
|
410
|
+
function buildUiResource(item, snapshot, events, instance) {
|
|
411
|
+
return {
|
|
412
|
+
type: "resource",
|
|
413
|
+
resource: {
|
|
414
|
+
uri: `ui://survey/review-card/${encodeURIComponent(instance)}`,
|
|
415
|
+
mimeType: "text/html;profile=mcp-app",
|
|
416
|
+
text: buildReviewCardHtml(item, snapshot, events),
|
|
417
|
+
_meta: {
|
|
418
|
+
"mcpui.dev/ui-preferred-frame-size": ["420px", "560px"],
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
// ---- Domain error (maps to isError:true, not a JSON-RPC error) -----------
|
|
424
|
+
class DomainError extends Error {
|
|
425
|
+
isDomainError = true;
|
|
426
|
+
}
|
|
427
|
+
// ---- JSON-RPC dispatch ---------------------------------------------------
|
|
428
|
+
async function handleLine(line, options, serverVersion) {
|
|
429
|
+
const trimmed = line.trim();
|
|
430
|
+
if (trimmed === "")
|
|
431
|
+
return;
|
|
432
|
+
let message;
|
|
433
|
+
try {
|
|
434
|
+
message = JSON.parse(trimmed);
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
const { id, method, params } = message;
|
|
441
|
+
const isNotification = id === undefined;
|
|
442
|
+
try {
|
|
443
|
+
if (method === "initialize") {
|
|
444
|
+
send({
|
|
445
|
+
jsonrpc: "2.0",
|
|
446
|
+
id,
|
|
447
|
+
result: {
|
|
448
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
449
|
+
capabilities: { tools: { listChanged: false } },
|
|
450
|
+
serverInfo: { name: "survey-review-mcp", title: "Survey Review MCP", version: serverVersion },
|
|
451
|
+
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.",
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
else if (method === "ping") {
|
|
456
|
+
send({ jsonrpc: "2.0", id, result: {} });
|
|
457
|
+
}
|
|
458
|
+
else if (method === "tools/list") {
|
|
459
|
+
send({
|
|
460
|
+
jsonrpc: "2.0",
|
|
461
|
+
id,
|
|
462
|
+
result: {
|
|
463
|
+
tools: [
|
|
464
|
+
{
|
|
465
|
+
name: "survey_review_queue",
|
|
466
|
+
title: "Review queue",
|
|
467
|
+
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.",
|
|
468
|
+
inputSchema: { type: "object", properties: {} },
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
name: "survey_review_item",
|
|
472
|
+
title: "Review item detail",
|
|
473
|
+
description: "Return full detail for a single review item: current vs proposed values, confidence, source references, excerpts, and the current decision (if any).",
|
|
474
|
+
inputSchema: {
|
|
475
|
+
type: "object",
|
|
476
|
+
properties: {
|
|
477
|
+
itemName: { type: "string", description: "The ReviewItem name to inspect." },
|
|
478
|
+
},
|
|
479
|
+
required: ["itemName"],
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
{
|
|
483
|
+
name: "survey_review_decide",
|
|
484
|
+
title: "Record a review decision",
|
|
485
|
+
description: "Apply a decision to a review item and persist it to the session file. Decision must be accept (accept-proposed), hold (keep-current), or reject (reject-proposed). Domain failures return isError:true.",
|
|
486
|
+
inputSchema: {
|
|
487
|
+
type: "object",
|
|
488
|
+
properties: {
|
|
489
|
+
itemName: { type: "string", description: "The ReviewItem name to decide." },
|
|
490
|
+
decision: {
|
|
491
|
+
type: "string",
|
|
492
|
+
enum: ["accept", "hold", "reject"],
|
|
493
|
+
description: "accept = accept-proposed, hold = keep-current, reject = reject-proposed.",
|
|
494
|
+
},
|
|
495
|
+
note: { type: "string", description: "Optional reviewer note / rationale." },
|
|
496
|
+
},
|
|
497
|
+
required: ["itemName", "decision"],
|
|
498
|
+
},
|
|
499
|
+
},
|
|
500
|
+
],
|
|
501
|
+
},
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
else if (method === "tools/call") {
|
|
505
|
+
const name = typeof params?.name === "string" ? params.name : "";
|
|
506
|
+
const toolArgs = (params?.arguments ?? {});
|
|
507
|
+
try {
|
|
508
|
+
let content;
|
|
509
|
+
if (name === "survey_review_queue") {
|
|
510
|
+
content = await toolQueue(options);
|
|
511
|
+
}
|
|
512
|
+
else if (name === "survey_review_item") {
|
|
513
|
+
const itemName = typeof toolArgs.itemName === "string" ? toolArgs.itemName : "";
|
|
514
|
+
if (!itemName) {
|
|
515
|
+
throw new DomainError("survey_review_item requires itemName");
|
|
516
|
+
}
|
|
517
|
+
content = await toolItem(itemName, options);
|
|
518
|
+
}
|
|
519
|
+
else if (name === "survey_review_decide") {
|
|
520
|
+
const itemName = typeof toolArgs.itemName === "string" ? toolArgs.itemName : "";
|
|
521
|
+
const decision = typeof toolArgs.decision === "string" ? toolArgs.decision : "";
|
|
522
|
+
const note = typeof toolArgs.note === "string" ? toolArgs.note : undefined;
|
|
523
|
+
if (!itemName)
|
|
524
|
+
throw new DomainError("survey_review_decide requires itemName");
|
|
525
|
+
if (!decision)
|
|
526
|
+
throw new DomainError("survey_review_decide requires decision");
|
|
527
|
+
content = await toolDecide(itemName, decision, note, options);
|
|
528
|
+
}
|
|
529
|
+
else {
|
|
530
|
+
send({ jsonrpc: "2.0", id, error: { code: -32602, message: `Unknown tool: ${name || "(missing name)"}` } });
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
send({ jsonrpc: "2.0", id, result: { content, isError: false } });
|
|
534
|
+
}
|
|
535
|
+
catch (error) {
|
|
536
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
537
|
+
send({ jsonrpc: "2.0", id, result: { content: [{ type: "text", text }], isError: true } });
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
else if (isNotification) {
|
|
541
|
+
// Lifecycle notifications such as notifications/initialized need no reply.
|
|
542
|
+
}
|
|
543
|
+
else {
|
|
544
|
+
send({ jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${method ?? "(none)"}` } });
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
catch (error) {
|
|
548
|
+
if (!isNotification) {
|
|
549
|
+
const messageText = error instanceof Error ? error.message : String(error);
|
|
550
|
+
send({ jsonrpc: "2.0", id, error: { code: -32603, message: messageText } });
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
// ---- Entry point ---------------------------------------------------------
|
|
555
|
+
function parseMcpArgs(args) {
|
|
556
|
+
const defaultSession = resolve(dirname(new URL(import.meta.url).pathname), "../../../example-data/mcp-review-session.json");
|
|
557
|
+
let sessionPath = defaultSession;
|
|
558
|
+
let noUi = false;
|
|
559
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
560
|
+
const arg = args[index];
|
|
561
|
+
if (arg === "--session") {
|
|
562
|
+
const next = args[++index];
|
|
563
|
+
if (!next)
|
|
564
|
+
throw new Error("--session requires a path argument");
|
|
565
|
+
sessionPath = resolve(next);
|
|
566
|
+
}
|
|
567
|
+
else if (arg === "--no-ui") {
|
|
568
|
+
noUi = true;
|
|
569
|
+
}
|
|
570
|
+
else {
|
|
571
|
+
throw new Error(`Unknown survey-review-mcp argument: ${arg}`);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
return { sessionPath, noUi };
|
|
575
|
+
}
|
|
576
|
+
async function readPackageVersion() {
|
|
577
|
+
try {
|
|
578
|
+
const raw = await readFile(new URL("../../../package.json", import.meta.url), "utf8");
|
|
579
|
+
const parsed = JSON.parse(raw);
|
|
580
|
+
return parsed.version ?? "0.0.0";
|
|
581
|
+
}
|
|
582
|
+
catch {
|
|
583
|
+
return "0.0.0";
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
function send(message) {
|
|
587
|
+
process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
588
|
+
}
|
|
589
|
+
export async function runReviewMcp(args) {
|
|
590
|
+
const options = parseMcpArgs(args);
|
|
591
|
+
const serverVersion = await readPackageVersion();
|
|
592
|
+
const rl = createInterface({ input: process.stdin, terminal: false });
|
|
593
|
+
rl.on("line", (line) => {
|
|
594
|
+
void handleLine(line, options, serverVersion);
|
|
595
|
+
});
|
|
596
|
+
await new Promise((resolveClosed) => {
|
|
597
|
+
rl.on("close", resolveClosed);
|
|
598
|
+
});
|
|
599
|
+
}
|