DEV Community

Cover image for Give Claude and ChatGPT Live Football Data: Building an MCP Server for a Football API
John Ellis
John Ellis

Posted on

Give Claude and ChatGPT Live Football Data: Building an MCP Server for a Football API

Ask Claude who's playing tonight and it will politely explain that it doesn't have live data. Ask ChatGPT how the odds moved before a match and you get a lecture about not being able to browse. Both are perfectly capable of reasoning about football — they just can't see the pitch.

The Model Context Protocol (MCP) fixes exactly this. An MCP server is a small program that exposes "tools" — functions with a name, a description and a JSON schema — and any MCP-capable assistant can discover and call them. Wrap a REST API in one and the assistant gains the ability to look things up before it answers.

This post does that for football. In the first half we install a ready-made server and connect it to Claude Code, Claude Desktop, Cursor and ChatGPT. In the second half we open it up and look at how it's built, so you can do the same for any API you care about.

The API underneath is 5DollarFootballAPI: fixtures, live scores, standings, corners, cards, odds and full odds movement history. Disclosure: I build that API, and I wrote the MCP server. Nothing below is specific to it — swap in your own client and the pattern is identical.

Part 1: two minutes to a football-aware assistant

You need Node 18+ and an API key. The free tier (no card) covers fixtures, live scores and standings, which is enough for most of the prompts below.

Claude Code

claude mcp add football-api -e FIVEDOLLARFOOTBALL_API_KEY=fb_live_your_key -- npx -y football-api-mcp
Enter fullscreen mode Exit fullscreen mode

Claude Desktop and Cursor

Both read the same JSON shape. Claude Desktop: claude_desktop_config.json. Cursor: .cursor/mcp.json in the project, or ~/.cursor/mcp.json globally.

