diff --git a/README.md b/README.md index a8cfda1..484a42a 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,51 @@ docker pull quantconnect/mcp-server ``` If you have an ARM chip, add the `--platform linux/arm64` option. +### Running Locally Without Docker +For local development you can execute the server directly with Python. The `main.py` entry point now provides a full CLI so you can select any FastMCP transport: + +```bash +# Install with development dependencies for testing and linting +uv sync --extra dev + +# Run the server +uv run src/main.py --transport streamable-http --host 0.0.0.0 --port 8900 +``` + +If you omit the flags, defaults are read from the environment: + +| Variable | Purpose | +| --- | --- | +| `MCP_TRANSPORT` | Transport to use (`auto`, `stdio`, `streamable-http`, `sse`, `websocket`, `ws`, `tcp`). | +| `MCP_HOST` / `MCP_PORT` | Bind address and port for network transports. | +| `MCP_LOG_LEVEL` | Override FastMCP log level (`INFO`, `DEBUG`, …). | +| `QUANTCONNECT_API_TIMEOUT` | Default timeout (seconds) for QuantConnect API calls. | +| `MOUNT_SOURCE_PATH` / `MOUNT_DST_PATH` | Optional Lean workspace mount configuration. | +| `AGENT_NAME` | Model source identifier attached to project changes. | + +Use `uv run src/main.py --list-transports` to see the supported transport strings (alias: `http` maps to `streamable-http`). + +#### Development Dependencies +Testing and linting tools (pytest, ruff) are available as optional development dependencies: + +```bash +# Install with dev dependencies for development work +uv sync --extra dev + +# Run tests +uv run pytest + +# Run linting +uv run ruff check +``` + +For production use, these development tools are not installed by default. + +### Migration Notes +- Existing environment variable names continue to work; defaults now match the legacy behaviour (`stdio` transport). +- Optional variables (`MCP_HOST`, `MCP_PORT`, `MCP_LOG_LEVEL`, `QUANTCONNECT_API_TIMEOUT`) can be introduced gradually—no changes required for existing deployments. +- The new CLI entry point is exposed as `quantconnect-mcp`; existing `python src/main.py` workflows remain valid. + ## Available Tools (64) | Tools provided by this Server | Short Description | | -------- | ------- | diff --git a/create_tool_markdown.py b/create_tool_markdown.py index 3cb6d2d..8b07d55 100644 --- a/create_tool_markdown.py +++ b/create_tool_markdown.py @@ -64,7 +64,12 @@ def create_tool_details(tools): elif '$ref' in meta: model_name = meta['$ref'].split("/")[-1] data_type = defs[model_name]['type'] - content += f"| `{name}` | `{data_type}` {'' if required else '*optional*'} | {meta['description'].split('\n')[0]} |\n" + description = meta.get('description', '') + first_line = description.splitlines()[0] if description else '' + optional_suffix = '' if required else '*optional*' + content += ( + f"| `{name}` | `{data_type}` {optional_suffix} | {first_line} |\n" + ) # These default values come from https://modelcontextprotocol.io/docs/concepts/tools#available-tool-annotations read_only = tool['annotations'].get('readOnlyHint', False) diff --git a/pyproject.toml b/pyproject.toml index b886a72..9b2d366 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,8 +8,26 @@ dependencies = [ "python-dotenv>=0.23.0", "httpx>=0.28.1", "mcp[cli]>=1.9.3", - "requests" + "requests", + "fastmcp>=2.12.5", ] +[project.optional-dependencies] +dev = [ + "ruff>=0.14.1", + "pytest>=8.0.0", + "pytest-asyncio>=0.24.0", +] + +[project.scripts] +quantconnect-mcp = "main:cli" + +[tool.ruff] +target-version = "py310" +exclude = ["tests/algorithms/syntax_errors.py"] + +[tool.ruff.lint] +per-file-ignores = {"tests/algorithms/*.py" = ["F403", "F405"]} + [tool.pytest.ini_options] pythonpath = "src tests" diff --git a/src/__init__.py b/src/__init__.py index ac24e82..c3d3d90 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -14,4 +14,4 @@ # The version is always set to "dev" in the Git repository. When a new release is ready, # a maintainer will push a new Git tag which will trigger GitHub Actions to publish a new # Docker image with the version of the tag. -__version__ = "0.0.0-dev" +from .version import __version__ # noqa: F401 diff --git a/src/api_connection.py b/src/api_connection.py index bd14132..dcd7dd6 100644 --- a/src/api_connection.py +++ b/src/api_connection.py @@ -1,51 +1,93 @@ -from __init__ import __version__ +from __future__ import annotations -import httpx from base64 import b64encode +from contextlib import asynccontextmanager from hashlib import sha256 from time import time -import os +from typing import Any, AsyncIterator + +import httpx from pydantic_core import to_jsonable_python -BASE_URL = 'https://www.quantconnect.com/api/v2' - -# Load credentials from environment variables. -USER_ID = os.getenv('QUANTCONNECT_USER_ID') -API_TOKEN = os.getenv('QUANTCONNECT_API_TOKEN') - -def get_headers(): - # Get timestamp - timestamp = f'{int(time())}' - time_stamped_token = f'{API_TOKEN}:{timestamp}'.encode('utf-8') - # Get hased API token - hashed_token = sha256(time_stamped_token).hexdigest() - authentication = f'{USER_ID}:{hashed_token}'.encode('utf-8') - authentication = b64encode(authentication).decode('ascii') - # Create headers dictionary. +from settings import get_settings +from version import __version__ + + +def _format_endpoint(endpoint: str) -> str: + """Return a normalized API endpoint path.""" + + return endpoint if endpoint.startswith("/") else f"/{endpoint}" + + +def _build_headers(user_id: str, api_token: str) -> dict[str, str]: + """Create the QuantConnect authentication headers.""" + + timestamp = str(int(time())) + hashed_token = sha256(f"{api_token}:{timestamp}".encode("utf-8")).hexdigest() + authentication = b64encode(f"{user_id}:{hashed_token}".encode("utf-8")).decode("ascii") return { - 'Authorization': f'Basic {authentication}', - 'Timestamp': timestamp, - 'User-Agent': f'QuantConnect MCP Server v{__version__}' + "Authorization": f"Basic {authentication}", + "Timestamp": timestamp, + "User-Agent": f"QuantConnect MCP Server v{__version__}", } -async def post(endpoint: str, model: object = None, timeout: float = 30.0): - """Make an HTTP POST request to the API with proper error handling. - - Args: - endpoint: The API endpoint path (ex: '/projects/create') - model: Optional Pydantics model for the request. - timeout: Optional timeout for the request (in seconds). - - Returns: - Response JSON if successful. Otherwise, throws an exception, - which is handled by the Server class. - """ - async with httpx.AsyncClient() as client: - response = await client.post( - f'{BASE_URL}{endpoint}', - headers=get_headers(), - json=to_jsonable_python(model, exclude_none=True) if model else {}, - timeout=timeout - ) - response.raise_for_status() - return response.json() + +def _serialize_payload(model: object | None) -> dict[str, Any]: + """Convert request models to JSON-compatible dictionaries.""" + + if model is None: + return {} + return to_jsonable_python(model, exclude_none=True) + + +@asynccontextmanager +async def authenticated_client( + *, follow_redirects: bool = False +) -> AsyncIterator[tuple[httpx.AsyncClient, dict[str, str], Any]]: + """Yield an authenticated AsyncClient instance with QuantConnect headers.""" + + settings = get_settings(require_credentials=True) + user_id, api_token = settings.ensure_credentials() + headers = _build_headers(user_id, api_token) + async with httpx.AsyncClient( + base_url=settings.api_base_url.rstrip("/"), + follow_redirects=follow_redirects, + ) as client: + yield client, headers, settings + + +async def post_raw( + endpoint: str, + model: object = None, + timeout: float | None = None, + *, + follow_redirects: bool = False, +) -> httpx.Response: + """Perform a POST request and return the raw httpx response.""" + + async with authenticated_client( + follow_redirects=follow_redirects + ) as (client, headers, settings): + timeout_value = timeout if timeout is not None else settings.api_timeout + try: + response = await client.post( + _format_endpoint(endpoint), + headers=headers, + json=_serialize_payload(model), + timeout=timeout_value, + ) + response.raise_for_status() + return response + except httpx.HTTPError as exc: + message = f"QuantConnect API request failed for endpoint {endpoint!r}" + raise RuntimeError(message) from exc + + +async def post(endpoint: str, model: object = None, timeout: float | None = None): + """Make an HTTP POST request to the API with proper error handling.""" + + try: + response = await post_raw(endpoint, model=model, timeout=timeout) + except RuntimeError: + raise + return response.json() diff --git a/src/code_source_id.py b/src/code_source_id.py index 8a9c153..52a4b33 100644 --- a/src/code_source_id.py +++ b/src/code_source_id.py @@ -1,8 +1,13 @@ -import os +from __future__ import annotations -# Load the agent name from the environment variables. -AGENT_NAME = os.getenv('AGENT_NAME', 'MCP Server') +from pydantic import BaseModel -def add_code_source_id(model): - model.codeSourceId = AGENT_NAME - return model \ No newline at end of file +from settings import get_settings + + +def add_code_source_id(model: BaseModel) -> BaseModel: + """Attach the configured agent identifier to the request model.""" + + agent_name = get_settings().agent_name + # Using model_copy avoids mutating the caller's instance. + return model.model_copy(update={"codeSourceId": agent_name}) diff --git a/src/main.py b/src/main.py index a0e2c0f..7a20740 100644 --- a/src/main.py +++ b/src/main.py @@ -1,52 +1,140 @@ -import os +from __future__ import annotations + +import argparse +import sys +from pathlib import Path +from typing import Sequence + from mcp.server.fastmcp import FastMCP -from tools.account import register_account_tools -from tools.project import register_project_tools -from tools.project_collaboration import register_project_collaboration_tools -from tools.project_nodes import register_project_node_tools -from tools.compile import register_compile_tools -from tools.files import register_file_tools -from tools.backtests import register_backtest_tools -from tools.optimizations import register_optimization_tools -from tools.live import register_live_trading_tools -from tools.live_commands import register_live_trading_command_tools -from tools.object_store import register_object_store_tools -from tools.lean_versions import register_lean_version_tools -from tools.ai import register_ai_tools -from tools.mcp_server_version import register_mcp_server_version_tools from organization_workspace import OrganizationWorkspace +from settings import NETWORK_TRANSPORTS, Transport, get_settings +from tools import register_all_tools + +__all__ = ["create_server", "run_server", "cli", "main", "mcp"] + + +def _load_instructions() -> str: + """Read the user-facing instructions bundled with the server.""" + + instructions_path = Path(__file__).with_name("instructions.md") + return instructions_path.read_text(encoding="utf-8") + + +def create_server() -> FastMCP: + """Instantiate the FastMCP server with all registered tools.""" + + mcp = FastMCP(name="quantconnect", instructions=_load_instructions()) + register_all_tools(mcp) + return mcp + + +mcp = create_server() + + +AVAILABLE_TRANSPORTS = tuple( + sorted({Transport.AUTO.value, Transport.STDIO.value, *NETWORK_TRANSPORTS}) +) + + +def run_server( + *, + transport: str | None = None, + host: str | None = None, + port: int | None = None, + log_level: str | None = None, +) -> None: + """Run the MCP server with the provided transport configuration.""" + + settings = get_settings() + OrganizationWorkspace.load(settings) + selected_transport_enum = settings._normalize_transport(transport) or settings.transport + selected_transport_value = selected_transport_enum.value + run_kwargs = {} + transport_kwargs = settings.transport_kwargs(selected_transport_value) + log_value = transport_kwargs.get("log_level") + + host_value = host if host is not None else settings.transport_host + port_value = port if port is not None else settings.transport_port + if selected_transport_value in NETWORK_TRANSPORTS: + if host_value is not None: + try: + mcp.settings.host = host_value # type: ignore[attr-defined] + except AttributeError as exc: # pragma: no cover - depends on fastmcp version + raise RuntimeError("Installed FastMCP version does not support host override via CLI.") from exc + if port_value is not None: + try: + mcp.settings.port = port_value # type: ignore[attr-defined] + except AttributeError as exc: # pragma: no cover - depends on fastmcp version + raise RuntimeError("Installed FastMCP version does not support port override via CLI.") from exc + else: + if host is not None or port is not None: + raise RuntimeError("STDIO transport does not support host or port overrides.") + + if log_level is not None: + log_value = log_level + if log_value is not None: + try: + mcp.settings.log_level = log_value # type: ignore[attr-defined] + except AttributeError as exc: # pragma: no cover + raise RuntimeError("Installed FastMCP version does not support log level override via CLI.") from exc + + mcp.run(transport=selected_transport_value, **run_kwargs) + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="QuantConnect MCP server powered by FastMCP." + ) + parser.add_argument( + "--transport", + choices=AVAILABLE_TRANSPORTS, + help="Transport to use when serving MCP (default: value from MCP_TRANSPORT).", + ) + parser.add_argument( + "--host", + help="Host/interface to bind for network transports.", + ) + parser.add_argument( + "--port", + type=int, + help="Port to bind for network transports.", + ) + parser.add_argument( + "--log-level", + help="Override FastMCP log level for this run.", + ) + parser.add_argument( + "--list-transports", + action="store_true", + help="List supported transports and exit.", + ) + return parser + + +def cli(argv: Sequence[str] | None = None) -> None: + """Command-line interface for running the MCP server.""" + + parser = _build_parser() + args = parser.parse_args(list(argv) if argv is not None else None) + + if args.list_transports: + print("Available transports:", ", ".join(AVAILABLE_TRANSPORTS)) + return + + run_server( + transport=args.transport, + host=args.host, + port=args.port, + log_level=args.log_level, + ) + + +def main() -> None: + """Default entry-point used by legacy invocations.""" + + run_server() -transport = os.getenv('MCP_TRANSPORT', 'stdio') - -# Load the server instructions. -with open('src/instructions.md', 'r', encoding='utf-8') as file: - instructions = file.read() -# Initialize the FastMCP server. -mcp = FastMCP('quantconnect', instructions, host="0.0.0.0") - -# Register all the tools. -registration_functions = [ - register_account_tools, - register_project_tools, - register_project_collaboration_tools, - register_project_node_tools, - register_compile_tools, - register_file_tools, - register_backtest_tools, - register_optimization_tools, - register_live_trading_tools, - register_live_trading_command_tools, - register_object_store_tools, - register_lean_version_tools, - register_ai_tools, - register_mcp_server_version_tools, -] -for f in registration_functions: - f(mcp) if __name__ == "__main__": - # Load the organization workspace. - OrganizationWorkspace.load() - # Run the server. - mcp.run(transport=transport) + cli(sys.argv[1:]) diff --git a/src/models.py b/src/models.py index ed8bab3..a8b9387 100644 --- a/src/models.py +++ b/src/models.py @@ -3,7 +3,6 @@ # timestamp: 2025-09-09T14:33:02+00:00 from __future__ import annotations -from pydantic import RootModel, ConfigDict from datetime import datetime, time from enum import Enum diff --git a/src/organization_workspace.py b/src/organization_workspace.py index 740f97d..530228e 100644 --- a/src/organization_workspace.py +++ b/src/organization_workspace.py @@ -1,41 +1,82 @@ -import os +from __future__ import annotations + import json +import warnings +from pathlib import Path + +from settings import ServerSettings, get_settings + + +IGNORED_ENTRIES = {".QuantConnect", "data", "lean.json"} class OrganizationWorkspace: + """Helper for mapping local Lean project paths to QuantConnect project IDs.""" + + available = False + project_id_by_path: dict[str, str] = {} + mount_source: Path | None = None + mount_destination: Path | None = None - # Load mount destination and source from environment variables. - MOUNT_SOURCE = os.getenv('MOUNT_SOURCE_PATH') - MOUNT_DESTINATION = os.getenv('MOUNT_DST_PATH') - - available = False # Indicate if local disk access is available - project_id_by_path = {} + # Backwards compatibility with legacy attribute names. + MOUNT_SOURCE: str | None = None + MOUNT_DESTINATION: str | None = None + _legacy_warning_emitted = False @classmethod - def load(cls): - if not (cls.MOUNT_SOURCE and cls.MOUNT_DESTINATION): - return - if not os.path.exists(cls.MOUNT_DESTINATION): + def configure(cls, settings: ServerSettings | None = None) -> None: + """Store mount configuration derived from the provided settings.""" + + settings = settings or get_settings() + cls.mount_source = settings.mount_source + cls.mount_destination = settings.mount_destination + cls.MOUNT_SOURCE = str(cls.mount_source) if cls.mount_source else None + cls.MOUNT_DESTINATION = ( + str(cls.mount_destination) if cls.mount_destination else None + ) + if (cls.MOUNT_SOURCE or cls.MOUNT_DESTINATION) and not cls._legacy_warning_emitted: + warnings.warn( + "OrganizationWorkspace.MOUNT_SOURCE and MOUNT_DESTINATION are deprecated; " + "use OrganizationWorkspace.mount_source and mount_destination instead.", + DeprecationWarning, + stacklevel=2, + ) + cls._legacy_warning_emitted = True + + @classmethod + def load(cls, settings: ServerSettings | None = None) -> None: + """Populate project mappings when a workspace mount is available.""" + + cls.configure(settings) + cls.project_id_by_path = {} + + destination = cls.mount_destination + if not destination or not destination.exists(): + cls.available = False return - for name in os.listdir(cls.MOUNT_DESTINATION): - if name in ['.QuantConnect', 'data', 'lean.json']: + + for entry in destination.iterdir(): + if entry.name in IGNORED_ENTRIES: continue - cls._process_directory(os.path.join(cls.MOUNT_DESTINATION, name)) + cls._process_directory(entry) cls.available = True @classmethod - def _process_directory(cls, path): - # If the current directory contains a config.json file, then - # it's a project, so save it's Id and path. - config_path = os.path.join(path, 'config.json') - if os.path.isfile(config_path): - with open(config_path, 'r') as f: - config_data = json.load(f) - if 'cloud-id' in config_data: - cls.project_id_by_path[path] = config_data['cloud-id'] - # Otherwise, it's a directory of projects, so recurse. - else: - for dir_name in os.listdir(path): - sub_path = os.path.join(path, dir_name) - if os.path.isdir(sub_path): - cls._process_directory(sub_path) + def _process_directory(cls, path: Path) -> None: + """Recursively collect project identifiers from Lean config files.""" + + if not path.is_dir(): + return + + config_path = path / "config.json" + if config_path.is_file(): + with config_path.open("r", encoding="utf-8") as handle: + config_data = json.load(handle) + cloud_id = config_data.get("cloud-id") + if cloud_id: + cls.project_id_by_path[str(path)] = cloud_id + return + + for child in path.iterdir(): + if child.is_dir(): + cls._process_directory(child) diff --git a/src/settings.py b/src/settings.py new file mode 100644 index 0000000..6d30c22 --- /dev/null +++ b/src/settings.py @@ -0,0 +1,231 @@ +from __future__ import annotations + +import os +from enum import Enum +from functools import lru_cache +from pathlib import Path +from typing import Any, Mapping + +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator + + +class Transport(str, Enum): + """Supported transport identifiers for FastMCP.""" + + AUTO = "auto" + STDIO = "stdio" + HTTP = "streamable-http" + SSE = "sse" + WEBSOCKET = "websocket" + WS = "ws" + TCP = "tcp" + + +NETWORK_TRANSPORTS = { + Transport.HTTP.value, + Transport.SSE.value, + Transport.WEBSOCKET.value, + Transport.WS.value, + Transport.TCP.value, +} + +DEFAULT_TRANSPORT_PORTS: dict[str, int] = { + Transport.HTTP.value: 8000, + Transport.SSE.value: 8000, + Transport.WEBSOCKET.value: 8765, + Transport.WS.value: 8765, + Transport.TCP.value: 8020, +} +DEFAULT_TRANSPORT_HOST = "127.0.0.1" + + +class ServerSettings(BaseModel): + """Configuration loaded from environment variables.""" + + model_config = ConfigDict(populate_by_name=True, extra="ignore") + + quantconnect_user_id: str | None = Field( + default=None, + alias="QUANTCONNECT_USER_ID", + description="QuantConnect account identifier used for API requests.", + ) + quantconnect_api_token: str | None = Field( + default=None, + alias="QUANTCONNECT_API_TOKEN", + description="QuantConnect API token used for authentication.", + ) + agent_name: str = Field( + default="QuantConnect MCP Server", + alias="AGENT_NAME", + description="Identifier attached to code modifications made via the MCP.", + ) + api_base_url: str = Field( + default="https://www.quantconnect.com/api/v2", + alias="QUANTCONNECT_API_BASE_URL", + description="Base URL for the QuantConnect REST API.", + ) + mount_source_path: str | None = Field( + default=None, + alias="MOUNT_SOURCE_PATH", + description="Host path containing locally synchronized Lean projects.", + ) + mount_destination_path: str | None = Field( + default=None, + alias="MOUNT_DST_PATH", + description="Container path where Lean projects are mounted.", + ) + transport: Transport = Field( + default=Transport.STDIO, + alias="MCP_TRANSPORT", + description="FastMCP transport. Supports stdio, http, websocket, tcp, or auto.", + ) + api_timeout: float = Field( + default=30.0, + alias="QUANTCONNECT_API_TIMEOUT", + ge=0.0, + description="Default timeout (seconds) for QuantConnect API requests.", + ) + transport_host: str | None = Field( + default=None, + alias="MCP_HOST", + description="Host/interface binding for network transports.", + ) + transport_port: int | None = Field( + default=None, + alias="MCP_PORT", + description="Port binding for network transports.", + ) + log_level: str | None = Field( + default=None, + alias="MCP_LOG_LEVEL", + description="Optional log level override for FastMCP run() helpers.", + ) + + def ensure_credentials(self) -> tuple[str, str]: + """Ensure that the credentials required for API calls are available.""" + + missing = [ + name + for name, value in ( + ("QUANTCONNECT_USER_ID", self.quantconnect_user_id), + ("QUANTCONNECT_API_TOKEN", self.quantconnect_api_token), + ) + if not value + ] + if missing: + message = ( + "Missing required QuantConnect credentials. " + f"Set the environment variable(s): {', '.join(missing)}." + ) + raise RuntimeError(message) + # The casts are safe because the missing list is empty. + return self.quantconnect_user_id, self.quantconnect_api_token # type: ignore[return-value] + + @staticmethod + def _safe_path(path_value: str | None) -> Path | None: + if not path_value: + return None + candidate = Path(path_value).expanduser() + try: + return candidate.resolve() + except (OSError, RuntimeError) as exc: + raise RuntimeError(f"Failed to resolve path '{path_value}': {exc}") from exc + + @property + def mount_source(self) -> Path | None: + """Return the resolved mount source path if configured.""" + + return self._safe_path(self.mount_source_path) + + @property + def mount_destination(self) -> Path | None: + """Return the resolved mount destination path if configured.""" + + return self._safe_path(self.mount_destination_path) + + @staticmethod + def _normalize_transport(value: Transport | str | None) -> Transport | None: + if value is None or value == "": + return None + if isinstance(value, Transport): + return value + normalized_value = value.lower() + alias_map = { + "http": Transport.HTTP, + "streamable-http": Transport.HTTP, + "streamablehttp": Transport.HTTP, + } + if normalized_value in alias_map: + return alias_map[normalized_value] + try: + return Transport(normalized_value) + except ValueError as exc: + valid = ", ".join(t.value for t in Transport) + raise RuntimeError(f"Unsupported transport '{value}'. Expected one of {valid}.") from exc + + @field_validator("transport", mode="before") + @classmethod + def _coerce_transport(cls, value: Transport | str | None) -> Transport: + normalized = cls._normalize_transport(value) + return normalized or Transport.STDIO + + def transport_kwargs(self, transport: str | None = None) -> dict[str, Any]: + """Return keyword arguments to forward to FastMCP.run based on transport.""" + + selected_transport = self._normalize_transport(transport) or self.transport + kwargs: dict[str, Any] = {} + if selected_transport.value in NETWORK_TRANSPORTS: + kwargs["host"] = self.transport_host or DEFAULT_TRANSPORT_HOST + kwargs["port"] = self.transport_port or DEFAULT_TRANSPORT_PORTS.get( + selected_transport.value, DEFAULT_TRANSPORT_PORTS[Transport.HTTP.value] + ) + if self.log_level: + kwargs["log_level"] = self.log_level + return kwargs + + +def _load_raw_environment(env: Mapping[str, str] | None = None) -> Mapping[str, str]: + if env is not None: + return env + # Casting to Mapping ensures type checkers do not treat os._Environ as mutable. + return os.environ + + +def resolve_settings( + *, env: Mapping[str, str] | None = None, require_credentials: bool = False +) -> ServerSettings: + """Instantiate ServerSettings from environment variables.""" + + raw_env = _load_raw_environment(env) + try: + settings = ServerSettings.model_validate(raw_env) + except ValidationError as exc: + raise RuntimeError(f"Failed to parse server settings: {exc}") from exc + + if require_credentials: + settings.ensure_credentials() + return settings + + +@lru_cache(maxsize=1) +def _cached_settings(require_credentials: bool) -> ServerSettings: + return resolve_settings(require_credentials=require_credentials) + + +def get_settings( + *, require_credentials: bool = False, refresh: bool = False +) -> ServerSettings: + """Cached accessor for server settings. + + Use `refresh=True` or :func:`clear_settings_cache` to reload configuration. + """ + + if refresh: + clear_settings_cache() + return _cached_settings(require_credentials) + + +def clear_settings_cache() -> None: + """Reset the cached ServerSettings instance.""" + + _cached_settings.cache_clear() diff --git a/src/tools/__init__.py b/src/tools/__init__.py index 8b13789..0b4167d 100644 --- a/src/tools/__init__.py +++ b/src/tools/__init__.py @@ -1 +1,47 @@ +from __future__ import annotations +from collections.abc import Callable +from typing import Tuple + +from mcp.server.fastmcp import FastMCP + +from .account import register_account_tools +from .ai import register_ai_tools +from .backtests import register_backtest_tools +from .compile import register_compile_tools +from .files import register_file_tools +from .lean_versions import register_lean_version_tools +from .live import register_live_trading_tools +from .live_commands import register_live_trading_command_tools +from .mcp_server_version import register_mcp_server_version_tools +from .object_store import register_object_store_tools +from .optimizations import register_optimization_tools +from .project import register_project_tools +from .project_collaboration import register_project_collaboration_tools +from .project_nodes import register_project_node_tools + +RegistrationFn = Callable[[FastMCP], None] + +REGISTRATION_FUNCTIONS: Tuple[RegistrationFn, ...] = ( + register_account_tools, + register_project_tools, + register_project_collaboration_tools, + register_project_node_tools, + register_compile_tools, + register_file_tools, + register_backtest_tools, + register_optimization_tools, + register_live_trading_tools, + register_live_trading_command_tools, + register_object_store_tools, + register_lean_version_tools, + register_ai_tools, + register_mcp_server_version_tools, +) + + +def register_all_tools(mcp: FastMCP) -> None: + """Register every tool exposed by the QuantConnect MCP server.""" + + for register in REGISTRATION_FUNCTIONS: + register(mcp) diff --git a/src/tools/account.py b/src/tools/account.py index 0cb8af9..3f77c26 100644 --- a/src/tools/account.py +++ b/src/tools/account.py @@ -1,15 +1,21 @@ +from __future__ import annotations + from api_connection import post from models import AccountResponse +from mcp.server.fastmcp import FastMCP + + +def register_account_tools(mcp: FastMCP) -> None: + """Expose account-related MCP tooling.""" -def register_account_tools(mcp): - # Read @mcp.tool( annotations={ - 'title': 'Read account', - 'readOnlyHint': True, - 'openWorldHint': True + "title": "Read account", + "readOnlyHint": True, + "openWorldHint": True, } ) async def read_account() -> AccountResponse: """Read the organization account status.""" - return await post('/account/read') + + return await post("/account/read") diff --git a/src/tools/ai.py b/src/tools/ai.py index 90c066c..e1f5e04 100644 --- a/src/tools/ai.py +++ b/src/tools/ai.py @@ -1,66 +1,67 @@ +from __future__ import annotations + from api_connection import post from models import ( + BacktestInitResponse, BasicFilesRequest, CodeCompletionRequest, - ErrorEnhanceRequest, - PEP8ConvertRequest, - BasicFilesRequest, - SearchRequest, - - BacktestInitResponse, CodeCompletionResponse, + ErrorEnhanceRequest, ErrorEnhanceResponse, + PEP8ConvertRequest, PEP8ConvertResponse, + SearchRequest, + SearchResponse, SyntaxCheckResponse, - SearchResponse ) +from mcp.server.fastmcp import FastMCP + + +def register_ai_tools(mcp: FastMCP) -> None: + """Expose AI-assistive tooling such as code completion and search.""" -def register_ai_tools(mcp): - # Get backtest initialization errors @mcp.tool( - annotations={ - 'title': 'Check initialization errors', 'readOnlyHint': True - } + annotations={"title": "Check initialization errors", "readOnlyHint": True} ) async def check_initialization_errors( - model: BasicFilesRequest) -> BacktestInitResponse: - """Run a backtest for a few seconds to initialize the algorithm - and get inialization errors if any.""" - return await post('/ai/tools/backtest-init', model) - - # Complete code - @mcp.tool(annotations={'title': 'Complete code', 'readOnlyHint': True}) + model: BasicFilesRequest, + ) -> BacktestInitResponse: + """Run a short backtest to surface initialization errors.""" + + return await post("/ai/tools/backtest-init", model) + + @mcp.tool(annotations={"title": "Complete code", "readOnlyHint": True}) async def complete_code( - model: CodeCompletionRequest) -> CodeCompletionResponse: - """Show the code completion for a specific text input.""" - return await post('/ai/tools/complete', model) + model: CodeCompletionRequest, + ) -> CodeCompletionResponse: + """Return code completion suggestions.""" - # Enchance error message - @mcp.tool( - annotations={'title': 'Enhance error message', 'readOnlyHint': True} - ) + return await post("/ai/tools/complete", model) + + @mcp.tool(annotations={"title": "Enhance error message", "readOnlyHint": True}) async def enhance_error_message( - model: ErrorEnhanceRequest) -> ErrorEnhanceResponse: - """Show additional context and suggestions for error messages.""" - return await post('/ai/tools/error-enhance', model) + model: ErrorEnhanceRequest, + ) -> ErrorEnhanceResponse: + """Augment runtime errors with additional guidance.""" - # Update code to PEP8 - @mcp.tool( - annotations={'title': 'Update code to PEP8', 'readOnlyHint': True} - ) + return await post("/ai/tools/error-enhance", model) + + @mcp.tool(annotations={"title": "Update code to PEP8", "readOnlyHint": True}) async def update_code_to_pep8( - model: PEP8ConvertRequest) -> PEP8ConvertResponse: - """Update Python code to follow PEP8 style.""" - return await post('/ai/tools/pep8-convert', model) + model: PEP8ConvertRequest, + ) -> PEP8ConvertResponse: + """Convert Python code snippets to follow PEP 8 style.""" - # Check syntax - @mcp.tool(annotations={'title': 'Check syntax', 'readOnlyHint': True}) + return await post("/ai/tools/pep8-convert", model) + + @mcp.tool(annotations={"title": "Check syntax", "readOnlyHint": True}) async def check_syntax(model: BasicFilesRequest) -> SyntaxCheckResponse: - """Check the syntax of a code.""" - return await post('/ai/tools/syntax-check', model) + """Validate algorithm syntax without running the algorithm.""" + + return await post("/ai/tools/syntax-check", model) - # Search - @mcp.tool(annotations={'title': 'Search QuantConnect', 'readOnlyHint': True}) + @mcp.tool(annotations={"title": "Search QuantConnect", "readOnlyHint": True}) async def search_quantconnect(model: SearchRequest) -> SearchResponse: - """Search for content in QuantConnect.""" - return await post('/ai/tools/search', model) + """Search QuantConnect documentation, forums, and examples.""" + + return await post("/ai/tools/search", model) diff --git a/src/tools/backtests.py b/src/tools/backtests.py index 4dbec4a..b6732c9 100644 --- a/src/tools/backtests.py +++ b/src/tools/backtests.py @@ -1,100 +1,87 @@ +from __future__ import annotations + from api_connection import post from models import ( + BacktestInsightsResponse, + BacktestOrdersResponse, + BacktestResponse, + BacktestSummaryResponse, CreateBacktestRequest, - ReadBacktestRequest, + DeleteBacktestRequest, + ListBacktestRequest, ReadBacktestChartRequest, - ReadBacktestOrdersRequest, ReadBacktestInsightsRequest, - BacktestReportRequest, - ListBacktestRequest, - UpdateBacktestRequest, - DeleteBacktestRequest, - BacktestResponse, - #LoadingChartResponse, + ReadBacktestOrdersRequest, + ReadBacktestRequest, ReadChartResponse, - BacktestOrdersResponse, - BacktestInsightsResponse, - BacktestSummaryResponse, - BacktestReport, - BacktestReportGeneratingResponse, - RestResponse + RestResponse, + UpdateBacktestRequest, ) +from mcp.server.fastmcp import FastMCP + + +def register_backtest_tools(mcp: FastMCP) -> None: + """Expose backtest management operations.""" -def register_backtest_tools(mcp): - # Create @mcp.tool( annotations={ - 'title': 'Create backtest', - 'destructiveHint': False + "title": "Create backtest", + "destructiveHint": False, } ) - async def create_backtest( - model: CreateBacktestRequest) -> BacktestResponse: - """Create a new backtest request and get the backtest Id.""" - return await post('/backtests/create', model) + async def create_backtest(model: CreateBacktestRequest) -> BacktestResponse: + """Kick off a new backtest request and return the tracking identifier.""" - # Read statistics for a single backtest. - @mcp.tool(annotations={'title': 'Read backtest', 'readOnlyHint': True}) + return await post("/backtests/create", model) + + @mcp.tool(annotations={"title": "Read backtest", "readOnlyHint": True}) async def read_backtest(model: ReadBacktestRequest) -> BacktestResponse: - """Read the results of a backtest.""" - return await post('/backtests/read', model) + """Retrieve the latest result for a completed backtest.""" + + return await post("/backtests/read", model) - # Read a summary of all the backtests. - @mcp.tool(annotations={'title': 'List backtests', 'readOnlyHint': True}) + @mcp.tool(annotations={"title": "List backtests", "readOnlyHint": True}) async def list_backtests( - model: ListBacktestRequest) -> BacktestSummaryResponse: - """List all the backtests for the project.""" - return await post('/backtests/list', model) + model: ListBacktestRequest, + ) -> BacktestSummaryResponse: + """Return a summary of recent backtests for a project.""" - # Read the chart of a single backtest. - @mcp.tool( - annotations={'title': 'Read backtest chart', 'readOnlyHint': True} - ) + return await post("/backtests/list", model) + + @mcp.tool(annotations={"title": "Read backtest chart", "readOnlyHint": True}) async def read_backtest_chart( - model: ReadBacktestChartRequest) -> ReadChartResponse: - """Read a chart from a backtest.""" - return await post('/backtests/chart/read', model) - - # Read the orders of a single backtest. - @mcp.tool( - annotations={'title': 'Read backtest orders', 'readOnlyHint': True} - ) + model: ReadBacktestChartRequest, + ) -> ReadChartResponse: + """Fetch chart data from a backtest result.""" + + return await post("/backtests/chart/read", model) + + @mcp.tool(annotations={"title": "Read backtest orders", "readOnlyHint": True}) async def read_backtest_orders( - model: ReadBacktestOrdersRequest) -> BacktestOrdersResponse: - """Read out the orders of a backtest.""" - return await post('/backtests/orders/read', model) - - # Read the insights of a single backtest. + model: ReadBacktestOrdersRequest, + ) -> BacktestOrdersResponse: + """Retrieve orders generated during a backtest.""" + + return await post("/backtests/orders/read", model) + @mcp.tool( - annotations={'title': 'Read backtest insights', 'readOnlyHint': True} + annotations={"title": "Read backtest insights", "readOnlyHint": True} ) async def read_backtest_insights( - model: ReadBacktestInsightsRequest) -> BacktestInsightsResponse: - """Read out the insights of a backtest.""" - return await post('/backtests/read/insights', model) - - ## Read the report of a single backtest. - #@mcp.tool( - # annotations={'title': 'Read backtest report', 'readOnlyHint': True} - #) - #async def read_backtest_report( - # model: BacktestReportRequest - # ) -> BacktestReport | BacktestReportGeneratingResponse: - # """Read out the report of a backtest.""" - # return await post('/backtests/read/report', model) - - # Update - @mcp.tool( - annotations={'title': 'Update backtest', 'idempotentHint': True} - ) + model: ReadBacktestInsightsRequest, + ) -> BacktestInsightsResponse: + """Retrieve alpha insights produced by a backtest.""" + + return await post("/backtests/read/insights", model) + + @mcp.tool(annotations={"title": "Update backtest", "idempotentHint": True}) async def update_backtest(model: UpdateBacktestRequest) -> RestResponse: - """Update the name or note of a backtest.""" - return await post('/backtests/update', model) - - # Delete - @mcp.tool( - annotations={'title': 'Delete backtest', 'idempotentHint': True} - ) + """Rename a backtest or update its notes.""" + + return await post("/backtests/update", model) + + @mcp.tool(annotations={"title": "Delete backtest", "idempotentHint": True}) async def delete_backtest(model: DeleteBacktestRequest) -> RestResponse: - """Delete a backtest from a project.""" - return await post('/backtests/delete', model) + """Remove a backtest from the project history.""" + + return await post("/backtests/delete", model) diff --git a/src/tools/compile.py b/src/tools/compile.py index ae46e91..369dea9 100644 --- a/src/tools/compile.py +++ b/src/tools/compile.py @@ -1,23 +1,28 @@ +from __future__ import annotations + from api_connection import post from models import ( CreateCompileRequest, - ReadCompileRequest, CreateCompileResponse, - ReadCompileResponse + ReadCompileRequest, + ReadCompileResponse, ) +from mcp.server.fastmcp import FastMCP + -def register_compile_tools(mcp): - # Create - @mcp.tool( - annotations={'title': 'Create compile', 'destructiveHint': False} - ) +def register_compile_tools(mcp: FastMCP) -> None: + """Expose Lean compilation helpers.""" + + @mcp.tool(annotations={"title": "Create compile", "destructiveHint": False}) async def create_compile( - model: CreateCompileRequest) -> CreateCompileResponse: - """Asynchronously create a compile job request for a project.""" - return await post('/compile/create', model) + model: CreateCompileRequest, + ) -> CreateCompileResponse: + """Submit an asynchronous compile request.""" + + return await post("/compile/create", model) - # Read - @mcp.tool(annotations={'title': 'Read compile', 'readOnlyHint': True}) + @mcp.tool(annotations={"title": "Read compile", "readOnlyHint": True}) async def read_compile(model: ReadCompileRequest) -> ReadCompileResponse: - """Read a compile packet job result.""" - return await post('/compile/read', model) + """Retrieve the status of a compile request.""" + + return await post("/compile/read", model) diff --git a/src/tools/files.py b/src/tools/files.py index 0c48fd6..ba69eba 100644 --- a/src/tools/files.py +++ b/src/tools/files.py @@ -1,66 +1,63 @@ +from __future__ import annotations + from api_connection import post from code_source_id import add_code_source_id from models import ( CreateProjectFileRequest, - ReadFilesRequest, - UpdateFileNameRequest, - UpdateFileContentsRequest, - PatchFileRequest, DeleteFileRequest, + PatchFileRequest, + ProjectFilesResponse, + ReadFilesRequest, RestResponse, - ProjectFilesResponse + UpdateFileContentsRequest, + UpdateFileNameRequest, ) +from mcp.server.fastmcp import FastMCP -def register_file_tools(mcp): - # Create +def register_file_tools(mcp: FastMCP) -> None: + """Expose project file management operations.""" + @mcp.tool( annotations={ - 'title': 'Create file', - 'destructiveHint': False, - 'idempotentHint': True + "title": "Create file", + "destructiveHint": False, + "idempotentHint": True, } ) - async def create_file( - model: CreateProjectFileRequest) -> RestResponse: + async def create_file(model: CreateProjectFileRequest) -> RestResponse: """Add a file to a given project.""" - return await post('/files/create', add_code_source_id(model)) - # Read - @mcp.tool(annotations={'title': 'Read file', 'readOnlyHint': True}) + return await post("/files/create", add_code_source_id(model)) + + @mcp.tool(annotations={"title": "Read file", "readOnlyHint": True}) async def read_file(model: ReadFilesRequest) -> ProjectFilesResponse: - """Read a file from a project, or all files in the project if - no file name is provided. - """ - return await post('/files/read', add_code_source_id(model)) - - # Update name - @mcp.tool( - annotations={'title': 'Update file name', 'idempotentHint': True} - ) + """Read a file or enumerate all files within a project.""" + + return await post("/files/read", add_code_source_id(model)) + + @mcp.tool(annotations={"title": "Update file name", "idempotentHint": True}) async def update_file_name(model: UpdateFileNameRequest) -> RestResponse: - """Update the name of a file.""" - return await post('/files/update', add_code_source_id(model)) + """Rename a project file.""" - # Update contents - @mcp.tool( - annotations={'title': 'Update file contents', 'idempotentHint': True} - ) + return await post("/files/update", add_code_source_id(model)) + + @mcp.tool(annotations={"title": "Update file contents", "idempotentHint": True}) async def update_file_contents( - model: UpdateFileContentsRequest) -> ProjectFilesResponse: - """Update the contents of a file.""" - return await post('/files/update', add_code_source_id(model)) + model: UpdateFileContentsRequest, + ) -> ProjectFilesResponse: + """Replace the contents of an existing file.""" - # Update lines (patch) - @mcp.tool( - annotations={'title': 'Patch file', 'idempotentHint': True} - ) + return await post("/files/update", add_code_source_id(model)) + + @mcp.tool(annotations={"title": "Patch file", "idempotentHint": True}) async def patch_file(model: PatchFileRequest) -> RestResponse: - """Apply a patch (unified diff) to a file in a project.""" - return await post('/files/patch', add_code_source_id(model)) - - # Delete - @mcp.tool(annotations={'title': 'Delete file', 'idempotentHint': True}) + """Apply a unified-diff patch to a file.""" + + return await post("/files/patch", add_code_source_id(model)) + + @mcp.tool(annotations={"title": "Delete file", "idempotentHint": True}) async def delete_file(model: DeleteFileRequest) -> RestResponse: - """Delete a file in a project.""" - return await post('/files/delete', add_code_source_id(model)) + """Delete a file from a project.""" + + return await post("/files/delete", add_code_source_id(model)) diff --git a/src/tools/lean_versions.py b/src/tools/lean_versions.py index ce34a3c..0dec4e1 100644 --- a/src/tools/lean_versions.py +++ b/src/tools/lean_versions.py @@ -1,13 +1,15 @@ +from __future__ import annotations + from api_connection import post -from models import LeanVersionsResponse +from models import LeanVersionsResponse +from mcp.server.fastmcp import FastMCP + -def register_lean_version_tools(mcp): - # Read - @mcp.tool( - annotations={'title': 'Read LEAN versions', 'readOnlyHint': True} - ) +def register_lean_version_tools(mcp: FastMCP) -> None: + """Expose Lean version discovery tooling.""" + + @mcp.tool(annotations={"title": "Read LEAN versions", "readOnlyHint": True}) async def read_lean_versions() -> LeanVersionsResponse: - """Returns a list of LEAN versions with basic information for - each version. - """ - return await post('/lean/versions/read') + """Return the list of Lean versions with metadata.""" + + return await post("/lean/versions/read") diff --git a/src/tools/live.py b/src/tools/live.py index b0c161b..b6c8c61 100644 --- a/src/tools/live.py +++ b/src/tools/live.py @@ -1,161 +1,145 @@ -from pydantic_core import to_jsonable_python +from __future__ import annotations + import webbrowser +from typing import Any -from api_connection import post, httpx, get_headers, BASE_URL +from api_connection import post, post_raw from models import ( AuthorizeExternalConnectionRequest, - CreateLiveAlgorithmRequest, - ReadLiveAlgorithmRequest, - ListLiveAlgorithmsRequest, - ReadLivePortfolioRequest, - ReadLiveChartRequest, - ReadLiveOrdersRequest, - ReadLiveInsightsRequest, - ReadLiveLogsRequest, - LiquidateLiveAlgorithmRequest, - StopLiveAlgorithmRequest, AuthorizeExternalConnectionResponse, + CreateLiveAlgorithmRequest, CreateLiveAlgorithmResponse, - LiveAlgorithmResults, + LiquidateLiveAlgorithmRequest, + ListLiveAlgorithmsRequest, LiveAlgorithmListResponse, + LiveAlgorithmResults, + LiveInsightsResponse, + LiveOrdersResponse, LivePortfolioResponse, ReadChartResponse, - LiveOrdersResponse, - LiveInsightsResponse, + ReadLiveAlgorithmRequest, + ReadLiveChartRequest, + ReadLiveInsightsRequest, + ReadLiveLogsRequest, ReadLiveLogsResponse, - RestResponse + ReadLiveOrdersRequest, + ReadLivePortfolioRequest, + RestResponse, + StopLiveAlgorithmRequest, ) +from mcp.server.fastmcp import FastMCP -async def handle_loading_response(response, text): - if 'progress' in response: + +async def handle_loading_response(response: dict[str, Any], text: str): + """Surface streaming-progress metadata returned by some live endpoints.""" + + if "progress" in response: progress = response["progress"] - return {'errors': [f'{text} Progress: {progress}']} + return {"errors": [f"{text} Progress: {progress}"]} return response -def register_live_trading_tools(mcp): - # Authenticate + +def register_live_trading_tools(mcp: FastMCP) -> None: + """Expose live trading management tools.""" + @mcp.tool( annotations={ - 'title': 'Authorize external connection', - 'readOnlyHint': False, - 'destructiveHint': False, - 'idempotentHint': True + "title": "Authorize external connection", + "readOnlyHint": False, + "destructiveHint": False, + "idempotentHint": True, } ) async def authorize_connection( - model: AuthorizeExternalConnectionRequest - ) -> AuthorizeExternalConnectionResponse: - """Authorize an external connection with a live brokerage or - data provider. - - This tool automatically opens your browser for you to complete - the authentication flow. For the flow to work, you must be - logged into your QuantConnect account on the browser that opens. - """ - # This endpoint is unique because post we need to extract and - # return the redirect URL and open it in a browser. - async with httpx.AsyncClient(follow_redirects=False) as client: - response = await client.post( - f'{BASE_URL}/live/auth0/authorize', - headers=get_headers(), - json=to_jsonable_python(model, exclude_none=True), - timeout=300.0 # 5 minutes - ) - # Extract the redirect URL from the 'Location' header - redirect_url = response.headers.get("Location") - # Open the URL in the user's default browser. + model: AuthorizeExternalConnectionRequest, + ) -> AuthorizeExternalConnectionResponse: + """Authorize a live brokerage or data provider connection.""" + + response = await post_raw( + "/live/auth0/authorize", + model=model, + timeout=300.0, + follow_redirects=False, + ) + redirect_url = response.headers.get("Location") + if redirect_url: webbrowser.open(redirect_url) - # Read the authentication. - return await post('/live/auth0/read', model, 800.0) + return await post("/live/auth0/read", model, 800.0) - # Create - @mcp.tool( - annotations={ - 'title': 'Create live algorithm', 'destructiveHint': False - } - ) + @mcp.tool(annotations={"title": "Create live algorithm", "destructiveHint": False}) async def create_live_algorithm( - model: CreateLiveAlgorithmRequest) -> CreateLiveAlgorithmResponse: - """Create a live algorithm.""" - return await post('/live/create', model) + model: CreateLiveAlgorithmRequest, + ) -> CreateLiveAlgorithmResponse: + """Deploy a project to live trading.""" - # Read (singular) - @mcp.tool(annotations={'title': 'Read live algorithm', 'readOnly': True}) + return await post("/live/create", model) + + @mcp.tool(annotations={"title": "Read live algorithm", "readOnlyHint": True}) async def read_live_algorithm( - model: ReadLiveAlgorithmRequest) -> LiveAlgorithmResults: - """Read details of a live algorithm.""" - return await post('/live/read', model) + model: ReadLiveAlgorithmRequest, + ) -> LiveAlgorithmResults: + """Retrieve the status of a live deployment.""" + + return await post("/live/read", model) - # Read (all). - @mcp.tool(annotations={'title': 'List live algorithms', 'readOnly': True}) + @mcp.tool(annotations={"title": "List live algorithms", "readOnlyHint": True}) async def list_live_algorithms( - model: ListLiveAlgorithmsRequest) -> LiveAlgorithmListResponse: - """List all your past and current live trading deployments.""" - return await post('/live/list', model) - - # Read a chart. - @mcp.tool(annotations={'title': 'Read live chart', 'readOnly': True}) - async def read_live_chart( - model: ReadLiveChartRequest) -> ReadChartResponse: - """Read a chart from a live algorithm.""" + model: ListLiveAlgorithmsRequest, + ) -> LiveAlgorithmListResponse: + """List current and historical live deployments.""" + + return await post("/live/list", model) + + @mcp.tool(annotations={"title": "Read live chart", "readOnlyHint": True}) + async def read_live_chart(model: ReadLiveChartRequest) -> ReadChartResponse: + """Retrieve chart data for a live deployment.""" + return await handle_loading_response( - await post('/live/chart/read', model), 'Chart is loading.' + await post("/live/chart/read", model), "Chart is loading." ) - # Read the logs. - @mcp.tool(annotations={'title': 'Read live logs', 'readOnly': True}) - async def read_live_logs( - model: ReadLiveLogsRequest) -> ReadLiveLogsResponse: - """Get the logs of a live algorithm. + @mcp.tool(annotations={"title": "Read live logs", "readOnlyHint": True}) + async def read_live_logs(model: ReadLiveLogsRequest) -> ReadLiveLogsResponse: + """Fetch recent logs for a live deployment.""" - The snapshot updates about every 5 minutes.""" - return await post('/live/logs/read', model) + return await post("/live/logs/read", model) - # Read the portfolio state. - @mcp.tool(annotations={'title': 'Read live portfolio', 'readOnly': True}) + @mcp.tool(annotations={"title": "Read live portfolio", "readOnlyHint": True}) async def read_live_portfolio( - model: ReadLivePortfolioRequest) -> LivePortfolioResponse: - """Read out the portfolio state of a live algorithm. + model: ReadLivePortfolioRequest, + ) -> LivePortfolioResponse: + """Retrieve the latest live portfolio snapshot.""" - The snapshot updates about every 10 minutes.""" - return await post('/live/portfolio/read', model) + return await post("/live/portfolio/read", model) - # Read the orders. - @mcp.tool(annotations={'title': 'Read live orders', 'readOnly': True}) - async def read_live_orders( - model: ReadLiveOrdersRequest) -> LiveOrdersResponse: - """Read out the orders of a live algorithm. + @mcp.tool(annotations={"title": "Read live orders", "readOnlyHint": True}) + async def read_live_orders(model: ReadLiveOrdersRequest) -> LiveOrdersResponse: + """Fetch orders for a live deployment.""" - The snapshot updates about every 10 minutes.""" return await handle_loading_response( - await post('/live/orders/read', model), 'Orders are loading.' + await post("/live/orders/read", model), "Orders are loading." ) - # Read the insights. - @mcp.tool(annotations={'title': 'Read live insights', 'readOnly': True}) + @mcp.tool(annotations={"title": "Read live insights", "readOnlyHint": True}) async def read_live_insights( - model: ReadLiveInsightsRequest) -> LiveInsightsResponse: - """Read out the insights of a live algorithm. + model: ReadLiveInsightsRequest, + ) -> LiveInsightsResponse: + """Fetch alpha insights for a live deployment.""" - The snapshot updates about every 10 minutes.""" - return await post('/live/insights/read', model) + return await post("/live/insights/read", model) - # Update (stop) - @mcp.tool( - annotations={'title': 'Stop live algorithm', 'idempotentHint': True} - ) - async def stop_live_algorithm( - model: StopLiveAlgorithmRequest) -> RestResponse: - """Stop a live algorithm.""" - return await post('/live/update/stop', model) + @mcp.tool(annotations={"title": "Stop live algorithm", "idempotentHint": True}) + async def stop_live_algorithm(model: StopLiveAlgorithmRequest) -> RestResponse: + """Stop a live deployment without liquidating.""" + + return await post("/live/update/stop", model) - # Update (liquidate) @mcp.tool( - annotations={ - 'title': 'Liquidate live algorithm', 'idempotentHint': True - } + annotations={"title": "Liquidate live algorithm", "idempotentHint": True} ) async def liquidate_live_algorithm( - model: LiquidateLiveAlgorithmRequest) -> RestResponse: - """Liquidate and stop a live algorithm.""" - return await post('/live/update/liquidate', model) + model: LiquidateLiveAlgorithmRequest, + ) -> RestResponse: + """Liquidate and stop a live deployment.""" + + return await post("/live/update/liquidate", model) diff --git a/src/tools/live_commands.py b/src/tools/live_commands.py index 8b7d669..3f683ae 100644 --- a/src/tools/live_commands.py +++ b/src/tools/live_commands.py @@ -1,22 +1,23 @@ +from __future__ import annotations + from api_connection import post -from models import ( - CreateLiveCommandRequest, - BroadcastLiveCommandRequest, - RestResponse -) +from models import BroadcastLiveCommandRequest, CreateLiveCommandRequest, RestResponse +from mcp.server.fastmcp import FastMCP + + +def register_live_trading_command_tools(mcp: FastMCP) -> None: + """Expose live command broadcast helpers.""" -def register_live_trading_command_tools(mcp): - # Create (singular algorithm) - @mcp.tool(annotations={'title': 'Create live command'}) - async def create_live_command( - model: CreateLiveCommandRequest) -> RestResponse: - """Send a command to a live trading algorithm.""" - return await post('/live/commands/create', model) + @mcp.tool(annotations={"title": "Create live command"}) + async def create_live_command(model: CreateLiveCommandRequest) -> RestResponse: + """Send a command to a specific live deployment.""" - # Create (multiple algorithms) - Broadcast - @mcp.tool(annotations={'title': 'Broadcast live command'}) + return await post("/live/commands/create", model) + + @mcp.tool(annotations={"title": "Broadcast live command"}) async def broadcast_live_command( - model: BroadcastLiveCommandRequest) -> RestResponse: - """Broadcast a live command to all live algorithms in an - organization.""" - return await post('/live/commands/broadcast', model) + model: BroadcastLiveCommandRequest, + ) -> RestResponse: + """Broadcast a live command to every deployment in the organization.""" + + return await post("/live/commands/broadcast", model) diff --git a/src/tools/mcp_server_version.py b/src/tools/mcp_server_version.py index 24a5156..6da510d 100644 --- a/src/tools/mcp_server_version.py +++ b/src/tools/mcp_server_version.py @@ -1,30 +1,40 @@ -from __init__ import __version__ +from __future__ import annotations -import requests +import httpx + +from mcp.server.fastmcp import FastMCP +from version import __version__ + +DOCKER_TAGS_URL = ( + "https://hub.docker.com/v2/namespaces/quantconnect/repositories/mcp-server/tags" +) + + +def register_mcp_server_version_tools(mcp: FastMCP) -> None: + """Expose utilities for inspecting server versions.""" -def register_mcp_server_version_tools(mcp): - # Read current version @mcp.tool( - annotations={ - 'title': 'Read QC MCP Server version', 'readOnlyHint': True - } + annotations={"title": "Read QC MCP Server version", "readOnlyHint": True} ) async def read_mcp_server_version() -> str: - """Returns the version of the QC MCP Server that's running.""" + """Return the version of the currently running MCP server.""" + return __version__ - # Read latest version @mcp.tool( annotations={ - 'title': 'Read latest QC MCP Server version', 'readOnlyHint': True + "title": "Read latest QC MCP Server version", + "readOnlyHint": True, } ) async def read_latest_mcp_server_version() -> str: - """Returns the latest version of the QC MCP Server released.""" - response = requests.get( - "https://hub.docker.com/v2/namespaces/quantconnect/repositories/mcp-server/tags", - params={"page_size": 2} - ) - response.raise_for_status() - # Get the name of the second result. The first one is 'latest'. - return response.json()['results'][1]['name'] + """Return the latest published version of the MCP server.""" + + async with httpx.AsyncClient() as client: + response = await client.get(DOCKER_TAGS_URL, params={"page_size": 5}) + response.raise_for_status() + for tag in response.json().get("results", []): + name = tag.get("name") + if name and name != "latest": + return name + raise RuntimeError("No version tags returned by Docker Hub.") diff --git a/src/tools/object_store.py b/src/tools/object_store.py index b9ffcf5..0917ba9 100644 --- a/src/tools/object_store.py +++ b/src/tools/object_store.py @@ -1,105 +1,101 @@ -from api_connection import post, httpx, get_headers, BASE_URL +from __future__ import annotations + +from api_connection import authenticated_client, post from models import ( - ObjectStoreBinaryFile, - GetObjectStorePropertiesRequest, - GetObjectStoreJobIdRequest, - GetObjectStoreURLRequest, - ListObjectStoreRequest, DeleteObjectStoreRequest, + GetObjectStoreJobIdRequest, + GetObjectStorePropertiesRequest, GetObjectStorePropertiesResponse, GetObjectStoreResponse, + GetObjectStoreURLRequest, + ListObjectStoreRequest, ListObjectStoreResponse, - RestResponse + ObjectStoreBinaryFile, + RestResponse, ) +from mcp.server.fastmcp import FastMCP -def register_object_store_tools(mcp): - # Create - @mcp.tool( - annotations={ - 'title': 'Upload Object Store file', 'idempotentHint': True - } - ) - async def upload_object( - model: ObjectStoreBinaryFile) -> RestResponse: - """Upload files to the Object Store.""" - # This endpoint is unique because post request requires `data` - # and `files` arguments. - async with httpx.AsyncClient() as client: - response = await client.post( - f'{BASE_URL}/object/set', - headers=get_headers(), - data={ - 'organizationId': model.organizationId, - 'key': model.key - }, - files={'objectData': model.objectData}, - timeout=30.0 - ) - response.raise_for_status() - return response.json() - # Read file metadata +def register_object_store_tools(mcp: FastMCP) -> None: + """Expose QuantConnect Object Store utilities.""" + + @mcp.tool(annotations={"title": "Upload Object Store file", "idempotentHint": True}) + async def upload_object(model: ObjectStoreBinaryFile) -> RestResponse: + """Upload a file to the Object Store.""" + + async with authenticated_client() as (client, headers, settings): + try: + response = await client.post( + "/object/set", + headers=headers, + data={ + "organizationId": model.organizationId, + "key": model.key, + }, + files={"objectData": model.objectData}, + timeout=settings.api_timeout, + ) + response.raise_for_status() + return response.json() + except Exception as exc: # pragma: no cover - httpx raises HTTPError subclasses + raise RuntimeError("Failed to upload Object Store file") from exc + @mcp.tool( annotations={ - 'title': 'Read Object Store file properties', 'readOnlyHint': True + "title": "Read Object Store file properties", + "readOnlyHint": True, } ) async def read_object_properties( - model: GetObjectStorePropertiesRequest - ) -> GetObjectStorePropertiesResponse: - """Get Object Store properties of a specific organization and - key. + model: GetObjectStorePropertiesRequest, + ) -> GetObjectStorePropertiesResponse: + """Read metadata for a specific Object Store key.""" - It doesn't work if the key is a directory in the Object Store. - """ - return await post('/object/properties', model) + return await post("/object/properties", model) - # Read file job Id @mcp.tool( annotations={ - 'title': 'Read Object Store file job Id', 'destructiveHint': False + "title": "Read Object Store file job Id", + "destructiveHint": False, } ) async def read_object_store_file_job_id( - model: GetObjectStoreJobIdRequest) -> GetObjectStoreResponse: - """Create a job to download files from the Object Store and - then read the job Id. - """ - return await post('/object/get', model) + model: GetObjectStoreJobIdRequest, + ) -> GetObjectStoreResponse: + """Create a download job and return its identifier.""" + + return await post("/object/get", model) - # Read file download URL @mcp.tool( annotations={ - 'title': 'Read Object Store file download URL', - 'readOnlyHint': True + "title": "Read Object Store file download URL", + "readOnlyHint": True, } ) async def read_object_store_file_download_url( - model: GetObjectStoreURLRequest) -> GetObjectStoreResponse: - """Get the URL for downloading files from the Object Store.""" - return await post('/object/get', model) + model: GetObjectStoreURLRequest, + ) -> GetObjectStoreResponse: + """Return a pre-signed download URL for an Object Store key.""" + + return await post("/object/get", model) - # Read all files @mcp.tool( - annotations={'title': 'List Object Store files', 'readOnlyHint': True} + annotations={"title": "List Object Store files", "readOnlyHint": True} ) async def list_object_store_files( - model: ListObjectStoreRequest) -> ListObjectStoreResponse: - """List the Object Store files under a specific directory in - an organization. - """ - return await post('/object/list', model) + model: ListObjectStoreRequest, + ) -> ListObjectStoreResponse: + """List Object Store files within a directory.""" + + return await post("/object/list", model) - # Delete @mcp.tool( annotations={ - 'title': 'Delete Object Store file', - 'idempotentHint': True + "title": "Delete Object Store file", + "idempotentHint": True, } ) - async def delete_object( - model: DeleteObjectStoreRequest) -> RestResponse: - """Delete the Object Store file of a specific organization and - key. - """ - return await post('/object/delete', model) + async def delete_object(model: DeleteObjectStoreRequest) -> RestResponse: + """Delete an Object Store key.""" + + return await post("/object/delete", model) diff --git a/src/tools/optimizations.py b/src/tools/optimizations.py index 2f9ed0c..0c0730b 100644 --- a/src/tools/optimizations.py +++ b/src/tools/optimizations.py @@ -1,85 +1,76 @@ +from __future__ import annotations + from api_connection import post from models import ( - EstimateOptimizationRequest, - CreateOptimizationRequest, - ReadOptimizationRequest, - ListOptimizationRequest, - EstimateOptimizationResponse, - UpdateOptimizationRequest, AbortOptimizationRequest, + CreateOptimizationRequest, DeleteOptimizationRequest, + EstimateOptimizationRequest, + EstimateOptimizationResponse, + ListOptimizationRequest, ListOptimizationResponse, + ReadOptimizationRequest, ReadOptimizationResponse, - RestResponse + RestResponse, + UpdateOptimizationRequest, ) +from mcp.server.fastmcp import FastMCP + + +def register_optimization_tools(mcp: FastMCP) -> None: + """Expose optimization helper endpoints.""" -def register_optimization_tools(mcp): - # Estimate cost @mcp.tool( annotations={ - 'title': 'Estimate optimization time', - 'readOnlyHint': True, + "title": "Estimate optimization time", + "readOnlyHint": True, } ) async def estimate_optimization_time( - model: EstimateOptimizationRequest) -> EstimateOptimizationResponse: - """Estimate the execution time of an optimization with the - specified parameters. - """ - return await post('/optimizations/estimate', model) + model: EstimateOptimizationRequest, + ) -> EstimateOptimizationResponse: + """Estimate resource requirements for an optimization.""" - # Create - @mcp.tool( - annotations={ - 'title': 'Create optimization', - 'destructiveHint': False - } - ) + return await post("/optimizations/estimate", model) + + @mcp.tool(annotations={"title": "Create optimization", "destructiveHint": False}) async def create_optimization( - model: CreateOptimizationRequest) -> ListOptimizationResponse: - """Create an optimization with the specified parameters.""" - return await post('/optimizations/create', model) + model: CreateOptimizationRequest, + ) -> ListOptimizationResponse: + """Start a new optimization job.""" - # Read a single optimization job. - @mcp.tool( - annotations={'title': 'Read optimization', 'readOnlyHint': True} - ) + return await post("/optimizations/create", model) + + @mcp.tool(annotations={"title": "Read optimization", "readOnlyHint": True}) async def read_optimization( - model: ReadOptimizationRequest) -> ReadOptimizationResponse: - """Read an optimization.""" - return await post('/optimizations/read', model) + model: ReadOptimizationRequest, + ) -> ReadOptimizationResponse: + """Retrieve the status of an optimization job.""" - # Read all optimizations for a project. - @mcp.tool( - annotations={'title': 'List optimizations', 'readOnlyHint': True} - ) + return await post("/optimizations/read", model) + + @mcp.tool(annotations={"title": "List optimizations", "readOnlyHint": True}) async def list_optimizations( - model: ListOptimizationRequest) -> ListOptimizationResponse: - """List all the optimizations for a project.""" - return await post('/optimizations/list', model) + model: ListOptimizationRequest, + ) -> ListOptimizationResponse: + """List optimizations for a project.""" - # Update the optimization name. - @mcp.tool( - annotations={'title': 'Update optimization', 'idempotentHint': True} - ) - async def update_optimization( - model: UpdateOptimizationRequest) -> RestResponse: - """Update the name of an optimization.""" - return await post('/optimizations/update', model) + return await post("/optimizations/list", model) - # Update the optimization status (stop). - @mcp.tool( - annotations={'title': 'Abort optimization', 'idempotentHint': True} - ) - async def abort_optimization( - model: AbortOptimizationRequest) -> RestResponse: - """Abort an optimization.""" - return await post('/optimizations/abort', model) + @mcp.tool(annotations={"title": "Update optimization", "idempotentHint": True}) + async def update_optimization(model: UpdateOptimizationRequest) -> RestResponse: + """Update the name of an optimization job.""" - # Delete - @mcp.tool( - annotations={'title': 'Delete optimization', 'idempotentHint': True} - ) + return await post("/optimizations/update", model) + + @mcp.tool(annotations={"title": "Abort optimization", "idempotentHint": True}) + async def abort_optimization(model: AbortOptimizationRequest) -> RestResponse: + """Abort an optimization that is currently running.""" + + return await post("/optimizations/abort", model) + + @mcp.tool(annotations={"title": "Delete optimization", "idempotentHint": True}) async def delete_optimization(model: DeleteOptimizationRequest) -> RestResponse: - """Delete an optimization.""" - return await post('/optimizations/delete', model) + """Delete an optimization job.""" + + return await post("/optimizations/delete", model) diff --git a/src/tools/project.py b/src/tools/project.py index b733a61..116bf7f 100644 --- a/src/tools/project.py +++ b/src/tools/project.py @@ -1,46 +1,67 @@ +from __future__ import annotations + from api_connection import post from models import ( - CreateProjectRequest, - ReadProjectRequest, - UpdateProjectRequest, + CreateProjectRequest, DeleteProjectRequest, ProjectListResponse, - RestResponse + ReadProjectRequest, + RestResponse, + UpdateProjectRequest, ) +from mcp.server.fastmcp import FastMCP + + +def _normalize_parameters(payload: dict) -> dict: + """QuantConnect returns [] for parameter sets; normalize to empty dicts.""" + + projects = payload.get("projects") + if isinstance(projects, list): + for project in projects: + parameters = project.get("parameters") + if parameters == []: + project["parameters"] = {} + return payload + + +def register_project_tools(mcp: FastMCP) -> None: + """Expose project management helpers.""" -def register_project_tools(mcp): - # Create @mcp.tool( annotations={ - 'title': 'Create project', - 'destructiveHint': False, - 'idempotentHint': False + "title": "Create project", + "destructiveHint": False, + "idempotentHint": False, } ) async def create_project(model: CreateProjectRequest) -> ProjectListResponse: - """Create a new project in your default organization.""" - return await post('/projects/create', model) + """Create a new project in the default organization.""" - # Read (singular) - @mcp.tool(annotations={'title': 'Read project', 'readOnlyHint': True}) + payload = await post("/projects/create", model) + return _normalize_parameters(payload) + + @mcp.tool(annotations={"title": "Read project", "readOnlyHint": True}) async def read_project(model: ReadProjectRequest) -> ProjectListResponse: - """List the details of a project or a set of recent projects.""" - return await post('/projects/read', model) - - # Read (all) - @mcp.tool(annotations={'title': 'List projects', 'readOnlyHint': True}) + """Return information for a specific project or recent projects.""" + + payload = await post("/projects/read", model) + return _normalize_parameters(payload) + + @mcp.tool(annotations={"title": "List projects", "readOnlyHint": True}) async def list_projects() -> ProjectListResponse: - """List the details of all projects.""" - return await post('/projects/read') + """List all projects in the organization.""" + + payload = await post("/projects/read") + return _normalize_parameters(payload) - # Update - @mcp.tool(annotations={'title': 'Update project', 'idempotentHint': True}) + @mcp.tool(annotations={"title": "Update project", "idempotentHint": True}) async def update_project(model: UpdateProjectRequest) -> RestResponse: """Update a project's name or description.""" - return await post('/projects/update', model) - - # Delete - @mcp.tool(annotations={'title': 'Delete project', 'idempotentHint': True}) + + return await post("/projects/update", model) + + @mcp.tool(annotations={"title": "Delete project", "idempotentHint": True}) async def delete_project(model: DeleteProjectRequest) -> RestResponse: """Delete a project.""" - return await post('/projects/delete', model) + + return await post("/projects/delete", model) diff --git a/src/tools/project_collaboration.py b/src/tools/project_collaboration.py index e99d1f1..f0894b5 100644 --- a/src/tools/project_collaboration.py +++ b/src/tools/project_collaboration.py @@ -1,82 +1,89 @@ +from __future__ import annotations + from api_connection import post from code_source_id import add_code_source_id from models import ( - CreateCollaboratorRequest, - ReadCollaboratorsRequest, - UpdateCollaboratorRequest, + CreateCollaboratorRequest, + CreateCollaboratorResponse, DeleteCollaboratorRequest, + DeleteCollaboratorResponse, LockCollaboratorRequest, - CreateCollaboratorResponse, + ReadCollaboratorsRequest, ReadCollaboratorsResponse, + RestResponse, + UpdateCollaboratorRequest, UpdateCollaboratorResponse, - DeleteCollaboratorResponse, - RestResponse ) +from mcp.server.fastmcp import FastMCP + + +def register_project_collaboration_tools(mcp: FastMCP) -> None: + """Expose collaboration management helpers.""" -def register_project_collaboration_tools(mcp): - # Create @mcp.tool( annotations={ - 'title': 'Create project collaborator', - 'destructiveHint': False, - 'idempotentHint': True + "title": "Create project collaborator", + "destructiveHint": False, + "idempotentHint": True, } ) async def create_project_collaborator( - model: CreateCollaboratorRequest) -> CreateCollaboratorResponse: + model: CreateCollaboratorRequest, + ) -> CreateCollaboratorResponse: """Add a collaborator to a project.""" - return await post('/projects/collaboration/create', model) - # Read + return await post("/projects/collaboration/create", model) + @mcp.tool( annotations={ - 'title': 'Read project collaborators', - 'readOnlyHint': True + "title": "Read project collaborators", + "readOnlyHint": True, } ) async def read_project_collaborators( - model: ReadCollaboratorsRequest) -> ReadCollaboratorsResponse: - """List all collaborators on a project.""" - return await post('/projects/collaboration/read', model) + model: ReadCollaboratorsRequest, + ) -> ReadCollaboratorsResponse: + """List collaborators for a given project.""" + + return await post("/projects/collaboration/read", model) - # Update @mcp.tool( annotations={ - 'title': 'Update project collaborator', - 'idempotentHint': True + "title": "Update project collaborator", + "idempotentHint": True, } ) async def update_project_collaborator( - model: UpdateCollaboratorRequest) -> UpdateCollaboratorResponse: - """Update collaborator information in a project.""" - return await post('/projects/collaboration/update', model) + model: UpdateCollaboratorRequest, + ) -> UpdateCollaboratorResponse: + """Update project collaborator permissions.""" + + return await post("/projects/collaboration/update", model) - # Delete @mcp.tool( annotations={ - 'title': 'Delete project collaborator', - 'idempotentHint': True + "title": "Delete project collaborator", + "idempotentHint": True, } ) async def delete_project_collaborator( - model: DeleteCollaboratorRequest) -> DeleteCollaboratorResponse: + model: DeleteCollaboratorRequest, + ) -> DeleteCollaboratorResponse: """Remove a collaborator from a project.""" - return await post('/projects/collaboration/delete', model) - # Lock + return await post("/projects/collaboration/delete", model) + @mcp.tool( annotations={ - 'title': 'Lock project with collaborators', - 'idempotentHint': True + "title": "Lock project with collaborators", + "idempotentHint": True, } ) async def lock_project_with_collaborators( - model: LockCollaboratorRequest) -> RestResponse: - """Lock a project so you can edit it. + model: LockCollaboratorRequest, + ) -> RestResponse: + """Acquire a collaboration lock before editing project files.""" - This is necessary when the project has collaborators or when an - LLM is editing files on your behalf via our MCP Server.""" return await post( - '/projects/collaboration/lock/acquire', add_code_source_id(model) + "/projects/collaboration/lock/acquire", add_code_source_id(model) ) - diff --git a/src/tools/project_nodes.py b/src/tools/project_nodes.py index cc34f8b..452addf 100644 --- a/src/tools/project_nodes.py +++ b/src/tools/project_nodes.py @@ -1,33 +1,31 @@ +from __future__ import annotations + from api_connection import post -from models import ( - ReadProjectNodesRequest, - UpdateProjectNodesRequest, - ProjectNodesResponse -) +from models import ProjectNodesResponse, ReadProjectNodesRequest, UpdateProjectNodesRequest +from mcp.server.fastmcp import FastMCP -def register_project_node_tools(mcp): - # Read - @mcp.tool( - annotations={'title': 'Read project nodes', 'readOnlyHint': True} - ) + +def register_project_node_tools(mcp: FastMCP) -> None: + """Expose endpoints for managing project compute nodes.""" + + @mcp.tool(annotations={"title": "Read project nodes", "readOnlyHint": True}) async def read_project_nodes( - model: ReadProjectNodesRequest) -> ProjectNodesResponse: + model: ReadProjectNodesRequest, + ) -> ProjectNodesResponse: """Read the available and selected nodes of a project.""" - return await post('/projects/nodes/read', model) - # Update + return await post("/projects/nodes/read", model) + @mcp.tool( annotations={ - 'title': 'Update project nodes', - 'destructiveHint': False, - 'idempotentHint': True + "title": "Update project nodes", + "destructiveHint": False, + "idempotentHint": True, } ) async def update_project_nodes( - model: UpdateProjectNodesRequest) -> ProjectNodesResponse: - """Update the active state of the given nodes to true. - - If you don't provide any nodes, all the nodes become inactive - and autoSelectNode is true. - """ - return await post('/projects/nodes/update', model) + model: UpdateProjectNodesRequest, + ) -> ProjectNodesResponse: + """Activate specific project nodes or fall back to auto-selection.""" + + return await post("/projects/nodes/update", model) diff --git a/tests/algorithms/charts.py b/tests/algorithms/charts.py index eaa6e1f..3910d71 100644 --- a/tests/algorithms/charts.py +++ b/tests/algorithms/charts.py @@ -1,3 +1,4 @@ +# ruff: noqa: F403, F405 # region imports from AlgorithmImports import * # endregion diff --git a/tests/algorithms/file_patch.py b/tests/algorithms/file_patch.py index 5a38b08..35a5251 100644 --- a/tests/algorithms/file_patch.py +++ b/tests/algorithms/file_patch.py @@ -1,3 +1,4 @@ +# ruff: noqa: F403, F405 # region imports from AlgorithmImports import * # endregion diff --git a/tests/algorithms/initialization_errors.py b/tests/algorithms/initialization_errors.py index 0c2d3ca..e765188 100644 --- a/tests/algorithms/initialization_errors.py +++ b/tests/algorithms/initialization_errors.py @@ -1,3 +1,4 @@ +# ruff: noqa: F403, F405 from AlgorithmImports import * diff --git a/tests/algorithms/insights.py b/tests/algorithms/insights.py index 5867210..2be6a3d 100644 --- a/tests/algorithms/insights.py +++ b/tests/algorithms/insights.py @@ -1,3 +1,4 @@ +# ruff: noqa: F403, F405 # region imports from AlgorithmImports import * # endregion diff --git a/tests/algorithms/live_charts.py b/tests/algorithms/live_charts.py index c24a5e6..021fab4 100644 --- a/tests/algorithms/live_charts.py +++ b/tests/algorithms/live_charts.py @@ -1,3 +1,4 @@ +# ruff: noqa: F403, F405 # region imports from AlgorithmImports import * # endregion diff --git a/tests/algorithms/live_command.py b/tests/algorithms/live_command.py index 9946c7c..a8cb512 100644 --- a/tests/algorithms/live_command.py +++ b/tests/algorithms/live_command.py @@ -1,3 +1,4 @@ +# ruff: noqa: F403, F405 # region imports from AlgorithmImports import * # endregion diff --git a/tests/algorithms/live_insights.py b/tests/algorithms/live_insights.py index c2c2141..f2423a4 100644 --- a/tests/algorithms/live_insights.py +++ b/tests/algorithms/live_insights.py @@ -1,3 +1,4 @@ +# ruff: noqa: F403, F405 # region imports from AlgorithmImports import * # endregion diff --git a/tests/algorithms/live_liquidate.py b/tests/algorithms/live_liquidate.py index 79673b4..82c50c1 100644 --- a/tests/algorithms/live_liquidate.py +++ b/tests/algorithms/live_liquidate.py @@ -1,3 +1,4 @@ +# ruff: noqa: F403, F405 # region imports from AlgorithmImports import * # endregion diff --git a/tests/algorithms/live_logs.py b/tests/algorithms/live_logs.py index 27fefdd..968b74c 100644 --- a/tests/algorithms/live_logs.py +++ b/tests/algorithms/live_logs.py @@ -1,3 +1,4 @@ +# ruff: noqa: F403, F405 # region imports from AlgorithmImports import * # endregion diff --git a/tests/algorithms/live_orders.py b/tests/algorithms/live_orders.py index b5d1417..17dc976 100644 --- a/tests/algorithms/live_orders.py +++ b/tests/algorithms/live_orders.py @@ -1,3 +1,4 @@ +# ruff: noqa: F403, F405 # region imports from AlgorithmImports import * # endregion diff --git a/tests/algorithms/order_properties.py b/tests/algorithms/order_properties.py index 9f20ada..d40b75a 100644 --- a/tests/algorithms/order_properties.py +++ b/tests/algorithms/order_properties.py @@ -1,3 +1,4 @@ +# ruff: noqa: F403, F405 # region imports from AlgorithmImports import * # endregion diff --git a/tests/algorithms/parameter_optimization.py b/tests/algorithms/parameter_optimization.py index 178c913..c48b39c 100644 --- a/tests/algorithms/parameter_optimization.py +++ b/tests/algorithms/parameter_optimization.py @@ -1,3 +1,4 @@ +# ruff: noqa: F403, F405 # region imports from AlgorithmImports import * # endregion diff --git a/tests/algorithms/pep8_violations.py b/tests/algorithms/pep8_violations.py index 5aa651b..6d82ab0 100644 --- a/tests/algorithms/pep8_violations.py +++ b/tests/algorithms/pep8_violations.py @@ -1,3 +1,4 @@ +# ruff: noqa: F403, F405 from AlgorithmImports import * diff --git a/tests/algorithms/runtime_error.py b/tests/algorithms/runtime_error.py index 2f54d0e..0ea377f 100644 --- a/tests/algorithms/runtime_error.py +++ b/tests/algorithms/runtime_error.py @@ -1,3 +1,4 @@ +# ruff: noqa: F403, F405 # region imports from AlgorithmImports import * # endregion @@ -6,4 +7,4 @@ class BacktestRuntimeErrorTestAlgorithm(QCAlgorithm): def initialize(self): - raise Exception('Test') \ No newline at end of file + raise Exception('Test') diff --git a/tests/algorithms/syntax_errors.py b/tests/algorithms/syntax_errors.py index 04ceb49..ebb7eca 100644 --- a/tests/algorithms/syntax_errors.py +++ b/tests/algorithms/syntax_errors.py @@ -1,3 +1,4 @@ +# ruff: skip-file from AlgorithmImports import * diff --git a/tests/test_api_connection.py b/tests/test_api_connection.py new file mode 100644 index 0000000..900f4c0 --- /dev/null +++ b/tests/test_api_connection.py @@ -0,0 +1,50 @@ +import httpx +import pytest + +from api_connection import post +from settings import clear_settings_cache, get_settings + + +@pytest.fixture(autouse=True) +def _configure_env(monkeypatch): + monkeypatch.setenv("QUANTCONNECT_USER_ID", "1") + monkeypatch.setenv("QUANTCONNECT_API_TOKEN", "token") + monkeypatch.setenv("QUANTCONNECT_API_TIMEOUT", "12.5") + clear_settings_cache() + yield + clear_settings_cache() + + +@pytest.mark.asyncio +async def test_post_uses_default_timeout(monkeypatch): + settings = get_settings(require_credentials=True) + captured = {} + + async def fake_post(self, path, **kwargs): + captured["timeout"] = kwargs.get("timeout") + + class DummyResponse: + def raise_for_status(self): + return None + + def json(self): + return {"success": True} + + return DummyResponse() + + monkeypatch.setattr(httpx.AsyncClient, "post", fake_post, raising=False) + response = await post("/projects/read") + assert response == {"success": True} + assert captured["timeout"] == settings.api_timeout + + +@pytest.mark.asyncio +async def test_post_wraps_http_errors(monkeypatch): + async def fake_post(self, path, **kwargs): + raise httpx.ConnectError("boom", request=httpx.Request("POST", "https://example.com")) + + monkeypatch.setattr(httpx.AsyncClient, "post", fake_post, raising=False) + + with pytest.raises(RuntimeError) as excinfo: + await post("/broken") + assert "QuantConnect API request failed" in str(excinfo.value) diff --git a/tests/test_backtest_charts.py b/tests/test_backtest_charts.py index c7fc6a3..cd8cf78 100644 --- a/tests/test_backtest_charts.py +++ b/tests/test_backtest_charts.py @@ -2,8 +2,6 @@ from main import mcp from test_project import Project -from test_compile import Compile -from test_files import Files from test_backtests import Backtest from utils import ( validate_models, diff --git a/tests/test_initialization.py b/tests/test_initialization.py index 8fecae0..9bd5025 100644 --- a/tests/test_initialization.py +++ b/tests/test_initialization.py @@ -1,6 +1,5 @@ import pytest -from main import mcp class TestInitialization: diff --git a/tests/test_live.py b/tests/test_live.py index ccd8e6e..8250c39 100644 --- a/tests/test_live.py +++ b/tests/test_live.py @@ -231,7 +231,7 @@ async def test_read_and_liquidate_portfolio(self, language, algo): 'quantity': 1, 'averagePrice': 100_000 } - response = await Live.create( + await Live.create( project_id, compile_id, await Live.get_node_id(project_id), brokerage={'id': 'QuantConnectBrokerage', 'holdings': [holding]} ) @@ -273,4 +273,3 @@ async def test_list_live_algorithms(self): for algo in algorithms: assert algo.status == 'Stopped' - diff --git a/tests/test_live_charts.py b/tests/test_live_charts.py index de62de1..5945e83 100644 --- a/tests/test_live_charts.py +++ b/tests/test_live_charts.py @@ -12,7 +12,6 @@ ) from models import ( ReadLiveChartRequest, - LoadingResponse, ReadChartResponse, ) diff --git a/tests/test_live_commands.py b/tests/test_live_commands.py index e691863..69b6353 100644 --- a/tests/test_live_commands.py +++ b/tests/test_live_commands.py @@ -8,8 +8,6 @@ from test_live_logs import LiveLogs from utils import validate_models from models import ( - CreateLiveCommandRequest, - BroadcastLiveCommandRequest, RestResponse ) diff --git a/tests/test_live_insights.py b/tests/test_live_insights.py index 2f96710..204eeab 100644 --- a/tests/test_live_insights.py +++ b/tests/test_live_insights.py @@ -62,9 +62,9 @@ async def test_read_live_insights(self, language, algo): insight.type == 'price' insight.direction == ['up', 'flat'][i%2] insight.period == 24*60*60 # seconds in a day - insight.magnitude == None - insight.confidence == None - insight.weight == None - insight.tag == None + insight.magnitude is None + insight.confidence is None + insight.weight is None + insight.tag is None # Delete the project to clean up. await Project.delete(project_id) diff --git a/tests/test_live_logs.py b/tests/test_live_logs.py index de5576a..d9378af 100644 --- a/tests/test_live_logs.py +++ b/tests/test_live_logs.py @@ -1,12 +1,12 @@ import pytest -from time import time, sleep +from time import sleep from main import mcp from test_live import Live from test_files import Files from test_project import Project from utils import validate_models -from models import ReadLiveLogsRequest, ReadLiveLogsResponse +from models import ReadLiveLogsResponse # Static helpers for common operations: diff --git a/tests/test_live_orders.py b/tests/test_live_orders.py index c4539b0..973c132 100644 --- a/tests/test_live_orders.py +++ b/tests/test_live_orders.py @@ -6,7 +6,7 @@ from test_files import Files from test_live import Live from utils import validate_models -from models import LiveOrdersResponse, LoadingResponse +from models import LiveOrdersResponse # Static helpers for common operations: diff --git a/tests/test_main_cli.py b/tests/test_main_cli.py new file mode 100644 index 0000000..56ec0ea --- /dev/null +++ b/tests/test_main_cli.py @@ -0,0 +1,70 @@ +import main +import pytest + +from settings import clear_settings_cache + + +@pytest.fixture(autouse=True) +def _reset_settings(): + """Ensure cached settings do not leak between tests.""" + + clear_settings_cache() + yield + clear_settings_cache() + + +def test_run_server_overrides_transport(monkeypatch): + monkeypatch.setenv("MCP_TRANSPORT", "auto") + recorded = {} + + class DummySettings: + def __init__(self): + self.host = None + self.port = None + self.log_level = None + + class DummyServer: + def __init__(self): + self.settings = DummySettings() + + def run(self, *, transport, **kwargs): + recorded["transport"] = transport + recorded["kwargs"] = kwargs + recorded["settings"] = self.settings + + monkeypatch.setattr(main, "mcp", DummyServer()) + + main.run_server( + transport="http", + host="1.2.3.4", + port=9000, + log_level="DEBUG", + ) + + assert recorded["transport"] == "streamable-http" + assert recorded["kwargs"] == {} + assert recorded["settings"].host == "1.2.3.4" + assert recorded["settings"].port == 9000 + assert recorded["settings"].log_level == "DEBUG" + + +def test_cli_lists_transports(monkeypatch, capsys): + monkeypatch.setenv("MCP_TRANSPORT", "stdio") + main.cli(["--list-transports"]) + captured = capsys.readouterr() + assert "Available transports" in captured.out + + +def test_cli_invokes_run_server(monkeypatch): + recorded = {} + + def fake_run_server(**kwargs): + recorded.update(kwargs) + + monkeypatch.setenv("MCP_TRANSPORT", "stdio") + monkeypatch.setattr(main, "run_server", fake_run_server) + + main.cli(["--transport", "stdio", "--log-level", "ERROR"]) + + assert recorded["transport"] == "stdio" + assert recorded["log_level"] == "ERROR" diff --git a/tests/test_mcp_server_version.py b/tests/test_mcp_server_version.py index 925538e..adc1507 100644 --- a/tests/test_mcp_server_version.py +++ b/tests/test_mcp_server_version.py @@ -1,7 +1,14 @@ +import os import pytest from main import mcp +requires_quantconnect_api_tests = pytest.mark.skipif( + os.getenv("RUN_QUANTCONNECT_API_TESTS") != "1", + reason="Requires access to QuantConnect/Docker Hub APIs. " + "Set RUN_QUANTCONNECT_API_TESTS=1 to run integration tests.", +) + class TestMCPServerVersion: @@ -14,8 +21,9 @@ async def test_read_verion(self): await self._ensure_response_has_two_periods('read_mcp_server_version') @pytest.mark.asyncio + @requires_quantconnect_api_tests async def test_read_latest_verion(self): await self._ensure_response_has_two_periods( 'read_latest_mcp_server_version' ) - \ No newline at end of file + diff --git a/tests/test_object_store.py b/tests/test_object_store.py index 192ce7a..4190c61 100644 --- a/tests/test_object_store.py +++ b/tests/test_object_store.py @@ -17,9 +17,7 @@ ObjectStoreBinaryFile, GetObjectStorePropertiesRequest, GetObjectStoreJobIdRequest, - GetObjectStoreURLRequest, ListObjectStoreRequest, - DeleteObjectStoreRequest, GetObjectStorePropertiesResponse, GetObjectStoreResponse, ListObjectStoreResponse, @@ -29,6 +27,13 @@ # Load the organization Id from the environment variables. ORGANIZATION_ID = os.getenv('QUANTCONNECT_ORGANIZATION_ID') +if os.getenv("RUN_QUANTCONNECT_API_TESTS") != "1": + pytest.skip( + "QuantConnect Object Store integration tests disabled. " + "Set RUN_QUANTCONNECT_API_TESTS=1 to enable.", + allow_module_level=True, + ) + # Static helpers for common operations: class ObjectStore: diff --git a/tests/test_organization_workspace.py b/tests/test_organization_workspace.py index e77a44e..df28b3c 100644 --- a/tests/test_organization_workspace.py +++ b/tests/test_organization_workspace.py @@ -1,39 +1,67 @@ +import json +from pathlib import Path + import pytest -import time -import docker from organization_workspace import OrganizationWorkspace -from api_connection import USER_ID, API_TOKEN - -def test_organization_workspace_mount(): - # Ensure the MOUNT_SOURCE environment variable is set. - assert OrganizationWorkspace.MOUNT_SOURCE, 'MOUNT_SOURCE env var is not set.' - # Create a Docker client. - client = docker.from_env() - # Start MCP Server inside a container. - container = client.containers.run( - image='quantconnect/mcp-server', - environment={ - 'QUANTCONNECT_USER_ID': USER_ID, - 'QUANTCONNECT_API_TOKEN': API_TOKEN - }, - platform='linux/amd64', - volumes={ - OrganizationWorkspace.MOUNT_SOURCE: { - 'bind': OrganizationWorkspace.MOUNT_DESTINATION, 'mode': 'ro'} - }, - detach=True, # Run in background - auto_remove=True # Equivalent to --rm +from settings import ServerSettings + + +def _reset_workspace_state() -> None: + OrganizationWorkspace.available = False + OrganizationWorkspace.project_id_by_path = {} + OrganizationWorkspace.mount_source = None + OrganizationWorkspace.mount_destination = None + OrganizationWorkspace.MOUNT_SOURCE = None + OrganizationWorkspace.MOUNT_DESTINATION = None + OrganizationWorkspace._legacy_warning_emitted = False + + +@pytest.fixture(autouse=True) +def reset_workspace() -> None: + _reset_workspace_state() + yield + _reset_workspace_state() + + +def test_configure_sets_legacy_attributes(tmp_path: Path) -> None: + mount_source = tmp_path / "source" + mount_dest = tmp_path / "dest" + mount_source.mkdir() + mount_dest.mkdir() + settings = ServerSettings( + mount_source_path=str(mount_source), + mount_destination_path=str(mount_dest), ) - # Wait for the container to start running. - time.sleep(5) - # Check if the expected mount exists. - assert any( - mount['Type'] == 'bind' and - mount['Source'] == OrganizationWorkspace.MOUNT_SOURCE and - mount['Destination'] == OrganizationWorkspace.MOUNT_DESTINATION and - mount['Mode'] == 'ro' - for mount in container.attrs['Mounts'] + + with pytest.warns(DeprecationWarning): + OrganizationWorkspace.configure(settings) + + assert OrganizationWorkspace.mount_source == mount_source.resolve() + assert OrganizationWorkspace.mount_destination == mount_dest.resolve() + assert OrganizationWorkspace.MOUNT_SOURCE == str(mount_source.resolve()) + assert OrganizationWorkspace.MOUNT_DESTINATION == str(mount_dest.resolve()) + + +def test_load_populates_project_ids(tmp_path: Path) -> None: + mount_source = tmp_path / "source" + mount_dest = tmp_path / "dest" + project_dir = mount_dest / "ProjectA" + ignored_dir = mount_dest / "data" + mount_source.mkdir() + mount_dest.mkdir() + project_dir.mkdir() + ignored_dir.mkdir() + (project_dir / "config.json").write_text(json.dumps({"cloud-id": "123"})) + (ignored_dir / "config.json").write_text(json.dumps({"cloud-id": "ignored"})) + settings = ServerSettings( + mount_source_path=str(mount_source), + mount_destination_path=str(mount_dest), ) - # Stop the container. - container.stop() + + with pytest.warns(DeprecationWarning): + OrganizationWorkspace.load(settings) + + assert OrganizationWorkspace.available is True + expected_path = str(project_dir.resolve()) + assert OrganizationWorkspace.project_id_by_path == {expected_path: "123"} diff --git a/tests/test_project.py b/tests/test_project.py index 70ba5cf..7226ae1 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -10,9 +10,6 @@ ) from models import ( CreateProjectRequest, - ReadProjectRequest, - UpdateProjectRequest, - DeleteProjectRequest, ProjectListResponse, RestResponse, ) diff --git a/tests/test_project_collaboration.py b/tests/test_project_collaboration.py index b1f7416..1b79a6e 100644 --- a/tests/test_project_collaboration.py +++ b/tests/test_project_collaboration.py @@ -11,7 +11,6 @@ ) from models import ( CreateCollaboratorRequest, - ReadCollaboratorsRequest, UpdateCollaboratorRequest, DeleteCollaboratorRequest, CreateCollaboratorResponse, diff --git a/tests/test_project_nodes.py b/tests/test_project_nodes.py index 7f0d874..7d8e266 100644 --- a/tests/test_project_nodes.py +++ b/tests/test_project_nodes.py @@ -9,7 +9,6 @@ ) from models import ( ReadProjectNodesRequest, - UpdateProjectNodesRequest, ProjectNodesResponse ) diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..0a92b1e --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,60 @@ +import pytest +from settings import ( + DEFAULT_TRANSPORT_HOST, + DEFAULT_TRANSPORT_PORTS, + Transport, + clear_settings_cache, + get_settings, + resolve_settings, +) + + +def test_transport_kwargs_default_log_level(): + settings = resolve_settings(env={"MCP_TRANSPORT": "stdio", "MCP_LOG_LEVEL": "DEBUG"}) + assert settings.transport is Transport.STDIO + assert settings.transport_kwargs() == {"log_level": "DEBUG"} + + +def test_transport_kwargs_for_network_transport(): + settings = resolve_settings(env={"MCP_TRANSPORT": "auto"}) + kwargs = settings.transport_kwargs("http") + assert kwargs["host"] == DEFAULT_TRANSPORT_HOST + assert kwargs["port"] == DEFAULT_TRANSPORT_PORTS[Transport.HTTP.value] + + +def test_ensure_credentials_raises_when_missing(): + settings = resolve_settings(env={}) + with pytest.raises(RuntimeError): + settings.ensure_credentials() + + +def test_invalid_transport_raises_runtime(): + with pytest.raises(RuntimeError): + resolve_settings(env={"MCP_TRANSPORT": "invalid"}) + + +def test_mount_source_path_resolution_handles_errors(monkeypatch): + class DummyPath: + def __init__(self): + self._called = False + + def expanduser(self): + return self + + def resolve(self): + raise OSError("boom") + + monkeypatch.setattr("settings.Path", lambda *_args, **_kwargs: DummyPath()) + settings = resolve_settings(env={"MOUNT_SOURCE_PATH": "/bad/path"}) + with pytest.raises(RuntimeError): + _ = settings.mount_source + + +def test_clear_settings_cache(): + clear_settings_cache() + first = get_settings() + second = get_settings() + assert first is second + clear_settings_cache() + third = get_settings() + assert third is not first diff --git a/tests/utils.py b/tests/utils.py index c2fa4e8..c4583cb 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,3 +1,4 @@ +import os import pytest from types import UnionType import jsonschema @@ -5,6 +6,16 @@ from pydantic import ValidationError, TypeAdapter from datetime import datetime +_RUN_QUANTCONNECT_API_TESTS = os.getenv("RUN_QUANTCONNECT_API_TESTS") == "1" + + +def _require_quantconnect_api_tests() -> None: + if not _RUN_QUANTCONNECT_API_TESTS: + pytest.skip( + "QuantConnect integration tests disabled. " + "Set RUN_QUANTCONNECT_API_TESTS=1 to enable network-backed tests." + ) + async def validate_response( mcp, tool_name, structured_response, output_class): # Check if the response schema is valid JSON. @@ -21,6 +32,7 @@ async def validate_response( async def validate_models( mcp, tool_name, input_args={}, output_class=None, success_expected=True): + _require_quantconnect_api_tests() # Call the tool with the arguments. If the input args are invalid, # it raises an error. unstructured_response, structured_response = await mcp.call_tool(