football-docs 0.1.2 → 0.2.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.
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Source discovery for football data providers.
3
+ *
4
+ * Given a provider's known URLs, discovers the best documentation source
5
+ * to crawl. Checks for llms.txt, ReadTheDocs structure, GitHub docs, etc.
6
+ *
7
+ * Usage:
8
+ * import { discoverBestSource } from "./discover.js";
9
+ * const result = await discoverBestSource(provider);
10
+ */
11
+ import { fetchText, urlExists } from "./http.js";
12
+ /**
13
+ * Probe a base URL for llms.txt variants.
14
+ * Returns the URL and content of the best llms.txt found, or null.
15
+ */
16
+ async function probeLlmsTxt(baseUrl) {
17
+ const root = baseUrl.replace(/\/$/, "");
18
+ for (const filename of ["llms-full.txt", "llms.txt"]) {
19
+ const url = `${root}/${filename}`;
20
+ const content = await fetchText(url);
21
+ if (content && content.length > 100) {
22
+ return { url, content };
23
+ }
24
+ }
25
+ return null;
26
+ }
27
+ /**
28
+ * Check if a URL looks like a ReadTheDocs or Sphinx site.
29
+ * Uses a single GET request (no redundant HEAD).
30
+ */
31
+ async function probeReadTheDocs(url) {
32
+ const html = await fetchText(url, 10000);
33
+ if (!html)
34
+ return false;
35
+ return (html.includes("readthedocs") ||
36
+ html.includes("sphinx") ||
37
+ html.includes("Read the Docs") ||
38
+ /\/en\/(latest|stable)\//.test(url));
39
+ }
40
+ /**
41
+ * Choose the best source to crawl from probe results.
42
+ *
43
+ * Scoring: llms-full.txt (>1000 chars) = 100, llms.txt (>1000 chars) = 90,
44
+ * ReadTheDocs/Sphinx = 70, short llms.txt = 50, reachable = 40, unreachable = 0.
45
+ *
46
+ * @param probeResults - What was actually found at each URL
47
+ * @returns The best source to crawl, or null if only curated content is available
48
+ */
49
+ export function chooseBestSource(probeResults) {
50
+ let bestScore = 0;
51
+ let bestSource = null;
52
+ for (const probe of probeResults) {
53
+ let score = 0;
54
+ if (probe.llmsTxt) {
55
+ const isFullTxt = probe.llmsTxt.url.includes("llms-full.txt");
56
+ const isSubstantial = probe.llmsTxt.content.length > 1000;
57
+ score = isFullTxt && isSubstantial ? 100
58
+ : isSubstantial ? 90
59
+ : 50;
60
+ }
61
+ else if (probe.isReadTheDocs) {
62
+ score = 70;
63
+ }
64
+ else if (probe.isReachable) {
65
+ score = 40;
66
+ }
67
+ if (score > bestScore) {
68
+ bestScore = score;
69
+ bestSource = probe.llmsTxt
70
+ ? { url: probe.llmsTxt.url, type: "llms_txt" }
71
+ : { url: probe.url, type: probe.sourceType };
72
+ }
73
+ }
74
+ return bestSource;
75
+ }
76
+ // ── Public API ───────────────────────────────────────────────────────
77
+ /**
78
+ * Discover the best documentation source for a provider.
79
+ *
80
+ * Probes all configured URLs for llms.txt, ReadTheDocs markers, and
81
+ * basic reachability, then uses chooseBestSource() to pick the winner.
82
+ */
83
+ export async function discoverBestSource(provider, sources) {
84
+ const checked = [];
85
+ const probeResults = [];
86
+ for (const source of sources) {
87
+ if (source.type === "curated" || !source.url)
88
+ continue;
89
+ try {
90
+ const [llmsTxt, isReadTheDocs, reachability] = await Promise.all([
91
+ probeLlmsTxt(source.url),
92
+ probeReadTheDocs(source.url),
93
+ urlExists(source.url),
94
+ ]);
95
+ const found = llmsTxt
96
+ ? `llms.txt (${llmsTxt.content.length} chars)`
97
+ : isReadTheDocs
98
+ ? "readthedocs"
99
+ : reachability.ok
100
+ ? "reachable"
101
+ : null;
102
+ checked.push({ url: source.url, found });
103
+ probeResults.push({
104
+ url: source.url,
105
+ sourceType: source.type,
106
+ llmsTxt,
107
+ isReadTheDocs,
108
+ isReachable: reachability.ok,
109
+ });
110
+ }
111
+ catch (err) {
112
+ checked.push({
113
+ url: source.url,
114
+ found: null,
115
+ error: err instanceof Error ? err.message : String(err),
116
+ });
117
+ }
118
+ }
119
+ const bestSource = chooseBestSource(probeResults);
120
+ return { provider, source: bestSource, checked };
121
+ }
package/dist/http.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ /** Shared HTTP utilities for crawling and discovery. */
2
+ /** Check if a URL returns a successful response. */
3
+ export declare function urlExists(url: string): Promise<{
4
+ ok: boolean;
5
+ contentType?: string;
6
+ }>;
7
+ /** Fetch text content from a URL, returning null on failure. Rejects responses over 10MB. */
8
+ export declare function fetchText(url: string, timeoutMs?: number): Promise<string | null>;
package/dist/http.js ADDED
@@ -0,0 +1,36 @@
1
+ /** Shared HTTP utilities for crawling and discovery. */
2
+ const MAX_RESPONSE_BYTES = 10 * 1024 * 1024; // 10 MB
3
+ /** Check if a URL returns a successful response. */
4
+ export async function urlExists(url) {
5
+ try {
6
+ const response = await fetch(url, {
7
+ method: "HEAD",
8
+ signal: AbortSignal.timeout(5000),
9
+ });
10
+ return {
11
+ ok: response.ok,
12
+ contentType: response.headers.get("content-type") ?? undefined,
13
+ };
14
+ }
15
+ catch {
16
+ return { ok: false };
17
+ }
18
+ }
19
+ /** Fetch text content from a URL, returning null on failure. Rejects responses over 10MB. */
20
+ export async function fetchText(url, timeoutMs = 15000) {
21
+ try {
22
+ const response = await fetch(url, {
23
+ signal: AbortSignal.timeout(timeoutMs),
24
+ });
25
+ if (!response.ok)
26
+ return null;
27
+ // Reject obviously oversized responses early via Content-Length
28
+ const contentLength = response.headers.get("content-length");
29
+ if (contentLength && Number(contentLength) > MAX_RESPONSE_BYTES)
30
+ return null;
31
+ return await response.text();
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
package/dist/index.d.ts CHANGED
@@ -2,10 +2,12 @@
2
2
  * Nutmeg Football Docs MCP Server
3
3
  *
4
4
  * A Context7-style searchable index of football data provider documentation.
5
- * Exposes two tools:
6
- * - search_docs: Full-text search across all provider docs
7
- * - list_providers: List all indexed providers and their coverage
5
+ * Exposes tools for searching docs, listing providers, comparing providers,
6
+ * and requesting documentation updates.
8
7
  *
9
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).
10
11
  */
11
- export {};
12
+ /** Sanitise a query string for FTS5 MATCH by wrapping in double quotes (phrase search). */
13
+ export declare function sanitiseFtsQuery(query: string): string;
package/dist/index.js CHANGED
@@ -2,32 +2,70 @@
2
2
  * Nutmeg Football Docs MCP Server
3
3
  *
4
4
  * A Context7-style searchable index of football data provider documentation.
5
- * Exposes two tools:
6
- * - search_docs: Full-text search across all provider docs
7
- * - list_providers: List all indexed providers and their coverage
5
+ * Exposes tools for searching docs, listing providers, comparing providers,
6
+ * and requesting documentation updates.
8
7
  *
9
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).
10
11
  */
12
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { dirname, resolve } from "node:path";
15
+ import { fileURLToPath } from "node:url";
11
16
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
12
17
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
13
- import { z } from "zod";
14
18
  import Database from "better-sqlite3";
15
- import { resolve, dirname } from "node:path";
16
- import { fileURLToPath } from "node:url";
17
- import { existsSync } from "node:fs";
19
+ import { z } from "zod";
18
20
  const __dirname = dirname(fileURLToPath(import.meta.url));
19
21
  const DB_PATH = resolve(__dirname, "..", "data", "docs.db");
22
+ 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
+ const QUEUE_DB_DIR = resolve(process.env.XDG_DATA_HOME ?? resolve(homedir(), ".local", "share"), "football-docs");
25
+ const QUEUE_DB_PATH = resolve(QUEUE_DB_DIR, "requests.db");
20
26
  // ── Database ────────────────────────────────────────────────────────────
21
27
  function openDb() {
22
28
  if (!existsSync(DB_PATH)) {
23
29
  throw new Error(`Docs database not found at ${DB_PATH}. Run 'npm run ingest' first to build the index.`);
24
30
  }
25
- return new Database(DB_PATH, { readonly: true });
31
+ const db = new Database(DB_PATH, { readonly: true });
32
+ // Check schema version — source_type column was added in v0.2.0
33
+ const columns = db.pragma("table_info(docs)");
34
+ const hasProvenance = columns.some((c) => c.name === "source_type");
35
+ if (!hasProvenance) {
36
+ db.close();
37
+ throw new Error(`Docs database is outdated (missing provenance columns). Run 'npm run ingest' to rebuild.`);
38
+ }
39
+ return db;
40
+ }
41
+ function openQueueDb() {
42
+ mkdirSync(QUEUE_DB_DIR, { recursive: true });
43
+ const db = new Database(QUEUE_DB_PATH);
44
+ db.pragma("journal_mode = WAL");
45
+ db.exec(`
46
+ CREATE TABLE IF NOT EXISTS requests (
47
+ id TEXT PRIMARY KEY,
48
+ type TEXT NOT NULL,
49
+ provider TEXT NOT NULL,
50
+ reason TEXT NOT NULL,
51
+ suggested_urls TEXT,
52
+ requested_at TEXT NOT NULL,
53
+ status TEXT NOT NULL DEFAULT 'pending'
54
+ )
55
+ `);
56
+ return db;
57
+ }
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, '""')}"`;
26
64
  }
