Skip to content

Commit f21c4e0

Browse files
committed
fix(builders,derive): lossless 2020-12 schema pipeline on the 2026 path
Two defects destroyed schema fidelity between tool-author types and tools/list: 1. ToolSchema::from_schemars (turul-mcp-builders/schemars_helpers.rs) stripped $defs/definitions from the root while passing properties through VERBATIM on 2026 — every "#/$defs/X" pointer dangled. The 2026 root now retains $defs/definitions/$schema (where verbatim property pointers resolve); the 2025 typed lane keeps its inline-resolution + stripping. 2. The derive macros (param arm + output-schema arm in derive/utils.rs) funneled schemars-generated documents through the typed-enum converter, silently collapsing data-bearing unions (oneOf + const tags -> {"type":"object"}) and other 2020-12 compositions. New lane-aware turul_mcp_builders::schemars_param_schema(): - 2026: inlines local $refs via the cycle-guarded resolve_local_refs() ($ref siblings compose via allOf) and carries the result verbatim through the new transparent JsonSchema::Raw variant — an untagged escape hatch on the 2026 typed enum (also the deserialize fallback for subschemas the structured variants reject). The 2026 typed enum is a framework vehicle, not a wire binding; the wire bindings already hold arbitrary Values. - 2025: status-quo typed conversion (frozen wire model). Documented limitations with rejection tests, not silent loss: - cyclic $refs cannot be inlined into a property subschema (error names the cycle; restructure the type or build a root document via from_schemars, which keeps $defs at the root); - non-local/network $refs are rejected (spec: MUST NOT auto-dereference). Tests: 7 builders fidelity tests (nested-$defs inlining with enum/required intact; tagged-union oneOf survival; composition keywords verbatim; cycle + non-local rejection; $ref-sibling allOf; root $defs retention) and 2 real-HTTP e2e in schema_fidelity_2026.rs — a derived tool's tagged-union param and schemars output reach tools/list undamaged with no dangling $ref, and tools/call structuredContent satisfies the ADVERTISED outputSchema wrapper field discovered from tools/list. Revert-and-fail: with the 2026 arm forced through the old converter, the tagged-union test fails showing the exact loss ("shape":{"type":"object"}) — recorded. One derive unit test migrated with the contract (asserts the new helper call). No public macro/builder API shape changed (stop condition not hit). Docs: review checklist updated — pipeline items plus the seven P1 fidelity items closed earlier today (incl. the SEP-2577 line codex flagged as report drift). CI: schema_fidelity_2026 gate in ci.yml + ci-gates.sh.
1 parent b571283 commit f21c4e0

10 files changed

Lines changed: 596 additions & 53 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ jobs:
5555
run: cargo test -p turul-mcp-server --no-default-features --features http,sse,protocol-2026-07-28 --test mcp_param_2026
5656
- name: 2026 per-request log gating (logLevel opt-in)
5757
run: cargo test -p turul-mcp-server --no-default-features --features http,sse,protocol-2026-07-28 --test log_gating_2026
58+
- name: 2026 schema fidelity (derive/builders pipeline to the wire)
59+
run: cargo test -p turul-mcp-server --no-default-features --features http,sse,protocol-2026-07-28 --test schema_fidelity_2026
5860
- name: protocol-2026 compliance + upstream wire fixtures
5961
run: cargo test -p turul-mcp-protocol-2026-07-28 --features compliance
6062
- name: Bilingual client (not in default-members)

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3737

3838
### Fixed (2026-06-10)
3939

