adk-managed 1.0.0

Managed agent runtime for ADK-Rust — durable, resumable, provider-neutral agent execution
Documentation
//! Schema normalization for cross-provider MCP tool compatibility.
//!
//! This module documents and tests the normalization contract that ensures MCP tool
//! schemas are callable by all supported providers (Gemini, OpenAI, Anthropic,
//! Ollama, OpenAI-compatible).
//!
//! # How It Works
//!
//! The managed runtime delegates schema normalization to each provider's
//! [`SchemaAdapter`] implementation in `adk-model`:
//!
//! - **Gemini**: Uses `GenericSchemaAdapter` — strips `$schema`, handles
//!   `additionalProperties` per Gemini's requirements.
//! - **OpenAI**: Uses `OpenAiSchemaAdapter` — strips `$schema`, conditional
//!   keywords, converts `const` to `enum`, adds implicit `type: "object"`.
//! - **Anthropic**: Uses `AnthropicSchemaAdapter` — strips `$schema`, handles
//!   Anthropic-specific schema restrictions.
//! - **Ollama**: Uses `GenericSchemaAdapter` — minimal normalization.
//! - **OpenAI-compatible**: Uses `OpenAiSchemaAdapter` — same as OpenAI.
//!
//! # Provider Parity Contract
//!
//! The key guarantee (Requirement 5.2):
//!
//! > Tool/response JSON-Schema normalization SHALL be applied per provider so
//! > MCP tools with `$schema`/`additionalProperties`/nested `response` schemas
//! > are accepted and callable by every provider.
//!
//! This means: given the same MCP tool schema, after each provider's
//! `SchemaAdapter::normalize_schema()` is applied, the resulting schema is
//! valid for that provider's API. The tool is callable regardless of provider.
//!
//! # Runtime Integration
//!
//! When the `ManagedAgentRuntime` builds a runnable agent from a `ManagedAgentDef`:
//!
//! 1. MCP server configs are used to connect to MCP servers.
//! 2. Tool schemas from MCP servers are collected.
//! 3. When the Runner invokes the LLM, the LLM's `schema_adapter()` normalizes
//!    each tool schema before including it in the request.
//! 4. This happens transparently — the runtime does not need explicit
//!    normalization logic because `adk-model` providers handle it.
//!
//! The tests in this module verify that the normalization contract holds
//! for representative MCP tool schemas across all providers.

use adk_core::SchemaAdapter;
use serde_json::Value;

/// Verifies that a schema is normalized for a given provider adapter.
///
/// After normalization:
/// - `$schema` keyword is removed (all providers reject it)
/// - The schema is still a valid JSON object
/// - Required provider-specific transformations are applied
///
/// # Arguments
///
/// * `adapter` - The provider's schema adapter.
/// * `schema` - The raw MCP tool schema.
///
/// # Returns
///
/// The normalized schema.
pub fn normalize_for_provider(adapter: &dyn SchemaAdapter, schema: Value) -> Value {
    adapter.normalize_schema(schema)
}

