@sportsbarwatch/mcp-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # SportsBarWatch MCP Server
2
+
3
+ Find sports bars showing live matches worldwide — World Cup 2026, Premier League, Champions League, and 30+ sports across 2,000+ bars in 99 countries.
4
+
5
+ An [MCP (Model Context Protocol)](https://modelcontextprotocol.io) server that lets AI assistants answer questions like:
6
+
7
+ - "Where can I watch Arsenal vs Chelsea in London?"
8
+ - "What's on at sports bars in Stockholm tonight?"
9
+ - "Find bars showing the World Cup in Tokyo"
10
+
11
+ ## Tools
12
+
13
+ | Tool | Description |
14
+ |------|-------------|
15
+ | `find_bars_showing_match` | Find bars showing a specific match by team name or match ID |
16
+ | `whats_on_tonight` | Get all matches showing at bars tonight in a city |
17
+ | `bar_details` | Get full details about a bar including upcoming matches |
18
+ | `search_matches` | Search matches by team, competition, or sport |
19
+ | `team_schedule` | Get upcoming schedule for a team with bar counts |
20
+
21
+ ## Quick Start
22
+
23
+ ### With Claude Desktop
24
+
25
+ Add to your config (`~/Library/Application Support/Claude/claude_desktop_config.json`):
26
+
27
+ ```json
28
+ {
29
+ "mcpServers": {
30
+ "sportsbarwatch": {
31
+ "command": "npx",
32
+ "args": ["-y", "@sportsbarwatch/mcp-server"],
33
+ "env": {
34
+ "SBW_DB_PATH": "/path/to/vargarmatchen.db"
35
+ }
36
+ }
37
+ }
38
+ }
39
+ ```
40
+
41
+ ### With Claude Code
42
+
43
+ ```bash
44
+ claude mcp add sportsbarwatch -- npx -y @sportsbarwatch/mcp-server
45
+ ```
46
+
47
+ ### From source
48
+
49
+ ```bash
50
+ git clone https://github.com/eliastuff/vargarmatchen
51
+ cd vargarmatchen/mcp-server
52
+ npm install && npm run build
53
+ node dist/index.js
54
+ ```
55
+
56
+ ## Environment Variables
57
+
58
+ | Variable | Description | Default |
59
+ |----------|-------------|---------|
60
+ | `SBW_DB_PATH` | Path to SQLite database | `../data/vargarmatchen.db` |
61
+ | `SBW_TELEMETRY` | Set to `off` to disable demand logging | `on` |
62
+
63
+ ## Anonymous Demand Logging
64
+
65
+ By default, the server logs anonymous demand signals (team + city + result count) to a local SQLite file (`mcp-demand.db`) next to the main database. This helps us understand which cities and teams have unmet demand so we can expand bar coverage.
66
+
67
+ **What is logged:** tool name, team/query (truncated to 80 chars, sanitized), city (bucketed to "[small city]" when fewer than 5 bars), sport, result count, date. One entry per unique combination per day. Data retained for 365 days.
68
+
69
+ **What is NOT logged:** no IP, no user identity, no device ID, no session tracking. Query inputs are truncated and sanitized — not raw conversation content.
70
+
71
+ **Opt out:** Set `SBW_TELEMETRY=off` in your environment.
72
+
73
+ ## Data
74
+
75
+ The server reads from a SQLite database (read-only, no writes) containing:
76
+
77
+ - **2,000+ bars** across 99 countries and 680+ cities
78
+ - **Daily-updated** match schedules from 188 automated scrapers
79
+ - **30+ sports**: football, ice hockey, basketball, tennis, rugby, motorsport, and more
80
+ - **World Cup 2026**: all 48 teams, group stage schedule, host city venues
81
+
82
+ Data is scraped daily from bar websites via GitHub Actions. Major chains include O'Learys, Walkabout, Social Pub & Kitchen, Belushi's, and hundreds of independent sports bars.
83
+
84
+ ## Example Queries
85
+
86
+ Ask your AI assistant:
87
+
88
+ - "Where can I watch the Champions League final in Berlin?"
89
+ - "What sports bars in Melbourne are showing AFL tonight?"
90
+ - "Find a bar showing England vs Croatia in the World Cup in London"
91
+ - "What's the schedule for Real Madrid this week?"
92
+ - "Tell me about O'Learys TOLV in Stockholm"
93
+
94
+ ## Links
95
+
96
+ - **Website**: [sportsbarwatch.com](https://sportsbarwatch.com)
97
+ - **GitHub**: [eliastuff/vargarmatchen](https://github.com/eliastuff/vargarmatchen)
98
+ - **World Cup 2026**: [sportsbarwatch.com/worldcup](https://sportsbarwatch.com/worldcup)
99
+
100
+ ## License
101
+
102
+ MIT
package/dist/db.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ import Database from "better-sqlite3";
2
+ export declare function getDb(): Database.Database;
3
+ /** Writable demand logging database — separate from main DB to preserve read-only guarantee */
4
+ export declare function getDemandDb(): Database.Database | null;
package/dist/db.js ADDED
@@ -0,0 +1,55 @@
1
+ import Database from "better-sqlite3";
2
+ import path from "path";
3
+ const DB_PATH = process.env.SBW_DB_PATH
4
+ || (process.env.FLY_APP_NAME ? "/data/vargarmatchen.db" : path.join(process.cwd(), "..", "data", "vargarmatchen.db"));
5
+ const DEMAND_DB_PATH = process.env.SBW_DEMAND_DB_PATH
6
+ || path.join(path.dirname(DB_PATH), "mcp-demand.db");
7
+ let db = null;
8
+ let demandDb = null;
9
+ let demandDbFailed = false; // Don't retry if init failed (read-only FS, etc.)
10
+ export function getDb() {
11
+ if (!db) {
12
+ db = new Database(DB_PATH, { readonly: true });
13
+ db.pragma("journal_mode = WAL");
14
+ }
15
+ return db;
16
+ }
17
+ /** Writable demand logging database — separate from main DB to preserve read-only guarantee */
18
+ export function getDemandDb() {
19
+ if (demandDbFailed)
20
+ return null;
21
+ if (!demandDb) {
22
+ try {
23
+ demandDb = new Database(DEMAND_DB_PATH);
24
+ demandDb.pragma("journal_mode = WAL");
25
+ // Drop and recreate on schema change — demand data is ephemeral/local, no migration needed
26
+ const tableInfo = demandDb.prepare(`PRAGMA table_info(mcp_demand)`).all();
27
+ const needsRecreate = tableInfo.length > 0 && tableInfo.some(c => c.name === "team" && c.notnull === 0);
28
+ if (needsRecreate) {
29
+ demandDb.exec(`DROP TABLE IF EXISTS mcp_demand`);
30
+ }
31
+ demandDb.exec(`
32
+ CREATE TABLE IF NOT EXISTS mcp_demand (
33
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
34
+ tool TEXT NOT NULL,
35
+ team TEXT NOT NULL DEFAULT '',
36
+ city TEXT NOT NULL DEFAULT '',
37
+ sport TEXT NOT NULL DEFAULT '',
38
+ results INTEGER NOT NULL DEFAULT 0,
39
+ date TEXT NOT NULL DEFAULT (date('now')),
40
+ UNIQUE(tool, team, city, date)
41
+ );
42
+ CREATE INDEX IF NOT EXISTS idx_demand_date ON mcp_demand(date);
43
+ CREATE INDEX IF NOT EXISTS idx_demand_city ON mcp_demand(city);
44
+ `);
45
+ // Prune entries older than 365 days to prevent unbounded growth
46
+ demandDb.exec(`DELETE FROM mcp_demand WHERE date < date('now', '-365 days')`);
47
+ }
48
+ catch (err) {
49
+ demandDbFailed = true;
50
+ console.error("[demand] DB init failed (read-only FS?):", err.message);
51
+ return null;
52
+ }
53
+ }
54
+ return demandDb;
55
+ }
@@ -0,0 +1,7 @@
1
+ export declare function logDemand(params: {
2
+ tool: string;
3
+ team?: string;
4
+ city?: string;
5
+ sport?: string;
6
+ results: number;
7
+ }): void;
package/dist/demand.js ADDED
@@ -0,0 +1,55 @@
1
+ import { getDemandDb, getDb } from "./db.js";
2
+ const TELEMETRY_ENABLED = process.env.SBW_TELEMETRY !== "off";
3
+ // Cities with fewer than this many bars get bucketed as "[small city]" for privacy
4
+ const MIN_BARS_FOR_CITY_NAME = 5;
5
+ // Cache bar counts per city to avoid repeated queries
6
+ let cityBarCounts = null;
7
+ function getCityBarCount(city) {
8
+ if (!cityBarCounts) {
9
+ cityBarCounts = new Map();
10
+ try {
11
+ const rows = getDb().prepare(`SELECT city, COUNT(*) as cnt FROM bars GROUP BY city`).all();
12
+ for (const r of rows)
13
+ cityBarCounts.set(r.city, r.cnt);
14
+ }
15
+ catch {
16
+ // If main DB isn't available, bucket all cities for privacy safety
17
+ return 0;
18
+ }
19
+ }
20
+ return cityBarCounts.get(city) || 0;
21
+ }
22
+ function bucketCity(city) {
23
+ if (!city)
24
+ return null;
25
+ return getCityBarCount(city) >= MIN_BARS_FOR_CITY_NAME ? city : "[small city]";
26
+ }
27
+ /**
28
+ * Log an anonymous demand signal. Deduplicates by tool+team+city per day.
29
+ * Only logs the highest results count per dedup key per day.
30
+ */
31
+ // Truncate and sanitize input to prevent storing arbitrary user text
32
+ function sanitize(input, maxLen = 80) {
33
+ if (!input)
34
+ return "";
35
+ return input.slice(0, maxLen).replace(/[\n\r\t]/g, " ").trim();
36
+ }
37
+ export function logDemand(params) {
38
+ if (!TELEMETRY_ENABLED)
39
+ return;
40
+ try {
41
+ const db = getDemandDb();
42
+ if (!db)
43
+ return;
44
+ const city = bucketCity(params.city);
45
+ db.prepare(`INSERT INTO mcp_demand (tool, team, city, sport, results)
46
+ VALUES (?, ?, ?, ?, ?)
47
+ ON CONFLICT(tool, team, city, date) DO UPDATE SET
48
+ results = MAX(results, excluded.results),
49
+ sport = CASE WHEN excluded.sport != '' THEN excluded.sport ELSE sport END`).run(params.tool, sanitize(params.team), city || "", sanitize(params.sport), params.results);
50
+ }
51
+ catch (err) {
52
+ // Never break tool responses, but log to stderr for debugging
53
+ console.error("[demand] write failed:", err.message);
54
+ }
55
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+ import { findBarsShowingMatch, whatsOnTonight, barDetails, searchMatches, teamSchedule } from "./tools.js";
6
+ import { checkRateLimit } from "./rate-limit.js";
7
+ import { logDemand } from "./demand.js";
8
+ const server = new McpServer({
9
+ name: "sportsbarwatch",
10
+ version: "0.1.0",
11
+ });
12
+ // Session ID for rate limiting (one per MCP connection)
13
+ const sessionId = process.env.SBW_API_KEY || `session-${Date.now()}`;
14
+ function rateLimitGuard() {
15
+ const { allowed, remaining } = checkRateLimit(sessionId);
16
+ if (!allowed) {
17
+ return {
18
+ content: [{
19
+ type: "text",
20
+ text: `Rate limit exceeded (100 queries/day). Contact hello@sportsbarwatch.com for higher limits.`,
21
+ }],
22
+ };
23
+ }
24
+ return null;
25
+ }
26
+ server.tool("find_bars_showing_match", "Find bars showing a specific match. Give team names, match description, or match ID, plus optional city to filter.", {
27
+ query: z.string().describe("Team name(s), match description, or match ID. E.g. 'Arsenal vs Chelsea' or 'Arsenal'"),
28
+ city: z.string().optional().describe("City to filter bars. E.g. 'Stockholm', 'London'"),
29
+ sport: z.string().optional().describe("Sport hint for telemetry. E.g. 'Fotboll', 'Ishockey'. Does not filter results (yet)."),
30
+ }, async ({ query, city, sport }) => {
31
+ const blocked = rateLimitGuard();
32
+ if (blocked)
33
+ return blocked;
34
+ const result = findBarsShowingMatch(query, city);
35
+ logDemand({ tool: "find_bars_showing_match", team: query, city, sport, results: result.bars.length });
36
+ if (!result.match) {
37
+ return { content: [{ type: "text", text: `No upcoming matches found for "${query}".` }] };
38
+ }
39
+ const matchStr = `${result.match.home_team} vs ${result.match.away_team} (${result.match.competition}) — ${result.match.date_time}`;
40
+ const barsStr = result.bars.length > 0
41
+ ? result.bars.map((b) => `- ${b.name}, ${b.address}, ${b.area}, ${b.city}${b.phone ? ` (${b.phone})` : ""}`).join("\n")
42
+ : "No bars found showing this match" + (city ? ` in ${city}` : "") + ".";
43
+ return {
44
+ content: [{
45
+ type: "text",
46
+ text: `Match: ${matchStr}\n\n${result.bars.length} bar(s):\n${barsStr}\n\nMore info: https://sportsbarwatch.com/match/${result.match.id}`,
47
+ }],
48
+ };
49
+ });
50
+ server.tool("whats_on_tonight", "Get all matches being shown at bars tonight in a specific city.", {
51
+ city: z.string().describe("City name. E.g. 'Stockholm', 'London', 'Berlin'"),
52
+ }, async ({ city }) => {
53
+ const blocked = rateLimitGuard();
54
+ if (blocked)
55
+ return blocked;
56
+ const result = whatsOnTonight(city);
57
+ logDemand({ tool: "whats_on_tonight", city, results: result.matches.length });
58
+ if (result.matches.length === 0) {
59
+ return { content: [{ type: "text", text: `No matches showing tonight in ${city}.` }] };
60
+ }
61
+ const lines = result.matches.map((m) => `- ${m.date_time.slice(11, 16)} | ${m.home_team} vs ${m.away_team} (${m.competition}) — ${m.bar_count} bar(s)`).join("\n");
62
+ return {
63
+ content: [{
64
+ type: "text",
65
+ text: `${result.matches.length} match(es) tonight in ${city}:\n\n${lines}\n\nFull schedule: https://sportsbarwatch.com/tonight/${encodeURIComponent(city)}`,
66
+ }],
67
+ };
68
+ });
69
+ server.tool("bar_details", "Get full details about a specific bar including address, contact info, and upcoming matches.", {
70
+ bar: z.string().describe("Bar name or ID. E.g. 'O'Learys Södermalm' or 'olearys-sodermalm'"),
71
+ }, async ({ bar }) => {
72
+ const blocked = rateLimitGuard();
73
+ if (blocked)
74
+ return blocked;
75
+ const result = barDetails(bar);
76
+ logDemand({ tool: "bar_details", results: result.bar ? 1 : 0 });
77
+ if (!result.bar) {
78
+ return { content: [{ type: "text", text: `Bar "${bar}" not found.` }] };
79
+ }
80
+ const b = result.bar;
81
+ const info = [
82
+ `${b.name}`,
83
+ `${b.address}, ${b.area}, ${b.city}, ${b.country}`,
84
+ b.phone ? `Phone: ${b.phone}` : null,
85
+ result.tags.length > 0 ? `Tags: ${result.tags.join(", ")}` : null,
86
+ "",
87
+ `${result.matches.length} upcoming match(es):`,
88
+ ...result.matches.map((m) => `- ${m.date_time.slice(0, 16)} | ${m.home_team} vs ${m.away_team} (${m.competition})`),
89
+ ].filter(Boolean).join("\n");
90
+ return {
91
+ content: [{
92
+ type: "text",
93
+ text: `${info}\n\nMore info: https://sportsbarwatch.com/barer/${b.id}`,
94
+ }],
95
+ };
96
+ });
97
+ server.tool("search_matches", "Search for upcoming matches by team name, competition, or sport. Returns matches with bar counts.", {
98
+ query: z.string().describe("Search term — team name, competition, or sport. E.g. 'Champions League', 'Fotboll', 'Arsenal'"),
99
+ city: z.string().optional().describe("Optional city to filter. E.g. 'Stockholm'"),
100
+ }, async ({ query, city }) => {
101
+ const blocked = rateLimitGuard();
102
+ if (blocked)
103
+ return blocked;
104
+ const result = searchMatches(query, city);
105
+ logDemand({ tool: "search_matches", team: query, city, results: result.matches.length });
106
+ if (result.matches.length === 0) {
107
+ return { content: [{ type: "text", text: `No upcoming matches found for "${query}"${city ? ` in ${city}` : ""}.` }] };
108
+ }
109
+ const lines = result.matches.map((m) => `- ${m.date_time.slice(0, 16)} | ${m.home_team} vs ${m.away_team} (${m.competition}, ${m.sport}) — ${m.bar_count} bar(s)`).join("\n");
110
+ return {
111
+ content: [{
112
+ type: "text",
113
+ text: `${result.matches.length} match(es) found:\n\n${lines}`,
114
+ }],
115
+ };
116
+ });
117
+ server.tool("team_schedule", "Get the upcoming schedule for a specific team, with bar counts and cities where each match is shown.", {
118
+ team: z.string().describe("Team name. E.g. 'Arsenal', 'Real Madrid', 'AIK'"),
119
+ city: z.string().optional().describe("Optional city to filter bars. E.g. 'Stockholm'"),
120
+ }, async ({ team, city }) => {
121
+ const blocked = rateLimitGuard();
122
+ if (blocked)
123
+ return blocked;
124
+ const result = teamSchedule(team, city);
125
+ logDemand({ tool: "team_schedule", team, city, results: result.matches.length });
126
+ if (result.matches.length === 0) {
127
+ return { content: [{ type: "text", text: `No upcoming matches found for "${team}".` }] };
128
+ }
129
+ const lines = result.matches.map((m) => `- ${m.date_time.slice(0, 16)} | ${m.home_team} vs ${m.away_team} (${m.competition}) — ${m.bar_count} bar(s) in ${m.cities.slice(0, 5).join(", ")}`).join("\n");
130
+ return {
131
+ content: [{
132
+ type: "text",
133
+ text: `${result.matches.length} upcoming match(es) for ${team}:\n\n${lines}`,
134
+ }],
135
+ };
136
+ });
137
+ async function main() {
138
+ const transport = new StdioServerTransport();
139
+ await server.connect(transport);
140
+ console.error("SportsBarWatch MCP server running on stdio");
141
+ }
142
+ main().catch(console.error);
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Simple in-memory rate limiter for MCP server.
3
+ * Tracks queries per session with a sliding daily window.
4
+ * Free tier: 100 queries/day. No API key = free tier.
5
+ */
6
+ export declare function checkRateLimit(sessionId: string): {
7
+ allowed: boolean;
8
+ remaining: number;
9
+ };
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Simple in-memory rate limiter for MCP server.
3
+ * Tracks queries per session with a sliding daily window.
4
+ * Free tier: 100 queries/day. No API key = free tier.
5
+ */
6
+ const DAILY_LIMIT = parseInt(process.env.SBW_RATE_LIMIT || "100", 10);
7
+ const store = new Map();
8
+ // Clean up expired entries every 10 minutes
9
+ setInterval(() => {
10
+ const now = Date.now();
11
+ for (const [key, entry] of store) {
12
+ if (entry.resetAt < now)
13
+ store.delete(key);
14
+ }
15
+ }, 10 * 60 * 1000);
16
+ export function checkRateLimit(sessionId) {
17
+ const now = Date.now();
18
+ const dayMs = 24 * 60 * 60 * 1000;
19
+ const entry = store.get(sessionId);
20
+ if (!entry || entry.resetAt < now) {
21
+ store.set(sessionId, { count: 1, resetAt: now + dayMs });
22
+ return { allowed: true, remaining: DAILY_LIMIT - 1 };
23
+ }
24
+ entry.count++;
25
+ if (entry.count > DAILY_LIMIT) {
26
+ return { allowed: false, remaining: 0 };
27
+ }
28
+ return { allowed: true, remaining: DAILY_LIMIT - entry.count };
29
+ }
@@ -0,0 +1,43 @@
1
+ export interface Bar {
2
+ id: string;
3
+ name: string;
4
+ address: string;
5
+ area: string;
6
+ city: string;
7
+ country: string;
8
+ phone: string | null;
9
+ }
10
+ interface Match {
11
+ id: string;
12
+ home_team: string;
13
+ away_team: string;
14
+ competition: string;
15
+ sport: string;
16
+ date_time: string;
17
+ }
18
+ export declare function findBarsShowingMatch(teamOrMatch: string, city?: string): {
19
+ match: Match | null;
20
+ bars: Bar[];
21
+ };
22
+ export declare function whatsOnTonight(city: string): {
23
+ matches: (Match & {
24
+ bar_count: number;
25
+ })[];
26
+ };
27
+ export declare function barDetails(barNameOrId: string): {
28
+ bar: Bar | null;
29
+ matches: Match[];
30
+ tags: string[];
31
+ };
32
+ export declare function searchMatches(query: string, city?: string): {
33
+ matches: (Match & {
34
+ bar_count: number;
35
+ })[];
36
+ };
37
+ export declare function teamSchedule(team: string, city?: string): {
38
+ matches: (Match & {
39
+ bar_count: number;
40
+ cities: string[];
41
+ })[];
42
+ };
43
+ export {};
package/dist/tools.js ADDED
@@ -0,0 +1,111 @@
1
+ import { getDb } from "./db.js";
2
+ function toPublicBar(b) {
3
+ return { id: b.id, name: b.name, address: b.address, area: b.area, city: b.city, country: b.country, phone: b.phone };
4
+ }
5
+ export function findBarsShowingMatch(teamOrMatch, city) {
6
+ const db = getDb();
7
+ const search = `%${teamOrMatch}%`;
8
+ // Find matching match
9
+ const match = db.prepare(`SELECT * FROM matches
10
+ WHERE (home_team LIKE ? OR away_team LIKE ? OR id LIKE ?)
11
+ AND date_time >= datetime('now', '-2 hours')
12
+ ORDER BY date_time ASC LIMIT 1`).get(search, search, search);
13
+ if (!match)
14
+ return { match: null, bars: [] };
15
+ let rawBars;
16
+ if (city) {
17
+ rawBars = db.prepare(`SELECT DISTINCT b.* FROM bars b
18
+ JOIN screenings s ON s.bar_id = b.id
19
+ WHERE s.match_id = ? AND b.city = ?
20
+ ORDER BY b.name`).all(match.id, city);
21
+ }
22
+ else {
23
+ rawBars = db.prepare(`SELECT DISTINCT b.* FROM bars b
24
+ JOIN screenings s ON s.bar_id = b.id
25
+ WHERE s.match_id = ?
26
+ ORDER BY b.name`).all(match.id);
27
+ }
28
+ return { match, bars: rawBars.map(toPublicBar) };
29
+ }
30
+ export function whatsOnTonight(city) {
31
+ const db = getDb();
32
+ const today = new Date().toISOString().split("T")[0];
33
+ const matches = db.prepare(`SELECT m.*, COUNT(DISTINCT s.bar_id) as bar_count
34
+ FROM matches m
35
+ JOIN screenings s ON s.match_id = m.id
36
+ JOIN bars b ON b.id = s.bar_id
37
+ WHERE m.date_time LIKE ? || '%'
38
+ AND b.city = ?
39
+ GROUP BY m.id
40
+ ORDER BY m.date_time ASC`).all(today, city);
41
+ return { matches };
42
+ }
43
+ export function barDetails(barNameOrId) {
44
+ const db = getDb();
45
+ let rawBar = db.prepare(`SELECT * FROM bars WHERE id = ?`).get(barNameOrId);
46
+ if (!rawBar) {
47
+ rawBar = db.prepare(`SELECT * FROM bars WHERE name LIKE ? LIMIT 1`).get(`%${barNameOrId}%`);
48
+ }
49
+ if (!rawBar)
50
+ return { bar: null, matches: [], tags: [] };
51
+ const bar = toPublicBar(rawBar);
52
+ const matches = db.prepare(`SELECT DISTINCT m.* FROM matches m
53
+ JOIN screenings s ON s.match_id = m.id
54
+ WHERE s.bar_id = ? AND m.date_time >= datetime('now', '-2 hours')
55
+ ORDER BY m.date_time ASC LIMIT 20`).all(rawBar.id);
56
+ const tagRows = db.prepare(`SELECT tag FROM bar_tags WHERE bar_id = ? ORDER BY tag`).all(rawBar.id);
57
+ const tags = tagRows.map(r => r.tag);
58
+ return { bar, matches, tags };
59
+ }
60
+ export function searchMatches(query, city) {
61
+ const db = getDb();
62
+ const search = `%${query}%`;
63
+ let sql;
64
+ let params;
65
+ if (city) {
66
+ sql = `SELECT m.*, COUNT(DISTINCT s.bar_id) as bar_count
67
+ FROM matches m
68
+ JOIN screenings s ON s.match_id = m.id
69
+ JOIN bars b ON b.id = s.bar_id
70
+ WHERE (m.home_team LIKE ? OR m.away_team LIKE ? OR m.competition LIKE ? OR m.sport LIKE ?)
71
+ AND m.date_time >= datetime('now', '-2 hours')
72
+ AND b.city = ?
73
+ GROUP BY m.id
74
+ ORDER BY m.date_time ASC LIMIT 20`;
75
+ params = [search, search, search, search, city];
76
+ }
77
+ else {
78
+ sql = `SELECT m.*, COUNT(DISTINCT s.bar_id) as bar_count
79
+ FROM matches m
80
+ JOIN screenings s ON s.match_id = m.id
81
+ WHERE (m.home_team LIKE ? OR m.away_team LIKE ? OR m.competition LIKE ? OR m.sport LIKE ?)
82
+ AND m.date_time >= datetime('now', '-2 hours')
83
+ GROUP BY m.id
84
+ ORDER BY m.date_time ASC LIMIT 20`;
85
+ params = [search, search, search, search];
86
+ }
87
+ const matches = db.prepare(sql).all(...params);
88
+ return { matches };
89
+ }
90
+ export function teamSchedule(team, city) {
91
+ const db = getDb();
92
+ const search = `%${team}%`;
93
+ const rawMatches = db.prepare(`SELECT m.*, COUNT(DISTINCT s.bar_id) as bar_count
94
+ FROM matches m
95
+ JOIN screenings s ON s.match_id = m.id
96
+ WHERE (m.home_team LIKE ? OR m.away_team LIKE ?)
97
+ AND m.date_time >= datetime('now', '-2 hours')
98
+ GROUP BY m.id
99
+ ORDER BY m.date_time ASC LIMIT 20`).all(search, search);
100
+ const matches = rawMatches.map((m) => {
101
+ let citiesQuery;
102
+ if (city) {
103
+ citiesQuery = db.prepare(`SELECT DISTINCT b.city FROM bars b JOIN screenings s ON s.bar_id = b.id WHERE s.match_id = ? AND b.city = ?`).all(m.id, city);
104
+ }
105
+ else {
106
+ citiesQuery = db.prepare(`SELECT DISTINCT b.city FROM bars b JOIN screenings s ON s.bar_id = b.id WHERE s.match_id = ?`).all(m.id);
107
+ }
108
+ return { ...m, cities: citiesQuery.map((c) => c.city) };
109
+ });
110
+ return { matches };
111
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@sportsbarwatch/mcp-server",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for SportsBarWatch — find bars showing live sports worldwide",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "sportsbarwatch-mcp": "dist/index.js"
9
+ },
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "start": "node dist/index.js"
13
+ },
14
+ "keywords": [
15
+ "mcp",
16
+ "sports",
17
+ "bars",
18
+ "football",
19
+ "world-cup",
20
+ "live-sports",
21
+ "sports-bars",
22
+ "model-context-protocol"
23
+ ],
24
+ "license": "MIT",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/eliastuff/vargarmatchen",
28
+ "directory": "mcp-server"
29
+ },
30
+ "homepage": "https://sportsbarwatch.com",
31
+ "files": [
32
+ "dist/**/*",
33
+ "README.md"
34
+ ],
35
+ "engines": {
36
+ "node": ">=20"
37
+ },
38
+ "dependencies": {
39
+ "@modelcontextprotocol/sdk": "^1.12.0",
40
+ "better-sqlite3": "^12.6.2",
41
+ "zod": "^3.23.0"
42
+ },
43
+ "devDependencies": {
44
+ "@types/better-sqlite3": "^7.6.12",
45
+ "@types/node": "^20.0.0",
46
+ "typescript": "^5.5.0"
47
+ }
48
+ }