Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions src/mcp/types.rs
Original file line number Diff line number Diff line change
@@ -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: <https://github.com/Piebald-AI/splitrail/issues/113>
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")]
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -136,31 +172,36 @@ 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<ModelUsageEntry>,
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<DailyCost>,
pub average_daily_cost: f64,
}

#[derive(Debug, Clone, Default, Serialize, JsonSchema)]
#[schemars(transform = RecursiveTransform(strip_non_standard_format))]
pub struct FileOpsResponse {
pub files_read: u64,
pub files_edited: u64,
Expand All @@ -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,
Expand All @@ -198,3 +240,83 @@ pub struct ToolComparisonResponse {
pub struct AnalyzerListResponse {
pub analyzers: Vec<String>,
}

#[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"
);
}
}