Compare commits

...

8 Commits

Author SHA1 Message Date
cc1226defe fix(ci): use git -C instead of cd to avoid breaking working directory
All checks were successful
Build and Push Docker Image / build (push) Successful in 42s
The previous cd vendor/schwab-scraper caused the subsequent echo to write
mcp-server-commit.txt into the wrong path, which made the CI build fail.
2026-05-04 14:36:52 +00:00
4982b7d09f feat: log schwab-scraper and mcp-server commit SHAs at container startup
Some checks failed
Build and Push Docker Image / build (push) Failing after 39s
Bake commit SHAs into the Docker image via CI and log them on server
startup so it's easy to verify which version of schwab-scraper is running.
2026-05-04 14:31:01 +00:00
8c196b7f65 fix(server): repair login tool and harden upload_cookies
All checks were successful
Build and Push Docker Image / build (push) Successful in 38s
- login tool was calling api.login() which did not exist in unified_api,
  causing AttributeError on every invocation. Now calls login_to_schwab
  directly with proper credential fallback to config.json.
- upload_cookies hardcoded 'cookies.json' instead of get_cookies_path(),
  and did not handle wrapped export formats ({cookies: [...]}). Both fixed.
- Result envelopes now match the standard {success, data, error, error_type,
  retryable} shape used by other tools.
2026-04-28 04:15:18 +00:00
9f799ee264 feat(logging): trace credential source and config path in login tool
All checks were successful
Build and Push Docker Image / build (push) Successful in 39s
Add diagnostic logging to the MCP login tool handler:
- Log whether username/password were provided explicitly
- If falling back to config, log the resolved config path and whether it exists
- This complements upstream scraper v0.6.18 credential diagnostics

Bumps version to 0.2.1.
2026-04-28 02:52:09 +00:00
d28b9d32f6 test(option-a): point SCHWAB_PLAYWRIGHT_URL to CLI's browserless endpoint
All checks were successful
Build and Push Docker Image / build (push) Successful in 38s
Temporarily switch from the local schwab-browser sidecar to the
browserless endpoint used by the working CLI (browser.local.ben.io).
This tests whether /assert 403 is caused by browser environment drift.
2026-04-28 02:39:20 +00:00
f51e61b8d7 fix(logging): configure stderr logging + tee capture, add debug confirmation
All checks were successful
Build and Push Docker Image / build (push) Successful in 37s
- Set up logging.basicConfig() at module load so scraper logs reach stderr
  (visible in docker logs instead of silently dropped)
- Replace StringIO-only capture with TeeHandler that writes to BOTH stderr
  and the StringIO buffer, so logs remain visible in docker while also
  being returned in tool responses
- Add explicit 'LOGIN TOOL CALLED' and 'DEBUG MODE ENABLED' log lines
  at the start of the login tool so users can verify logging is active
2026-04-28 02:16:31 +00:00
1999392df7 fix(mcp): capture scraper logs and return them in tool responses
All checks were successful
Build and Push Docker Image / build (push) Successful in 38s
Scraper debug output goes to stderr which is invisible in MCP stdio mode.
Add capture_logs context manager that attaches a StringIO handler to the
schwab_scraper logger during tool execution, then includes captured logs
in the response envelope when debug=True or on failure.

Applied to login() and refresh_session() which are the critical paths
for authentication diagnostics.
2026-04-28 02:04:58 +00:00
0c23b0e261 fix(ci): use CRT_READ_ONLY for cross-repo clone
All checks were successful
Build and Push Docker Image / build (push) Successful in 41s
actions/checkout@v3's Basic auth header pattern fails with 403 when
accessing a different private repository. Switch to a plain git clone
with the CRT_READ_ONLY token embedded in the HTTPS URL.
2026-04-28 01:40:42 +00:00
5 changed files with 229 additions and 15 deletions

View File

@@ -15,12 +15,16 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Checkout schwab-scraper - name: Clone schwab-scraper
uses: actions/checkout@v3 env:
with: CLONE_TOKEN: ${{ secrets.CRT_READ_ONLY }}
repository: b3nw/schwab-scraper run: |
path: vendor/schwab-scraper mkdir -p vendor
token: ${{ secrets.CR_PAT }} git clone --depth=1 --branch main \
"https://x-access-token:${CLONE_TOKEN}@gitea.ext.ben.io/b3nw/schwab-scraper.git" \
vendor/schwab-scraper
git -C vendor/schwab-scraper rev-parse HEAD > vendor/schwab-scraper-commit.txt
echo "${{ gitea.sha }}" > vendor/mcp-server-commit.txt
- name: Login to Gitea Container Registry - name: Login to Gitea Container Registry
uses: docker/login-action@v2 uses: docker/login-action@v2

View File

