football-docs 0.6.0 → 0.6.2

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.
Files changed (51) hide show
  1. package/README.md +34 -21
  2. package/bin/serve.js +6 -1
  3. package/data/docs.db +0 -0
  4. package/dist/index.d.ts +1 -0
  5. package/dist/index.js +25 -5
  6. package/dist/tools.d.ts +12 -1
  7. package/dist/tools.js +309 -18
  8. package/docs/fotmob/identity-surfaces.md +3 -4
  9. package/docs/free-sources/contextual-story-joins.md +110 -0
  10. package/docs/free-sources/overview.md +60 -13
  11. package/docs/free-sources/understat.md +15 -6
  12. package/docs/free-sources/xg-timelines.md +123 -0
  13. package/docs/impect/identity-surfaces.md +3 -4
  14. package/docs/kloppy/event-derived-metrics.md +337 -0
  15. package/docs/kloppy/tracking-rendering.md +420 -0
  16. package/docs/mplsoccer/visualizations.md +363 -6
  17. package/docs/opta/charting-game-state.md +216 -0
  18. package/docs/opta/charting-lineups.md +90 -0
  19. package/docs/opta/charting-passmaps.md +130 -0
  20. package/docs/opta/charting-set-pieces.md +106 -0
  21. package/docs/opta/charting-shot-placement.md +233 -0
  22. package/docs/opta/event-types.md +44 -0
  23. package/docs/opta/identity-surfaces.md +4 -4
  24. package/docs/opta/qualifiers.md +16 -7
  25. package/docs/skillcorner/identity-surfaces.md +4 -4
  26. package/docs/skillcorner/physical-data.md +35 -0
  27. package/docs/socceraction/spadl.md +21 -0
  28. package/docs/soccerdata/data-sources.md +37 -0
  29. package/docs/soccerdonna/identity-surfaces.md +6 -6
  30. package/docs/sportmonks/charting-season-stories.md +125 -0
  31. package/docs/sportmonks/data-model.md +35 -12
  32. package/docs/sportmonks/event-types.md +1 -1
  33. package/docs/sportmonks/identity-surfaces.md +4 -4
  34. package/docs/sportradar/api-access.md +75 -0
  35. package/docs/sportradar/api-endpoints.md +92 -0
  36. package/docs/sportradar/charting-and-stories.md +57 -0
  37. package/docs/sportradar/data-model.md +84 -0
  38. package/docs/sportradar/integration-notes.md +58 -0
  39. package/docs/statsbomb/charting-lineups.md +79 -0
  40. package/docs/statsbomb/identity-surfaces.md +5 -4
  41. package/docs/statsbomb/player-mapping.md +6 -7
  42. package/docs/thesportsdb/api-access.md +68 -0
  43. package/docs/thesportsdb/api-endpoints.md +55 -0
  44. package/docs/thesportsdb/identity-surfaces.md +49 -0
  45. package/docs/thesportsdb/livescore.md +70 -0
  46. package/docs/transfermarkt/identity-surfaces.md +51 -6
  47. package/docs/transferroom/charting-availability.md +74 -0
  48. package/docs/wyscout/charting-analysis-metrics.md +124 -0
  49. package/docs/wyscout/identity-surfaces.md +4 -4
  50. package/package.json +3 -2
  51. package/providers.json +127 -2
package/README.md CHANGED
@@ -4,19 +4,26 @@ Searchable football data provider and tooling documentation for AI coding agents
4
4
 
5
5
  **Who it's for:** Developers and analysts who use AI coding tools (Claude Code, Cursor, VS Code Copilot) to work with football data. Works with any tool that supports MCP.
6
6
 
7
- **What it does:** Gives your AI agent a searchable index of documentation for 17 football data providers and tools — event types, qualifier IDs, coordinate systems, API endpoints, data models, identity surfaces, and cross-provider comparisons for the data providers (StatsBomb, Opta, Wyscout, Impect, SkillCorner, FMDB Pro, TransferRoom, and more), plus the open-source libraries people build with (kloppy, mplsoccer, socceraction, soccerdata, and more). Your agent looks up the real docs instead of guessing from training data.
7
+ **What it does:** Gives your AI agent a searchable index of documentation for 19 football data providers and tools — event types, qualifier IDs, coordinate systems, API endpoints, data models, identity surfaces, and cross-provider comparisons for the data providers (StatsBomb, Opta, Wyscout, Impect, SkillCorner, Sportradar, TheSportsDB, FMDB Pro, TransferRoom, and more), plus the open-source libraries people build with (kloppy, mplsoccer, socceraction, soccerdata, and more). Your agent looks up the real docs instead of guessing from training data.
8
8
 
9
9
  **Why not just let the AI figure it out?** LLMs get football data specifics wrong constantly — Opta qualifier IDs, StatsBomb coordinate ranges, API endpoint URLs, library method signatures. These are mutable facts that change across versions. football-docs gives the agent verified, sourced documentation with provenance tracking so you know where every answer came from.
10
10
 
11
+ ## Strategy
12
+
13
+ football-docs is intended to be a community-owned, source-transparent Context7
14
+ for football data. The public operating contract is in
15
+ [STRATEGY.md](STRATEGY.md): what belongs here, what must stay out, how we handle
16
+ public-safe provider facts, and how contributors should prove retrieval quality.
17
+
11
18
  ## Provider identity facts
12
19
 
13
20
  football-docs is the public source for provider identity-surface facts: access
