mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-21 04:54:17 +02:00
Merge branch 'main' into claude/issue-3049-20260131-2232
This commit is contained in:
commit
b3b26e602c
9 changed files with 253 additions and 12 deletions
|
|
@ -19,6 +19,7 @@ from typing import (
|
|||
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ from mcp.types import (
|
|||
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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeVar, runtime_check
|
|||
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ from pydantic import (
|
|||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from urllib.parse import parse_qs, unquote
|
|||
|
||||
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 @@ class ResourceTemplate(FastMCPComponent):
|
|||
class FunctionResourceTemplate(ResourceTemplate):
|
||||
"""A template for dynamically creating resources."""
|
||||
|
||||
fn: Callable[..., Any]
|
||||
fn: SkipJsonSchema[Callable[..., Any]]
|
||||
|
||||
@overload
|
||||
async def _read(
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import anyio
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ from mcp.types import (
|
|||
)
|
||||
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[
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from mcp.types import ToolAnnotations
|
|||
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:
|
||||
|
|
|
|||
231
tests/test_json_schema_generation.py
Normal file
231
tests/test_json_schema_generation.py
Normal file
|
|
@ -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__}"
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue