Skip to content

Commit 4cebc58

Browse files
Kludexmaxisbey
authored andcommitted
Remove casts from the input_required resolver path
Replace every cast() with a checked or properly-typed alternative: model the request_state payload with pydantic (_State/_StateEntry) so the untrusted JSON is validated instead of cast; type _Resolution.pending as InputRequests so an ElicitRequest fits without a cast; add a _is_elicit TypeGuard and an _accepted helper that carry the right types; and narrow req.params via isinstance in the test helper. No behavior change.
1 parent 555c640 commit 4cebc58

2 files changed

Lines changed: 66 additions & 47 deletions

File tree

src/mcp/server/mcpserver/resolve.py

Lines changed: 56 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,9 @@
2626
from __future__ import annotations
2727

2828
import inspect
29-
import json
3029
import typing
3130
from collections.abc import Callable, Hashable, Mapping
32-
from typing import Annotated, Any, Generic, cast, get_args, get_origin
31+
from typing import Annotated, Any, Generic, Literal, TypeGuard, get_args, get_origin
3332

3433
import anyio.to_thread
3534
from mcp_types import (
@@ -41,7 +40,7 @@
4140
InputResponses,
4241
)
4342
from mcp_types.version import LATEST_MODERN_VERSION, is_version_at_least
44-
from pydantic import BaseModel
43+
from pydantic import BaseModel, ValidationError
4544
from typing_extensions import TypeVar
4645