40+
- **Builders/derive schema pipeline is lossless on the 2026 path.** Two defects destroyed JSON Schema 2020-12 fidelity between a tool author's types and `tools/list`: (1) `ToolSchema::from_schemars` stripped `$defs`/`definitions` from the root while passing properties through verbatim — every `#/$defs/X` pointer dangled; the 2026 root now RETAINS `$defs`/`definitions`/`$schema` (the 2025 typed lane keeps its inline-resolution and stripping). (2) The derive macros funneled schemars-generated parameter and output schemas through the typed-enum converter, silently collapsing data-bearing unions (`oneOf` + `const` tags → bare `{"type":"object"}`) and other 2020-12 compositions. New lane-aware `turul_mcp_builders::schemars_param_schema`: on 2026 it inlines local `$ref`s (cycle-guarded `resolve_local_refs`; `$ref` siblings compose via `allOf`) and carries the result verbatim via the new transparent `JsonSchema::Raw` variant (untagged escape hatch on the 2026 typed enum — also the deserialize fallback for subschemas the structured variants reject); on 2025 it is the status-quo typed conversion. **Documented limitations with rejection tests** (not silent loss): cyclic `$ref`s cannot be inlined into a property subschema (error names the cycle; restructure the type or use a root `from_schemars` document), and non-local/network `$ref`s are rejected per the spec's no-auto-deref rule. Tests: 7 builders fidelity tests (nested `$defs` inlining with enum/required intact, tagged-union `oneOf` survival, composition-keyword verbatim round-trip, cycle/non-local rejection, `$ref`-sibling `allOf` composition, root `$defs` retention) + 2 real-HTTP e2e (`schema_fidelity_2026.rs`: a derived tool's tagged-union param and schemars output reach `tools/list` undamaged with no dangling `$ref`; `tools/call` `structuredContent` satisfies the ADVERTISED `outputSchema` wrapper field discovered from `tools/list`). Revert-and-fail: with the 2026 arm forced through the old converter, the tagged-union test fails showing the exact loss (`"shape":{"type":"object"}`) — recorded. No public macro/builder API shape changed.
4041
- **Protocol-fidelity sweep, part 2 — `ttlMs` as a schema `number` + SEP-2577 marker absorption.** (a) `CacheableResult.ttlMs` (and its embeddings in the tools/resources/prompts/discover results) is now `f64` per the schema's `number` type: fractional values are accepted on deserialize and survive round trips, negative/non-finite values reject (`@minimum 0`), and whole values keep the compact integer wire form (byte-stable for the common `ttlMs: 0` case). (b) The re-pinned schema's SEP-2577 deprecations are now fully absorbed as `#[deprecated]` markers: `LoggingLevel` (+ `LogLevel` alias), the per-request `_meta` `logLevel` key and `RequestMetaObject.log_level`/`with_log_level`, `ServerCapabilities.logging`, `ModelHint`/`ModelPreferences`/`ToolChoice`, the `ContentBlock::ToolUse`/`ToolResult` variants and constructors, and the sampling trait surface (`HasCreateMessageRequestParams`/`CreateMessageRequest`/`CreateMessageResult`/`HasLevelParam`). The earlier rustdoc claim that `LoggingLevel`/`logLevel` were "the non-deprecated replacement" was wrong against the re-pin and is corrected — the whole Logging surface (including the per-request opt-in this branch implements) is deprecated-but-normative through the migration window. Framework-internal use sites carry scoped `#[allow(deprecated)]` (the framework intentionally serves the surface through the window); downstream consumers now get compiler nudges.
4142
- **Protocol-fidelity sweep, part 1 (wire/type drift vs the pinned schema).** (a) `ToolChoice` no longer carries a non-spec `name` field on the wire (the `specific()` constructor is gone) and `mode` is optional per schema (`{}` parses; absent means `"auto"`; `effective_mode()` helper). (b) `PromptReference` is `BaseMetadata`-shaped: gains `title`, drops the non-spec `description`. (c) `Annotations.audience` is the closed `Role[]` union instead of `Vec<String>` — wire-invalid values like `"system"` are now rejected at parse time; the builders' `annotation_audience` takes `Role` (converted to strings on the frozen 2025 lane). (d) The duplicate `Role` binding is gone — `sampling::Role` re-exports the single `prompts::Role`. (e) `LoggingCapabilities`/`CompletionsCapabilities` match the schema's opaque `JSONObject`: the invented `enabled`/`levels` keys are removed from the bindings and from both server builders' capability advertisements (presence of the object is the signal). 5 new wire-shape contract tests in `compliance.rs`; existing tests migrated with the contract (e.g. the empty `ToolChoice` parse fails against the pre-fix required-`mode` binding).
4243

crates/turul-mcp-builders/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ pub mod tool;
153153
pub mod schemars_helpers;
154154
pub use schemars_helpers::{
155155
ToolSchemaExt, convert_value_to_json_schema, convert_value_to_json_schema_with_defs,
156+
resolve_local_refs, schemars_param_schema,
156157
};
157158

158159
// Schema provider with automatic JsonSchema detection

crates/turul-mcp-builders/src/schemars_helpers.rs

Lines changed: 312 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -397,7 +397,13 @@ impl ToolSchemaExt for ToolSchema {
397397
.collect()
398398
});
399399

