openfoot-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +86 -0
- package/package.json +39 -0
- package/scripts/smoke.js +43 -0
- package/src/index.js +298 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Robert Dumitriu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# openfoot-mcp
|
|
2
|
+
|
|
3
|
+
MCP server for the [OpenFootAPI](https://openfootapi.com/) football intelligence API. Gives an LLM client real football data — fixtures, standings, lineups, live events, **shot-level xG with pitch coordinates**, and model-derived fair odds — instead of a hallucinated scoreline.
|
|
4
|
+
|
|
5
|
+
12 tools, 1 prompt. Node ≥ 20, no build step.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx openfoot-mcp
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Set your API key in the environment. Free tier: 5,000 requests/month. Get a key at [openfootapi.com/pricing](https://openfootapi.com/pricing).
|
|
14
|
+
|
|
15
|
+
### Claude Desktop / Claude Code
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{
|
|
19
|
+
"mcpServers": {
|
|
20
|
+
"openfoot": {
|
|
21
|
+
"command": "npx",
|
|
22
|
+
"args": ["-y", "openfoot-mcp"],
|
|
23
|
+
"env": { "OPENFOOT_API_KEY": "of_live_..." }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Cursor / Windsurf / any stdio MCP client
|
|
30
|
+
|
|
31
|
+
Same block, in that client's MCP config file.
|
|
32
|
+
|
|
33
|
+
## Tools
|
|
34
|
+
|
|
35
|
+
| Tool | What it returns |
|
|
36
|
+
|---|---|
|
|
37
|
+
| `openfoot_competitions` | Supported competitions, season metadata, data source and licence per competition |
|
|
38
|
+
| `openfoot_search` | Free-text team/competition name → stable IDs |
|
|
39
|
+
| `openfoot_matches` | Fixtures and results, filtered by date / competition / team / status / season / round, cursor-paginated |
|
|
40
|
+
| `openfoot_standings` | Standings table for a competition and season |
|
|
41
|
+
| `openfoot_match_lineups` | Starting XI, bench, formation |
|
|
42
|
+
| `openfoot_match_events` | Goals, cards, substitutions, commentary timeline |
|
|
43
|
+
| `openfoot_match_xg` | One entry per shot: pitch coordinates + xG value |
|
|
44
|
+
| `openfoot_match_context` | Derived context — form, head-to-head, pre-computed signals |
|
|
45
|
+
| `openfoot_league_xg` | League xG table: xG for, xG against, over/under-performance vs actual goals |
|
|
46
|
+
| `openfoot_odds` | Bookmaker benchmark + implied fair probabilities. Informational, not betting advice |
|
|
47
|
+
| `openfoot_quota` | Remaining monthly quota — this call does not consume quota |
|
|
48
|
+
| `openfoot_health` | Reachability check. Works without an API key |
|
|
49
|
+
|
|
50
|
+
Prompt: `scout_team_form` — resolve a team, pull its last 5 matches, read the xG behind the results.
|
|
51
|
+
|
|
52
|
+
**Start with `openfoot_search`** to resolve IDs. Guessing IDs wastes quota: 404s and empty results are metered like any other request.
|
|
53
|
+
|
|
54
|
+
## Coverage, stated honestly
|
|
55
|
+
|
|
56
|
+
The catalogue lists 75 competitions. **Depth is not uniform, and the catalogue is wider than the deep coverage.**
|
|
57
|
+
|
|
58
|
+
- **Deepest:** Bundesliga, 2. Bundesliga, DFB Pokal, Superliga României
|
|
59
|
+
- **Expanded European:** Eredivisie, Primeira Liga, Süper Lig, Pro League, Scottish Premiership
|
|
60
|
+
- **Historical / analytics only:** Premier League, La Liga, Serie A, Ligue 1 (xG is Understat-derived)
|
|
61
|
+
|
|
62
|
+
Call `openfoot_competitions` and check your league before you build on it.
|
|
63
|
+
|
|
64
|
+
## Quota behaviour
|
|
65
|
+
|
|
66
|
+
- Free: 5,000 requests/month, 15 req/min. Developer $14/month: 250,000 requests/month, 100 req/min, includes xG, shot maps, lineups, live events and fair odds. Pro $39/month: 2,000,000/month, 250 req/min.
|
|
67
|
+
- **No overage billing.** When the quota is spent the API returns 429; this server surfaces that as a `quota_or_rate_limit` error rather than an empty result.
|
|
68
|
+
- Quota resets on the 1st of the month, UTC.
|
|
69
|
+
- Every request is metered, including 404s and empty results.
|
|
70
|
+
|
|
71
|
+
## When this is the wrong tool
|
|
72
|
+
|
|
73
|
+
- **High-frequency live polling across many competitions.** A monthly quota is the wrong shape for it — a per-day or per-second plan elsewhere will cost you less.
|
|
74
|
+
- **Leagues outside the deep-coverage list above.**
|
|
75
|
+
- **You need a contractual SLA, uptime credits or a named support contact.** Not offered at these prices.
|
|
76
|
+
|
|
77
|
+
## Development
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
npm install
|
|
81
|
+
npm run smoke # boots the server over stdio, lists tools, calls health
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`npm run smoke` works without an API key: `openfoot_health` returns live status, and a key-gated tool returns a readable `missing_api_key` error so you can tell "not configured" from "broken".
|
|
85
|
+
|
|
86
|
+
MIT.
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "openfoot-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Native Model Context Protocol (MCP) server for OpenFootAPI — football fixtures, standings, lineups, events timeline, shot-level xG and match context.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://openfootapi.com",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": {
|
|
9
|
+
"openfoot-mcp": "src/index.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src",
|
|
13
|
+
"scripts/smoke.js",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=20"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"mcp",
|
|
22
|
+
"modelcontextprotocol",
|
|
23
|
+
"football",
|
|
24
|
+
"soccer",
|
|
25
|
+
"xg",
|
|
26
|
+
"expected-goals",
|
|
27
|
+
"football-api",
|
|
28
|
+
"sports-data",
|
|
29
|
+
"openfoot"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"start": "node src/index.js",
|
|
33
|
+
"smoke": "node scripts/smoke.js"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
37
|
+
"zod": "^3.25.0"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/scripts/smoke.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Smoke test: boots the server over stdio, lists tools and prompts, and calls
|
|
4
|
+
* openfoot_health (the only tool that works without an API key).
|
|
5
|
+
* Run with: npm run smoke
|
|
6
|
+
*/
|
|
7
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
8
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { dirname, resolve } from "node:path";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
|
|
13
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const entry = resolve(here, "../src/index.js");
|
|
15
|
+
|
|
16
|
+
const transport = new StdioClientTransport({ command: process.execPath, args: [entry] });
|
|
17
|
+
const client = new Client({ name: "smoke", version: "0.0.0" });
|
|
18
|
+
await client.connect(transport);
|
|
19
|
+
|
|
20
|
+
const { tools } = await client.listTools();
|
|
21
|
+
console.log(`tools (${tools.length}):`);
|
|
22
|
+
for (const t of tools) console.log(` - ${t.name}`);
|
|
23
|
+
assert.equal(tools.length, 12, "Expected exactly 12 tools registered");
|
|
24
|
+
|
|
25
|
+
const { prompts } = await client.listPrompts();
|
|
26
|
+
console.log(`prompts (${prompts.length}): ${prompts.map((p) => p.name).join(", ")}`);
|
|
27
|
+
assert.equal(prompts.length, 1, "Expected exactly 1 prompt registered");
|
|
28
|
+
assert.equal(prompts[0].name, "scout_team_form");
|
|
29
|
+
|
|
30
|
+
const health = await client.callTool({ name: "openfoot_health", arguments: {} });
|
|
31
|
+
console.log("health ->", health.content?.[0]?.text?.slice(0, 300));
|
|
32
|
+
assert.equal(health.isError, undefined, "Health check should not be an error");
|
|
33
|
+
const healthData = JSON.parse(health.content[0].text);
|
|
34
|
+
assert.equal(healthData.data?.status, "operational", "Health status must be operational");
|
|
35
|
+
|
|
36
|
+
const gated = await client.callTool({ name: "openfoot_competitions", arguments: {} });
|
|
37
|
+
console.log("competitions ->", gated.content?.[0]?.text?.slice(0, 300));
|
|
38
|
+
assert.equal(gated.isError, true, "Gated call without key must return isError: true");
|
|
39
|
+
const gatedData = JSON.parse(gated.content[0].text);
|
|
40
|
+
assert.equal(gatedData.error, "missing_api_key");
|
|
41
|
+
|
|
42
|
+
await client.close();
|
|
43
|
+
console.log("\n✅ Smoke test passed all strict assertions.");
|
package/src/index.js
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* OpenFootAPI MCP server.
|
|
4
|
+
*
|
|
5
|
+
* Exposes the OpenFootAPI football intelligence API (fixtures, standings, lineups,
|
|
6
|
+
* live events, shot-level xG, fair odds) as MCP tools so an LLM client can
|
|
7
|
+
* query real football data instead of hallucinating.
|
|
8
|
+
*
|
|
9
|
+
* Auth: set OPENFOOT_API_KEY (keys use the of_live_ prefix).
|
|
10
|
+
* Free tier: 5,000 requests/month.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
14
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
|
|
17
|
+
const BASE_URL = process.env.OPENFOOT_BASE_URL ?? "https://openfootapi.com";
|
|
18
|
+
const API_KEY = process.env.OPENFOOT_API_KEY ?? "";
|
|
19
|
+
const VERSION = "0.1.0";
|
|
20
|
+
|
|
21
|
+
/** Endpoints that never need a key, so the server is useful before signup. */
|
|
22
|
+
const PUBLIC_PATHS = new Set(["/v1/health"]);
|
|
23
|
+
|
|
24
|
+
async function call(path, query = {}) {
|
|
25
|
+
const url = new URL(path, BASE_URL);
|
|
26
|
+
for (const [k, v] of Object.entries(query)) {
|
|
27
|
+
if (v !== undefined && v !== null && v !== "") url.searchParams.set(k, String(v));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!API_KEY && !PUBLIC_PATHS.has(path)) {
|
|
31
|
+
return {
|
|
32
|
+
error: "missing_api_key",
|
|
33
|
+
message:
|
|
34
|
+
"OPENFOOT_API_KEY is not set. Create a free key (5,000 requests/month) at https://openfootapi.com/pricing and put it in the server's env.",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const headers = { Accept: "application/json", "User-Agent": `openfoot-mcp/${VERSION}` };
|
|
39
|
+
if (API_KEY) headers.Authorization = `Bearer ${API_KEY}`;
|
|
40
|
+
|
|
41
|
+
let res;
|
|
42
|
+
try {
|
|
43
|
+
res = await fetch(url, { headers, signal: AbortSignal.timeout(20_000) });
|
|
44
|
+
} catch (err) {
|
|
45
|
+
return { error: "network_error", message: String(err?.message ?? err), url: url.toString() };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const text = await res.text();
|
|
49
|
+
let body;
|
|
50
|
+
try {
|
|
51
|
+
body = text ? JSON.parse(text) : null;
|
|
52
|
+
} catch {
|
|
53
|
+
body = { raw: text.slice(0, 2000) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (res.status === 401 || res.status === 403) {
|
|
57
|
+
return { error: "unauthorized", status: res.status, message: "API key missing, invalid or revoked.", body };
|
|
58
|
+
}
|
|
59
|
+
if (res.status === 429) {
|
|
60
|
+
return {
|
|
61
|
+
error: "quota_or_rate_limit",
|
|
62
|
+
status: 429,
|
|
63
|
+
message:
|
|
64
|
+
"Rate limit or monthly quota hit. There is no overage billing — the quota resets on the 1st of the month UTC. Check remaining quota with openfoot_quota.",
|
|
65
|
+
body,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (!res.ok) return { error: "http_error", status: res.status, body };
|
|
69
|
+
|
|
70
|
+
return body;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function jsonResult(data) {
|
|
74
|
+
const isErr = Boolean(data && typeof data === "object" && (data.error || data.status >= 400));
|
|
75
|
+
return {
|
|
76
|
+
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
77
|
+
...(isErr ? { isError: true } : {}),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Wrap a handler so a thrown error becomes a readable tool result, not a crash. */
|
|
82
|
+
function tool(fn) {
|
|
83
|
+
return async (args) => {
|
|
84
|
+
try {
|
|
85
|
+
return jsonResult(await fn(args ?? {}));
|
|
86
|
+
} catch (err) {
|
|
87
|
+
return { content: [{ type: "text", text: `Tool failed: ${String(err?.message ?? err)}` }], isError: true };
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const server = new McpServer({ name: "openfoot", version: VERSION });
|
|
93
|
+
|
|
94
|
+
/* ---------------------------------------------------------------- discovery */
|
|
95
|
+
|
|
96
|
+
server.registerTool(
|
|
97
|
+
"openfoot_competitions",
|
|
98
|
+
{
|
|
99
|
+
title: "List competitions",
|
|
100
|
+
description:
|
|
101
|
+
"List the competitions OpenFootAPI supports, with IDs, country codes, tiers and provider coverage. Use this first when you need a valid competition ID.",
|
|
102
|
+
inputSchema: {},
|
|
103
|
+
},
|
|
104
|
+
tool(() => call("/v1/competitions")),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
server.registerTool(
|
|
108
|
+
"openfoot_search",
|
|
109
|
+
{
|
|
110
|
+
title: "Search teams and competitions",
|
|
111
|
+
description: "Search for teams or competitions by fuzzy name to find their canonical OpenFootAPI IDs.",
|
|
112
|
+
inputSchema: {
|
|
113
|
+
q: z.string().min(1).describe("Search query, e.g. 'Arsenal', 'Real Madrid', 'Bundesliga'"),
|
|
114
|
+
type: z.enum(["team", "competition"]).optional().describe("Optional filter by entity type"),
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
tool(({ q, type }) => call("/v1/search", { q, type })),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
/* ------------------------------------------------------------------ matches */
|
|
121
|
+
|
|
122
|
+
server.registerTool(
|
|
123
|
+
"openfoot_matches",
|
|
124
|
+
{
|
|
125
|
+
title: "List matches",
|
|
126
|
+
description:
|
|
127
|
+
"List fixtures and results with scores, kickoff times, status and teams. Filter by competition, team, status, date or season.",
|
|
128
|
+
inputSchema: {
|
|
129
|
+
competition: z.string().optional().describe("Competition ID, e.g. 'comp_premier_league_eng', 'comp_bundesliga_de'"),
|
|
130
|
+
team: z.string().optional().describe("Team ID or fuzzy name"),
|
|
131
|
+
status: z.enum(["SCHEDULED", "LIVE", "IN_PLAY", "PAUSED", "FINISHED", "POSTPONED", "CANCELLED"]).optional(),
|
|
132
|
+
from: z.string().optional().describe("Start date in YYYY-MM-DD format"),
|
|
133
|
+
to: z.string().optional().describe("End date in YYYY-MM-DD format"),
|
|
134
|
+
season: z.string().optional().describe("Season in YYYY/YY format (e.g. '2025/26')"),
|
|
135
|
+
limit: z.number().int().min(1).max(100).optional().describe("Max matches to return (default 50, max 100)"),
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
tool(({ competition, team, status, from, to, season, limit }) =>
|
|
139
|
+
call("/v1/matches", { competition, team, status, from, to, season, limit }),
|
|
140
|
+
),
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
server.registerTool(
|
|
144
|
+
"openfoot_standings",
|
|
145
|
+
{
|
|
146
|
+
title: "Get standings",
|
|
147
|
+
description: "Get the league table for a competition — rank, points, played, won, drawn, lost, goals for/against, goal difference and recent form.",
|
|
148
|
+
inputSchema: {
|
|
149
|
+
competition: z.string().describe("Competition ID, e.g. 'comp_premier_league_eng', 'comp_bundesliga_de'"),
|
|
150
|
+
season: z.string().optional().describe("Season in YYYY/YY format (e.g. '2025/26')"),
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
tool(({ competition, season }) => call("/v1/standings", { competition, season })),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
/* ----------------------------------------------------------- match deep dives */
|
|
157
|
+
|
|
158
|
+
server.registerTool(
|
|
159
|
+
"openfoot_match_lineups",
|
|
160
|
+
{
|
|
161
|
+
title: "Get match lineups",
|
|
162
|
+
description: "Confirmed starting XI, benches and formations for a match.",
|
|
163
|
+
inputSchema: {
|
|
164
|
+
matchId: z.string().describe("Match ID returned by openfoot_matches"),
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
tool(({ matchId }) => call(`/v1/matches/${encodeURIComponent(matchId)}/lineups`)),
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
server.registerTool(
|
|
171
|
+
"openfoot_match_events",
|
|
172
|
+
{
|
|
173
|
+
title: "Get match events",
|
|
174
|
+
description: "Timeline of events in a match: goals, assists, yellow/red cards, substitutions, VAR checks, plus live minute-by-minute text commentary.",
|
|
175
|
+
inputSchema: {
|
|
176
|
+
matchId: z.string().describe("Match ID returned by openfoot_matches"),
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
tool(({ matchId }) => call(`/v1/matches/${encodeURIComponent(matchId)}/events`)),
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
server.registerTool(
|
|
183
|
+
"openfoot_match_xg",
|
|
184
|
+
{
|
|
185
|
+
title: "Get match shot map & xG",
|
|
186
|
+
description: "Shot-level Expected Goals data with coordinates on the pitch, shot type, situation (open play, penalty, corner), outcome and cumulative xG.",
|
|
187
|
+
inputSchema: {
|
|
188
|
+
matchId: z.string().describe("Match ID returned by openfoot_matches"),
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
tool(({ matchId }) => call(`/v1/matches/${encodeURIComponent(matchId)}/xg`)),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
server.registerTool(
|
|
195
|
+
"openfoot_match_context",
|
|
196
|
+
{
|
|
197
|
+
title: "Get match context dossier",
|
|
198
|
+
description:
|
|
199
|
+
"A complete pre-match or post-match dossier assembled for analysis: recent form, Elo delta, rest days, head-to-head record and model-derived fair match probabilities.",
|
|
200
|
+
inputSchema: {
|
|
201
|
+
matchId: z.string().describe("Match ID returned by openfoot_matches"),
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
tool(({ matchId }) => call(`/v1/matches/${encodeURIComponent(matchId)}/context`)),
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
/* ---------------------------------------------------------------- analytics */
|
|
208
|
+
|
|
209
|
+
server.registerTool(
|
|
210
|
+
"openfoot_league_xg",
|
|
211
|
+
{
|
|
212
|
+
title: "Get league xG analytics",
|
|
213
|
+
description:
|
|
214
|
+
"Season-level Expected Goals analytics for a league — teams ranked by xG generated, xG conceded, over/underperformance vs real goals, and shot efficiency.",
|
|
215
|
+
inputSchema: {
|
|
216
|
+
league: z.string().describe("League code, e.g. 'epl', 'la-liga', 'bundesliga', 'serie-a', 'ligue-1'"),
|
|
217
|
+
season: z.string().optional().describe("Season year (e.g. '2025' for 2025/26)"),
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
tool(({ league, season }) => call("/v1/analytics/xg", { league, season })),
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
server.registerTool(
|
|
224
|
+
"openfoot_odds",
|
|
225
|
+
{
|
|
226
|
+
title: "Get match odds",
|
|
227
|
+
description: "Fair 1X2 and over/under 2.5 probabilities derived from underlying team form, Elo delta and xG performance.",
|
|
228
|
+
inputSchema: {
|
|
229
|
+
matchId: z.string().optional().describe("Specific match ID"),
|
|
230
|
+
competition: z.string().optional().describe("Competition ID to list odds for"),
|
|
231
|
+
date: z.string().optional().describe("Date in YYYY-MM-DD format"),
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
tool(({ matchId, competition, date }) => call("/v1/odds", { matchId, competition, date })),
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
/* ----------------------------------------------------------------- account */
|
|
238
|
+
|
|
239
|
+
server.registerTool(
|
|
240
|
+
"openfoot_quota",
|
|
241
|
+
{
|
|
242
|
+
title: "Check API quota",
|
|
243
|
+
description: "Check your API key's tier, monthly request allowance, requests used this month and reset date.",
|
|
244
|
+
inputSchema: {},
|
|
245
|
+
},
|
|
246
|
+
tool(() => call("/v1/account/quota")),
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
server.registerTool(
|
|
250
|
+
"openfoot_health",
|
|
251
|
+
{
|
|
252
|
+
title: "Check upstream health",
|
|
253
|
+
description: "Check the operational status of OpenFootAPI without authentication.",
|
|
254
|
+
inputSchema: {},
|
|
255
|
+
},
|
|
256
|
+
tool(() => call("/v1/health")),
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
/* ------------------------------------------------------------------ prompt */
|
|
260
|
+
|
|
261
|
+
server.registerPrompt(
|
|
262
|
+
"scout_team_form",
|
|
263
|
+
{
|
|
264
|
+
title: "Scout a team's recent form",
|
|
265
|
+
description: "Build a dossier on a team: recent results, xG trend, upcoming fixtures and key strengths.",
|
|
266
|
+
arguments: [
|
|
267
|
+
{
|
|
268
|
+
name: "team",
|
|
269
|
+
description: "Team name, e.g. 'Arsenal' or 'Bayern Munich'",
|
|
270
|
+
required: true,
|
|
271
|
+
},
|
|
272
|
+
],
|
|
273
|
+
},
|
|
274
|
+
(args) => ({
|
|
275
|
+
messages: [
|
|
276
|
+
{
|
|
277
|
+
role: "user",
|
|
278
|
+
content: {
|
|
279
|
+
type: "text",
|
|
280
|
+
text: `Please scout ${args.team}. First use openfoot_search to find their team ID, then fetch their last 5 matches using openfoot_matches (with status=FINISHED) and their next fixture (with status=SCHEDULED). If xG is available, summarize their attacking efficiency and defensive stability.`,
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
],
|
|
284
|
+
}),
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
/* ----------------------------------------------------------------- startup */
|
|
288
|
+
|
|
289
|
+
async function main() {
|
|
290
|
+
const transport = new StdioServerTransport();
|
|
291
|
+
await server.connect(transport);
|
|
292
|
+
console.error(`[openfoot-mcp v${VERSION}] connected to stdio`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
main().catch((err) => {
|
|
296
|
+
console.error("Fatal error in openfoot-mcp:", err);
|
|
297
|
+
process.exit(1);
|
|
298
|
+
});
|