Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| -------- | ------- |
Expand Down
7 changes: 6 additions & 1 deletion create_tool_markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
20 changes: 19 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
2 changes: 1 addition & 1 deletion src/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
126 changes: 84 additions & 42 deletions src/api_connection.py
Original file line number Diff line number Diff line change
@@ -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()
17 changes: 11 additions & 6 deletions src/code_source_id.py
Original file line number Diff line number Diff line change
@@ -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
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})
Loading