Skip to content

Commit 4f099bf

Browse files
committed
func_metadata: re-inspect residual after stripping InputRequiredResult; fix migration.md call_tool return
Stripping InputRequiredResult arms rebound return_type_expr but not inspected_return_ann, so a single-arm residual that was itself an Annotated[...] (e.g. -> Annotated[CallToolResult, Model] | InputRequiredResult, the documented pattern in README.v2.md) skipped the CallToolResult-with- metadata special case: isinstance(Annotated[...], type) is False and .metadata was still the union's empty tuple. It fell through to _create_wrapped_model, advertising {result: CallToolResult} and raising ValidationError on every successful return. Re-run inspect_annotation on the residual so the post-strip state is indistinguishable from a fresh call without InputRequiredResult — the existing dispatch then handles every case unchanged. This also restores Annotated metadata for the simpler Annotated[X, meta] | InputRequiredResult case. Also: docs/migration.md still said MCPServer.call_tool() 'always returns a CallToolResult'; amend to note the InputRequiredResult arm.
1 parent 3507ce7 commit 4f099bf

3 files changed

Lines changed: 31 additions & 3 deletions

File tree

docs/migration.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ Version 2 of the MCP Python SDK introduces several breaking changes to improve t
1010

1111
### `MCPServer.call_tool()` returns `CallToolResult`
1212

13-
`MCPServer.call_tool()` now always returns a `CallToolResult`. It previously
13+
`MCPServer.call_tool()` now returns a `CallToolResult` (or an
14+
`InputRequiredResult` when a multi-round tool requests further input). It previously
1415
advertised `Sequence[ContentBlock] | dict[str, Any]` and leaked the internal
1516
conversion shapes (a bare content sequence or a `(content, structured_content)`
1617
tuple), forcing callers to re-assemble a `CallToolResult` themselves.

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -294,8 +294,12 @@ def func_metadata(
294294
return FuncMetadata(arg_model=arguments_model)
295295
if len(residual) != len(args):
296296
# PEP 604 has no syntax for "union of a runtime tuple"; Union[...] is the only spelling.
297-
return_type_expr = residual[0] if len(residual) == 1 else Union[residual] # noqa: UP007
298-
effective_annotation = return_type_expr
297+
effective_annotation = residual[0] if len(residual) == 1 else Union[residual] # noqa: UP007
298+
# Re-normalize so the residual is processed exactly as if it had been the declared
299+
# return annotation: unwraps a top-level Annotated[...] arm and re-derives metadata,
300+
# so the CallToolResult/BaseModel/TypedDict dispatch below sees the bare type.
301+
inspected_return_ann = inspect_annotation(effective_annotation, annotation_source=AnnotationSource.FUNCTION)
302+
return_type_expr = inspected_return_ann.type
299303
if len(residual) > 1 and any(
300304
isinstance(a, type) and issubclass(a, CallToolResult) for a in residual if a is not type(None)
301305
):

tests/server/mcpserver/test_func_metadata.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -862,6 +862,29 @@ def func_returning_annotated_tool_call_result() -> Annotated[CallToolResult, Per
862862
assert isinstance(meta.convert_result(func_returning_annotated_tool_call_result()), CallToolResult)
863863

864864

865+
def test_tool_call_result_annotated_unioned_with_input_required_result_is_equivalent_to_the_bare_annotated_form():
866+
"""Stripping `InputRequiredResult` makes the residual behave exactly as if it were the
867+
declared return annotation, including the `Annotated[CallToolResult, Model]` special case
868+
— the schema derives from `Model` and `convert_result` validates `structured_content`
869+
against it instead of wrapping the whole `CallToolResult`."""
870+
871+
class PersonClass(BaseModel):
872+
name: str
873+
874+
def fn_bare() -> Annotated[CallToolResult, PersonClass]:
875+
return CallToolResult(content=[], structured_content={"name": "Brandon"})
876+
877+
def fn_iir() -> Annotated[CallToolResult, PersonClass] | InputRequiredResult:
878+
return CallToolResult(content=[], structured_content={"name": "Brandon"})
879+
880+
bare = func_metadata(fn_bare)
881+
iir = func_metadata(fn_iir)
882+
assert iir.output_schema == bare.output_schema
883+
assert iir.wrap_output == bare.wrap_output
884+
assert isinstance(bare.convert_result(fn_bare()), CallToolResult)
885+
assert isinstance(iir.convert_result(fn_iir()), CallToolResult)
886+
887+
865888
def test_tool_call_result_annotated_is_structured_and_invalid():
866889
class PersonClass(BaseModel):
867890
name: str

0 commit comments

Comments
 (0)