longhao 6b14ff6da5 feat: add advanced dependency resolution and package download tools
- Add DependencyParser for parsing and categorizing package dependencies
- Add DependencyResolver for recursive dependency tree analysis
- Add PackageDownloader for downloading packages with dependencies
- Add resolve_dependencies MCP tool for comprehensive dependency analysis
- Add download_package MCP tool for package collection
- Support Python version filtering and extra dependencies
- Include comprehensive test coverage for new functionality
- Add demonstration script for new features
- Update README with new capabilities and usage examples

Signed-off-by: Hal <hal.long@outlook.com>
2025-05-27 19:06:18 +08:00

431 lines
16 KiB
Python

"""FastMCP server for PyPI package queries."""
import logging
from typing import Any
import click
from fastmcp import FastMCP
from .core.exceptions import InvalidPackageNameError, NetworkError, PackageNotFoundError
from .tools import (
check_python_compatibility,
download_package_with_dependencies,
get_compatible_python_versions,
query_package_dependencies,
query_package_info,
query_package_versions,
resolve_package_dependencies,
)
# Configure logging
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
# Create FastMCP application
mcp = FastMCP("PyPI Query MCP Server")
@mcp.tool()
async def get_package_info(package_name: str) -> dict[str, Any]:
"""Query comprehensive information about a PyPI package.
This tool retrieves detailed information about a Python package from PyPI,
including metadata, description, author information, dependencies, and more.
Args:
package_name: The name of the PyPI package to query (e.g., 'requests', 'django')
Returns:
Dictionary containing comprehensive package information including:
- Basic metadata (name, version, summary, description)
- Author and maintainer information
- License and project URLs
- Python version requirements
- Dependencies and classifiers
- Version history summary
Raises:
InvalidPackageNameError: If package name is empty or invalid
PackageNotFoundError: If package is not found on PyPI
NetworkError: For network-related errors
"""
try:
logger.info(f"MCP tool: Querying package info for {package_name}")
result = await query_package_info(package_name)
logger.info(f"Successfully retrieved info for package: {package_name}")
return result
except (InvalidPackageNameError, PackageNotFoundError, NetworkError) as e:
logger.error(f"Error querying package {package_name}: {e}")
return {
"error": str(e),
"error_type": type(e).__name__,
"package_name": package_name,
}
except Exception as e:
logger.error(f"Unexpected error querying package {package_name}: {e}")
return {
"error": f"Unexpected error: {e}",
"error_type": "UnexpectedError",
"package_name": package_name,
}
@mcp.tool()
async def get_package_versions(package_name: str) -> dict[str, Any]:
"""Get version information for a PyPI package.
This tool retrieves comprehensive version information for a Python package,
including all available versions, release details, and distribution formats.
Args:
package_name: The name of the PyPI package to query (e.g., 'requests', 'numpy')
Returns:
Dictionary containing version information including:
- Latest version and total version count
- List of all available versions (sorted)
- Recent versions with release details
- Distribution format information (wheel, source)
Raises:
InvalidPackageNameError: If package name is empty or invalid
PackageNotFoundError: If package is not found on PyPI
NetworkError: For network-related errors
"""
try:
logger.info(f"MCP tool: Querying versions for {package_name}")
result = await query_package_versions(package_name)
logger.info(f"Successfully retrieved versions for package: {package_name}")
return result
except (InvalidPackageNameError, PackageNotFoundError, NetworkError) as e:
logger.error(f"Error querying versions for {package_name}: {e}")
return {
"error": str(e),
"error_type": type(e).__name__,
"package_name": package_name,
}
except Exception as e:
logger.error(f"Unexpected error querying versions for {package_name}: {e}")
return {
"error": f"Unexpected error: {e}",
"error_type": "UnexpectedError",
"package_name": package_name,
}
@mcp.tool()
async def get_package_dependencies(
package_name: str, version: str | None = None
) -> dict[str, Any]:
"""Get dependency information for a PyPI package.
This tool retrieves comprehensive dependency information for a Python package,
including runtime dependencies, development dependencies, and optional dependencies.
Args:
package_name: The name of the PyPI package to query (e.g., 'django', 'flask')
version: Specific version to query (optional, defaults to latest version)
Returns:
Dictionary containing dependency information including:
- Runtime dependencies and development dependencies
- Optional dependency groups
- Python version requirements
- Dependency counts and summary statistics
Raises:
InvalidPackageNameError: If package name is empty or invalid
PackageNotFoundError: If package is not found on PyPI
NetworkError: For network-related errors
"""
try:
logger.info(
f"MCP tool: Querying dependencies for {package_name}"
+ (f" version {version}" if version else " (latest)")
)
result = await query_package_dependencies(package_name, version)
logger.info(f"Successfully retrieved dependencies for package: {package_name}")
return result
except (InvalidPackageNameError, PackageNotFoundError, NetworkError) as e:
logger.error(f"Error querying dependencies for {package_name}: {e}")
return {
"error": str(e),
"error_type": type(e).__name__,
"package_name": package_name,
"version": version,
}
except Exception as e:
logger.error(f"Unexpected error querying dependencies for {package_name}: {e}")
return {
"error": f"Unexpected error: {e}",
"error_type": "UnexpectedError",
"package_name": package_name,
"version": version,
}
@mcp.tool()
async def check_package_python_compatibility(
package_name: str, target_python_version: str, use_cache: bool = True
) -> dict[str, Any]:
"""Check if a package is compatible with a specific Python version.
This tool analyzes a package's Python version requirements and determines
if it's compatible with your target Python version.
Args:
package_name: The name of the PyPI package to check (e.g., 'django', 'requests')
target_python_version: Target Python version to check (e.g., '3.9', '3.10.5', '3.11')
use_cache: Whether to use cached package data (default: True)
Returns:
Dictionary containing detailed compatibility information including:
- Compatibility status (True/False)
- Source of compatibility information (requires_python or classifiers)
- Detailed analysis and suggestions
- Package version requirements
Raises:
InvalidPackageNameError: If package name is empty or invalid
PackageNotFoundError: If package is not found on PyPI
NetworkError: For network-related errors
"""
try:
logger.info(
f"MCP tool: Checking Python {target_python_version} compatibility for {package_name}"
)
result = await check_python_compatibility(
package_name, target_python_version, use_cache
)
logger.info(f"Compatibility check completed for {package_name}")
return result
except (InvalidPackageNameError, PackageNotFoundError, NetworkError) as e:
logger.error(f"Error checking compatibility for {package_name}: {e}")
return {
"error": str(e),
"error_type": type(e).__name__,
"package_name": package_name,
"target_python_version": target_python_version,
}
except Exception as e:
logger.error(f"Unexpected error checking compatibility for {package_name}: {e}")
return {
"error": f"Unexpected error: {e}",
"error_type": "UnexpectedError",
"package_name": package_name,
"target_python_version": target_python_version,
}
@mcp.tool()
async def get_package_compatible_python_versions(
package_name: str, python_versions: list[str] | None = None, use_cache: bool = True
) -> dict[str, Any]:
"""Get all Python versions compatible with a package.
This tool analyzes a package and returns which Python versions are
compatible with it, along with recommendations.
Args:
package_name: The name of the PyPI package to analyze (e.g., 'numpy', 'pandas')
python_versions: List of Python versions to check (optional, defaults to common versions)
use_cache: Whether to use cached package data (default: True)
Returns:
Dictionary containing compatibility information including:
- List of compatible Python versions
- List of incompatible versions with reasons
- Compatibility rate and recommendations
- Package version requirements
Raises:
InvalidPackageNameError: If package name is empty or invalid
PackageNotFoundError: If package is not found on PyPI
NetworkError: For network-related errors
"""
try:
logger.info(f"MCP tool: Getting compatible Python versions for {package_name}")
result = await get_compatible_python_versions(
package_name, python_versions, use_cache
)
logger.info(f"Compatible versions analysis completed for {package_name}")
return result
except (InvalidPackageNameError, PackageNotFoundError, NetworkError) as e:
logger.error(f"Error getting compatible versions for {package_name}: {e}")
return {
"error": str(e),
"error_type": type(e).__name__,
"package_name": package_name,
}
except Exception as e:
logger.error(
f"Unexpected error getting compatible versions for {package_name}: {e}"
)
return {
"error": f"Unexpected error: {e}",
"error_type": "UnexpectedError",
"package_name": package_name,
}
@mcp.tool()
async def resolve_dependencies(
package_name: str,
python_version: str | None = None,
include_extras: list[str] | None = None,
include_dev: bool = False,
max_depth: int = 5
) -> dict[str, Any]:
"""Resolve all dependencies for a PyPI package recursively.
This tool performs comprehensive dependency resolution for a Python package,
analyzing the complete dependency tree including transitive dependencies.
Args:
package_name: The name of the PyPI package to analyze (e.g., 'pyside2', 'django')
python_version: Target Python version for dependency filtering (e.g., '3.10', '3.11')
include_extras: List of extra dependency groups to include (e.g., ['dev', 'test'])
include_dev: Whether to include development dependencies (default: False)
max_depth: Maximum recursion depth for dependency resolution (default: 5)
Returns:
Dictionary containing comprehensive dependency analysis including:
- Complete dependency tree with all transitive dependencies
- Dependency categorization (runtime, development, extras)
- Package metadata for each dependency
- Summary statistics and analysis
Raises:
InvalidPackageNameError: If package name is empty or invalid
PackageNotFoundError: If package is not found on PyPI
NetworkError: For network-related errors
"""
try:
logger.info(
f"MCP tool: Resolving dependencies for {package_name} "
f"(Python {python_version}, extras: {include_extras})"
)
result = await resolve_package_dependencies(
package_name=package_name,
python_version=python_version,
include_extras=include_extras,
include_dev=include_dev,
max_depth=max_depth
)
logger.info(f"Successfully resolved dependencies for package: {package_name}")
return result
except (InvalidPackageNameError, PackageNotFoundError, NetworkError) as e:
logger.error(f"Error resolving dependencies for {package_name}: {e}")
return {
"error": str(e),
"error_type": type(e).__name__,
"package_name": package_name,
"python_version": python_version,
}
except Exception as e:
logger.error(f"Unexpected error resolving dependencies for {package_name}: {e}")
return {
"error": f"Unexpected error: {e}",
"error_type": "UnexpectedError",
"package_name": package_name,
"python_version": python_version,
}
@mcp.tool()
async def download_package(
package_name: str,
download_dir: str = "./downloads",
python_version: str | None = None,
include_extras: list[str] | None = None,
include_dev: bool = False,
prefer_wheel: bool = True,
verify_checksums: bool = True,
max_depth: int = 5
) -> dict[str, Any]:
"""Download a PyPI package and all its dependencies to local directory.
This tool downloads a Python package and all its dependencies, providing
comprehensive package collection for offline installation or analysis.
Args:
package_name: The name of the PyPI package to download (e.g., 'pyside2', 'requests')
download_dir: Local directory to download packages to (default: './downloads')
python_version: Target Python version for compatibility (e.g., '3.10', '3.11')
include_extras: List of extra dependency groups to include (e.g., ['dev', 'test'])
include_dev: Whether to include development dependencies (default: False)
prefer_wheel: Whether to prefer wheel files over source distributions (default: True)
verify_checksums: Whether to verify downloaded file checksums (default: True)
max_depth: Maximum dependency resolution depth (default: 5)
Returns:
Dictionary containing download results including:
- Download statistics and file information
- Dependency resolution results
- File verification results
- Success/failure summary for each package
Raises:
InvalidPackageNameError: If package name is empty or invalid
PackageNotFoundError: If package is not found on PyPI
NetworkError: For network-related errors
"""
try:
logger.info(
f"MCP tool: Downloading {package_name} and dependencies to {download_dir} "
f"(Python {python_version})"
)
result = await download_package_with_dependencies(
package_name=package_name,
download_dir=download_dir,
python_version=python_version,
include_extras=include_extras,
include_dev=include_dev,
prefer_wheel=prefer_wheel,
verify_checksums=verify_checksums,
max_depth=max_depth
)
logger.info(f"Successfully downloaded {package_name} and dependencies")
return result
except (InvalidPackageNameError, PackageNotFoundError, NetworkError) as e:
logger.error(f"Error downloading {package_name}: {e}")
return {
"error": str(e),
"error_type": type(e).__name__,
"package_name": package_name,
"download_dir": download_dir,
}
except Exception as e:
logger.error(f"Unexpected error downloading {package_name}: {e}")
return {
"error": f"Unexpected error: {e}",
"error_type": "UnexpectedError",
"package_name": package_name,
"download_dir": download_dir,
}
@click.command()
@click.option(
"--log-level",
default="INFO",
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"]),
help="Logging level",
)
def main(log_level: str) -> None:
"""Start the PyPI Query MCP Server."""
# Set logging level
logging.getLogger().setLevel(getattr(logging, log_level))
logger.info("Starting PyPI Query MCP Server")
logger.info(f"Log level set to: {log_level}")
# Run the FastMCP server (uses STDIO transport by default)
mcp.run()
if __name__ == "__main__":
main()