11use schemars:: JsonSchema ;
2+ use schemars:: transform:: RecursiveTransform ;
23use serde:: { Deserialize , Serialize } ;
34use std:: collections:: BTreeMap ;
45
56use crate :: types:: DailyStats ;
67
8+ /// Strips non-standard numeric `format` annotations from JSON Schemas.
9+ ///
10+ /// The `schemars` crate emits format values like `"uint64"`, `"int32"`, and `"double"` for Rust
11+ /// numeric types. These are not defined by the JSON Schema specification and cause noisy warnings
12+ /// in strict validators such as `ajv` (used by OpenCode and other MCP clients).
13+ ///
14+ /// See: <https://github.com/Piebald-AI/splitrail/issues/113>
15+ fn strip_non_standard_format ( schema : & mut schemars:: Schema ) {
16+ let dominated = schema
17+ . get ( "format" )
18+ . and_then ( |v| v. as_str ( ) )
19+ . is_some_and ( |f| {
20+ matches ! (
21+ f,
22+ "uint8"
23+ | "int8"
24+ | "uint16"
25+ | "int16"
26+ | "uint32"
27+ | "int32"
28+ | "uint64"
29+ | "int64"
30+ | "uint"
31+ | "int"
32+ | "float"
33+ | "double"
34+ )
35+ } ) ;
36+ if dominated {
37+ schema. remove ( "format" ) ;
38+ }
39+ }
40+
741// ============================================================================
842// Request Types
943// ============================================================================
1044
1145#[ derive( Debug , Clone , Deserialize , JsonSchema ) ]
46+ #[ schemars( transform = RecursiveTransform ( strip_non_standard_format) ) ]
1247pub struct GetDailyStatsRequest {
1348 /// Filter by specific date (YYYY-MM-DD format). If omitted, returns all dates.
1449 #[ serde( skip_serializing_if = "Option::is_none" ) ]
@@ -79,6 +114,7 @@ pub struct ListAnalyzersRequest {}
79114// ============================================================================
80115
81116#[ derive( Debug , Clone , Serialize , JsonSchema ) ]
117+ #[ schemars( transform = RecursiveTransform ( strip_non_standard_format) ) ]
82118pub struct DailySummary {
83119 pub date : String ,
84120 pub user_messages : u32 ,
@@ -136,31 +172,36 @@ impl DailySummary {
136172}
137173
138174#[ derive( Debug , Clone , Serialize , JsonSchema ) ]
175+ #[ schemars( transform = RecursiveTransform ( strip_non_standard_format) ) ]
139176pub struct ModelUsageEntry {
140177 pub model : String ,
141178 pub message_count : u32 ,
142179}
143180
144181#[ derive( Debug , Clone , Serialize , JsonSchema ) ]
182+ #[ schemars( transform = RecursiveTransform ( strip_non_standard_format) ) ]
145183pub struct ModelUsageResponse {
146184 pub models : Vec < ModelUsageEntry > ,
147185 pub total_messages : u32 ,
148186}
149187
150188#[ derive( Debug , Clone , Serialize , JsonSchema ) ]
189+ #[ schemars( transform = RecursiveTransform ( strip_non_standard_format) ) ]
151190pub struct DailyCost {
152191 pub date : String ,
153192 pub cost : f64 ,
154193}
155194
156195#[ derive( Debug , Clone , Serialize , JsonSchema ) ]
196+ #[ schemars( transform = RecursiveTransform ( strip_non_standard_format) ) ]
157197pub struct CostBreakdownResponse {
158198 pub total_cost : f64 ,
159199 pub daily_costs : Vec < DailyCost > ,
160200 pub average_daily_cost : f64 ,
161201}
162202
163203#[ derive( Debug , Clone , Default , Serialize , JsonSchema ) ]
204+ #[ schemars( transform = RecursiveTransform ( strip_non_standard_format) ) ]
164205pub struct FileOpsResponse {
165206 pub files_read : u64 ,
166207 pub files_edited : u64 ,
@@ -180,6 +221,7 @@ pub struct FileOpsResponse {
180221}
181222
182223#[ derive( Debug , Clone , Serialize , JsonSchema ) ]
224+ #[ schemars( transform = RecursiveTransform ( strip_non_standard_format) ) ]
183225pub struct ToolSummary {
184226 pub name : String ,
185227 pub total_cost : f64 ,
@@ -198,3 +240,83 @@ pub struct ToolComparisonResponse {
198240pub struct AnalyzerListResponse {
199241 pub analyzers : Vec < String > ,
200242}
243+
244+ #[ cfg( test) ]
245+ mod tests {
246+ use super :: * ;
247+ use rmcp:: serde_json;
248+ use schemars:: schema_for;
249+ use schemars:: transform:: Transform ;
250+
251+ /// Formats that schemars emits for Rust numeric types but that are not
252+ /// part of the JSON Schema specification.
253+ const NON_STANDARD_FORMATS : & [ & str ] = & [
254+ "uint8" , "int8" , "uint16" , "int16" , "uint32" , "int32" , "uint64" , "int64" , "uint" , "int" ,
255+ "float" , "double" ,
256+ ] ;
257+
258+ /// Recursively check that no value in the JSON tree equals any of the
259+ /// non-standard format strings.
260+ fn assert_no_non_standard_formats ( value : & serde_json:: Value , path : & str ) {
261+ match value {
262+ serde_json:: Value :: Object ( map) => {
263+ if let Some ( fmt) = map. get ( "format" ) . and_then ( |v| v. as_str ( ) ) {
264+ assert ! (
265+ !NON_STANDARD_FORMATS . contains( & fmt) ,
266+ "found non-standard format \" {fmt}\" at {path}/format"
267+ ) ;
268+ }
269+ for ( key, val) in map {
270+ assert_no_non_standard_formats ( val, & format ! ( "{path}/{key}" ) ) ;
271+ }
272+ }
273+ serde_json:: Value :: Array ( arr) => {
274+ for ( i, val) in arr. iter ( ) . enumerate ( ) {
275+ assert_no_non_standard_formats ( val, & format ! ( "{path}[{i}]" ) ) ;
276+ }
277+ }
278+ _ => { }
279+ }
280+ }
281+
282+ #[ test]
283+ fn mcp_schemas_contain_no_non_standard_formats ( ) {
284+ // Generate schemas for every MCP type that has numeric fields and
285+ // verify the transform successfully stripped the non-standard format
286+ // annotations.
287+ let schemas: Vec < ( & str , schemars:: Schema ) > = vec ! [
288+ ( "GetDailyStatsRequest" , schema_for!( GetDailyStatsRequest ) ) ,
289+ ( "DailySummary" , schema_for!( DailySummary ) ) ,
290+ ( "ModelUsageEntry" , schema_for!( ModelUsageEntry ) ) ,
291+ ( "ModelUsageResponse" , schema_for!( ModelUsageResponse ) ) ,
292+ ( "DailyCost" , schema_for!( DailyCost ) ) ,
293+ ( "CostBreakdownResponse" , schema_for!( CostBreakdownResponse ) ) ,
294+ ( "FileOpsResponse" , schema_for!( FileOpsResponse ) ) ,
295+ ( "ToolSummary" , schema_for!( ToolSummary ) ) ,
296+ ] ;
297+
298+ for ( name, schema) in & schemas {
299+ let value = serde_json:: to_value ( schema) . expect ( "schema should serialize" ) ;
300+ assert_no_non_standard_formats ( & value, & format ! ( "#/{name}" ) ) ;
301+ }
302+ }
303+
304+ #[ test]
305+ fn strip_non_standard_format_is_selective ( ) {
306+ // Verify the transform only strips non-standard formats and leaves
307+ // standard ones (like "date-time") untouched.
308+ let mut schema = schemars:: json_schema!( {
309+ "type" : "string" ,
310+ "format" : "date-time"
311+ } ) ;
312+
313+ let mut transform = RecursiveTransform ( super :: strip_non_standard_format) ;
314+ transform. transform ( & mut schema) ;
315+
316+ assert_eq ! (
317+ schema. get( "format" ) . and_then( |v| v. as_str( ) ) ,
318+ Some ( "date-time" ) ,
319+ "standard formats must not be stripped"
320+ ) ;
321+ }
322+ }
0 commit comments