@kontourai/survey 2.5.0 → 3.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
CHANGED
|
@@ -203,7 +203,8 @@ host app should read as itself.
|
|
|
203
203
|
|
|
204
204
|
## Review MCP
|
|
205
205
|
|
|
206
|
-
Drive review-queue decisions from
|
|
206
|
+
Drive review-queue decisions from any MCP host. The official server runtime
|
|
207
|
+
automatically supports MCP 2026-07-28 discovery and existing legacy clients:
|
|
207
208
|
|
|
208
209
|
```sh
|
|
209
210
|
npx survey-review-mcp --session path/to/session.json
|
|
@@ -35,12 +35,12 @@ export declare const publicDirectoryReviewItemExample: {
|
|
|
35
35
|
excerpt: string;
|
|
36
36
|
};
|
|
37
37
|
extraction: {
|
|
38
|
+
model?: undefined;
|
|
38
39
|
extractionId: string;
|
|
39
40
|
target: string;
|
|
40
41
|
confidence: number;
|
|
41
42
|
extractor: string;
|
|
42
43
|
extractedAt: string;
|
|
43
|
-
model?: undefined;
|
|
44
44
|
};
|
|
45
45
|
claimTarget: {
|
|
46
46
|
claimId: string;
|
|
@@ -61,13 +61,13 @@ export declare const publicDirectoryReviewItemExample: {
|
|
|
61
61
|
claimId: string;
|
|
62
62
|
};
|
|
63
63
|
producer: {
|
|
64
|
+
proposalId?: undefined;
|
|
65
|
+
oldValue?: undefined;
|
|
64
66
|
sourceAuthority: {
|
|
65
67
|
authorityClass: string;
|
|
66
68
|
declaredBy: string;
|
|
67
69
|
scope: string;
|
|
68
70
|
};
|
|
69
|
-
proposalId?: undefined;
|
|
70
|
-
oldValue?: undefined;
|
|
71
71
|
};
|
|
72
72
|
} | {
|
|
73
73
|
id: string;
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* has full provenance (excerpt, span, extractor name, confidence) and is
|
|
17
17
|
* run through the Inquiry pipeline rather than treated as authoritative.
|
|
18
18
|
*/
|
|
19
|
-
import type { DerivationRule, InquiryRecord, TrustBundle } from "@kontourai/surface";
|
|
19
|
+
import type { DerivationRule, InquiryRecord, TrustBundle, TrustStatus } from "@kontourai/surface";
|
|
20
20
|
import type { CanonicalClaimTarget } from "@kontourai/surface";
|
|
21
21
|
import type { Candidate, CandidateSet, Extraction, RawSource, SurveyInput } from "./types.js";
|
|
22
22
|
import type { InquiryMapping } from "./inquiry-mapping.js";
|
|
@@ -52,17 +52,63 @@ export interface UtteranceClaimExtractor {
|
|
|
52
52
|
extract(utterance: string): ExtractedStatement[] | Promise<ExtractedStatement[]>;
|
|
53
53
|
}
|
|
54
54
|
/**
|
|
55
|
-
* Badge values for each extracted statement
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
55
|
+
* Badge values for each extracted statement.
|
|
56
|
+
*
|
|
57
|
+
* A badge grades THE STATEMENT, not the target. It is a function of two
|
|
58
|
+
* things: the status of the bundle's answer for the statement's canonical
|
|
59
|
+
* target, and how the statement's own asserted value compares to that
|
|
60
|
+
* answer's value.
|
|
61
|
+
*
|
|
62
|
+
* The status half of the vocabulary is Surface's `TrustStatus` verbatim —
|
|
63
|
+
* Survey does not mint parallel status terms for concepts Surface (and the
|
|
64
|
+
* Hachure core record shapes it implements) already name. Only two badge
|
|
65
|
+
* values are Survey-side additions, because they describe the
|
|
66
|
+
* statement-vs-answer relation rather than a claim's standing:
|
|
67
|
+
*
|
|
68
|
+
* - "contradicted": the bundle has an answer that would otherwise read as
|
|
69
|
+
* support ("verified", "assumed", "stale") and the statement asserts a
|
|
70
|
+
* DIFFERENT value. Mirrors the Hachure `contradiction` transparency-gap
|
|
71
|
+
* type (merge.md §7b): a value conflict is surfaced, never silently
|
|
72
|
+
* resolved in favour of one side.
|
|
73
|
+
* - "unsupported": the inquiry did not resolve to an answer at all — no
|
|
74
|
+
* mapping and no registered claim for the target. This value means "there
|
|
75
|
+
* is nothing here to compare against", and nothing else: a claim that is
|
|
76
|
+
* registered but merely awaiting review badges "proposed", and one with no
|
|
77
|
+
* evidence badges "unknown".
|
|
78
|
+
*
|
|
79
|
+
* Every other badge is the answer's `TrustStatus` passed through unchanged,
|
|
80
|
+
* so there is no fall-through path that can report a real status under a
|
|
81
|
+
* label meaning "no such claim".
|
|
82
|
+
*/
|
|
83
|
+
export type StatementBadge = TrustStatus | "contradicted" | "unsupported";
|
|
84
|
+
/**
|
|
85
|
+
* How a statement's asserted value compares to the bundle's answer value.
|
|
86
|
+
*
|
|
87
|
+
* - "agrees": both are comparable scalars and equivalent under
|
|
88
|
+
* `assertionComparisonKey`.
|
|
89
|
+
* - "contradicts": both are comparable scalars and NOT equivalent.
|
|
90
|
+
* - "not-compared": no comparison was possible — the inquiry produced no
|
|
91
|
+
* answer, or the extractor parsed no value out of the statement
|
|
92
|
+
* (`ExtractedStatement.value` absent), or one side is not a scalar.
|
|
93
|
+
* Never treated as agreement.
|
|
94
|
+
*/
|
|
95
|
+
export type StatementValueComparison = "agrees" | "contradicts" | "not-compared";
|
|
96
|
+
/**
|
|
97
|
+
* How the Extraction's `text-span:` locator was resolved for a statement.
|
|
98
|
+
*
|
|
99
|
+
* - "span": the extractor supplied an explicit character span; the locator
|
|
100
|
+
* points where the extractor said it does.
|
|
101
|
+
* - "excerpt-match": no span, but the excerpt was found verbatim in the
|
|
102
|
+
* utterance; the locator points at that occurrence.
|
|
103
|
+
* - "unanchored-fallback": no span AND the excerpt does not occur in the
|
|
104
|
+
* utterance. The locator is still well-formed (`text-span:0-<length>`) so
|
|
105
|
+
* downstream producer discipline holds, but it is a length-shaped
|
|
106
|
+
* placeholder anchored at offset 0 — it does NOT point at the excerpt, and
|
|
107
|
+
* the text it spans is unrelated prose. Anything that resolves the locator
|
|
108
|
+
* against the source MUST check this field first; a hallucinated excerpt
|
|
109
|
+
* lands here.
|
|
64
110
|
*/
|
|
65
|
-
export type
|
|
111
|
+
export type LocatorResolution = "span" | "excerpt-match" | "unanchored-fallback";
|
|
66
112
|
export interface UtteranceStatement {
|
|
67
113
|
excerpt: string;
|
|
68
114
|
span?: {
|
|
@@ -70,6 +116,29 @@ export interface UtteranceStatement {
|
|
|
70
116
|
end: number;
|
|
71
117
|
};
|
|
72
118
|
target: CanonicalClaimTarget;
|
|
119
|
+
/**
|
|
120
|
+
* The value the statement asserted, verbatim from the extractor
|
|
121
|
+
* (`ExtractedStatement.value`) — not normalized, not defaulted. Absent when
|
|
122
|
+
* the extractor parsed no value, which is exactly when `valueComparison`
|
|
123
|
+
* is "not-compared". This is the field a reader needs to see WHAT was
|
|
124
|
+
* compared against the bundle's answer.
|
|
125
|
+
*/
|
|
126
|
+
assertedValue?: unknown;
|
|
127
|
+
/** How `assertedValue` compares to `inquiryRecord.answer?.value`. */
|
|
128
|
+
valueComparison: StatementValueComparison;
|
|
129
|
+
/** Human-readable account of the comparison, naming both sides. */
|
|
130
|
+
comparisonRationale: string;
|
|
131
|
+
/**
|
|
132
|
+
* The Survey provenance records this statement produced: its Extraction,
|
|
133
|
+
* its Candidate, and the per-target Candidate Set it belongs to. The
|
|
134
|
+
* Candidate Set carries the Candidate Conflict verdict and rationale when
|
|
135
|
+
* two statements in the SAME utterance disagree about one target
|
|
136
|
+
* (`candidateSet.status === "conflict"`), which is a different signal from
|
|
137
|
+
* `valueComparison` (statement vs bundle).
|
|
138
|
+
*/
|
|
139
|
+
records: UtteranceStatementRecords;
|
|
140
|
+
/** How this statement's Extraction locator was resolved. */
|
|
141
|
+
locatorResolution: LocatorResolution;
|
|
73
142
|
inquiryRecord: InquiryRecord;
|
|
74
143
|
badge: StatementBadge;
|
|
75
144
|
}
|
|
@@ -107,6 +176,13 @@ interface UtteranceRecordsResult {
|
|
|
107
176
|
records: UtteranceStatementRecords[];
|
|
108
177
|
extractions: Extraction[];
|
|
109
178
|
candidateSets: CandidateSet[];
|
|
179
|
+
/**
|
|
180
|
+
* `locatorResolutions[idx]` is how `records[idx]`'s Extraction locator was
|
|
181
|
+
* resolved — a parallel array rather than a fourth key on
|
|
182
|
+
* `UtteranceStatementRecords`, whose shape is a pinned contract. The same
|
|
183
|
+
* value is also on `records[idx].extraction.metadata.agentUtterance`.
|
|
184
|
+
*/
|
|
185
|
+
locatorResolutions: LocatorResolution[];
|
|
110
186
|
}
|
|
111
187
|
/**
|
|
112
188
|
* Build the full set of Survey records for every extracted statement in one
|
|
@@ -59,8 +59,10 @@ function buildUtteranceExtraction(params) {
|
|
|
59
59
|
const candidateId = `${statementId}.candidate`;
|
|
60
60
|
// Compute locator — required for non-manual-entry sources
|
|
61
61
|
// (assertProducerDiscipline throws without it). Source Locator rule:
|
|
62
|
-
// span-first, excerpt-fallback —
|
|
63
|
-
|
|
62
|
+
// span-first, excerpt-fallback — locator VALUES unchanged from Slice 1;
|
|
63
|
+
// what is new is that the record now says which branch produced them, so
|
|
64
|
+
// an unanchored placeholder is never mistaken for a resolved pointer.
|
|
65
|
+
const { locator, resolution: locatorResolution } = resolveUtteranceLocator(utterance, statement);
|
|
64
66
|
const extraction = {
|
|
65
67
|
id: extractionId,
|
|
66
68
|
sourceId,
|
|
@@ -77,6 +79,7 @@ function buildUtteranceExtraction(params) {
|
|
|
77
79
|
excerpt: statement.excerpt,
|
|
78
80
|
extractorName,
|
|
79
81
|
confidence: statement.confidence,
|
|
82
|
+
locatorResolution,
|
|
80
83
|
},
|
|
81
84
|
},
|
|
82
85
|
};
|
|
@@ -93,7 +96,7 @@ function buildUtteranceExtraction(params) {
|
|
|
93
96
|
confidence: statement.confidence,
|
|
94
97
|
},
|
|
95
98
|
};
|
|
96
|
-
return { extraction, proposal };
|
|
99
|
+
return { extraction, proposal, locatorResolution };
|
|
97
100
|
}
|
|
98
101
|
/**
|
|
99
102
|
* Group extraction/proposal pairs by canonical target and project each
|
|
@@ -166,7 +169,7 @@ function groupUtteranceExtractionsByTarget(sourceId, items) {
|
|
|
166
169
|
export function buildUtteranceRecords(params) {
|
|
167
170
|
const { sourceId, utterance, extracted, extractorName, observedAt } = params;
|
|
168
171
|
const items = extracted.map((statement, idx) => {
|
|
169
|
-
const { extraction, proposal } = buildUtteranceExtraction({
|
|
172
|
+
const { extraction, proposal, locatorResolution } = buildUtteranceExtraction({
|
|
170
173
|
sourceId,
|
|
171
174
|
idx,
|
|
172
175
|
statement,
|
|
@@ -174,7 +177,7 @@ export function buildUtteranceRecords(params) {
|
|
|
174
177
|
extractorName,
|
|
175
178
|
observedAt,
|
|
176
179
|
});
|
|
177
|
-
return { statement, extraction, proposal };
|
|
180
|
+
return { statement, extraction, proposal, locatorResolution };
|
|
178
181
|
});
|
|
179
182
|
const groups = groupUtteranceExtractionsByTarget(sourceId, items);
|
|
180
183
|
const records = items.map((item) => {
|
|
@@ -186,6 +189,7 @@ export function buildUtteranceRecords(params) {
|
|
|
186
189
|
records,
|
|
187
190
|
extractions: items.map((i) => i.extraction),
|
|
188
191
|
candidateSets: [...groups.values()].map((g) => g.candidateSet),
|
|
192
|
+
locatorResolutions: items.map((i) => i.locatorResolution),
|
|
189
193
|
};
|
|
190
194
|
}
|
|
191
195
|
// ---------------------------------------------------------------------------
|
|
@@ -234,7 +238,7 @@ export function utteranceToSurveyInput(utterance, extracted, context) {
|
|
|
234
238
|
// core) — replaces the old per-statement builder call. Claims below stay
|
|
235
239
|
// one-per-statement; `record.candidateSet.id`/`record.candidate.id` may be
|
|
236
240
|
// shared across several claims when statements share a target (legal).
|
|
237
|
-
const { records, extractions, candidateSets } = buildUtteranceRecords({
|
|
241
|
+
const { records, extractions, candidateSets, locatorResolutions } = buildUtteranceRecords({
|
|
238
242
|
sourceId,
|
|
239
243
|
utterance,
|
|
240
244
|
extracted,
|
|
@@ -270,6 +274,9 @@ export function utteranceToSurveyInput(utterance, extracted, context) {
|
|
|
270
274
|
span: statement.span,
|
|
271
275
|
confidence: statement.confidence,
|
|
272
276
|
locator: record.extraction.locator,
|
|
277
|
+
// Travels with the locator so a downstream reader of the Claim
|
|
278
|
+
// never has to assume the locator resolved.
|
|
279
|
+
locatorResolution: locatorResolutions[idx],
|
|
273
280
|
},
|
|
274
281
|
},
|
|
275
282
|
},
|
|
@@ -318,13 +325,14 @@ export async function surveyAgentUtterance(utterance, extractor, context) {
|
|
|
318
325
|
};
|
|
319
326
|
// Step 2: Extract statements
|
|
320
327
|
const extracted = await Promise.resolve(extractor.extract(utterance));
|
|
321
|
-
// Batched, grouped provenance construction
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
|
|
328
|
+
// Batched, grouped provenance construction. Grouping needs every statement
|
|
329
|
+
// of a target's group present at once, so this cannot be a per-statement
|
|
330
|
+
// call. The result is now carried onto every UtteranceStatement
|
|
331
|
+
// (`records`), so the extractor's confidence, locator, Candidate and
|
|
332
|
+
// Candidate Set — including the Candidate Conflict verdict when two
|
|
333
|
+
// statements in this utterance disagree about one target — are observable
|
|
334
|
+
// in the report instead of being computed and dropped on the floor.
|
|
335
|
+
const { records, locatorResolutions } = buildUtteranceRecords({
|
|
328
336
|
sourceId,
|
|
329
337
|
utterance,
|
|
330
338
|
extracted,
|
|
@@ -333,7 +341,7 @@ export async function surveyAgentUtterance(utterance, extractor, context) {
|
|
|
333
341
|
});
|
|
334
342
|
// Step 3 & 4: Resolve each statement and build the report
|
|
335
343
|
const statements = [];
|
|
336
|
-
for (const statement of extracted) {
|
|
344
|
+
for (const [idx, statement] of extracted.entries()) {
|
|
337
345
|
// Resolve the claim
|
|
338
346
|
let inquiryRecord;
|
|
339
347
|
if (mappings && mappings.length > 0) {
|
|
@@ -358,11 +366,17 @@ export async function surveyAgentUtterance(utterance, extractor, context) {
|
|
|
358
366
|
// Resolve directly by canonical target
|
|
359
367
|
inquiryRecord = resolveByTarget(bundle, statement.target, agentId, observedAt, rules, now);
|
|
360
368
|
}
|
|
361
|
-
const
|
|
369
|
+
const comparison = compareAssertedValue(statement, inquiryRecord);
|
|
370
|
+
const badge = badgeFor(inquiryRecord, comparison.valueComparison);
|
|
362
371
|
statements.push({
|
|
363
372
|
excerpt: statement.excerpt,
|
|
364
373
|
span: statement.span,
|
|
365
374
|
target: statement.target,
|
|
375
|
+
...(hasAssertedValue(statement) ? { assertedValue: statement.value } : {}),
|
|
376
|
+
valueComparison: comparison.valueComparison,
|
|
377
|
+
comparisonRationale: comparison.rationale,
|
|
378
|
+
records: records[idx],
|
|
379
|
+
locatorResolution: locatorResolutions[idx],
|
|
366
380
|
inquiryRecord,
|
|
367
381
|
badge,
|
|
368
382
|
});
|
|
@@ -389,45 +403,122 @@ function canonicalTargetKey(target) {
|
|
|
389
403
|
function targetToQuestion(target) {
|
|
390
404
|
return `${target.subjectId} ${target.fieldOrBehavior}`;
|
|
391
405
|
}
|
|
392
|
-
|
|
406
|
+
/**
|
|
407
|
+
* Answer statuses a reader takes as support for what the statement said.
|
|
408
|
+
* These — and only these — are the statuses a contradiction must override:
|
|
409
|
+
* badging a statement "verified" when it asserts a value the verified claim
|
|
410
|
+
* denies is the exact failure this profile exists to prevent. For any other
|
|
411
|
+
* status the claim's own standing is already the more informative thing to
|
|
412
|
+
* show, and the contradiction stays legible in `valueComparison`.
|
|
413
|
+
*/
|
|
414
|
+
const SUPPORTING_STATUSES = new Set(["verified", "assumed", "stale"]);
|
|
415
|
+
function hasAssertedValue(statement) {
|
|
416
|
+
return statement.value !== undefined;
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Whether a value can take part in a statement-vs-answer comparison at all.
|
|
420
|
+
* Scalars can; objects and arrays cannot, because an utterance extractor
|
|
421
|
+
* pulls a token out of prose and there is no defensible way to decide
|
|
422
|
+
* whether that token "is" a structured value. Those report "not-compared"
|
|
423
|
+
* rather than being asserted to contradict.
|
|
424
|
+
*/
|
|
425
|
+
function isComparableScalar(value) {
|
|
426
|
+
return value === null || ["string", "number", "boolean"].includes(typeof value);
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* The statement-vs-answer comparison key.
|
|
430
|
+
*
|
|
431
|
+
* This is deliberately NOT `utteranceEquivalenceKey`. That key compares two
|
|
432
|
+
* values from the SAME extractor, which share one type discipline, so it
|
|
433
|
+
* refuses cross-type equality on purpose (5 and "5" from one extractor
|
|
434
|
+
* really are different findings). A statement-vs-answer comparison crosses a
|
|
435
|
+
* boundary: the left side is a token the extractor pulled out of prose, the
|
|
436
|
+
* right side is a value the producer typed. Comparing those two by
|
|
437
|
+
* `typeof` would badge every true statement about a numeric or boolean field
|
|
438
|
+
* as a contradiction — a false accusation, which damages the badge exactly
|
|
439
|
+
* as much as a false green does.
|
|
440
|
+
*
|
|
441
|
+
* So scalars are compared by their canonical TEXT rendering, trimmed and
|
|
442
|
+
* lowercased. This bridges "the agent wrote 95" to "the producer stored 95"
|
|
443
|
+
* without losing a genuine disagreement: "5" and "6" still differ, and the
|
|
444
|
+
* bridge is named in `comparisonRationale` rather than applied silently.
|
|
445
|
+
*/
|
|
446
|
+
function assertionComparisonKey(value) {
|
|
447
|
+
return String(value).trim().toLowerCase();
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Compare the statement's own asserted value against the bundle's answer.
|
|
451
|
+
*
|
|
452
|
+
* An absent asserted value is never treated as agreement: an extractor that
|
|
453
|
+
* parsed no value has asserted nothing to check, so it reports
|
|
454
|
+
* "not-compared".
|
|
455
|
+
*/
|
|
456
|
+
function compareAssertedValue(statement, record) {
|
|
457
|
+
const targetKey = canonicalTargetKey(statement.target);
|
|
458
|
+
const answer = record.answer;
|
|
459
|
+
if (!answer) {
|
|
460
|
+
return {
|
|
461
|
+
valueComparison: "not-compared",
|
|
462
|
+
rationale: `No answer for ${targetKey} (inquiry outcome: ${record.outcome}); nothing to compare the statement against.`,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
if (!hasAssertedValue(statement)) {
|
|
466
|
+
return {
|
|
467
|
+
valueComparison: "not-compared",
|
|
468
|
+
rationale: `The extractor parsed no value out of this statement, so nothing was compared against the ${answer.status} answer for ${targetKey}.`,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
if (!isComparableScalar(statement.value) || !isComparableScalar(answer.value)) {
|
|
472
|
+
return {
|
|
473
|
+
valueComparison: "not-compared",
|
|
474
|
+
rationale: `Statement value ${JSON.stringify(statement.value) ?? "undefined"} and the ${answer.status} answer ${JSON.stringify(answer.value) ?? "undefined"} for ${targetKey} are not both scalars; no comparison was attempted.`,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
const asserted = assertionComparisonKey(statement.value);
|
|
478
|
+
const answered = assertionComparisonKey(answer.value);
|
|
479
|
+
const shown = `statement "${asserted}" vs ${answer.status} answer "${answered}" (compared as text)`;
|
|
480
|
+
return asserted === answered
|
|
481
|
+
? { valueComparison: "agrees", rationale: `Agrees for ${targetKey}: ${shown}.` }
|
|
482
|
+
: { valueComparison: "contradicts", rationale: `Contradicts for ${targetKey}: ${shown}.` };
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Grade the STATEMENT: the answer's status, overridden by "contradicted"
|
|
486
|
+
* when the statement asserts something the answer denies and that answer
|
|
487
|
+
* would otherwise have read as support.
|
|
488
|
+
*/
|
|
489
|
+
function badgeFor(record, valueComparison) {
|
|
393
490
|
if (record.outcome === "unsupported")
|
|
394
491
|
return "unsupported";
|
|
395
492
|
const status = record.answer?.status;
|
|
396
493
|
if (!status)
|
|
397
494
|
return "unsupported";
|
|
398
|
-
if (
|
|
399
|
-
return "
|
|
400
|
-
|
|
401
|
-
return "assumed";
|
|
402
|
-
if (status === "stale")
|
|
403
|
-
return "stale";
|
|
404
|
-
if (status === "disputed")
|
|
405
|
-
return "disputed";
|
|
406
|
-
if (status === "rejected" || status === "superseded")
|
|
407
|
-
return "rejected";
|
|
408
|
-
return "unsupported";
|
|
495
|
+
if (valueComparison === "contradicts" && SUPPORTING_STATUSES.has(status))
|
|
496
|
+
return "contradicted";
|
|
497
|
+
return status;
|
|
409
498
|
}
|
|
410
499
|
/**
|
|
411
|
-
*
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
500
|
+
* The single Source Locator rule this module guarantees: span-first,
|
|
501
|
+
* excerpt-match second, unanchored placeholder last — and it always reports
|
|
502
|
+
* WHICH of the three produced the locator.
|
|
503
|
+
*
|
|
504
|
+
* The locator strings are unchanged from Slice 1, including the last branch's
|
|
505
|
+
* `text-span:0-<excerpt.length>` placeholder. That placeholder is deliberate
|
|
506
|
+
* (producer discipline requires a locator on a non-manual-entry source), but
|
|
507
|
+
* it is well-formed and therefore resolvable — it will happily span real,
|
|
508
|
+
* unrelated prose at the head of the utterance. Returning the resolution
|
|
509
|
+
* alongside it is what keeps a hallucinated excerpt from acquiring a pointer
|
|
510
|
+
* that looks exactly like a found one: the record now says the lookup failed
|
|
511
|
+
* instead of leaving the reader to re-derive it.
|
|
422
512
|
*/
|
|
423
|
-
function
|
|
424
|
-
|
|
513
|
+
function resolveUtteranceLocator(utterance, statement) {
|
|
514
|
+
if (statement.span) {
|
|
515
|
+
return { locator: `text-span:${statement.span.start}-${statement.span.end}`, resolution: "span" };
|
|
516
|
+
}
|
|
517
|
+
const idx = utterance.indexOf(statement.excerpt);
|
|
425
518
|
if (idx >= 0) {
|
|
426
|
-
return `text-span:${idx}-${idx + excerpt.length}
|
|
519
|
+
return { locator: `text-span:${idx}-${idx + statement.excerpt.length}`, resolution: "excerpt-match" };
|
|
427
520
|
}
|
|
428
|
-
|
|
429
|
-
// best-effort for span-less extractors)
|
|
430
|
-
return `text-span:0-${excerpt.length}`;
|
|
521
|
+
return { locator: `text-span:0-${statement.excerpt.length}`, resolution: "unanchored-fallback" };
|
|
431
522
|
}
|
|
432
523
|
// ---------------------------------------------------------------------------
|
|
433
524
|
// Reference extractor (deterministic, for tests — not for production use)
|
package/dist/src/index.d.ts
CHANGED
|
@@ -42,7 +42,7 @@ export type { ApiRecordSourceInput, ChecksumInput, ManualEntrySourceInput, Polic
|
|
|
42
42
|
export { applyAutoAcceptPolicy, applyMappingReview, buildMappingReviewItems, lookupMapping, lookupRejectedMapping, normalizeQuestion, proposalsToCandidateSet, referenceMappingProposer, resolveQuestion, } from "./inquiry-mapping.js";
|
|
43
43
|
export type { AutoAcceptPolicy, InquiryMapping, MappingProposal, MappingProposer, } from "./inquiry-mapping.js";
|
|
44
44
|
export { referenceUtteranceExtractor, surveyAgentUtterance, utteranceToSurveyInput, } from "./agent-utterance.js";
|
|
45
|
-
export type { ExtractedStatement, StatementBadge, UtteranceClaimExtractor, UtteranceStatement, UtteranceStatementRecords, UtteranceTrustReport, } from "./agent-utterance.js";
|
|
45
|
+
export type { ExtractedStatement, LocatorResolution, StatementBadge, StatementValueComparison, UtteranceClaimExtractor, UtteranceStatement, UtteranceStatementRecords, UtteranceTrustReport, } from "./agent-utterance.js";
|
|
46
46
|
export { mappingReviewToSurface, referenceSchemaExtractor, surveySchemaMapping, } from "./schema-mapping.js";
|
|
47
47
|
export type { MappingProposalRecord, ReviewedMapping, SchemaMappingExtractor, SchemaMappingOptions, SystemFieldRef, } from "./schema-mapping.js";
|
|
48
48
|
export { buildAuthorizedActionAuthorizing, buildPromptRef, isValidAuthorizing, validateAuthorizing } from "./review-authorizing.js";
|
|
@@ -1,30 +1,16 @@
|
|
|
1
|
-
import { createInterface } from "node:readline";
|
|
2
1
|
import { readFile, writeFile, rename } 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";
|
|
16
8
|
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
9
|
const UI_RESOURCE_URI_META_KEY = "ui/resourceUri";
|
|
25
10
|
const UI_CAPABILITY_EXTENSION = "io.modelcontextprotocol/ui";
|
|
26
11
|
const QUEUE_PANEL_URI = "ui://survey/review-card/queue";
|
|
27
12
|
const UI_RESOURCE_MIME = "text/html;profile=mcp-app";
|
|
13
|
+
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
14
|
// MCP tool decision strings → ReviewWorkbenchDecision
|
|
29
15
|
const MCP_DECISION_MAP = {
|
|
30
16
|
accept: "accept-proposed",
|
|
@@ -449,213 +435,160 @@ function buildUiResource(item, snapshot, events, instance) {
|
|
|
449
435
|
mimeType: "text/html;profile=mcp-app",
|
|
450
436
|
text: buildReviewCardHtml(item, snapshot, events),
|
|
451
437
|
_meta: {
|
|
438
|
+
ui: {
|
|
439
|
+
csp: {
|
|
440
|
+
connectDomains: [],
|
|
441
|
+
resourceDomains: [],
|
|
442
|
+
},
|
|
443
|
+
},
|
|
452
444
|
"mcpui.dev/ui-preferred-frame-size": ["420px", "560px"],
|
|
453
445
|
},
|
|
454
446
|
},
|
|
455
447
|
};
|
|
456
448
|
}
|
|
457
|
-
// Render the
|
|
458
|
-
//
|
|
459
|
-
|
|
460
|
-
async function readQueuePanelHtml(options) {
|
|
449
|
+
// Render the declared review card from the same function used by embedded tool
|
|
450
|
+
// results, so Apps and text-first hosts cannot drift.
|
|
451
|
+
async function readQueuePanelResource(options) {
|
|
461
452
|
const { snapshot, events } = await readSessionFile(options.sessionPath);
|
|
462
453
|
const current = currentSessionState(snapshot, events);
|
|
463
454
|
const activeItem = currentReviewItem(current);
|
|
464
|
-
return
|
|
455
|
+
return buildUiResource(activeItem, snapshot, events, "queue").resource;
|
|
465
456
|
}
|
|
466
457
|
// ---- Domain error (maps to isError:true, not a JSON-RPC error) -----------
|
|
467
458
|
class DomainError extends Error {
|
|
468
459
|
isDomainError = true;
|
|
469
460
|
}
|
|
470
|
-
// ----
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
461
|
+
// ---- Official dual-era MCP server ---------------------------------------
|
|
462
|
+
function uiResourceMeta(resourceUri) {
|
|
463
|
+
return {
|
|
464
|
+
ui: { resourceUri, visibility: ["model", "app"] },
|
|
465
|
+
[UI_RESOURCE_URI_META_KEY]: resourceUri,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
function createReviewMcpServer(options, serverVersion) {
|
|
469
|
+
const server = new McpServer({
|
|
470
|
+
name: "survey-review-mcp",
|
|
471
|
+
title: "Survey Review MCP",
|
|
472
|
+
version: serverVersion,
|
|
473
|
+
}, {
|
|
474
|
+
instructions: SERVER_INSTRUCTIONS,
|
|
475
|
+
capabilities: options.noUi
|
|
476
|
+
? {}
|
|
477
|
+
: {
|
|
478
|
+
extensions: {
|
|
479
|
+
[UI_CAPABILITY_EXTENSION]: {},
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
cacheHints: {
|
|
483
|
+
"server/discover": { ttlMs: 0, cacheScope: "private" },
|
|
484
|
+
"tools/list": { ttlMs: 0, cacheScope: "private" },
|
|
485
|
+
"resources/list": { ttlMs: 0, cacheScope: "private" },
|
|
486
|
+
"resources/read": { ttlMs: 0, cacheScope: "private" },
|
|
487
|
+
},
|
|
488
|
+
});
|
|
489
|
+
server.registerTool("survey_review_queue", {
|
|
490
|
+
title: "Review queue",
|
|
491
|
+
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.",
|
|
492
|
+
inputSchema: z.object({}),
|
|
493
|
+
...(options.noUi ? {} : { _meta: uiResourceMeta(QUEUE_PANEL_URI) }),
|
|
494
|
+
}, async () => runReviewTool(() => toolQueue(options)));
|
|
495
|
+
server.registerTool("survey_review_item", {
|
|
496
|
+
title: "Review item detail",
|
|
497
|
+
description: "Return full detail for one review item: current and proposed values, confidence, source references, excerpts, and any current decision.",
|
|
498
|
+
inputSchema: z.object({
|
|
499
|
+
itemName: z.string().min(1).describe("The ReviewItem name to inspect."),
|
|
500
|
+
}),
|
|
501
|
+
}, async ({ itemName }) => runReviewTool(() => toolItem(itemName, options)));
|
|
502
|
+
server.registerTool("survey_review_decide", {
|
|
503
|
+
title: "Record a review decision",
|
|
504
|
+
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.",
|
|
505
|
+
inputSchema: z.discriminatedUnion("decision", [
|
|
506
|
+
z.object({
|
|
507
|
+
itemName: z.string().min(1).describe("The ReviewItem name to decide."),
|
|
508
|
+
decision: z
|
|
509
|
+
.enum(["accept", "hold", "reject"])
|
|
510
|
+
.describe("accept = accept-proposed, hold = keep-current, reject = reject-proposed."),
|
|
511
|
+
note: z.string().optional().describe("Optional reviewer note or rationale."),
|
|
512
|
+
}),
|
|
513
|
+
z.object({
|
|
514
|
+
itemName: z.string().min(1).describe("The ReviewItem name to decide."),
|
|
515
|
+
decision: z
|
|
516
|
+
.literal("could-not-confirm")
|
|
517
|
+
.describe("Record a terminal non-answer after evidence attempts are exhausted."),
|
|
518
|
+
reason: z
|
|
519
|
+
.string()
|
|
520
|
+
.trim()
|
|
521
|
+
.min(1)
|
|
522
|
+
.describe("Required non-empty reason for the could-not-confirm decision."),
|
|
523
|
+
attemptEvidenceIds: z
|
|
524
|
+
.array(z.string())
|
|
525
|
+
.optional()
|
|
526
|
+
.describe("Evidence ids attempted before a could-not-confirm decision."),
|
|
527
|
+
}),
|
|
528
|
+
]),
|
|
529
|
+
}, async (input) => runReviewTool(() => input.decision === "could-not-confirm"
|
|
530
|
+
? toolDecide(input.itemName, input.decision, input.reason, input.attemptEvidenceIds, options)
|
|
531
|
+
: toolDecide(input.itemName, input.decision, input.note, undefined, options)));
|
|
532
|
+
if (!options.noUi) {
|
|
533
|
+
server.registerResource("survey-review-workbench", QUEUE_PANEL_URI, {
|
|
534
|
+
title: "Survey review workbench",
|
|
535
|
+
description: "Interactive review card for the active item in the configured review session.",
|
|
536
|
+
mimeType: UI_RESOURCE_MIME,
|
|
537
|
+
cacheHint: { ttlMs: 0, cacheScope: "private" },
|
|
538
|
+
}, async () => {
|
|
539
|
+
const resource = await readQueuePanelResource(options);
|
|
540
|
+
return {
|
|
541
|
+
contents: [
|
|
542
|
+
{
|
|
543
|
+
uri: QUEUE_PANEL_URI,
|
|
544
|
+
mimeType: resource.mimeType,
|
|
545
|
+
text: sanitizeProtocolText(resource.text),
|
|
546
|
+
_meta: resource._meta,
|
|
547
|
+
},
|
|
548
|
+
],
|
|
549
|
+
};
|
|
550
|
+
});
|
|
482
551
|
}
|
|
483
|
-
|
|
484
|
-
|
|
552
|
+
return server;
|
|
553
|
+
}
|
|
554
|
+
async function runReviewTool(operation) {
|
|
485
555
|
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
|
-
}
|
|
556
|
+
return {
|
|
557
|
+
content: (await operation()).map(sanitizeContentItem),
|
|
558
|
+
isError: false,
|
|
559
|
+
};
|
|
651
560
|
}
|
|
652
561
|
catch (error) {
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
562
|
+
return {
|
|
563
|
+
content: [
|
|
564
|
+
{
|
|
565
|
+
type: "text",
|
|
566
|
+
text: sanitizeProtocolText(error instanceof Error ? error.message : String(error)),
|
|
567
|
+
},
|
|
568
|
+
],
|
|
569
|
+
isError: true,
|
|
570
|
+
};
|
|
657
571
|
}
|
|
658
572
|
}
|
|
573
|
+
const UNSAFE_TEXT_CHARS_RE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u0080-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u206f]/g;
|
|
574
|
+
function sanitizeProtocolText(text) {
|
|
575
|
+
return text.replace(UNSAFE_TEXT_CHARS_RE, "");
|
|
576
|
+
}
|
|
577
|
+
function sanitizeContentItem(item) {
|
|
578
|
+
if (item.type === "text") {
|
|
579
|
+
return { ...item, text: sanitizeProtocolText(item.text) };
|
|
580
|
+
}
|
|
581
|
+
return {
|
|
582
|
+
...item,
|
|
583
|
+
resource: {
|
|
584
|
+
...item.resource,
|
|
585
|
+
text: sanitizeProtocolText(item.resource.text),
|
|
586
|
+
},
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
function sanitizeDiagnostic(text) {
|
|
590
|
+
return sanitizeProtocolText(text).replaceAll(/\s*\r?\n\s*/g, " ").trim();
|
|
591
|
+
}
|
|
659
592
|
// ---- Entry point ---------------------------------------------------------
|
|
660
593
|
function parseMcpArgs(args) {
|
|
661
594
|
const defaultSession = resolve(dirname(new URL(import.meta.url).pathname), "../../../example-data/mcp-review-session.json");
|
|
@@ -688,17 +621,19 @@ async function readPackageVersion() {
|
|
|
688
621
|
return "0.0.0";
|
|
689
622
|
}
|
|
690
623
|
}
|
|
691
|
-
function send(message) {
|
|
692
|
-
process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
693
|
-
}
|
|
694
624
|
export async function runReviewMcp(args) {
|
|
695
625
|
const options = parseMcpArgs(args);
|
|
696
626
|
const serverVersion = await readPackageVersion();
|
|
697
|
-
const
|
|
698
|
-
|
|
699
|
-
|
|
627
|
+
const inputClosed = new Promise((resolveClosed) => {
|
|
628
|
+
process.stdin.once("end", resolveClosed);
|
|
629
|
+
process.stdin.once("close", resolveClosed);
|
|
700
630
|
});
|
|
701
|
-
|
|
702
|
-
|
|
631
|
+
const handle = serveStdio(() => createReviewMcpServer(options, serverVersion), {
|
|
632
|
+
legacy: "serve",
|
|
633
|
+
onerror: (error) => {
|
|
634
|
+
process.stderr.write(`survey-review-mcp: ${sanitizeDiagnostic(error.message)}\n`);
|
|
635
|
+
},
|
|
703
636
|
});
|
|
637
|
+
await inputClosed;
|
|
638
|
+
await handle.close();
|
|
704
639
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kontourai/survey",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "Producer-side source, extraction, candidate, and review contracts for projecting verified claims into Surface.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -50,9 +50,10 @@
|
|
|
50
50
|
"prepare": "npm run build",
|
|
51
51
|
"typecheck": "tsc --noEmit",
|
|
52
52
|
"test": "npm run build && node --test dist/tests/*.test.js",
|
|
53
|
+
"test:package-smoke": "npm run build && node --test dist/tests/package-smoke/*.test.js",
|
|
53
54
|
"test:browser": "playwright test",
|
|
54
55
|
"test:browser:concurrent": "npm run build && node scripts/test-playwright-concurrency.mjs",
|
|
55
|
-
"verify": "npm run check:content-boundary && npm run check:decisions && npm run typecheck && npm test && npm run check:review-workbench-assets && npm run check:review-workbench && npm run test:browser && npm run test:browser:concurrent",
|
|
56
|
+
"verify": "npm run check:content-boundary && npm run check:decisions && npm run typecheck && npm test && npm run test:package-smoke && npm run check:review-workbench-assets && npm run check:review-workbench && npm run test:browser && npm run test:browser:concurrent",
|
|
56
57
|
"check:content-boundary": "node --test tests/content-boundary-script.test.cjs && node scripts/check-content-boundary.cjs",
|
|
57
58
|
"check:decisions": "node scripts/check-decisions.cjs check",
|
|
58
59
|
"check:guards": "node scripts/check-guards.mjs",
|
|
@@ -71,7 +72,9 @@
|
|
|
71
72
|
"workflow:validate-artifacts": "flow-agents-validate-artifacts"
|
|
72
73
|
},
|
|
73
74
|
"dependencies": {
|
|
74
|
-
"@kontourai/surface": "^2.13.0"
|
|
75
|
+
"@kontourai/surface": "^2.13.0",
|
|
76
|
+
"@modelcontextprotocol/server": "2.0.0",
|
|
77
|
+
"zod": "4.2.0"
|
|
75
78
|
},
|
|
76
79
|
"peerDependencies": {
|
|
77
80
|
"typescript": ">=5.0.0"
|