3232from libcst .helpers import get_full_name_for_node
3333from libcst .metadata import (
3434 CodeRange ,
35- ExpressionContext ,
36- ExpressionContextProvider ,
3735 MetadataWrapper ,
3836 PositionProvider ,
3937 QualifiedNameProvider ,
4442 CAMEL_FIELDS ,
4543 ERRORDATA_QNAMES ,
4644 FASTMCP_QNAMES ,
45+ LOWLEVEL_CTOR_POSITIONAL_PARAMS ,
4746 LOWLEVEL_DECORATOR_METHODS ,
4847 LOWLEVEL_REMOVED_ATTRS ,
4948 LOWLEVEL_SERVER_QNAMES ,
@@ -276,7 +275,7 @@ def visit_AnnAssign(self, node: cst.AnnAssign) -> None:
276275
277276
278277class _V1ToV2 (cst .CSTTransformer ):
279- METADATA_DEPENDENCIES = (QualifiedNameProvider , PositionProvider , ExpressionContextProvider )
278+ METADATA_DEPENDENCIES = (QualifiedNameProvider , PositionProvider )
280279
281280 def __init__ (self , prepass : _PrePass , * , add_markers : bool ) -> None :
282281 super ().__init__ ()
@@ -302,7 +301,6 @@ def __init__(self, prepass: _PrePass, *, add_markers: bool) -> None:
302301 # and whether its type names `McpError`. An inner handler that re-binds a
303302 # name shadows the outer binding of that name; any other inner handler is
304303 # transparent to the lookup.
305- self ._except_bindings : list [tuple [str , bool ]] = []
306304 # Calls that are a `with` item bound to a three-element tuple: the one form
307305 # whose result tuple `leave_WithItem` can rewrite rather than flag.
308306 self ._narrowable_calls : set [int ] = set ()
@@ -409,37 +407,6 @@ def visit_Arg(self, node: cst.Arg) -> None:
409407 def visit_Param (self , node : cst .Param ) -> None :
410408 self ._not_a_reference .add (id (node .name ))
411409
412- def _is_mcperror_binding (self , name : str ) -> bool :
413- """Whether the nearest enclosing handler that binds `name` catches `McpError`.
414-
415- Handlers that bind some other name (or none) are transparent, so a nested
416- `try`/`except` inside an `except McpError as e:` does not hide `e`; one
417- that re-binds `e` itself shadows the outer binding.
418- """
419- for bound , is_mcperror in reversed (self ._except_bindings ):
420- if bound == name :
421- return is_mcperror
422- return False
423-
424- def visit_ExceptHandler (self , node : cst .ExceptHandler ) -> None :
425- bound = ""
426- if node .name is not None and isinstance (node .name .name , cst .Name ):
427- bound = node .name .name .value
428- # `except (McpError, ValueError) as e:` catches a tuple of types.
429- if isinstance (node .type , cst .Tuple ):
430- caught : list [cst .BaseExpression ] = [element .value for element in node .type .elements ]
431- elif node .type is not None :
432- caught = [node .type ]
433- else :
434- caught = []
435- self ._except_bindings .append ((bound , any (self ._qualified (kind ) & MCPERROR_QNAMES for kind in caught )))
436-
437- def leave_ExceptHandler (
438- self , original_node : cst .ExceptHandler , updated_node : cst .ExceptHandler
439- ) -> cst .ExceptHandler :
440- self ._except_bindings .pop ()
441- return updated_node
442-
443410 # ------------------------------------------------------------------ imports
444411
445412 def leave_ImportFrom (self , original_node : cst .ImportFrom , updated_node : cst .ImportFrom ) -> cst .ImportFrom :
@@ -580,22 +547,10 @@ def leave_Name(self, original_node: cst.Name, updated_node: cst.Name) -> cst.Nam
580547 return updated_node
581548
582549 def leave_Attribute (self , original_node : cst .Attribute , updated_node : cst .Attribute ) -> cst .BaseExpression :
583- # A READ of `e.error.code` -> `e.code` when `e` is bound by `except McpError
584- # as e:`. Only the full three-part chain in a load context is touched: a bare
585- # `e.error` may be a whole `ErrorData` being passed somewhere, and an
586- # ASSIGNMENT like `e.error.message = ...` must stay as written -- v2's
587- # `MCPError.message` is a read-only property over the still-mutable `.error`,
588- # so collapsing a write would break code that works on v2 today.
589- if (
590- original_node .attr .value in ("code" , "message" , "data" )
591- and isinstance (original_node .value , cst .Attribute )
592- and original_node .value .attr .value == "error"
593- and isinstance (original_node .value .value , cst .Name )
594- and self ._is_mcperror_binding (original_node .value .value .value )
595- and self .get_metadata (ExpressionContextProvider , original_node , None ) is ExpressionContext .LOAD
596- ):
597- self .rewrites ["mcperror_attr" ] += 1
598- return updated_node .with_changes (value = cst .ensure_type (updated_node .value , cst .Attribute ).value )
550+ # `e.error.code` on a caught error is deliberately NOT collapsed to `e.code`:
551+ # v2's `MCPError` keeps a typed `.error` ErrorData, so the v1 spelling runs
552+ # and type-checks unchanged -- touching it would be modernization, not
553+ # migration.
599554
600555 # An attribute the lowlevel `Server` lost whose name survives elsewhere on
601556 # v2, matched only against a receiver the pre-pass proved is such a server
@@ -679,15 +634,22 @@ def leave_Attribute(self, original_node: cst.Attribute, updated_node: cst.Attrib
679634 def leave_Call (self , original_node : cst .Call , updated_node : cst .Call ) -> cst .Call :
680635 callee = self ._qualified (original_node .func )
681636
682- # `McpError(ErrorData(code=..., message=..., data=...))` flattened to
683- # `MCPError(code=..., message=..., data=...)`; the name itself is renamed by
684- # `leave_Name`, which has already run on the inner nodes. v1's constructor
685- # took a single `ErrorData`; when that one argument is anything other than
686- # an inline `ErrorData(...)` call there is nothing safe to unpack, so the
687- # call is marked instead -- v2's signature is `(code, message, data=None)`.
637+ # v1's constructor took a single `ErrorData`; v2's classmethod
638+ # `MCPError.from_error_data(...)` takes exactly that argument, so any
639+ # one-argument call converts uniformly -- the user's expression is kept as
640+ # written, whatever it is. The name itself is renamed by `leave_Name`,
641+ # which has already run on the inner nodes.
642+ if callee & MCPERROR_QNAMES and len (original_node .args ) == 1 :
643+ self .rewrites ["mcperror_ctor" ] += 1
644+ return updated_node .with_changes (
645+ func = cst .Attribute (value = updated_node .func , attr = cst .Name ("from_error_data" ))
646+ )
647+
688648 # A subclass's `super().__init__(...)` is the same constructor spelled the
689- # one way a qualified name cannot reach, so it gets the same treatment.
690- if (callee & MCPERROR_QNAMES or self ._is_mcperror_super_init (original_node )) and len (original_node .args ) == 1 :
649+ # one way a classmethod cannot replace, so the inline `ErrorData(...)` is
650+ # flattened into v2's `(code, message, data=None)` arguments; any other
651+ # single argument has nothing safe to unpack and is marked.
652+ if self ._is_mcperror_super_init (original_node ) and len (original_node .args ) == 1 :
691653 wrapped = original_node .args [0 ].value
692654 if isinstance (wrapped , cst .Call ) and self ._qualified (wrapped .func ) & ERRORDATA_QNAMES :
693655 self .rewrites ["mcperror_ctor" ] += 1
@@ -700,12 +662,12 @@ def leave_Call(self, original_node: cst.Call, updated_node: cst.Call) -> cst.Cal
700662 "unpack the `ErrorData` being passed here into those arguments" ,
701663 )
702664
703- # camelCase keyword arguments still work on v2 (every model field also
704- # accepts its camelCase alias by name), so unlike an attribute READ this
705- # rename is cosmetic and cannot break the call -- which is why, unlike the
706- # attribute form, the risky tier needs no review marker here. Every
707- # hand-migrated example in the SDK converted them, so the codemod follows
708- # suit, gated on the callee resolving into the SDK.
665+ # camelCase keyword arguments still work at RUNTIME on v2 (every model
666+ # field accepts its camelCase alias by name), but the synthesized
667+ # `__init__` signatures are snake_case, so leaving them fails the user's
668+ # own type-checking. The rename cannot break the call -- which is why,
669+ # unlike the attribute form, the risky tier needs no review marker here --
670+ # and is gated on the callee resolving into the SDK.
709671 if any (name == "mcp" or name .startswith (("mcp." , "mcp_types." )) for name in callee ):
710672 arguments : list [cst .Arg ] = []
711673 renamed_any = False
@@ -747,6 +709,29 @@ def leave_Call(self, original_node: cst.Call, updated_node: cst.Call) -> cst.Cal
747709 elif keyword in REMOVED_CTOR_PARAMS :
748710 self ._diag (argument , "removed_ctor_param" , "manual" , f"`{ keyword } =` { REMOVED_CTOR_PARAMS [keyword ]} " )
749711
712+ # The lowlevel `Server` constructor is keyword-only after `name` on v2, but
713+ # its parameters kept v1's names and order, so v1 positionals convert to
714+ # keywords one for one. A `*`-splat hides how many positions it fills, so a
715+ # call carrying one is left for v2 to reject loudly at construction.
716+ if (
717+ callee & LOWLEVEL_SERVER_QNAMES
718+ and 1 < len (original_node .args ) <= 1 + len (LOWLEVEL_CTOR_POSITIONAL_PARAMS )
719+ and not any (argument .star for argument in original_node .args )
720+ ):
721+ arguments = []
722+ for index , argument in enumerate (updated_node .args ):
723+ if index > 0 and argument .keyword is None :
724+ self .rewrites ["lowlevel_ctor_kwargs" ] += 1
725+ argument = argument .with_changes (
726+ keyword = cst .Name (LOWLEVEL_CTOR_POSITIONAL_PARAMS [index - 1 ]),
727+ equal = cst .AssignEqual (
728+ whitespace_before = cst .SimpleWhitespace ("" ), whitespace_after = cst .SimpleWhitespace ("" )
729+ ),
730+ )
731+ arguments .append (argument )
732+ if arguments != list (updated_node .args ):
733+ updated_node = updated_node .with_changes (args = arguments )
734+
750735 # The streamable-HTTP client's keyword surface and yield shape both changed.
751736 # The keyword check lives here so that it fires however the call is used (an
752737 # `async with` item, `enter_async_context(...)`, an intermediate variable).
0 commit comments