Initial commit: Hybrid MCP Light setup for Schwab
Some checks failed
Build and Push Docker Image / build (push) Failing after 48s
Some checks failed
Build and Push Docker Image / build (push) Failing after 48s
This commit is contained in:
16
.env.example
Normal file
16
.env.example
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Schwab API Credentials (required)
|
||||||
|
# Get these from https://developer.schwab.com after app approval
|
||||||
|
SCHWAB_API_KEY=your_app_key
|
||||||
|
SCHWAB_APP_SECRET=your_app_secret
|
||||||
|
SCHWAB_CALLBACK_URL=https://127.0.0.1:8182
|
||||||
|
SCHWAB_TOKEN_PATH=/data/token.json
|
||||||
|
|
||||||
|
# Trading Permissions (optional)
|
||||||
|
# ALLOW_TRADING must be "true" to enable any write operations
|
||||||
|
# TRADING_ACCOUNTS: comma-separated account hashes, or "ALL"
|
||||||
|
# If ALLOW_TRADING=true but TRADING_ACCOUNTS is empty, no accounts can trade (safe default)
|
||||||
|
ALLOW_TRADING=false
|
||||||
|
TRADING_ACCOUNTS=
|
||||||
|
|
||||||
|
# Server Configuration (optional)
|
||||||
|
# PORT=8000
|
||||||
32
.gitea/workflows/build.yaml
Normal file
32
.gitea/workflows/build.yaml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
name: Build and Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: gitea.ext.ben.io
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.CR_PAT }}
|
||||||
|
|
||||||
|
- name: Build and Push
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: gitea.ext.ben.io/${{ gitea.repository }}:latest
|
||||||
|
secrets: |
|
||||||
|
"ssh_key=${{ secrets.SSH_KEY }}"
|
||||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
.venv/
|
||||||
|
.env
|
||||||
|
config.json
|
||||||
|
cookies.json
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
1
BLUEPRINT.md
Symbolic link
1
BLUEPRINT.md
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../BLUEPRINT.md
|
||||||
46
Dockerfile
Normal file
46
Dockerfile
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder
|
||||||
|
|
||||||
|
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Enable SSH for git dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
git \
|
||||||
|
ssh-client \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Add gitea.ext.ben.io to known hosts
|
||||||
|
RUN mkdir -p -m 0700 ~/.ssh && ssh-keyscan git.local.ben.io >> ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
|
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||||
|
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||||
|
--mount=type=secret,id=ssh_key,target=/root/.ssh/id_rsa \
|
||||||
|
uv sync --frozen --no-install-project --no-dev
|
||||||
|
|
||||||
|
# Copy the rest of the application
|
||||||
|
COPY . /app
|
||||||
|
|
||||||
|
# Install the project
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
|
--mount=type=secret,id=ssh_key,target=/root/.ssh/id_rsa \
|
||||||
|
uv sync --frozen --no-dev
|
||||||
|
|
||||||
|
FROM python:3.12-slim-bookworm
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy the environment from the builder
|
||||||
|
COPY --from=builder /app /app
|
||||||
|
|
||||||
|
# Set up environment variables
|
||||||
|
ENV PATH="/app/.venv/bin:$PATH"
|
||||||
|
ENV PORT=8000
|
||||||
|
|
||||||
|
# Expose the port
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Run the server
|
||||||
|
CMD ["python", "server.py"]
|
||||||
30
compose.yaml
Normal file
30
compose.yaml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
include:
|
||||||
|
- ../deploy/base.yaml
|
||||||
|
|
||||||
|
services:
|
||||||
|
schwab-mcp:
|
||||||
|
<<: *mcp-service
|
||||||
|
<<: *mcp-healthcheck
|
||||||
|
image: gitea.ext.ben.io/b3nw/schwab-mcp-custom:latest
|
||||||
|
container_name: schwab-mcp
|
||||||
|
environment:
|
||||||
|
- SCHWAB_PLAYWRIGHT_URL=ws://schwab-browser:3000/playwright/chromium
|
||||||
|
- PORT=8000
|
||||||
|
volumes:
|
||||||
|
- ./cookies.json:/app/cookies.json
|
||||||
|
- ./config.json:/app/config.json
|
||||||
|
ports:
|
||||||
|
- "${PORT:-8160}:8000"
|
||||||
|
depends_on:
|
||||||
|
schwab-browser:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
schwab-browser:
|
||||||
|
image: ghcr.io/browserless/chromium:latest
|
||||||
|
container_name: schwab-browser
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- TIMEOUT=300000
|
||||||
|
- MAX_CONCURRENT_SESSIONS=2
|
||||||
|
- PREBOOT_CHROME=true
|
||||||
|
shm_size: "1gb"
|
||||||
23
pyproject.toml
Normal file
23
pyproject.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
[project]
|
||||||
|
name = "schwab-mcp-custom"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Hybrid MCP Light server for Schwab scraper"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"mcp>=1.2.0",
|
||||||
|
"fastmcp>=0.4.1",
|
||||||
|
"starlette>=0.41.0",
|
||||||
|
"uvicorn>=0.32.0",
|
||||||
|
"schwab-scraper @ git+ssh://gitea@git.local.ben.io/b3nw/schwab-scraper.git",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.metadata]
|
||||||
|
allow-direct-references = true
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = []
|
||||||
0
schwab_mcp_custom/__init__.py
Normal file
0
schwab_mcp_custom/__init__.py
Normal file
131
server.py
Normal file
131
server.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Optional, Any
|
||||||
|
|
||||||
|
from fastmcp import FastMCP
|
||||||
|
from starlette.applications import Starlette
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
from starlette.routing import Route, Mount
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
# Import the unified API from the schwab_scraper dependency
|
||||||
|
import schwab_scraper.unified_api as api
|
||||||
|
|
||||||
|
# Initialize FastMCP
|
||||||
|
mcp = FastMCP("SchwabScraper")
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_session_status(debug: bool = False) -> str:
|
||||||
|
"""Get the current session status of the Schwab scraper.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
debug: Enable debug logging
|
||||||
|
"""
|
||||||
|
result = await api.get_session_status(debug=debug)
|
||||||
|
return json.dumps(result)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def list_accounts(debug: bool = False) -> str:
|
||||||
|
"""List all Schwab accounts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
debug: Enable debug logging
|
||||||
|
"""
|
||||||
|
result = await api.list_accounts(debug=debug)
|
||||||
|
return json.dumps(result)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_account_overview(account: Optional[str] = None, debug: bool = False) -> str:
|
||||||
|
"""Get the overview for a specific account.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
account: Account summary or ID (optional)
|
||||||
|
debug: Enable debug logging
|
||||||
|
"""
|
||||||
|
result = await api.get_account_overview(account=account, debug=debug)
|
||||||
|
return json.dumps(result)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_positions(account: Optional[str] = None, include_non_equity: bool = False, debug: bool = False) -> str:
|
||||||
|
"""Get positions for a specific account.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
account: Account summary or ID (optional)
|
||||||
|
include_non_equity: Whether to include non-equity positions
|
||||||
|
debug: Enable debug logging
|
||||||
|
"""
|
||||||
|
result = await api.get_positions(account=account, include_non_equity=include_non_equity, debug=debug)
|
||||||
|
return json.dumps(result)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_transactions(
|
||||||
|
account: Optional[str] = None,
|
||||||
|
start_date: Optional[str] = None,
|
||||||
|
end_date: Optional[str] = None,
|
||||||
|
time_period: Optional[str] = None,
|
||||||
|
debug: bool = False
|
||||||
|
) -> str:
|
||||||
|
"""Get transaction history.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
account: Account ID (optional)
|
||||||
|
start_date: Start date for transactions (optional)
|
||||||
|
end_date: End date for transactions (optional)
|
||||||
|
time_period: Time period (e.g., '1D', '1M') (optional)
|
||||||
|
debug: Enable debug logging
|
||||||
|
"""
|
||||||
|
result = await api.get_transaction_history(
|
||||||
|
account=account,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
time_period=time_period,
|
||||||
|
debug=debug
|
||||||
|
)
|
||||||
|
return json.dumps(result)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_morningstar_data(ticker: str, debug: bool = False) -> str:
|
||||||
|
"""Get Morningstar data for a ticker.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ticker: Stock ticker symbol
|
||||||
|
debug: Enable debug logging
|
||||||
|
"""
|
||||||
|
result = await api.get_morningstar_data(ticker, debug=debug)
|
||||||
|
return json.dumps(result)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def api_call(endpoint: str, method: str = "GET", params: str = "{}") -> str:
|
||||||
|
"""Executes a raw API call to the Schwab service (Dummy implementation).
|
||||||
|
|
||||||
|
Refer to the 'api-reference' resource for available endpoints and parameters.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
endpoint: The API path
|
||||||
|
method: HTTP method (GET, POST, etc.)
|
||||||
|
params: JSON string of parameters/body
|
||||||
|
"""
|
||||||
|
return json.dumps({"status": "not_implemented", "message": "API pass-through not supported for scraper"})
|
||||||
|
|
||||||
|
@mcp.resource("service://api-reference")
|
||||||
|
def get_api_docs() -> str:
|
||||||
|
"""Returns the API documentation for using the 'api_call' tool."""
|
||||||
|
return "Schwab Scraper MCP Server - Unified API Documentation\n\nThis server provides tools to interact with Schwab accounts via scraping. The 'api_call' tool is a placeholder."
|
||||||
|
|
||||||
|
async def health(request):
|
||||||
|
"""Health check endpoint."""
|
||||||
|
return JSONResponse({"status": "ok"})
|
||||||
|
|
||||||
|
# Create the Starlette application
|
||||||
|
mcp_app = mcp.http_app()
|
||||||
|
app = Starlette(
|
||||||
|
routes=[
|
||||||
|
Route("/health", health),
|
||||||
|
Mount("/", app=mcp_app)
|
||||||
|
],
|
||||||
|
lifespan=mcp_app.lifespan
|
||||||
|
)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
port = int(os.getenv("PORT", 8160))
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=port)
|
||||||
Reference in New Issue
Block a user