14
21
  shape, ID schemes, matching fields, provider quirks, and provenance rules.
15
- Curated Reep-derived notes belong here when they can be stated without private
16
- register state. They should say whether a fact comes from public docs, public
17
- page evidence, licensed feed shape, or a reviewed public-safe observation, and
18
- must not include private paths, credentials, scraper state, action ledgers, mint
19
- salts, or raw restricted payloads.
22
+ Curated provider identity notes belong here when they can be stated without
23
+ private register state. They should say whether a fact comes from public docs,
24
+ public page evidence, licensed feed shape, or a reviewed public-safe
25
+ observation, and must not include private paths, credentials, scraper state,
26
+ action ledgers, mint salts, or raw restricted payloads.
20
27
 
21
28
  MCP ([Model Context Protocol](https://modelcontextprotocol.io)) is a standard for connecting AI coding tools to external data sources.
22
29
 
@@ -78,11 +85,15 @@ Add to `claude_desktop_config.json`:
78
85
  | Tool | Description |
79
86
  |------|-------------|
80
87
  | `search_docs` | Full-text search across all provider docs. Filter by provider. Results include provenance (source URL, version). |
88
+ | `resolve_provider_id` | Resolve provider names and aliases to canonical indexed provider keys before searching. |
89
+ | `get_provider_docs` | Retrieve docs for a resolved provider, optionally filtered by topic or category. |
81
90
  | `list_providers` | List all indexed providers and their doc coverage. |
82
91
  | `compare_providers` | Compare how different providers handle the same concept. |
83
- | `request_update` | Request a new provider, flag outdated docs, or suggest a better doc source. Queued for maintainer review. |
92
+ | `request_update` | Request a new provider, flag outdated docs, or suggest a better doc source. Queues locally and points to the matching public GitHub issue template. |
84
93
  | `resolve_entity` | Resolve players, teams, or coaches to cross-provider IDs via the Reep API. |
85
94
 
95
+ Provider filters use the indexed provider keys shown by `list_providers`, but common aliases are accepted. Examples: `fbref`, `understat`, `ClubElo`, `football-data.co.uk`, and `engsoccerdata` search `free-sources`; `Sofascore` and `ESPN` search `soccerdata`; `FMDB` searches `fmdb-pro`; `Transfer Room` searches `transferroom`; `Hudl Wyscout` searches `wyscout`; `Stats Perform` / `Opta F24` / `WhoScored` search `opta`; `Metrica`, `Sportec` / `DFL`, and `TRACAB` search `databallpy`; `Second Spectrum` searches `kloppy`; `SportRadar API` / `Soccer Extended` search `sportradar`; `The Sports DB` / `TSDB` search `thesportsdb`; `StatsBomb Open Data` searches `statsbomb`.
96
+
86
97
  ## Example queries
87
98
 
88
99
  - "What is Opta qualifier 76?" (big chance)
@@ -97,32 +108,34 @@ Add to `claude_desktop_config.json`:
97
108
 
98
109
  | Provider | Chunks | Categories |
99
110
  |----------|--------|------------|
100
- | StatsBomb | 231 | event-types, data-model, coordinate-system, api-access, api-endpoints, xg-model, iq-metrics, player/team stats, player-mapping, identity-surfaces |
101
- | Wyscout | 157 | event-types, data-model, coordinate-system, api-access, api-endpoints, glossary, identity-surfaces |
102
- | kloppy | 100 | data-model, usage, provider-mapping |
103
- | SportMonks | 78 | event-types, data-model, api-access, identity-surfaces |
111
+ | StatsBomb | 237 | event-types, data-model, coordinate-system, api-access, api-endpoints, charting-lineups, xg-model, iq-metrics, player/team stats, player-mapping, identity-surfaces |
112
+ | Wyscout | 164 | event-types, data-model, coordinate-system, api-access, api-endpoints, charting-analysis-metrics, glossary, identity-surfaces |
113
+ | kloppy | 126 | data-model, usage, provider-mapping, tracking-rendering, event-derived-metrics |
114
+ | SportMonks | 85 | event-types, data-model, api-access, charting-season-stories, identity-surfaces |
104
115
  | databallpy | 63 | data-model, overview, usage |
105
- | mplsoccer | 62 | overview, pitch-types, visualizations |
116
+ | mplsoccer | 64 | overview, pitch-types, visualizations |
106
117
  | Impect | 58 | api-access, api-endpoints, data-model, event-types, coordinate-system, concepts, identity-surfaces |
107
- | SkillCorner | 51 | api-access, api-endpoints, data-model, physical-data, coordinate-system, concepts, identity-surfaces |
108
- | Free sources | 45 | overview, fbref, understat |
109
- | soccerdata | 40 | overview, data-sources, usage |
110
- | TransferRoom | 38 | api-access, api-endpoints, data-model, identity-surfaces |
111
- | Opta | 36 | event-types, qualifiers, coordinate-system, api-access, identity-surfaces |
118
+ | SkillCorner | 52 | api-access, api-endpoints, data-model, physical-data, coordinate-system, concepts, identity-surfaces |
119
+ | Free sources | 62 | overview, fbref, understat, contextual-story-joins, xg-timelines |
120
+ | soccerdata | 41 | overview, data-sources, usage |
121
+ | TransferRoom | 43 | api-access, api-endpoints, charting-availability, data-model, identity-surfaces |
122
+ | Opta | 74 | event-types, qualifiers, coordinate-system, api-access, charting-game-state, charting-lineups, charting-passmaps, charting-set-pieces, charting-shot-placement, identity-surfaces |
112
123
  | FMDB Pro | 36 | api-access, api-endpoints, data-model, identity-surfaces |
113
- | socceraction | 26 | SPADL format, VAEP, Expected Threat |
124
+ | Sportradar | 29 | api-access, api-endpoints, data-model, charting-and-stories, integration-notes |
125
+ | socceraction | 27 | SPADL format, VAEP, Expected Threat |
126
+ | TheSportsDB | 18 | api-access, api-endpoints, livescore, identity-surfaces |
114
127
  | FotMob | 7 | identity-surfaces |
115
128
  | Soccerdonna | 7 | identity-surfaces |
116
- | Transfermarkt | 7 | identity-surfaces |
129
+ | Transfermarkt | 8 | identity-surfaces |
117
130
 
118
- **1,042 searchable chunks** across 17 providers and tools.
131
+ **1,201 searchable chunks** across 19 providers and tools.
119
132
 
120
133
  ## Contributing
121
134
 
122
135
  Contributions are welcome from everyone. There are three ways to help:
123
136
 
124
137
  1. **Open an issue** — [request a new provider](https://github.com/withqwerty/football-docs/issues/new/choose), [flag outdated docs](https://github.com/withqwerty/football-docs/issues/new/choose), or [suggest a better doc source](https://github.com/withqwerty/football-docs/issues/new/choose)
125
- 2. **Use the `request_update` tool** — AI agents can flag outdated or missing docs directly via the MCP server, which queues requests for maintainer review
138
+ 2. **Use the `request_update` tool** — AI agents can flag outdated or missing docs directly via the MCP server, which queues requests locally and points to the matching public GitHub issue template
126
139
  3. **Open a PR** — fix errors, add new providers, or improve existing docs
127
140
 
128
141
  **You don't need to be an expert.** See **[CONTRIBUTING.md](CONTRIBUTING.md)** for the full guide.
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
@@ -15,3 +15,4 @@ export { sanitiseFtsQuery } from "./tools.js";
15
15
  export declare function openDb(): Database.Database;
16
16
  export declare function openQueueDb(): Database.Database;
17
17
  export declare function createFootballDocsServer(): McpServer;
18
+ export declare function main(): Promise<void>;
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@ 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";
20
+ import { compareProviders, getProviderDocs, listProviders, requestUpdate, resolveEntity, resolveProviderId, searchDocs, } from "./tools.js";
21
21
  export { sanitiseFtsQuery } from "./tools.js";
22
22
  const __dirname = dirname(fileURLToPath(import.meta.url));
23
23
  const DB_PATH = resolve(__dirname, "..", "data", "docs.db");
@@ -78,24 +78,44 @@ export function createFootballDocsServer() {
78
78
  version: PKG_VERSION,
79
79
  });
80
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'"),
81
+ query: z.string().describe("Search query. Examples: 'Opta goal qualifier', 'StatsBomb shot event type', 'coordinate system differences', 'xG qualifier ID', 'SportMonks fixture endpoint', 'FMDB Pro players endpoint'"),
82
82
  provider: z
83
83
  .string()
84
84
  .optional()
85
- .describe("Filter to a specific provider: opta, statsbomb, wyscout, sportmonks, fbref, understat, kloppy, or leave empty for all"),
85
+ .describe("Optional provider filter. Use list_providers for indexed provider keys. Common aliases such as fbref, understat, ClubElo, football-data.co.uk, engsoccerdata, Sofascore, ESPN, FMDB, TransferRoom, Hudl Wyscout, Stats Perform, Opta F24, WhoScored, Metrica, Sportec/DFL, TRACAB, Second Spectrum, SportRadar API, Soccer Extended, TheSportsDB, and TSDB are accepted."),
86
86
  max_results: z
87
87
  .number()
88
88
  .optional()
89
89
  .default(10)
90
90
  .describe("Maximum number of results to return (default 10)"),
91
91
  }, { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async (args) => withDocsDb((db) => searchDocs(db, args)));
92
+ server.tool("resolve_provider_id", "Resolve a football data provider name or alias to the canonical football-docs provider key before searching. Use when users mention brands, vendors, products, or aliases such as Stats Perform, Opta F24, Hudl Wyscout, Second Spectrum, FMDB, Transfer Room, FBref, Sofascore, or TheSportsDB.", {
93
+ query: z
94
+ .string()
95
+ .describe("Provider name, brand, product, or alias to resolve to a canonical provider key."),
96
+ }, { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async (args) => withDocsDb((db) => resolveProviderId(db, args)));
97
+ server.tool("get_provider_docs", "Retrieve documentation for a resolved provider, optionally filtered by topic or indexed category. Use after resolve_provider_id when you know which provider to inspect and want provenance-bearing docs.", {
98
+ provider: z
99
+ .string()
100
+ .describe("Provider key or alias. Use resolve_provider_id first when the provider name is ambiguous."),
101
+ topic: z.string().optional().describe("Optional topic to search within this provider's docs."),
102
+ category: z
103
+ .string()
104
+ .optional()
105
+ .describe("Optional indexed category to restrict results, such as api-endpoints, qualifiers, identity-surfaces, or tracking-rendering."),
106
+ max_results: z
107
+ .number()
108
+ .optional()
109
+ .default(10)
110
+ .describe("Maximum number of provider docs to return (default 10)."),
111
+ }, { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async (args) => withDocsDb((db) => getProviderDocs(db, args)));
92
112
  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
113
  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
114
  topic: z.string().describe("The concept to compare across providers. Examples: 'shot events', 'coordinate systems', 'xG', 'pass types'"),
95
115
  providers: z
96
116
  .array(z.string())
97
117
  .optional()
98
- .describe("Providers to compare. If omitted, compares all indexed providers."),
118
+ .describe("Providers to compare. If omitted, compares all indexed providers. Use list_providers for indexed keys; common aliases such as ClubElo, football-data.co.uk, engsoccerdata, Sofascore, ESPN, StatsBomb Open Data, Opta F24, WhoScored, SkillCorner, Metrica, Sportec/DFL, TRACAB, Second Spectrum, SportRadar API, Soccer Extended, TheSportsDB, and TSDB are accepted."),
99
119
  }, { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async (args) => withDocsDb((db) => compareProviders(db, args)));
100
120
  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
121
  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)"),
@@ -128,7 +148,7 @@ export function createFootballDocsServer() {
128
148
  }, { readOnlyHint: true, destructiveHint: false, openWorldHint: true }, async (args) => resolveEntity(args));