{
  "mcpServers": {
    "football-api": {
      "command": "npx",
      "args": ["-y", "football-api-mcp"],
      "env": { "FIVEDOLLARFOOTBALL_API_KEY": "fb_live_your_key" }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Restart the app and you should see eleven new tools listed under the server.

ChatGPT — the remote-only exception

ChatGPT supports MCP too, but with two constraints worth knowing before you spend time on it:

  1. It only connects to remote servers over HTTPS (Streamable HTTP). A local npx command is invisible to it.
  2. You need a Plus, Pro or Business plan and have to enable Developer mode — it lives under Settings → Security and login, flagged "elevated risk", which is fair: an unreviewed MCP server sees whatever the model sends it.

So the server has an --http mode, and you put a tunnel in front of it:

# terminal 1 — serve MCP over HTTP on localhost:3333
FIVEDOLLARFOOTBALL_API_KEY=fb_live_your_key npx -y football-api-mcp --http --token pick-a-secret

# terminal 2 — expose it (cloudflared shown; ngrok works the same way)
cloudflared tunnel --url http://localhost:3333
Enter fullscreen mode Exit fullscreen mode

cloudflared prints a https://something.trycloudflare.com URL. In ChatGPT open Plugins in the sidebar, hit the + (Create app), choose Server URL, paste https://something.trycloudflare.com/mcp/pick-a-secret, set Authentication to No Auth, tick the acknowledgement, Create, then Connect. ChatGPT fetches the tool list immediately, so a typo in the URL fails right there rather than mid-conversation.

That --token is not decoration. ChatGPT can't attach custom headers to its requests, so a secret in the URL path is the only thing standing between "anyone who guesses the tunnel URL" and your API key. Pick something long.

Part 2: what it feels like to use

A few prompts that work on the free tier:

"What Premier League matches are on today, and what are the scores?"

The assistant calls get_fixtures (today, UTC), notices it needs a league id, calls search_leagues with "Premier League", then filters. You get a readable list with live scores, corner counts and cards.

"Show me the corner standings for the Premier League this season."

get_standings with type: "corner" — a table ranked by corners rather than points. This is the sort of question that's awkward on most football APIs and trivial here, because the data model has corner tables as a first-class thing.

"How did the 1x2 odds move for Arsenal vs Chelsea before kickoff?"

get_team_fixtures to find the fixture id, then get_odds_history with market: "1x2". Every recorded price tick comes back with a timestamp, and the assistant summarises the drift — which is a nicer experience than the Python loop in my previous post. Note that current odds need the $5 plan and the tick history the $25 one; the tool returns a clear insufficient_plan error otherwise, and the assistant explains it instead of hallucinating numbers.

What makes this work well is not the model — it's that each tool has a boring, precise description and a schema with every parameter documented. More on that below.

Part 3: how the server is built

The whole thing is under 300 lines of JavaScript on top of the official SDK. Source: github.com/5dollarfootball-api/football-api-mcp.

npm install @modelcontextprotocol/sdk fivedollarfootball zod
Enter fullscreen mode Exit fullscreen mode

1. A server and one tool

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';

export function buildServer(footballClient, { version = '0.0.0' } = {}) {
  const server = new McpServer({ name: 'football-api-mcp', version });

  server.registerTool('get_standings', {
    description: 'League table for a league and season. type "corner" and "card" return corner/card standings instead of points.',
    inputSchema: {
      league_id: z.number().int().describe('League id'),
      season: z.string().optional().describe('Season, e.g. "2026" or "26/27" (default: current)'),
      type: z.enum(['total', 'corner', 'card']).optional().describe('Table type (default total)'),
    },
  }, async ({ league_id, season, type }) => {
    const table = await footballClient.standings(league_id, { season, type });
    return { content: [{ type: 'text', text: JSON.stringify(table, null, 2) }] };
  });

  return server;
}
Enter fullscreen mode Exit fullscreen mode

Three things to notice:

  • The client is injected, not constructed inside. That's what lets the test suite run an in-memory MCP client against a stubbed football client with no network.
  • The description is written for a model, not a human. "type 'corner' and 'card' return corner standings" is the sentence that makes the assistant reach for this tool when someone asks about corners. Vague descriptions produce wrong tool choices far more often than weak models do.
  • Every parameter has .describe(). The zod schema becomes the JSON schema the assistant sees. An undocumented optional parameter is one the model will either ignore or guess at.

2. Errors the model can act on

Ten tools in a row wrapping try/catch gets old, so there's one helper:

const run = (handler) => async (args) => {
  try {
    const payload = await handler(args ?? {});
    return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
  } catch (err) {
    const bits = [err.message];
    if (err.code) bits.push(`code=${err.code}`);
    if (err.status) bits.push(`http=${err.status}`);
    if (err.requestId) bits.push(`request_id=${err.requestId}`);
    return { isError: true, content: [{ type: 'text', text: bits.join(' | ') }] };
  }
};
Enter fullscreen mode Exit fullscreen mode

isError: true is the important bit. A thrown exception becomes a protocol error the assistant can't do much with; a tool result flagged as an error becomes text it reads and reasons about. That's why "this needs the $25 plan" turns into a helpful sentence rather than a crash, and why a request_id in the message means a user can paste it into a support email.

3. Transports: stdio for desktop apps, HTTP for everything else

The server object doesn't know how it's being talked to. The entry point picks a transport:

import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';

const server = buildServer(new Client(process.env.FIVEDOLLARFOOTBALL_API_KEY), { version });
await server.connect(new StdioServerTransport());
Enter fullscreen mode Exit fullscreen mode

That's the whole stdio story — Claude Desktop and Cursor spawn the process and speak JSON-RPC over stdin/stdout.

For HTTP the SDK ships StreamableHTTPServerTransport. The simplest correct shape is stateless: build a fresh transport and server per request and let the SDK deal with the protocol.

import { createServer } from 'node:http';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';

createServer(async (req, res) => {
  if (new URL(req.url, 'http://x').pathname !== '/mcp') { res.writeHead(404).end(); return; }
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
  await buildServer(footballClient, { version }).connect(transport);
  await transport.handleRequest(req, res);
}).listen(3333, '127.0.0.1');
Enter fullscreen mode Exit fullscreen mode

sessionIdGenerator: undefined is what makes it stateless. You'd want sessions if tools held per-conversation state; for a read-only API wrapper they're overhead. This is also why a one-line --token path gate is enough security for a personal tunnel: there's no session to hijack, only requests to forward.

4. Test it without a network

The SDK has an in-memory transport pair, which turns "does my MCP server work" into a normal unit test:

import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';

const server = buildServer(stubFootballClient, { version: 'test' });
const client = new Client({ name: 'test', version: '0' });
const [ct, st] = InMemoryTransport.createLinkedPair();
await Promise.all([server.connect(st), client.connect(ct)]);

const { tools } = await client.listTools();          // 11 tools
const r = await client.callTool({ name: 'get_fixtures', arguments: { date: '2026-08-22' } });
Enter fullscreen mode Exit fullscreen mode

The HTTP mode gets the same treatment with StreamableHTTPClientTransport pointed at a loopback port. A dozen tests, no API key, runs in under a second.

Small decisions that matter more than they look

  • Dates. get_fixtures takes YYYY-MM-DD and treats it as a UTC day. Anything else ("tonight", a locale-formatted date) is rejected with an error that states the expected format — and because it comes back as a tool result, the assistant simply retries with the right one.
  • Pagination belongs in the tool, not in the conversation. Results carry a pagination object and the description says so. Leave a model to infer paging from a raw array and it will sometimes loop, sometimes stop early, and occasionally loop forever.
  • Document the defaults. The odds tools take bookmaker slugs; the description says "(default bet365)". Without that line a model tends to call get_bookmakers first on every single odds question — a wasted round trip on each turn.
  • Read-only, and say so — in the protocol, not just the README. MCP tools carry annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint). I skipped them at first, and ChatGPT's plugin view promptly labelled every tool PUBLIC WRITE / DESTRUCTIVE — its default for anything unannotated. One shared object fixed it:
  const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
  server.registerTool('get_standings', { description: '...', annotations: READ_ONLY, inputSchema: { ... } }, handler);
Enter fullscreen mode Exit fullscreen mode

A test now asserts every registered tool carries it, so the next tool can't forget.

Wrapping up

If you wrap a different API the same way, I'd genuinely like to see it — the pattern above (inject the client, describe for the model, flag errors as results, keep HTTP stateless) has held up well and I'm curious where it breaks.

Questions or feedback? I'm happy to answer in the comments.

Top comments (0)