football-docs 0.5.1 → 0.6.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/README.md +5 -2
- package/data/docs.db +0 -0
- package/dist/index.d.ts +10 -6
- package/dist/index.js +83 -303
- package/dist/tools.d.ts +52 -0
- package/dist/tools.js +235 -0
- package/docs/fmdb-pro/api-access.md +120 -0
- package/docs/fmdb-pro/api-endpoints.md +123 -0
- package/docs/fmdb-pro/data-model.md +124 -0
- package/docs/fmdb-pro/identity-surfaces.md +123 -0
- package/docs/transferroom/api-access.md +91 -0
- package/docs/transferroom/api-endpoints.md +257 -0
- package/docs/transferroom/data-model.md +143 -0
- package/docs/transferroom/identity-surfaces.md +132 -0
- package/package.json +3 -14
- package/providers.json +22 -0
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Searchable football data provider and tooling documentation for AI coding agents
|
|
|
4
4
|
|
|
5
5
|
**Who it's for:** Developers and analysts who use AI coding tools (Claude Code, Cursor, VS Code Copilot) to work with football data. Works with any tool that supports MCP.
|
|
6
6
|
|
|
7
|
-
**What it does:** Gives your AI agent a searchable index of documentation for
|
|
7
|
+
**What it does:** Gives your AI agent a searchable index of documentation for 17 football data providers and tools — event types, qualifier IDs, coordinate systems, API endpoints, data models, identity surfaces, and cross-provider comparisons for the data providers (StatsBomb, Opta, Wyscout, Impect, SkillCorner, FMDB Pro, TransferRoom, and more), plus the open-source libraries people build with (kloppy, mplsoccer, socceraction, soccerdata, and more). Your agent looks up the real docs instead of guessing from training data.
|
|
8
8
|
|
|
9
9
|
**Why not just let the AI figure it out?** LLMs get football data specifics wrong constantly — Opta qualifier IDs, StatsBomb coordinate ranges, API endpoint URLs, library method signatures. These are mutable facts that change across versions. football-docs gives the agent verified, sourced documentation with provenance tracking so you know where every answer came from.
|
|
10
10
|
|
|
@@ -81,6 +81,7 @@ Add to `claude_desktop_config.json`:
|
|
|
81
81
|
| `list_providers` | List all indexed providers and their doc coverage. |
|
|
82
82
|
| `compare_providers` | Compare how different providers handle the same concept. |
|
|
83
83
|
| `request_update` | Request a new provider, flag outdated docs, or suggest a better doc source. Queued for maintainer review. |
|
|
84
|
+
| `resolve_entity` | Resolve players, teams, or coaches to cross-provider IDs via the Reep API. |
|
|
84
85
|
|
|
85
86
|
## Example queries
|
|
86
87
|
|
|
@@ -106,13 +107,15 @@ Add to `claude_desktop_config.json`:
|
|
|
106
107
|
| SkillCorner | 51 | api-access, api-endpoints, data-model, physical-data, coordinate-system, concepts, identity-surfaces |
|
|
107
108
|
| Free sources | 45 | overview, fbref, understat |
|
|
108
109
|
| soccerdata | 40 | overview, data-sources, usage |
|
|
110
|
+
| TransferRoom | 38 | api-access, api-endpoints, data-model, identity-surfaces |
|
|
109
111
|
| Opta | 36 | event-types, qualifiers, coordinate-system, api-access, identity-surfaces |
|
|
112
|
+
| FMDB Pro | 36 | api-access, api-endpoints, data-model, identity-surfaces |
|
|
110
113
|
| socceraction | 26 | SPADL format, VAEP, Expected Threat |
|
|
111
114
|
| FotMob | 7 | identity-surfaces |
|
|
112
115
|
| Soccerdonna | 7 | identity-surfaces |
|
|
113
116
|
| Transfermarkt | 7 | identity-surfaces |
|
|
114
117
|
|
|
115
|
-
**
|
|
118
|
+
**1,042 searchable chunks** across 17 providers and tools.
|
|
116
119
|
|
|
117
120
|
## Contributing
|
|
118
121
|
|
package/data/docs.db
CHANGED
|
Binary file
|
package/dist/index.d.ts
CHANGED
|
@@ -3,11 +3,15 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A Context7-style searchable index of football data provider documentation.
|
|
5
5
|
* Exposes tools for searching docs, listing providers, comparing providers,
|
|
6
|
-
*
|
|
6
|
+
* requesting documentation updates, and resolving football entities.
|
|
7
7
|
*
|
|
8
|
-
* Data is stored in a SQLite FTS5 index for fast offline search.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* Data is stored in a SQLite FTS5 index for fast offline search. Update
|
|
9
|
+
* requests are stored in a separate writable SQLite DB in a user-writable
|
|
10
|
+
* location (XDG_DATA_HOME or ~/.local/share).
|
|
11
11
|
*/
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
13
|
+
import Database from "better-sqlite3";
|
|
14
|
+
export { sanitiseFtsQuery } from "./tools.js";
|
|
15
|
+
export declare function openDb(): Database.Database;
|
|
16
|
+
export declare function openQueueDb(): Database.Database;
|
|
17
|
+
export declare function createFootballDocsServer(): McpServer;
|
package/dist/index.js
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A Context7-style searchable index of football data provider documentation.
|
|
5
5
|
* Exposes tools for searching docs, listing providers, comparing providers,
|
|
6
|
-
*
|
|
6
|
+
* requesting documentation updates, and resolving football entities.
|
|
7
7
|
*
|
|
8
|
-
* Data is stored in a SQLite FTS5 index for fast offline search.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* Data is stored in a SQLite FTS5 index for fast offline search. Update
|
|
9
|
+
* requests are stored in a separate writable SQLite DB in a user-writable
|
|
10
|
+
* location (XDG_DATA_HOME or ~/.local/share).
|
|
11
11
|
*/
|
|
12
12
|
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
13
13
|
import { homedir } from "node:os";
|
|
@@ -17,28 +17,27 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
17
17
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
18
18
|
import Database from "better-sqlite3";
|
|
19
19
|
import { z } from "zod";
|
|
20
|
+
import { compareProviders, listProviders, requestUpdate, resolveEntity, searchDocs, } from "./tools.js";
|
|
21
|
+
export { sanitiseFtsQuery } from "./tools.js";
|
|
20
22
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
21
23
|
const DB_PATH = resolve(__dirname, "..", "data", "docs.db");
|
|
22
24
|
const PKG_VERSION = JSON.parse(readFileSync(resolve(__dirname, "..", "package.json"), "utf-8")).version;
|
|
23
|
-
// Request queue DB goes in a user-writable location, not inside the npm package
|
|
24
25
|
const QUEUE_DB_DIR = resolve(process.env.XDG_DATA_HOME ?? resolve(homedir(), ".local", "share"), "football-docs");
|
|
25
26
|
const QUEUE_DB_PATH = resolve(QUEUE_DB_DIR, "requests.db");
|
|
26
|
-
|
|
27
|
-
function openDb() {
|
|
27
|
+
export function openDb() {
|
|
28
28
|
if (!existsSync(DB_PATH)) {
|
|
29
29
|
throw new Error(`Docs database not found at ${DB_PATH}. Run 'npm run ingest' first to build the index.`);
|
|
30
30
|
}
|
|
31
31
|
const db = new Database(DB_PATH, { readonly: true });
|
|
32
|
-
// Check schema version — source_type column was added in v0.2.0
|
|
33
32
|
const columns = db.pragma("table_info(docs)");
|
|
34
|
-
const hasProvenance = columns.some((
|
|
33
|
+
const hasProvenance = columns.some((column) => column.name === "source_type");
|
|
35
34
|
if (!hasProvenance) {
|
|
36
35
|
db.close();
|
|
37
|
-
throw new Error(
|
|
36
|
+
throw new Error("Docs database is outdated (missing provenance columns). Run 'npm run ingest' to rebuild.");
|
|
38
37
|
}
|
|
39
38
|
return db;
|
|
40
39
|
}
|
|
41
|
-
function openQueueDb() {
|
|
40
|
+
export function openQueueDb() {
|
|
42
41
|
mkdirSync(QUEUE_DB_DIR, { recursive: true });
|
|
43
42
|
const db = new Database(QUEUE_DB_PATH);
|
|
44
43
|
db.pragma("journal_mode = WAL");
|
|
@@ -55,309 +54,90 @@ function openQueueDb() {
|
|
|
55
54
|
`);
|
|
56
55
|
return db;
|
|
57
56
|
}
|
|
58
|
-
|
|
59
|
-
export function sanitiseFtsQuery(query) {
|
|
60
|
-
// FTS5 special syntax (AND, OR, NOT, NEAR, column:, *, ^) can cause errors.
|
|
61
|
-
// Wrap the user query in double quotes to treat it as a phrase search,
|
|
62
|
-
// escaping any embedded double quotes first.
|
|
63
|
-
return `"${query.replace(/"/g, '""')}"`;
|
|
64
|
-
}
|
|
65
|
-
// ── Server ──────────────────────────────────────────────────────────────
|
|
66
|
-
const server = new McpServer({
|
|
67
|
-
name: "nutmeg-football-docs",
|
|
68
|
-
version: PKG_VERSION,
|
|
69
|
-
});
|
|
70
|
-
// Tool: search_docs
|
|
71
|
-
server.tool("search_docs", "Search football data provider documentation. Use for finding event types, qualifier IDs, API endpoints, coordinate systems, data models, and cross-provider mappings. Returns the most relevant documentation chunks.", {
|
|
72
|
-
query: z.string().describe("Search query. Examples: 'Opta goal qualifier', 'StatsBomb shot event type', 'coordinate system differences', 'xG qualifier ID', 'SportMonks fixture endpoint'"),
|
|
73
|
-
provider: z
|
|
74
|
-
.string()
|
|
75
|
-
.optional()
|
|
76
|
-
.describe("Filter to a specific provider: opta, statsbomb, wyscout, sportmonks, fbref, understat, kloppy, or leave empty for all"),
|
|
77
|
-
max_results: z
|
|
78
|
-
.number()
|
|
79
|
-
.optional()
|
|
80
|
-
.default(10)
|
|
81
|
-
.describe("Maximum number of results to return (default 10)"),
|
|
82
|
-
}, { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async ({ query, provider, max_results }) => {
|
|
83
|
-
const db = openDb();
|
|
84
|
-
try {
|
|
85
|
-
const safeQuery = sanitiseFtsQuery(query);
|
|
86
|
-
let sql = `
|
|
87
|
-
SELECT d.provider, d.category, d.title, d.content,
|
|
88
|
-
d.source_type, d.source_url, d.upstream_version,
|
|
89
|
-
rank * -1 as relevance
|
|
90
|
-
FROM docs_fts
|
|
91
|
-
JOIN docs d ON d.id = docs_fts.rowid
|
|
92
|
-
WHERE docs_fts MATCH ?
|
|
93
|
-
`;
|
|
94
|
-
const params = [safeQuery];
|
|
95
|
-
if (provider) {
|
|
96
|
-
sql += ` AND d.provider = ?`;
|
|
97
|
-
params.push(provider.toLowerCase());
|
|
98
|
-
}
|
|
99
|
-
sql += ` ORDER BY rank LIMIT ?`;
|
|
100
|
-
params.push(max_results);
|
|
101
|
-
const rows = db.prepare(sql).all(...params);
|
|
102
|
-
if (rows.length === 0) {
|
|
103
|
-
return {
|
|
104
|
-
content: [
|
|
105
|
-
{
|
|
106
|
-
type: "text",
|
|
107
|
-
text: `No results found for "${query}"${provider ? ` in ${provider}` : ""}. Try broader terms or remove the provider filter.`,
|
|
108
|
-
},
|
|
109
|
-
],
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
const results = rows
|
|
113
|
-
.map((r, i) => {
|
|
114
|
-
const source = r.source_type === "curated"
|
|
115
|
-
? "**Source:** curated by football-docs contributors"
|
|
116
|
-
: `**Source:** ${r.source_type}${r.source_url ? ` (${r.source_url})` : ""}${r.upstream_version ? ` | v${r.upstream_version}` : ""}`;
|
|
117
|
-
return `## [${i + 1}] ${r.title}\n**Provider:** ${r.provider} | **Category:** ${r.category} | ${source}\n\n${r.content}`;
|
|
118
|
-
})
|
|
119
|
-
.join("\n\n---\n\n");
|
|
120
|
-
return {
|
|
121
|
-
content: [
|
|
122
|
-
{
|
|
123
|
-
type: "text",
|
|
124
|
-
text: `Found ${rows.length} result(s) for "${query}"${provider ? ` in ${provider}` : ""}:\n\n${results}`,
|
|
125
|
-
},
|
|
126
|
-
],
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
finally {
|
|
130
|
-
db.close();
|
|
131
|
-
}
|
|
132
|
-
});
|
|
133
|
-
// Tool: list_providers
|
|
134
|
-
server.tool("list_providers", "List all indexed football data providers, their document count, and coverage categories. Use to understand what documentation is available. Call this first to see what providers are indexed before searching.", {}, { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async () => {
|
|
57
|
+
function withDocsDb(handler) {
|
|
135
58
|
const db = openDb();
|
|
136
59
|
try {
|
|
137
|
-
|
|
138
|
-
.prepare(`SELECT provider, category, COUNT(*) as chunks
|
|
139
|
-
FROM docs
|
|
140
|
-
GROUP BY provider, category
|
|
141
|
-
ORDER BY provider, category`)
|
|
142
|
-
.all();
|
|
143
|
-
const byProvider = new Map();
|
|
144
|
-
for (const r of rows) {
|
|
145
|
-
const entry = byProvider.get(r.provider) ?? { categories: [], total: 0 };
|
|
146
|
-
entry.categories.push(`${r.category} (${r.chunks})`);
|
|
147
|
-
entry.total += r.chunks;
|
|
148
|
-
byProvider.set(r.provider, entry);
|
|
149
|
-
}
|
|
150
|
-
const lines = [...byProvider.entries()]
|
|
151
|
-
.map(([p, info]) => `**${p}** (${info.total} chunks): ${info.categories.join(", ")}`)
|
|
152
|
-
.join("\n");
|
|
153
|
-
return {
|
|
154
|
-
content: [
|
|
155
|
-
{
|
|
156
|
-
type: "text",
|
|
157
|
-
text: `Indexed providers:\n\n${lines}`,
|
|
158
|
-
},
|
|
159
|
-
],
|
|
160
|
-
};
|
|
60
|
+
return handler(db);
|
|
161
61
|
}
|
|
162
62
|
finally {
|
|
163
63
|
db.close();
|
|
164
64
|
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
topic: z.string().describe("The concept to compare across providers. Examples: 'shot events', 'coordinate systems', 'xG', 'pass types'"),
|
|
169
|
-
providers: z.array(z.string()).optional().describe("Providers to compare. If omitted, compares all indexed providers."),
|
|
170
|
-
}, { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async ({ topic, providers }) => {
|
|
171
|
-
const db = openDb();
|
|
65
|
+
}
|
|
66
|
+
function withQueueDb(handler) {
|
|
67
|
+
const db = openQueueDb();
|
|
172
68
|
try {
|
|
173
|
-
|
|
174
|
-
let sql = `
|
|
175
|
-
SELECT d.provider, d.category, d.title, d.content,
|
|
176
|
-
rank * -1 as relevance
|
|
177
|
-
FROM docs_fts
|
|
178
|
-
JOIN docs d ON d.id = docs_fts.rowid
|
|
179
|
-
WHERE docs_fts MATCH ?
|
|
180
|
-
`;
|
|
181
|
-
const params = [safeTopic];
|
|
182
|
-
if (providers && providers.length > 0) {
|
|
183
|
-
const placeholders = providers.map(() => "?").join(", ");
|
|
184
|
-
sql += ` AND d.provider IN (${placeholders})`;
|
|
185
|
-
params.push(...providers.map((p) => p.toLowerCase()));
|
|
186
|
-
}
|
|
187
|
-
sql += ` ORDER BY d.provider, rank LIMIT 30`;
|
|
188
|
-
const rows = db.prepare(sql).all(...params);
|
|
189
|
-
if (rows.length === 0) {
|
|
190
|
-
return {
|
|
191
|
-
content: [
|
|
192
|
-
{
|
|
193
|
-
type: "text",
|
|
194
|
-
text: `No documentation found for "${topic}". Try different terms.`,
|
|
195
|
-
},
|
|
196
|
-
],
|
|
197
|
-
};
|
|
198
|
-
}
|
|
199
|
-
// Group by provider
|
|
200
|
-
const grouped = new Map();
|
|
201
|
-
for (const r of rows) {
|
|
202
|
-
const entries = grouped.get(r.provider) ?? [];
|
|
203
|
-
entries.push(`### ${r.title}\n${r.content}`);
|
|
204
|
-
grouped.set(r.provider, entries);
|
|
205
|
-
}
|
|
206
|
-
const sections = [...grouped.entries()]
|
|
207
|
-
.map(([p, chunks]) => `## ${p}\n\n${chunks.slice(0, 3).join("\n\n")}`)
|
|
208
|
-
.join("\n\n---\n\n");
|
|
209
|
-
return {
|
|
210
|
-
content: [
|
|
211
|
-
{
|
|
212
|
-
type: "text",
|
|
213
|
-
text: `Comparison for "${topic}" across ${grouped.size} provider(s):\n\n${sections}`,
|
|
214
|
-
},
|
|
215
|
-
],
|
|
216
|
-
};
|
|
69
|
+
return handler(db);
|
|
217
70
|
}
|
|
218
71
|
finally {
|
|
219
72
|
db.close();
|
|
220
73
|
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
server
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
finally {
|
|
279
|
-
qdb.close();
|
|
280
|
-
}
|
|
281
|
-
});
|
|
282
|
-
// Tool: resolve_entity
|
|
283
|
-
const REEP_API = "https://reep-api.rahulkeerthi2-95d.workers.dev";
|
|
284
|
-
server.tool("resolve_entity", "Resolve a football entity (player, team, or coach) to get cross-provider IDs. Use when you need to map between Transfermarkt, FBref, Sofascore, Opta, and other provider IDs, or when you need to look up a player/team/coach by name.", {
|
|
285
|
-
name: z.string().optional().describe("Entity name to search for (e.g. 'Cole Palmer', 'Arsenal'). Fuzzy match on name and aliases."),
|
|
286
|
-
provider: z.string().optional().describe("Source provider for ID resolution (e.g. 'transfermarkt', 'fbref', 'sofascore', 'opta', 'soccerway')"),
|
|
287
|
-
id: z.string().optional().describe("ID from the source provider to resolve to all other IDs"),
|
|
288
|
-
qid: z.string().optional().describe("Wikidata QID for direct lookup (e.g. 'Q99760796')"),
|
|
289
|
-
type: z.enum(["player", "team", "coach"]).optional().describe("Filter results by entity type"),
|
|
290
|
-
}, { readOnlyHint: true, destructiveHint: false, openWorldHint: true }, async ({ name, provider, id, qid, type }) => {
|
|
291
|
-
let url;
|
|
292
|
-
if (qid) {
|
|
293
|
-
url = `${REEP_API}/lookup?qid=${encodeURIComponent(qid)}`;
|
|
294
|
-
}
|
|
295
|
-
else if (provider && id) {
|
|
296
|
-
url = `${REEP_API}/resolve?provider=${encodeURIComponent(provider)}&id=${encodeURIComponent(id)}`;
|
|
297
|
-
}
|
|
298
|
-
else if (name) {
|
|
299
|
-
url = `${REEP_API}/search?name=${encodeURIComponent(name)}&limit=10`;
|
|
300
|
-
if (type)
|
|
301
|
-
url += `&type=${encodeURIComponent(type)}`;
|
|
302
|
-
}
|
|
303
|
-
else {
|
|
304
|
-
return {
|
|
305
|
-
isError: true,
|
|
306
|
-
content: [{
|
|
307
|
-
type: "text",
|
|
308
|
-
text: "Provide at least one of: name, qid, or provider+id. Examples:\n- name: 'Cole Palmer'\n- provider: 'transfermarkt', id: '568177'\n- qid: 'Q99760796'",
|
|
309
|
-
}],
|
|
310
|
-
};
|
|
311
|
-
}
|
|
312
|
-
try {
|
|
313
|
-
const resp = await fetch(url);
|
|
314
|
-
if (!resp.ok) {
|
|
315
|
-
return {
|
|
316
|
-
isError: true,
|
|
317
|
-
content: [{ type: "text", text: `Reep API error: ${resp.status} ${resp.statusText}` }],
|
|
318
|
-
};
|
|
319
|
-
}
|
|
320
|
-
const data = await resp.json();
|
|
321
|
-
if (!data.results?.length) {
|
|
322
|
-
return {
|
|
323
|
-
content: [{ type: "text", text: "No entities found matching the query." }],
|
|
324
|
-
};
|
|
325
|
-
}
|
|
326
|
-
const formatted = data.results.map((e) => {
|
|
327
|
-
const ids = e.external_ids;
|
|
328
|
-
const idLines = ids
|
|
329
|
-
? Object.entries(ids).map(([k, v]) => ` ${k}: ${v}`).join("\n")
|
|
330
|
-
: " (none)";
|
|
331
|
-
const bio = [
|
|
332
|
-
e.date_of_birth && `DOB: ${e.date_of_birth}`,
|
|
333
|
-
e.nationality && `Nationality: ${e.nationality}`,
|
|
334
|
-
e.position && `Position: ${e.position}`,
|
|
335
|
-
e.height_cm && `Height: ${e.height_cm}cm`,
|
|
336
|
-
e.country && `Country: ${e.country}`,
|
|
337
|
-
e.stadium && `Stadium: ${e.stadium}`,
|
|
338
|
-
].filter(Boolean).join(" | ");
|
|
339
|
-
return `### ${e.name_en} (${e.type})\nWikidata: ${e.qid}${e.aliases_en ? `\nAliases: ${e.aliases_en}` : ""}${bio ? `\n${bio}` : ""}\nProvider IDs:\n${idLines}`;
|
|
340
|
-
}).join("\n\n");
|
|
341
|
-
return {
|
|
342
|
-
content: [{
|
|
343
|
-
type: "text",
|
|
344
|
-
text: `Found ${data.results.length} result(s):\n\n${formatted}`,
|
|
345
|
-
}],
|
|
346
|
-
};
|
|
347
|
-
}
|
|
348
|
-
catch (err) {
|
|
349
|
-
return {
|
|
350
|
-
isError: true,
|
|
351
|
-
content: [{ type: "text", text: `Failed to reach Reep API: ${err instanceof Error ? err.message : String(err)}` }],
|
|
352
|
-
};
|
|
353
|
-
}
|
|
354
|
-
});
|
|
355
|
-
// ── Start ───────────────────────────────────────────────────────────────
|
|
74
|
+
}
|
|
75
|
+
export function createFootballDocsServer() {
|
|
76
|
+
const server = new McpServer({
|
|
77
|
+
name: "nutmeg-football-docs",
|
|
78
|
+
version: PKG_VERSION,
|
|
79
|
+
});
|
|
80
|
+
server.tool("search_docs", "Search football data provider documentation. Use for finding event types, qualifier IDs, API endpoints, coordinate systems, data models, and cross-provider mappings. Returns the most relevant documentation chunks.", {
|
|
81
|
+
query: z.string().describe("Search query. Examples: 'Opta goal qualifier', 'StatsBomb shot event type', 'coordinate system differences', 'xG qualifier ID', 'SportMonks fixture endpoint'"),
|
|
82
|
+
provider: z
|
|
83
|
+
.string()
|
|
84
|
+
.optional()
|
|
85
|
+
.describe("Filter to a specific provider: opta, statsbomb, wyscout, sportmonks, fbref, understat, kloppy, or leave empty for all"),
|
|
86
|
+
max_results: z
|
|
87
|
+
.number()
|
|
88
|
+
.optional()
|
|
89
|
+
.default(10)
|
|
90
|
+
.describe("Maximum number of results to return (default 10)"),
|
|
91
|
+
}, { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async (args) => withDocsDb((db) => searchDocs(db, args)));
|
|
92
|
+
server.tool("list_providers", "List all indexed football data providers, their document count, and coverage categories. Use to understand what documentation is available. Call this first to see what providers are indexed before searching.", {}, { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async () => withDocsDb((db) => listProviders(db)));
|
|
93
|
+
server.tool("compare_providers", "Compare what two or more providers offer for a specific data type or concept. For example: 'How do Opta and StatsBomb represent shot events differently?'", {
|
|
94
|
+
topic: z.string().describe("The concept to compare across providers. Examples: 'shot events', 'coordinate systems', 'xG', 'pass types'"),
|
|
95
|
+
providers: z
|
|
96
|
+
.array(z.string())
|
|
97
|
+
.optional()
|
|
98
|
+
.describe("Providers to compare. If omitted, compares all indexed providers."),
|
|
99
|
+
}, { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async (args) => withDocsDb((db) => compareProviders(db, args)));
|
|
100
|
+
server.tool("request_update", "Request that a provider's documentation be added, updated, or recrawled. Use when you notice docs are outdated, a provider is missing, or you know of a better documentation source. Requests are queued for review.", {
|
|
101
|
+
type: z.enum(["new_provider", "recrawl", "flag_outdated", "suggest_source"]).describe("Type of request: new_provider (add a new tool/library), recrawl (refresh existing docs), flag_outdated (mark docs as stale), suggest_source (recommend a better doc source like llms.txt)"),
|
|
102
|
+
provider: z
|
|
103
|
+
.string()
|
|
104
|
+
.max(100)
|
|
105
|
+
.describe("Provider name (existing or proposed). Examples: 'statsbomb', 'mplsoccer', 'floodlight'"),
|
|
106
|
+
reason: z
|
|
107
|
+
.string()
|
|
108
|
+
.max(2000)
|
|
109
|
+
.describe("Why this update is needed. Be specific: version bump, missing event types, new API endpoints, etc."),
|
|
110
|
+
suggested_urls: z
|
|
111
|
+
.array(z.string().url().max(500))
|
|
112
|
+
.max(10)
|
|
113
|
+
.optional()
|
|
114
|
+
.describe("URLs for documentation sources (readthedocs, GitHub, llms.txt, etc.)"),
|
|
115
|
+
}, { readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async (args) => withQueueDb((db) => requestUpdate(db, args)));
|
|
116
|
+
server.tool("resolve_entity", "Resolve a football entity (player, team, or coach) to get cross-provider IDs. Use when you need to map between Transfermarkt, FBref, Sofascore, Opta, and other provider IDs, or when you need to look up a player/team/coach by name.", {
|
|
117
|
+
name: z
|
|
118
|
+
.string()
|
|
119
|
+
.optional()
|
|
120
|
+
.describe("Entity name to search for (e.g. 'Cole Palmer', 'Arsenal'). Fuzzy match on name and aliases."),
|
|
121
|
+
provider: z
|
|
122
|
+
.string()
|
|
123
|
+
.optional()
|
|
124
|
+
.describe("Source provider for ID resolution (e.g. 'transfermarkt', 'fbref', 'sofascore', 'opta', 'soccerway')"),
|
|
125
|
+
id: z.string().optional().describe("ID from the source provider to resolve to all other IDs"),
|
|
126
|
+
qid: z.string().optional().describe("Wikidata QID for direct lookup (e.g. 'Q99760796')"),
|
|
127
|
+
type: z.enum(["player", "team", "coach"]).optional().describe("Filter results by entity type"),
|
|
128
|
+
}, { readOnlyHint: true, destructiveHint: false, openWorldHint: true }, async (args) => resolveEntity(args));
|
|
129
|
+
return server;
|
|
130
|
+
}
|
|
356
131
|
async function main() {
|
|
357
132
|
const transport = new StdioServerTransport();
|
|
358
|
-
await
|
|
133
|
+
await createFootballDocsServer().connect(transport);
|
|
134
|
+
}
|
|
135
|
+
const isDirectRun = process.argv[1]
|
|
136
|
+
? resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
137
|
+
: false;
|
|
138
|
+
if (isDirectRun) {
|
|
139
|
+
main().catch((error) => {
|
|
140
|
+
console.error("Failed to start nutmeg docs server:", error);
|
|
141
|
+
process.exit(1);
|
|
142
|
+
});
|
|
359
143
|
}
|
|
360
|
-
main().catch((err) => {
|
|
361
|
-
console.error("Failed to start nutmeg docs server:", err);
|
|
362
|
-
process.exit(1);
|
|
363
|
-
});
|
package/dist/tools.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type Database from "better-sqlite3";
|
|
2
|
+
export declare const TOOL_NAMES: readonly ["search_docs", "list_providers", "compare_providers", "request_update", "resolve_entity"];
|
|
3
|
+
type TextContent = {
|
|
4
|
+
type: "text";
|
|
5
|
+
text: string;
|
|
6
|
+
};
|
|
7
|
+
export type ToolResponse = {
|
|
8
|
+
isError?: boolean;
|
|
9
|
+
content: TextContent[];
|
|
10
|
+
};
|
|
11
|
+
export type SearchDocsArgs = {
|
|
12
|
+
query: string;
|
|
13
|
+
provider?: string;
|
|
14
|
+
max_results?: number;
|
|
15
|
+
};
|
|
16
|
+
export type CompareProvidersArgs = {
|
|
17
|
+
topic: string;
|
|
18
|
+
providers?: string[];
|
|
19
|
+
};
|
|
20
|
+
export type RequestUpdateArgs = {
|
|
21
|
+
type: "new_provider" | "recrawl" | "flag_outdated" | "suggest_source";
|
|
22
|
+
provider: string;
|
|
23
|
+
reason: string;
|
|
24
|
+
suggested_urls?: string[];
|
|
25
|
+
};
|
|
26
|
+
export type ResolveEntityArgs = {
|
|
27
|
+
name?: string;
|
|
28
|
+
provider?: string;
|
|
29
|
+
id?: string;
|
|
30
|
+
qid?: string;
|
|
31
|
+
type?: "player" | "team" | "coach";
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Build a safe FTS5 MATCH query from user text.
|
|
35
|
+
*
|
|
36
|
+
* User questions are usually natural language, not exact doc phrases. Joining
|
|
37
|
+
* searchable tokens with AND keeps results precise while still allowing queries
|
|
38
|
+
* like "Opta qualifier 76" to match docs that contain those tokens separately.
|
|
39
|
+
*/
|
|
40
|
+
export declare function sanitiseFtsQuery(query: string): string;
|
|
41
|
+
export declare function searchDocs(db: Database.Database, args: SearchDocsArgs): ToolResponse;
|
|
42
|
+
export declare function listProviders(db: Database.Database): ToolResponse;
|
|
43
|
+
export declare function compareProviders(db: Database.Database, args: CompareProvidersArgs): ToolResponse;
|
|
44
|
+
export declare function requestUpdate(db: Database.Database, args: RequestUpdateArgs, options?: {
|
|
45
|
+
now?: Date;
|
|
46
|
+
requestId?: string;
|
|
47
|
+
}): ToolResponse;
|
|
48
|
+
export declare function resolveEntity(args: ResolveEntityArgs, options?: {
|
|
49
|
+
baseUrl?: string;
|
|
50
|
+
fetchImpl?: typeof fetch;
|
|
51
|
+
}): Promise<ToolResponse>;
|
|
52
|
+
export {};
|