129
149
  return server;
130
150
  }
131
- async function main() {
151
+ export async function main() {
132
152
  const transport = new StdioServerTransport();
133
153
  await createFootballDocsServer().connect(transport);
134
154
  }
package/dist/tools.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type Database from "better-sqlite3";
2
- export declare const TOOL_NAMES: readonly ["search_docs", "list_providers", "compare_providers", "request_update", "resolve_entity"];
2
+ export declare const TOOL_NAMES: readonly ["search_docs", "resolve_provider_id", "get_provider_docs", "list_providers", "compare_providers", "request_update", "resolve_entity"];
3
3
  type TextContent = {
4
4
  type: "text";
5
5
  text: string;
@@ -13,6 +13,15 @@ export type SearchDocsArgs = {
13
13
  provider?: string;
14
14
  max_results?: number;
15
15
  };
16
+ export type ResolveProviderIdArgs = {
17
+ query: string;
18
+ };
19
+ export type GetProviderDocsArgs = {
20
+ provider: string;
21
+ topic?: string;
22
+ category?: string;
23
+ max_results?: number;
24
+ };
16
25
  export type CompareProvidersArgs = {
17
26
  topic: string;
18
27
  providers?: string[];
@@ -38,6 +47,8 @@ export type ResolveEntityArgs = {
38
47
  * like "Opta qualifier 76" to match docs that contain those tokens separately.
39
48
  */
40
49
  export declare function sanitiseFtsQuery(query: string): string;
50
+ export declare function resolveProviderId(db: Database.Database, args: ResolveProviderIdArgs): ToolResponse;
51
+ export declare function getProviderDocs(db: Database.Database, args: GetProviderDocsArgs): ToolResponse;
41
52
  export declare function searchDocs(db: Database.Database, args: SearchDocsArgs): ToolResponse;
42
53
  export declare function listProviders(db: Database.Database): ToolResponse;
43
54
  export declare function compareProviders(db: Database.Database, args: CompareProvidersArgs): ToolResponse;
package/dist/tools.js CHANGED
@@ -1,11 +1,47 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
1
4
  export const TOOL_NAMES = [
2
5
  "search_docs",
6
+ "resolve_provider_id",
7
+ "get_provider_docs",
3
8
  "list_providers",
4
9
  "compare_providers",
5
10
  "request_update",
6
11
  "resolve_entity",
7
12
  ];
8
13
  const QUERY_STOP_WORDS = new Set(["and", "or", "not", "near"]);
14
+ const __dirname = dirname(fileURLToPath(import.meta.url));
15
+ const PROVIDERS_PATH = resolve(__dirname, "..", "providers.json");
16
+ const PROVIDER_REGISTRY = JSON.parse(readFileSync(PROVIDERS_PATH, "utf-8"));
17
+ function slugProvider(provider) {
18
+ return provider
19
+ .trim()
20
+ .toLowerCase()
21
+ .replace(/&/g, " and ")
22
+ .replace(/[^a-z0-9]+/g, "-")
23
+ .replace(/^-|-$/g, "");
24
+ }
25
+ function buildProviderAliases() {
26
+ const aliases = {};
27
+ for (const [provider, metadata] of Object.entries(PROVIDER_REGISTRY.providers)) {
28
+ aliases[slugProvider(provider)] = provider;
29
+ aliases[slugProvider(metadata.display_name)] = provider;
30
+ for (const alias of metadata.aliases) {
31
+ aliases[slugProvider(alias)] = provider;
32
+ }
33
+ }
34
+ return aliases;
35
+ }
36
+ const PROVIDER_ALIASES = buildProviderAliases();
37
+ const DISPLAY_PROVIDER_ALIASES = Object.entries(PROVIDER_ALIASES).reduce((aliasesByProvider, [alias, provider]) => {
38
+ if (alias === provider)
39
+ return aliasesByProvider;
40
+ const aliases = aliasesByProvider[provider] ?? [];
41
+ aliases.push(alias);
42
+ aliasesByProvider[provider] = aliases;
43
+ return aliasesByProvider;
44
+ }, {});
9
45
  function textResult(text, isError = false) {
10
46
  return {
11
47
  isError: isError || undefined,
@@ -42,7 +78,83 @@ function relaxedFtsQuery(query) {
42
78
  return tokens.map(quoteFtsToken).join(" OR ");
43
79
  }
44
80
  function normaliseProvider(provider) {
45
- return provider.trim().toLowerCase();
81
+ const slug = slugProvider(provider);
82
+ return PROVIDER_ALIASES[slug] ?? slug;
83
+ }
84
+ function providerFilterLabel(original, normalised) {
85
+ if (!original || !normalised)
86
+ return "";
87
+ const trimmed = original.trim();
88
+ return trimmed === normalised ? normalised : `${trimmed} (${normalised})`;
89
+ }
90
+ function indexedProviders(db) {
91
+ const rows = db.prepare("SELECT DISTINCT provider FROM docs ORDER BY provider").all();
92
+ return new Set(rows.map((row) => row.provider));
93
+ }
94
+ function editDistance(a, b) {
95
+ const previous = Array.from({ length: b.length + 1 }, (_, index) => index);
96
+ const current = Array.from({ length: b.length + 1 }, () => 0);
97
+ for (let i = 1; i <= a.length; i += 1) {
98
+ current[0] = i;
99
+ for (let j = 1; j <= b.length; j += 1) {
100
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
101
+ current[j] = Math.min(current[j - 1] + 1, previous[j] + 1, previous[j - 1] + cost);
102
+ }
103
+ previous.splice(0, previous.length, ...current);
104
+ }
105
+ return previous[b.length];
106
+ }
107
+ function providerSuggestions(provider, providerSet) {
108
+ const candidates = new Map();
109
+ for (const indexedProvider of providerSet) {
110
+ candidates.set(indexedProvider, indexedProvider);
111
+ }
112
+ for (const [alias, indexedProvider] of Object.entries(PROVIDER_ALIASES)) {
113
+ if (providerSet.has(indexedProvider)) {
114
+ candidates.set(alias, indexedProvider);
115
+ }
116
+ }
117
+ const threshold = Math.max(2, Math.ceil(provider.length * 0.3));
118
+ return [...candidates.entries()]
119
+ .map(([candidate, indexedProvider]) => ({
120
+ candidate,
121
+ indexedProvider,
122
+ distance: editDistance(provider, candidate),
123
+ }))
124
+ .filter((suggestion) => suggestion.distance <= threshold)
125
+ .sort((a, b) => a.distance - b.distance || a.candidate.localeCompare(b.candidate))
126
+ .map((suggestion) => suggestion.indexedProvider)
127
+ .filter((providerName, index, suggestions) => suggestions.indexOf(providerName) === index)
128
+ .slice(0, 3);
129
+ }
130
+ function unknownProviderMessage(original, provider, providerSet) {
131
+ const suggestions = providerSuggestions(provider, providerSet);
132
+ const suggestionText = suggestions.length ? ` Did you mean: ${suggestions.join(", ")}?` : "";
133
+ return `Provider "${providerFilterLabel(original, provider)}" is not indexed.${suggestionText} Call list_providers for available provider keys, or use request_update to suggest adding it.`;
134
+ }
135
+ function requestProviderKey(provider) {
136
+ return slugProvider(provider).replace(/-/g, "");
137
+ }
138
+ function requestIssueTemplate(type) {
139
+ return {
140
+ new_provider: "new-provider.md",
141
+ recrawl: "flag-outdated.md",
142
+ flag_outdated: "flag-outdated.md",
143
+ suggest_source: "suggest-source.md",
144
+ }[type];
145
+ }
146
+ function requestIssueUrl(args) {
147
+ const titlePrefix = {
148
+ new_provider: "New provider",
149
+ recrawl: "Recrawl",
150
+ flag_outdated: "Outdated",
151
+ suggest_source: "Better source",
152
+ }[args.type];
153
+ const params = new URLSearchParams({
154
+ template: requestIssueTemplate(args.type),
155
+ title: `${titlePrefix}: ${args.provider}`,
156
+ });
157
+ return `https://github.com/withqwerty/football-docs/issues/new?${params.toString()}`;
46
158
  }
47
159
  function normaliseLimit(value, defaultValue, maxValue) {
48
160
  if (value === undefined || !Number.isFinite(value))
@@ -53,6 +165,7 @@ function searchRows(db, matchQuery, provider, limit) {
53
165
  let sql = `
54
166
  SELECT d.provider, d.category, d.title, d.content,
55
167
  d.source_type, d.source_url, d.upstream_version,
168
+ d.crawled_at,
56
169
  rank * -1 as relevance
57
170
  FROM docs_fts
58
171
  JOIN docs d ON d.id = docs_fts.rowid
@@ -84,29 +197,162 @@ function compareRows(db, matchQuery, providers) {
84
197
  sql += " ORDER BY d.provider, rank LIMIT 30";
85
198
  return db.prepare(sql).all(...params);
86
199
  }
200
+ function compareRowsForProvider(db, matchQuery, provider, limit) {
201
+ return db
202
+ .prepare(`SELECT d.provider, d.category, d.title, d.content,
203
+ rank * -1 as relevance
204
+ FROM docs_fts
205
+ JOIN docs d ON d.id = docs_fts.rowid
206
+ WHERE docs_fts MATCH ? AND d.provider = ?
207
+ ORDER BY rank
208
+ LIMIT ?`)
209
+ .all(matchQuery, normaliseProvider(provider), limit);
210
+ }
87
211
  function sourceLabel(row) {
212
+ const crawledAt = row.crawled_at ? ` | crawled ${row.crawled_at}` : "";
88
213
  if (row.source_type === "curated") {
89
- return "**Source:** curated by football-docs contributors";
214
+ return `**Source:** curated by football-docs contributors${crawledAt}`;
90
215
  }
91
- return `**Source:** ${row.source_type}${row.source_url ? ` (${row.source_url})` : ""}${row.upstream_version ? ` | v${row.upstream_version}` : ""}`;
216
+ return `**Source:** ${row.source_type}${row.source_url ? ` (${row.source_url})` : ""}${row.upstream_version ? ` | v${row.upstream_version}` : ""}${crawledAt}`;
217
+ }
218
+ function providerMetadata(provider) {
219
+ return PROVIDER_REGISTRY.providers[provider];
220
+ }
221
+ function providerCoverage(db, provider) {
222
+ const rows = db
223
+ .prepare(`SELECT category, COUNT(*) as chunks
224
+ FROM docs
225
+ WHERE provider = ?
226
+ GROUP BY category
227
+ ORDER BY category`)
228
+ .all(provider);
229
+ return {
230
+ indexed: rows.length > 0,
231
+ total: rows.reduce((sum, row) => sum + row.chunks, 0),
232
+ categories: rows.map((row) => `${row.category} (${row.chunks})`),
233
+ };
234
+ }
235
+ function formatProviderResolution(db, provider, originalQuery) {
236
+ const metadata = providerMetadata(provider);
237
+ const coverage = providerCoverage(db, provider);
238
+ const aliasText = metadata?.aliases.length ? metadata.aliases.join(", ") : "none";
239
+ const sourceText = metadata?.sources
240
+ .map((source) => `${source.type}${source.url ? `: ${source.url}` : ""}`)
241
+ .join("; ") || "none registered";
242
+ return [
243
+ `Resolved "${originalQuery}" to provider ID: **${provider}**`,
244
+ `**Display name:** ${metadata?.display_name ?? provider}`,
245
+ `**Indexed:** ${coverage.indexed ? `yes (${coverage.total} chunks)` : "no"}`,
246
+ `**Categories:** ${coverage.categories.length ? coverage.categories.join(", ") : "none indexed"}`,
247
+ `**Aliases:** ${aliasText}`,
248
+ `**Access level:** ${metadata?.access_level ?? "unknown"}`,
249
+ `**Licence/status:** ${metadata?.licence_status ?? "unknown"}`,
250
+ `**Public-safety notes:** ${metadata?.public_safety_notes ?? "none registered"}`,
251
+ `**Version:** ${metadata?.version ?? "unversioned"}`,
252
+ `**Last crawled:** ${metadata?.last_crawled ?? "not crawled"}`,
253
+ `**Registered sources:** ${sourceText}`,
254
+ ].join("\n");
255
+ }
256
+ function providerDocsRows(db, provider, matchQuery, category, limit) {
257
+ const params = [];
258
+ let sql;
259
+ if (matchQuery) {
260
+ sql = `
261
+ SELECT d.provider, d.category, d.title, d.content,
262
+ d.source_type, d.source_url, d.upstream_version,
263
+ d.crawled_at,
264
+ rank * -1 as relevance
265
+ FROM docs_fts
266
+ JOIN docs d ON d.id = docs_fts.rowid
267
+ WHERE docs_fts MATCH ? AND d.provider = ?
268
+ `;
269
+ params.push(matchQuery, provider);
270
+ }
271
+ else {
272
+ sql = `
273
+ SELECT d.provider, d.category, d.title, d.content,
274
+ d.source_type, d.source_url, d.upstream_version,
275
+ d.crawled_at,
276
+ 0 as relevance
277
+ FROM docs d
278
+ WHERE d.provider = ?
279
+ `;
280
+ params.push(provider);
281
+ }
282
+ if (category) {
283
+ sql += " AND d.category = ?";
284
+ params.push(category);
285
+ }
286
+ sql += matchQuery ? " ORDER BY rank LIMIT ?" : " ORDER BY d.category, d.id LIMIT ?";
287
+ params.push(limit);
288
+ return db.prepare(sql).all(...params);
289
+ }
290
+ export function resolveProviderId(db, args) {
291
+ const provider = normaliseProvider(args.query);
292
+ const registryProviders = new Set(Object.keys(PROVIDER_REGISTRY.providers));
293
+ const indexedProviderSet = indexedProviders(db);
294
+ if (registryProviders.has(provider)) {
295
+ return textResult(formatProviderResolution(db, provider, args.query));
296
+ }
297
+ const suggestions = providerSuggestions(provider, new Set([...registryProviders, ...indexedProviderSet]));
298
+ const suggestionText = suggestions.length ? ` Did you mean: ${suggestions.join(", ")}?` : "";
299
+ return textResult(`Provider "${args.query}" is not registered.${suggestionText} Use request_update to suggest adding it, or open a GitHub issue with the new-provider template.`, true);
300
+ }
301
+ export function getProviderDocs(db, args) {
302
+ const provider = normaliseProvider(args.provider);
303
+ const providerSet = indexedProviders(db);
304
+ if (!providerSet.has(provider)) {
305
+ const registryProviders = new Set(Object.keys(PROVIDER_REGISTRY.providers));
306
+ if (registryProviders.has(provider)) {
307
+ return textResult(`Provider "${providerFilterLabel(args.provider, provider)}" is registered but has no indexed docs yet. Use request_update to ask maintainers to crawl or curate it.`, true);
308
+ }
309
+ return textResult(unknownProviderMessage(args.provider, provider, providerSet), true);
310
+ }
311
+ const limit = normaliseLimit(args.max_results, 10, 50);
312
+ const category = args.category ? slugProvider(args.category) : undefined;
313
+ const strictQuery = args.topic ? sanitiseFtsQuery(args.topic) : undefined;
314
+ const fallbackQuery = args.topic ? relaxedFtsQuery(args.topic) : undefined;
315
+ let rows = providerDocsRows(db, provider, strictQuery, category, limit);
316
+ if (rows.length === 0 && fallbackQuery && fallbackQuery !== strictQuery) {
317
+ rows = providerDocsRows(db, provider, fallbackQuery, category, limit);
318
+ }
319
+ if (rows.length === 0) {
320
+ return textResult(`No provider docs found for "${providerFilterLabel(args.provider, provider)}"${category ? ` in category "${category}"` : ""}${args.topic ? ` matching "${args.topic}"` : ""}. Call resolve_provider_id or list_providers to inspect available coverage.`);
321
+ }
322
+ const metadata = providerMetadata(provider);
323
+ const docs = rows
324
+ .map((row, index) => {
325
+ return `## [${index + 1}] ${row.title}\n**Provider:** ${row.provider} | **Category:** ${row.category} | ${sourceLabel(row)}\n\n${row.content}`;
326
+ })
327
+ .join("\n\n---\n\n");
328
+ return textResult(`Provider docs for **${provider}** (${metadata?.display_name ?? provider})${args.topic ? ` matching "${args.topic}"` : ""}${category ? ` in category "${category}"` : ""}:\n\n${docs}`);
92
329
  }
93
330
  export function searchDocs(db, args) {
94
331
  const limit = normaliseLimit(args.max_results, 10, 50);
95
332
  const strictQuery = sanitiseFtsQuery(args.query);
96
333
  const fallbackQuery = relaxedFtsQuery(args.query);
97
- let rows = searchRows(db, strictQuery, args.provider, limit);
334
+ const provider = args.provider ? normaliseProvider(args.provider) : undefined;
335
+ if (args.provider && provider) {
336
+ const providerSet = indexedProviders(db);
337
+ if (!providerSet.has(provider)) {
338
+ return textResult(unknownProviderMessage(args.provider, provider, providerSet), true);
339
+ }
340
+ }
341
+ let rows = searchRows(db, strictQuery, provider, limit);
98
342
  if (rows.length === 0 && fallbackQuery !== strictQuery) {
99
- rows = searchRows(db, fallbackQuery, args.provider, limit);
343
+ rows = searchRows(db, fallbackQuery, provider, limit);
100
344
  }
101
345
  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.`);
346
+ const providerLabel = providerFilterLabel(args.provider, provider);
347
+ return textResult(`No results found for "${args.query}"${providerLabel ? ` in ${providerLabel}` : ""}. Try broader football-data terms, remove the provider filter, or call list_providers to inspect coverage.`);
103
348
  }
104
349
  const results = rows
105
350
  .map((row, index) => {
106
351
  return `## [${index + 1}] ${row.title}\n**Provider:** ${row.provider} | **Category:** ${row.category} | ${sourceLabel(row)}\n\n${row.content}`;
107
352
  })
108
353
  .join("\n\n---\n\n");
109
- return textResult(`Found ${rows.length} result(s) for "${args.query}"${args.provider ? ` in ${args.provider}` : ""}:\n\n${results}`);
354
+ const providerLabel = providerFilterLabel(args.provider, provider);
355
+ return textResult(`Found ${rows.length} result(s) for "${args.query}"${providerLabel ? ` in ${providerLabel}` : ""}:\n\n${results}`);
110
356
  }
111
357
  export function listProviders(db) {
112
358
  const rows = db
@@ -124,7 +370,9 @@ export function listProviders(db) {
124
370
  }
125
371
  const lines = [...byProvider.entries()]
126
372
  .map(([provider, info]) => {
127
- return `**${provider}** (${info.total} chunks): ${info.categories.join(", ")}`;
373
+ const aliases = DISPLAY_PROVIDER_ALIASES[provider];
374
+ const aliasText = aliases?.length ? ` | aliases: ${aliases.join(", ")}` : "";
375
+ return `**${provider}** (${info.total} chunks): ${info.categories.join(", ")}${aliasText}`;
128
376
  })
129
377
  .join("\n");
130
378
  return textResult(`Indexed providers:\n\n${lines}`);
@@ -132,11 +380,39 @@ export function listProviders(db) {
132
380
  export function compareProviders(db, args) {
133
381
  const strictQuery = sanitiseFtsQuery(args.topic);
134
382
  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);
383
+ let rows;
384
+ let requestedProviders = [];
385
+ const providerSet = indexedProviders(db);
386
+ if (args.providers?.length) {
387
+ requestedProviders = [...new Set(args.providers.map(normaliseProvider))];
388
+ rows = requestedProviders.flatMap((provider) => {
389
+ if (!providerSet.has(provider))
390
+ return [];
391
+ const strictRows = compareRowsForProvider(db, strictQuery, provider, 3);
392
+ if (strictRows.length > 0 || fallbackQuery === strictQuery)
393
+ return strictRows;
394
+ return compareRowsForProvider(db, fallbackQuery, provider, 3);
395
+ });
396
+ }
397
+ else {
398
+ rows = compareRows(db, strictQuery, args.providers);
399
+ if (rows.length === 0 && fallbackQuery !== strictQuery) {
400
+ rows = compareRows(db, fallbackQuery, args.providers);
401
+ }
138
402
  }
139
403
  if (rows.length === 0) {
404
+ if (requestedProviders.length > 0) {
405
+ const missingProviders = requestedProviders.filter((provider) => !providerSet.has(provider));
406
+ const providerNote = missingProviders.length
407
+ ? ` Requested provider(s) not indexed: ${missingProviders.join(", ")}.${missingProviders
408
+ .map((provider) => {
409
+ const suggestions = providerSuggestions(provider, providerSet);
410
+ return suggestions.length ? ` ${provider}: did you mean ${suggestions.join(", ")}?` : "";
411
+ })
412
+ .join("")}`
413
+ : "";
414
+ return textResult(`No matching docs found for "${args.topic}" across requested provider(s): ${requestedProviders.join(", ")}.${providerNote} Call list_providers to inspect coverage, or use request_update to suggest missing provider docs.`);
415
+ }
140
416
  return textResult(`No documentation found for "${args.topic}". Try different terms.`);
141
417
  }
142
418
  const grouped = new Map();
@@ -148,27 +424,39 @@ export function compareProviders(db, args) {
148
424
  const sections = [...grouped.entries()]
149
425
  .map(([provider, chunks]) => `## ${provider}\n\n${chunks.slice(0, 3).join("\n\n")}`)
150
426
  .join("\n\n---\n\n");
151
- return textResult(`Comparison for "${args.topic}" across ${grouped.size} provider(s):\n\n${sections}`);
427
+ const missingProviders = requestedProviders.filter((provider) => !grouped.has(provider));
428
+ const missingNote = missingProviders.length
429
+ ? `\n\nNo matching docs found for requested provider(s): ${missingProviders.join(", ")}.${missingProviders
430
+ .map((provider) => {
431
+ if (providerSet.has(provider))
432
+ return "";
433
+ const suggestions = providerSuggestions(provider, providerSet);
434
+ return suggestions.length ? ` ${provider}: did you mean ${suggestions.join(", ")}?` : "";
435
+ })
436
+ .join("")}`
437
+ : "";
438
+ return textResult(`Comparison for "${args.topic}" across ${grouped.size} provider(s):\n\n${sections}${missingNote}`);
152
439
  }
153
440
  export function requestUpdate(db, args, options = {}) {
441
+ const requestedAt = (options.now ?? new Date()).toISOString();
442
+ const providerKey = requestProviderKey(args.provider);
154
443
  const countRow = db.prepare("SELECT COUNT(*) as cnt FROM requests").get();
155
444
  if (countRow.cnt >= 500) {
156
445
  return textResult(`Request queue is full (${countRow.cnt} entries). Please file an issue at https://github.com/withqwerty/football-docs/issues instead.`, true);
157
446
  }
158
447
  const recentRequest = db
159
448
  .prepare(`SELECT id, requested_at FROM requests
160
- WHERE lower(provider) = lower(?) AND status = 'pending'
161
- AND datetime(requested_at) > datetime('now', '-7 days')
449
+ WHERE provider = ? AND status = 'pending'
450
+ AND datetime(requested_at) > datetime(?, '-7 days')
162
451
  LIMIT 1`)
163
- .get(args.provider);
452
+ .get(providerKey, requestedAt);
164
453
  if (recentRequest) {
165
454
  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
455
  }
167
456
  const id = options.requestId ??
168
457
  `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
169
- const requestedAt = (options.now ?? new Date()).toISOString();
170
458
  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);
459
+ VALUES (?, ?, ?, ?, ?, ?, 'pending')`).run(id, args.type, providerKey, args.reason, args.suggested_urls ? JSON.stringify(args.suggested_urls) : null, requestedAt);
172
460
  const typeLabel = {
173
461
  new_provider: "New provider request",
174
462
  recrawl: "Recrawl request",
@@ -177,7 +465,7 @@ export function requestUpdate(db, args, options = {}) {
177
465
  }[args.type];
178
466
  return textResult(`${typeLabel} queued for "${args.provider}" (ID: ${id}).\n\nReason: ${args.reason}${args.suggested_urls?.length
179
467
  ? `\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.`);
468
+ : ""}\n\nThis local request will be reviewed by maintainers. For community visibility, also open the matching GitHub issue: ${requestIssueUrl(args)}`);
181
469
  }
182
470
  export async function resolveEntity(args, options = {}) {
183
471
  const baseUrl = options.baseUrl ?? "https://reep-api.rahulkeerthi2-95d.workers.dev";
@@ -200,6 +488,9 @@ export async function resolveEntity(args, options = {}) {
200
488
  try {
201
489
  const response = await fetchImpl(url);
202
490
  if (!response.ok) {
491
+ if (response.status === 401 || response.status === 403) {
492
+ return textResult(`Reep API authentication failed (${response.status} ${response.statusText}). Entity resolution is optional and needs a Reep API credential/configured endpoint before provider ID lookup can work. Docs search tools still work without Reep access.`, true);
493
+ }
203
494
  return textResult(`Reep API error: ${response.status} ${response.statusText}`, true);
204
495
  }
205
496
  const data = (await response.json());