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 +93 -0
- package/mcp-server-node/README.md +75 -0
- package/mcp-server-node/index.js +170 -0
- package/package.json +29 -0
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
|
+
}
|