diff --git a/README.md b/README.md index 66356f48..9fc8e492 100644 --- a/README.md +++ b/README.md @@ -202,3 +202,20 @@ projects anyway. ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md). + +## Type Checking + +This project uses `pyright` for type checking with strict mode enabled. The type checking configuration is in `pyproject.toml`. We use a few strategies to maintain type safety: + +1. Type stubs for external libraries: + - Custom type stubs are in the `stubs/` directory + - The `stubPackages` configuration in `pyproject.toml` maps libraries to their stub packages + +2. File-specific ignores for challenging cases: + - For some files with complex dynamic typing patterns (particularly testing code), we use file-specific ignores via `tool.pyright.ignoreExtraErrors` in `pyproject.toml` + - This is preferable to inline ignores and lets us maintain type safety in most of the codebase + +When making changes, please ensure type checking passes by running: +``` +./run_typecheck.sh +``` diff --git a/codemcp/code_command.py b/codemcp/code_command.py index d98b58cb..7283291c 100644 --- a/codemcp/code_command.py +++ b/codemcp/code_command.py @@ -3,7 +3,7 @@ import logging import os import subprocess -from typing import List, Optional +from typing import List, Optional, Dict, Any, cast import tomli @@ -37,15 +37,15 @@ def get_command_from_config(project_dir: str, command_name: str) -> Optional[Lis return None with open(config_path, "rb") as f: - config = tomli.load(f) + config: Dict[str, Any] = tomli.load(f) if "commands" in config and command_name in config["commands"]: cmd_config = config["commands"][command_name] # Handle both direct command lists and dictionaries with 'command' field if isinstance(cmd_config, list): - return cmd_config # type: ignore + return cast(List[str], cmd_config) elif isinstance(cmd_config, dict) and "command" in cmd_config: - return cmd_config["command"] # type: ignore + return cast(List[str], cmd_config["command"]) return None except Exception as e: diff --git a/codemcp/hot_reload_entry.py b/codemcp/hot_reload_entry.py index 9056f5b3..34bf02df 100644 --- a/codemcp/hot_reload_entry.py +++ b/codemcp/hot_reload_entry.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# pyright: reportUnknownMemberType=false + import asyncio import functools @@ -165,7 +165,6 @@ async def _run_manager_task( if command == "call": # Use explicit type cast for arguments to satisfy the type checker tool_args = cast(Dict[str, Any], args) - # pyright: ignore[reportUnknownMemberType] result = await session.call_tool( name="codemcp", arguments=tool_args ) diff --git a/codemcp/testing.py b/codemcp/testing.py index e762f759..44b94407 100644 --- a/codemcp/testing.py +++ b/codemcp/testing.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# pyright: reportUnknownArgumentType=false, reportUnknownMemberType=false, reportUnknownVariableType=false + import asyncio import os diff --git a/pyproject.toml b/pyproject.toml index 07b1b37f..d6b1ea63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,3 +85,18 @@ reportUntypedFunctionDecorator = true reportFunctionMemberAccess = true reportIncompatibleMethodOverride = true stubPath = "./stubs" + +# Type stub package mappings +stubPackages = [ + { source = "tomli", stub = "tomli_stubs" }, + { source = "mcp", stub = "mcp_stubs" } +] + +# For testing code specific ignores +[[tool.pyright.ignoreExtraErrors]] +path = "codemcp/hot_reload_entry.py" +errorCodes = ["reportUnknownMemberType"] + +[[tool.pyright.ignoreExtraErrors]] +path = "codemcp/testing.py" +errorCodes = ["reportUnknownMemberType", "reportUnknownArgumentType", "reportUnknownVariableType"] diff --git a/stubs/mcp_stubs/ClientSession.pyi b/stubs/mcp_stubs/ClientSession.pyi new file mode 100644 index 00000000..a5124c5e --- /dev/null +++ b/stubs/mcp_stubs/ClientSession.pyi @@ -0,0 +1,72 @@ +"""Type stubs for the mcp.ClientSession class. + +This module provides type definitions for the mcp.ClientSession class. +""" + +from typing import ( + Any, + Dict, + List, + Optional, + Protocol, + TypeVar, + Union, + AsyncContextManager, + Callable, + Awaitable, + Tuple, + Coroutine, +) +import asyncio + +T = TypeVar("T") + +class CallToolResult: + """Result of calling a tool via MCP.""" + + isError: bool + content: Union[str, List["TextContent"], Any] + +class TextContent: + """A class representing text content.""" + + text: str + + def __init__(self, text: str) -> None: + """Initialize a new TextContent instance. + + Args: + text: The text content + """ + ... + +class ClientSession: + """A session for interacting with an MCP server.""" + + def __init__(self, read: Any, write: Any) -> None: + """Initialize a new ClientSession. + + Args: + read: A callable that reads from the server + write: A callable that writes to the server + """ + ... + + async def initialize(self) -> None: + """Initialize the session.""" + ... + + async def call_tool(self, name: str, arguments: Dict[str, Any]) -> CallToolResult: + """Call a tool on the MCP server. + + Args: + name: The name of the tool to call + arguments: Dictionary of arguments to pass to the tool + + Returns: + An object with isError and content attributes + """ + ... + + async def __aenter__(self) -> "ClientSession": ... + async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: ... diff --git a/stubs/mcp_stubs/__init__.pyi b/stubs/mcp_stubs/__init__.pyi new file mode 100644 index 00000000..758d0059 --- /dev/null +++ b/stubs/mcp_stubs/__init__.pyi @@ -0,0 +1,72 @@ +"""Type stubs for the mcp (Model Context Protocol) package. + +This module provides type definitions for the mcp package to help with +type checking when using the MCP SDK. +""" + +from typing import ( + Any, + Dict, + List, + Optional, + Protocol, + TypeVar, + Union, + AsyncContextManager, + Callable, + Awaitable, + Tuple, + Generic, + Coroutine, +) +import asyncio +from pathlib import Path +import os + +# Export ClientSession at the top level +from .ClientSession import ClientSession + +# Export StdioServerParameters at the top level +class StdioServerParameters: + """Parameters for connecting to an MCP server via stdio.""" + + def __init__( + self, + command: str, + args: List[str], + env: Optional[Dict[str, str]] = None, + cwd: Optional[str] = None, + ) -> None: + """Initialize parameters for connecting to an MCP server. + + Args: + command: The command to run + args: Arguments to pass to the command + env: Environment variables to set + cwd: Working directory for the command + """ + ... + +# Re-export from client.stdio +from .client.stdio import stdio_client + +# Type for MCP content items +class TextContent: + """A class representing text content.""" + + text: str + + def __init__(self, text: str) -> None: + """Initialize a new TextContent instance. + + Args: + text: The text content + """ + ... + +# Type for API call results +class CallToolResult: + """Result of calling a tool via MCP.""" + + isError: bool + content: Union[str, List[TextContent], Any] diff --git a/stubs/mcp_stubs/client/__init__.pyi b/stubs/mcp_stubs/client/__init__.pyi new file mode 100644 index 00000000..ce2e2e1b --- /dev/null +++ b/stubs/mcp_stubs/client/__init__.pyi @@ -0,0 +1,18 @@ +"""Type stubs for the mcp.client package. + +This module provides type definitions for the mcp.client package. +""" + +from typing import ( + Any, + Dict, + List, + Optional, + Protocol, + TypeVar, + Union, + AsyncContextManager, + Callable, + Awaitable, + Tuple, +) diff --git a/stubs/mcp_stubs/client/stdio.pyi b/stubs/mcp_stubs/client/stdio.pyi new file mode 100644 index 00000000..382c7bab --- /dev/null +++ b/stubs/mcp_stubs/client/stdio.pyi @@ -0,0 +1,34 @@ +"""Type stubs for the mcp.client.stdio module. + +This module provides type definitions for the mcp.client.stdio module. +""" + +from typing import ( + Any, + Dict, + List, + Optional, + Protocol, + TypeVar, + Union, + AsyncContextManager, + Callable, + Awaitable, + Tuple, + AsyncGenerator, +) +import asyncio +from .. import StdioServerParameters + +async def stdio_client( + server_params: StdioServerParameters, **kwargs: Any +) -> AsyncContextManager[Tuple[Any, Any]]: + """Create a stdio client connected to an MCP server. + + Args: + server_params: Parameters for connecting to the server + + Returns: + A context manager that yields (read, write) handles + """ + ... diff --git a/stubs/mcp_stubs/server/__init__.pyi b/stubs/mcp_stubs/server/__init__.pyi new file mode 100644 index 00000000..5ca9db78 --- /dev/null +++ b/stubs/mcp_stubs/server/__init__.pyi @@ -0,0 +1,6 @@ +"""Type stubs for the mcp.server package. + +This module provides type definitions for the mcp.server package. +""" + +from typing import Any, Dict, List, Optional, Protocol, TypeVar, Union, Callable diff --git a/stubs/mcp_stubs/server/fastmcp.pyi b/stubs/mcp_stubs/server/fastmcp.pyi new file mode 100644 index 00000000..cb4f804e --- /dev/null +++ b/stubs/mcp_stubs/server/fastmcp.pyi @@ -0,0 +1,47 @@ +"""Type stubs for the mcp.server.fastmcp module. + +This module provides type definitions for the mcp.server.fastmcp module. +""" + +from typing import ( + Any, + Dict, + List, + Optional, + Protocol, + TypeVar, + Union, + Callable, + Type, + TypeVar, + overload, + cast, +) + +F = TypeVar("F", bound=Callable[..., Any]) + +class FastMCP: + """MCP server implementation using FastAPI. + + This class provides a way to define and register tools for an MCP server. + """ + + def __init__(self, name: str) -> None: + """Initialize a new FastMCP server. + + Args: + name: The name of the server + """ + ... + + def tool(self) -> Callable[[F], F]: + """Decorator for registering a function as a tool. + + Returns: + A decorator function that registers the decorated function as a tool + """ + ... + + def run(self) -> None: + """Run the server.""" + ... diff --git a/stubs/mcp_stubs/types.pyi b/stubs/mcp_stubs/types.pyi new file mode 100644 index 00000000..49e97068 --- /dev/null +++ b/stubs/mcp_stubs/types.pyi @@ -0,0 +1,19 @@ +"""Type stubs for the mcp.types module. + +This module provides type definitions for the mcp.types module. +""" + +from typing import Any, Dict, List, Optional, Protocol, TypeVar, Union + +class TextContent: + """A class representing text content.""" + + text: str + + def __init__(self, text: str) -> None: + """Initialize a new TextContent instance. + + Args: + text: The text content + """ + ... diff --git a/stubs/tomli_stubs/__init__.pyi b/stubs/tomli_stubs/__init__.pyi new file mode 100644 index 00000000..42fb22bd --- /dev/null +++ b/stubs/tomli_stubs/__init__.pyi @@ -0,0 +1,64 @@ +"""Type stubs for tomli package. + +This module provides type definitions for the tomli package to help with +type checking when parsing TOML files. +""" + +from typing import ( + Any, + Dict, + List, + Union, + IO, + Callable, + Optional, + TypeVar, + overload, + cast, +) + +# Define more specific types for TOML data structures +TOMLPrimitive = Union[str, int, float, bool, None] +TOMLArray = List["TOMLValue"] +TOMLTable = Dict[str, "TOMLValue"] +TOMLValue = Union[TOMLPrimitive, TOMLArray, TOMLTable] + +# Specific types for command config +CommandList = List[str] +CommandDict = Dict[str, CommandList] +CommandConfig = Union[CommandList, CommandDict] + +def load(file_obj: IO[bytes]) -> Dict[str, Any]: + """Parse a file as TOML and return a dict. + + Args: + file_obj: A binary file object. + + Returns: + A dict mapping string keys to complex nested structures of + strings, ints, floats, lists, and dicts. + + Raises: + TOMLDecodeError: When a TOML formatted file can't be parsed. + """ + ... + +def loads(s: str) -> Dict[str, Any]: + """Parse a string as TOML and return a dict. + + Args: + s: String containing TOML formatted text. + + Returns: + A dict mapping string keys to complex nested structures of + strings, ints, floats, lists, and dicts. + + Raises: + TOMLDecodeError: When a TOML formatted string can't be parsed. + """ + ... + +class TOMLDecodeError(ValueError): + """Error raised when decoding TOML fails.""" + + pass