Skip to content

Commit f2bd2fc

Browse files
authored
fix: strip non-standard numeric format annotations from MCP JSON schemas (#114)
schemars emits format values like "uint64", "int32", and "double" for Rust numeric types. These are not defined by the JSON Schema spec and cause noisy warnings in strict validators such as ajv (used by OpenCode). Add a RecursiveTransform that strips these non-standard format annotations from all MCP request/response types that contain numeric fields. Closes #113
1 parent 4d07acc commit f2bd2fc

1 file changed

Lines changed: 122 additions & 0 deletions

File tree

src/mcp/types.rs

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,49 @@
11
use schemars::JsonSchema;
2+
use schemars::transform::RecursiveTransform;
23
use serde::{Deserialize, Serialize};
34
use std::collections::BTreeMap;
45

56
use 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))]
1247
pub 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))]
82118
pub 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))]
139176
pub 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))]
145183
pub 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))]
151190
pub 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))]
157197
pub 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))]
164205
pub 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))]
183225
pub struct ToolSummary {
184226
pub name: String,
185227
pub total_cost: f64,
@@ -198,3 +240,83 @@ pub struct ToolComparisonResponse {
198240
pub 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

Comments
 (0)