onto-mcp 0.4.13 → 0.4.14

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.
@@ -1,3 +1,15 @@
1
+ /** The built-in code fixtures this gate can score — the value-level single
2
+ * source (the fixture-id type derives from it), mirroring
3
+ * SEMANTIC_QUALITY_GATE_CHECK_IDS below. FIXTURES is keyed by this type, so a new
4
+ * id here must ship a preset; the V2 scoring-completeness meta-test pins its
5
+ * scored set against THIS list at runtime, so a new fixture cannot enter the cert
6
+ * set with unproven (possibly stuck-on-pass) scoring. */
7
+ export const SEMANTIC_QUALITY_GATE_FIXTURE_IDS = [
8
+ "review-pipeline-target-v1",
9
+ "retry-policy-target-v1",
10
+ "clean-target-v1",
11
+ "shared-root-target-v1",
12
+ ];
1
13
  /** The complete check-id universe this gate can emit — the value-level single
2
14
  * source (the check_id type derives from it). The review-cert/v2 evidence
3
15
  * contract pins its per-run check universe against THIS list, so a harness
@@ -17,6 +29,21 @@ export const SEMANTIC_QUALITY_GATE_CHECK_IDS = [
17
29
  "count_list_consistency",
18
30
  "grounding",
19
31
  ];
32
+ /**
33
+ * Checks the gate OMITS for a clean-target fixture (expectsNoMaterialDefects):
34
+ * recall has nothing to recall, and grounding/actionability structurally fail
35
+ * on an empty material set (they require a material issue to ground / act on).
36
+ * These are declared N/A via the fixture's applicable_check_ids; the gate emits
37
+ * exactly the complementary applicable set. Kept as a module constant so the
38
+ * record-layer emission check (A-1b) can pin the same set.
39
+ */
40
+ export const CLEAN_TARGET_EXCLUDED_CHECK_IDS = new Set([
41
+ "material_issue_recall",
42
+ "final_result_material_issue_recall",
43
+ "artifact_material_issue_recall",
44
+ "grounding",
45
+ "actionability",
46
+ ]);
20
47
  const DEFAULT_FIXTURE_ID = "review-pipeline-target-v1";
