diff --git a/AGENTS.md b/AGENTS.md index 3681e0a..1a17210 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,9 +25,13 @@ for human and operational workflows; content and section mutations are not CLI commands. Every MCP structured result must have an object root, and each advertised output -schema must be precise and derived from its boundary DTO. Arbitrary JSON schema -terms must use object form rather than bare boolean terms. Every MCP change must -pass the repository-wide strict-client output-schema contract test. +schema must be precise and derived from its boundary DTO's serialized JSON +contract: fields, requiredness, JSON types, value and structural constraints, +and references. `format` annotations are intentionally omitted: client support +is nonportable, so they do not establish a client-facing validation or UI +contract; server-side validation is authoritative. Arbitrary JSON schema terms +must use object form rather than bare boolean terms. Every MCP change must pass +the repository-wide strict-client output-schema contract test. ## Build & Test diff --git a/flicknote-cli/src/main_tests/mcp.rs b/flicknote-cli/src/main_tests/mcp.rs index 82c2b48..3d40ac2 100644 --- a/flicknote-cli/src/main_tests/mcp.rs +++ b/flicknote-cli/src/main_tests/mcp.rs @@ -474,6 +474,26 @@ fn assert_no_boolean_schema_terms(schema: &serde_json::Value, tool: &str) { } } +fn assert_no_schema_formats(schema: &serde_json::Value, tool: &str) { + match schema { + serde_json::Value::Object(map) => { + assert!( + !map.contains_key("format"), + "tool {tool}: schema contains a format annotation: {schema}" + ); + for value in map.values() { + assert_no_schema_formats(value, tool); + } + } + serde_json::Value::Array(values) => { + for value in values { + assert_no_schema_formats(value, tool); + } + } + _ => {} + } +} + #[tokio::test] async fn mcp_tool_output_schemas_are_strict_client_compatible() { let mut harness = McpHarness::start().await; @@ -487,6 +507,8 @@ async fn mcp_tool_output_schemas_are_strict_client_compatible() { "tool {name}: outputSchema root type must be object" ); assert_no_boolean_schema_terms(output, name); + assert_no_schema_formats(output, name); + assert_no_schema_formats(&tool["inputSchema"], name); } let list = tools diff --git a/flicknote-cli/src/mcp/server.rs b/flicknote-cli/src/mcp/server.rs index 8402058..f63e6d4 100644 --- a/flicknote-cli/src/mcp/server.rs +++ b/flicknote-cli/src/mcp/server.rs @@ -75,10 +75,21 @@ impl FlickNoteMcp { pub(crate) fn new(config: Arc) -> Self { Self { config, - tool_router: Self::tool_router(), + tool_router: Self::normalized_tool_router(), } } + fn normalized_tool_router() -> ToolRouter { + let mut router = Self::tool_router(); + for route in router.map.values_mut() { + normalize_schema_formats(Arc::make_mut(&mut route.attr.input_schema)); + if let Some(output_schema) = &mut route.attr.output_schema { + normalize_schema_formats(Arc::make_mut(output_schema)); + } + } + router + } + async fn call(&self, request: AppRequest) -> Result { DaemonClient::new(&self.config).call(request).await } @@ -100,6 +111,33 @@ impl FlickNoteMcp { } } +/// Remove generator-specific format annotations from advertised MCP schemas. +/// +/// The server remains the authority for value validation; MCP clients do not +/// provide a useful user-facing behavior for these annotations. +fn normalize_schema_formats(schema: &mut serde_json::Map) { + fn visit(value: &mut serde_json::Value) { + match value { + serde_json::Value::Object(object) => { + object.remove("format"); + for value in object.values_mut() { + visit(value); + } + } + serde_json::Value::Array(values) => { + for value in values { + visit(value); + } + } + _ => {} + } + } + + for value in schema.values_mut() { + visit(value); + } +} + fn structured(result: Result) -> Result, CallToolResult> { result.map(Json).map_err(|error| tool_error(&error)) }