4746
from mcp.server.elicitation import (
@@ -298,7 +297,7 @@ def __init__(
298297
self.answers: InputResponses = context.input_responses or {} if input_required else {}
299298
self.state = _decode_state(context.request_state) if input_required else {}
300299
self.cache: dict[str, ElicitationResult[Any]] = {}
301-
self.pending: dict[str, ElicitRequest] = {}
300+
self.pending: InputRequests = {}
302301

303302

304303
def _state_key(fn: Callable[..., Any]) -> str:
@@ -342,10 +341,7 @@ async def resolve_arguments(
342341
injected[name] = outcome if wants_union else _unwrap(outcome, name)
343342

344343
if res.pending:
345-
return InputRequiredResult(
346-
input_requests=cast("InputRequests", res.pending),
347-
request_state=_encode_state(res.cache),
348-
)
344+
return InputRequiredResult(input_requests=res.pending, request_state=_encode_state(res.cache))
349345
return injected
350346

351347

@@ -379,23 +375,24 @@ async def _resolve(fn: Callable[..., Any], res: _Resolution) -> ElicitationResul
379375
dep_outcome = await _resolve(param_plan.resolve.fn, res)
380376
kwargs[param_name] = dep_outcome if param_plan.wants_union else _unwrap(dep_outcome, param_name)
381377

378+
result: Any
382379
if plan.is_async:
383380
result = await fn(**kwargs)
384381
else:
385382
result = await anyio.to_thread.run_sync(lambda: fn(**kwargs))
386383

387-
if isinstance(result, Elicit):
388-
outcome = await _elicit(cast("Elicit[BaseModel]", result), key, res)
384+
if _is_elicit(result):
385+
outcome = await _elicit(result, key, res)
389386
else:
390-
# A resolver may return any type (not just `BaseModel`); `model_construct`
391-
# wraps it as an accepted result without validating against the schema bound.
392-
outcome = cast("AcceptedElicitation[Any]", AcceptedElicitation.model_construct(data=result))
387+
# A resolver may return any type (not just `BaseModel`), so accept it as the
388+
# outcome without validating against the schema bound.
389+
outcome = _accepted(result)
393390

394391
res.cache[key] = outcome
395392
return outcome
396393

397394

398-
async def _elicit(elicit: Elicit[BaseModel], key: str, res: _Resolution) -> ElicitationResult[Any]:
395+
async def _elicit(elicit: Elicit[Any], key: str, res: _Resolution) -> ElicitationResult[Any]:
399396
"""Turn a resolver's `Elicit` into an outcome via the negotiated transport."""
400397
if not res.input_required:
401398
return await res.context.elicit(elicit.message, elicit.schema)
@@ -419,6 +416,20 @@ def _unwrap(outcome: ElicitationResult[Any], name: str) -> Any:
419416
raise ToolError(f"Resolver for parameter {name!r} could not resolve: elicitation was {outcome.action}")
420417

421418

419+
def _is_elicit(value: Any) -> TypeGuard[Elicit[Any]]:
420+
"""Runtime narrow of a resolver's return value to a (parameter-erased) `Elicit`."""
421+
return isinstance(value, Elicit)
422+
423+
424+
def _accepted(data: Any) -> AcceptedElicitation[Any]:
425+
"""Wrap a resolved value as an accepted outcome without schema validation.
426+
427+
A resolver may return any type (the schema bound only constrains `Elicit[T]`),
428+
and a value restored from `request_state` is already validated.
429+
"""
430+
return AcceptedElicitation[Any].model_construct(data=data)
431+
432+
422433
def uses_input_required(protocol_version: str | None) -> bool:
423434
"""True when this request must elicit via `InputRequiredResult` (>= 2026-07-28).
424435
@@ -434,50 +445,56 @@ def _elicit_request(elicit: Elicit[Any]) -> ElicitRequest:
434445
return ElicitRequest(params=ElicitRequestFormParams(message=elicit.message, requested_schema=json_schema))
435446

436447

437-
def _decode_state(request_state: str | None) -> dict[str, dict[str, Any]]:
448+
class _StateEntry(BaseModel):
449+
"""One resolver's recorded outcome inside `request_state`."""
450+
451+
action: Literal["accept", "decline", "cancel"]
452+
data: Any = None
453+
454+
455+
class _State(BaseModel):
456+
"""The decoded `request_state`: resolver outcomes from earlier rounds."""
457+
458+
v: int
459+
outcomes: dict[str, _StateEntry] = {}
460+
461+
462+
def _decode_state(request_state: str | None) -> dict[str, _StateEntry]:
438463
"""Decode the per-call resolution progress from `request_state`.
439464
440-
`request_state` is client-trusted (integrity sealing is a follow-up); decode
441-
defensively and treat anything malformed as "no progress yet".
465+
`request_state` is client-trusted (integrity sealing is a follow-up); validate
466+
it through `_State` and treat anything malformed as "no progress yet".
442467
"""
443468
if not request_state:
444469
return {}
445470
try:
446-
decoded: Any = json.loads(request_state)
447-
except json.JSONDecodeError:
448-
return {}
449-
if not isinstance(decoded, dict):
450-
return {}
451-
payload = cast("dict[str, Any]", decoded)
452-
if payload.get("v") != _STATE_VERSION:
471+
state = _State.model_validate_json(request_state)
472+
except ValidationError:
453473
return {}
454-
outcomes = payload.get("outcomes")
455-
return cast("dict[str, dict[str, Any]]", outcomes) if isinstance(outcomes, dict) else {}
474+
return state.outcomes if state.v == _STATE_VERSION else {}
456475

457476

458477
def _encode_state(outcomes: Mapping[str, ElicitationResult[Any]]) -> str:
459478
"""Encode resolved outcomes (keyed by resolver path) for the next round."""
460-
encoded: dict[str, dict[str, Any]] = {}
479+
entries: dict[str, _StateEntry] = {}
461480
for path, outcome in outcomes.items():
462-
entry: dict[str, Any] = {"action": outcome.action}
463-
if isinstance(outcome, AcceptedElicitation):
464-
data = outcome.data
465-
entry["data"] = data.model_dump(mode="json") if isinstance(data, BaseModel) else data
466-
encoded[path] = entry
467-
return json.dumps({"v": _STATE_VERSION, "outcomes": encoded})
481+
data = outcome.data if isinstance(outcome, AcceptedElicitation) else None
482+
if isinstance(data, BaseModel):
483+
data = data.model_dump(mode="json")
484+
entries[path] = _StateEntry(action=outcome.action, data=data)
485+
return _State(v=_STATE_VERSION, outcomes=entries).model_dump_json()
468486

469487

470-
def _outcome_from_state(entry: Mapping[str, Any], schema: type[BaseModel] | None) -> ElicitationResult[Any]:
488+
def _outcome_from_state(entry: _StateEntry, schema: type[BaseModel] | None) -> ElicitationResult[Any]:
471489
"""Rebuild an `ElicitationResult` from a decoded `request_state` entry."""
472-
action = entry.get("action")
473-
if action == "decline":
490+
if entry.action == "decline":
474491
return DeclinedElicitation()
475-
if action == "cancel":
492+
if entry.action == "cancel":
476493
return CancelledElicitation()
477-
data = entry.get("data")
494+
data = entry.data
478495
if schema is not None and isinstance(data, dict):
479496
data = schema.model_validate(data)
480-
return cast("AcceptedElicitation[Any]", AcceptedElicitation.model_construct(data=data))
497+
return _accepted(data)
481498

482499

483500
__all__ = [

tests/server/mcpserver/test_resolve.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
"""Tests for resolver dependency injection (MRTR) on MCPServer tools."""
22

33
from collections.abc import Callable
4-
from typing import Annotated, Literal, cast
4+
from typing import Annotated, Literal
55

66
import pytest
77
from mcp_types import (
88
CallToolResult,
9+
ElicitRequestFormParams,
910
ElicitRequestParams,
1011
ElicitResult,
1112
InputRequiredResult,
@@ -73,7 +74,7 @@ async def _drive_mrtr(
7374
client: Client,
7475
tool: str,
7576
args: dict[str, object],
76-
answer: Callable[[str, ElicitRequestParams], ElicitResult],
77+
answer: Callable[[str, ElicitRequestFormParams], ElicitResult],
7778
max_rounds: int = 10,
7879
) -> CallToolResult:
7980
"""Drive the 2026-07-28 `input_required` loop to completion.
@@ -92,9 +93,10 @@ async def _drive_mrtr(
9293
return result
9394
assert isinstance(result, InputRequiredResult)
9495
assert result.input_requests is not None
95-
responses = {
96-
key: answer(key, cast(ElicitRequestParams, req.params)) for key, req in result.input_requests.items()
97-
}
96+
responses = {}
97+
for key, req in result.input_requests.items():
98+
assert isinstance(req.params, ElicitRequestFormParams)
99+
responses[key] = answer(key, req.params)
98100
state = result.request_state
99101
raise AssertionError("input_required loop did not converge") # pragma: no cover
100102

@@ -626,7 +628,7 @@ async def test_input_required_loop_handles_every_outcome(
626628
mcp, fs = _delete_folder_server()
627629
fs["/docs"] = ["a.txt", "b.txt"]
628630

629-
def answer(key: str, params: ElicitRequestParams) -> ElicitResult:
631+
def answer(key: str, params: ElicitRequestFormParams) -> ElicitResult:
630632
assert "/docs has 2 file(s)" in params.message
631633
return ElicitResult(action=action, content=content)
632634

@@ -672,7 +674,7 @@ async def act(
672674
) -> str:
673675
return f"{login.username}:{confirm.ok}"
674676

675-
def answer(key: str, params: ElicitRequestParams) -> ElicitResult:
677+
def answer(key: str, params: ElicitRequestFormParams) -> ElicitResult:
676678
if "Username" in params.message:
677679
return ElicitResult(action="accept", content={"username": "octocat"})
678680
return ElicitResult(action="accept", content={"ok": True})
@@ -707,7 +709,7 @@ async def both(
707709
) -> str:
708710
return f"{name.username}:{confirm.ok}"
709711

710-
def answer(key: str, params: ElicitRequestParams) -> ElicitResult:
712+
def answer(key: str, params: ElicitRequestFormParams) -> ElicitResult:
711713
if "Name" in params.message:
712714
return ElicitResult(action="accept", content={"username": "octocat"})
713715
return ElicitResult(action="accept", content={"ok": True})

0 commit comments

Comments
 (0)