Skip to content

Commit c3d4d4f

Browse files
committed
outputSchema basics implemented, more testing required also potentially need an opt out for conversion to automatic conversion to DataContent
1 parent addce22 commit c3d4d4f

File tree

8 files changed

+143
-39
lines changed

8 files changed

+143
-39
lines changed

src/mcp/cli/claude.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ def get_claude_config_path() -> Path | None:
3131
return path
3232
return None
3333

34+
3435
def get_uv_path() -> str:
3536
"""Get the full path to the uv executable."""
3637
uv_path = shutil.which("uv")
@@ -42,6 +43,7 @@ def get_uv_path() -> str:
4243
return "uv" # Fall back to just "uv" if not found
4344
return uv_path
4445

46+
4547
def update_claude_config(
4648
file_spec: str,
4749
server_name: str,

src/mcp/server/fastmcp/server.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
from mcp.shared.context import LifespanContextT, RequestContext
5353
from mcp.types import (
5454
AnyFunction,
55+
DataContent,
5556
EmbeddedResource,
5657
GetPromptResult,
5758
ImageContent,
@@ -270,7 +271,7 @@ def get_context(self) -> Context[ServerSession, object]:
270271

271272
async def call_tool(
272273
self, name: str, arguments: dict[str, Any]
273-
) -> Sequence[TextContent | ImageContent | EmbeddedResource]:
274+
) -> Sequence[TextContent | DataContent | ImageContent | EmbeddedResource]:
274275
"""Call a tool by name with arguments."""
275276
context = self.get_context()
276277
result = await self._tool_manager.call_tool(name, arguments, context=context)
@@ -870,12 +871,12 @@ async def get_prompt(
870871

871872
def _convert_to_content(
872873
result: Any,
873-
) -> Sequence[TextContent | ImageContent | EmbeddedResource]:
874+
) -> Sequence[TextContent | ImageContent | EmbeddedResource | DataContent]:
874875
"""Convert a result to a sequence of content objects."""
875876
if result is None:
876877
return []
877878

878-
if isinstance(result, TextContent | ImageContent | EmbeddedResource):
879+
if isinstance(result, TextContent | ImageContent | EmbeddedResource | DataContent):
879880
return [result]
880881

881882
if isinstance(result, Image):

src/mcp/server/fastmcp/tools/base.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from mcp.server.fastmcp.exceptions import ToolError
1010
from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata, func_metadata
11-
from mcp.types import ToolAnnotations
11+
from mcp.types import DataContent, ToolAnnotations
1212

1313
if TYPE_CHECKING:
1414
from mcp.server.fastmcp.server import Context
@@ -70,7 +70,7 @@ def from_function(
7070
skip_names=[context_kwarg] if context_kwarg is not None else [],
7171
)
7272
parameters = func_arg_metadata.arg_model.model_json_schema()
73-
output = func_arg_metadata.output_model.model_json_schema()
73+
output = func_arg_metadata.output_schema
7474

7575
return cls(
7676
fn=fn,
@@ -91,13 +91,20 @@ async def run(
9191
) -> Any:
9292
"""Run the tool with arguments."""
9393
try:
94-
return await self.fn_metadata.call_fn_with_arg_validation(
94+
result = await self.fn_metadata.call_fn_with_arg_validation(
9595
self.fn,
9696
self.is_async,
9797
arguments,
9898
{self.context_kwarg: context}
9999
if self.context_kwarg is not None
100100
else None,
101101
)
102+
if self.output.get("type") == "object":
103+
return DataContent(
104+
type="data",
105+
data=result,
106+
)
107+
else:
108+
return result
102109
except Exception as e:
103110
raise ToolError(f"Error executing tool {self.name}: {e}") from e

src/mcp/server/fastmcp/utilities/func_metadata.py

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,20 @@
77
ForwardRef,
88
)
99

10-
from pydantic import BaseModel, ConfigDict, Field, WithJsonSchema, create_model
10+
from pydantic import (
11+
BaseModel,
12+
ConfigDict,
13+
Field,
14+
TypeAdapter,
15+
WithJsonSchema,
16+
create_model,
17+
)
1118
from pydantic._internal._typing_extra import eval_type_backport
1219
from pydantic.fields import FieldInfo
1320
from pydantic_core import PydanticUndefined
1421

1522
from mcp.server.fastmcp.exceptions import InvalidSignature
23+
from mcp.server.fastmcp.utilities import types
1624
from mcp.server.fastmcp.utilities.logging import get_logger
1725

1826
logger = get_logger(__name__)
@@ -38,7 +46,7 @@ def model_dump_one_level(self) -> dict[str, Any]:
3846

3947
class FuncMetadata(BaseModel):
4048
arg_model: Annotated[type[ArgModelBase], WithJsonSchema(None)]
41-
output_model: Annotated[type[ArgModelBase], WithJsonSchema(None)]
49+
output_schema: dict[str, Any]
4250
# We can add things in the future like
4351
# - Maybe some args are excluded from attempting to parse from JSON
4452
# - Maybe some args are special (like context) for dependency injection
@@ -129,7 +137,7 @@ def func_metadata(
129137
sig = _get_typed_signature(func)
130138
params = sig.parameters
131139
dynamic_pydantic_model_params: dict[str, Any] = {}
132-
dynamic_pydantic_model_output: dict[str, Any] = {}
140+
output_schema: dict[str, Any] = {}
133141
globalns = getattr(func, "__globals__", {})
134142
for param in params.values():
135143
if param.name.startswith("_"):
@@ -169,21 +177,22 @@ def func_metadata(
169177
dynamic_pydantic_model_params[param.name] = (field_info.annotation, field_info)
170178
continue
171179

172-
# dynamic_pydantic_model_output[]
173-
174180
arguments_model = create_model(
175181
f"{func.__name__}Arguments",
176182
**dynamic_pydantic_model_params,
177183
__base__=ArgModelBase,
178184
)
179185

180-
output_model = create_model(
181-
f"{func.__name__}Output",
182-
**dynamic_pydantic_model_output,
183-
__base__=ArgModelBase
184-
)
185-
resp = FuncMetadata(arg_model=arguments_model, output_model=output_model)
186-
return resp
186+
if sig.return_annotation is inspect.Parameter.empty:
187+
pass
188+
elif sig.return_annotation is None:
189+
pass
190+
elif sig.return_annotation is types.Image:
191+
pass
192+
else:
193+
output_schema = TypeAdapter(sig.return_annotation).json_schema()
194+
195+
return FuncMetadata(arg_model=arguments_model, output_schema=output_schema)
187196

188197

189198
def _get_typed_annotation(annotation: Any, globalns: dict[str, Any]) -> Any:
@@ -220,5 +229,6 @@ def _get_typed_signature(call: Callable[..., Any]) -> inspect.Signature:
220229
)
221230
for param in signature.parameters.values()
222231
]
223-
typed_signature = inspect.Signature(typed_params, return_annotation=signature.return_annotation)
232+
typed_return = _get_typed_annotation(signature.return_annotation, globalns)
233+
typed_signature = inspect.Signature(typed_params, return_annotation=typed_return)
224234
return typed_signature

src/mcp/server/lowlevel/server.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -399,7 +399,10 @@ def decorator(
399399
...,
400400
Awaitable[
401401
Iterable[
402-
types.TextContent | types.ImageContent | types.EmbeddedResource
402+
types.TextContent
403+
| types.DataContent
404+
| types.ImageContent
405+
| types.EmbeddedResource
403406
]
404407
],
405408
],

src/mcp/types.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -646,6 +646,21 @@ class ImageContent(BaseModel):
646646
model_config = ConfigDict(extra="allow")
647647

648648

649+
class DataContent(BaseModel):
650+
"""Data content for a message."""
651+
652+
type: Literal["data"]
653+
data: Any
654+
"""The JSON serializable object containing structured data."""
655+
schema_: str | Any | None = Field(serialization_alias="schema", default=None)
656+
657+
"""
658+
Optional reference to a JSON schema that describes the structure of the data.
659+
"""
660+
annotations: Annotations | None = None
661+
model_config = ConfigDict(extra="allow")
662+
663+
649664
class SamplingMessage(BaseModel):
650665
"""Describes a message issued to or received from an LLM API."""
651666

@@ -793,7 +808,7 @@ class CallToolRequest(Request[CallToolRequestParams, Literal["tools/call"]]):
793808
class CallToolResult(Result):
794809
"""The server's response to a tool call."""
795810

796-
content: list[TextContent | ImageContent | EmbeddedResource]
811+
content: list[TextContent | DataContent | ImageContent | EmbeddedResource]
797812
isError: bool = False
798813

799814

tests/client/test_config.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ def test_absolute_uv_path(mock_config_path: Path):
5454
"""Test that the absolute path to uv is used when available."""
5555
# Mock the shutil.which function to return a fake path
5656
mock_uv_path = "/usr/local/bin/uv"
57-
57+
5858
with patch("mcp.cli.claude.get_uv_path", return_value=mock_uv_path):
5959
# Setup
6060
server_name = "test_server"
@@ -71,5 +71,5 @@ def test_absolute_uv_path(mock_config_path: Path):
7171
# Verify the command is the absolute path
7272
server_config = config["mcpServers"][server_name]
7373
command = server_config["command"]
74-
75-
assert command == mock_uv_path
74+
75+
assert command == mock_uv_path

tests/server/fastmcp/test_func_metadata.py

Lines changed: 81 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@
77
from mcp.server.fastmcp.utilities.func_metadata import func_metadata
88

99

10-
class SomeInputModelA(BaseModel):
10+
class SomeModelA(BaseModel):
1111
pass
1212

1313

14-
class SomeInputModelB(BaseModel):
14+
class SomeModelB(BaseModel):
1515
class InnerModel(BaseModel):
1616
x: int
1717

@@ -46,15 +46,15 @@ def complex_arguments_fn(
4646
int, Field(1)
4747
],
4848
unannotated,
49-
my_model_a: SomeInputModelA,
50-
my_model_a_forward_ref: "SomeInputModelA",
51-
my_model_b: SomeInputModelB,
49+
my_model_a: SomeModelA,
50+
my_model_a_forward_ref: "SomeModelA",
51+
my_model_b: SomeModelB,
5252
an_int_annotated_with_field_default: Annotated[
5353
int,
5454
Field(1, description="An int with a field"),
5555
],
5656
unannotated_with_default=5,
57-
my_model_a_with_default: SomeInputModelA = SomeInputModelA(), # noqa: B008
57+
my_model_a_with_default: SomeModelA = SomeModelA(), # noqa: B008
5858
an_int_with_default: int = 1,
5959
must_be_none_with_default: None = None,
6060
an_int_with_equals_field: int = Field(1, ge=0),
@@ -85,6 +85,26 @@ def complex_arguments_fn(
8585
return "ok!"
8686

8787

88+
def simple_str_fun() -> str:
89+
return "ok"
90+
91+
92+
def simple_bool_fun() -> bool:
93+
return True
94+
95+
96+
def simple_int_fun() -> int:
97+
return 1
98+
99+
100+
def simple_float_fun() -> float:
101+
return 1.0
102+
103+
104+
def complex_model_fun() -> SomeModelB:
105+
return SomeModelB(how_many_shrimp=1, ok=SomeModelB.InnerModel(x=2), y=None)
106+
107+
88108
@pytest.mark.anyio
89109
async def test_complex_function_runtime_arg_validation_non_json():
90110
"""Test that basic non-JSON arguments are validated correctly"""
@@ -269,7 +289,7 @@ def test_complex_function_json_schema():
269289
# Normalize the my_model_a_with_default field to handle both pydantic formats
270290
if "allOf" in actual_schema["properties"]["my_model_a_with_default"]:
271291
normalized_schema["properties"]["my_model_a_with_default"] = {
272-
"$ref": "#/$defs/SomeInputModelA",
292+
"$ref": "#/$defs/SomeModelA",
273293
"default": {},
274294
}
275295

@@ -281,12 +301,12 @@ def test_complex_function_json_schema():
281301
"title": "InnerModel",
282302
"type": "object",
283303
},
284-
"SomeInputModelA": {
304+
"SomeModelA": {
285305
"properties": {},
286-
"title": "SomeInputModelA",
306+
"title": "SomeModelA",
287307
"type": "object",
288308
},
289-
"SomeInputModelB": {
309+
"SomeModelB": {
290310
"properties": {
291311
"how_many_shrimp": {
292312
"description": "How many shrimp in the tank???",
@@ -297,7 +317,7 @@ def test_complex_function_json_schema():
297317
"y": {"title": "Y", "type": "null"},
298318
},
299319
"required": ["how_many_shrimp", "ok", "y"],
300-
"title": "SomeInputModelB",
320+
"title": "SomeModelB",
301321
"type": "object",
302322
},
303323
},
@@ -341,9 +361,9 @@ def test_complex_function_json_schema():
341361
"type": "integer",
342362
},
343363
"unannotated": {"title": "unannotated", "type": "string"},
344-
"my_model_a": {"$ref": "#/$defs/SomeInputModelA"},
345-
"my_model_a_forward_ref": {"$ref": "#/$defs/SomeInputModelA"},
346-
"my_model_b": {"$ref": "#/$defs/SomeInputModelB"},
364+
"my_model_a": {"$ref": "#/$defs/SomeModelA"},
365+
"my_model_a_forward_ref": {"$ref": "#/$defs/SomeModelA"},
366+
"my_model_b": {"$ref": "#/$defs/SomeModelB"},
347367
"an_int_annotated_with_field_default": {
348368
"default": 1,
349369
"description": "An int with a field",
@@ -356,7 +376,7 @@ def test_complex_function_json_schema():
356376
"type": "string",
357377
},
358378
"my_model_a_with_default": {
359-
"$ref": "#/$defs/SomeInputModelA",
379+
"$ref": "#/$defs/SomeModelA",
360380
"default": {},
361381
},
362382
"an_int_with_default": {
@@ -414,3 +434,49 @@ def func_with_str_and_int(a: str, b: int):
414434
result = meta.pre_parse_json({"a": "123", "b": 123})
415435
assert result["a"] == "123"
416436
assert result["b"] == 123
437+
438+
439+
def test_simple_function_output_schema():
440+
"""Test JSON schema generation for simple return types."""
441+
442+
assert func_metadata(simple_str_fun).output_schema == {
443+
"type": "string",
444+
}
445+
assert func_metadata(simple_bool_fun).output_schema == {
446+
"type": "boolean",
447+
}
448+
assert func_metadata(simple_int_fun).output_schema == {
449+
"type": "integer",
450+
}
451+
assert func_metadata(simple_float_fun).output_schema == {
452+
"type": "number",
453+
}
454+
455+
456+
def test_complex_function_output_schema():
457+
"""Test JSON schema generation for simple return types."""
458+
459+
assert func_metadata(complex_model_fun).output_schema == {
460+
"type": "object",
461+
"$defs": {
462+
"InnerModel": {
463+
"properties": {"x": {"title": "X", "type": "integer"}},
464+
"required": [
465+
"x",
466+
],
467+
"title": "InnerModel",
468+
"type": "object",
469+
}
470+
},
471+
"properties": {
472+
"how_many_shrimp": {
473+
"description": "How many shrimp in the tank???",
474+
"title": "How Many Shrimp",
475+
"type": "integer",
476+
},
477+
"ok": {"$ref": "#/$defs/InnerModel"},
478+
"y": {"title": "Y", "type": "null"},
479+
},
480+
"required": ["how_many_shrimp", "ok", "y"],
481+
"title": "SomeModelB",
482+
}

0 commit comments

Comments
 (0)