400-
// Preserve remaining top-level fields (description, title, additionalProperties, etc.)
400+
// Preserve remaining top-level fields (description, title,
401+
// additionalProperties, etc.). Lane split: the 2025 typed model
402+
// resolves `$ref`s into inline schemas, so its root drops
403+
// `$defs`/`definitions`/`$schema`; the 2026 path keeps properties
404+
// VERBATIM, so the root must retain `$defs`/`definitions` (otherwise
405+
// every `#/$defs/X` pointer dangles) and the `$schema` dialect marker.
406+
#[cfg(feature = "protocol-2025-11-25")]
401407
let reserved = [
402408
"type",
403409
"properties",
@@ -406,6 +412,8 @@ impl ToolSchemaExt for ToolSchema {
406412
"definitions",
407413
"$schema",
408414
];
415+
#[cfg(feature = "protocol-2026-07-28")]
416+
let reserved = ["type", "properties", "required"];
409417
let additional: HashMap<String, Value> = obj
410418
.iter()
411419
.filter(|(k, _)| !reserved.contains(&k.as_str()))
@@ -421,6 +429,119 @@ impl ToolSchemaExt for ToolSchema {
421429
}
422430
}
423431

432+
/// Inline every local `$ref` (`#/$defs/X`, `#/definitions/X`) in `value`
433+
/// against `defs`, recursively. Errors on unresolvable pointers and on
434+
/// cyclic references — cyclic schemas cannot be inlined and are not
435+
/// supported as tool parameter/output types (restructure the type, or build
436+
/// the whole `ToolSchema` from a root schemars document via `from_schemars`,
437+
/// which keeps `$defs` at the schema root instead).
438+
pub fn resolve_local_refs(value: &Value, defs: &HashMap<String, Value>) -> Result<Value, String> {
439+
fn walk(
440+
value: &Value,
441+
defs: &HashMap<String, Value>,
442+
stack: &mut Vec<String>,
443+
) -> Result<Value, String> {
444+
match value {
445+
Value::Object(obj) => {
446+
if let Some(reference) = obj.get("$ref").and_then(|r| r.as_str()) {
447+
let name = reference
448+
.strip_prefix("#/$defs/")
449+
.or_else(|| reference.strip_prefix("#/definitions/"))
450+
.ok_or_else(|| {
451+
format!("unsupported non-local $ref '{reference}' in tool schema")
452+
})?;
453+
if stack.iter().any(|n| n == name) {
454+
return Err(format!(
455+
"cyclic $ref '#/$defs/{name}' cannot be inlined into a tool property schema"
456+
));
457+
}
458+
let definition = defs.get(name).ok_or_else(|| {
459+
format!("dangling $ref '{reference}': no such definition")
460+
})?;
461+
stack.push(name.to_string());
462+
let resolved = walk(definition, defs, stack)?;
463+
stack.pop();
464+
// 2020-12 allows $ref siblings; preserve them alongside the
465+
// resolved schema via allOf composition.
466+
let siblings: serde_json::Map<String, Value> = obj
467+
.iter()
468+
.filter(|(k, _)| k.as_str() != "$ref")
469+
.map(|(k, v)| Ok((k.clone(), walk(v, defs, stack)?)))
470+
.collect::<Result<_, String>>()?;
471+
if siblings.is_empty() {
472+
Ok(resolved)
473+
} else {
474+
let mut combined = siblings;
475+
combined.insert("allOf".to_string(), Value::Array(vec![resolved]));
476+
Ok(Value::Object(combined))
477+
}
478+
} else {
479+
obj.iter()
480+
.map(|(k, v)| Ok((k.clone(), walk(v, defs, stack)?)))
481+
.collect::<Result<serde_json::Map<_, _>, String>>()
482+
.map(Value::Object)
483+
}
484+
}
485+
Value::Array(items) => items
486+
.iter()
487+
.map(|v| walk(v, defs, &mut stack.clone()))
488+
.collect::<Result<Vec<_>, String>>()
489+
.map(Value::Array),
490+
other => Ok(other.clone()),
491+
}
492+
}
493+
let mut stack = Vec::new();
494+
walk(value, defs, &mut stack)
495+
}
496+
497+
/// Convert a schemars-generated document into a single property/field
498+
/// subschema for embedding inside a `ToolSchema`.
499+
///
500+
/// 2026-07-28: local `$ref`s are inlined (a subschema cannot carry the
501+
/// document-root `$defs` its absolute pointers need) and the result is a
502+
/// verbatim 2020-12 [`JsonSchema::Raw`] — `oneOf`/`anyOf`/`allOf`, `const`,
503+
/// enum constraints, and nested object trees survive untouched.
504+
/// 2025-11-25: the structured typed conversion (the wire model there is the
505+
/// typed enum).
506+
///
507+
/// # Panics
508+
/// On cyclic `$ref`s — cyclic schemas cannot be inlined into a property
509+
/// subschema and are not supported as tool parameter/output types. Surface a
510+
/// non-recursive type instead.
511+
pub fn schemars_param_schema(schema: schemars::Schema) -> JsonSchema {
512+
let schema_value =
513+
serde_json::to_value(&schema).expect("schemars schema should serialize to JSON");
514+
515+
let definitions: HashMap<String, Value> = ["$defs", "definitions"]
516+
.iter()
517+
.filter_map(|k| schema_value.get(*k).and_then(|v| v.as_object()))
518+
.flat_map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())))
519+
.collect();
520+
521+
#[cfg(feature = "protocol-2026-07-28")]
522+
{
523+
let resolved = resolve_local_refs(&schema_value, &definitions)
524+
.unwrap_or_else(|e| panic!("unsupported tool schema: {e}"));
525+
let mut resolved = resolved;
526+
if let Some(obj) = resolved.as_object_mut() {
527+
obj.remove("$schema");
528+
obj.remove("$defs");
529+
obj.remove("definitions");
530+
}
531+
JsonSchema::raw(resolved)
532+
}
533+
534+
#[cfg(feature = "protocol-2025-11-25")]
535+
{
536+
let mut schema_value = schema_value;
537+
if let Some(obj) = schema_value.as_object_mut() {
538+
obj.remove("$defs");
539+
obj.remove("definitions");
540+
}
541+
convert_value_to_json_schema_with_defs(&schema_value, &definitions)
542+
}
543+
}
544+
424545
#[cfg(test)]
425546
mod tests {
426547
use super::*;
@@ -571,3 +692,193 @@ mod tests {
571692
);
572693
}
573694
}
695+
696+
#[cfg(all(test, feature = "protocol-2026-07-28"))]
697+
mod schema_fidelity_2026_tests {
698+
//! 2020-12 fidelity through the 2026 conversion pipeline: nothing a
699+
//! schemars-generated document expresses may be silently downgraded.
700+
use super::*;
701+
use schemars::{JsonSchema as SchemarsJsonSchema, schema_for};
702+
use serde::Serialize;
703+
use serde_json::json;
704+
705+
#[derive(Serialize, SchemarsJsonSchema)]
706+
#[allow(dead_code)]
707+
enum Mode {
708+
Fast,
709+
Thorough,
710+
}
711+
712+
#[derive(Serialize, SchemarsJsonSchema)]
713+
#[allow(dead_code)]
714+
struct Inner {
715+
threshold: f64,
716+
mode: Mode,
717+
}
718+
719+
#[derive(Serialize, SchemarsJsonSchema)]
720+
#[allow(dead_code)]
721+
struct Outer {
722+
name: String,
723+
inner: Inner,
724+
tags: Vec<Mode>,
725+
}
726+
727+
#[test]
728+
fn nested_defs_are_inlined_with_constraints_intact() {
729+
let schema = schema_for!(Outer);
730+
let prop = schemars_param_schema(schema);
731+
let v = serde_json::to_value(&prop).unwrap();
732+
733+
// No dangling pointers and no orphaned defs.
734+
let rendered = v.to_string();
735+
assert!(
736+
!rendered.contains("$ref"),
737+
"local $refs must be inlined: {rendered}"
738+
);
739+
assert!(
740+
!rendered.contains("$defs") && !rendered.contains("definitions"),
741+
"defs must not leak into a property subschema: {rendered}"
742+
);
743+
744+
// The nested object tree survives with required + enum constraints.
745+
assert_eq!(v["type"], "object");
746+
assert_eq!(v["properties"]["inner"]["type"], "object");
747+
assert!(
748+
v["properties"]["inner"]["properties"]["threshold"]["type"] == "number",
749+
"nested numeric property must survive: {v}"
750+
);
751+
let mode = &v["properties"]["inner"]["properties"]["mode"];
752+
assert!(
753+
mode.to_string().contains("Fast") && mode.to_string().contains("Thorough"),
754+
"enum variants must survive inlining: {v}"
755+
);
756+
assert!(
757+
v["properties"]["tags"]["items"].is_object(),
758+
"array items must survive: {v}"
759+
);
760+
let required: Vec<_> = v["required"]
761+
.as_array()
762+
.unwrap()
763+
.iter()
764+
.filter_map(|r| r.as_str())
765+
.collect();
766+
assert!(required.contains(&"name") && required.contains(&"inner"));
767+
}
768+
769+
#[derive(Serialize, SchemarsJsonSchema)]
770+
#[serde(tag = "kind")]
771+
#[allow(dead_code)]
772+
enum Shape {
773+
Circle { radius: f64 },
774+
Rect { w: f64, h: f64 },
775+
}
776+
777+
#[derive(Serialize, SchemarsJsonSchema)]
778+
#[allow(dead_code)]
779+
struct Drawing {
780+
title: String,
781+
shape: Shape,
782+
accent: Option<Mode>,
783+
}
784+
785+
#[test]
786+
fn tagged_union_one_of_survives_the_pipeline() {
787+
// A data-bearing enum renders as oneOf-with-const-tag subschemas —
788+
// exactly the shape the old typed conversion DOWNGRADED (no
789+
// oneOf/const in the structured model). The pipeline must carry it.
790+
let schema = schema_for!(Drawing);
791+
let prop = schemars_param_schema(schema);
792+
let v = serde_json::to_value(&prop).unwrap();
793+
let shape = v["properties"]["shape"].to_string();
794+
assert!(
795+
shape.contains("oneOf") || shape.contains("anyOf"),
796+
"the tagged-union composition must survive: {v}"
797+
);
798+
assert!(
799+
shape.contains("Circle") && shape.contains("Rect"),
800+
"both variant tags must survive: {v}"
801+
);
802+
assert!(
803+
shape.contains("radius"),
804+
"variant payload properties must survive: {v}"
805+
);
806+
}
807+
808+
#[test]
809+
fn composition_keywords_survive_verbatim() {
810+
// oneOf/anyOf/allOf + const are exactly what the typed model used to
811+
// downgrade; the Raw path must carry them untouched.
812+
let value = json!({
813+
"oneOf": [
814+
{"type": "string", "const": "a"},
815+
{"type": "object", "properties": {"x": {"type": "integer"}}, "required": ["x"]}
816+
],
817+
"anyOf": [{"type": "string"}],
818+
"allOf": [{"minProperties": 1}]
819+
});
820+
let resolved = resolve_local_refs(&value, &HashMap::new()).unwrap();
821+
assert_eq!(
822+
resolved, value,
823+
"composition keywords are preserved verbatim"
824+
);
825+
}
826+
827+
#[test]
828+
fn cyclic_refs_are_rejected_not_silently_lost() {
829+
// A self-referential definition cannot be inlined into a property
830+
// subschema — the resolver must REJECT it (documented limitation),
831+
// never emit a dangling or truncated schema.
832+
let defs: HashMap<String, Value> = [(
833+
"Node".to_string(),
834+
json!({
835+
"type": "object",
836+
"properties": { "next": { "$ref": "#/$defs/Node" } }
837+
}),
838+
)]
839+
.into();
840+
let value = json!({ "$ref": "#/$defs/Node" });
841+
let err = resolve_local_refs(&value, &defs).unwrap_err();
842+
assert!(
843+
err.contains("cyclic"),
844+
"cycle must be named in the error: {err}"
845+
);
846+
}
847+
848+
#[test]
849+
fn non_local_refs_are_rejected() {
850+
// Network/external $refs MUST NOT be auto-dereferenced (spec) — and we
851+
// can't inline what we won't fetch: precise rejection.
852+
let value = json!({ "$ref": "https://example.com/schema.json" });
853+
let err = resolve_local_refs(&value, &HashMap::new()).unwrap_err();
854+
assert!(err.contains("non-local"), "{err}");
855+
}
856+
857+
#[test]
858+
fn ref_siblings_compose_via_all_of() {
859+
let defs: HashMap<String, Value> = [("S".to_string(), json!({"type": "string"}))].into();
860+
let value = json!({ "$ref": "#/$defs/S", "description": "named thing" });
861+
let resolved = resolve_local_refs(&value, &defs).unwrap();
862+
assert_eq!(resolved["description"], "named thing");
863+
assert_eq!(resolved["allOf"][0]["type"], "string");
864+
}
865+
866+
#[test]
867+
fn root_from_schemars_keeps_defs_for_verbatim_properties() {
868+
// The ToolSchema root path (whole-document conversion) keeps $defs at
869+
// the root, where verbatim property `#/$defs/X` pointers resolve.
870+
let schema = schema_for!(Outer);
871+
let tool_schema = ToolSchema::from_schemars(schema).unwrap();
872+
let v = serde_json::to_value(&tool_schema).unwrap();
873+
assert!(
874+
v.get("$defs").is_some() || !v.to_string().contains("$ref"),
875+
"either $defs ride at the root or no refs remain: {v}"
876+
);
877+
if let Some(defs) = v.get("$defs") {
878+
assert!(
879+
defs.get("Inner").is_some(),
880+
"Inner definition retained: {v}"
881+
);
882+
}
883+
}
884+
}

crates/turul-mcp-derive/src/tests/comprehensive_tests.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -252,8 +252,8 @@ mod schema_generation_tests {
252252
"Unknown types should use schemars for schema generation, got: {schema_str}"
253253
);
254254
assert!(
255-
contains_pattern(&schema_str, "convert_value_to_json_schema_with_defs"),
256-
"Should use the schemars-to-JsonSchema converter, got: {schema_str}"
255+
contains_pattern(&schema_str, "schemars_param_schema"),
256+
"Should use the lane-aware schemars conversion helper, got: {schema_str}"
257257
);
258258
}
259259

0 commit comments

Comments
 (0)