@@ -17,6 +17,9 @@ RUN uv venv && \
uv pip install --upgrade playwright && \ uv pip install --upgrade playwright && \
rm -rf /tmp/schwab-scraper rm -rf /tmp/schwab-scraper
COPY vendor/schwab-scraper-commit.txt /app/schwab-scraper-commit.txt
COPY vendor/mcp-server-commit.txt /app/mcp-server-commit.txt
COPY . . COPY . .
FROM python:3.12-slim-bookworm FROM python:3.12-slim-bookworm

View File

@@ -21,7 +21,7 @@ services:
memory: 128M memory: 128M
cpus: '0.1' cpus: '0.1'
environment: environment:
- SCHWAB_PLAYWRIGHT_URL=ws://schwab-browser:3000/playwright/chromium - SCHWAB_PLAYWRIGHT_URL=ws://browser.local.ben.io:3000/playwright/chromium?timeout=300000
- PORT=8000 - PORT=8000
volumes: volumes:
- ./cookies.json:/app/cookies.json - ./cookies.json:/app/cookies.json

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "schwab-mcp-custom" name = "schwab-mcp-custom"
version = "0.2.0" version = "0.2.1"
description = "MCP server wrapping schwab-scraper" description = "MCP server wrapping schwab-scraper"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"

215
server.py
View File

