Open Net Worth
Open Net Worth는 직접 운영하는 오픈소스 개인 AI 자산 비서로, 전체 재무를 하나의 원장에 기록하고 사용자가 제어하는 AI가 관리
6억 개가 넘는 데이터와 2900개가 넘는 회사의 펀더멘털과 13F를 Claude나 Cursor에 연결합니다
Connect Claude, Cursor, or any MCP client to 600M+ curated datapoints — fundamentals, 13F ownership, insider filings, transcripts, estimates, and technical signals across 2,900+ companies.
금융Python · DockerfileGitHub @cbecks1212온라인운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기MCP server for real-time equity research: real-time market events, 13F ownership, earnings transcripts, fundamentals, estimates & insider filings on 2,900+ companies for Claude and other AI agents.
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
A standalone MCP microservice that exposes the FlexReport
equity backend's live events, research-report artifacts, and database of
750M+ datapoints as on-demand tools for Claude (and any MCP client).
It supercharges AI agents with real-time market events and curated, golden-source
data spanning fundamentals, transcripts, filings, insider trades (Forms 3, 4, and 5),
ratios, macro data, IR decks pulled straight from each company's investor relations
site, and more.README 전문 보기 ↗A standalone MCP microservice that provides access to Flexreport Finance's live events, research-report artifacts, and database of 750M+ datapoints as on-demand tools for Claude (and any MCP client).
It supercharges AI agents with real-time market events and curated, golden-source data spanning fundamentals, transcripts, filings, insider trades (Forms 3, 4, and 5), ratios, macro data, IR decks pulled straight from each company's investor relations site, and more.
Add the connector to Claude Code
claude mcp add --transport http flexreport https://mcp.flexreportfinapi.com/mcp
Or add to Codex
codex mcp add flexreport --url https://mcp.flexreportfinapi.com/mcp
Then start Claude and just ask (e.g. "pull the biggest movers from flexreport").
On the first data call your MCP client runs an OAuth sign-in in your browser —
sign in or register when prompted; you never paste a token. Add --scope user to
make it available in every directory. See Auth for details.
Stream real-time events in the terminal. Ask Claude to watch the market and events arrive as they land: movers, earnings releases, 8-Ks, IR decks and fresh report plans. As each report plan saves, Claude renders the refreshed report.
"Look out for the biggest movers, earnings releases, transcript updates, investor decks, and 8K releases post market close, streaming these as they happen with Flexreport"
"For every thing that comes, can you check if the latest report plan is available, to pull a refreshed report to provide deeper context"
Open the research report. Every figure links back to the query that produced it, and every headline links to its source. Here's a report for AAPL:
"Get me the latest flexreport report for AAPL"
Open the full AAPL report (PDF, 13 pages)
1a. Streaming (events and real-time reports). Subscribe to events as they publish, including the report_plan topic, which fires each time a report is ready, then pull the rendered report: "Subscribe me to earnings releases, 8-Ks, IR publications and new report plans for NVDA, AVGO, AMD and MU. As each one lands, tell me what happened, check the intraday tape for an abnormal move, and pull the Flexreport Finance report once it's ready."
Tools: register_event_subscription → stream_event_subscription, or ad-hoc stream_events, then detect_intraday_outlier_jumps and get_latest_report.
By default, a live-streaming ask gets a dashboard (in Claude, an Artifact) rather than a chat transcript. The page runs the stream_events loop itself, carrying the cursor from read to read, so one prompt keeps the stream open. If the page can't reach the server, the agent runs the loop and writes each event into the dashboard. Ask for chat output explicitly if you prefer the terminal view shown in the demo above.
1b. Events from the past twelve hours. Analyze real-time SEC 8-K filings, while also pulling in investor relations decks and 5-minute bars to quickly identify stocks making meaningful moves: "What are the latest 8-K releases? Highlight the most significant ones, pull the accompanying investor relations releases via Flexreport Finance, chart 5-minute bars for any names making meaningful moves, and tell me how they align with each company's current state. I would like this in tabular format: 8-K summary, company trend, Flexreport Finance 8-K analysis S3 link, IR deck link."
Tools: situate (no symbols) → list_realtime_events, then get_aftermarket_data / detect_intraday_outlier_jumps and get_latest_report.
2a. The latest report for a specific company: "Pull the latest Flexreport Finance report on Snowflake and summarize what changed this quarter: guidance, margins, and anything management flagged on the call."
Tools: get_latest_report(["SNOW"]). It returns the latest report in seconds; a stale or missing symbol can get a new note from generate_report_for_stock (a few minutes).
2b. The latest reports for a time period: "What reports have come out in the Energy sector over the past three days? Pull the fresh ones and rank them by how significant the underlying event was."
Tools: list_available_reports(report_date=<3 days ago>), filtered to Energy names (list_options("sectors"), get_company_snapshot), then get_latest_report for the fresh ones.
2c. A bespoke report built on your criteria: "Build me a report on large-cap US semiconductors with ROIC above 20% and net debt/EBITDA under 1x, trading above their 200-day SMA with RSI below 60. Show the fundamentals, the technicals and the latest estimate revisions for each, laid out in my saved template."
Tools: screen_stocks, explore_data_catalogue (ratios, fundamentals, estimates), get_technical_indicator_data, and get_user_template. The agent assembles the PDF in your template; set one up with draft_user_template → save_user_template.
Predict upcoming earnings volatility, pulling in simple and exponential moving averages and Bollinger Bands, and creating a bespoke investment memo: "Predict tomorrow's upcoming earnings volatility, highlighting the stocks slated for the biggest moves; chart their simple and exponential moving averages and Bollinger Bands; and highlight the biggest fundamental drivers right now, putting this all in an investment memo for my team."
Tools: list_earnings_announcements → predict_earnings_move, get_technical_indicator_data, get_company_snapshot.
Run thematic research on a market narrative: "The AI trade has cooled recently, largely due to CapEx concerns and return on investment. I believe this has happened before, in the fall of 2025. Can you confirm, and what were the factors that allayed those concerns? Was it management commentary, continued demand for AI services, robust profitability and higher guidance? Please put together an in-depth report with Flexreport Finance."
Tools: explore_data_catalogue (transcripts, fundamentals, estimates), trace_data_sources / get_document_section for cited quotes, or generate_research_report for the full write-up.
62 tools, grouped by the module that registers them. Clients see them in tools/__init__.py import order (situate first).
tools/realtime_events.py)| Tool | Backend endpoint | What it does |
|---|---|---|
situate(symbols, event_type, card_types, window_days, sections, include_fetch, event_types, family, question, top_tickers, report_inventory, include_keys) | GET /situate-company (with symbols, one call per symbol in parallel) / GET /situate-market (no symbols) | The first call. A pass-through to the backend's situation endpoints. With symbols: the company's event web deduped to the newest node per event type and per table (same_chain_run edges link each table to the event that wrote it), the event cards, the table cards with read_by (the tool that reads each table — company_financials for the EDGAR statements) and refreshed_by, and entailed routed to the company's EDGAR or FMP side. Defaults to the backend's lean call (MAR on 2026-11-02: 110K characters, 53K with event_type + card_types, 220K with every section and key). No symbols: the market — up to 4 event types in reading order (shape named_types / family / sweep, routed from event_types, else family, else the question keywords; nothing named = a sweep, no default family), each with its trimmed card, what the 12h realtime cache holds (count, newest/oldest, tickers or CIK/strategy members) and fresh/stale report-plan counts, plus the whole event-type corpus and market status; 3-10K characters. Scope-mismatched arguments are rejected. Unstructured output (a company payload would be sent twice). Synchronous, auth |
list_realtime_events(event_type, tickers, sector, industry, market_cap) | POST /get-realtime-events | Pull live events (EPS updates, transcripts, ratings, …) from the 12h cache; each event's report_inputs (the ~27 KB report config) is dropped, report_inputs_key kept |
get_company_snapshot(symbol) | GET /get-company-snapshot | Structured snapshot: thesis, fundamentals, technicals, price targets, ownership, grades |
get_company_event_web(symbol, window_days, max_nodes) | GET /get-company-event-web | The why behind the snapshot — the company's recent event graph: time-ordered event/data_update nodes with fetch hints and typed edges (same_chain_run / / ). Pair it with , and call it chaining a targeted / / report request at one symbol |
tools/event_subscriptions.py)Default delivery for a live-streaming ask is a dashboard (in Claude, an Artifact) whose page loops stream_events with its cursor. The server instructions and the stream tools' docstrings both say so.
| Tool | Backend endpoint | What it does |
|---|---|---|
stream_events(topics, symbols, cursor, max_events, max_wait_seconds) | GET /events (SSE) | Push delivery, ad-hoc. A bounded read of the realtime-event stream: returns after max_events matched events or max_wait_seconds (cap 55 s) with {events, cursor, closed} — each event is the same object list_realtime_events returns, the moment it publishes. Filter inline by topics (event types or families from list_options("event_types"), plus the stream-only report_plan topic — a frame each time a report plan is saved) and symbols. No server-side state: always pass the returned cursor back in or the next call starts at "now" (authed) |
register_event_subscription(topics, symbols) | POST /register-subscription | Save a filter server-side → {subscription_id, filter, cursor, created_at}. Only events published after registration are delivered; keep the id (no list endpoint yet). 30/hour (authed) |
stream_event_subscription(subscription_id, cursor, max_events, max_wait_seconds) | GET /event-subscription/{id} (SSE) | Push delivery, resumable. Same bounded-read shape as stream_events, but the API saves the cursor when the read closes, so repeated calls see every matching event exactly once with no cursor bookkeeping; cursor only overrides the saved position for one connection. closed: "subscription deleted" means stop reconnecting; 404 covers a missing id and another user's id alike (authed) |
delete_event_subscription(subscription_id) | DELETE /event-subscription/{id} | Remove a saved subscription; any open stream on it ends with a closed frame within one read cycle (authed) |
tools/pdf_reports.py)| Tool | Backend endpoint | What it does |
|---|---|---|
get_latest_report(symbols) | POST /get-cached-reports | The fast path, fetch-only, for one or more named tickers. Every material event writes the symbol's report as it happens, so each symbol comes back in seconds as its latest PDF — a presigned url (valid ~6h: download it to read the report, hand it to the user to open; call again for a fresh link) with generated_at, age_hours, latest_event_at, stale, template — or in missing. No inline PDF since 2026-11-02 (the base64 was 118K–744K characters per symbol); stale_symbols lists the stale hits and rendering is always empty (kept for older clients). stale: true means an event landed after the PDF — if minutes ago its report is probably still being written (it arrives on report_plan); otherwise generate_report_for_stock(ticker) writes a new note (a few minutes). 500/hour |
list_available_reports(event_types, report_date) | POST /list-available-reports | Inventory of saved report plans built on or after report_date (YYYY-MM-DD, default today; plans stay listed for 7 days, so pass an earlier date for the running inventory — an empty list means nothing was planned in the window, not that there is no research) — every symbol whose material event (eps_update, eps_release, 8k_release, financials_release, ir_publication, transcript_update, biggest_mover, biggest_gainer, biggest_loser; company_update nightly) saved a plan, newest first, each with event_type, planned_at, queued_at, source and a fresh flag that applies the same rule /get-cached-reports uses: true → the report get_latest_report([ticker]) returns is current, false → a newer event landed since (its report may be minutes away; otherwise generate_report_for_stock writes a new note). Filter by event_types; widen with report_date (a malformed date is a 422). The check between list_realtime_events (what just published) and get_latest_report (pull it), or the direct way to see what real-time research is available now |
tools/data_exploration.py)| Tool | Backend endpoint | What it does |
|---|---|---|
company_financials(symbol, financial_type, limit, line_items) | POST /company-financials | One company's EDGAR statements, read directly — standardized quarterly/annual (edgar_quarterly_financials default), as filed (edgar_10q_financials / edgar_10k_financials, XBRL concepts with accession + filing_url), 8-K tables (edgar_eight_k) or YoY change (edgar_financials_yoy_change_m, stale until the post-transcript / 19:00 ET refresh). limit 1-12, default 5 (includes the year-ago quarter); always pass line_items — unfiltered as-filed pulls cost 28K-108K tokens and provenance comes from trace_data_sources(relation=<financial_type>, keys=[{symbol, date, statement, line_item}]). situate's table cards name it in read_by. Synchronous, auth |
explore_data_catalogue(query) | POST /data-catalogue-exploration | Default route — fast, interactive EDA against the data platform → result sets to render as charts/tables (dashboard only, 20/hour) → {task_id, status} |
explore_data_coverage(query) | POST /data-coverage-exploration | Coverage counts and breakdowns — how many symbols carry a dataset (IR decks, transcripts, 13F, insider, estimates…), by sector / industry / country / market cap / year, and how far history goes. Public: no account needed, so prospects can ask what the platform covers |
get_signed_sql_drilldown(encrypted_query_token, prompt, values, max_rows) | GET /query-data | Follow a get_company_event_web node's signed_query_url (or its bare t token) to the rows behind it — new record flagged is_new_record: true, plus context rows; tokens are server-minted only, never constructed. Optional values (typed slot fill, no LLM) and prompt (natural language, fills what values left out) re-aim a token that is a placeholder twin at other entities; max_rows caps the payload, and a sliced reply carries row_count + truncated: true (authed) |
trace_data_sources(relation, keys, column, encrypted_query_token, prompt, values, max_documents, resolve_passages) |
tools/market_data.py)| Tool | Backend endpoint | What it does |
|---|---|---|
get_technical_indicator_data(symbol, indicator, start_date, end_date) | GET /technical-indicator-endpoint | Daily history of one technical indicator (from list_options("technical_indicators")) over an inclusive YYYY-MM-DD range (authed) |
detect_intraday_outlier_jumps(symbol, frequency, zscore_threshold) | GET /detect-intraday-outlier-jumps | Live look at today's 1-min tape; at frequency (from list_options("intraday_frequency")); flags bars whose move is a daily-sigma outlier (synchronous, authed) |
get_aftermarket_data(symbols, start_datetime, end_datetime) | POST /get-aftermarket-data | Query stored after-hours 15-minute bars (open/high/low/close/volume, 16:00-20:00 ET) for symbols over an ET datetime range; rows ordered symbol, bar time DESC (defaults to today, authed, 300/min) |
predict_earnings_move(symbols) | POST /predict-earnings-announcement-move | Expected size of each stock's move on its next earnings reaction (e.g. NFLX expected_move 0.10, about 10%), next to its own historical average |
list_earnings_announcements(start_date, end_date, symbols, industry, sector, market_cap) | POST /list-upcoming-earnings-announcements | Scheduled earnings announcements in a date window (both default to today), covered names only; sector / industry / market_cap validated server-side (422 on a bad value) |
is_market_open(exchange) | GET /is-market-open | Is an exchange open now — returns a single is_open boolean (exchange code, default NYSE) — routes between detect_intraday_outlier_jumps (open) and get_aftermarket_data (closed). Public |
tools/institutional_ownership.py)| Tool | Backend endpoint | What it does |
|---|---|---|
get_13f_position_changes(cik, position_filter) | POST /13f-position-change | One filer's positions in its latest filed quarter, one row per position with share and value changes and sec_urls. position_filter defaults to NEW + CLOSED here (the API defaults to all five; BlackRock's full book is 2,680 rows, 1.16 MB). Returns {cik, position_filter, count, rows, note} |
get_13f_ownership_by_manager_type(symbol, manager_type, limit) | POST /13f-position-by-manager-type | One symbol's institutional ownership by manager type, per quarter. limit counts quarters (1-8, default 8). Changes are percentages of value, so they include price moves. Returns {symbol, count, rows, note} |
get_13f_cik_allocation(cik, group_by, limit) | POST /13f-cik-allocation | One filer's portfolio weights by sector or industry against all institutions. weight_pct is a share of covered_value, so report coverage_pct with it. limit defaults to 8 quarters for sector and 2 for industry (about 300 KB at 8). Returns {cik, group_by, limit, count, rows, note} |
All three are synchronous and need auth (100/min, counts toward plan quota). The CIK comes from list_options("institutional_managers", q=...).
tools/data_feeds.py)| Tool | Backend endpoint | What it does |
|---|---|---|
download_dataset(dataset, start_date, end_date, latest_file_version, bulk_download, event_types, symbols, include_page_text, document_urls, max_rows) | GET /list-data-feed-options + POST /download-flexreport-dataset | Read Flexreport's versioned daily feed files by date. The feeds are not hardcoded: each call first reads the public catalogue (list_options("data_feeds")) and refuses an unknown dataset or event_types before spending a download; the catalogue's returns picks rows vs presigned links (valid 12 h), and rows carrying document_url + page_number collapse to one row per document unless page text is asked for. Rate-limited to 3 calls/week per user, so start_date is required (explicit ET dates, never the API's UTC default) and the tool does the narrowing itself on the one response: symbols / document_urls filtering, page collapsing, a max_rows cap (default 500) with row_count / truncated, and notes saying what was left out. Fetch once and embed; never poll it from a dashboard (authed) |
draft_custom_dataset(request, previous_draft_id, feedback, sample_rows, target_format) | POST /draft-custom-dataset | Step 1 of the user's own scheduled datasets: plans candidate tables from a plain-English description and samples each; target_format rss/atom shapes one row per feed item. Async (task_id = draft_id, ~45 s-4 min via get_task_status; sample_rows 1-5, default 5). For event/news feeds the agent researches first (situate + ontology, definitions agreed with the user, one scoped explore census) and sends a request naming the table and column behind each field; plain data feeds draft directly. Research reports are in every feed by default: the build adds research_url to rows with a symbol/ticker column, so the request never writes report links (that link is for the feed's readers only: agents never call or offer it, and users get reports via get_latest_report); the result's candidates carry an opaque query_token that is never shown, only passed to the save. Refine with the same request + previous_draft_id + feedback. 20/hour per user (authed) |
tools/strategies.py)| Tool | Backend endpoint | What it does |
|---|---|---|
screen_stocks(metrics, sectors, sub_sectors, market_cap, analyst_ratings, institutional_ownership, countries, price_performance) | POST /screen-stocks | Screen the universe by growth metrics, sector / sub-industry, market cap, analyst ratings, institutional ownership, country, and price performance. Async → {task_id} (10/hour) |
optimize_portfolio_default(symbols, risk_tolerance) | POST /optimize-symbols-non-llm | Default optimizer — risk-optimized weights for a ticker list, synchronous, no LLM; unsupported tickers in missing, short-history ones in dropped. Public |
optimize_portfolio(symbols, risk_tolerance, delivery) | POST /optimize-symbols | Slower LLM-curated variant: multi-signal scoring + LLM curation + risk optimization. Async → {task_id} (10/hour) |
get_stock_picks(strategy_name) | GET /get-stock-picks | Latest LLM-selected stock picks for the current rebalance (optionally one strategy) |
get_strategy_performance_summary(amount) | GET /get-strategy-performance-summary | Leaderboard of every strategy book vs the S&P 500 since inception (return, excess, Sharpe, drawdown, YTD, growth of amount) — the first stop for "how are the picks doing?" |
get_strategy_track_record(strategy_name, book, amount) | GET /get-strategy-track-record | One book's daily since-inception series vs the S&P 500 plus headline stats; book = llm (default) or mechanical, strategy_name may be pooled |
get_strategy_swaps(strategy_name) | GET /get-strategy-swaps | Per-trade ledger of weekly adds / drops, each measured against the S&P 500 (drops as foregone return) |
tools/scheduling.py)| Tool | Backend endpoint | What it does |
|---|---|---|
schedule_task(name, steps, regular_cron, delivery, description) | POST /save-user-workflow | Save a 1-8 step workflow (fetch allowlisted endpoints + transform steps) and attach a recurring cron delivery; upsert by lowercase-slug name |
list_scheduled_tasks() | GET /get-scheduled-tasks | The caller's cron jobs (name, schedule, enabled, last_run_at, total_run_count); workflow jobs are named wf:{email}:{workflow-name} |
delete_scheduled_task(task_name) | DELETE /delete-scheduled-task | Remove a cron job by the full name from list_scheduled_tasks; the saved workflow definition is kept |
tools/utility.py)| Tool | Backend endpoint | What it does |
|---|---|---|
get_task_status(task_id) | GET /task-status | Poll an async job to SUCCESS and read its result. Results over 8 MB (MCP_MAX_TASK_RESULT_BYTES) are refused with a "narrow the query" error; every other backend response is capped at 16 MB (MCP_MAX_RESPONSE_BYTES) in _send |
list_options(kind, ticker, q, cik, manager_type, limit, offset) | GET /list-realtime-event-options, /list-financial-items, /list-financial-ratios, /list-as-reported-items?ticker=, /list-revenue-segments?ticker=, /list-institutional-managers?q=&cik=&manager_type=&limit=&offset=, /get-sectors, /list-institutional-investor-types, /list-countries, /get-fiscal-quarter, /list-marketcap-options, /list-intraday-chart-options, /list-technical-indicators, /list-tickers, /list-symbols-with-names, /list-data-feed-options | One catalogue tool: enumerate valid values for a parameter (event types, line items, ratios, a filer's own as-reported XBRL concepts and revenue segments (ticker), institutional managers by name fragment / CIK / manager type (q / cik / manager_type, paged with limit / offset), sectors, investor types, countries, fiscal quarter, market-cap buckets, intraday frequencies, technical indicators, the ticker universe with or without company names, and the download_dataset feeds) |
list_sub_industries(sectors) | GET /get-sub-industries | Distinct industries within the given sector(s) |
onboard_symbol(symbol) | POST /onboard-symbol | Request onboarding of an uncovered ticker (async, authed, 5/hour) |
upgrade_plan() | GET /payment/upgrade-link | Checkout link to upgrade or change the user's plan (e.g. after hitting a quota); payment completes in the browser |
manage_billing() | GET /payment/portal-link |
For a single named company, get_company_snapshot and get_company_event_web are the standard pair — the two halves of the same question, usually called together. The snapshot is the what (where the company stands now: thesis, fundamentals, technicals, ownership, grades); the event web is the why (the episode behind it: earnings print → 8-K → transcript update → IR publication → analyst reaction, with edges saying how each relates). A snapshot on its own is a verdict with no evidence; the web is the evidence. The web is also the cheap grounding call that makes the rest of the loop precise — each node carries the exact follow-up call, so the next list_realtime_events, explore_data_catalogue, or report request carries real event types and dates instead of guessed ones.
Typical agent loop: default to explore_data_catalogue(query) for open-ended/exploratory questions (fast, interactive charts/tables). Escalate only on a crystal-clear intent — get_latest_report(symbols) for the existing report on a named ticker, screen_stocks(...) to filter the universe, or generate_research_report(query) for an explicit deep dive (~10-12 min, async — poll with get_task_status).
Asked the same question twice — or the same question about a different company? explore_data_catalogue re-plans its SQL every time, so once an exploration is done the agent offers to save it: save_user_query stores its query_tokens, and the backend authors a placeholder twin of each query — lifting the ticker, CIK list, date, interval, and count into typed slots — alongside the row's metadata, which names those slots and summarises what the row asks. run_saved_query then replays the row's queries exactly (the same queries against live data, in seconds, no planning step, no polling), or takes the row's query_placeholder tokens plus typed values to re-aim the request at other entities — "TGT's last 8 quarters of sales" saved yesterday answers "KSS's last 8 quarters of sales" today, deterministically and with no language model in the fill, the substituted parameters (and any defaulted_parameters / ignored_values) returned beside the rows; a prompt still covers what the agent cannot type. list_saved_queries is the thing to check before any exploration, matching the question against each row's metadata — summary, parameters, datasets — rather than its user_request label, and skipping rows marked stale; delete_saved_query clears one out. Pass max_rows on every replay, and pick the queries to run from metadata.result_schema so the _constituents companion (7,000–35,000 rows) stays out of the call. The tokens are opaque ciphertext the backend minted: pass them through unchanged, never construct or edit one, and never show one to the user.
Want reports in your own format? A user supplies an HTML page — masthead, colours, layout, branding — and it is saved as a blueprint: the author's stylesheet and page chrome plus one markup pattern per content kind, which the backend applies automatically to every report written for that user (each event-time report gets a copy in their template, which get_latest_report serves as template: "user", and scheduled reports). A template is structure and style applied to the document's own content: nobody fills template markup, and no other call changes. It is a visual choice, so it is approved by looking: draft_user_template compiles the page under several treatments and renders each as PNG previews of the platform's specimen document (an add_on restyles one section of the standard layout, named by anchor; a bespoke drives the whole document), the user picks one, and save_user_template(draft_id, variant_id) stores that treatment — one template per user, upserted. update_user_template changes any field later, or recompiles the stored design when called with no arguments; get_user_template shows what is in force; delete_user_template returns the user to the standard layouts.
There is no agent-driven PDF builder: the build_pdf_full_width / build_pdf_sidebar tools (backend /create-pdf and /create-pdf-sidebar) and the pdf_options catalogue were retired in favour of templates. When a user wants a document composed from Flexreport Finance data that no backend report covers — including a bespoke take on a company report ("the SNOW report, built around product revenue and RPO") — the agent explores the items they named, ideates with them over the result sets, and builds the PDF with its own document tooling, overlaying the agreed results in the user's saved template (get_user_template) when one exists — and offering to draft one (draft → preview → save, then update) when none does.
Reports are written at event time: each material event publishes a new report for the symbol, announced on the stream-only report_plan topic. The two report endpoints are strictly separate:
get_latest_report → /get-cached-reports is the fast path, and fetch-only. A material event publishing for a symbol saves a report plan (JSON — brief, queries, results, format map) and writes the symbol's report from it within minutes, plus a copy in each saved user template (template: "user"). The endpoint returns that latest PDF in seconds, tagged stale (true = an event landed after it — its report may still be in progress) or lands the symbol in missing. Nothing is rendered on fetch: rendering is always empty.generate_report_for_stock → /create-full-report writes a new research note (a few minutes), used sparingly. The agent calls it with the ticker alone only when get_latest_report returned the symbol stale with no report on the way, or missing, and the user wants to wait; the note replaces the symbol's report, so later get_latest_report calls return it and report_plan subscribers are notified. Never fanned out across a list.list_saved_queries, then explore_data_catalogue), ideates with them over the result sets, and overlays the agreed results into their template (get_user_template) with its own document tooling.list_available_reports(event_types, report_date) is the inventory of those saved plans, scoped to plans built on or after report_date (default today) — which symbols have real-time research ready and whether each is fresh (the report get_latest_report returns is current) or has a newer event since — so the agent can go from list_realtime_events (an event published) to a fresh report without guessing, or skip the events step and list what is available outright. For "pull the most relevant reports today" the events are the filter and the plans are the availability check, in that order: pull the material types (transcript_update, 8k_release, ir_publication, eps_release, financials_release) and the tape (biggest_mover, biggest_loser), read the content, confirm significance against intraday price action (detect_intraday_outlier_jumps), match the survivors to plans with list_available_reports(event_types=[...]), and pull only those with get_latest_report. situate surfaces this as a suggestion on every market-wide plan, carrying report_date when the 12h realtime window opened on an earlier UTC date. Only an explicit ask for the pipeline's own report rebuilt around named items makes it a custom report — user_override=true plus the shaping lists (financial_items, ratios, as_reported_financial_items, revenue_segment, technical_analysis_items, estimate_items, institutional_ownership CIKs, include_as_report_financials / as_reported_periods) — which writes the same note steered toward those items (a few minutes) and, like a standard call, replaces the symbol's report. The backend rejects shaping lists without the switch (422), so the tool sets it whenever a shaping field is non-empty. Vocabularies come from list_options: financial_items, financial_ratios, technical_indicators, and the ticker-scoped as_reported_items / revenue_segments and the q-scoped institutional_managers (returns CIKs). Event context is server-owned — the request carries no thesis, change-summary, include_*, or price-date fields.
server.py entrypoint: imports the tools package, adds /health, runs the transport
core.py the FastMCP instance, server instructions and `_send` (backend forwarding)
client.py shared httpx client + inbound-bearer forwarding
auth_verifier.py OAuth resource-server token validation (RS256 via the backend JWKS)
instructions.json server instructions (non-code copy)
tools/
realtime_events.py situate, list_realtime_events, company snapshot / event web, event ontology
event_subscriptions.py stream_events, register / stream / delete_event_subscription (SSE bounded reads)
pdf_reports.py get_latest_report, list_available_reports, generate_*, download_pdf_from_url, PDF templates
data_exploration.py explore_data_*, signed drilldowns, trace_data_sources, document sections, saved queries
utility.py get_task_status, list_options, list_sub_industries, onboard_symbol, billing
market_data.py technical indicators, intraday outliers, after-hours bars, earnings calendar + move prediction, market status
institutional_ownership.py 13F position changes, ownership by manager type, portfolio allocation
data_feeds.py download_dataset (versioned daily feed files), user datasets (draft / save / list / delete)
strategies.py screen_stocks, portfolio optimization, stock picks, strategy track records
scheduling.py schedule / list / delete scheduled tasks
Each module does from core import mcp, _send and registers with @mcp.tool(...);
importing tools registers everything. tools/__init__.py fixes the import order,
which is the order tools are advertised to clients (situate first).
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # set API_BASE_URL, MCP_HOST, MCP_PORT
set -a && source .env && set +a
python server.py # serves streamable-http on http://MCP_HOST:MCP_PORT/mcp
The server is an OAuth 2.0 Resource Server and stateless (load-balancer friendly), so auth rides each call. It holds no credentials and no signing secret — it validates the inbound bearer token and forwards it to the backend, which enforces scope, plan, and quota.
Sign-in is a standard browser authorization-code + PKCE flow, run by your MCP client (e.g. Claude) against the Flexreport Finance backend, which is the Authorization Server. Register or sign in with an email + password, or use Google Sign-In — you never paste or type a token:
401 with a
WWW-Authenticate challenge and serves Protected Resource Metadata at
/.well-known/oauth-protected-resource, pointing the client at the backend AS.aud, iss, and exp — then
forwards it to the backend. Invalid or expired → a clean 401 and the client
re-runs the flow.The server never sees your password and never holds the signing key — it stays a credential-free proxy. Only valid RS256 OAuth tokens are accepted; there is no password or static-JWT fallback.
| Var | Default | Purpose |
|---|---|---|
OAUTH_ISSUER | https://app.flexreportfinapi.com | Expected token iss + advertised authorization server. Must match the backend's iss — prod uses the root domain https://flexreportfinapi.com. |
OAUTH_AUDIENCE | = OAUTH_ISSUER | Expected token aud. Set both sides to the canonical MCP URL for true audience binding. |
OAUTH_JWKS_URL | {issuer}/.well-known/jwks.json | Where public keys are fetched (decoupled from issuer for container networking). |
MCP_RESOURCE_URL | https://mcp.flexreportfinapi.com/mcp | This server's canonical resource identifier (the PRM resource). |
Configure Authorization: Bearer <OAuth access token> in your MCP client and the
server validates and forwards it like any other call — useful for testing with a
token minted elsewhere. Nothing is stored at rest; tokens are forwarded per-call.
.mcp.json (Claude Code):
{
"mcpServers": {
"flexreport": {
"type": "http",
"url": "http://localhost:8000/mcp",
"headers": { "Authorization": "Bearer <YOUR_OAUTH_ACCESS_TOKEN>" }
}
}
}
npx @modelcontextprotocol/inspector
# Connect to http://localhost:8000/mcp with header Authorization: Bearer <OAuth access token>
# Confirm the tools list loads (46 tools), then exercise:
# list_realtime_events("eps_update") -> events (or [])
# get_company_event_web("NVDA") -> event graph (or an empty/degraded web)
# get_latest_report(["AAPL"]) -> rendering task_id, cached PDF url, or missing [named-ticker report; max 100 symbols]
# list_available_reports(["eps_release"]) -> plans built today with `fresh` flags (or []); report_date="YYYY-MM-DD" widens
# explore_data_catalogue("MU EPS growth last 8 quarters") -> task_id [default exploratory route]
# get_task_status(task_id) -> eventually SUCCESS
# Negative: connect with no/invalid token -> 401 + WWW-Authenticate challenge
Build the image and run it as its own container (e.g. a separate ECS service with
its own task definition), independent of the API and Celery workers. Set
API_BASE_URL to the deployed backend URL.
docker build -t flexreport-mcp .
docker run -p 8000:8000 -e API_BASE_URL=https://flexreportfinapi.com flexreport-mcp
Flexreport Finance's privacy policy — what's collected, retention windows, and the third parties involved — is published at app.flexreportfinapi.com/privacy (source: PRIVACY.md).
lineageco_occurrenceget_company_snapshotlist_realtime_eventsexplore_data_catalogueget_event_ontology(event_type, family, relation, format) | GET /get-event-ontology | The class-level ontology behind the event web — what an event type entails: the tables written when it fires (by layer), the events that usually follow (observed rate / lag), where the payload lives after the 12h realtime cache (persisted_in), which workflow refreshes each relation and its next_run_at (the freshness check), and which tool reads it. Symbol-independent, public, cacheable — call it first on any event-driven request and join it to a web node's type / relation / family |
generate_report_for_stock(ticker, user_override, financial_items, as_reported_financial_items, ratios, revenue_segment, technical_analysis_items, estimate_items, institutional_ownership, include_as_report_financials, as_reported_periods) |
POST /create-full-report |
Writes a new research note (a few minutes) that replaces the symbol's report — use sparingly. Standard (ticker only): the call to make only when get_latest_report returned the ticker stale (with no report on the way) or missing and the user wants to wait. Custom (user_override=true + shaping lists): the same note, also replacing the symbol's report, steered toward the line items, ratios, as-reported concepts, segments, indicators, estimates, or manager CIKs the user named; the tool sets the switch whenever a shaping field is non-empty. Bespoke asks are normally explore → ideate → overlay into the template instead. Async → {ticker: {task_id, status}}; the task result is the saved plan, so fetch the PDF with get_latest_report on SUCCESS (or on its report_plan frame) |
generate_research_report(query, delivery) | POST /generate-research-report | Deep dive (~10-12 min, async) — analyst-grade writeup, only when the user explicitly asks for a full report → {task_id, status} |
download_pdf_from_url(url, file_name) | POST /download-pdf-from-url | Fallback only: fetch a presigned S3 PDF URL server-side (SSRF-guarded) and return it inline as base64, for clients that can't reach the link but can run code to decode it — e.g. a get_latest_report url on clients that can't open the link (authed) |
draft_user_template(template, template_type, anchor) | POST /draft-user-template | Step 1 of 2 for a user's own PDF format: compiles an HTML page into a blueprint under several treatments and renders each as presigned PNG previews of the platform's specimen document — one of every content kind (title, lead, sections, bullets, KPI cards, tables, charts, figure, source line, footnote); an add_on at its in-report column width, a bespoke as full pages — plus a draft_id. The user signs off on how the design treats each kind of content, not on sample text. Nothing is stored — the draft lives 24h (authed, 30/min) |
save_user_template(draft_id, variant_id, template, template_type, anchor) | POST /save-user-template | Step 2 of 2: pass draft_id + the chosen variant_id and the reviewed treatment is saved; or pass template + template_type (+ anchor) directly to skip the previews (HTML only — markdown is a 422). One template per user — upsert, saving replaces the previous one. Returns {status: "SAVED", template_type, anchor, bytes, patterns, tones, warnings} — the content kinds the design styles and its tone classes (authed, 100/min) |
update_user_template(template, template_type, anchor, draft_id, variant_id) | PUT /update-user-template | Change the saved template — any field (omitted fields keep their stored values), or no fields to recompile it as stored with the current compiler. Async: {task_id, status: "PENDING", changed, next_step} → poll get_task_status for {status: "SAVED", patterns, tones, warnings}. 404 = nothing saved yet (save first); 422 = validation errors (authed) |
get_user_template() | GET /get-user-template | The caller's saved template (template_type, template_format, anchor, sanitized template, timestamps). 404 = none saved; a 503 means the backend table isn't created yet, not "nothing saved". Also the markup to lay an agent-composed PDF out in |
delete_user_template() | DELETE /delete-user-template | Delete the saved template — permanent, PDFs revert to the standard layouts; ownership enforced in the WHERE clause |
GET /trace-sources |
Citations — resolve numbers from explore_data_catalogue / run_saved_query / get_signed_sql_drilldown rows back to the filing, section, or verified quote they were read from. Prefer relation + keys (every identifying column, incl. the period) over a token |
get_document_section(symbol, year, quarter, n, name) | GET /query-section | Open one numbered section of a source document — the exact earnings-call speaker turn a trace_data_sources citation points at. Read the four values off the citation URL; year / quarter are the fiscal period of the call, not the calendar quarter |
get_transcript_guidance(symbol, limit) | POST /transcript-guidance | One company's guidance label call by call (RAISED, INLINE, REVISED DOWNWARDS, NOT_PROVIDED), newest first; limit 1-20, default 4 (about a year). Returns {symbol, count, rows, note} |
get_transcript_analysis(symbol, limit) | POST /transcript-analysis | One company's call-over-call analysis: the summary and what the call added, removed, strengthened, weakened or reinforced against the prior call, with source_urls resolving its [n] markers on calls that have stored citations. Returns {symbol, count, rows, note} |
save_user_query(user_request, query_tokens) | POST /save-user-query | Save an explore_data_catalogue result's opaque query_tokens under the user's own wording, so the request can be replayed without re-planning it. Returns {msg, id, placeholder_status} — id is the delete key; PENDING means the backend is authoring a placeholder twin of each query plus the row's metadata slot schema (~10 s to 5 min) so the request can later be re-asked for other entities; NOT_DISPATCHED means exact replay only (authed, owner-scoped; no upsert — saving twice creates two rows, 100/min) |
list_saved_queries(limit) | GET /get-saved-queries | The caller's saved requests, newest first — id (what delete takes), queries (the tokens for an exact replay), query_placeholder (the twin tokens, index-aligned with queries), user_request (the user's own label), and metadata: the row's summary (one line, slots in braces), parameters slot schema ({name, type, description, example} — the keys of run_saved_query(values=…)), entities, datasets, topics, per-query result_schema, stale, run_count, last_run_at. Match a question on metadata, not on the label, and skip stale rows; metadata and query_placeholder are null until the placeholder task has run. A 503 means the backend table isn't created yet, not "nothing saved" |
run_saved_query(query_tokens, prompt, values, max_rows) | GET /query-data | Replay a saved request: each token runs against live data, synchronously, in the order given (max 25) — rows only, never SQL; seconds instead of a ~1-5 min planning job, and no draw on the 20/hour exploration budget. With query_placeholder tokens, values (keyed by metadata.parameters, filled deterministically with no LLM — entity-set slots take [{id, name}] rows and a name with no id resolves server-side) re-aims the saved queries; prompt ("the past 8 quarters of sales for KSS") fills only what values left out, and a slot neither covers keeps its saved example. The reply carries parameters, defaulted_parameters and ignored_values; a 422 means the request could not be mapped — fall back to explore_data_catalogue. Always pass max_rows (e.g. 500): a row's _constituents companion returns 7,000–35,000 rows uncapped |
delete_saved_query(query_id) | DELETE /delete-saved-query | Delete one saved request by id — permanent, and the tokens go with it; the 404 deliberately covers both "no such id" and "not yours" |
save_user_dataset(draft_id, query_token, dataset_name, date_mode, cron, format, build_now) | POST /save-user-dataset | Step 2: saves the chosen candidate with its schema, format (parquet, csv, tsv, json, jsonl, xlsx, xml, rss, atom), date mode (rolling shifts fixed dates to each build date, snapshot re-runs as saved) and any valid 5-field ET cron (hourly, every 30 minutes, ranges). New items are LLM-reviewed before publishing, up to 40 per build (the rest of a large first build publish as queried and are reviewed later; links/GUIDs/tickers/dates never change); rss items carry <company:symbol> and up to 10 <category>, one item per guid. Every row with a symbol/ticker gets research_url, a fixed signed link (/user-dataset-research/<token>/<SYMBOL>, no sign-in) to the research report PDF behind the item, shown as a "Research" link in rss/atom items. It exists only for feed redistribution: anyone holding the feed can open its reports (the agent says so when the user plans to share it), deleting the dataset kills the links, and agents never call it or offer it as a download route. Upserts by name; schedule appears as ds:{email}:{name} in list_scheduled_tasks. Returns latest_url, a fixed sign-in-free link to the newest file, and feed_fields for rss/atom. Max 5 datasets, 10 schedules shared with workflows (authed) |
list_user_datasets(dataset_name, start_date, end_date, latest_only) | GET /list-user-datasets | The user's datasets with last build status (written / unchanged / empty / too_large / schema_drift / failed) and presigned 12 h links to each build's file — handed to the user, never fetched (authed) |
get_user_dataset_file(dataset_name, version) | GET /list-user-datasets + the file at latest_url (or a dated version's presigned link) | Reads one of the user's datasets as published and returns its rows (json, jsonl, csv, tsv, xml, or rss/atom items with categories, tickers and research_url). This is how dashboards read a dataset, never a replay of its saved query; the file is fetched server-side without the bearer (the link is its own key) under the response size cap (authed) |
delete_user_data_feed(dataset_name) | DELETE /delete-user-data-feed | Removes one of the user's datasets entirely: schedule, files, fixed link and saved query. Irreversible, so the agent confirms first; a 503 is safe to retry (authed) |
| Stripe billing-portal link — invoices, card, cancellation |