{
  "markdown": "# Quality Screener MCP server\n\nA standalone [Model Context Protocol](https://modelcontextprotocol.io) (MCP)\nserver that exposes the [Quality Screener](https://qualityscreener.io)\nstock-screening engine as tools for AI agents (Claude, Cursor, and any other MCP\nclient).\n\nOnce connected, an agent can screen and filter the scored universe, compute\ncustom quality scores, inspect score history, manage saved scoring systems, and\ngenerate shareable screen links — **acting as the signed-in user**, against the\nsame data they see in the web dashboard.\n\n- **No SDK dependency.** The server is a thin HTTP layer over the public\n  Quality Screener API. It has **no dependency on the backend Python package** —\n  every tool just calls a REST endpoint and returns the JSON payload.\n- **Multi-tenant & credential-free.** When deployed over HTTP the server stores\n  no credentials of its own. Each request carries the caller's own access token,\n  which the server forwards to the API, so a single public deployment can serve\n  many users without ever acting on a shared account.\n\n---\n\n## Table of contents\n\n- [How it works](#how-it-works)\n- [Quick start (remote)](#quick-start-remote)\n- [Running locally](#running-locally)\n- [Configuration](#configuration)\n- [Authentication](#authentication)\n- [Tools](#tools)\n- [Working with `CustomScoreConfig`](#working-with-customscoreconfig)\n- [Connecting an MCP client](#connecting-an-mcp-client)\n- [Deployment](#deployment)\n- [Development](#development)\n- [Privacy Policy](#privacy-policy)\n- [Support](#support)\n- [License](#license)\n\n---\n\n## How it works\n\n```\n┌─────────────┐   MCP (stdio | streamable-HTTP)   ┌──────────────────┐   HTTPS   ┌──────────────────────┐\n│  AI agent   │ ────────────────────────────────► │  qscreener-mcp   │ ────────► │ Quality Screener API │\n│ (MCP client)│ ◄──────────────────────────────── │   (this server)  │ ◄──────── │   (FastAPI backend)  │\n└─────────────┘         tool calls / JSON          └──────────────────┘  REST     └──────────────────────┘\n```\n\nEach MCP tool maps to one Quality Screener REST endpoint. The server attaches\nthe caller's bearer token to every outbound request (header\n`X-Stobot-CLI-Token`, `Authorization: Bearer …` also accepted) and returns the\ndecoded JSON. There is no business logic in the server itself — it is a typed,\nauthenticated façade over the API.\n\nIt runs in two transport modes:\n\n| Transport | Use | Authentication |\n| --- | --- | --- |\n| `stdio` (default) | A local agent (e.g. Claude Code) launches the server as a subprocess | Token from `$QSCREENER_TOKEN` or `~/.config/qscreener/credentials.json` |\n| `streamable-http` | A remote, externally reachable deployment (e.g. Railway) | End-to-end MCP **OAuth 2.0** — the client opens the browser once, then sends the token automatically; or a per-request `X-Stobot-CLI-Token` header |\n\nOver HTTP the MCP endpoint is served at `/mcp`.\n\n---\n\n## Quick start (remote)\n\nThe easiest way to use the server is to point your MCP client at the hosted\ndeployment. No token to copy — the client triggers a browser sign-in on first\nconnect:\n\n```json\n{\n  \"mcpServers\": {\n    \"qscreener\": {\n      \"type\": \"streamable-http\",\n      \"url\": \"https://mcp.qualityscreener.io/mcp\"\n    }\n  }\n}\n```\n\nOn first use your browser opens the Quality Screener sign-in page. Approve once,\nand the agent stays connected. You need a Quality Screener account; the agent\ninherits exactly your access.\n\n---\n\n## Running locally\n\nRequires [`uv`](https://docs.astral.sh/uv/).\n\n```bash\n# Install dependencies\nuv sync\n\n# stdio — for a local agent that launches this as a subprocess\nuv run qscreener-mcp\n\n# streamable-HTTP — mirrors the remote deployment\nQSCREENER_MCP_TRANSPORT=streamable-http QSCREENER_MCP_PORT=8080 \\\n  QSCREENER_API_URL=http://localhost:8001 \\\n  uv run qscreener-mcp\n# -> MCP endpoint at http://localhost:8080/mcp\n```\n\nWith Docker:\n\n```bash\ndocker build -t qscreener-mcp .\ndocker run --rm -p 8080:8080 \\\n  -e QSCREENER_API_URL=https://your-backend.example.com \\\n  -e QSCREENER_MCP_PUBLIC_URL=http://localhost:8080 \\\n  qscreener-mcp\n# -> MCP endpoint at http://localhost:8080/mcp\n```\n\nBy default the container runs the `streamable-http` transport on port `8080`.\n\n---\n\n## Configuration\n\nAll configuration is via environment variables, resolved at startup.\n\n| Env var | Default | Meaning |\n| --- | --- | --- |\n| `QSCREENER_API_URL` | `http://localhost:8001` | Base URL of the Quality Screener backend API the tools call |\n| `QSCREENER_MCP_TRANSPORT` | `stdio` | `stdio`, `streamable-http`, or `sse` |\n| `QSCREENER_WEBSITE_URL` | `http://localhost:3001` | Web-app base URL used to build the OAuth browser-login link and shareable screen URLs |\n| `QSCREENER_MCP_PUBLIC_URL` | `http://localhost:{PORT\\|8080}` | Publicly reachable base URL of this server; used to build the OAuth callback URL |\n| `PORT` | — | Bind port for HTTP transports (Railway sets this automatically) |\n| `QSCREENER_MCP_PORT` | `8080` | Bind port fallback when `PORT` is unset |\n| `QSCREENER_MCP_HOST` | `0.0.0.0` | Bind host for HTTP transports |\n| `QSCREENER_TOKEN` | — | Bearer-token override for stdio mode (single user) |\n| `QSCREENER_CONFIG_DIR` | `~/.config/qscreener` | Directory holding `credentials.json` for stdio mode |\n\n---\n\n## Authentication\n\nThe server resolves a bearer token for each call with the following precedence:\n\n1. **HTTP request header** — `X-Stobot-CLI-Token`, then `Authorization: Bearer <token>`.\n2. **`$QSCREENER_TOKEN`** environment variable.\n3. **`$QSCREENER_CONFIG_DIR/credentials.json`** — the `token` field.\n\n### Remote (OAuth 2.0)\n\nFor a `streamable-http` deployment, authentication is fully automated via the\nMCP OAuth flow:\n\n1. The MCP client discovers the authorization server and opens the user's browser.\n2. The browser lands on the Quality Screener web app, which exchanges the user's\n   web session for a short-lived CLI token and redirects back to this server's\n   `/oauth/callback`.\n3. The server hands the token to the MCP client, which sends it as a bearer token\n   on every subsequent request.\n\nThe token is validated on each request by calling the backend's\n`/v1/cli/auth/whoami` endpoint, so a revoked or expired token is rejected\nimmediately. The server never persists user tokens.\n\n### Local (stdio)\n\nMint a token through the browser login flow and store it locally, then run the\nserver over stdio:\n\n```bash\nqscreener auth login                          # opens the web app, stores a token\ncat ~/.config/qscreener/credentials.json      # the \"token\" field is your bearer token\n```\n\nOr set `QSCREENER_TOKEN` directly for CI / scripted use.\n\n---\n\n## Tools\n\nAll tools require authentication. Filters use **OR logic within a filter** and\n**AND logic across filters**. Market caps are always in **USD**.\n\n### Account & status\n\n| Tool | Signature | Description |\n| --- | --- | --- |\n| `auth_status` | `auth_status()` | Whether a token is present and which user it authenticates as. |\n| `account_profile` | `account_profile()` | The signed-in user's profile (email, username, organization). |\n| `health` | `health()` | API and database health check. |\n\n### Scores & screening\n\n| Tool | Signature | Description |\n| --- | --- | --- |\n| `scores_top` | `scores_top(limit=20)` | Top tickers by quality score, as a `{ticker: score}` map. |\n| `scores_list` | `scores_list(ticker=None, sectors=None, industries=None, countries=None, currencies=None, exchanges=None, min_score=None, max_score=None, min_market_cap_usd=None, max_market_cap_usd=None, sort_by=\"quality_score\", sort_order=\"desc\", offset=0, limit=50, include_duplicates=False)` | List scored tickers with optional filters. |\n| `scores_show` | `scores_show(ticker)` | Full score row(s) for a single ticker. |\n| `scores_for_tickers` | `scores_for_tickers(tickers, scoring_system_id=None)` | Current scores for a specific list of tickers, under default scoring or a saved scoring system. Unknown tickers are omitted. |\n| `scores_statistics` | `scores_statistics(sectors=None, min_score=None, max_score=None, min_market_cap_usd=None, max_market_cap_usd=None)` | Min / max / average score statistics for a filtered universe. |\n| `scores_market_cap` | `scores_market_cap(sectors=None, min_score=None)` | Aggregated total market cap (USD) for a filtered universe. |\n| `score_compute` | `score_compute(config, scoring_universe=None, sectors=None, industries=None, regions=None, countries=None, currencies=None, exchanges=None, min_market_cap_usd=None, max_market_cap_usd=None, sort_by=\"quality_score\", sort_order=\"desc\", offset=0, limit=50, include_duplicates=False)` | Compute custom scores from a `CustomScoreConfig`. `scoring_universe` picks the peer group (changes the scores); the other filters select rows (do not). |\n\n### Sharing\n\n| Tool | Signature | Description |\n| --- | --- | --- |\n| `screen_share` | `screen_share(config)` | Persist a `CustomScoreConfig` and return a public, copy-pasteable share link (`url`, `slug`, `created`, `view_count`). Content-addressed: an identical config returns the same link. |\n\n### Filters & tickers\n\n| Tool | Signature | Description |\n| --- | --- | --- |\n| `filters_list` | `filters_list()` | Available filter values (sectors, industries, countries, currencies, exchanges). |\n| `tickers_list` | `tickers_list(limit=None)` | Available tickers, optionally truncated to `limit`. |\n| `tickers_search` | `tickers_search(query)` | Search available tickers by case-insensitive substring. |\n\n### Score history\n\nDates are `YYYY-MM-DD`. Pass `scoring_system_id` to compute history against a\nsaved scoring system instead of the default quality score.\n\n| Tool | Signature | Description |\n| --- | --- | --- |\n| `history_ticker` | `history_ticker(ticker, start=None, end=None, scoring_system_id=None)` | Score history for a single ticker over a date range. |\n| `history_batch` | `history_batch(tickers, start=None, end=None, scoring_system_id=None)` | Score history for several tickers at once. |\n| `history_top` | `history_top(top=10, scoring_system_id=None)` | Fetch the current top-N tickers and return their score history. |\n\n### Saved scoring systems\n\nA scoring system is a named, reusable `CustomScoreConfig` stored against your\naccount.\n\n| Tool | Signature | Description |\n| --- | --- | --- |\n| `systems_list` | `systems_list()` | List your saved scoring systems. |\n| `systems_show` | `systems_show(system_id)` | Show a saved scoring system by ID. |\n| `systems_create` | `systems_create(name, config, description=None)` | Create a saved scoring system from a config object. |\n| `systems_update` | `systems_update(system_id, name=None, config=None, description=None)` | Update a saved scoring system. |\n| `systems_delete` | `systems_delete(system_id)` | Delete a saved scoring system. |\n| `systems_apply` | `systems_apply(system_id)` | Apply a saved scoring system (increments its usage count). |\n\n---\n\n## Working with `CustomScoreConfig`\n\n`score_compute`, `screen_share`, and the `systems_*` tools accept a\n`CustomScoreConfig` object describing how to weight financial metrics. Its shape\nmirrors the score builder in the web dashboard: weighted metric **groups**, each\ncontaining weighted **metrics**, plus scoring parameters and an optional nested\n`filters` block. A minimal example:\n\n```json\n{\n  \"name\": \"My quality screen\",\n  \"winsorizePercentile\": 5,\n  \"missingDataPercentile\": 0.25,\n  \"normalizeGroupZScores\": false,\n  \"includeDuplicatesInScoring\": false,\n  \"groups\": [\n    {\n      \"id\": \"returns\",\n      \"name\": \"Returns\",\n      \"weight\": 0.5,\n      \"metrics\": [\n        { \"id\": \"roe\", \"name\": \"ROE\", \"weight\": 0.5 },\n        { \"id\": \"roic\", \"name\": \"ROIC\", \"weight\": 0.5 }\n      ]\n    },\n    {\n      \"id\": \"profitability\",\n      \"name\": \"Profitability\",\n      \"weight\": 0.5,\n      \"metrics\": [\n        { \"id\": \"profit_margin\", \"name\": \"Profit Margin\", \"weight\": 1.0 }\n      ]\n    }\n  ],\n  \"scoringUniverseFilters\": { \"countries\": [\"Italy\"], \"min_market_cap\": 1 },\n  \"filters\": { \"min_score\": 1.2 }\n}\n```\n\nScoring parameters use camelCase: `winsorizePercentile` (1-10), `missingDataPercentile`\n(0.1-0.5), `normalizeGroupZScores` and `includeDuplicatesInScoring` (booleans).\n`scoringUniverseFilters` defines the peer group the scores are computed against; the nested\n`filters` block holds saved-screen state. Market caps are in **billions USD** inside both\nblocks (the tool arguments take USD). Loose inputs — snake_case keys, the legacy\n`winsorize`/`zScore` flags, or filter keys placed at the top level — are normalized to this\nshape automatically, but emitting it directly is preferred. Use `filters_list` to discover valid filter values, and build a config\ninteractively in the dashboard if you want a starting point to copy.\n\n### Two stages: score against, then filter\n\nQuality scores are **relative** — every company is winsorized and z-scored against a\npopulation — so *who is in the peer group* and *which rows you look at* are different\nquestions, and `score_compute` takes them separately.\n\n| Stage | Where | Effect |\n|---|---|---|\n| **1. Scoring universe** | `scoring_universe` argument, or `config.scoringUniverseFilters` | applied **before** winsorize/z-score — **changes every score** |\n| **2. Result filters** | the `sectors` / `countries` / … arguments | applied **after** scoring — **never changes a score** |\n\n\"Best European tech judged against European tech\" and \"best European tech judged against the\nworld\" are different lists, not the same list rescaled — narrowing the universe moves each\nmetric's bounds, mean and σ by different amounts, so companies genuinely reorder:\n\n```jsonc\n// judged against European tech — the peer group is European tech\nscore_compute(config, scoring_universe={\"sectors\": [\"Technology\"], \"regions\": [\"Europe\"]})\n\n// judged against the world — the peer group is everyone, then Europe is shown\nscore_compute(config, sectors=[\"Technology\"], regions=[\"Europe\"])\n```\n\nStage 1 accepts `sectors`, `industries`, `regions`, `countries`, `currencies`, `exchanges`,\n`min_market_cap_usd` and `max_market_cap_usd`. It rejects `min_score`, `max_score`, `ticker`\nand `tickers` with an error rather than ignoring them: the first two filter on the very\nscores being computed, the rest select rows.\n\nEvery response carries a `scoring_universe` field naming the peer group and its size. Scores\ncomputed against different peer groups are not comparable — do not mix them in one table.\n\nTwo edges worth knowing:\n\n- **`min_market_cap_usd` as a stage-2 argument also floors the scoring population.** This is\n  long-standing backend behaviour, kept for compatibility. Set `min_market_cap_usd` inside\n  `scoring_universe` to control the peer group explicitly; it overrides the stage-2 floor.\n  `max_market_cap_usd` filters rows only unless you set it in `scoring_universe`.\n- **A very small universe still scores.** There is no minimum-population guard yet, so\n  winsorizing at the 5th/95th percentile of a dozen companies returns confident nonsense.\n\n### What the nested `filters` block does\n\nIt is **saved-screen state**. `screen_share` and `systems_create`/`systems_update` persist\nit so a shared screen or saved scoring system restores its filter selections when reopened\nin the dashboard.\n\nIt does not define the peer group — `scoringUniverseFilters` does. Passing a saved config to\n`score_compute` applies its `filters` block as **stage-2** filters (an explicit argument\nwins), matching what the dashboard does, so re-scoring a saved system keeps its view.\n\n---\n\n## Connecting an MCP client\n\n### Remote (recommended)\n\nAny `streamable-http` MCP client works. No token needed — OAuth handles login:\n\n```json\n{\n  \"mcpServers\": {\n    \"qscreener\": {\n      \"type\": \"streamable-http\",\n      \"url\": \"https://mcp.qualityscreener.io/mcp\"\n    }\n  }\n}\n```\n\nIf your client cannot perform the OAuth flow, send a minted token directly:\n\n```json\n{\n  \"mcpServers\": {\n    \"qscreener\": {\n      \"url\": \"https://mcp.qualityscreener.io/mcp\",\n      \"headers\": { \"X-Stobot-CLI-Token\": \"<your token>\" }\n    }\n  }\n}\n```\n\n### Local (stdio)\n\n```json\n{\n  \"mcpServers\": {\n    \"qscreener\": {\n      \"command\": \"uv\",\n      \"args\": [\"run\", \"--directory\", \"/path/to/quality-screener-mcp-server\", \"qscreener-mcp\"],\n      \"env\": { \"QSCREENER_API_URL\": \"https://your-backend.example.com\" }\n    }\n  }\n}\n```\n\n---\n\n## Deployment\n\nThe server deploys as a single container. On [Railway](https://railway.app):\n\n1. **New service → Deploy from repo**, pointing at this repository. The\n   Dockerfile is self-contained, so the build context is the repo root.\n2. Set environment variables:\n   - `QSCREENER_MCP_TRANSPORT=streamable-http`\n   - `QSCREENER_API_URL=https://<your-backend-domain>`\n   - `QSCREENER_WEBSITE_URL=https://<your-frontend-domain>`\n   - `QSCREENER_MCP_PUBLIC_URL=https://<generated-mcp-domain>`\n\n   Railway injects `PORT` automatically; the server binds to it.\n3. **Networking → Generate Domain.** The MCP endpoint is\n   `https://<generated-domain>/mcp`.\n   - Leave the HTTP healthcheck path unset (or use a TCP check): `/mcp` answers\n     `406 Not Acceptable` to a plain `GET`, so an HTTP healthcheck expecting\n     `200` would mark the deploy unhealthy.\n4. **Connect** your MCP client — the OAuth flow triggers automatically on first\n   connection.\n\n---\n\n## Development\n\n```bash\nuv sync            # install dependencies (including dev)\nuv run pytest      # run the test suite\n```\n\nThe codebase is small and self-contained:\n\n| Path | Purpose |\n| --- | --- |\n| `qscreener_mcp/server.py` | FastMCP server, tool definitions, transport entry point |\n| `qscreener_mcp/client.py` | Minimal httpx client that attaches the bearer token |\n| `qscreener_mcp/oauth.py` | MCP OAuth 2.0 provider (token validation, browser flow) |\n| `tests/` | pytest suite (token resolution, filter forwarding, share-link building) |\n\n---\n\n## Privacy Policy\n\nThe full privacy policy is published at **[PRIVACY.md](PRIVACY.md)**\n(<https://github.com/quality-screener/quality-screener-mcp-server/blob/main/PRIVACY.md>).\n\nIn short:\n\n- The only personal data retained is your **email address**, which identifies\n  your account. Username and organization are optional profile fields.\n- The MCP server is a **stateless proxy** — it holds no database and writes no\n  personal data to storage of its own. Each request is forwarded to the Quality\n  Screener API using *your* access token, never a shared account.\n- Your data is **not sold**, not used for advertising, and not used to train\n  machine-learning models.\n- Operational logs reference accounts by a pseudonymous user ID, not by email.\n- Account data is deleted when you delete your account.\n\nSee the policy for retention periods, third-party recipients, international\ntransfers, and your GDPR rights.\n\n---\n\n## Support\n\n| Channel | Use it for |\n| --- | --- |\n| **info@qualityscreener.io** | Support requests, security reports, privacy and data-subject requests |\n| [GitHub Issues](https://github.com/quality-screener/quality-screener-mcp-server/issues) | Bug reports and feature requests |\n\nThis README is the canonical documentation for the MCP server:\n<https://github.com/quality-screener/quality-screener-mcp-server>\n\nPlease report suspected security vulnerabilities privately by email rather than\nopening a public issue.\n\n---\n\n## License\n\n[MIT](LICENSE) © Quality Screener.\n",
  "bytes": 19234,
  "sha": "44b888a6a4413323f590fd9f0dac2c54a15e8eb9af3ec1d40da5e9a042727b43",
  "repo_slug": "quality-screener/quality-screener-mcp-server",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_quality_screener_qscreener_74729d56/readme"
}