Skip to content
Merged
Show file tree
Hide file tree
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
3 changes: 2 additions & 1 deletion src/fastmcp/prompts/function_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
3 changes: 2 additions & 1 deletion src/fastmcp/prompts/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
)

Expand Down
3 changes: 2 additions & 1 deletion src/fastmcp/resources/function_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
3 changes: 2 additions & 1 deletion src/fastmcp/resources/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
5 changes: 3 additions & 2 deletions src/fastmcp/resources/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
3 changes: 2 additions & 1 deletion src/fastmcp/tools/function_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -81,7 +82,7 @@ class ToolMeta:


class FunctionTool(Tool):
fn: Callable[..., Any]
fn: SkipJsonSchema[Callable[..., Any]]

def to_mcp_tool(
self,
Expand Down
5 changes: 3 additions & 2 deletions src/fastmcp/tools/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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[
Expand Down
9 changes: 6 additions & 3 deletions src/fastmcp/tools/tool_transform.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
231 changes: 231 additions & 0 deletions tests/test_json_schema_generation.py
Original file line number Diff line number Diff line change
@@ -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__}"
)