diff --git a/src/fastmcp/prompts/function_prompt.py b/src/fastmcp/prompts/function_prompt.py index 4648c269fe..12d70ffbed 100644 --- a/src/fastmcp/prompts/function_prompt.py +++ b/src/fastmcp/prompts/function_prompt.py @@ -19,6 +19,7 @@ import pydantic_core from mcp.types import Icon +from pydantic.json_schema import SkipJsonSchema import fastmcp from fastmcp.decorators import resolve_task_config @@ -73,7 +74,7 @@ class PromptMeta: class FunctionPrompt(Prompt): """A prompt that is a function.""" - fn: Callable[..., Any] + fn: SkipJsonSchema[Callable[..., Any]] @classmethod def from_function( diff --git a/src/fastmcp/prompts/prompt.py b/src/fastmcp/prompts/prompt.py index 57bbecd56a..8cf2d3265b 100644 --- a/src/fastmcp/prompts/prompt.py +++ b/src/fastmcp/prompts/prompt.py @@ -25,6 +25,7 @@ from mcp.types import Prompt as SDKPrompt from mcp.types import PromptArgument as SDKPromptArgument from pydantic import Field +from pydantic.json_schema import SkipJsonSchema from fastmcp.server.tasks.config import TaskConfig, TaskMeta from fastmcp.tools.tool import AuthCheckCallable @@ -194,7 +195,7 @@ class Prompt(FastMCPComponent): arguments: list[PromptArgument] | None = Field( default=None, description="Arguments that can be passed to the prompt" ) - auth: AuthCheckCallable | list[AuthCheckCallable] | None = Field( + auth: SkipJsonSchema[AuthCheckCallable | list[AuthCheckCallable] | None] = Field( default=None, description="Authorization checks for this prompt", exclude=True ) diff --git a/src/fastmcp/resources/function_resource.py b/src/fastmcp/resources/function_resource.py index c6a881dab7..76c6f974c4 100644 --- a/src/fastmcp/resources/function_resource.py +++ b/src/fastmcp/resources/function_resource.py @@ -10,6 +10,7 @@ from mcp.types import Annotations, Icon from pydantic import AnyUrl +from pydantic.json_schema import SkipJsonSchema import fastmcp from fastmcp.decorators import resolve_task_config @@ -73,7 +74,7 @@ class FunctionResource(Resource): - other types will be converted to JSON """ - fn: Callable[..., Any] + fn: SkipJsonSchema[Callable[..., Any]] @classmethod def from_function( diff --git a/src/fastmcp/resources/resource.py b/src/fastmcp/resources/resource.py index 2aaca1863e..1f36fd5e28 100644 --- a/src/fastmcp/resources/resource.py +++ b/src/fastmcp/resources/resource.py @@ -26,6 +26,7 @@ field_validator, model_validator, ) +from pydantic.json_schema import SkipJsonSchema from typing_extensions import Self from fastmcp.server.tasks.config import TaskConfig, TaskMeta @@ -226,7 +227,7 @@ class Resource(FastMCPComponent): Field(description="Optional annotations about the resource's behavior"), ] = None auth: Annotated[ - AuthCheckCallable | list[AuthCheckCallable] | None, + SkipJsonSchema[AuthCheckCallable | list[AuthCheckCallable] | None], Field(description="Authorization checks for this resource", exclude=True), ] = None diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py index 02836e3982..8ce6500737 100644 --- a/src/fastmcp/resources/template.py +++ b/src/fastmcp/resources/template.py @@ -10,6 +10,7 @@ import mcp.types from mcp.types import Annotations, Icon +from pydantic.json_schema import SkipJsonSchema if TYPE_CHECKING: from docket import Docket @@ -116,7 +117,7 @@ class ResourceTemplate(FastMCPComponent): annotations: Annotations | None = Field( default=None, description="Optional annotations about the resource's behavior" ) - auth: AuthCheckCallable | list[AuthCheckCallable] | None = Field( + auth: SkipJsonSchema[AuthCheckCallable | list[AuthCheckCallable] | None] = Field( default=None, description="Authorization checks for this resource template", exclude=True, @@ -327,7 +328,7 @@ def get_span_attributes(self) -> dict[str, Any]: class FunctionResourceTemplate(ResourceTemplate): """A template for dynamically creating resources.""" - fn: Callable[..., Any] + fn: SkipJsonSchema[Callable[..., Any]] @overload async def _read( diff --git a/src/fastmcp/tools/function_tool.py b/src/fastmcp/tools/function_tool.py index 2c22fb8f2b..38d3116c6e 100644 --- a/src/fastmcp/tools/function_tool.py +++ b/src/fastmcp/tools/function_tool.py @@ -20,6 +20,7 @@ import mcp.types from mcp.shared.exceptions import McpError from mcp.types import ErrorData, Icon, ToolAnnotations, ToolExecution +from pydantic.json_schema import SkipJsonSchema import fastmcp from fastmcp.decorators import resolve_task_config @@ -81,7 +82,7 @@ class ToolMeta: class FunctionTool(Tool): - fn: Callable[..., Any] + fn: SkipJsonSchema[Callable[..., Any]] def to_mcp_tool( self, diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index 36b4913433..e13cda2808 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -24,6 +24,7 @@ ) from mcp.types import Tool as MCPTool from pydantic import BaseModel, Field, model_validator +from pydantic.json_schema import SkipJsonSchema from fastmcp.server.tasks.config import TaskConfig, TaskMeta from fastmcp.utilities.components import FastMCPComponent @@ -140,13 +141,13 @@ class Tool(FastMCPComponent): Field(description="Task execution configuration (SEP-1686)"), ] = None serializer: Annotated[ - ToolResultSerializerType | None, + SkipJsonSchema[ToolResultSerializerType | None], Field( description="Deprecated. Return ToolResult from your tools for full control over serialization." ), ] = None auth: Annotated[ - AuthCheckCallable | list[AuthCheckCallable] | None, + SkipJsonSchema[AuthCheckCallable | list[AuthCheckCallable] | None], Field(description="Authorization checks for this tool", exclude=True), ] = None timeout: Annotated[ diff --git a/src/fastmcp/tools/tool_transform.py b/src/fastmcp/tools/tool_transform.py index 17f1a256ce..85ea9e02ea 100644 --- a/src/fastmcp/tools/tool_transform.py +++ b/src/fastmcp/tools/tool_transform.py @@ -13,6 +13,7 @@ from pydantic import ConfigDict from pydantic.fields import Field from pydantic.functional_validators import BeforeValidator +from pydantic.json_schema import SkipJsonSchema import fastmcp from fastmcp.tools.function_parsing import ParsedFunction @@ -253,9 +254,11 @@ class TransformedTool(Tool): model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True) - parent_tool: Tool - fn: Callable[..., Any] - forwarding_fn: Callable[..., Any] # Always present, handles arg transformation + parent_tool: SkipJsonSchema[Tool] + fn: SkipJsonSchema[Callable[..., Any]] + forwarding_fn: SkipJsonSchema[ + Callable[..., Any] + ] # Always present, handles arg transformation transform_args: dict[str, ArgTransform] async def run(self, arguments: dict[str, Any]) -> ToolResult: diff --git a/tests/test_json_schema_generation.py b/tests/test_json_schema_generation.py new file mode 100644 index 0000000000..9cf10a0a50 --- /dev/null +++ b/tests/test_json_schema_generation.py @@ -0,0 +1,231 @@ +"""Tests for JSON schema generation from FastMCP BaseModel classes. + +Validates that callable fields are properly excluded from generated schemas +using SkipJsonSchema annotations. +""" + +from fastmcp.prompts.function_prompt import FunctionPrompt +from fastmcp.resources.function_resource import FunctionResource +from fastmcp.resources.template import FunctionResourceTemplate +from fastmcp.tools.function_tool import FunctionTool +from fastmcp.tools.tool import Tool +from fastmcp.tools.tool_transform import TransformedTool + + +class TestToolJsonSchema: + """Test JSON schema generation for Tool classes.""" + + def test_tool_json_schema_generation(self): + """Verify Tool.model_json_schema() works without errors.""" + # This should not raise an error + schema = Tool.model_json_schema() + + # Verify schema is valid + assert schema["type"] == "object" + assert "properties" in schema + + # Verify callable fields are excluded from schema + assert "serializer" not in schema["properties"] + # auth already uses exclude=True, so it shouldn't be in schema + assert "auth" not in schema["properties"] + + def test_function_tool_json_schema_generation(self): + """Verify FunctionTool.model_json_schema() works without errors.""" + + def sample_tool(x: int, y: int) -> int: + """Add two numbers.""" + return x + y + + tool = FunctionTool.from_function(sample_tool) + + # This should not raise an error + schema = tool.model_json_schema() + + # Verify schema is valid + assert schema["type"] == "object" + assert "properties" in schema + + # Verify callable field 'fn' is excluded from schema + assert "fn" not in schema["properties"] + + def test_transformed_tool_json_schema_generation(self): + """Verify TransformedTool.model_json_schema() works without errors.""" + + def parent_fn(x: int) -> int: + return x * 2 + + parent_tool = FunctionTool.from_function(parent_fn) + transformed_tool = TransformedTool.from_tool(parent_tool, name="doubled") + + # This should not raise an error + schema = transformed_tool.model_json_schema() + + # Verify schema is valid + assert schema["type"] == "object" + assert "properties" in schema + + # Verify callable fields are excluded from schema + assert "fn" not in schema["properties"] + assert "forwarding_fn" not in schema["properties"] + assert "parent_tool" not in schema["properties"] + + +class TestResourceJsonSchema: + """Test JSON schema generation for Resource classes.""" + + def test_function_resource_json_schema_generation(self): + """Verify FunctionResource.model_json_schema() works without errors.""" + + def sample_resource() -> str: + """Return sample data.""" + return "Hello, world!" + + resource = FunctionResource.from_function( + sample_resource, uri="test://resource" + ) + + # This should not raise an error + schema = resource.model_json_schema() + + # Verify schema is valid + assert schema["type"] == "object" + assert "properties" in schema + + # Verify callable field 'fn' is excluded from schema + assert "fn" not in schema["properties"] + # auth already uses exclude=True + assert "auth" not in schema["properties"] + + def test_function_resource_template_json_schema_generation(self): + """Verify FunctionResourceTemplate.model_json_schema() works without errors.""" + + def sample_template(name: str) -> str: + """Return greeting for name.""" + return f"Hello, {name}!" + + template = FunctionResourceTemplate.from_function( + sample_template, uri_template="greeting://{name}" + ) + + # This should not raise an error + schema = template.model_json_schema() + + # Verify schema is valid + assert schema["type"] == "object" + assert "properties" in schema + + # Verify callable field 'fn' is excluded from schema + assert "fn" not in schema["properties"] + + +class TestPromptJsonSchema: + """Test JSON schema generation for Prompt classes.""" + + def test_function_prompt_json_schema_generation(self): + """Verify FunctionPrompt.model_json_schema() works without errors.""" + + def sample_prompt(topic: str) -> str: + """Generate prompt about topic.""" + return f"Tell me about {topic}" + + prompt = FunctionPrompt.from_function(sample_prompt) + + # This should not raise an error + schema = prompt.model_json_schema() + + # Verify schema is valid + assert schema["type"] == "object" + assert "properties" in schema + + # Verify callable field 'fn' is excluded from schema + assert "fn" not in schema["properties"] + # auth already uses exclude=True + assert "auth" not in schema["properties"] + + +class TestJsonSchemaIntegration: + """Integration tests for JSON schema generation across all classes.""" + + def test_all_classes_generate_valid_schemas(self): + """Verify all affected classes can generate valid JSON schemas.""" + + # Create instances of all affected classes + def tool_fn(x: int) -> int: + return x + + def resource_fn() -> str: + return "data" + + def template_fn(id: str) -> str: + return f"data-{id}" + + def prompt_fn(input: str) -> str: + return f"Prompt: {input}" + + tool = FunctionTool.from_function(tool_fn) + transformed_tool = TransformedTool.from_tool(tool) + resource = FunctionResource.from_function(resource_fn, uri="test://resource") + template = FunctionResourceTemplate.from_function( + template_fn, uri_template="test://{id}" + ) + prompt = FunctionPrompt.from_function(prompt_fn) + + # All of these should succeed without errors + schemas = [ + Tool.model_json_schema(), + tool.model_json_schema(), + transformed_tool.model_json_schema(), + resource.model_json_schema(), + template.model_json_schema(), + prompt.model_json_schema(), + ] + + # Verify all schemas are valid + for schema in schemas: + assert isinstance(schema, dict) + assert schema["type"] == "object" + assert "properties" in schema + + def test_callable_fields_not_in_any_schema(self): + """Verify no callable fields appear in any generated schema.""" + + # Define test functions + def tool_fn(x: int) -> int: + return x + + def resource_fn() -> str: + return "data" + + def template_fn(id: str) -> str: + return f"data-{id}" + + def prompt_fn(input: str) -> str: + return f"Prompt: {input}" + + # Create instances + tool = FunctionTool.from_function(tool_fn) + transformed_tool = TransformedTool.from_tool(tool) + resource = FunctionResource.from_function(resource_fn, uri="test://resource") + template = FunctionResourceTemplate.from_function( + template_fn, uri_template="test://{id}" + ) + prompt = FunctionPrompt.from_function(prompt_fn) + + # List of (instance, callable_field_names) tuples + test_cases = [ + (tool, ["fn", "serializer"]), + (transformed_tool, ["fn", "forwarding_fn", "parent_tool", "serializer"]), + (resource, ["fn"]), + (template, ["fn"]), + (prompt, ["fn"]), + ] + + for instance, callable_fields in test_cases: + schema = instance.model_json_schema() + properties = schema.get("properties", {}) + + # Verify none of the callable fields are in the schema + for field in callable_fields: + assert field not in properties, ( + f"Callable field '{field}' found in schema for {type(instance).__name__}" + )