football-docs 0.5.2 → 0.6.1

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 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/bin/serve.js CHANGED
@@ -3,4 +3,9 @@ import { resolve, dirname } from "node:path";
3
3
  import { fileURLToPath, pathToFileURL } from "node:url";
4
4
 
5
5
  const __dirname = dirname(fileURLToPath(import.meta.url));
6
- await import(pathToFileURL(resolve(__dirname, "..", "dist", "index.js")).href);
6
+ const { main } = await import(pathToFileURL(resolve(__dirname, "..", "dist", "index.js")).href);
7
+
8
+ main().catch((error) => {
9
+ console.error("Failed to start football-docs MCP server:", error);
10
+ process.exit(1);
11
+ });
package/data/docs.db CHANGED
Binary file
package/dist/index.d.ts CHANGED
@@ -3,11 +3,16 @@
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
- * and requesting documentation updates.
6
+ * requesting documentation updates, and resolving football entities.
7
7
  *
8
- * Data is stored in a SQLite FTS5 index for fast offline search.
9
- * Update requests are stored in a separate writable SQLite DB in a
10
- * user-writable location (XDG_DATA_HOME or ~/.local/share).
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
- /** Sanitise a query string for FTS5 MATCH by wrapping in double quotes (phrase search). */
13
- export declare function sanitiseFtsQuery(query: string): string;
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;
18
+ export declare function main(): Promise<void>;
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
- * and requesting documentation updates.
6
+ * requesting documentation updates, and resolving football entities.
7
7
  *
8
- * Data is stored in a SQLite FTS5 index for fast offline search.
9
- * Update requests are stored in a separate writable SQLite DB in a
10
- * user-writable location (XDG_DATA_HOME or ~/.local/share).
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
- // ── Database ────────────────────────────────────────────────────────────
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((c) => c.name === "source_type");
33
+ const hasProvenance = columns.some((column) => column.name === "source_type");
35
34
  if (!hasProvenance) {
36
35
  db.close();
37
- throw new Error(`Docs database is outdated (missing provenance columns). Run 'npm run ingest' to rebuild.`);
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
- /** Sanitise a query string for FTS5 MATCH by wrapping in double quotes (phrase search). */
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
- const rows = db
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
- // Tool: compare_providers
167
- 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?'", {
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
- const safeTopic = sanitiseFtsQuery(topic);
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
- // Tool: request_update
223
- 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.", {
224
- 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)"),
225
- provider: z.string().max(100).describe("Provider name (existing or proposed). Examples: 'statsbomb', 'mplsoccer', 'floodlight'"),
226
- reason: z.string().max(2000).describe("Why this update is needed. Be specific: version bump, missing event types, new API endpoints, etc."),
227
- suggested_urls: z.array(z.string().url().max(500)).max(10).optional().describe("URLs for documentation sources (readthedocs, GitHub, llms.txt, etc.)"),
228
- }, { readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async ({ type, provider, reason, suggested_urls }) => {
229
- const qdb = openQueueDb();
230
- try {
231
- // Hard cap on queue size
232
- const countRow = qdb.prepare("SELECT COUNT(*) as cnt FROM requests").get();
233
- if (countRow.cnt >= 500) {
234
- return {
235
- isError: true,
236
- content: [
237
- {
238
- type: "text",
239
- text: `Request queue is full (${countRow.cnt} entries). Please file an issue at https://github.com/withqwerty/football-docs/issues instead.`,
240
- },
241
- ],
242
- };
243
- }
244
- // Cooldown: reject if same provider was requested in last 7 days
245
- const recentRequest = qdb.prepare(`SELECT id, requested_at FROM requests
246
- WHERE lower(provider) = lower(?) AND status = 'pending'
247
- AND datetime(requested_at) > datetime('now', '-7 days')
248
- LIMIT 1`).get(provider);
249
- if (recentRequest) {
250
- return {
251
- isError: true,
252
- content: [
253
- {
254
- type: "text",
255
- text: `A pending request for "${provider}" already exists (from ${recentRequest.requested_at}). Requests have a 7-day cooldown to prevent duplicate work. Request ID: ${recentRequest.id}`,
256
- },
257
- ],
258
- };
259
- }
260
- const id = `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
261
- qdb.prepare(`INSERT INTO requests (id, type, provider, reason, suggested_urls, requested_at, status)
262
- VALUES (?, ?, ?, ?, ?, ?, 'pending')`).run(id, type, provider.toLowerCase(), reason, suggested_urls ? JSON.stringify(suggested_urls) : null, new Date().toISOString());
263
- const typeLabel = {
264
- new_provider: "New provider request",
265
- recrawl: "Recrawl request",
266
- flag_outdated: "Outdated docs flag",
267
- suggest_source: "Source suggestion",
268
- }[type];
269
- return {
270
- content: [
271
- {
272
- type: "text",
273
- text: `${typeLabel} queued for "${provider}" (ID: ${id}).\n\nReason: ${reason}${suggested_urls?.length ? `\nSuggested URLs:\n${suggested_urls.map((u) => ` - ${u}`).join("\n")}` : ""}\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.`,
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 ───────────────────────────────────────────────────────────────
356
- async function main() {
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
+ }
131
+ export async function main() {
357
132
  const transport = new StdioServerTransport();
358
- await server.connect(transport);
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
- });
@@ -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.5.2",
3
+ "version": "0.6.1",
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.19.4",
62
+ "tsx": "^4.23.0",
63
63
  "typescript": "^5.8.3",
64
- "vitest": "^4.1.5"
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/",