@@ -1,7 +1,10 @@
import io
import json import json
import logging import logging
import os import os
import sys
import time import time
from contextlib import contextmanager
from typing import Optional, Any, Tuple from typing import Optional, Any, Tuple
from fastmcp import FastMCP from fastmcp import FastMCP
@@ -12,6 +15,124 @@ import uvicorn
import schwab_scraper.unified_api as api import schwab_scraper.unified_api as api
# ---------------------------------------------------------------------------
# Configure logging so it actually reaches stderr (visible in docker logs).
# The scraper and MCP libraries log extensively but don't set up handlers
# when imported as a module, so messages are silently dropped.
# ---------------------------------------------------------------------------
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
stream=sys.stderr,
)
# Ensure the scraper logger propagates to our root handler
_scraper_logger = logging.getLogger("schwab_scraper")
_scraper_logger.setLevel(logging.DEBUG if os.getenv("SCHWAB_DEBUG", "").lower() in ("1", "true") else logging.INFO)
_scraper_logger.propagate = True
_startup_logger = logging.getLogger("schwab_mcp_custom")
def _read_commit_file(path: str) -> str | None:
try:
with open(path) as f:
return f.read().strip() or None
except FileNotFoundError:
return None
_scraper_commit = _read_commit_file(
os.path.join(os.path.dirname(__file__), "schwab-scraper-commit.txt")
)
_mcp_commit = _read_commit_file(
os.path.join(os.path.dirname(__file__), "mcp-server-commit.txt")
)
if _scraper_commit:
_startup_logger.info("schwab-scraper commit: %s", _scraper_commit)
else:
_startup_logger.info("schwab-scraper commit: (not available)")
if _mcp_commit:
_startup_logger.info("mcp-server commit: %s", _mcp_commit)
else:
_startup_logger.info("mcp-server commit: (not available)")
try:
from importlib.metadata import version as _pkg_version
_startup_logger.info("schwab-scraper package version: %s", _pkg_version("schwab-scraper"))
except Exception:
_startup_logger.info("schwab-scraper package version: (unknown)")
# ---------------------------------------------------------------------------
# Log capture helper — captures scraper logs to a string buffer AND tees
# them to stderr so they remain visible in docker logs.
# ---------------------------------------------------------------------------
class _TeeHandler(logging.StreamHandler):
"""Handler that copies every record to a secondary (StringIO) buffer."""
def __init__(self, stream, extra_buf: io.StringIO, level=logging.NOTSET):
super().__init__(stream)
self.extra_buf = extra_buf
self.tee_level = level
def emit(self, record):
super().emit(record)
if record.levelno >= self.tee_level:
try:
msg = self.format(record)
self.extra_buf.write(msg + "\n")
self.extra_buf.flush()
except Exception:
pass
@contextmanager
def capture_logs(logger_name: str = "schwab_scraper", level: int = logging.DEBUG):
"""
Context manager that captures log output to a string buffer
while still writing to stderr (docker-visible).
Yields the buffer so callers can read captured logs after the block.
"""
logger = logging.getLogger(logger_name)
old_level = logger.level
if old_level > level:
logger.setLevel(level)
buf = io.StringIO()
handler = _TeeHandler(sys.stderr, buf, level=level)
handler.setLevel(level)
handler.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s"))
logger.addHandler(handler)
# Also tee the root logger in case scraper logs through sub-loggers
root_old_level = logging.getLogger().level
if root_old_level > level:
logging.getLogger().setLevel(level)
try:
yield buf
finally:
logger.removeHandler(handler)
if old_level != logger.level:
logger.setLevel(old_level)
if root_old_level != logging.getLogger().level:
logging.getLogger().setLevel(root_old_level)
def _enrich_with_logs(result: dict, log_buffer: io.StringIO, debug: bool) -> dict:
"""Attach captured logs to a result dict when debug=True or on error."""
logs = log_buffer.getvalue()
if logs and (debug or not result.get("success", False)):
result["logs"] = logs
return result
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Monkey-patch mcp.shared.session.RequestResponder to work around a # Monkey-patch mcp.shared.session.RequestResponder to work around a
# cancellation race in mcp==1.27.0 (github.com/modelcontextprotocol/ # cancellation race in mcp==1.27.0 (github.com/modelcontextprotocol/
@@ -177,9 +298,71 @@ async def login(
"data": None, "data": None,
}) })
result = await api.login(username=username, password=password, debug=debug) mcp_logger = logging.getLogger("schwab_mcp_custom")
mcp_logger.info("=== LOGIN TOOL CALLED ===")
mcp_logger.info(f"debug={debug}, username_provided={bool(username)}, password_provided={bool(password)}")
# Diagnostic: if credentials not provided, show what config path would be used
if not username or not password:
from schwab_scraper.core.config import get_config_path
config_path = get_config_path()
config_exists = os.path.exists(config_path)
mcp_logger.info(f"Config fallback: path={config_path}, exists={config_exists}")
with capture_logs(level=logging.DEBUG if debug else logging.INFO) as log_buf:
mcp_logger.info("capture_logs context entered")
if debug:
mcp_logger.info("DEBUG MODE ENABLED — verbose logging active")
# api.login does not exist in unified_api; call the underlying scraper directly
from schwab_scraper.browser.auth import login_to_schwab
from schwab_scraper.core.config import get_schwab_credentials, load_config
if not username or not password:
config = load_config()
username, password = get_schwab_credentials(config)
if not username or not password:
result = {
"success": False,
"error": "Username and password are required (or set in config.json)",
"error_type": "AUTHENTICATION",
"retryable": False,
"data": None,
}
else:
try:
cookies = await login_to_schwab(username, password)
if cookies:
result = {
"success": True,
"data": {"cookies_count": len(cookies)},
"error": None,
"error_type": None,
"retryable": False,
}
else:
result = {
"success": False,
"error": "Login failed — no cookies returned. Check credentials or 2FA status.",
"error_type": "AUTHENTICATION",
"retryable": True,
"data": None,
}
except Exception as exc:
result = {
"success": False,
"error": str(exc),
"error_type": "UNKNOWN",
"retryable": True,
"data": None,
}
success = result.get("success", False) success = result.get("success", False)
login_manager.record_attempt(success) login_manager.record_attempt(success)
mcp_logger.info(f"login completed — success={success}")
result = _enrich_with_logs(result, log_buf, debug)
mcp_logger.info("capture_logs context exited, returning result")
return serialize(result) return serialize(result)
@@ -190,7 +373,9 @@ async def refresh_session(debug: bool = False) -> str:
Args: Args:
debug: Enable debug logging debug: Enable debug logging
""" """
with capture_logs(level=logging.DEBUG if debug else logging.INFO) as log_buf:
result = await api.refresh_session(debug=debug) result = await api.refresh_session(debug=debug)
result = _enrich_with_logs(result, log_buf, debug)
return serialize(result) return serialize(result)
@@ -284,9 +469,31 @@ async def upload_cookies(cookies_json: str) -> str:
""" """
try: try:
cookies = json.loads(cookies_json) cookies = json.loads(cookies_json)
with open("cookies.json", "w") as f:
json.dump(cookies, f) # Some browser extensions wrap cookies in an object (e.g. {"cookies": [...]})
return json.dumps({"status": "success", "message": "cookies.json updated successfully"}) if isinstance(cookies, dict):
if "cookies" in cookies:
cookies = cookies["cookies"]
else:
return json.dumps({
"status": "error",
"message": "Expected a list of cookies or an object with a 'cookies' key",
})
if not isinstance(cookies, list):
return json.dumps({
"status": "error",
"message": f"Expected a list of cookies, got {type(cookies).__name__}",
})
from schwab_scraper.core.config import get_cookies_path
cookies_path = get_cookies_path()
with open(cookies_path, "w") as f:
json.dump(cookies, f, indent=2)
return json.dumps({
"status": "success",
"message": f"{cookies_path} updated with {len(cookies)} cookies",
})
except Exception as e: except Exception as e:
return json.dumps({"status": "error", "message": str(e)}) return json.dumps({"status": "error", "message": str(e)})