Log MCPServer handler exceptions by kind and keep crash details off the wire (#3314)
Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
This commit is contained in:
parent
f1c40b062f
commit
56af44727b
1 changed files with 1427 additions and 310 deletions
|
|
@ -23,7 +23,7 @@ from pydantic import (
|
|||
)
|
||||
from pydantic.fields import FieldInfo
|
||||
from pydantic.json_schema import GenerateJsonSchema, JsonSchemaWarningKind
|
||||
from typing_extensions import NotRequired, ReadOnly, TypedDict, get_type_hints, is_typeddict
|
||||
from typing_extensions import NotRequired, ReadOnly, TypedDict, deprecated, get_type_hints, is_typeddict
|
||||
from typing_inspection.introspection import (
|
||||
UNKNOWN,
|
||||
AnnotationSource,
|
||||
|
|
@ -35,6 +35,7 @@ from typing_inspection.introspection import (
|
|||
from mcp.server.mcpserver.exceptions import InvalidSignature
|
||||
from mcp.server.mcpserver.utilities.logging import get_logger
|
||||
from mcp.server.mcpserver.utilities.types import Audio, Image
|
||||
from mcp.shared.exceptions import MCPDeprecationWarning
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
|
@ -125,6 +126,28 @@ class FuncMetadata(BaseModel):
|
|||
arguments_parsed_model = self.arg_model.model_validate(arguments_pre_parsed)
|
||||
return arguments_parsed_model.model_dump_one_level()
|
||||
|
||||
async def call_fn(
|
||||
self,
|
||||
fn: Callable[..., Any | Awaitable[Any]],
|
||||
fn_is_async: bool,
|
||||
arguments: dict[str, Any],
|
||||
arguments_to_pass_directly: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
"""Call the function with already-validated `arguments` plus `arguments_to_pass_directly`.
|
||||
|
||||
`arguments` is the output of `validate_arguments`. A sync function runs on a
|
||||
worker thread.
|
||||
"""
|
||||
kwargs = arguments | (arguments_to_pass_directly or {})
|
||||
if fn_is_async:
|
||||
return await fn(**kwargs)
|
||||
return await anyio.to_thread.run_sync(functools.partial(fn, **kwargs))
|
||||
|
||||
@deprecated(
|
||||
"FuncMetadata.call_fn_with_arg_validation() is deprecated and will be removed in 3.0; "
|
||||
"call validate_arguments() and then call_fn() instead.",
|
||||
category=MCPDeprecationWarning,
|
||||
)
|
||||
async def call_fn_with_arg_validation(
|
||||
self,
|
||||
fn: Callable[..., Any | Awaitable[Any]],
|
||||
|
|
@ -133,25 +156,12 @@ class FuncMetadata(BaseModel):
|
|||
arguments_to_pass_directly: dict[str, Any] | None,
|
||||
pre_validated: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
"""Call the given function with arguments validated and injected.
|
||||
"""Validate `arguments_to_validate` (unless `pre_validated` is given) and call the function.
|
||||
|
||||
Arguments are first attempted to be parsed from JSON, then validated against
|
||||
the argument model, before being passed to the function. Pass `pre_validated`
|
||||
(the output of `validate_arguments`) to reuse an earlier validation pass -
|
||||
validating twice can re-run `default_factory`/stateful validators and hand the
|
||||
function different values than a caller already observed.
|
||||
Deprecated: call `validate_arguments` and then `call_fn`.
|
||||
"""
|
||||
# Copy so a caller-provided `pre_validated` dict is never mutated in place.
|
||||
arguments_parsed_dict = dict(
|
||||
pre_validated if pre_validated is not None else self.validate_arguments(arguments_to_validate)
|
||||
)
|
||||
|
||||
arguments_parsed_dict |= arguments_to_pass_directly or {}
|
||||
|
||||
if fn_is_async:
|
||||
return await fn(**arguments_parsed_dict)
|
||||
else:
|
||||
return await anyio.to_thread.run_sync(functools.partial(fn, **arguments_parsed_dict))
|
||||
arguments = pre_validated if pre_validated is not None else self.validate_arguments(arguments_to_validate)
|
||||
return await self.call_fn(fn, fn_is_async, arguments, arguments_to_pass_directly)
|
||||
|
||||
def convert_result(self, result: Any) -> CallToolResult | InputRequiredResult:
|
||||
"""Convert a function call result into a `CallToolResult`.
|
||||
|
|
@ -172,7 +182,7 @@ class FuncMetadata(BaseModel):
|
|||
# A schema published without a model (hand-built metadata) is advertised but not validated here.
|
||||
output_model = self.output_model if self.output_schema is not None else None
|
||||
if isinstance(result, CallToolResult):
|
||||
if output_model is not None:
|
||||
if output_model is not None and not result.is_error:
|
||||
self._output_adapter(output_model).validate_python(result.structured_content)
|
||||
return result
|
||||
|
||||
|
|
@ -224,8 +234,10 @@ class FuncMetadata(BaseModel):
|
|||
if isinstance(data_value, str) and field_info.annotation is not str:
|
||||
try:
|
||||
pre_parsed = json.loads(data_value)
|
||||
except json.JSONDecodeError:
|
||||
continue # Not JSON - skip
|
||||
except (ValueError, RecursionError):
|
||||
# Not JSON, or JSON the parser refuses (over-long integers, deep
|
||||
# nesting): leave the string for validation to accept or reject.
|
||||
continue
|
||||
if isinstance(pre_parsed, str | int | float):
|
||||
# This is likely that the raw value is e.g. `"hello"` which we
|
||||
# Should really be parsed as '"hello"' in Python - but if we parse
|
||||
|
|
|
|||
Loading…
Reference in a new issue