21
48
  const FIXTURES = {
22
49
  "review-pipeline-target-v1": {
@@ -73,6 +100,71 @@ const FIXTURES = {
73
100
  targetAnchor: "src/retry.ts",
74
101
  targetAnchorTerms: ["src/retry.ts", "retry.ts"],
75
102
  },
103
+ // v3 G1 clean-target (design §D1/§D3): ZERO material defects + one boundary
104
+ // decoy (an accepted-but-unread field whose defect status cannot be decided
105
+ // without caller/public-API evidence). materialTerms is empty (nothing to
106
+ // recall — accepted only because expectsNoMaterialDefects); recall/grounding/
107
+ // actionability are excluded from emission (CLEAN_TARGET_EXCLUDED). The control
108
+ // is the boundary decoy: yes-man → false_materiality_guard fails; empty silence
109
+ // → boundary_uncertainty_preservation MUST-preserve fails; correct silence
110
+ // (decoy preserved as non-material boundary context) passes.
111
+ "clean-target-v1": {
112
+ fixtureId: "clean-target-v1",
113
+ materialTerms: [],
114
+ expectedMaterialTruth: "no material defect (clean target)",
115
+ boundaryUncertaintyTerms: ["telemetry label", "debug export"],
116
+ boundaryContextTerms: [
117
+ "evidence gap",
118
+ "needs evidence",
119
+ "insufficient evidence",
120
+ "without caller",
121
+ "without public api",
122
+ ],
123
+ actionMaterialTerms: [],
124
+ actionRemediationTerms: [],
125
+ targetAnchor: "src/clean-target.ts",
126
+ targetAnchorTerms: ["src/clean-target.ts", "clean-target.ts"],
127
+ expectsNoMaterialDefects: true,
128
+ requiresBoundaryPreservation: true,
129
+ },
130
+ // v3 G2 shared-root (design §D1/§D3): ONE common root (rawFormat delegating to
131
+ // JSON.stringify, which returns undefined for top-level undefined) surfaces as
132
+ // TWO distinct defects (unstableFormat + alternateFormat both delegate to it),
133
+ // plus one INDEPENDENT defect (truncate off-by-one) and a lensId boundary decoy.
134
+ // The control is expectedSharedCauseAnchorPairs: the shared_cause branch of
135
+ // causal_relation_correctness no longer passes vacuously (`.every` over an empty
136
+ // relation set) — a valid shared_cause_candidate relation MUST connect the
137
+ // unstableFormat and alternate findings. All 12 checks are emitted (full
138
+ // universe): shared-root has real material defects, so the recall floor applies.
139
+ "shared-root-target-v1": {
140
+ fixtureId: "shared-root-target-v1",
141
+ materialTerms: ["unstableformat", "json.stringify", "undefined"],
142
+ expectedMaterialTruth: "unstableFormat + JSON.stringify + undefined",
143
+ boundaryUncertaintyTerms: ["lensid", "lens id", "lens ids", "lens identity"],
144
+ boundaryContextTerms: [
145
+ "evidence gap",
146
+ "needs evidence",
147
+ "insufficient evidence",
148
+ "low-confidence",
149
+ "unresolved",
150
+ "without caller",
151
+ "without public api",
152
+ "caller evidence",
153
+ "public api evidence",
154
+ ],
155
+ actionMaterialTerms: ["unstableformat", "json.stringify", "undefined"],
156
+ actionRemediationTerms: [
157
+ "return type",
158
+ "fallback",
159
+ "widen",
160
+ "guard",
161
+ "focused test",
162
+ "verify",
163
+ ],
164
+ targetAnchor: "src/shared-root.ts",
165
+ targetAnchorTerms: ["src/shared-root.ts", "shared-root.ts"],
166
+ expectedSharedCauseAnchorPairs: [[["unstableformat"], ["alternate"]]],
167
+ },
76
168
  };
77
169
  function semanticFixture(fixtureId) {
78
170
  const resolved = fixtureId ?? DEFAULT_FIXTURE_ID;
@@ -81,6 +173,13 @@ function semanticFixture(fixtureId) {
81
173
  }
82
174
  return FIXTURES[resolved];
83
175
  }
176
+ /** SSOT accessor for a built-in fixture's pinned expectation preset (design §D3 —
177
+ * Phase A fixtures are code-presets). Returns a deep copy so callers cannot mutate
178
+ * the shared preset; tests and the cert manifest read the SAME source, so an
179
+ * injected copy can never drift from what the gate actually evaluates. */
180
+ export function semanticQualityFixturePreset(fixtureId) {
181
+ return structuredClone(semanticFixture(fixtureId));
182
+ }
84
183
  function records(value) {
85
184
  return Array.isArray(value)
86
185
  ? value.filter((item) => typeof item === "object" && item !== null)
@@ -151,6 +250,24 @@ function validCausalPath(value) {
151
250
  nonEmptyString(step.claim) &&
152
251
  nonEmptyStringArray(step.evidence_refs));
153
252
  }
253
+ /**
254
+ * A shared_cause_candidate relation is internally consistent: it carries a
255
+ * shared_cause block whose from/to cause refs are owned by the relation's own
256
+ * endpoint findings. The single authority for shared-cause validity — consumed
257
+ * both by causal_relation_correctness's negative sweep (every relation must be
258
+ * valid) and by its positive anchor-pair existence requirement (v3 G2).
259
+ */
260
+ function validSharedCauseRelation(relation, causeOwnerById) {
261
+ const sharedCause = record(relation.shared_cause);
262
+ const fromFindingId = typeof relation.from_finding_id === "string" ? relation.from_finding_id : "";
263
+ const toFindingId = typeof relation.to_finding_id === "string" ? relation.to_finding_id : "";
264
+ return (sharedCause !== null &&
265
+ nonEmptyString(sharedCause.cause_claim) &&
266
+ nonEmptyString(sharedCause.from_cause_ref) &&
267
+ nonEmptyString(sharedCause.to_cause_ref) &&
268
+ causeOwnerById.get(sharedCause.from_cause_ref) === fromFindingId &&
269
+ causeOwnerById.get(sharedCause.to_cause_ref) === toFindingId);
270
+ }
154
271
  /**
155
272
  * Whether two findings inside one issue are connected through
156
273
  * same_root_candidate relations cited in that issue's relation_refs — the
@@ -313,28 +430,53 @@ function issueArtifactChecks(artifacts, declaredNonMaterialFindingCount, nonMate
313
430
  `preserved_non_material_ids=${nonMaterialPreservationIds.join(",") || "none"}`,
314
431
  "non-material findings must preserve target-specific boundary uncertainty and remain outside relation coverage",
315
432
  ]);
433
+ // v3 G2: when the fixture declares anchor pairs, each pair MUST be connected
434
+ // by a valid shared_cause_candidate relation between findings anchored to the
435
+ // two term-groups — closing the `.every`-vacuous pass that lets a missing
436
+ // relation through. A finding is anchored to a group when its text contains
437
+ // every term in the group (terms are lowercased to match normalizedText).
438
+ const anchorPairs = fixture.expectedSharedCauseAnchorPairs ?? [];
439
+ const findingById = new Map();
440
+ for (const finding of findings) {
441
+ if (typeof finding.finding_id === "string") {
442
+ findingById.set(finding.finding_id, finding);
443
+ }
444
+ }
445
+ const findingMatchesAnchorGroup = (finding, group) => {
446
+ if (!finding)
447
+ return false;
448
+ const text = normalizedText(finding);
449
+ return group.every((term) => text.includes(term.toLowerCase()));
450
+ };
451
+ const anchorPairsSatisfied = anchorPairs.every(([groupA, groupB]) => relationRows.some((relation) => {
452
+ if (relation.relation !== "shared_cause_candidate")
453
+ return false;
454
+ // G2 requires TWO DISTINCT surface defects sharing a root — a self-relation
455
+ // (from === to) on one finding that happens to match both groups is a
456
+ // degenerate loop, not a genuine cross-defect link.
457
+ if (relation.from_finding_id === relation.to_finding_id)
458
+ return false;
459
+ if (!validSharedCauseRelation(relation, causeOwnerById))
460
+ return false;
461
+ const fromFinding = typeof relation.from_finding_id === "string"
462
+ ? findingById.get(relation.from_finding_id)
463
+ : undefined;
464
+ const toFinding = typeof relation.to_finding_id === "string"
465
+ ? findingById.get(relation.to_finding_id)
466
+ : undefined;
467
+ return ((findingMatchesAnchorGroup(fromFinding, groupA) &&
468
+ findingMatchesAnchorGroup(toFinding, groupB)) ||
469
+ (findingMatchesAnchorGroup(fromFinding, groupB) &&
470
+ findingMatchesAnchorGroup(toFinding, groupA)));
471
+ }));
316
472
  const causalRelationCorrectness = check("causal_relation_correctness", materialFindingIds.every((findingId) => relationCoveredIds.has(findingId)) &&
317
- relationRows.every((relation) => {
318
- if (relation.relation !== "shared_cause_candidate")
319
- return true;
320
- const sharedCause = record(relation.shared_cause);
321
- const fromFindingId = typeof relation.from_finding_id === "string"
322
- ? relation.from_finding_id
323
- : "";
324
- const toFindingId = typeof relation.to_finding_id === "string"
325
- ? relation.to_finding_id
326
- : "";
327
- return (sharedCause !== null &&
328
- nonEmptyString(sharedCause.cause_claim) &&
329
- nonEmptyString(sharedCause.from_cause_ref) &&
330
- nonEmptyString(sharedCause.to_cause_ref) &&
331
- causeOwnerById.get(sharedCause.from_cause_ref) ===
332
- fromFindingId &&
333
- causeOwnerById.get(sharedCause.to_cause_ref) === toFindingId);
334
- }), [
473
+ relationRows.every((relation) => relation.relation !== "shared_cause_candidate" ||
474
+ validSharedCauseRelation(relation, causeOwnerById)) &&
475
+ anchorPairsSatisfied, [
335
476
  `material_finding_ids=${materialFindingIds.join(",") || "none"}`,
336
477
  `relation_covered_ids=${[...relationCoveredIds].join(",") || "none"}`,
337
478
  `shared_cause_relation_ids=${sharedCauseRelationIds.join(",") || "none"}`,
479
+ `expected_shared_cause_anchor_pairs=${anchorPairs.length} satisfied=${anchorPairsSatisfied}`,
338
480
  ]);
339
481
  const dependencyPreservation = check("issue_dependency_preservation", relationRows.every((relation) => {
340
482
  if (relation.relation !== "shared_cause_candidate")
@@ -420,20 +562,41 @@ export function evaluateReviewPipelineSemanticQualityGate(args) {
420
562
  const fixture = args.expectations ?? semanticFixture(args.fixtureId);
421
563
  if (fixture.materialTerms.length === 0) {
422
564
  // textContainsAll over an empty list is vacuously true, so the material
423
- // recall checks would prove nothing — fail loud instead.
424
- throw new Error("SemanticQualityExpectations.materialTerms must not be empty");
565
+ // recall checks would prove nothing — fail loud UNLESS this is a
566
+ // clean-target fixture, where there is genuinely nothing to recall and the
567
+ // recall checks are excluded from emission (see CLEAN_TARGET_EXCLUDED).
568
+ if (!fixture.expectsNoMaterialDefects) {
569
+ throw new Error("SemanticQualityExpectations.materialTerms must not be empty");
570
+ }
425
571
  }
426
- // 빈 문자열 term/alternate는 text.includes("")가 항상 참이라 해당 entry를
427
- // 공허 충족시킨다 게이트 진입에서 fail loud.
428
- for (const term of fixture.materialTerms) {
429
- const alternates = Array.isArray(term) ? term : [term];
430
- if (alternates.length === 0 ||
431
- alternates.some((alternate) => alternate.trim().length === 0)) {
432
- throw new Error("SemanticQualityExpectations.materialTerms entries must be non-empty strings or non-empty groups of non-empty strings");
572
+ else {
573
+ // 문자열 term/alternate는 text.includes("")가 항상 참이라 해당 entry를
574
+ // 공허 충족시킨다 게이트 진입에서 fail loud.
575
+ for (const term of fixture.materialTerms) {
576
+ const alternates = Array.isArray(term) ? term : [term];
577
+ if (alternates.length === 0 ||
578
+ alternates.some((alternate) => alternate.trim().length === 0)) {
579
+ throw new Error("SemanticQualityExpectations.materialTerms entries must be non-empty strings or non-empty groups of non-empty strings");
580
+ }
433
581
  }
434
582
  }
583
+ // A clean-target's whole control is the boundary decoy: with no material
584
+ // defect the recall floor cannot catch silence, so distinguishing correct
585
+ // silence from lazy empty silence REQUIRES MUST-preserve. A clean-target that
586
+ // forgets requiresBoundaryPreservation would let empty silence pass vacuously
587
+ // — fail loud on the misconfiguration rather than certify a broken control.
588
+ if (fixture.expectsNoMaterialDefects && !fixture.requiresBoundaryPreservation) {
589
+ throw new Error("SemanticQualityExpectations.expectsNoMaterialDefects requires requiresBoundaryPreservation — without the boundary decoy control, empty silence passes vacuously");
590
+ }
435
591
  const summary = args.reviewRecord.result_classification_summary ?? null;
436
592
  const materialIssues = records(summary?.material_issues);
593
+ // Clean-target (v3 G1): material promotion must be caught on BOTH surfaces —
594
+ // the summary AND the finding-ledger authority. false_materiality_guard reads
595
+ // the summary; this counts artifact-ledger material findings so a fabricated
596
+ // material finding injected only into the ledger cannot slip past the guard.
597
+ const artifactMaterialFindingCount = fixture.expectsNoMaterialDefects
598
+ ? recordArray(record(args.issueArtifacts?.findingLedger)?.findings).filter((finding) => ["blocker", "high", "medium"].includes(String(finding.severity))).length
599
+ : 0;
437
600
  const nonMaterialFindings = records(summary?.non_material_findings);
438
601
  const actionCandidates = records(summary?.action_candidates);
439
602
  const materialText = normalizedText(materialIssues);
@@ -469,13 +632,27 @@ export function evaluateReviewPipelineSemanticQualityGate(args) {
469
632
  : `Final Review Result chars=${finalReviewResult.length}`,
470
633
  `expected final explanation to preserve ${fixture.expectedMaterialTruth}`,
471
634
  ]);
472
- const falseMaterialityGuard = check("false_materiality_guard", !materialBoundarySensitiveFalsePositive &&
635
+ const falseMaterialityGuard = check("false_materiality_guard",
636
+ // Clean-target (v3 G1): the target has zero material defects, so ANY
637
+ // admitted material issue is a false positive — on EITHER surface. A yes-man
638
+ // that promotes a fabricated issue fails here whether it lands in the summary
639
+ // (materialIssues) or only in the finding-ledger authority
640
+ // (artifactMaterialFindingCount), not just the boundary case the base branch
641
+ // catches.
642
+ (!fixture.expectsNoMaterialDefects ||
643
+ (materialIssues.length === 0 && artifactMaterialFindingCount === 0)) &&
644
+ !materialBoundarySensitiveFalsePositive &&
473
645
  (!falseMaterialityCandidateObserved ||
474
646
  (textContainsAny(preservedBoundaryText, fixture.boundaryUncertaintyTerms) &&
475
647
  textContainsAny(preservedBoundaryText, fixture.boundaryContextTerms))), [
476
648
  `boundary-sensitive uncertainty terms must be disclosed with boundary context when they are not admitted material issues: ${fixture.boundaryUncertaintyTerms.join(", ")}`,
477
649
  `non_material_finding_count=${nonMaterialFindings.length}`,
478
650
  `boundary_notes_chars=${boundaryNotes?.length ?? 0}`,
651
+ ...(fixture.expectsNoMaterialDefects
652
+ ? [
653
+ `clean-target: admitted material_issue_count=${materialIssues.length}, artifact_material_finding_count=${artifactMaterialFindingCount} (both must be 0)`,
654
+ ]
655
+ : []),
479
656
  ]);
480
657
  // Boundary uncertainty's preservation AUTHORITY is the finding-ledger
481
658
  // (non-material findings), not the final Boundary Notes projection. A model
@@ -488,10 +665,19 @@ export function evaluateReviewPipelineSemanticQualityGate(args) {
488
665
  // false_materiality_guard, which checks the ORTHOGONAL axis: the decoy was not
489
666
  // mis-promoted to a material issue. (A decoy surfaced only in the final note but
490
667
  // absent from the authority still fails here — authority is where it must live.)
491
- const boundaryUncertainty = check("boundary_uncertainty_preservation", !falseMaterialityCandidateObserved ||
492
- containsBoundarySensitiveUncertainty(nonMaterialText, fixture), [
668
+ const boundaryUncertainty = check("boundary_uncertainty_preservation",
669
+ // Clean-target (v3 G1): the boundary decoy MUST be preserved — an empty,
670
+ // lazy review that observes nothing no longer passes the vacuous first
671
+ // clause. Otherwise the base rule stands: preserve only what was observed.
672
+ fixture.requiresBoundaryPreservation
673
+ ? containsBoundarySensitiveUncertainty(nonMaterialText, fixture)
674
+ : !falseMaterialityCandidateObserved ||
675
+ containsBoundarySensitiveUncertainty(nonMaterialText, fixture), [
493
676
  `non_material_boundary_chars=${nonMaterialText.length}`,
494
677
  `expected boundary uncertainty (${fixture.boundaryUncertaintyTerms.join(", ")}) preserved in the finding-ledger authority (non-material findings)`,
678
+ ...(fixture.requiresBoundaryPreservation
679
+ ? ["clean-target: boundary decoy MUST be preserved (empty silence fails)"]
680
+ : []),
495
681
  ]);
496
682
  const materialActionRefs = new Set(materialIssues.flatMap((issue) => strings(issue.action_candidates)));
497
683
  for (const item of actionCandidates) {
@@ -530,12 +716,21 @@ export function evaluateReviewPipelineSemanticQualityGate(args) {
530
716
  actionability,
531
717
  grounding,
532
718
  ];
719
+ // Clean-target (v3 G1): emit only the applicable set — the recall/grounding/
720
+ // actionability checks are N/A with no material defect and would structurally
721
+ // fail on an empty material set. The record layer's per-fixture emission pin
722
+ // (A-1b) expects exactly this reduced set for such a fixture.
723
+ const emittedChecks = fixture.expectsNoMaterialDefects
724
+ ? checks.filter((item) => !CLEAN_TARGET_EXCLUDED_CHECK_IDS.has(item.check_id))
725
+ : checks;
533
726
  return {
534
- status: checks.every((item) => item.status === "passed") ? "passed" : "failed",
727
+ status: emittedChecks.every((item) => item.status === "passed")
728
+ ? "passed"
729
+ : "failed",
535
730
  fixture_id: fixture.fixtureId,
536
731
  scope: "fixture_specific",
537
732
  fixture_target_anchor: fixture.targetAnchor,
538
733
  applicability: "real_model_only",
539
- checks,
734
+ checks: emittedChecks,
540
735
  };
541
736
  }
@@ -18,6 +18,32 @@ const MCP_COMPACT_SIGNAL_MAX_CHARS = 360;
18
18
  const MCP_COMPACT_ID_MAX_CHARS = 120;
19
19
  const reviewApi = createOntoReviewCoreApi();
20
20
  const reconstructApi = createOntoReconstructCoreApi();
21
+ // Shared advertised JSON-schema fragment for the per-call `llmOverride` property.
22
+ // Kept in sync with PerCallLlmOverrideSchema (settings-chain); tool-surface.test.ts
23
+ // asserts both the zod and advertised surfaces carry it.
24
+ const LLM_OVERRIDE_PROPERTY_SCHEMA = {
25
+ type: "object",
26
+ additionalProperties: false,
27
+ properties: {
28
+ provider: {
29
+ type: "string",
30
+ enum: ["openai", "anthropic", "grok", "lmstudio"],
31
+ },
32
+ auth: { type: "string", enum: ["api_key", "oauth", "local"] },
33
+ model: { type: "string" },
34
+ effort: { type: "string" },
35
+ service_tier: { type: "string" },
36
+ },
37
+ // A provider switch needs an explicit model (the switched-in provider has no
38
+ // default model to inherit). The zod boundary enforces this; declaring it here
39
+ // too means a client reading tools/list sees the same contract instead of
40
+ // discovering it from a rejection. Both draft-07 (`if`/`then`) and 2019-09+
41
+ // (`dependentRequired`) spellings are supplied so either validator honors it.
42
+ if: { required: ["provider"] },
43
+ then: { required: ["model"] },
44
+ dependentRequired: { provider: ["model"] },
45
+ description: "Optional per-call LLM override: an ephemeral settings-llm overlay applied to this invocation's dispatch seats (provider switch replaces the actor block; a partial block field-overlays same-provider). Transport (base_url/api_key_env/timeout) stays settings-owned. Omit → settings unchanged (byte-identical default-off).",
46
+ };
21
47
  const REVIEW_INPUT_SCHEMA = {
22
48
  type: "object",
23
49
  additionalProperties: false,
@@ -97,6 +123,7 @@ const REVIEW_INPUT_SCHEMA = {
97
123
  type: "number",
98
124
  description: "Optional synchronous wait budget in milliseconds. When exceeded after session planning, onto_review returns a running handle and background execution continues; recover via onto_review_read(latest=true). The default is profile-aware (simple 45s, full 25s; override with env ONTO_MCP_REVIEW_RETURN_RUNNING_AFTER_MS or ..._SIMPLE) — most core-axis reviews exceed any host-safe window and return a handle.",
99
125
  },
126
+ llmOverride: LLM_OVERRIDE_PROPERTY_SCHEMA,
100
127
  },
101
128
  };
102
129
  const SESSION_INPUT_SCHEMA = {
@@ -357,10 +384,6 @@ const RECONSTRUCT_INPUT_SCHEMA = {
357
384
  enum: ["direct_call"],
358
385
  description: "Explicit confirmation provider realization. direct_call uses configured llm provider.",
359
386
  },
360
- llmEffort: {
361
- type: "string",
362
- description: "Optional reasoning-effort pin applied to both reconstruct actors (live only; mock ignores it).",
363
- },
364
387
  judgeLlmEffort: {
365
388
  type: "string",
366
389
  description: "Opt-in: run the answer-support judge at a different reasoning effort than the semantic author (live only). Reduces same-model rubber-stamping.",
@@ -369,6 +392,7 @@ const RECONSTRUCT_INPUT_SCHEMA = {
369
392
  type: "string",
370
393
  description: "Opt-in: swap the answer-support judge MODEL (on the semantic author's provider; live only). An unsupported model degrades to the author model (INV-MODEL-1); a degrade is recorded as a runtime status note.",
371
394
  },
395
+ llmOverride: LLM_OVERRIDE_PROPERTY_SCHEMA,
372
396
  },
373
397
  };
374
398
  const RECONSTRUCT_SESSION_INPUT_SCHEMA = {
@@ -526,12 +550,124 @@ function resolveToolProfile() {
526
550
  ? "simple"
527
551
  : "full";
528
552
  }
529
- export function advertisedToolDefinitions() {
530
- if (resolveToolProfile() !== "simple") {
531
- return TOOL_DEFINITIONS;
532
- }
533
- const simple = new Set(OntoSimpleProfileToolNames);
534
- return TOOL_DEFINITIONS.filter((tool) => simple.has(tool.name));
553
+ // Published MCP protocol revisions onto's response shapes conform to, newest
554
+ // first. onto emits additive fields from later revisions — tool annotations
555
+ // (2025-03-26), structuredContent/outputSchema (2025-06-18) — and does not use
556
+ // JSON-RPC batching (removed in 2025-06-18), so it honestly supports the whole
557
+ // range. The optional 2025-11-25 server features (icons, tasks, elicitation)
558
+ // are not advertised and not required for conformance. Negotiation echoes the
559
+ // client's requested version when supported; otherwise it returns the latest.
560
+ //
561
+ // Scope: this negotiator governs the `initialize` handshake used by every stable
562
+ // revision through 2025-11-25. The in-progress draft (RC 2026-07-28) removes
563
+ // `initialize` for a stateless model (per-request `_meta` version +
564
+ // `server/discover`); it is intentionally out of scope here. onto degrades
565
+ // gracefully with RC-aware clients — an unknown `server/discover` returns -32601
566
+ // (Method not found), so the client falls back to this handshake — a path locked
567
+ // by protocol-version.test.ts. Full RC support is a separate, deferred workstream
568
+ // (the draft is not yet ready for consumption).
569
+ export const SUPPORTED_PROTOCOL_VERSIONS = [
570
+ "2025-11-25",
571
+ "2025-06-18",
572
+ "2025-03-26",
573
+ "2024-11-05",
574
+ ];
575
+ export const LATEST_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS[0];
576
+ // The floor is the oldest supported revision — the version a client that never
577
+ // completed negotiation is treated as, so it receives the pre-2025 tool surface.
578
+ const FLOOR_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS[SUPPORTED_PROTOCOL_VERSIONS.length - 1];
579
+ export function negotiateProtocolVersion(requested) {
580
+ return typeof requested === "string" &&
581
+ SUPPORTED_PROTOCOL_VERSIONS.includes(requested)
582
+ ? requested
583
+ : LATEST_PROTOCOL_VERSION;
584
+ }
585
+ // Connection-scoped: a stdio server serves one client per process, so the
586
+ // version negotiated at `initialize` is remembered here and gates which
587
+ // additive tool-definition fields tools/list may emit. It starts at the floor
588
+ // so a client that skips negotiation gets the conservative legacy surface.
589
+ let negotiatedProtocolVersion = FLOOR_PROTOCOL_VERSION;
590
+ export function setNegotiatedProtocolVersion(version) {
591
+ negotiatedProtocolVersion = version;
592
+ }
593
+ // Revisions that introduced each additive tool-definition field. onto emits a
594
+ // field only to clients that negotiated its revision (or later), so an older
595
+ // client — including an older Claude Desktop that negotiates 2024-11-05 —
596
+ // receives byte-identical pre-2025 tool definitions. YYYY-MM-DD strings compare
597
+ // chronologically under lexical ordering.
598
+ const TOOL_ANNOTATIONS_SINCE = "2025-03-26";
599
+ const TOOL_OUTPUT_SCHEMA_SINCE = "2025-06-18";
600
+ // Advisory MCP tool hints (introduced 2025-03-26, carried unchanged into the
601
+ // draft/RC). onto confines every write to `.onto/` and never destroys user
602
+ // sources, so destructiveHint is false throughout. readOnlyHint marks the pure
603
+ // read/list tools; openWorldHint marks the tools that themselves dispatch to
604
+ // external LLM providers (they spend tokens and are non-deterministic). Keyed by
605
+ // OntoToolName for compile-time completeness — a new advertised tool must
606
+ // declare its hints here or the build fails.
607
+ const TOOL_ANNOTATIONS = {
608
+ onto_review: { title: "Run review", readOnlyHint: false, destructiveHint: false, openWorldHint: true },
609
+ onto_prepare_review: { title: "Prepare review session", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
610
+ onto_review_continue: { title: "Resume review", readOnlyHint: false, destructiveHint: false, openWorldHint: true },
611
+ onto_review_round: { title: "Get review round (host-orchestrated)", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
612
+ onto_review_advance: { title: "Advance review round", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
613
+ onto_review_cancel: { title: "Cancel review", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
614
+ onto_review_read: { title: "Read review session", readOnlyHint: true, destructiveHint: false, openWorldHint: false },
615
+ onto_observe_source: { title: "Observe reconstruct sources", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
616
+ onto_validate_reconstruct_directive: { title: "Validate reconstruct directive", readOnlyHint: false, destructiveHint: false, openWorldHint: false },
617
+ onto_reconstruct: { title: "Reconstruct ontology seed", readOnlyHint: false, destructiveHint: false, openWorldHint: true },
618
+ onto_reconstruct_read: { title: "Read reconstruct session", readOnlyHint: true, destructiveHint: false, openWorldHint: false },
619
+ onto_list: { title: "List registry", readOnlyHint: true, destructiveHint: false, openWorldHint: false },
620
+ };
621
+ // Declared result schema for tools whose structuredContent shape is stable
622
+ // enough to pin without brittleness. MCP (2025-06-18+, loosened in the draft/RC)
623
+ // requires a tool that declares outputSchema to return conforming
624
+ // structuredContent, so this is intentionally selective. onto_list's three
625
+ // per-kind envelopes are typed and stable; the read/record tools vary by
626
+ // projectionLevel and are deliberately deferred. Parity with the real output is
627
+ // enforced by tool-surface.behavior.test.ts against these declared shapes.
628
+ const TOOL_OUTPUT_SCHEMAS = {
629
+ onto_list: {
630
+ type: "object",
631
+ anyOf: [
632
+ {
633
+ required: ["full", "coreAxis"],
634
+ properties: {
635
+ full: { type: "array", items: { type: "string" } },
636
+ coreAxis: { type: "array", items: { type: "string" } },
637
+ },
638
+ },
639
+ {
640
+ required: ["domains"],
641
+ properties: { domains: { type: "array", items: { type: "string" } } },
642
+ },
643
+ {
644
+ required: ["sourceProfiles"],
645
+ properties: { sourceProfiles: { type: "array" } },
646
+ },
647
+ ],
648
+ },
649
+ };
650
+ export function advertisedToolDefinitions(protocolVersion = negotiatedProtocolVersion) {
651
+ const base = resolveToolProfile() === "simple"
652
+ ? TOOL_DEFINITIONS.filter((tool) => new Set(OntoSimpleProfileToolNames).has(tool.name))
653
+ : TOOL_DEFINITIONS;
654
+ const withAnnotations = protocolVersion >= TOOL_ANNOTATIONS_SINCE;
655
+ const withOutputSchema = protocolVersion >= TOOL_OUTPUT_SCHEMA_SINCE;
656
+ // Merge additive fields onto a copy — TOOL_DEFINITIONS stays the pristine
657
+ // dispatch source; only tools/list carries them, and only for clients that
658
+ // negotiated the revision that introduced each field. A client below both
659
+ // thresholds receives the definition unchanged.
660
+ return base.map((tool) => {
661
+ const merged = { ...tool };
662
+ if (withAnnotations)
663
+ merged.annotations = TOOL_ANNOTATIONS[tool.name];
664
+ if (withOutputSchema) {
665
+ const outputSchema = TOOL_OUTPUT_SCHEMAS[tool.name];
666
+ if (outputSchema !== undefined)
667
+ merged.outputSchema = outputSchema;
668
+ }
669
+ return merged;
670
+ });
535
671
  }
536
672
  export const USAGE_GUIDE = `# Using onto via MCP
537
673
 
@@ -611,6 +747,24 @@ Discover material profiles with \`onto_list\` (kind="source_profiles").
611
747
  they are presentation guidance, not operating instructions.
612
748
  - onto writes only under \`{projectRoot}/.onto/\`; it never mutates your sources.
613
749
  `;
750
+ // Server-level orientation returned in the `initialize` result so a host LLM
751
+ // gets the big picture on connect. Deliberately a short summary plus a pointer:
752
+ // the durable, detailed authority is the `onto://usage` resource, which is why
753
+ // this stays initialize-era-only. The draft/RC drops the initialize handshake,
754
+ // so orientation there falls back to that same resource — no detail is stranded
755
+ // in this field.
756
+ const SERVER_INSTRUCTIONS = [
757
+ "onto is an MCP-native ontology-as-code runtime with two paths: `review`",
758
+ "(structured, context-isolated multi-lens review of a target) and `reconstruct`",
759
+ "(derive a bounded, actionable ontology seed from real sources). The runtime",
760
+ "owns every artifact and validation gate; tool results put structured data in",
761
+ "structuredContent and carry llmPresentation prompts for user-facing",
762
+ "explanation. Both paths execute real LLM work and FAIL LOUD without a provider",
763
+ "configured in .onto/settings.json (or ~/.onto/settings.json). review is",
764
+ "long-running — poll onto_review_read until it is no longer running. Read the",
765
+ "`onto://usage` resource for provider setup and the full workflows before first",
766
+ "use.",
767
+ ].join(" ");
614
768
  const RESOURCE_DEFINITIONS = [
615
769
  {
616
770
  uri: "onto://usage",
@@ -697,6 +851,9 @@ function toReviewRequest(input) {
697
851
  ...(parsed.confirmValueAlignment !== undefined
698
852
  ? { confirmValueAlignment: parsed.confirmValueAlignment }
699
853
  : {}),
854
+ ...(parsed.llmOverride !== undefined
855
+ ? { llmOverride: parsed.llmOverride }
856
+ : {}),
700
857
  };
701
858
  return request;
702
859
  }
@@ -1537,9 +1694,9 @@ export async function callTool(name, args, options = {}) {
1537
1694
  ...(parsed.resumeMode !== undefined ? { resumeMode: parsed.resumeMode } : {}),
1538
1695
  semanticAuthorRealization: parsed.semanticAuthorRealization,
1539
1696
  confirmationProviderRealization: parsed.confirmationProviderRealization,
1540
- ...(parsed.llmEffort !== undefined ? { llmEffort: parsed.llmEffort } : {}),
1541
1697
  ...(parsed.judgeLlmEffort !== undefined ? { judgeLlmEffort: parsed.judgeLlmEffort } : {}),
1542
1698
  ...(parsed.judgeModel !== undefined ? { judgeModel: parsed.judgeModel } : {}),
1699
+ ...(parsed.llmOverride !== undefined ? { llmOverride: parsed.llmOverride } : {}),
1543
1700
  ...(sessionRoot ? { sessionRoot } : {}),
1544
1701
  ...(profilesRoot ? { profilesRoot } : {}),
1545
1702
  ...(filesystemAllowedRoots ? { filesystemAllowedRoots } : {}),
@@ -1597,20 +1754,26 @@ function jsonRpcError(id, code, message) {
1597
1754
  error: { code, message },
1598
1755
  };
1599
1756
  }
1600
- async function handleRequest(message) {
1757
+ export async function handleRequest(message) {
1601
1758
  if (!message.id && message.method?.startsWith("notifications/")) {
1602
1759
  return null;
1603
1760
  }
1604
1761
  switch (message.method) {
1605
- case "initialize":
1762
+ case "initialize": {
1763
+ const params = message.params;
1764
+ const negotiated = negotiateProtocolVersion(params?.protocolVersion);
1765
+ // Remember it so tools/list gates its additive fields on the same version.
1766
+ setNegotiatedProtocolVersion(negotiated);
1606
1767
  return jsonRpcResult(message.id, {
1607
- protocolVersion: "2024-11-05",
1768
+ protocolVersion: negotiated,
1608
1769
  capabilities: { tools: {}, resources: {}, prompts: {} },
1609
1770
  serverInfo: {
1610
1771
  name: "onto-mcp",
1611
1772
  version: await readPackageVersion(),
1612
1773
  },
1774
+ instructions: SERVER_INSTRUCTIONS,
1613
1775
  });
1776
+ }
1614
1777
  case "ping":
1615
1778
  return jsonRpcResult(message.id, {});
1616
1779
  case "tools/list":