MCP server¶
Expose precomputed TiDE forecasts and market data to MCP clients (Claude Desktop, Cursor, SuperGrok, etc.). Remote clients only access data through MCP tools — there is no client-side database file, host path, or direct DuckDB connection for the connector.
On the host, the server reads the same search store as the dashboard (operators: data_store.md). That is an implementation detail; do not surface host paths or engine names in client-facing product copy.
NOT FINANCIAL OR INVESTMENT ADVICE. USE AT YOUR OWN RISK.
Client access boundary¶
| Clients may | Clients must not assume |
|---|---|
Call MCP tools (get_chart_data, get_forecast, get_close_price, …) |
A local DuckDB/file path on their machine |
Optional get_db_schema + run_select via MCP |
Opening the host search DB outside this server |
Use get_server_info.access as the source of truth |
SQL against “the DuckDB” without run_select |
get_server_info includes an access string and does not return db_path / data_dir / db_file to remote clients.
Behavior¶
| Mode | How | Tools |
|---|---|---|
| Read-only (default) | python -m canswim mcp |
health, list, forecast/scan/price queries, get_db_schema, run_select (SELECT / WITH…SELECT only) |
| Runs allowed | MCP_ALLOW_RUNS=1 (or CANSWIM_ALLOW_RUNS=1) |
also gather_tickers, forecast_tickers, refresh_tickers, refresh_job_start |
CLI --tickers and the dashboard Run tab do not need MCP_ALLOW_RUNS. Write tools share the same backend as CLI/GUI: run_triggers.md.
Server version (client rediscovery)¶
MCP initialize and get_server_info expose package version from setup.cfg (via canswim.version). Any MCP tool add/rename/remove or behavior change must bump that version in the same PR so remote clients refresh tool discovery instead of caching a stale list.
Prerequisites¶
- Local parquet under
data/data-3rd-party/and forecasts underdata/forecast/(or yourdata_dir). - A search DuckDB built at least once:
python -m canswim dashboard --same_data True
# first time: omit --same_data (or False) to (re)build from parquet
# or: MCP_INIT_DB=1 when starting MCP
Paths: .env / env data_dir, db_file — same as the dashboard. See data_store.md.
Run¶
python -m canswim mcp
# equivalents: canswim-mcp / python -m canswim.mcp
MCP_ALLOW_RUNS=1 python -m canswim mcp
Streamable HTTP (production / mcp-gateway)¶
Default transport is stdio (desktop clients). For a reverse-proxy gateway (Tailscale Funnel + Caddy apikey), run Streamable HTTP bound to localhost:
python -m canswim mcp --http --host 127.0.0.1 --port 3472
# equivalent:
python -m canswim mcp --transport streamable-http --host 127.0.0.1 --port 3472
| Flag / env | Meaning |
|---|---|
--http |
Shorthand for Streamable HTTP transport |
--transport stdio\|streamable-http\|http\|sse |
Explicit transport (http ≡ streamable-http) |
--host / CANSWIM_MCP_HOST / MCP_HOST |
Bind address (default 127.0.0.1) |
--port / CANSWIM_MCP_PORT / MCP_PORT |
Bind port (default 8000) |
CANSWIM_MCP_TRANSPORT / MCP_TRANSPORT |
Env override for transport |
FastMCP serves the MCP endpoint at /mcp on that host:port. Public clients use the gateway path with ?apikey= (never expose the bind port directly on the public internet).
Full production layout (user systemd, Tailscale-only Gradio UI, Funnel + Caddy apikey): deploy_service.md.
Example client config (stdio)¶
{
"mcpServers": {
"canswim": {
"command": "python",
"args": ["-m", "canswim", "mcp"],
"cwd": "/path/to/canswim",
"env": {
"data_dir": "data",
"db_file": "canswim_local.duckdb"
}
}
}
}
Add "MCP_ALLOW_RUNS": "1" under env only if the client should be allowed to gather/forecast.
Tools¶
Canonical registration: src/canswim/mcp/server.py. Update this table in the same PR as any tool add/rename/remove.
| Tool | Description | Runs gate |
|---|---|---|
health_check |
DB path / readiness | — |
get_server_info |
Version, read-only / runs_allowed, tool list | — |
list_tickers |
Symbols in search DB | — |
get_forecast |
Quantile forecast rows for a symbol | — |
get_reward_risk |
Reward/risk for a forecast (confidence 80/95/99) | — |
scan_forecasts |
Universe scan (≡ dashboard Scans) | — |
get_close_price |
Historical closes only (not a full chart) | — |
get_chart_data |
PRIMARY one-shot dashboard Charts payload: ~1–2y actual close, all in-window forecast overlays (backtests + live) with median + low/high band, reward/risk, plot_hints |
— |
plot_chart |
Alias of get_chart_data (same args/payload) — use if a connector omits get_chart_data |
— |
get_backtest_error |
Forecast vs actual error (mean abs log-error) | — |
get_db_schema |
Tables, columns, indexes, row counts + markdown (for agent SQL) | — |
run_select |
Single read-only SELECT or WITH…SELECT (≡ Advanced Queries) |
— |
resolve_forecast_start |
Preview week-aligned start (≡ CLI resolve_start) |
— |
gather_tickers |
Scoped gather (≡ gatherdata --tickers) |
MCP_ALLOW_RUNS=1 |
forecast_tickers |
Scoped forecast; blank start = monthly catch-up + live | MCP_ALLOW_RUNS=1 |
refresh_tickers |
Gather + catch-up forecast — async by default (returns job_id; ≡ dashboard refresh). wait=true = old blocking path |
MCP_ALLOW_RUNS=1 |
refresh_job_start |
Explicit async start (same as refresh_tickers with wait=false) |
MCP_ALLOW_RUNS=1 |
refresh_job_status |
Poll a job from refresh_tickers / refresh_job_start |
— |
Error shape (client-facing)¶
Tool results use a stable envelope:
| Field | Meaning |
|---|---|
ok |
true / false |
error |
Non-empty human string when ok is false — what failed and who acts (client vs operator) |
fail_reason |
Machine-readable code when known (branch without scraping free text) |
client_hint |
Short next-step guidance for agents (poll job, fix args, contact operator, …) |
data |
Optional structured payload (partial results, coverage, parse details) |
Common fail_reason values: invalid_input, db_not_ready, runs_disabled, job_unknown, job_busy, job_failed, model_not_loaded, remote_api. Failed async jobs also set data.fail_reason / data.client_hint on refresh_job_status when status=failed.
Do not claim success when ok is false or a job status is still queued/running. Prefer client_hint over inventing recovery steps.
One-shot chart (dashboard Charts equivalent)¶
get_chart_data(symbol) (alias plot_chart) is the only tool needed for the Charts tab view. Both names are registered; if a client says the tool is “unavailable”, reconnect the connector and call plot_chart if get_chart_data is missing from its tool list.
- Call
get_chart_dataorplot_chartwith the symbol (optionalconfidence80/95/99, default 80;history_yearsdefault 2). - Plot
data.actual.dates/data.actual.closeas a solid price line. - For each entry in
data.forecasts(monthly backtests + latest live): plotmediandashed and fill betweenlowandhigh. Do not drop to latest-only. - Optional table:
data.reward_risk.
Do not use raw get_close_price rows + latest-only get_forecast for a full chart. If get_chart_data / plot_chart are missing from the connector list, call get_forecast(symbol, as_chart=true) or get_close_price(symbol, as_chart=true) (same full payload). The server also accepts connector-prefixed names like canswim___get_chart_data. See get_server_info.chart_guidance. Restart canswim-mcp after deploy so clients rediscover tools (version bump).
Async refresh (default — SuperGrok / short tool timeouts)¶
Long refreshes take many minutes. refresh_tickers starts a background job and returns immediately (do not treat the start response as completion):
- Call
get_server_infoonce (noteversion+refresh_guidance). refresh_tickerswith the symbol list (≤ ~200 async max). Response includesjob_id,poll_after_seconds,client_hint. Oversized lists error (no silent truncate).- Sleep about
poll_after_seconds(typically 5–15s). refresh_job_statusuntilstatusissucceededorfailed(done=true).- Report
coverage. Only claim success for symbols in that job’sticker_list.
Optional: wait=true on refresh_tickers forces the old blocking path (max ~50; SuperGrok will disconnect — avoid).
Workers batch symbols (~20). Job state is under {data_dir}/mcp_jobs/. Only one refresh job at a time.
Idempotent multi-client work: if client B starts a refresh whose ticker list is a subset of an already queued/running job (same dry_run / include_covariates), the server coalesces — ok=true, same job_id, coalesced=true. Poll refresh_job_status; do not start a second job. If the active job does not cover the full request, response is ok=false / fail_reason=job_busy with active_job_id — poll until done, then start only remaining symbols.
The in-process weekend catch-up job uses this same registry (source=weekend, full DuckDB universe). Portfolio MCP refreshes during the weekend run join that job instead of duplicating work. Gather/forecast units are idempotent (skip remote when history is fresh; skip forecast when a complete partition already exists). A narrow fcntl.flock only serializes parquet write batches vs CLI weekend on the same host — not the API contract.
Do not claim portfolio-wide success after a tool timeout, a dry_run, a subset list, or while status is still queued/running.
Progress streaming (blocking long runs)¶
refresh_tickers, forecast_tickers, and gather_tickers stream live progress while they run (and while the tool call stays open):
| Channel | When the client sees it |
|---|---|
MCP notifications/progress |
Client includes a progressToken in the tool call request meta (standard MCP progress protocol). Values are 0–100 with total=100 and a human message (e.g. “Step 2/2: catch-up forecasts…”, origin/symbol stages). |
MCP log (info) |
Same stage messages, when the client supports logging notifications. |
Progress is the same pipeline as the dashboard Run-tab bar (run_triggers → progress_cb). Work runs off the MCP event loop so notifications can flush mid-run. Final tool result is still the usual {ok, data|error} payload when the job finishes.
Clients that omit progressToken get only the final result (no error). Async jobs use file status instead of mid-call progress notifications.
Host diagnostics: with MCP_PROGRESS_DEBUG=1 (default when unset), the MCP process
logs to the journal whether each long tool saw a progressToken and each
progress emit (MCP progress: … / MCP progress emit: …). Set
MCP_PROGRESS_DEBUG=0 to silence. Example:
journalctl --user -u canswim-mcp -f | rg 'MCP progress'
Custom SQL (read-only, still MCP-only)¶
Prefer purpose-built tools first (get_chart_data, get_forecast, …). For ad-hoc analytics:
- Call
get_db_schemafor logical tables/columns (no host path in the payload). - Call
run_selectwith oneSELECTorWITH … SELECTstatement through MCP. - Guards:
- Statement must start with
SELECTorWITH(and containSELECT). - DDL/DML keywords, multi-statement
;,PRAGMA,ATTACH,COPY, etc. are rejected. - Server executes read-only; the client never receives a DB file path.
- Results are wrapped with
LIMIT(default 5000). - Writes are never free-form SQL — only gated tools (
gather_tickers,forecast_tickers,refresh_tickers,refresh_job_start) whenMCP_ALLOW_RUNS=1.
Related docs¶
- cli.md — CLI recipes
- run_triggers.md — gather/forecast policy
- data_store.md — parquet vs DuckDB