diff --git a/src/mcp/cli/claude.py b/src/mcp/cli/claude.py index 17c957df..1629f928 100644 --- a/src/mcp/cli/claude.py +++ b/src/mcp/cli/claude.py @@ -31,6 +31,7 @@ def get_claude_config_path() -> Path | None: return path return None + def get_uv_path() -> str: """Get the full path to the uv executable.""" uv_path = shutil.which("uv") @@ -42,6 +43,7 @@ def get_uv_path() -> str: return "uv" # Fall back to just "uv" if not found return uv_path + def update_claude_config( file_spec: str, server_name: str, diff --git a/src/mcp/server/fastmcp/server.py b/src/mcp/server/fastmcp/server.py index c31f29d4..8d37beec 100644 --- a/src/mcp/server/fastmcp/server.py +++ b/src/mcp/server/fastmcp/server.py @@ -52,6 +52,7 @@ from mcp.shared.context import LifespanContextT, RequestContext from mcp.types import ( AnyFunction, + DataContent, EmbeddedResource, GetPromptResult, ImageContent, @@ -251,6 +252,7 @@ async def list_tools(self) -> list[MCPTool]: name=info.name, description=info.description, inputSchema=info.parameters, + outputSchema=info.output, annotations=info.annotations, ) for info in tools @@ -269,7 +271,7 @@ def get_context(self) -> Context[ServerSession, object]: async def call_tool( self, name: str, arguments: dict[str, Any] - ) -> Sequence[TextContent | ImageContent | EmbeddedResource]: + ) -> Sequence[TextContent | DataContent | ImageContent | EmbeddedResource]: """Call a tool by name with arguments.""" context = self.get_context() result = await self._tool_manager.call_tool(name, arguments, context=context) @@ -869,12 +871,12 @@ async def get_prompt( def _convert_to_content( result: Any, -) -> Sequence[TextContent | ImageContent | EmbeddedResource]: +) -> Sequence[TextContent | ImageContent | EmbeddedResource | DataContent]: """Convert a result to a sequence of content objects.""" if result is None: return [] - if isinstance(result, TextContent | ImageContent | EmbeddedResource): + if isinstance(result, TextContent | ImageContent | EmbeddedResource | DataContent): return [result] if isinstance(result, Image): diff --git a/src/mcp/server/fastmcp/tools/base.py b/src/mcp/server/fastmcp/tools/base.py index 21eb1841..8d201759 100644 --- a/src/mcp/server/fastmcp/tools/base.py +++ b/src/mcp/server/fastmcp/tools/base.py @@ -8,7 +8,7 @@ from mcp.server.fastmcp.exceptions import ToolError from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata, func_metadata -from mcp.types import ToolAnnotations +from mcp.types import DataContent, ToolAnnotations if TYPE_CHECKING: from mcp.server.fastmcp.server import Context @@ -23,6 +23,10 @@ class Tool(BaseModel): name: str = Field(description="Name of the tool") description: str = Field(description="Description of what the tool does") parameters: dict[str, Any] = Field(description="JSON schema for tool parameters") + output: dict[str, Any] | None = Field( + description="JSON schema for tool output", + default=None, + ) fn_metadata: FuncMetadata = Field( description="Metadata about the function including a pydantic model for tool" " arguments" @@ -69,12 +73,14 @@ def from_function( skip_names=[context_kwarg] if context_kwarg is not None else [], ) parameters = func_arg_metadata.arg_model.model_json_schema() + output = func_arg_metadata.output_schema return cls( fn=fn, name=func_name, description=func_doc, parameters=parameters, + output=output, fn_metadata=func_arg_metadata, is_async=is_async, context_kwarg=context_kwarg, @@ -88,7 +94,7 @@ async def run( ) -> Any: """Run the tool with arguments.""" try: - return await self.fn_metadata.call_fn_with_arg_validation( + result = await self.fn_metadata.call_fn_with_arg_validation( self.fn, self.is_async, arguments, @@ -96,5 +102,12 @@ async def run( if self.context_kwarg is not None else None, ) + if self.output and self.output.get("type") == "object": + return DataContent( + type="data", + data=result, + ) + else: + return result except Exception as e: raise ToolError(f"Error executing tool {self.name}: {e}") from e diff --git a/src/mcp/server/fastmcp/utilities/func_metadata.py b/src/mcp/server/fastmcp/utilities/func_metadata.py index 37439132..628cf45e 100644 --- a/src/mcp/server/fastmcp/utilities/func_metadata.py +++ b/src/mcp/server/fastmcp/utilities/func_metadata.py @@ -7,12 +7,20 @@ ForwardRef, ) -from pydantic import BaseModel, ConfigDict, Field, WithJsonSchema, create_model +from pydantic import ( + BaseModel, + ConfigDict, + Field, + TypeAdapter, + WithJsonSchema, + create_model, +) from pydantic._internal._typing_extra import eval_type_backport from pydantic.fields import FieldInfo from pydantic_core import PydanticUndefined from mcp.server.fastmcp.exceptions import InvalidSignature +from mcp.server.fastmcp.utilities import types from mcp.server.fastmcp.utilities.logging import get_logger logger = get_logger(__name__) @@ -38,6 +46,7 @@ def model_dump_one_level(self) -> dict[str, Any]: class FuncMetadata(BaseModel): arg_model: Annotated[type[ArgModelBase], WithJsonSchema(None)] + output_schema: dict[str, Any] | None # We can add things in the future like # - Maybe some args are excluded from attempting to parse from JSON # - Maybe some args are special (like context) for dependency injection @@ -128,6 +137,7 @@ def func_metadata( sig = _get_typed_signature(func) params = sig.parameters dynamic_pydantic_model_params: dict[str, Any] = {} + output_schema: dict[str, Any] | None = None globalns = getattr(func, "__globals__", {}) for param in params.values(): if param.name.startswith("_"): @@ -172,8 +182,13 @@ def func_metadata( **dynamic_pydantic_model_params, __base__=ArgModelBase, ) - resp = FuncMetadata(arg_model=arguments_model) - return resp + + # TODO this could be moved to a constant or passed in as param as per skip_names + ignore = [inspect.Parameter.empty, None, types.Image] + if sig.return_annotation not in ignore: + output_schema = TypeAdapter(sig.return_annotation).json_schema() + + return FuncMetadata(arg_model=arguments_model, output_schema=output_schema) def _get_typed_annotation(annotation: Any, globalns: dict[str, Any]) -> Any: @@ -210,5 +225,6 @@ def _get_typed_signature(call: Callable[..., Any]) -> inspect.Signature: ) for param in signature.parameters.values() ] - typed_signature = inspect.Signature(typed_params) + typed_return = _get_typed_annotation(signature.return_annotation, globalns) + typed_signature = inspect.Signature(typed_params, return_annotation=typed_return) return typed_signature diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 4b97b33d..ece295b6 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -399,7 +399,10 @@ def decorator( ..., Awaitable[ Iterable[ - types.TextContent | types.ImageContent | types.EmbeddedResource + types.TextContent + | types.DataContent + | types.ImageContent + | types.EmbeddedResource ] ], ], diff --git a/src/mcp/types.py b/src/mcp/types.py index 6ab7fba5..9865aa5e 100644 --- a/src/mcp/types.py +++ b/src/mcp/types.py @@ -646,6 +646,21 @@ class ImageContent(BaseModel): model_config = ConfigDict(extra="allow") +class DataContent(BaseModel): + """Data content for a message.""" + + type: Literal["data"] + data: Any + """The JSON serializable object containing structured data.""" + schema_: str | Any | None = Field(serialization_alias="schema", default=None) + + """ + Optional reference to a JSON schema that describes the structure of the data. + """ + annotations: Annotations | None = None + model_config = ConfigDict(extra="allow") + + class SamplingMessage(BaseModel): """Describes a message issued to or received from an LLM API.""" @@ -762,6 +777,8 @@ class Tool(BaseModel): """A human-readable description of the tool.""" inputSchema: dict[str, Any] """A JSON Schema object defining the expected parameters for the tool.""" + outputSchema: dict[str, Any] | None = None + """A JSON Schema object defining the expected outputs for the tool.""" annotations: ToolAnnotations | None = None """Optional additional tool information.""" model_config = ConfigDict(extra="allow") @@ -791,7 +808,7 @@ class CallToolRequest(Request[CallToolRequestParams, Literal["tools/call"]]): class CallToolResult(Result): """The server's response to a tool call.""" - content: list[TextContent | ImageContent | EmbeddedResource] + content: list[TextContent | DataContent | ImageContent | EmbeddedResource] isError: bool = False diff --git a/tests/client/test_config.py b/tests/client/test_config.py index 6577d663..9f1cd8ee 100644 --- a/tests/client/test_config.py +++ b/tests/client/test_config.py @@ -54,7 +54,7 @@ def test_absolute_uv_path(mock_config_path: Path): """Test that the absolute path to uv is used when available.""" # Mock the shutil.which function to return a fake path mock_uv_path = "/usr/local/bin/uv" - + with patch("mcp.cli.claude.get_uv_path", return_value=mock_uv_path): # Setup server_name = "test_server" @@ -71,5 +71,5 @@ def test_absolute_uv_path(mock_config_path: Path): # Verify the command is the absolute path server_config = config["mcpServers"][server_name] command = server_config["command"] - - assert command == mock_uv_path \ No newline at end of file + + assert command == mock_uv_path diff --git a/tests/server/fastmcp/test_func_metadata.py b/tests/server/fastmcp/test_func_metadata.py index b1828ffe..cdd05f4f 100644 --- a/tests/server/fastmcp/test_func_metadata.py +++ b/tests/server/fastmcp/test_func_metadata.py @@ -7,11 +7,11 @@ from mcp.server.fastmcp.utilities.func_metadata import func_metadata -class SomeInputModelA(BaseModel): +class SomeModelA(BaseModel): pass -class SomeInputModelB(BaseModel): +class SomeModelB(BaseModel): class InnerModel(BaseModel): x: int @@ -46,15 +46,15 @@ def complex_arguments_fn( int, Field(1) ], unannotated, - my_model_a: SomeInputModelA, - my_model_a_forward_ref: "SomeInputModelA", - my_model_b: SomeInputModelB, + my_model_a: SomeModelA, + my_model_a_forward_ref: "SomeModelA", + my_model_b: SomeModelB, an_int_annotated_with_field_default: Annotated[ int, Field(1, description="An int with a field"), ], unannotated_with_default=5, - my_model_a_with_default: SomeInputModelA = SomeInputModelA(), # noqa: B008 + my_model_a_with_default: SomeModelA = SomeModelA(), # noqa: B008 an_int_with_default: int = 1, must_be_none_with_default: None = None, an_int_with_equals_field: int = Field(1, ge=0), @@ -85,6 +85,30 @@ def complex_arguments_fn( return "ok!" +def simple_no_annotation_fun(): + return "ok" + + +def simple_str_fun() -> str: + return "ok" + + +def simple_bool_fun() -> bool: + return True + + +def simple_int_fun() -> int: + return 1 + + +def simple_float_fun() -> float: + return 1.0 + + +def complex_model_fun() -> SomeModelB: + return SomeModelB(how_many_shrimp=1, ok=SomeModelB.InnerModel(x=2), y=None) + + @pytest.mark.anyio async def test_complex_function_runtime_arg_validation_non_json(): """Test that basic non-JSON arguments are validated correctly""" @@ -269,7 +293,7 @@ def test_complex_function_json_schema(): # Normalize the my_model_a_with_default field to handle both pydantic formats if "allOf" in actual_schema["properties"]["my_model_a_with_default"]: normalized_schema["properties"]["my_model_a_with_default"] = { - "$ref": "#/$defs/SomeInputModelA", + "$ref": "#/$defs/SomeModelA", "default": {}, } @@ -281,12 +305,12 @@ def test_complex_function_json_schema(): "title": "InnerModel", "type": "object", }, - "SomeInputModelA": { + "SomeModelA": { "properties": {}, - "title": "SomeInputModelA", + "title": "SomeModelA", "type": "object", }, - "SomeInputModelB": { + "SomeModelB": { "properties": { "how_many_shrimp": { "description": "How many shrimp in the tank???", @@ -297,7 +321,7 @@ def test_complex_function_json_schema(): "y": {"title": "Y", "type": "null"}, }, "required": ["how_many_shrimp", "ok", "y"], - "title": "SomeInputModelB", + "title": "SomeModelB", "type": "object", }, }, @@ -341,9 +365,9 @@ def test_complex_function_json_schema(): "type": "integer", }, "unannotated": {"title": "unannotated", "type": "string"}, - "my_model_a": {"$ref": "#/$defs/SomeInputModelA"}, - "my_model_a_forward_ref": {"$ref": "#/$defs/SomeInputModelA"}, - "my_model_b": {"$ref": "#/$defs/SomeInputModelB"}, + "my_model_a": {"$ref": "#/$defs/SomeModelA"}, + "my_model_a_forward_ref": {"$ref": "#/$defs/SomeModelA"}, + "my_model_b": {"$ref": "#/$defs/SomeModelB"}, "an_int_annotated_with_field_default": { "default": 1, "description": "An int with a field", @@ -356,7 +380,7 @@ def test_complex_function_json_schema(): "type": "string", }, "my_model_a_with_default": { - "$ref": "#/$defs/SomeInputModelA", + "$ref": "#/$defs/SomeModelA", "default": {}, }, "an_int_with_default": { @@ -414,3 +438,50 @@ def func_with_str_and_int(a: str, b: int): result = meta.pre_parse_json({"a": "123", "b": 123}) assert result["a"] == "123" assert result["b"] == 123 + + +def test_simple_function_output_schema(): + """Test JSON schema generation for simple return types.""" + + assert func_metadata(simple_no_annotation_fun).output_schema is None + assert func_metadata(simple_str_fun).output_schema == { + "type": "string", + } + assert func_metadata(simple_bool_fun).output_schema == { + "type": "boolean", + } + assert func_metadata(simple_int_fun).output_schema == { + "type": "integer", + } + assert func_metadata(simple_float_fun).output_schema == { + "type": "number", + } + + +def test_complex_function_output_schema(): + """Test JSON schema generation for complex return types.""" + + assert func_metadata(complex_model_fun).output_schema == { + "type": "object", + "$defs": { + "InnerModel": { + "properties": {"x": {"title": "X", "type": "integer"}}, + "required": [ + "x", + ], + "title": "InnerModel", + "type": "object", + } + }, + "properties": { + "how_many_shrimp": { + "description": "How many shrimp in the tank???", + "title": "How Many Shrimp", + "type": "integer", + }, + "ok": {"$ref": "#/$defs/InnerModel"}, + "y": {"title": "Y", "type": "null"}, + }, + "required": ["how_many_shrimp", "ok", "y"], + "title": "SomeModelB", + }