27
65
  // ── Server ──────────────────────────────────────────────────────────────
28
66
  const server = new McpServer({
29
67
  name: "nutmeg-football-docs",
30
- version: "0.1.0",
68
+ version: PKG_VERSION,
31
69
  });
32
70
  // Tool: search_docs
33
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.", {
@@ -44,19 +82,22 @@ server.tool("search_docs", "Search football data provider documentation. Use for
44
82
  }, async ({ query, provider, max_results }) => {
45
83
  const db = openDb();
46
84
  try {
85
+ const safeQuery = sanitiseFtsQuery(query);
47
86
  let sql = `
48
- SELECT provider, category, title, content,
87
+ SELECT d.provider, d.category, d.title, d.content,
88
+ d.source_type, d.source_url, d.upstream_version,
49
89
  rank * -1 as relevance
50
90
  FROM docs_fts
91
+ JOIN docs d ON d.id = docs_fts.rowid
51
92
  WHERE docs_fts MATCH ?
52
93
  `;
53
- const params = [query];
94
+ const params = [safeQuery];
54
95
  if (provider) {
55
- sql += ` AND provider = ?`;
96
+ sql += ` AND d.provider = ?`;
56
97
  params.push(provider.toLowerCase());
57
98
  }
58
99
  sql += ` ORDER BY rank LIMIT ?`;
59
- params.push(max_results ?? 10);
100
+ params.push(max_results);
60
101
  const rows = db.prepare(sql).all(...params);
61
102
  if (rows.length === 0) {
62
103
  return {
@@ -69,7 +110,12 @@ server.tool("search_docs", "Search football data provider documentation. Use for
69
110
  };
70
111
  }
71
112
  const results = rows
72
- .map((r, i) => `## [${i + 1}] ${r.title}\n**Provider:** ${r.provider} | **Category:** ${r.category}\n\n${r.content}`)
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
+ })
73
119
  .join("\n\n---\n\n");
74
120
  return {
75
121
  content: [
@@ -124,19 +170,21 @@ server.tool("compare_providers", "Compare what two or more providers offer for a
124
170
  }, async ({ topic, providers }) => {
125
171
  const db = openDb();
126
172
  try {
173
+ const safeTopic = sanitiseFtsQuery(topic);
127
174
  let sql = `
128
- SELECT provider, category, title, content,
175
+ SELECT d.provider, d.category, d.title, d.content,
129
176
  rank * -1 as relevance
130
177
  FROM docs_fts
178
+ JOIN docs d ON d.id = docs_fts.rowid
131
179
  WHERE docs_fts MATCH ?
132
180
  `;
133
- const params = [topic];
181
+ const params = [safeTopic];
134
182
  if (providers && providers.length > 0) {
135
183
  const placeholders = providers.map(() => "?").join(", ");
136
- sql += ` AND provider IN (${placeholders})`;
184
+ sql += ` AND d.provider IN (${placeholders})`;
137
185
  params.push(...providers.map((p) => p.toLowerCase()));
138
186
  }
139
- sql += ` ORDER BY provider, rank LIMIT 30`;
187
+ sql += ` ORDER BY d.provider, rank LIMIT 30`;
140
188
  const rows = db.prepare(sql).all(...params);
141
189
  if (rows.length === 0) {
142
190
  return {
@@ -171,6 +219,66 @@ server.tool("compare_providers", "Compare what two or more providers offer for a
171
219
  db.close();
172
220
  }
173
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
+ }, 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
+ });
174
282
  // ── Start ───────────────────────────────────────────────────────────────
175
283
  async function main() {
176
284
  const transport = new StdioServerTransport();
package/dist/ingest.d.ts CHANGED
@@ -1,21 +1,45 @@
1
1
  /**
2
2
  * Ingest provider documentation into the SQLite FTS5 index.
3
3
  *
4
- * Reads markdown files from docs/providers/{provider}/*.md and chunks them
5
- * by heading (## or ###), storing each chunk with metadata.
4
+ * Reads markdown files from docs/{provider}/*.md and chunks them
5
+ * by heading (## or ###), storing each chunk with provenance metadata.
6
+ *
7
+ * Each markdown file can include a YAML frontmatter block with provenance:
8
+ * ---
9
+ * source_url: https://example.com/docs
10
+ * source_type: readthedocs
11
+ * upstream_version: 1.8.8
12
+ * crawled_at: 2026-03-26T00:00:00Z
13
+ * ---
14
+ *
15
+ * Files without frontmatter default to source_type: "curated".
6
16
  *
7
17
  * Usage:
8
18
  * npm run ingest # ingest all providers
9
19
  * npm run ingest -- --provider opta # ingest one provider
10
- *
11
- * Doc file naming convention:
12
- * docs/providers/{provider}/{category}.md
13
- *
14
- * Example:
15
- * docs/providers/opta/event-types.md
16
- * docs/providers/opta/qualifiers.md
17
- * docs/providers/opta/coordinate-system.md
18
- * docs/providers/statsbomb/events.md
19
- * docs/providers/kloppy/data-model.md
20
20
  */
21
+ interface Frontmatter {
22
+ source_url: string | null;
23
+ source_type: string;
24
+ upstream_version: string | null;
25
+ crawled_at: string | null;
26
+ }
27
+ interface DocChunk {
28
+ provider: string;
29
+ category: string;
30
+ title: string;
31
+ content: string;
32
+ source_url: string | null;
33
+ source_type: string;
34
+ upstream_version: string | null;
35
+ crawled_at: string | null;
36
+ }
37
+ /** Parse optional YAML frontmatter from a markdown file. */
38
+ export declare function parseFrontmatter(text: string): {
39
+ frontmatter: Frontmatter;
40
+ body: string;
41
+ };
42
+ /** Split a markdown file into chunks by ## or ### headings. */
43
+ export declare function chunkMarkdown(text: string, provider: string, category: string): DocChunk[];
44
+ export declare const SCHEMA_SQL = "\n CREATE TABLE IF NOT EXISTS docs (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n provider TEXT NOT NULL,\n category TEXT NOT NULL,\n title TEXT NOT NULL,\n content TEXT NOT NULL,\n source_url TEXT,\n source_type TEXT NOT NULL DEFAULT 'curated',\n upstream_version TEXT,\n crawled_at TEXT\n );\n\n CREATE VIRTUAL TABLE IF NOT EXISTS docs_fts USING fts5(\n provider,\n category,\n title,\n content,\n content='docs',\n content_rowid='id',\n tokenize='porter unicode61'\n );\n\n CREATE TRIGGER IF NOT EXISTS docs_ai AFTER INSERT ON docs BEGIN\n INSERT INTO docs_fts(rowid, provider, category, title, content)\n VALUES (new.id, new.provider, new.category, new.title, new.content);\n END;\n";
21
45
  export {};