diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c6024c9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,58 @@ +name: CI + +on: + workflow_dispatch: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup uv + uses: astral-sh/setup-uv@v3 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install dependencies + run: uv pip install --system -e ".[dev]" + + - name: Install pre-commit + run: uv pip install --system pre-commit + + - name: Run pre-commit + run: pre-commit run --all-files + + test: + runs-on: ubuntu-latest + env: + CI: 1 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup uv + uses: astral-sh/setup-uv@v3 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install dependencies + run: uv pip install --system -e ".[dev]" + + - name: Run unit tests + run: uv run pytest tests/unit/ -v + + - name: Run integration tests + run: uv run pytest tests/integration/ -v diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a70d895..052dd83 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,18 +5,26 @@ repos: - id: no-commit-to-branch args: [--branch, main] - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.8.4 + - repo: local hooks: - id: ruff + name: ruff + entry: uv run ruff check args: [--fix, --exit-non-zero-on-fix] + language: system + types: [python] + require_serial: true + - id: ruff-format + name: ruff-format + entry: uv run ruff format + language: system + types: [python] + require_serial: true - - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.13.0 - hooks: - id: mypy - additional_dependencies: - - pytest - - types-requests - files: ^(src/|servers/|tests/) + name: mypy + entry: uv run mypy + language: system + types: [python] + files: ^(src/|tests/) diff --git a/README.md b/README.md index bbe2d0a..f9d379f 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,11 @@ cd wags uv venv source .venv/bin/activate -# Install the package in development mode +# Install with dev dependencies (for testing and linting) uv pip install -e ".[dev]" + +# Optional: Install with evaluation dependencies for running benchmarks +uv pip install -e ".[dev,evals]" ``` ### Verify Installation @@ -190,6 +193,13 @@ WAGS includes evaluation support for the Berkeley Function Call Leaderboard (BFC ### Setup +First, install the evaluation dependencies: + +```bash +# Install evaluation dependencies (BFCL, fast-agent, etc.) +uv pip install -e ".[dev,evals]" +``` + If you cloned the repository without submodules, initialize them: ```bash diff --git a/docs/snippets/quickstart/handlers.py b/docs/snippets/quickstart/handlers.py index 2e6fdf6..fb31203 100644 --- a/docs/snippets/quickstart/handlers.py +++ b/docs/snippets/quickstart/handlers.py @@ -20,10 +20,7 @@ async def create_issue( repo: str, # Allow user to review and edit these fields # before actually invoking the tool - title: Annotated[str, RequiresElicitation( - "Title")], - body: Annotated[str, RequiresElicitation( - "Body" - )] + title: Annotated[str, RequiresElicitation("Title")], + body: Annotated[str, RequiresElicitation("Body")], ): pass diff --git a/docs/snippets/quickstart/main.py b/docs/snippets/quickstart/main.py index a4058e0..235b037 100644 --- a/docs/snippets/quickstart/main.py +++ b/docs/snippets/quickstart/main.py @@ -18,4 +18,5 @@ if __name__ == "__main__": import asyncio + asyncio.run(mcp.run_stdio_async()) diff --git a/pyproject.toml b/pyproject.toml index dc89d9a..ee4a023 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,12 +10,11 @@ requires-python = ">=3.13.5" dependencies = [ "mcp @ git+https://github.com/chughtapan/python-sdk.git@wags-dev", - "fast-agent-mcp @ git+https://github.com/chughtapan/fast-agent.git@wags-dev", # TODO: also move into evals section + "fast-agent-mcp @ git+https://github.com/chughtapan/fast-agent.git@wags-dev", "fastmcp @ git+https://github.com/chughtapan/fastmcp.git@wags-dev", "cyclopts>=2.0.0", "rich>=13.0.0", "jinja2>=3.0.0", - "mpmath>=1.3.0", # TODO: Remove (only used for BFCL evals) ] [project.scripts] @@ -30,6 +29,9 @@ dev = [ "pytest-asyncio>=0.21", "ruff", "mypy", +] + +evals = [ "bfcl-eval", ] @@ -54,6 +56,12 @@ select = [ ] ignore = ["PERF203", "PLC0415", "PLR0402"] +[tool.ruff.lint.per-file-ignores] +# Handler methods have many required parameters matching API signatures +"servers/*/handlers.py" = ["PLR0913"] +# CLI commands naturally have many parameters (one per flag/option) +"src/wags/cli/main.py" = ["PLR0913"] + [tool.ruff.lint.pylint] allow-magic-value-types = ["bytes", "float", "int", "str"] diff --git a/servers/github/main.py b/servers/github/main.py index ef3f992..3a8db80 100644 --- a/servers/github/main.py +++ b/servers/github/main.py @@ -2,11 +2,11 @@ from pathlib import Path +from handlers import GithubHandlers + from wags import create_proxy, load_config from wags.middleware import ElicitationMiddleware, RootsMiddleware -from handlers import GithubHandlers - # Load config and create proxy server config = load_config(Path(__file__).parent / "config.json") mcp = create_proxy(config, "github-proxy") diff --git a/servers/github/test_non_file_roots.py b/servers/github/test_non_file_roots.py deleted file mode 100644 index c2ff6ab..0000000 --- a/servers/github/test_non_file_roots.py +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env python -"""Test script demonstrating non-file roots with GitHub MCP server. - -This shows how RootsMiddleware controls access to GitHub repos using URLs. -""" - -import asyncio -import os -from pathlib import Path - -from fast_agent import FastAgent - - -async def test_github_roots() -> None: - """Test GitHub non-file roots feature.""" - - # Check for GitHub token - if not os.getenv("GITHUB_PERSONAL_ACCESS_TOKEN"): - print("⚠️ Please set GITHUB_PERSONAL_ACCESS_TOKEN environment variable") - print(" Create token at: https://github.com/settings/tokens") - return - - # Use the config with roots configured - config_path = Path(__file__).parent / "fastagent.config.yaml" - - # Create FastAgent - fast = FastAgent("GitHubRootsTest", config_path=str(config_path)) - - print("\n" + "=" * 60) - print("GitHub Non-File Roots Test") - print("=" * 60) - print("\nThis test demonstrates how RootsMiddleware controls") - print("access to GitHub repositories using URL-based roots.\n") - print("Configured roots:") - print(" - https://github.com/anthropics/courses (specific repo)") - print(" - https://github.com/modelcontextprotocol/* (entire org)") - print() - - @fast.agent( - name="test_agent", - model="gpt-4o-mini", - servers=["github"], - instruction="You are a concise GitHub assistant. Always use the available GitHub tools to fulfill requests.", - ) - async def test_agent() -> None: - pass - - async with fast.run() as agent_app: - # Test 1: Allowed specific repo - print("Test 1: Accessing allowed repository (anthropics/courses)") - print("-" * 40) - response = await agent_app.send("List any open issues in anthropics/courses") - print(f"Response: {response}") - await asyncio.sleep(1) - - # Test 2: Allowed org (any repo in modelcontextprotocol) - print("\nTest 2: Accessing allowed org (modelcontextprotocol/*)") - print("-" * 40) - response = await agent_app.send("Check for issues in modelcontextprotocol/servers") - print(f"Response: {response}") - await asyncio.sleep(1) - - # Test 3: Denied repo (not in roots) - print("\nTest 3: Attempting denied repository (github/docs)") - print("-" * 40) - response = await agent_app.send("List issues in github/docs repository") - print(f"Response: {response}") - await asyncio.sleep(1) - - # Analyze results - messages = agent_app._agent(None).message_history - - print("\n" + "=" * 60) - print("Results:") - print("=" * 60) - - # Check for access control - access_denied = False - tool_calls = 0 - for msg in messages: - # Check for tool calls - if hasattr(msg, "tool_calls") and msg.tool_calls: - tool_calls += len(msg.tool_calls) - - # Check for access denials - if hasattr(msg, "content") and msg.content: - content = str(msg.content) - if "Access denied" in content or "not in allowed roots" in content: - access_denied = True - print("✓ Access control working - denied unauthorized access") - - print(f"\nTotal tool calls made: {tool_calls}") - - if not access_denied: - print("⚠️ No explicit access denial found in responses") - print(" (The agent may have handled the denial gracefully)") - - print("\nExpected behavior:") - print(" ✓ Allowed access to anthropics/courses") - print(" ✓ Allowed access to modelcontextprotocol/* repos") - print(" ✗ Denied access to github/docs (not in roots)") - - print("\n✅ Test complete!") - - -if __name__ == "__main__": - asyncio.run(test_github_roots()) diff --git a/src/wags/cli/main.py b/src/wags/cli/main.py index 22148f7..debe659 100644 --- a/src/wags/cli/main.py +++ b/src/wags/cli/main.py @@ -7,6 +7,7 @@ import cyclopts from fastmcp import __version__ as fastmcp_version from fastmcp.utilities.logging import get_logger +from mcp.types import Tool from rich.console import Console from wags import __version__ @@ -53,11 +54,57 @@ def run( sys.exit(1) +def _check_overwrite(file_path: Path, force: bool) -> bool: + """Check if file can be overwritten.""" + if not file_path.exists(): + return True + if force: + console.print(f"[yellow]Overwriting existing file:[/yellow] {file_path}") + return True + from rich.prompt import Confirm + + return Confirm.ask(f"File {file_path} already exists. Overwrite?", default=False) + + +def _generate_handlers_file(handlers_path: Path, class_name: str, tools: list[Tool], force: bool) -> None: + """Generate and write handlers file.""" + from wags.utils.handlers_generator import generate_handlers_class + + if _check_overwrite(handlers_path, force): + console.print("[cyan]Generating handlers file...[/cyan]") + handlers_code = generate_handlers_class(class_name, tools) + handlers_path.write_text(handlers_code) + console.print(f"[green]Created:[/green] {handlers_path}") + else: + console.print("[yellow]Skipped handlers file[/yellow]") + + +def _generate_main_file(main_path: Path, handlers_file: str, class_name: str, config_name: str, force: bool) -> None: + """Generate and write main file.""" + from jinja2 import Template + + if _check_overwrite(main_path, force): + console.print("[cyan]Generating main file...[/cyan]") + templates_dir = Path(__file__).parent.parent / "templates" + with open(templates_dir / "main.py.j2") as f: + template = Template(f.read()) + handlers_module = Path(handlers_file).stem + main_code = template.render( + handlers_module=handlers_module, + class_name=class_name, + config_filename=config_name, + server_name="wags-proxy", + ) + main_path.write_text(main_code) + console.print(f"[green]Created:[/green] {main_path}") + else: + console.print("[yellow]Skipped main file[/yellow]") + + @app.command def quickstart( config: Path, *, - server_name: str | None = None, handlers_file: str | None = None, main_file: str | None = None, class_name: str | None = None, @@ -69,7 +116,6 @@ def quickstart( Args: config: Path to MCP config.json file - server_name: Name of the server in config (defaults to first server) handlers_file: Output path for handlers file (defaults to handlers.py) main_file: Output path for main file (defaults to main.py) class_name: Name for the handlers class (defaults to auto-generated) @@ -77,45 +123,58 @@ def quickstart( only_main: Only generate main file force: Overwrite existing files without asking """ - from wags.utils.quickstart import run_quickstart + from wags.utils.config import load_config + from wags.utils.handlers_generator import introspect_server try: - asyncio.run( - run_quickstart( - config_path=config, - server_name=server_name, - handlers_file=handlers_file, - main_file=main_file, - class_name=class_name, - only_handlers=only_handlers, - only_main=only_main, - force=force, + config_dir = config.parent + handlers_file = handlers_file or "handlers.py" + main_file = main_file or "main.py" + handlers_path = config_dir / handlers_file + main_path = config_dir / main_file + + if only_main and only_handlers: + console.print("[red]Error: Cannot specify both --only-handlers and --only-main[/red]") + sys.exit(1) + + if only_main and not handlers_path.exists(): + console.print( + f"[red]Error: Handlers file {handlers_path} does not exist. " + "Generate it first or specify --handlers-file[/red]" ) - ) - except Exception as e: - logger.error(f"Failed to run quickstart: {e}") - sys.exit(1) + sys.exit(1) + load_config(config) -@app.command -def init( - name: str, - *, - path: Path | None = None, -) -> None: - """Initialize a new server with middleware scaffold. + tools = [] + if not only_main: + console.print("[cyan]Connecting to MCP server to discover tools...[/cyan]") + # Handle both sync and async contexts + try: + asyncio.get_running_loop() + # We're in an async context, run in a thread to avoid event loop conflict + import concurrent.futures - Args: - name: Name for the new server - path: Directory to create server in (defaults to servers/{name}) - """ - from wags.utils.server_template import create_server_scaffold + with concurrent.futures.ThreadPoolExecutor() as executor: + future = executor.submit(asyncio.run, introspect_server(config)) + tools = future.result() + except RuntimeError: + # No event loop running, use asyncio.run + tools = asyncio.run(introspect_server(config)) + console.print(f"[green]Found {len(tools)} tools[/green]") + + class_name = class_name or "Handlers" + + if not only_main: + _generate_handlers_file(handlers_path, class_name, tools, force) + + if not only_handlers: + _generate_main_file(main_path, handlers_file, class_name, config.name, force) + + console.print("\n[bold green]✅ Quickstart complete![/bold green]") - try: - create_server_scaffold(name, path) - console.print(f"[green]✓[/green] Created server scaffold at {path or f'servers/{name}'}") except Exception as e: - logger.error(f"Failed to initialize server: {e}") + logger.error(f"Failed to run quickstart: {e}") sys.exit(1) diff --git a/src/wags/middleware/todo.py b/src/wags/middleware/todo.py index 678caf8..c0827a6 100644 --- a/src/wags/middleware/todo.py +++ b/src/wags/middleware/todo.py @@ -5,7 +5,7 @@ from fastmcp import FastMCP from pydantic import BaseModel, Field -# Instructions embedded in this module +# ruff: noqa: E501 TODO_INSTRUCTIONS = """ # Task Management (MANDATORY) You have access to the TodoWrite tools to help you manage and plan tasks. Use these tools VERY frequently to ensure that you are tracking your tasks and giving the user visibility into your progress. diff --git a/src/wags/templates/main.py.j2 b/src/wags/templates/main.py.j2 index e568c45..46c083f 100644 --- a/src/wags/templates/main.py.j2 +++ b/src/wags/templates/main.py.j2 @@ -1,23 +1,18 @@ -"""Main entry point for {{ name }} server.""" +"""WAGS proxy server with middleware.""" from pathlib import Path from wags import create_proxy, load_config from wags.middleware import ElicitationMiddleware, RootsMiddleware -from .handlers import {{ class_name }} +from {{ handlers_module }} import {{ class_name }} -# Load config and create proxy server -config = load_config(Path(__file__).parent / "config.json") -mcp = create_proxy(config, "{{ name }}-proxy") +config = load_config(Path(__file__).parent / "{{ config_filename }}") +mcp = create_proxy(config, server_name="{{ server_name }}") -# Initialize handlers handlers = {{ class_name }}() -# Add middleware stack # mcp.add_middleware(RootsMiddleware(handlers=handlers)) # mcp.add_middleware(ElicitationMiddleware(handlers=handlers)) -# Run the server when executed directly if __name__ == "__main__": - import asyncio - asyncio.run(mcp.run_stdio_async()) \ No newline at end of file + mcp.run() \ No newline at end of file diff --git a/src/wags/utils/config.py b/src/wags/utils/config.py index 388c643..9a4fdd8 100644 --- a/src/wags/utils/config.py +++ b/src/wags/utils/config.py @@ -74,3 +74,22 @@ def load_config(config_path: Path | str) -> dict[str, Any]: server_config["env"] = _substitute_env_vars(server_config["env"]) return config + + +def resolve_server_name(config: dict[str, Any], server_name: str | None = None) -> str: + """Resolve server name from config.""" + if "mcpServers" not in config: + raise ValueError("Config file must have 'mcpServers' section") + + servers = config["mcpServers"] + if not servers: + raise ValueError("No servers found in config") + + if server_name is None: + if len(servers) > 1: + raise ValueError(f"Multiple servers found, please specify one: {', '.join(servers.keys())}") + server_name = next(iter(servers)) + elif server_name not in servers: + raise ValueError(f"Server '{server_name}' not found. Available: {', '.join(servers.keys())}") + + return server_name diff --git a/src/wags/utils/handlers_generator.py b/src/wags/utils/handlers_generator.py index 54a392a..f7abc68 100644 --- a/src/wags/utils/handlers_generator.py +++ b/src/wags/utils/handlers_generator.py @@ -11,7 +11,7 @@ from fastmcp import Client from mcp.types import Tool -from wags import load_config +from wags.utils.config import load_config def json_schema_to_python_type(schema: dict[str, Any]) -> str: @@ -157,6 +157,15 @@ class {class_name}: return code +async def introspect_server(config_path: Path) -> list[Tool]: + """Connect to MCP server and get its tools.""" + config = load_config(config_path) + client = Client(config, roots=[]) + async with client: + tools = await client.list_tools() + return tools + + async def generate_handlers_stub( config_path: Path, server_name: str | None = None, diff --git a/src/wags/utils/quickstart.py b/src/wags/utils/quickstart.py deleted file mode 100644 index a9a7a6b..0000000 --- a/src/wags/utils/quickstart.py +++ /dev/null @@ -1,341 +0,0 @@ -"""Quickstart command for creating WAGS proxy servers.""" - -from pathlib import Path -from typing import Any - -from fastmcp import Client -from fastmcp.utilities.logging import get_logger -from mcp.types import Tool -from rich.console import Console -from rich.prompt import Confirm - -from wags import load_config - -logger = get_logger("wags.utils.quickstart") -console = Console() - - -def json_schema_to_python_type(schema: dict[str, Any]) -> str: - """Convert JSON Schema to Python type annotation.""" - if not isinstance(schema, dict): - return "Any" - - schema_type = schema.get("type") - if schema_type is None: - return "Any" - - if schema_type == "string": - if "enum" in schema: - values = ", ".join(f'"{v}"' for v in schema["enum"]) - return f"Literal[{values}]" - return "str" - - # Handle array type - if schema_type == "array": - items = schema.get("items", {}) - item_type = json_schema_to_python_type(items) - return f"list[{item_type}]" - - # Map simple types - type_mapping = { - "integer": "int", - "number": "float", - "boolean": "bool", - "object": "dict[str, Any]", - "null": "None", - } - - return type_mapping.get(schema_type, "Any") - - -def sanitize_method_name(name: str) -> str: - """Convert tool name to valid Python method name.""" - # Replace common separators with underscore - for char in ["-", ".", "/", " "]: - name = name.replace(char, "_") - - name = "".join(c if c.isalnum() or c == "_" else "" for c in name) - - # Ensure it doesn't start with a number - if name and name[0].isdigit(): - name = f"tool_{name}" - - # Ensure it's not empty - if not name: - name = "tool" - - return name.lower() - - -def generate_method_stub(tool: Tool) -> str: - """Generate a method stub for a tool.""" - method_name = sanitize_method_name(tool.name) - - # Parse parameters from inputSchema - params = [] - params.append("self") - - if tool.inputSchema and isinstance(tool.inputSchema, dict): - properties = tool.inputSchema.get("properties", {}) - required = tool.inputSchema.get("required", []) - - # Process required parameters first - for param_name in required: - if param_name in properties: - param_schema = properties[param_name] - param_type = json_schema_to_python_type(param_schema) - params.append(f"{param_name}: {param_type}") - - # Process optional parameters - for param_name, param_schema in properties.items(): - if param_name not in required: - param_type = json_schema_to_python_type(param_schema) - default = "None" - if param_schema.get("type") == "boolean": - default = "False" - elif param_schema.get("type") in ["integer", "number"]: - if "default" in param_schema: - default = str(param_schema["default"]) - params.append(f"{param_name}: {param_type} | None = {default}") - - # Build method signature - params_str = ",\n ".join(params) - - # Build docstring - docstring = tool.description or f"Handler for {tool.name}" - - method = f""" async def {method_name}( - {params_str} - ): - \"\"\"{docstring}\"\"\" - pass # Stub - actual execution happens in MCP server""" - - return method - - -def generate_handlers_class(class_name: str, tools: list[Tool]) -> str: - """Generate the complete handlers class code.""" - - # Collect unique imports needed - needs_literal = any( - "enum" in prop - for tool in tools - if tool.inputSchema and isinstance(tool.inputSchema, dict) - for prop in tool.inputSchema.get("properties", {}).values() - ) - - # Generate imports - imports = [] - imports.append('"""Handler stubs for MCP server tools."""') - imports.append("") - imports.append("from typing import Any") - if needs_literal: - imports[-1] = "from typing import Any, Literal" - imports.append("") - imports.append("# Example middleware decorators - add as needed:") - imports.append("# from wags.middleware import requires_root, RequiresElicitation") - imports.append("") - - # Generate class definition - class_def = f""" - -class {class_name}: - \"\"\"Handler stubs for MCP server tools. - - These are empty stubs used to attach middleware decorators. - The actual tool implementation is in the MCP server. - \"\"\" -""" - - # Generate method stubs - methods = [] - for tool in tools: - method = generate_method_stub(tool) - methods.append(method) - - # Combine everything - code = "\n".join(imports) + class_def - if methods: - code += "\n\n".join(methods) - else: - code += "\n pass" - - return code - - -def generate_main_file(config_path: Path, handlers_module: str, class_name: str, server_name: str | None = None) -> str: - """Generate the main.py file for the proxy server.""" - - # Determine import statement for handlers - if "/" in handlers_module or "\\" in handlers_module: - # It's a path, extract just the module name - handlers_module = Path(handlers_module).stem - - template = f'''"""WAGS proxy server with middleware.""" - -from pathlib import Path -from wags import create_proxy, load_config -from wags.middleware import RootsMiddleware, ElicitationMiddleware -from {handlers_module} import {class_name} - -# Load configuration -config = load_config(Path(__file__).parent / "{config_path.name}") -mcp = create_proxy(config, server_name="{server_name or 'wags-proxy'}") - -# Initialize handler stubs -handlers = {class_name}() - -# Add middleware - customize as needed -# Uncomment and configure the middleware you want to use: - -# Access control - requires @requires_root decorators on handler methods -# mcp.add_middleware(RootsMiddleware(handlers=handlers)) - -# Parameter elicitation - requires RequiresElicitation annotations -# mcp.add_middleware(ElicitationMiddleware(handlers=handlers)) - -if __name__ == "__main__": - import asyncio - asyncio.run(mcp.run_stdio_async()) -''' - - return template - - -def resolve_server_name(config: dict[str, Any], server_name: str | None = None) -> str: - """Resolve the server name from config.""" - if "mcpServers" not in config: - raise ValueError("Config file must have 'mcpServers' section") - - servers = config["mcpServers"] - if not servers: - raise ValueError("No servers found in config") - - if server_name is None: - if len(servers) > 1: - raise ValueError(f"Multiple servers found, please specify one: {', '.join(servers.keys())}") - result: str = next(iter(servers)) - return result - elif server_name not in servers: - raise ValueError(f"Server '{server_name}' not found. Available: {', '.join(servers.keys())}") - - return server_name - - -async def introspect_server(config_path: Path) -> list[Tool]: - """Connect to MCP server and get its tools.""" - config = load_config(config_path) - - client = Client(config) - async with client: - tools = await client.list_tools() - - return tools - - -def check_file_exists(file_path: Path, force: bool = False) -> bool: - """Check if file exists and ask for confirmation if needed.""" - if file_path.exists(): - if force: - console.print(f"[yellow]Overwriting existing file:[/yellow] {file_path}") - return True - else: - return Confirm.ask(f"File {file_path} already exists. Overwrite?", default=False) - return True - - -async def run_quickstart( - config_path: Path, - server_name: str | None = None, - handlers_file: str | None = None, - main_file: str | None = None, - class_name: str | None = None, - only_handlers: bool = False, - only_main: bool = False, - force: bool = False, -) -> None: - """Run the quickstart command to generate WAGS proxy files. - - Args: - config_path: Path to MCP server config.json - server_name: Name of server in config (defaults to first) - handlers_file: Output path for handlers file (defaults to handlers.py) - main_file: Output path for main file (defaults to main.py) - class_name: Name for handlers class (defaults to auto-generated) - only_handlers: Only generate handlers file - only_main: Only generate main file (requires existing handlers) - force: Overwrite existing files without asking - """ - config_dir = config_path.parent - - # Default file names - if handlers_file is None: - handlers_file = "handlers.py" - if main_file is None: - main_file = "main.py" - - handlers_path = config_dir / handlers_file - main_path = config_dir / main_file - - # Determine what to generate - generate_handlers = not only_main - generate_main = not only_handlers - - if only_main and only_handlers: - raise ValueError("Cannot specify both --only-handlers and --only-main") - - # For main-only, we need the handlers to exist or be specified - if only_main: - if not handlers_path.exists(): - raise FileNotFoundError( - f"Handlers file {handlers_path} does not exist. Generate it first or specify --handlers-file" - ) - - # Load config and resolve server name - config = load_config(config_path) - server_name = resolve_server_name(config, server_name) - - # Introspect server if we need to generate handlers - tools: list[Tool] = [] - if generate_handlers: - console.print(f"[cyan]Connecting to MCP server '{server_name}' to discover tools...[/cyan]") - tools = await introspect_server(config_path) - console.print(f"[green]Found {len(tools)} tools in server '{server_name}'[/green]") - - # Generate class name if not provided - if class_name is None: - if server_name: - parts = server_name.replace("-", "_").replace(".", "_").split("_") - class_name = "".join(p.capitalize() for p in parts) + "Handlers" - else: - class_name = "Handlers" - - # Generate and write handlers file - if generate_handlers: - if check_file_exists(handlers_path, force): - console.print("[cyan]Generating handlers file...[/cyan]") - handlers_code = generate_handlers_class(class_name, tools) - handlers_path.write_text(handlers_code) - console.print(f"[green]Created:[/green] {handlers_path}") - else: - console.print("[yellow]Skipped handlers file[/yellow]") - - # Generate and write main file - if generate_main: - if check_file_exists(main_path, force): - console.print("[cyan]Generating main file...[/cyan]") - main_code = generate_main_file(config_path, handlers_file, class_name, server_name or "wags-proxy") - main_path.write_text(main_code) - console.print(f"[green]Created:[/green] {main_path}") - else: - console.print("[yellow]Skipped main file[/yellow]") - - # Show next steps - console.print("\n[bold green]✅ Quickstart complete![/bold green]") - console.print("\n[bold]Next steps:[/bold]") - console.print(f"1. Review and add middleware decorators to {handlers_file}") - console.print(" - Add @requires_root for access control") - console.print(" - Add RequiresElicitation for parameter review") - console.print(f"2. Uncomment desired middleware in {main_file}") - console.print(f"3. Run your proxy server: [cyan]python {main_file}[/cyan]") - console.print("4. Configure your MCP client to use the proxy instead of the direct server") diff --git a/src/wags/utils/server_template.py b/src/wags/utils/server_template.py deleted file mode 100644 index f9e3803..0000000 --- a/src/wags/utils/server_template.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Server template utilities for creating new WAGS servers.""" - -from pathlib import Path - -from fastmcp.utilities.logging import get_logger -from jinja2 import Template - -logger = get_logger("wags.utils.server_template") - - -def create_server_scaffold(name: str, path: Path | None = None) -> None: - """Create a new server scaffold with handlers template. - - Note: This does NOT create a config.json - users should provide their own. - """ - if path is None: - path = Path("servers") / name - - path.mkdir(parents=True, exist_ok=True) - - class_name = name.replace("-", "_").title() + "Handlers" - templates_dir = Path(__file__).parent.parent / "templates" - - with open(templates_dir / "main.py.j2") as f: - main_template = Template(f.read()) - main_content = main_template.render(name=name, class_name=class_name) - (path / "main.py").write_text(main_content) - - with open(templates_dir / "handlers.py.j2") as f: - handlers_template = Template(f.read()) - handlers_content = handlers_template.render(name=name, class_name=class_name) - (path / "handlers.py").write_text(handlers_content) - - (path / "__init__.py").write_text("") - - logger.info(f"Created server scaffold at {path}") - logger.info("Note: You need to create your own config.json with mcpServers configuration") diff --git a/tests/integration/fixtures/server.py b/tests/integration/fixtures/server.py new file mode 100644 index 0000000..24f63f3 --- /dev/null +++ b/tests/integration/fixtures/server.py @@ -0,0 +1,21 @@ +"""Simple MCP server for integration testing.""" + +from fastmcp import FastMCP + +mcp = FastMCP("test-server") + + +@mcp.tool() +async def echo(message: str) -> str: + """Echo a message back.""" + return message + + +@mcp.tool() +async def add(a: int, b: int) -> int: + """Add two numbers.""" + return a + b + + +if __name__ == "__main__": + mcp.run() diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py new file mode 100644 index 0000000..dca3b88 --- /dev/null +++ b/tests/integration/test_cli.py @@ -0,0 +1,130 @@ +"""Integration tests for WAGS CLI commands.""" + +import importlib.util +import sys +from pathlib import Path + +import pytest +from fastmcp import Client + + +@pytest.fixture +def fixtures_dir() -> Path: + """Path to integration test fixtures.""" + return Path(__file__).parent / "fixtures" + + +@pytest.fixture +def working_dir(tmp_path: Path, fixtures_dir: Path) -> Path: + """Create a working directory with test server and config.""" + import json + + # Copy server.py to working dir + server_src = fixtures_dir / "server.py" + server_dst = tmp_path / "server.py" + server_dst.write_text(server_src.read_text()) + + # Create config with absolute path to server + config_data = { + "mcpServers": { + "test": { + "command": sys.executable, + "args": [str(server_dst)], + } + } + } + config_dst = tmp_path / "config.json" + config_dst.write_text(json.dumps(config_data, indent=2)) + + return tmp_path + + +class TestQuickstartCommand: + """Test the quickstart CLI command.""" + + def test_quickstart_generates_files(self, working_dir: Path) -> None: + """Test quickstart generates handlers and main files.""" + from wags.cli.main import quickstart + + config_path = working_dir / "config.json" + quickstart(config_path, force=True) + + handlers_path = working_dir / "handlers.py" + main_path = working_dir / "main.py" + + assert handlers_path.exists() + assert main_path.exists() + + # Verify handlers content + handlers_content = handlers_path.read_text() + assert "class Handlers:" in handlers_content + assert "async def echo" in handlers_content + assert "async def add" in handlers_content + + # Verify main content + main_content = main_path.read_text() + assert "from handlers import Handlers" in main_content + assert "load_config" in main_content + assert "create_proxy" in main_content + + async def test_generated_server_works(self, working_dir: Path) -> None: + """Test that generated main.py creates a working proxy server.""" + from wags.cli.main import quickstart + + config_path = working_dir / "config.json" + quickstart(config_path, force=True) + + # Import generated modules + sys.path.insert(0, str(working_dir)) + try: + # Import handlers + handlers_spec = importlib.util.spec_from_file_location("handlers", working_dir / "handlers.py") + assert handlers_spec and handlers_spec.loader + handlers_module = importlib.util.module_from_spec(handlers_spec) + handlers_spec.loader.exec_module(handlers_module) + + # Import main + main_spec = importlib.util.spec_from_file_location("main", working_dir / "main.py") + assert main_spec and main_spec.loader + main_module = importlib.util.module_from_spec(main_spec) + main_spec.loader.exec_module(main_module) + + # Test the proxy server + mcp_proxy = main_module.mcp + async with Client(mcp_proxy) as client: + tools = await client.list_tools() + tool_names = [tool.name for tool in tools] + + assert "echo" in tool_names + assert "add" in tool_names + + # Verify tools work + result = await client.call_tool("add", {"a": 5, "b": 3}) + assert result.data == 8 + + finally: + sys.path.remove(str(working_dir)) + + def test_quickstart_only_handlers(self, working_dir: Path) -> None: + """Test quickstart --only-handlers flag.""" + from wags.cli.main import quickstart + + config_path = working_dir / "config.json" + quickstart(config_path, only_handlers=True, force=True) + + assert (working_dir / "handlers.py").exists() + assert not (working_dir / "main.py").exists() + + def test_quickstart_custom_class_name(self, working_dir: Path) -> None: + """Test quickstart with custom class name.""" + from wags.cli.main import quickstart + + config_path = working_dir / "config.json" + quickstart(config_path, class_name="CustomHandlers", force=True) + + handlers_content = (working_dir / "handlers.py").read_text() + assert "class CustomHandlers:" in handlers_content + + main_content = (working_dir / "main.py").read_text() + assert "from handlers import CustomHandlers" in main_content + assert "CustomHandlers()" in main_content diff --git a/tests/integration/test_cli_integration.py b/tests/integration/test_cli_integration.py deleted file mode 100644 index 8cac48d..0000000 --- a/tests/integration/test_cli_integration.py +++ /dev/null @@ -1,164 +0,0 @@ -"""Integration tests for WAGS CLI workflow.""" - -import json -from pathlib import Path -from unittest.mock import AsyncMock, patch - -import pytest -from mcp.types import Tool - -from wags import load_config -from wags.utils.server_template import create_server_scaffold - - -class TestCLIIntegration: - """End-to-end tests for WAGS CLI workflow.""" - - def test_full_server_creation_workflow(self, tmp_path: Path) -> None: - """Test creating a new server from scratch.""" - # Change to temp directory - with pytest.MonkeyPatch.context() as m: - m.chdir(tmp_path) - - # Step 1: Create a new server scaffold - server_name = "test-integration" - create_server_scaffold(server_name) - - # Verify server was created - server_path = tmp_path / "servers" / server_name - assert server_path.exists() - # Note: config.json is no longer created automatically - assert (server_path / "handlers.py").exists() - assert (server_path / "main.py").exists() - assert (server_path / "__init__.py").exists() - - # Step 2: Create config manually (no longer auto-created) - config = {"mcpServers": {server_name: {"transport": "stdio", "command": "echo", "args": ["test"]}}} - config_path = server_path / "config.json" - config_path.write_text(json.dumps(config, indent=2)) - - # Step 3: Verify handlers structure - handlers_content = (server_path / "handlers.py").read_text() - assert "class Test_IntegrationHandlers" in handlers_content - # No more decorators or inheritance from ElicitationMiddleware - assert "async def" in handlers_content - - # Step 4: Verify main.py structure - main_content = (server_path / "main.py").read_text() - assert "load_config" in main_content - assert "create_proxy" in main_content - assert "ElicitationMiddleware(handlers=" in main_content - - @pytest.mark.asyncio - async def test_stub_generation_workflow(self, tmp_path: Path) -> None: - """Test generating handlers stubs from an MCP server.""" - # Create a mock config - config_path = tmp_path / "test_config.json" - config_data = {"mcpServers": {"test-server": {"transport": "stdio", "command": "echo", "args": ["test"]}}} - config_path.write_text(json.dumps(config_data)) - - # Mock the Client to return test tools - mock_tools = [ - Tool( - name="create_item", - description="Create a new item", - inputSchema={ - "type": "object", - "properties": {"name": {"type": "string"}, "quantity": {"type": "integer"}}, - "required": ["name"], - }, - ), - Tool( - name="delete_item", - description="Delete an item", - inputSchema={"type": "object", "properties": {"id": {"type": "string"}}, "required": ["id"]}, - ), - ] - - with patch("wags.utils.handlers_generator.Client") as mock_client: - mock_mcp = AsyncMock() - mock_mcp.list_tools = AsyncMock(return_value=mock_tools) - mock_mcp.__aenter__ = AsyncMock(return_value=mock_mcp) - mock_mcp.__aexit__ = AsyncMock() - mock_client.return_value = mock_mcp - - # Generate stub - output_path = tmp_path / "generated_handlers.py" - from wags.utils.handlers_generator import generate_handlers_stub - - await generate_handlers_stub( - config_path, server_name="test-server", output_path=output_path, class_name="TestGeneratedHandlers" - ) - - # Verify generated code - assert output_path.exists() - generated_content = output_path.read_text() - - # Check class definition (no inheritance anymore) - assert "class TestGeneratedHandlers:" in generated_content - - # Check generated methods (no decorators) - assert "async def create_item(" in generated_content - assert "name: str" in generated_content - assert "quantity: int | None = None" in generated_content - - assert "async def delete_item(" in generated_content - assert "id: str" in generated_content - - # Check docstrings but not decorators - assert '"""Create a new item"""' in generated_content - assert '"""Delete an item"""' in generated_content - # No more @tool_handler decorator - assert "@tool_handler" not in generated_content - - def test_multiple_server_workflow(self, tmp_path: Path) -> None: - """Test managing multiple servers.""" - with pytest.MonkeyPatch.context() as m: - m.chdir(tmp_path) - - # Create multiple servers - servers = ["auth-server", "data-server", "api-gateway"] - for server in servers: - create_server_scaffold(server) - - # Verify all servers were created - for server in servers: - server_path = tmp_path / "servers" / server - assert server_path.exists() - - # Check each has unique handlers class - handlers_content = (server_path / "handlers.py").read_text() - class_name = server.replace("-", "_").title() + "Handlers" - assert f"class {class_name}" in handlers_content - - @pytest.mark.asyncio - async def test_config_with_env_vars(self, tmp_path: Path) -> None: - """Test that environment variables are properly substituted.""" - import os - - # Set test environment variable - os.environ["TEST_API_KEY"] = "secret_key_123" - - try: - # Create server with env var in config - server_path = tmp_path / "env-test" - server_path.mkdir(parents=True) - - config_data = { - "mcpServers": { - "env-test": {"transport": "stdio", "command": "test", "env": {"API_KEY": "${TEST_API_KEY}"}} - } - } - - config_path = server_path / "config.json" - config_path.write_text(json.dumps(config_data)) - - # Test loading config with env substitution - loaded = load_config(config_path) - - # Verify env var was substituted - assert loaded["mcpServers"]["env-test"]["env"]["API_KEY"] == "secret_key_123" - - finally: - # Clean up env var - del os.environ["TEST_API_KEY"] diff --git a/tests/unit/cli/test_commands.py b/tests/unit/cli/test_commands.py index 2b6638e..05ae50a 100644 --- a/tests/unit/cli/test_commands.py +++ b/tests/unit/cli/test_commands.py @@ -4,7 +4,7 @@ from typing import Any from unittest.mock import patch -from wags.cli.main import init, run +from wags.cli.main import quickstart, run @patch("wags.utils.server.run_server") @@ -12,7 +12,6 @@ @patch("wags.cli.main.sys.exit") def test_run_command_handles_errors(mock_exit: Any, mock_logger: Any, mock_run_server: Any) -> None: """Test run command handles errors gracefully.""" - # Mock run_server as an async function that raises an exception mock_run_server.side_effect = Exception("Server startup failed") run(Path("test-server")) @@ -21,15 +20,14 @@ def test_run_command_handles_errors(mock_exit: Any, mock_logger: Any, mock_run_s mock_exit.assert_called_once_with(1) -@patch("wags.cli.main.console") +@patch("wags.utils.config.load_config") @patch("wags.cli.main.logger") @patch("wags.cli.main.sys.exit") -def test_init_command_handles_errors(mock_exit: Any, mock_logger: Any, mock_console: Any) -> None: - """Test init command handles errors gracefully.""" - with patch("wags.utils.server_template.create_server_scaffold") as mock_scaffold: - mock_scaffold.side_effect = Exception("Failed to create scaffold") +def test_quickstart_command_handles_errors(mock_exit: Any, mock_logger: Any, mock_load_config: Any) -> None: + """Test quickstart command handles errors gracefully.""" + mock_load_config.side_effect = Exception("Failed to load config") - init("test-server", path=None) + quickstart(Path("test-config.json")) - mock_logger.error.assert_called_once() - mock_exit.assert_called_once_with(1) + mock_logger.error.assert_called_once() + mock_exit.assert_called_once_with(1) diff --git a/tests/unit/middleware/test_todo.py b/tests/unit/middleware/test_todo.py index cdeb800..1a83e35 100644 --- a/tests/unit/middleware/test_todo.py +++ b/tests/unit/middleware/test_todo.py @@ -40,7 +40,6 @@ async def test_todo_write_basic(self) -> None: assert result.is_error is False assert "2 todos" in str(result.content) - @pytest.mark.asyncio async def test_in_progress_message(self) -> None: """Test that message includes in_progress task.""" diff --git a/tests/unit/utils/test_server_template.py b/tests/unit/utils/test_server_template.py deleted file mode 100644 index 14cb11b..0000000 --- a/tests/unit/utils/test_server_template.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Unit tests for server template utilities.""" - -from pathlib import Path - -import pytest - -from wags.utils.server_template import create_server_scaffold - - -class TestCreateServerScaffold: - """Tests for create_server_scaffold function.""" - - def test_creates_expected_file_structure(self, tmp_path: Path) -> None: - """Test scaffold creates all expected files.""" - with pytest.MonkeyPatch.context() as m: - m.chdir(tmp_path) - - create_server_scaffold("test-server") - - server_dir = tmp_path / "servers" / "test-server" - assert server_dir.exists() - assert (server_dir / "__init__.py").exists() - assert (server_dir / "handlers.py").exists() - assert (server_dir / "main.py").exists() - - def test_uses_custom_path(self, tmp_path: Path) -> None: - """Test scaffold works with custom path.""" - custom_path = tmp_path / "custom" / "location" - create_server_scaffold("my-server", custom_path) - - assert custom_path.exists() - assert (custom_path / "__init__.py").exists() - assert (custom_path / "handlers.py").exists() - assert (custom_path / "main.py").exists() - - def test_generates_correct_class_names(self, tmp_path: Path) -> None: - """Test that generated files contain correct class names.""" - create_server_scaffold("test-server", tmp_path) - - handlers_content = (tmp_path / "handlers.py").read_text() - main_content = (tmp_path / "main.py").read_text() - - # Verify class name generation - assert "class Test_ServerHandlers" in handlers_content - assert "Test_ServerHandlers()" in main_content - assert 'if __name__ == "__main__":' in main_content - assert "mcp.run_stdio_async()" in main_content - assert "RequiresElicitation" in handlers_content - assert "async def" in handlers_content - - def test_different_names_generate_correct_classes(self, tmp_path: Path) -> None: - """Test class name generation for various server names.""" - test_cases = [ - ("simple", "SimpleHandlers"), - ("test-server", "Test_ServerHandlers"), - ("my-api", "My_ApiHandlers"), - ] - - for server_name, expected_class in test_cases: - server_path = tmp_path / server_name - create_server_scaffold(server_name, server_path) - - handlers_content = (server_path / "handlers.py").read_text() - assert f"class {expected_class}" in handlers_content diff --git a/uv.lock b/uv.lock index 1d363d7..34e26f4 100644 --- a/uv.lock +++ b/uv.lock @@ -840,7 +840,7 @@ name = "grpcio" version = "1.75.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '4'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9d/f7/8963848164c7604efb3a3e6ee457fdb3a469653e19002bd24742473254f8/grpcio-1.75.1.tar.gz", hash = "sha256:3e81d89ece99b9ace23a6916880baca613c03a799925afb2857887efa8b1b3d2", size = 12731327, upload-time = "2025-09-26T09:03:36.887Z" } wheels = [ @@ -1422,6 +1422,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/69/b547032297c7e63ba2af494edba695d781af8a0c6e89e4d06cf848b21d80/multidict-6.6.4-py3-none-any.whl", hash = "sha256:27d8f8e125c07cb954e54d75d04905a9bba8a439c1d84aca94949d4d03d8601c", size = 12313, upload-time = "2025-08-11T12:08:46.891Z" }, ] +[[package]] +name = "mypy" +version = "1.18.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload-time = "2025-09-19T00:11:10.519Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728, upload-time = "2025-09-19T00:10:01.33Z" }, + { url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758, upload-time = "2025-09-19T00:10:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342, upload-time = "2025-09-19T00:11:00.371Z" }, + { url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709, upload-time = "2025-09-19T00:11:03.358Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806, upload-time = "2025-09-19T00:10:26.073Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262, upload-time = "2025-09-19T00:10:40.035Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0c/7d5300883da16f0063ae53996358758b2a2df2a09c72a5061fa79a1f5006/mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce", size = 12893775, upload-time = "2025-09-19T00:10:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/50/df/2cffbf25737bdb236f60c973edf62e3e7b4ee1c25b6878629e88e2cde967/mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d", size = 11936852, upload-time = "2025-09-19T00:10:51.631Z" }, + { url = "https://files.pythonhosted.org/packages/be/50/34059de13dd269227fb4a03be1faee6e2a4b04a2051c82ac0a0b5a773c9a/mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c", size = 12480242, upload-time = "2025-09-19T00:11:07.955Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/040983fad5132d85914c874a2836252bbc57832065548885b5bb5b0d4359/mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb", size = 13326683, upload-time = "2025-09-19T00:09:55.572Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ba/89b2901dd77414dd7a8c8729985832a5735053be15b744c18e4586e506ef/mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075", size = 13514749, upload-time = "2025-09-19T00:10:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/25/bc/cc98767cffd6b2928ba680f3e5bc969c4152bf7c2d83f92f5a504b92b0eb/mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf", size = 9982959, upload-time = "2025-09-19T00:10:37.344Z" }, + { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload-time = "2025-09-19T00:10:15.489Z" }, +] + [[package]] name = "mypy-extensions" version = "1.1.0" @@ -1684,8 +1710,8 @@ name = "opentelemetry-exporter-otlp" version = "1.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-exporter-otlp-proto-grpc" }, - { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-exporter-otlp-proto-grpc", marker = "python_full_version < '4'" }, + { name = "opentelemetry-exporter-otlp-proto-http", marker = "python_full_version < '4'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/64/df/47fde1de15a3d5ad410e98710fac60cd3d509df5dc7ec1359b71d6bf7e70/opentelemetry_exporter_otlp-1.37.0.tar.gz", hash = "sha256:f85b1929dd0d750751cc9159376fb05aa88bb7a08b6cdbf84edb0054d93e9f26", size = 6145, upload-time = "2025-09-11T10:29:03.075Z" } wheels = [ @@ -1709,13 +1735,13 @@ name = "opentelemetry-exporter-otlp-proto-grpc" version = "1.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "googleapis-common-protos" }, - { name = "grpcio" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-otlp-proto-common" }, - { name = "opentelemetry-proto" }, - { name = "opentelemetry-sdk" }, - { name = "typing-extensions" }, + { name = "googleapis-common-protos", marker = "python_full_version < '4'" }, + { name = "grpcio", marker = "python_full_version < '4'" }, + { name = "opentelemetry-api", marker = "python_full_version < '4'" }, + { name = "opentelemetry-exporter-otlp-proto-common", marker = "python_full_version < '4'" }, + { name = "opentelemetry-proto", marker = "python_full_version < '4'" }, + { name = "opentelemetry-sdk", marker = "python_full_version < '4'" }, + { name = "typing-extensions", marker = "python_full_version < '4'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d1/11/4ad0979d0bb13ae5a845214e97c8d42da43980034c30d6f72d8e0ebe580e/opentelemetry_exporter_otlp_proto_grpc-1.37.0.tar.gz", hash = "sha256:f55bcb9fc848ce05ad3dd954058bc7b126624d22c4d9e958da24d8537763bec5", size = 24465, upload-time = "2025-09-11T10:29:04.172Z" } wheels = [ @@ -1760,10 +1786,10 @@ name = "opentelemetry-instrumentation-anthropic" version = "0.47.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "opentelemetry-semantic-conventions-ai" }, + { name = "opentelemetry-api", marker = "python_full_version < '4'" }, + { name = "opentelemetry-instrumentation", marker = "python_full_version < '4'" }, + { name = "opentelemetry-semantic-conventions", marker = "python_full_version < '4'" }, + { name = "opentelemetry-semantic-conventions-ai", marker = "python_full_version < '4'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/de/bc/e241871daf8264c7d527cecf18f2d26365f3289e0a2034795692be5d58d7/opentelemetry_instrumentation_anthropic-0.47.3.tar.gz", hash = "sha256:e5ea191c354701ff0adcf03144d7d2de1a2408482058f524d4ea84bececd6259", size = 14684, upload-time = "2025-09-21T12:12:41.825Z" } wheels = [ @@ -1789,11 +1815,11 @@ name = "opentelemetry-instrumentation-mcp" version = "0.47.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-otlp" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "opentelemetry-semantic-conventions-ai" }, + { name = "opentelemetry-api", marker = "python_full_version < '4'" }, + { name = "opentelemetry-exporter-otlp", marker = "python_full_version < '4'" }, + { name = "opentelemetry-instrumentation", marker = "python_full_version < '4'" }, + { name = "opentelemetry-semantic-conventions", marker = "python_full_version < '4'" }, + { name = "opentelemetry-semantic-conventions-ai", marker = "python_full_version < '4'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a8/75/61f0d4b058ef4185d69a0d83424b6dc4af459f9417b64aa94345b6590f21/opentelemetry_instrumentation_mcp-0.47.3.tar.gz", hash = "sha256:62bcad30d5bcac8dbd17b6b20187d3ca9822f28909e8d214bc1591d20294577d", size = 8750, upload-time = "2025-09-21T12:12:52.85Z" } wheels = [ @@ -1805,10 +1831,10 @@ name = "opentelemetry-instrumentation-openai" version = "0.47.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "opentelemetry-semantic-conventions-ai" }, + { name = "opentelemetry-api", marker = "python_full_version < '4'" }, + { name = "opentelemetry-instrumentation", marker = "python_full_version < '4'" }, + { name = "opentelemetry-semantic-conventions", marker = "python_full_version < '4'" }, + { name = "opentelemetry-semantic-conventions-ai", marker = "python_full_version < '4'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3a/6b/4f92bb43a4be136203c72a690ed293fdae1d33492e1e96a224b6bbd020d6/opentelemetry_instrumentation_openai-0.47.3.tar.gz", hash = "sha256:e9b4c5a3b119cdf6a023eaf2ebff3feaaf0f3a1fc1616c3ce802b8644d3eab62", size = 25413, upload-time = "2025-09-21T12:12:56.868Z" } wheels = [ @@ -3190,35 +3216,35 @@ dependencies = [ { name = "fastmcp" }, { name = "jinja2" }, { name = "mcp" }, - { name = "mpmath" }, { name = "rich" }, ] [package.optional-dependencies] dev = [ - { name = "bfcl-eval" }, - { name = "black" }, + { name = "mypy" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "ruff" }, ] +evals = [ + { name = "bfcl-eval" }, +] [package.metadata] requires-dist = [ - { name = "bfcl-eval", marker = "extra == 'dev'", git = "https://github.com/chughtapan/gorilla.git?subdirectory=berkeley-function-call-leaderboard&branch=wags-dev" }, - { name = "black", marker = "extra == 'dev'" }, + { name = "bfcl-eval", marker = "extra == 'evals'", git = "https://github.com/chughtapan/gorilla.git?subdirectory=berkeley-function-call-leaderboard&branch=wags-dev" }, { name = "cyclopts", specifier = ">=2.0.0" }, { name = "fast-agent-mcp", git = "https://github.com/chughtapan/fast-agent.git?rev=wags-dev" }, { name = "fastmcp", git = "https://github.com/chughtapan/fastmcp.git?rev=wags-dev" }, { name = "jinja2", specifier = ">=3.0.0" }, { name = "mcp", git = "https://github.com/chughtapan/python-sdk.git?rev=wags-dev" }, - { name = "mpmath", specifier = ">=1.3.0" }, + { name = "mypy", marker = "extra == 'dev'" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21" }, { name = "rich", specifier = ">=13.0.0" }, { name = "ruff", marker = "extra == 'dev'" }, ] -provides-extras = ["dev"] +provides-extras = ["dev", "evals"] [[package]] name = "wcwidth"