diff --git a/src/mcp/types.rs b/src/mcp/types.rs index cfeb004..c34e51d 100644 --- a/src/mcp/types.rs +++ b/src/mcp/types.rs @@ -1,14 +1,49 @@ use schemars::JsonSchema; +use schemars::transform::RecursiveTransform; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use crate::types::DailyStats; +/// Strips non-standard numeric `format` annotations from JSON Schemas. +/// +/// The `schemars` crate emits format values like `"uint64"`, `"int32"`, and `"double"` for Rust +/// numeric types. These are not defined by the JSON Schema specification and cause noisy warnings +/// in strict validators such as `ajv` (used by OpenCode and other MCP clients). +/// +/// See: +fn strip_non_standard_format(schema: &mut schemars::Schema) { + let dominated = schema + .get("format") + .and_then(|v| v.as_str()) + .is_some_and(|f| { + matches!( + f, + "uint8" + | "int8" + | "uint16" + | "int16" + | "uint32" + | "int32" + | "uint64" + | "int64" + | "uint" + | "int" + | "float" + | "double" + ) + }); + if dominated { + schema.remove("format"); + } +} + // ============================================================================ // Request Types // ============================================================================ #[derive(Debug, Clone, Deserialize, JsonSchema)] +#[schemars(transform = RecursiveTransform(strip_non_standard_format))] pub struct GetDailyStatsRequest { /// Filter by specific date (YYYY-MM-DD format). If omitted, returns all dates. #[serde(skip_serializing_if = "Option::is_none")] @@ -79,6 +114,7 @@ pub struct ListAnalyzersRequest {} // ============================================================================ #[derive(Debug, Clone, Serialize, JsonSchema)] +#[schemars(transform = RecursiveTransform(strip_non_standard_format))] pub struct DailySummary { pub date: String, pub user_messages: u32, @@ -136,24 +172,28 @@ impl DailySummary { } #[derive(Debug, Clone, Serialize, JsonSchema)] +#[schemars(transform = RecursiveTransform(strip_non_standard_format))] pub struct ModelUsageEntry { pub model: String, pub message_count: u32, } #[derive(Debug, Clone, Serialize, JsonSchema)] +#[schemars(transform = RecursiveTransform(strip_non_standard_format))] pub struct ModelUsageResponse { pub models: Vec, pub total_messages: u32, } #[derive(Debug, Clone, Serialize, JsonSchema)] +#[schemars(transform = RecursiveTransform(strip_non_standard_format))] pub struct DailyCost { pub date: String, pub cost: f64, } #[derive(Debug, Clone, Serialize, JsonSchema)] +#[schemars(transform = RecursiveTransform(strip_non_standard_format))] pub struct CostBreakdownResponse { pub total_cost: f64, pub daily_costs: Vec, @@ -161,6 +201,7 @@ pub struct CostBreakdownResponse { } #[derive(Debug, Clone, Default, Serialize, JsonSchema)] +#[schemars(transform = RecursiveTransform(strip_non_standard_format))] pub struct FileOpsResponse { pub files_read: u64, pub files_edited: u64, @@ -180,6 +221,7 @@ pub struct FileOpsResponse { } #[derive(Debug, Clone, Serialize, JsonSchema)] +#[schemars(transform = RecursiveTransform(strip_non_standard_format))] pub struct ToolSummary { pub name: String, pub total_cost: f64, @@ -198,3 +240,83 @@ pub struct ToolComparisonResponse { pub struct AnalyzerListResponse { pub analyzers: Vec, } + +#[cfg(test)] +mod tests { + use super::*; + use rmcp::serde_json; + use schemars::schema_for; + use schemars::transform::Transform; + + /// Formats that schemars emits for Rust numeric types but that are not + /// part of the JSON Schema specification. + const NON_STANDARD_FORMATS: &[&str] = &[ + "uint8", "int8", "uint16", "int16", "uint32", "int32", "uint64", "int64", "uint", "int", + "float", "double", + ]; + + /// Recursively check that no value in the JSON tree equals any of the + /// non-standard format strings. + fn assert_no_non_standard_formats(value: &serde_json::Value, path: &str) { + match value { + serde_json::Value::Object(map) => { + if let Some(fmt) = map.get("format").and_then(|v| v.as_str()) { + assert!( + !NON_STANDARD_FORMATS.contains(&fmt), + "found non-standard format \"{fmt}\" at {path}/format" + ); + } + for (key, val) in map { + assert_no_non_standard_formats(val, &format!("{path}/{key}")); + } + } + serde_json::Value::Array(arr) => { + for (i, val) in arr.iter().enumerate() { + assert_no_non_standard_formats(val, &format!("{path}[{i}]")); + } + } + _ => {} + } + } + + #[test] + fn mcp_schemas_contain_no_non_standard_formats() { + // Generate schemas for every MCP type that has numeric fields and + // verify the transform successfully stripped the non-standard format + // annotations. + let schemas: Vec<(&str, schemars::Schema)> = vec![ + ("GetDailyStatsRequest", schema_for!(GetDailyStatsRequest)), + ("DailySummary", schema_for!(DailySummary)), + ("ModelUsageEntry", schema_for!(ModelUsageEntry)), + ("ModelUsageResponse", schema_for!(ModelUsageResponse)), + ("DailyCost", schema_for!(DailyCost)), + ("CostBreakdownResponse", schema_for!(CostBreakdownResponse)), + ("FileOpsResponse", schema_for!(FileOpsResponse)), + ("ToolSummary", schema_for!(ToolSummary)), + ]; + + for (name, schema) in &schemas { + let value = serde_json::to_value(schema).expect("schema should serialize"); + assert_no_non_standard_formats(&value, &format!("#/{name}")); + } + } + + #[test] + fn strip_non_standard_format_is_selective() { + // Verify the transform only strips non-standard formats and leaves + // standard ones (like "date-time") untouched. + let mut schema = schemars::json_schema!({ + "type": "string", + "format": "date-time" + }); + + let mut transform = RecursiveTransform(super::strip_non_standard_format); + transform.transform(&mut schema); + + assert_eq!( + schema.get("format").and_then(|v| v.as_str()), + Some("date-time"), + "standard formats must not be stripped" + ); + } +}