quiniela-mcp 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,93 @@
1
+ # World Cup 2026 — Quiniela + Prediction Markets
2
+
3
+ Two **completely separate** games in one dark, Polymarket-styled app:
4
+
5
+ 1. **Quiniela** — free score-prediction pool with stage-weighted scoring, Jokers,
6
+ perfect-round and champion bonuses, a live leaderboard, and a dynamic knockout bracket.
7
+ 2. **Prediction Markets** — a peer-to-peer binary (YES/NO) exchange played with admin-granted
8
+ **credits**, settled at the end of the tournament.
9
+
10
+ - **Backend** — FastAPI + SQLite + JWT auth (`backend/`)
11
+ - **Frontend** — React + Vite SPA, dark themed (`frontend/`)
12
+
13
+ ## Quick start
14
+
15
+ ### Backend
16
+ ```bash
17
+ cd backend
18
+ python -m venv venv && source venv/bin/activate
19
+ pip install -r requirements.txt
20
+ cp .env.example .env
21
+ uvicorn app.main:app --reload
22
+ ```
23
+ API + Swagger docs at http://localhost:8000/docs. A seed admin (`admin` / `admin123`),
24
+ sample teams and matches across every stage are created on first startup.
25
+
26
+ ### Frontend
27
+ ```bash
28
+ cd frontend
29
+ npm install
30
+ cp .env.example .env
31
+ npm run dev
32
+ ```
33
+ App at http://localhost:5173 (it proxies `/api` to the backend).
34
+
35
+ ## How scoring works
36
+ See `backend/app/scoring.py` and `backend/app/bonuses.py`.
37
+
38
+ **Per match** — accuracy scaled by a **stage multiplier** (group ×1 … final ×5):
39
+
40
+ | Result | Base points |
41
+ |---|---|
42
+ | Exact scoreline | 3 |
43
+ | Correct outcome (win/draw/loss) | 1 |
44
+ | Wrong | 0 |
45
+
46
+ `match_points = round(base × stage_multiplier)`, then **×2 if you played your Joker** on it.
47
+
48
+ **Bonuses**
49
+ - 🃏 **Joker** — one per group (and one per knockout round); doubles that match's points.
50
+ - 🎯 **Perfect round** — get every game in a group (or a full knockout round) correct → **+5**.
51
+ - 🏆 **Champion** — correctly pick the tournament winner → **+10** (locks at first kickoff,
52
+ or whenever the admin locks it).
53
+
54
+ All constants live in `scoring.py` and are easy to tune.
55
+
56
+ ## Data
57
+ Seeded from the real 2026 World Cup final draw (5 Dec 2025): 48 teams across
58
+ Groups A–L with country flags (via flagcdn.com) and all 72 group-stage fixtures.
59
+ Results are blank until entered.
60
+
61
+ ## Pages
62
+
63
+ **Quiniela**
64
+ - **Predict** — picks grouped by group → matchday, each with a live **countdown** + lock; one
65
+ **Joker** per round; a **champion** picker. Once a match locks, reveal **everyone's** picks.
66
+ - **Groups** — live standings tables: P / W / D / L / GF / GA / GD / Pts.
67
+ - **Bracket** — knockout slots that **populate dynamically**; admins assign teams as they
68
+ qualify (no preselection); predictable once both teams are set.
69
+ - **Leaderboard** — **live**, with a points breakdown (match / 🎯 / 🏆 / total).
70
+ - **Profile** — your stats.
71
+
72
+ **Prediction Markets** (credits, separate)
73
+ - **Markets** — per-match YES/NO markets as Polymarket-style cards with implied-probability bars.
74
+ - **Market detail** — order book: **make** an offer (pick a side + price + shares) or **take**
75
+ the other side of any offer (partial fills). Admins resolve YES/NO.
76
+ - **Wallet** — balance, locked, granted, and net.
77
+
78
+ **Shared / admin**
79
+ - **Rules** — the full rules of each game, side by side.
80
+ - **Admin** — enter results, sync from the live feed, lock champion picks, set the winner.
81
+ - **Dashboard** (admin) — grant credits and view the settlement table (granted vs balance vs net).
82
+
83
+ ## Auto-syncing results
84
+ The Admin "Sync now" button calls `POST /admin/sync-results`, which pulls finished scores from a
85
+ free no-key feed (`RESULTS_API_URL`, default [worldcup26.ir](https://worldcup26.ir/get/games)),
86
+ matches them to our fixtures by group + team names, and recomputes points. You can always enter
87
+ or override results by hand.
88
+
89
+ ## Typical flow
90
+ 1. Register / log in (a seeded `admin` / `admin123` account can enter results).
91
+ 2. Players enter scores, place a Joker per round, and pick a champion (before kickoff).
92
+ 3. Results arrive via the Admin **Sync** button (or manual entry / `POST /matches/{id}/result`).
93
+ 4. Points + bonuses recompute automatically — watch the live Leaderboard, Groups, and Profile.
@@ -0,0 +1,75 @@
1
+ # Quiniela MCP server (Node / npm)
2
+
3
+ An MCP server that lets agents play the World Cup 2026 quiniela + prediction
4
+ markets. Node 18+ only — no Python, no build step. Defaults to the public
5
+ deployment, so it works with zero config.
6
+
7
+ ## Use it (other people — the easy way)
8
+
9
+ Add this to your MCP client config (Claude Desktop, Claude Code, Cursor, …):
10
+
11
+ ```json
12
+ {
13
+ "mcpServers": {
14
+ "quiniela": {
15
+ "command": "npx",
16
+ "args": ["-y", "quiniela-mcp"]
17
+ }
18
+ }
19
+ }
20
+ ```
21
+
22
+ `npx` downloads and runs it on demand — nothing to install. (Works once the
23
+ package is published to npm; see *Publishing* below.)
24
+
25
+ **No npm publish?** Point `npx` straight at the GitHub repo instead:
26
+
27
+ ```json
28
+ {
29
+ "mcpServers": {
30
+ "quiniela": {
31
+ "command": "npx",
32
+ "args": ["-y", "github:davidesquer/quiniela-driven-development"]
33
+ }
34
+ }
35
+ }
36
+ ```
37
+
38
+ Then just tell your agent things like *"using the quiniela tools, show the
39
+ leaderboard"*, *"log in as <user>/<pass> and predict 2–1 on match 1, joker it"*,
40
+ or *"place a YES offer at 0.6 for 10 shares on the Over 2.5 market for game X"*.
41
+
42
+ ## Auth
43
+ - Read-only tools (`standings`, `leaderboard`, `markets_overview`, `list_matches`)
44
+ need no login.
45
+ - Player/admin tools: have the agent call `login(username, password)` first, or
46
+ add `"env": { "QUINIELA_USERNAME": "...", "QUINIELA_PASSWORD": "..." }` to the
47
+ config (use a dedicated bot account, not your admin password).
48
+ - Point at a local backend with `"env": { "QUINIELA_API_URL": "http://localhost:8010/api" }`.
49
+
50
+ ## Run from a clone (dev)
51
+ ```bash
52
+ npm install # at the repo root (installs the MCP deps)
53
+ node mcp-server-node/index.js
54
+ ```
55
+ The repo's `.mcp.json` already uses this, so Claude Code auto-discovers it.
56
+
57
+ ## Publishing (so anyone can `npx quiniela-mcp`)
58
+ The package lives at the repo root (`package.json`, name `quiniela-mcp`).
59
+ ```bash
60
+ npm login
61
+ npm publish # publishes just mcp-server-node/ (see "files")
62
+ ```
63
+ After that, `npx -y quiniela-mcp` works for everyone.
64
+
65
+ ## Tools (29)
66
+ Auth: `login`, `register`, `whoami` · Quiniela: `list_matches`,
67
+ `submit_prediction`, `set_joker`, `my_predictions`, `set_champion`, `standings`,
68
+ `leaderboard`, `profile`, `match_predictions` · Markets: `markets_overview`,
69
+ `list_markets`, `market_detail`, `make_offer`, `take_offer`, `cancel_order`,
70
+ `wallet` · Admin: `admin_set_result`, `admin_assign_knockout`,
71
+ `admin_remove_knockout`, `admin_add_market`, `admin_resolve_market`,
72
+ `admin_grant_credits`, `admin_list_users`, `admin_settlement`,
73
+ `admin_set_tournament_winner`, `admin_sync_results`.
74
+
75
+ > A Python/uv version of the same server lives in `../mcp-server` if you prefer it.
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env node
2
+ // MCP server for the World Cup 2026 quiniela + prediction markets (Node).
3
+ // Exposes the API as tools so agents can play. Uses Node's built-in fetch.
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { z } from "zod";
7
+
8
+ const API = (
9
+ process.env.QUINIELA_API_URL || "https://quiniela-wc26.onrender.com/api"
10
+ ).replace(/\/+$/, "");
11
+ let TOKEN = process.env.QUINIELA_TOKEN || null;
12
+
13
+ async function doLogin(username, password) {
14
+ const r = await fetch(`${API}/auth/login`, {
15
+ method: "POST",
16
+ body: new URLSearchParams({ username, password }),
17
+ });
18
+ if (!r.ok) throw new Error(`login failed: ${await r.text()}`);
19
+ TOKEN = (await r.json()).access_token;
20
+ }
21
+
22
+ async function ensureAuth() {
23
+ if (TOKEN) return;
24
+ const u = process.env.QUINIELA_USERNAME;
25
+ const p = process.env.QUINIELA_PASSWORD;
26
+ if (u && p) await doLogin(u, p);
27
+ }
28
+
29
+ async function req(method, path, { auth = true, json, params } = {}) {
30
+ if (auth) await ensureAuth();
31
+ let url = API + path;
32
+ if (params) url += "?" + new URLSearchParams(params);
33
+ const headers = {};
34
+ if (auth && TOKEN) headers["Authorization"] = `Bearer ${TOKEN}`;
35
+ let body;
36
+ if (json !== undefined) {
37
+ headers["Content-Type"] = "application/json";
38
+ body = JSON.stringify(json);
39
+ }
40
+ const r = await fetch(url, { method, headers, body });
41
+ const text = await r.text();
42
+ if (!r.ok) throw new Error(`HTTP ${r.status} ${path}: ${text}`);
43
+ try {
44
+ return JSON.parse(text);
45
+ } catch {
46
+ return text;
47
+ }
48
+ }
49
+
50
+ function brief(m) {
51
+ return {
52
+ id: m.id,
53
+ stage: m.stage,
54
+ round_key: m.round_key,
55
+ kickoff: m.kickoff,
56
+ status: m.status,
57
+ home: m.home_team?.name ?? null,
58
+ away: m.away_team?.name ?? null,
59
+ score: m.home_score != null ? `${m.home_score}-${m.away_score}` : null,
60
+ };
61
+ }
62
+
63
+ // name, description, zod schema, handler returning data (string|object)
64
+ const tools = [
65
+ // --- auth ---
66
+ ["login", "Authenticate and store a bearer token.",
67
+ { username: z.string(), password: z.string() },
68
+ async ({ username, password }) => {
69
+ await doLogin(username, password);
70
+ const me = await req("GET", "/auth/me");
71
+ return `Logged in as ${me.username} (admin=${me.is_admin}).`;
72
+ }],
73
+ ["whoami", "Current authenticated user.", {}, () => req("GET", "/auth/me")],
74
+ ["register", "Create a new player account.",
75
+ { username: z.string(), password: z.string() },
76
+ ({ username, password }) =>
77
+ req("POST", "/auth/register", { auth: false, json: { username, password } })],
78
+
79
+ // --- quiniela ---
80
+ ["list_matches", "List matches (brief). Optional stage filter (group, round_of_32, round_of_16, quarter, semi, third_place, final).",
81
+ { stage: z.string().optional() },
82
+ async ({ stage }) => {
83
+ const ms = await req("GET", "/matches", { auth: false });
84
+ return (stage ? ms.filter((m) => m.stage === stage) : ms).map(brief);
85
+ }],
86
+ ["submit_prediction", "Submit/update your scoreline prediction for a match (locks at kickoff).",
87
+ { match_id: z.number(), home_score: z.number(), away_score: z.number() },
88
+ ({ match_id, home_score, away_score }) =>
89
+ req("POST", "/predictions", { json: { match_id, pred_home: home_score, pred_away: away_score } })],
90
+ ["set_joker", "Play your one Joker (x2 points) for this match's group.",
91
+ { match_id: z.number() },
92
+ ({ match_id }) => req("POST", `/predictions/${match_id}/joker`)],
93
+ ["my_predictions", "Your current quiniela predictions.", {}, () => req("GET", "/predictions")],
94
+ ["set_champion", "Pick the tournament champion (+10; locks at first kickoff).",
95
+ { team_id: z.number() },
96
+ ({ team_id }) => req("POST", "/champion", { json: { team_id } })],
97
+ ["standings", "Group standings tables.", {}, () => req("GET", "/standings", { auth: false })],
98
+ ["leaderboard", "Quiniela leaderboard with breakdown.", {}, () => req("GET", "/leaderboard", { auth: false })],
99
+ ["profile", "Your quiniela stats.", {}, () => req("GET", "/profile")],
100
+ ["match_predictions", "Everyone's picks for a match (only once it has locked).",
101
+ { match_id: z.number() },
102
+ ({ match_id }) => req("GET", `/matches/${match_id}/predictions`, { auth: false })],
103
+
104
+ // --- markets ---
105
+ ["markets_overview", "All games that have markets, with summaries.", {},
106
+ () => req("GET", "/markets/overview", { auth: false })],
107
+ ["list_markets", "Markets for a match.",
108
+ { match_id: z.number() },
109
+ ({ match_id }) => req("GET", "/markets", { auth: false, params: { match_id } })],
110
+ ["market_detail", "Order book, recent fills, and your position for a market.",
111
+ { market_id: z.number() },
112
+ ({ market_id }) => req("GET", `/markets/${market_id}`)],
113
+ ["make_offer", "Post an offer to back a side (side=YES|NO, price 0..1) for N shares.",
114
+ { market_id: z.number(), side: z.enum(["YES", "NO"]), price: z.number(), shares: z.number() },
115
+ ({ market_id, side, price, shares }) =>
116
+ req("POST", `/markets/${market_id}/orders`, { json: { side, price, shares } })],
117
+ ["take_offer", "Take the opposite side of an existing offer (partial fills allowed).",
118
+ { order_id: z.number(), shares: z.number() },
119
+ ({ order_id, shares }) => req("POST", `/orders/${order_id}/take`, { json: { shares } })],
120
+ ["cancel_order", "Cancel your own open offer (refunds unmatched escrow).",
121
+ { order_id: z.number() },
122
+ ({ order_id }) => req("DELETE", `/orders/${order_id}`)],
123
+ ["wallet", "Your credits wallet: balance / locked / granted / net.", {}, () => req("GET", "/wallet")],
124
+
125
+ // --- admin ---
126
+ ["admin_set_result", "(admin) Set a match result; recomputes points.",
127
+ { match_id: z.number(), home_score: z.number(), away_score: z.number() },
128
+ ({ match_id, home_score, away_score }) =>
129
+ req("POST", `/matches/${match_id}/result`, { json: { home_score, away_score } })],
130
+ ["admin_assign_knockout", "(admin) Assign two teams to a knockout slot.",
131
+ { match_id: z.number(), home_team_id: z.number(), away_team_id: z.number() },
132
+ ({ match_id, home_team_id, away_team_id }) =>
133
+ req("POST", `/matches/${match_id}/teams`, { json: { home_team_id, away_team_id } })],
134
+ ["admin_remove_knockout", "(admin) Clear a knockout matchup (voids/refunds its markets + predictions).",
135
+ { match_id: z.number() },
136
+ ({ match_id }) => req("DELETE", `/matches/${match_id}/teams`)],
137
+ ["admin_add_market", "(admin) Add a custom YES/NO market to a match.",
138
+ { match_id: z.number(), question: z.string(), category: z.string().optional() },
139
+ ({ match_id, question, category }) =>
140
+ req("POST", "/markets", { json: { match_id, question, category: category || "custom" } })],
141
+ ["admin_resolve_market", "(admin) Resolve a market; pays winners, refunds unmatched.",
142
+ { market_id: z.number(), outcome_yes: z.boolean() },
143
+ ({ market_id, outcome_yes }) =>
144
+ req("POST", `/markets/${market_id}/resolve`, { json: { outcome: outcome_yes } })],
145
+ ["admin_grant_credits", "(admin) Grant credits to a user (buy-in).",
146
+ { user_id: z.number(), amount: z.number(), note: z.string().optional() },
147
+ ({ user_id, amount, note }) =>
148
+ req("POST", "/admin/credits", { json: { user_id, amount, note: note || null } })],
149
+ ["admin_list_users", "(admin) List users.", {}, () => req("GET", "/admin/users")],
150
+ ["admin_settlement", "(admin) Settlement dashboard.", {}, () => req("GET", "/admin/settlement")],
151
+ ["admin_set_tournament_winner", "(admin) Record the actual champion (+10 bonus).",
152
+ { team_id: z.number() },
153
+ ({ team_id }) => req("POST", "/tournament/champion", { json: { team_id } })],
154
+ ["admin_sync_results", "(admin) Pull finished scores from the live feed.", {},
155
+ () => req("POST", "/admin/sync-results")],
156
+ ];
157
+
158
+ const server = new McpServer({ name: "quiniela", version: "0.1.0" });
159
+ for (const [name, desc, schema, fn] of tools) {
160
+ server.tool(name, desc, schema, async (args) => {
161
+ const data = await fn(args || {});
162
+ return {
163
+ content: [
164
+ { type: "text", text: typeof data === "string" ? data : JSON.stringify(data, null, 2) },
165
+ ],
166
+ };
167
+ });
168
+ }
169
+
170
+ await server.connect(new StdioServerTransport());
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "quiniela-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for the FIFA World Cup 2026 quiniela + prediction markets — lets agents make predictions and trade markets.",
5
+ "type": "module",
6
+ "bin": {
7
+ "quiniela-mcp": "mcp-server-node/index.js"
8
+ },
9
+ "files": [
10
+ "mcp-server-node"
11
+ ],
12
+ "engines": {
13
+ "node": ">=18"
14
+ },
15
+ "keywords": ["mcp", "model-context-protocol", "world-cup", "quiniela", "prediction-markets"],
16
+ "license": "MIT",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/davidesquer/quiniela-driven-development.git"
20
+ },
21
+ "homepage": "https://github.com/davidesquer/quiniela-driven-development/tree/main/mcp-server-node#readme",
22
+ "bugs": {
23
+ "url": "https://github.com/davidesquer/quiniela-driven-development/issues"
24
+ },
25
+ "dependencies": {
26
+ "@modelcontextprotocol/sdk": "^1.0.0",
27
+ "zod": "^3.23.8"
28
+ }
29
+ }