SofaScore MCP
by TejasQ
README.md
# SofaScore ChatGPT App β½π
A **ChatGPT App** (OpenAI Apps SDK / MCP-UI) that answers football questions with
rich, interactive widgets β centered on the **2026 FIFA World Cup** (48 teams,
hosted by the USA, Canada & Mexico) while still handling general football.
It's an MCP server (Streamable HTTP) whose tools each render a React widget
inside ChatGPT via the `openai/outputTemplate` + `text/html+skybridge` pattern.
Data comes from SofaScore's public JSON API.

<p>
<img src="docs/match-detail.png" width="32%" alt="Match detail" />
<img src="docs/knockout-bracket.png" width="32%" alt="Knockout bracket" />
<img src="docs/standings-light.png" width="32%" alt="Standings (light theme)" />
</p>
## What it does
| Tool | What it answers | Widget |
| --- | --- | --- |
| `world_cup` | Anything about the World Cup β groups, knockout bracket, live/upcoming matches. Views: `overview` Β· `groups` Β· `knockout` Β· `matches`. | Tabbed World Cup hub with a knockout bracket |
| `list_matches` | "What football is on today?", live scores, a date's fixtures, one competition's matches. | Scoreboard grouped by tournament, live pulse |
| `get_match` | Full detail of one match: score, goal/card timeline, team stats, momentum. | Match page with momentum chart & stat bars |
| `get_standings` | A competition's league table (by name or SofaScore id). | Standings table (multi-group aware) |
| `search_football` | Find a team / player / competition / manager by name. | Result list that drills into the other tools |
Widgets are theme-aware (light/dark follow `window.openai.theme`), use SofaScore
crest/flag images with an initials fallback, and are interactive β match rows and
search results call back into other tools via `window.openai.callTool`.
## Architecture
```
src/
server.ts Express + StreamableHTTPServerTransport (POST /mcp, /health)
mcp.ts McpServer factory: server instructions + resources + tools
resources.ts registers each built widget as a ui:// text/html+skybridge resource
sofascore/ data layer: SofaScore client (browser headers, TTL cache,
optional proxy, server-side image fetch) + ESPN fallback
source, both behind one FootballApi interface (provider.ts,
fallback.ts, espn.ts); rawβstructuredContent mappers, WC logic
api/index.ts Vercel serverless entry (re-exports the Express app)
shared/shapes.ts structuredContent types shared by server + widgets
tools/ list_matches Β· get_match Β· get_standings Β· world_cup Β· search_football
widgets/ React sources (built per-widget to one self-contained HTML)
shared/ window.openai bridge + hooks, theme, reusable components
scripts/ build-widgets Β· smoke (wiring) Β· screenshots (visual)
test/fixtures/ demo SofaScore responses for offline runs
```
Each widget is built by `vite` + `vite-plugin-singlefile` into a single
`dist/widgets/<name>/index.html` (JS + CSS inlined), which is served verbatim as
the skybridge resource β no separate asset host needed.
### Images (why they route through this server)
Team/tournament/player crests can't be loaded straight from `api.sofascore.com`
inside the ChatGPT Apps sandbox: the iframe's CSP (`img-src`) blocks the host,
and SofaScore itself 403s the referer-less cross-origin request. So the server
exposes an **image proxy** and the widgets point at it instead:
```
GET /img/team/:id β https://api.sofascore.com/api/v1/team/:id/image
GET /img/tournament/:id β β¦/unique-tournament/:id/image
GET /img/player/:id β β¦/player/:id/image
GET /img/flag/:alpha2 β β¦/img/flags/:alpha2.png
```
Only these allow-listed shapes are proxied. `resources.ts` injects
`window.__SOFA_BASE__` (from `PUBLIC_BASE_URL`) into each widget's `<head>`, and
`widgets/shared/img.ts` builds `${__SOFA_BASE__}/img/...` URLs from it. If
`PUBLIC_BASE_URL` is unset (e.g. local MCP Inspector) the widgets fall back to
loading images directly from SofaScore.
## Run it
```bash
npm install
npm run dev # builds widgets, then starts the server on :3000 (POST /mcp)
```
Point an MCP client at `http://localhost:3000/mcp`, or run with `SOFA_MOCK=1` to
serve the bundled fixtures instead of the live API:
```bash
SOFA_MOCK=1 npm run dev
```
### Environment variables
| Variable | Purpose |
| ----------------- | --------------------------------------------------------------------------------------------------------- |
| `PORT` | HTTP port (default `3000`). |
| `SOFA_MOCK` | `1` serves the bundled fixtures instead of the live API β no network needed. |
| `PUBLIC_BASE_URL` | Public origin this server is reachable at (e.g. `https://sofa.example.com`). Needed for the image proxy so crests load inside the ChatGPT sandbox. On Vercel it defaults to the deployment URL. Unset elsewhere β widgets load images directly. |
| `SOFA_PROXY` | Outbound HTTP(S) forward proxy for **all** SofaScore fetches (data + images). Point at a residential / unblocked proxy to defeat the Varnish/IP block. Falls back to `HTTPS_PROXY`. |
| `ESPN_ONLY` | `1` skips SofaScore entirely and serves everything from the ESPN fallback. Handy on hosts where SofaScore is always blocked (avoids the per-request probe + cooldown). |
```bash
# Live, behind a proxy, with images proxied through this origin:
PUBLIC_BASE_URL=https://sofa.example.com \
SOFA_PROXY=http://user:pass@proxy-host:port \
npm run dev
```
### Add it to ChatGPT (Developer Mode)
1. Expose the server over HTTPS (e.g. `ngrok http 3000`).
2. In ChatGPT β **Settings β Connectors β Advanced β Developer mode**, add a new
connector pointing at `https://<your-tunnel>/mcp`.
3. Ask: *"Show me the World Cup group standings"* or *"What football is live right now?"* β
the matching widget renders inline.
### Deploy to Vercel
The repo is Vercel-ready: `api/index.ts` re-exports the Express app as a single
serverless function and `vercel.json` rewrites every path to it.
```bash
vercel deploy # first run links the project; prod: `vercel --prod`
```
- **Build** β `vercel.json`'s `buildCommand` runs `npm run build`, bundling the
five widget HTML files into `dist/widgets/`; `includeFiles` ships them with the
function (the resource loader also resolves them from `process.cwd()`).
- **Base URL** β `PUBLIC_BASE_URL` defaults to the deployment's `VERCEL_URL`, so
the image proxy works with no extra config. Set it explicitly for a custom
domain.
- **Data** β ESPN fallback means live data works immediately. To keep SofaScore
primary, add `SOFA_PROXY` in the Vercel project's Environment Variables; to
drop it, set `ESPN_ONLY=1`.
- **Connector URL** β point ChatGPT at `https://<deployment>/mcp`.
The function's `maxDuration` is 30s to cover ESPN's multi-league fan-out; the
landing page at `/` is served statically from `public/`.
## Verify
All verification runs offline (no live network needed):
```bash
npm run typecheck # server + widgets typecheck
npm run build:widgets # bundle the 5 self-contained widget HTML files
npm run verify # build + wiring smoke test + Playwright screenshots
```
- **`scripts/smoke.ts`** boots the server with fixtures, connects an in-process MCP
client, and asserts every tool's `openai/outputTemplate` resolves to a registered
`ui://` resource with non-empty `structuredContent`.
- **`scripts/screenshots.ts`** renders each built widget with the real server output
(Chromium, `window.openai` set exactly like the host) into `.artifacts/shots/`.
## Note on data & this environment
The primary source is the live SofaScore API (`api.sofascore.com`), used with
browser-like headers. Its edge (Varnish) returns **403 to datacenter IPs and
flagged TLS fingerprints regardless of headers** β so a cloud host, CI runner,
or this sandbox often can't reach it. This is an egress-IP block, not a missing
header.
**So the server falls back to ESPN's public soccer API automatically.** Every
data source sits behind one `FootballApi` interface; `FallbackApi` tries
SofaScore first (honouring `SOFA_PROXY`) and, on any 403 / timeout / empty
result, transparently serves the same request from ESPN β mapped into the exact
same shapes, so tools and widgets never know the difference. ESPN is reachable
from blocked networks, covers the **FIFA World Cup + the top-5 European leagues**,
and needs no API key. Result: **live data flows out of the box**, no proxy
required. Team crests come from `a.espncdn.com` via the same image proxy.
Ways to control the source:
1. **Default** β SofaScore β ESPN fallback. Just works anywhere.
2. **`ESPN_ONLY=1`** β skip SofaScore entirely (best on always-blocked hosts).
3. **`SOFA_PROXY=<url>`** β give SofaScore a residential / unblocked egress so it
stays primary (see below).
4. **`SOFA_MOCK=1`** β bundled fixtures, fully offline (dev / tests / demos).
The World Cup season resolves dynamically (not hardcoded).
### Getting a proxy endpoint
`SOFA_PROXY` takes a standard `http(s)://[user:pass@]host:port` URL. What you
need is an IP SofaScore hasn't blocked β in practice a **residential** or
**mobile** proxy (datacenter proxies are usually blocked too). Options:
- **Managed residential proxy providers** β Bright Data, Oxylabs, Smartproxy,
IPRoyal, Soax, etc. Sign up, create an endpoint, and they give you a
`http://user:pass@gateway:port` URL. Paste it into `SOFA_PROXY`. Cheapest to
start; billed per GB (crest images are tiny, JSON is tiny β usage is low).
- **Your own home/residential IP** β run the server on a machine at home, or
tunnel through one (e.g. a small box on a home connection running
[`ngrok`](https://ngrok.com) / [`tailscale`](https://tailscale.com) + a
forward proxy like [`tinyproxy`](https://tinyproxy.github.io/) or
[`squid`](http://www.squid-cache.org/)). Free if you already have the hardware.
- **A cheap VPS on a residential-friendly ASP** β some VPS/edge providers hand
out IP ranges SofaScore doesn't block; try a `curl` from the box first (below).
**Test any candidate before wiring it in:**
```bash
curl -x "http://user:pass@proxy-host:port" \
-H "User-Agent: Mozilla/5.0" -H "Referer: https://www.sofascore.com/" \
-o /dev/null -w "%{http_code}\n" \
https://api.sofascore.com/api/v1/sport/football/events/live
```
`200` β set that URL as `SOFA_PROXY`. `403` β that IP is blocked too; try another.
Not affiliated with SofaScore; uses their public endpoints for informational use.
This server cannot be deployed
Maintenance
ActivityStale
ResponsivenessNo issues