Skip to content

WIP: add tool outputSchema and DataContent type to support structured content #685

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
2 changes: 2 additions & 0 deletions src/mcp/cli/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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,
Expand Down
8 changes: 5 additions & 3 deletions src/mcp/server/fastmcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
from mcp.shared.context import LifespanContextT, RequestContext
from mcp.types import (
AnyFunction,
DataContent,
EmbeddedResource,
GetPromptResult,
ImageContent,
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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):
Expand Down
17 changes: 15 additions & 2 deletions src/mcp/server/fastmcp/tools/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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,
Expand All @@ -88,13 +94,20 @@ 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,
{self.context_kwarg: context}
if self.context_kwarg is not None
else None,
)
if self.output and self.output.get("type") == "object":
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic is debatable it introduces a breaking change, potential to make this opt-in or opt-out by adding a flag to the @mcp.tool() annotation something like @mcp.tool(enable_data_content=True)

return DataContent(
type="data",
data=result,
)
else:
return result
except Exception as e:
raise ToolError(f"Error executing tool {self.name}: {e}") from e
24 changes: 20 additions & 4 deletions src/mcp/server/fastmcp/utilities/func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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
Expand Down Expand Up @@ -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("_"):
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
5 changes: 4 additions & 1 deletion src/mcp/server/lowlevel/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,10 @@ def decorator(
...,
Awaitable[
Iterable[
types.TextContent | types.ImageContent | types.EmbeddedResource
types.TextContent
| types.DataContent
| types.ImageContent
| types.EmbeddedResource
]
],
],
Expand Down
19 changes: 18 additions & 1 deletion src/mcp/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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


Expand Down
6 changes: 3 additions & 3 deletions tests/client/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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

assert command == mock_uv_path
Loading
Loading