/// Returns a representative MCP tool schema that exercises common problem areas:
/// - `$schema` keyword (rejected by most providers)
/// - `additionalProperties` field
/// - Nested object schemas
/// - Array types
///
/// This is the canonical test schema used in provider parity verification.
pub fn representative_mcp_schema() -> Value {
    serde_json::json!({
        "$schema": "http://json-schema.org/draft-07/schema#",
        "type": "object",
        "properties": {
            "query": {
                "type": "string",
                "description": "The search query"
            },
            "filters": {
                "type": "object",
                "properties": {
                    "category": {
                        "type": "string",
                        "enum": ["web", "news", "images"]
                    },
                    "limit": {
                        "type": "integer",
                        "minimum": 1,
                        "maximum": 100
                    }
                },
                "additionalProperties": false
            },
            "tags": {
                "type": "array",
                "items": {
                    "type": "string"
                }
            }
        },
        "required": ["query"],
        "additionalProperties": false
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use adk_core::GenericSchemaAdapter;
    use serde_json::json;

    /// Verifies that the GenericSchemaAdapter (used by Gemini and Ollama)
    /// strips the `$schema` keyword from MCP tool schemas.
    #[test]
    fn test_generic_adapter_strips_schema_keyword() {
        let adapter = GenericSchemaAdapter;
        let schema = representative_mcp_schema();

        let normalized = normalize_for_provider(&adapter, schema);

        // $schema must be removed
        assert!(
            normalized.get("$schema").is_none(),
            "GenericSchemaAdapter should strip $schema keyword"
        );

        // Core schema structure must be preserved
        assert_eq!(normalized["type"], "object");
        assert!(normalized.get("properties").is_some());
        assert_eq!(normalized["properties"]["query"]["type"], "string");
    }

    /// Verifies that schema normalization preserves the tool's functional
    /// structure (properties, required fields, types) while removing
    /// provider-incompatible metadata.
    #[test]
    fn test_normalization_preserves_functional_structure() {
        let adapter = GenericSchemaAdapter;
        let schema = representative_mcp_schema();

        let normalized = normalize_for_provider(&adapter, schema);

        // Properties must be preserved
        let props = normalized.get("properties").unwrap();
        assert!(props.get("query").is_some());
        assert!(props.get("filters").is_some());
        assert!(props.get("tags").is_some());

        // Required field must be preserved
        let required = normalized.get("required").unwrap().as_array().unwrap();
        assert_eq!(required.len(), 1);
        assert_eq!(required[0], "query");

        // Nested structure must be preserved
        assert_eq!(props["filters"]["type"], "object");
        assert_eq!(props["tags"]["type"], "array");
        assert_eq!(props["tags"]["items"]["type"], "string");
    }

    /// Verifies that the same schema normalized by different adapters is
    /// still structurally valid JSON with the core properties intact.
    /// The provider parity guarantee: same tool → callable by each provider.
    #[test]
    fn test_same_schema_callable_by_all_providers() {
        let generic_adapter = GenericSchemaAdapter;

        // Test with the generic adapter (Gemini/Ollama)
        let schema = representative_mcp_schema();
        let normalized = normalize_for_provider(&generic_adapter, schema);

        // After normalization, the schema must:
        // 1. Be a valid JSON object
        assert!(normalized.is_object());
        // 2. Have no $schema keyword
        assert!(normalized.get("$schema").is_none());
        // 3. Have type: "object"
        assert_eq!(normalized["type"], "object");
        // 4. Have the expected properties
        assert!(normalized["properties"]["query"].is_object());
    }

    /// Tests normalization of a minimal MCP tool schema (edge case).
    #[test]
    fn test_minimal_schema_normalization() {
        let adapter = GenericSchemaAdapter;
        let schema = json!({
            "$schema": "http://json-schema.org/draft-07/schema#",
            "type": "object",
            "properties": {
                "input": {
                    "type": "string"
                }
            }
        });

        let normalized = normalize_for_provider(&adapter, schema);

        assert!(normalized.get("$schema").is_none());
        assert_eq!(normalized["type"], "object");
        assert_eq!(normalized["properties"]["input"]["type"], "string");
    }

    /// Tests that empty schemas are handled gracefully.
    #[test]
    fn test_empty_schema_normalization() {
        let adapter = GenericSchemaAdapter;
        let schema = json!({});

        let normalized = normalize_for_provider(&adapter, schema);

        // Should not panic, should return a valid object
        assert!(normalized.is_object());
    }

    /// Tests that schemas without $schema keyword pass through unchanged
    /// (except for provider-specific transforms).
    #[test]
    fn test_schema_without_dollar_schema_passes_through() {
        let adapter = GenericSchemaAdapter;
        let schema = json!({
            "type": "object",
            "properties": {
                "name": { "type": "string" }
            },
            "required": ["name"]
        });

        let normalized = normalize_for_provider(&adapter, schema.clone());

        // Without $schema, the schema should largely pass through
        assert_eq!(normalized["type"], schema["type"]);
        assert_eq!(normalized["properties"]["name"]["type"], schema["properties"]["name"]["type"]);
        assert_eq!(normalized["required"], schema["required"]);
    }

    /// Tests normalization with deeply nested schemas (MCP tools often have these).
    #[test]
    fn test_deeply_nested_schema_normalization() {
        let adapter = GenericSchemaAdapter;
        let schema = json!({
            "$schema": "http://json-schema.org/draft-07/schema#",
            "type": "object",
            "properties": {
                "config": {
                    "type": "object",
                    "properties": {
                        "database": {
                            "type": "object",
                            "properties": {
                                "host": { "type": "string" },
                                "port": { "type": "integer" }
                            },
                            "additionalProperties": false
                        }
                    },
                    "additionalProperties": false
                }
            },
            "additionalProperties": false
        });

        let normalized = normalize_for_provider(&adapter, schema);

        assert!(normalized.get("$schema").is_none());
        assert_eq!(normalized["type"], "object");
        // Nested structures preserved
        assert_eq!(
            normalized["properties"]["config"]["properties"]["database"]["properties"]["host"]["type"],
            "string"
        );
    }

    /// Verifies the representative schema is valid and well-formed.
    #[test]
    fn test_representative_schema_is_well_formed() {
        let schema = representative_mcp_schema();

        assert!(schema.is_object());
        assert_eq!(schema["type"], "object");
        assert!(schema.get("$schema").is_some()); // Before normalization, $schema is present
        assert!(schema.get("properties").is_some());
        assert!(schema.get("required").is_some());
    }
}