football-docs 0.5.2 → 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 +1 -0
- 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/package.json +3 -14
package/README.md
CHANGED
|
@@ -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
|
|
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 {};
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
export const TOOL_NAMES = [
|
|
2
|
+
"search_docs",
|
|
3
|
+
"list_providers",
|
|
4
|
+
"compare_providers",
|
|
5
|
+
"request_update",
|
|
6
|
+
"resolve_entity",
|
|
7
|
+
];
|
|
8
|
+
const QUERY_STOP_WORDS = new Set(["and", "or", "not", "near"]);
|
|
9
|
+
function textResult(text, isError = false) {
|
|
10
|
+
return {
|
|
11
|
+
isError: isError || undefined,
|
|
12
|
+
content: [{ type: "text", text }],
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function extractFtsTokens(query) {
|
|
16
|
+
const rawTokens = query.match(/[\p{L}\p{N}_]+/gu) ?? [];
|
|
17
|
+
const tokens = rawTokens
|
|
18
|
+
.map((token) => token.trim().toLowerCase())
|
|
19
|
+
.filter((token) => token.length > 0 && !QUERY_STOP_WORDS.has(token));
|
|
20
|
+
return [...new Set(tokens)];
|
|
21
|
+
}
|
|
22
|
+
function quoteFtsToken(token) {
|
|
23
|
+
return `"${token.replace(/"/g, '""')}"`;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Build a safe FTS5 MATCH query from user text.
|
|
27
|
+
*
|
|
28
|
+
* User questions are usually natural language, not exact doc phrases. Joining
|
|
29
|
+
* searchable tokens with AND keeps results precise while still allowing queries
|
|
30
|
+
* like "Opta qualifier 76" to match docs that contain those tokens separately.
|
|
31
|
+
*/
|
|
32
|
+
export function sanitiseFtsQuery(query) {
|
|
33
|
+
const tokens = extractFtsTokens(query);
|
|
34
|
+
if (tokens.length === 0)
|
|
35
|
+
return '""';
|
|
36
|
+
return tokens.map(quoteFtsToken).join(" AND ");
|
|
37
|
+
}
|
|
38
|
+
function relaxedFtsQuery(query) {
|
|
39
|
+
const tokens = extractFtsTokens(query);
|
|
40
|
+
if (tokens.length === 0)
|
|
41
|
+
return '""';
|
|
42
|
+
return tokens.map(quoteFtsToken).join(" OR ");
|
|
43
|
+
}
|
|
44
|
+
function normaliseProvider(provider) {
|
|
45
|
+
return provider.trim().toLowerCase();
|
|
46
|
+
}
|
|
47
|
+
function normaliseLimit(value, defaultValue, maxValue) {
|
|
48
|
+
if (value === undefined || !Number.isFinite(value))
|
|
49
|
+
return defaultValue;
|
|
50
|
+
return Math.min(Math.max(Math.trunc(value), 1), maxValue);
|
|
51
|
+
}
|
|
52
|
+
function searchRows(db, matchQuery, provider, limit) {
|
|
53
|
+
let sql = `
|
|
54
|
+
SELECT d.provider, d.category, d.title, d.content,
|
|
55
|
+
d.source_type, d.source_url, d.upstream_version,
|
|
56
|
+
rank * -1 as relevance
|
|
57
|
+
FROM docs_fts
|
|
58
|
+
JOIN docs d ON d.id = docs_fts.rowid
|
|
59
|
+
WHERE docs_fts MATCH ?
|
|
60
|
+
`;
|
|
61
|
+
const params = [matchQuery];
|
|
62
|
+
if (provider) {
|
|
63
|
+
sql += " AND d.provider = ?";
|
|
64
|
+
params.push(normaliseProvider(provider));
|
|
65
|
+
}
|
|
66
|
+
sql += " ORDER BY rank LIMIT ?";
|
|
67
|
+
params.push(limit);
|
|
68
|
+
return db.prepare(sql).all(...params);
|
|
69
|
+
}
|
|
70
|
+
function compareRows(db, matchQuery, providers) {
|
|
71
|
+
let sql = `
|
|
72
|
+
SELECT d.provider, d.category, d.title, d.content,
|
|
73
|
+
rank * -1 as relevance
|
|
74
|
+
FROM docs_fts
|
|
75
|
+
JOIN docs d ON d.id = docs_fts.rowid
|
|
76
|
+
WHERE docs_fts MATCH ?
|
|
77
|
+
`;
|
|
78
|
+
const params = [matchQuery];
|
|
79
|
+
if (providers && providers.length > 0) {
|
|
80
|
+
const placeholders = providers.map(() => "?").join(", ");
|
|
81
|
+
sql += ` AND d.provider IN (${placeholders})`;
|
|
82
|
+
params.push(...providers.map(normaliseProvider));
|
|
83
|
+
}
|
|
84
|
+
sql += " ORDER BY d.provider, rank LIMIT 30";
|
|
85
|
+
return db.prepare(sql).all(...params);
|
|
86
|
+
}
|
|
87
|
+
function sourceLabel(row) {
|
|
88
|
+
if (row.source_type === "curated") {
|
|
89
|
+
return "**Source:** curated by football-docs contributors";
|
|
90
|
+
}
|
|
91
|
+
return `**Source:** ${row.source_type}${row.source_url ? ` (${row.source_url})` : ""}${row.upstream_version ? ` | v${row.upstream_version}` : ""}`;
|
|
92
|
+
}
|
|
93
|
+
export function searchDocs(db, args) {
|
|
94
|
+
const limit = normaliseLimit(args.max_results, 10, 50);
|
|
95
|
+
const strictQuery = sanitiseFtsQuery(args.query);
|
|
96
|
+
const fallbackQuery = relaxedFtsQuery(args.query);
|
|
97
|
+
let rows = searchRows(db, strictQuery, args.provider, limit);
|
|
98
|
+
if (rows.length === 0 && fallbackQuery !== strictQuery) {
|
|
99
|
+
rows = searchRows(db, fallbackQuery, args.provider, limit);
|
|
100
|
+
}
|
|
101
|
+
if (rows.length === 0) {
|
|
102
|
+
return textResult(`No results found for "${args.query}"${args.provider ? ` in ${args.provider}` : ""}. Try broader terms or remove the provider filter.`);
|
|
103
|
+
}
|
|
104
|
+
const results = rows
|
|
105
|
+
.map((row, index) => {
|
|
106
|
+
return `## [${index + 1}] ${row.title}\n**Provider:** ${row.provider} | **Category:** ${row.category} | ${sourceLabel(row)}\n\n${row.content}`;
|
|
107
|
+
})
|
|
108
|
+
.join("\n\n---\n\n");
|
|
109
|
+
return textResult(`Found ${rows.length} result(s) for "${args.query}"${args.provider ? ` in ${args.provider}` : ""}:\n\n${results}`);
|
|
110
|
+
}
|
|
111
|
+
export function listProviders(db) {
|
|
112
|
+
const rows = db
|
|
113
|
+
.prepare(`SELECT provider, category, COUNT(*) as chunks
|
|
114
|
+
FROM docs
|
|
115
|
+
GROUP BY provider, category
|
|
116
|
+
ORDER BY provider, category`)
|
|
117
|
+
.all();
|
|
118
|
+
const byProvider = new Map();
|
|
119
|
+
for (const row of rows) {
|
|
120
|
+
const entry = byProvider.get(row.provider) ?? { categories: [], total: 0 };
|
|
121
|
+
entry.categories.push(`${row.category} (${row.chunks})`);
|
|
122
|
+
entry.total += row.chunks;
|
|
123
|
+
byProvider.set(row.provider, entry);
|
|
124
|
+
}
|
|
125
|
+
const lines = [...byProvider.entries()]
|
|
126
|
+
.map(([provider, info]) => {
|
|
127
|
+
return `**${provider}** (${info.total} chunks): ${info.categories.join(", ")}`;
|
|
128
|
+
})
|
|
129
|
+
.join("\n");
|
|
130
|
+
return textResult(`Indexed providers:\n\n${lines}`);
|
|
131
|
+
}
|
|
132
|
+
export function compareProviders(db, args) {
|
|
133
|
+
const strictQuery = sanitiseFtsQuery(args.topic);
|
|
134
|
+
const fallbackQuery = relaxedFtsQuery(args.topic);
|
|
135
|
+
let rows = compareRows(db, strictQuery, args.providers);
|
|
136
|
+
if (rows.length === 0 && fallbackQuery !== strictQuery) {
|
|
137
|
+
rows = compareRows(db, fallbackQuery, args.providers);
|
|
138
|
+
}
|
|
139
|
+
if (rows.length === 0) {
|
|
140
|
+
return textResult(`No documentation found for "${args.topic}". Try different terms.`);
|
|
141
|
+
}
|
|
142
|
+
const grouped = new Map();
|
|
143
|
+
for (const row of rows) {
|
|
144
|
+
const entries = grouped.get(row.provider) ?? [];
|
|
145
|
+
entries.push(`### ${row.title}\n${row.content}`);
|
|
146
|
+
grouped.set(row.provider, entries);
|
|
147
|
+
}
|
|
148
|
+
const sections = [...grouped.entries()]
|
|
149
|
+
.map(([provider, chunks]) => `## ${provider}\n\n${chunks.slice(0, 3).join("\n\n")}`)
|
|
150
|
+
.join("\n\n---\n\n");
|
|
151
|
+
return textResult(`Comparison for "${args.topic}" across ${grouped.size} provider(s):\n\n${sections}`);
|
|
152
|
+
}
|
|
153
|
+
export function requestUpdate(db, args, options = {}) {
|
|
154
|
+
const countRow = db.prepare("SELECT COUNT(*) as cnt FROM requests").get();
|
|
155
|
+
if (countRow.cnt >= 500) {
|
|
156
|
+
return textResult(`Request queue is full (${countRow.cnt} entries). Please file an issue at https://github.com/withqwerty/football-docs/issues instead.`, true);
|
|
157
|
+
}
|
|
158
|
+
const recentRequest = db
|
|
159
|
+
.prepare(`SELECT id, requested_at FROM requests
|
|
160
|
+
WHERE lower(provider) = lower(?) AND status = 'pending'
|
|
161
|
+
AND datetime(requested_at) > datetime('now', '-7 days')
|
|
162
|
+
LIMIT 1`)
|
|
163
|
+
.get(args.provider);
|
|
164
|
+
if (recentRequest) {
|
|
165
|
+
return textResult(`A pending request for "${args.provider}" already exists (from ${recentRequest.requested_at}). Requests have a 7-day cooldown to prevent duplicate work. Request ID: ${recentRequest.id}`, true);
|
|
166
|
+
}
|
|
167
|
+
const id = options.requestId ??
|
|
168
|
+
`req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
|
169
|
+
const requestedAt = (options.now ?? new Date()).toISOString();
|
|
170
|
+
db.prepare(`INSERT INTO requests (id, type, provider, reason, suggested_urls, requested_at, status)
|
|
171
|
+
VALUES (?, ?, ?, ?, ?, ?, 'pending')`).run(id, args.type, args.provider.toLowerCase(), args.reason, args.suggested_urls ? JSON.stringify(args.suggested_urls) : null, requestedAt);
|
|
172
|
+
const typeLabel = {
|
|
173
|
+
new_provider: "New provider request",
|
|
174
|
+
recrawl: "Recrawl request",
|
|
175
|
+
flag_outdated: "Outdated docs flag",
|
|
176
|
+
suggest_source: "Source suggestion",
|
|
177
|
+
}[args.type];
|
|
178
|
+
return textResult(`${typeLabel} queued for "${args.provider}" (ID: ${id}).\n\nReason: ${args.reason}${args.suggested_urls?.length
|
|
179
|
+
? `\nSuggested URLs:\n${args.suggested_urls.map((url) => ` - ${url}`).join("\n")}`
|
|
180
|
+
: ""}\n\nThis request will be reviewed by maintainers. You can also file an issue at https://github.com/withqwerty/football-docs/issues for community visibility.`);
|
|
181
|
+
}
|
|
182
|
+
export async function resolveEntity(args, options = {}) {
|
|
183
|
+
const baseUrl = options.baseUrl ?? "https://reep-api.rahulkeerthi2-95d.workers.dev";
|
|
184
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
185
|
+
let url;
|
|
186
|
+
if (args.qid) {
|
|
187
|
+
url = `${baseUrl}/lookup?qid=${encodeURIComponent(args.qid)}`;
|
|
188
|
+
}
|
|
189
|
+
else if (args.provider && args.id) {
|
|
190
|
+
url = `${baseUrl}/resolve?provider=${encodeURIComponent(args.provider)}&id=${encodeURIComponent(args.id)}`;
|
|
191
|
+
}
|
|
192
|
+
else if (args.name) {
|
|
193
|
+
url = `${baseUrl}/search?name=${encodeURIComponent(args.name)}&limit=10`;
|
|
194
|
+
if (args.type)
|
|
195
|
+
url += `&type=${encodeURIComponent(args.type)}`;
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
return textResult("Provide at least one of: name, qid, or provider+id. Examples:\n- name: 'Cole Palmer'\n- provider: 'transfermarkt', id: '568177'\n- qid: 'Q99760796'", true);
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
const response = await fetchImpl(url);
|
|
202
|
+
if (!response.ok) {
|
|
203
|
+
return textResult(`Reep API error: ${response.status} ${response.statusText}`, true);
|
|
204
|
+
}
|
|
205
|
+
const data = (await response.json());
|
|
206
|
+
if (!data.results?.length) {
|
|
207
|
+
return textResult("No entities found matching the query.");
|
|
208
|
+
}
|
|
209
|
+
const formatted = data.results
|
|
210
|
+
.map((entity) => {
|
|
211
|
+
const ids = entity.external_ids;
|
|
212
|
+
const idLines = ids
|
|
213
|
+
? Object.entries(ids)
|
|
214
|
+
.map(([provider, id]) => ` ${provider}: ${id}`)
|
|
215
|
+
.join("\n")
|
|
216
|
+
: " (none)";
|
|
217
|
+
const bio = [
|
|
218
|
+
entity.date_of_birth && `DOB: ${entity.date_of_birth}`,
|
|
219
|
+
entity.nationality && `Nationality: ${entity.nationality}`,
|
|
220
|
+
entity.position && `Position: ${entity.position}`,
|
|
221
|
+
entity.height_cm && `Height: ${entity.height_cm}cm`,
|
|
222
|
+
entity.country && `Country: ${entity.country}`,
|
|
223
|
+
entity.stadium && `Stadium: ${entity.stadium}`,
|
|
224
|
+
]
|
|
225
|
+
.filter(Boolean)
|
|
226
|
+
.join(" | ");
|
|
227
|
+
return `### ${entity.name_en} (${entity.type})\nWikidata: ${entity.qid}${entity.aliases_en ? `\nAliases: ${entity.aliases_en}` : ""}${bio ? `\n${bio}` : ""}\nProvider IDs:\n${idLines}`;
|
|
228
|
+
})
|
|
229
|
+
.join("\n\n");
|
|
230
|
+
return textResult(`Found ${data.results.length} result(s):\n\n${formatted}`);
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
return textResult(`Failed to reach Reep API: ${error instanceof Error ? error.message : String(error)}`, true);
|
|
234
|
+
}
|
|
235
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "football-docs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Searchable football data provider documentation for AI coding agents. Like Context7 for football data.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"mcpName": "io.github.withqwerty/football-docs",
|
|
@@ -59,20 +59,9 @@
|
|
|
59
59
|
"@types/better-sqlite3": "^7.6.13",
|
|
60
60
|
"@types/node": "^22.15.2",
|
|
61
61
|
"@types/turndown": "^5.0.6",
|
|
62
|
-
"tsx": "^4.
|
|
62
|
+
"tsx": "^4.23.0",
|
|
63
63
|
"typescript": "^5.8.3",
|
|
64
|
-
"vitest": "^4.1.
|
|
65
|
-
},
|
|
66
|
-
"pnpm": {
|
|
67
|
-
"onlyBuiltDependencies": [
|
|
68
|
-
"better-sqlite3",
|
|
69
|
-
"esbuild"
|
|
70
|
-
],
|
|
71
|
-
"overrides": {
|
|
72
|
-
"vite@<8.0.5": ">=8.0.5",
|
|
73
|
-
"fast-uri@<3.1.2": ">=3.1.2",
|
|
74
|
-
"qs@<6.15.2": ">=6.15.2"
|
|
75
|
-
}
|
|
64
|
+
"vitest": "^4.1.10"
|
|
76
65
|
},
|
|
77
66
|
"files": [
|
|
78
67
|
"dist/",
|