livetennisapi-mcp 1.0.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/LICENSE +21 -0
- package/README.md +117 -0
- package/dist/index.js +315 -0
- package/package.json +63 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Live Tennis API
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
<img src="https://raw.githubusercontent.com/livetennisapi/.github/main/profile/banner.jpg" alt="Live Tennis API" width="640">
|
|
4
|
+
|
|
5
|
+
# livetennisapi-mcp
|
|
6
|
+
|
|
7
|
+
**MCP server for the [Live Tennis API](https://livetennisapi.com).**
|
|
8
|
+
|
|
9
|
+
Give Claude, Cursor, Zed or any MCP client live tennis scores, players, odds
|
|
10
|
+
and model win-probability — for ATP, WTA, Challenger and ITF.
|
|
11
|
+
|
|
12
|
+
[](https://www.npmjs.com/package/livetennisapi-mcp)
|
|
13
|
+
[](LICENSE)
|
|
14
|
+
|
|
15
|
+
[**Documentation**](https://docs.livetennisapi.com) · [**Get an API key**](https://livetennisapi.com/#pricing)
|
|
16
|
+
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Setup
|
|
22
|
+
|
|
23
|
+
**Claude Code**
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
claude mcp add livetennis -e LIVETENNISAPI_KEY=twjp_… -- npx -y livetennisapi-mcp
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
**Claude Desktop** — add to `claude_desktop_config.json`:
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"mcpServers": {
|
|
34
|
+
"livetennis": {
|
|
35
|
+
"command": "npx",
|
|
36
|
+
"args": ["-y", "livetennisapi-mcp"],
|
|
37
|
+
"env": { "LIVETENNISAPI_KEY": "twjp_…" }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
**Cursor / Zed / others** — same command, same env var. No install step; `npx`
|
|
44
|
+
fetches it on demand.
|
|
45
|
+
|
|
46
|
+
Get a key at [livetennisapi.com](https://livetennisapi.com/#pricing).
|
|
47
|
+
|
|
48
|
+
## Try it
|
|
49
|
+
|
|
50
|
+
> *"What tennis matches are live right now?"*
|
|
51
|
+
> *"Who's winning the Alcaraz match, and what does the model give him?"*
|
|
52
|
+
> *"Show me Sinner's ranking and recent results."*
|
|
53
|
+
> *"What are the current odds on match 18953?"*
|
|
54
|
+
|
|
55
|
+
## Tools
|
|
56
|
+
|
|
57
|
+
| Tool | Does | Plan |
|
|
58
|
+
|---|---|:--:|
|
|
59
|
+
| `get_live_matches` | Matches in progress, with live scores | BASIC |
|
|
60
|
+
| `get_upcoming_matches` | Matches starting soon | BASIC |
|
|
61
|
+
| `get_match` | Full detail for one match | BASIC |
|
|
62
|
+
| `get_match_score` | Current score only — fastest read | BASIC |
|
|
63
|
+
| `search_players` | Find players by name | BASIC |
|
|
64
|
+
| `get_player` | Profile, ranking, country, handedness | BASIC |
|
|
65
|
+
| `get_fixtures` | Forward schedule | BASIC |
|
|
66
|
+
| `get_recent_results` | Completed matches and winners | BASIC |
|
|
67
|
+
| `get_match_events` | Breaks, games, sets, momentum runs | PRO |
|
|
68
|
+
| `get_match_odds` | Match-winner prices — bid / ask / mid | PRO |
|
|
69
|
+
| `get_match_analysis` | Model thesis, win probability, key factors | ULTRA |
|
|
70
|
+
| `check_api_status` | Reachability + which plan your key is on | — |
|
|
71
|
+
|
|
72
|
+
## Tier awareness
|
|
73
|
+
|
|
74
|
+
The API gates endpoints by plan and returns a bare `403 {"error":"upgrade_required"}`.
|
|
75
|
+
Handed that, a model will usually invent a reason or retry pointlessly.
|
|
76
|
+
|
|
77
|
+
So every tool that can hit a tier wall returns a plain-English explanation —
|
|
78
|
+
**as a normal result, not an error** — naming the tier required and where to
|
|
79
|
+
upgrade. The assistant can then tell you something true and actionable:
|
|
80
|
+
|
|
81
|
+
> This data requires the ULTRA plan, and the configured API key is on a lower
|
|
82
|
+
> tier. Nothing is wrong with the key — the endpoint is simply not included in
|
|
83
|
+
> the current plan. Upgrade at https://livetennisapi.com/#pricing
|
|
84
|
+
|
|
85
|
+
`check_api_status` probes upward to report which plan your key is actually on,
|
|
86
|
+
so you can diagnose that without guessing.
|
|
87
|
+
|
|
88
|
+
## Plans
|
|
89
|
+
|
|
90
|
+
| | BASIC | PRO | ULTRA |
|
|
91
|
+
|---|:--:|:--:|:--:|
|
|
92
|
+
| Matches, scores, players, fixtures, results | ✅ | ✅ | ✅ |
|
|
93
|
+
| Match events + odds | — | ✅ | ✅ |
|
|
94
|
+
| Model analysis + win probability | — | — | ✅ |
|
|
95
|
+
|
|
96
|
+
## Notes
|
|
97
|
+
|
|
98
|
+
- **Read-only.** Every tool is a GET; nothing here can modify anything.
|
|
99
|
+
- **Your key stays local.** It is read from the environment by the server
|
|
100
|
+
process on your machine and sent only to `api.livetennisapi.com`.
|
|
101
|
+
- Requires Node 18+.
|
|
102
|
+
|
|
103
|
+
## Development
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
npm install
|
|
107
|
+
npm run build
|
|
108
|
+
LIVETENNISAPI_KEY=twjp_… node dist/index.js # speaks MCP over stdio
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Built on the official [`livetennisapi`](https://www.npmjs.com/package/livetennisapi)
|
|
112
|
+
client.
|
|
113
|
+
|
|
114
|
+
## Licence
|
|
115
|
+
|
|
116
|
+
MIT — see [LICENSE](LICENSE). Use of the API service is governed by the
|
|
117
|
+
[Terms of Service](https://livetennisapi.com/terms).
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import {
|
|
7
|
+
LiveTennisAPI,
|
|
8
|
+
NotFound,
|
|
9
|
+
RateLimited,
|
|
10
|
+
Unauthorized,
|
|
11
|
+
UpgradeRequired,
|
|
12
|
+
formatScore
|
|
13
|
+
} from "livetennisapi";
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
var VERSION = "1.0.0";
|
|
16
|
+
var apiKey = (process.env.LIVETENNISAPI_KEY ?? "").trim();
|
|
17
|
+
var client = new LiveTennisAPI({
|
|
18
|
+
apiKey,
|
|
19
|
+
baseUrl: process.env.LIVETENNISAPI_BASE_URL
|
|
20
|
+
});
|
|
21
|
+
var text = (body) => ({ content: [{ type: "text", text: body }] });
|
|
22
|
+
async function guard(run) {
|
|
23
|
+
if (!apiKey) {
|
|
24
|
+
return text(
|
|
25
|
+
"No API key configured. Set the LIVETENNISAPI_KEY environment variable in your MCP client config. Get a key at https://livetennisapi.com/#pricing"
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
return text(await run());
|
|
30
|
+
} catch (err) {
|
|
31
|
+
if (err instanceof UpgradeRequired) {
|
|
32
|
+
return text(
|
|
33
|
+
`This data requires the ${err.requiredTier ?? "a higher"} plan, and the configured API key is on a lower tier. Nothing is wrong with the key \u2014 the endpoint is simply not included in the current plan. Upgrade at https://livetennisapi.com/#pricing
|
|
34
|
+
|
|
35
|
+
Tiers: BASIC = matches, scores, players, fixtures, history \xB7 PRO = + match events and market prices \xB7 ULTRA = + model analysis, win probability and the live WebSocket feed.`
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
if (err instanceof Unauthorized) {
|
|
39
|
+
return text(
|
|
40
|
+
"The API key was rejected \u2014 it is missing, unknown, or disabled. Check LIVETENNISAPI_KEY in your MCP client config. Keys are at https://livetennisapi.com"
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
if (err instanceof NotFound) {
|
|
44
|
+
return text("No data found for that request. The id may be wrong, or there may be no data yet.");
|
|
45
|
+
}
|
|
46
|
+
if (err instanceof RateLimited) {
|
|
47
|
+
const wait = err.retryAfter ? ` Retry in about ${err.retryAfter}s.` : "";
|
|
48
|
+
return text(`Rate limit reached for this plan.${wait}`);
|
|
49
|
+
}
|
|
50
|
+
return { ...text(`Request failed: ${err instanceof Error ? err.message : String(err)}`), isError: true };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function summarise(match) {
|
|
54
|
+
const p1 = match.players?.p1?.name ?? "?";
|
|
55
|
+
const p2 = match.players?.p2?.name ?? "?";
|
|
56
|
+
const serving = match.score?.server === 1 ? " (serving)" : "";
|
|
57
|
+
const serving2 = match.score?.server === 2 ? " (serving)" : "";
|
|
58
|
+
const bits = [
|
|
59
|
+
`[${match.id}] ${match.tournament ?? "Unknown event"}${match.round ? ` \u2014 ${match.round}` : ""}`,
|
|
60
|
+
` ${p1}${serving} vs ${p2}${serving2}`,
|
|
61
|
+
` Score: ${formatScore(match.score)}`
|
|
62
|
+
];
|
|
63
|
+
if (match.surface) bits.push(` Surface: ${match.surface}${match.indoor ? " (indoor)" : ""}`);
|
|
64
|
+
if (match.status && match.status !== "live") bits.push(` Status: ${match.status}`);
|
|
65
|
+
if (match.winner) bits.push(` Winner: ${match.winner === 1 ? p1 : p2}`);
|
|
66
|
+
if (match.score?.win_probability_p1 != null) {
|
|
67
|
+
bits.push(` Model win probability (${p1}): ${(match.score.win_probability_p1 * 100).toFixed(1)}%`);
|
|
68
|
+
}
|
|
69
|
+
return bits.join("\n");
|
|
70
|
+
}
|
|
71
|
+
var server = new McpServer({ name: "livetennisapi", version: VERSION });
|
|
72
|
+
server.tool(
|
|
73
|
+
"get_live_matches",
|
|
74
|
+
'List tennis matches currently in progress, with live scores. Covers ATP, WTA, Challenger and ITF. Use this for "what tennis is on right now".',
|
|
75
|
+
{ limit: z.number().int().min(1).max(200).default(20).describe("Maximum matches to return") },
|
|
76
|
+
({ limit }) => guard(async () => {
|
|
77
|
+
const page = await client.listMatches({ status: "live", limit });
|
|
78
|
+
if (!page.data.length) return "No matches are live right now.";
|
|
79
|
+
return `${page.data.length} live match(es):
|
|
80
|
+
|
|
81
|
+
${page.data.map(summarise).join("\n\n")}`;
|
|
82
|
+
})
|
|
83
|
+
);
|
|
84
|
+
server.tool(
|
|
85
|
+
"get_upcoming_matches",
|
|
86
|
+
"List tennis matches scheduled to start soon, with players and tournament.",
|
|
87
|
+
{ limit: z.number().int().min(1).max(200).default(20) },
|
|
88
|
+
({ limit }) => guard(async () => {
|
|
89
|
+
const page = await client.listMatches({ status: "upcoming", limit });
|
|
90
|
+
if (!page.data.length) return "No upcoming matches are scheduled.";
|
|
91
|
+
return `${page.data.length} upcoming match(es):
|
|
92
|
+
|
|
93
|
+
${page.data.map(summarise).join("\n\n")}`;
|
|
94
|
+
})
|
|
95
|
+
);
|
|
96
|
+
server.tool(
|
|
97
|
+
"get_match",
|
|
98
|
+
"Full detail for one match by id: players, score, surface, round and status. Includes market prices on PRO and model analysis on ULTRA.",
|
|
99
|
+
{ match_id: z.number().int().describe("Match id, from get_live_matches or search") },
|
|
100
|
+
({ match_id }) => guard(async () => {
|
|
101
|
+
const match = await client.getMatch(match_id);
|
|
102
|
+
let out = summarise(match);
|
|
103
|
+
if (match.market) {
|
|
104
|
+
out += `
|
|
105
|
+
|
|
106
|
+
Market: ${match.market.question ?? "-"}`;
|
|
107
|
+
for (const price of match.market.prices ?? []) {
|
|
108
|
+
out += `
|
|
109
|
+
Side ${price.side}: mid ${price.mid ?? "-"} (bid ${price.bid ?? "-"} / ask ${price.ask ?? "-"})`;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (match.analysis?.profile) {
|
|
113
|
+
const profile = match.analysis.profile;
|
|
114
|
+
out += `
|
|
115
|
+
|
|
116
|
+
Model analysis:`;
|
|
117
|
+
if (profile.win_probability_p1 != null) {
|
|
118
|
+
out += `
|
|
119
|
+
Win probability (player 1): ${(profile.win_probability_p1 * 100).toFixed(1)}%`;
|
|
120
|
+
}
|
|
121
|
+
if (profile.key_factors?.length) out += `
|
|
122
|
+
Key factors: ${profile.key_factors.join("; ")}`;
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
})
|
|
126
|
+
);
|
|
127
|
+
server.tool(
|
|
128
|
+
"get_match_score",
|
|
129
|
+
"Current score for one match \u2014 the fastest, lowest-latency read. Use this when you only need the score and already know the match id.",
|
|
130
|
+
{ match_id: z.number().int() },
|
|
131
|
+
({ match_id }) => guard(async () => {
|
|
132
|
+
const score = await client.getMatchScore(match_id);
|
|
133
|
+
const parts = [`Score: ${formatScore(score)}`];
|
|
134
|
+
if (score.sets) parts.push(`Sets: ${score.sets.join("-")}`);
|
|
135
|
+
if (score.server) parts.push(`Serving: player ${score.server}`);
|
|
136
|
+
if (score.is_tiebreak) parts.push("In a tiebreak");
|
|
137
|
+
if (score.win_probability_p1 != null) {
|
|
138
|
+
parts.push(`Model win probability (player 1): ${(score.win_probability_p1 * 100).toFixed(1)}%`);
|
|
139
|
+
}
|
|
140
|
+
return parts.join("\n");
|
|
141
|
+
})
|
|
142
|
+
);
|
|
143
|
+
server.tool(
|
|
144
|
+
"search_players",
|
|
145
|
+
"Search tennis players by name. Returns id, country, ranking and tour. Use the returned id with get_player.",
|
|
146
|
+
{
|
|
147
|
+
query: z.string().min(1).describe('Full or partial player name, e.g. "alcaraz"'),
|
|
148
|
+
limit: z.number().int().min(1).max(200).default(10)
|
|
149
|
+
},
|
|
150
|
+
({ query, limit }) => guard(async () => {
|
|
151
|
+
const page = await client.searchPlayers(query, { limit });
|
|
152
|
+
if (!page.data.length) return `No players matched "${query}".`;
|
|
153
|
+
return page.data.map(
|
|
154
|
+
(p) => `[${p.id}] ${p.name ?? "?"}${p.country ? ` (${p.country})` : ""}${p.ranking != null ? ` \u2014 rank ${p.ranking}` : ""}${p.tour ? ` \xB7 ${p.tour}` : ""}`
|
|
155
|
+
).join("\n");
|
|
156
|
+
})
|
|
157
|
+
);
|
|
158
|
+
server.tool(
|
|
159
|
+
"get_player",
|
|
160
|
+
"One player's profile: ranking, country, handedness, date of birth and cached stats.",
|
|
161
|
+
{ player_id: z.number().int().describe("Player id, from search_players") },
|
|
162
|
+
({ player_id }) => guard(async () => {
|
|
163
|
+
const p = await client.getPlayer(player_id);
|
|
164
|
+
const rows = [
|
|
165
|
+
`${p.name ?? "Unknown"} [${p.id}]`,
|
|
166
|
+
p.country ? `Country: ${p.country}` : null,
|
|
167
|
+
p.ranking != null ? `Ranking: ${p.ranking}${p.ranking_points ? ` (${p.ranking_points} pts)` : ""}` : null,
|
|
168
|
+
p.ranking_movement ? `Movement: ${p.ranking_movement}` : null,
|
|
169
|
+
p.hand ? `Plays: ${p.hand === "R" ? "right-handed" : "left-handed"}` : null,
|
|
170
|
+
p.birthday ? `Born: ${p.birthday}` : null,
|
|
171
|
+
p.tour ? `Tour: ${p.tour}` : null
|
|
172
|
+
].filter(Boolean);
|
|
173
|
+
return rows.join("\n");
|
|
174
|
+
})
|
|
175
|
+
);
|
|
176
|
+
server.tool(
|
|
177
|
+
"get_fixtures",
|
|
178
|
+
"Upcoming scheduled tennis fixtures, earliest first \u2014 the forward schedule.",
|
|
179
|
+
{ limit: z.number().int().min(1).max(200).default(20) },
|
|
180
|
+
({ limit }) => guard(async () => {
|
|
181
|
+
const page = await client.listFixtures({ limit });
|
|
182
|
+
if (!page.data.length) return "No upcoming fixtures.";
|
|
183
|
+
return page.data.map(
|
|
184
|
+
(f) => `${f.event_date ?? "?"} \u2014 ${f.tournament ?? "?"}${f.round ? ` (${f.round})` : ""}: ${f.player1_name ?? "?"} vs ${f.player2_name ?? "?"}`
|
|
185
|
+
).join("\n");
|
|
186
|
+
})
|
|
187
|
+
);
|
|
188
|
+
server.tool(
|
|
189
|
+
"get_recent_results",
|
|
190
|
+
"Recently completed tennis matches with final scores and winners.",
|
|
191
|
+
{ limit: z.number().int().min(1).max(200).default(20) },
|
|
192
|
+
({ limit }) => guard(async () => {
|
|
193
|
+
const page = await client.listCompletedMatches({ limit });
|
|
194
|
+
if (!page.data.length) return "No completed matches available.";
|
|
195
|
+
return page.data.map(summarise).join("\n\n");
|
|
196
|
+
})
|
|
197
|
+
);
|
|
198
|
+
server.tool(
|
|
199
|
+
"get_match_events",
|
|
200
|
+
"Timeline of events for a match \u2014 breaks, games won, sets won, momentum runs. Requires the PRO plan.",
|
|
201
|
+
{ match_id: z.number().int(), limit: z.number().int().min(1).max(200).default(30) },
|
|
202
|
+
({ match_id, limit }) => guard(async () => {
|
|
203
|
+
const page = await client.listMatchEvents(match_id, { limit });
|
|
204
|
+
if (!page.data.length) return "No events recorded for this match.";
|
|
205
|
+
return page.data.map((e) => `${e.timestamp ?? "?"} \u2014 ${e.type ?? "?"}${e.player ? ` (player ${e.player})` : ""}`).join("\n");
|
|
206
|
+
})
|
|
207
|
+
);
|
|
208
|
+
server.tool(
|
|
209
|
+
"get_match_odds",
|
|
210
|
+
"Match-winner market prices for a match \u2014 implied probability per player, with bid, ask and mid. Requires the PRO plan.",
|
|
211
|
+
{ match_id: z.number().int(), limit: z.number().int().min(1).max(200).default(10) },
|
|
212
|
+
({ match_id, limit }) => guard(async () => {
|
|
213
|
+
const market = await client.getMarketPrices(match_id, { limit });
|
|
214
|
+
const lines = [`Market: ${market.question ?? "-"}`];
|
|
215
|
+
if (market.status) lines.push(`Status: ${market.status}`);
|
|
216
|
+
if (market.volume != null) lines.push(`24h volume: ${market.volume}`);
|
|
217
|
+
if (market.liquidity != null) lines.push(`Liquidity: ${market.liquidity}`);
|
|
218
|
+
lines.push("", "Recent prices (newest first):");
|
|
219
|
+
for (const p of market.prices ?? []) {
|
|
220
|
+
lines.push(
|
|
221
|
+
` side ${p.side}: mid ${p.mid ?? "-"} \xB7 bid ${p.bid ?? "-"} \xB7 ask ${p.ask ?? "-"}${p.timestamp ? ` @ ${p.timestamp}` : ""}`
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
return lines.join("\n");
|
|
225
|
+
})
|
|
226
|
+
);
|
|
227
|
+
server.tool(
|
|
228
|
+
"get_match_analysis",
|
|
229
|
+
"Model analysis for a match: predicted win probability, the model's thesis and the key factors behind it. Requires the ULTRA plan.",
|
|
230
|
+
{ match_id: z.number().int() },
|
|
231
|
+
({ match_id }) => guard(async () => {
|
|
232
|
+
const analysis = await client.getMatchAnalysis(match_id);
|
|
233
|
+
if (!analysis.thesis && !analysis.profile) return "No model analysis exists for this match yet.";
|
|
234
|
+
const lines = [];
|
|
235
|
+
if (analysis.profile) {
|
|
236
|
+
const p = analysis.profile;
|
|
237
|
+
lines.push("Profile:");
|
|
238
|
+
if (p.win_probability_p1 != null) {
|
|
239
|
+
lines.push(` Win probability (player 1): ${(p.win_probability_p1 * 100).toFixed(1)}%`);
|
|
240
|
+
}
|
|
241
|
+
if (p.expected_closeness != null) lines.push(` Expected closeness: ${p.expected_closeness}`);
|
|
242
|
+
if (p.volatility_rating) lines.push(` Volatility: ${p.volatility_rating}`);
|
|
243
|
+
if (p.key_factors?.length) lines.push(` Key factors: ${p.key_factors.join("; ")}`);
|
|
244
|
+
}
|
|
245
|
+
if (analysis.thesis) {
|
|
246
|
+
const t = analysis.thesis;
|
|
247
|
+
lines.push("", "Thesis:");
|
|
248
|
+
if (t.pick_side) lines.push(` Pick: player ${t.pick_side}`);
|
|
249
|
+
if (t.confidence != null) lines.push(` Confidence: ${(t.confidence * 100).toFixed(0)}%`);
|
|
250
|
+
if (t.state) lines.push(` State: ${t.state}`);
|
|
251
|
+
if (t.reasoning) lines.push(` Reasoning: ${t.reasoning}`);
|
|
252
|
+
}
|
|
253
|
+
return lines.join("\n");
|
|
254
|
+
})
|
|
255
|
+
);
|
|
256
|
+
server.tool(
|
|
257
|
+
"check_api_status",
|
|
258
|
+
"Check whether the Live Tennis API is reachable and which plan the configured key is on. Useful for diagnosing why other tools are refusing data.",
|
|
259
|
+
{},
|
|
260
|
+
async () => {
|
|
261
|
+
try {
|
|
262
|
+
const health = await client.health();
|
|
263
|
+
if (!apiKey) {
|
|
264
|
+
return text(
|
|
265
|
+
`API is reachable (status: ${health.status}, version: ${health.version}).
|
|
266
|
+
No API key is configured, so only this check will work. Set LIVETENNISAPI_KEY in your MCP client config \u2014 get a key at https://livetennisapi.com/#pricing`
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
let tier = "BASIC";
|
|
270
|
+
try {
|
|
271
|
+
await client.listMatches({ status: "completed", limit: 1 });
|
|
272
|
+
} catch (err) {
|
|
273
|
+
if (err instanceof Unauthorized) {
|
|
274
|
+
return text("API is reachable, but the configured key was rejected (unauthorized).");
|
|
275
|
+
}
|
|
276
|
+
throw err;
|
|
277
|
+
}
|
|
278
|
+
const probe = await client.listCompletedMatches({ limit: 1 });
|
|
279
|
+
const id = probe.data[0]?.id;
|
|
280
|
+
if (id != null) {
|
|
281
|
+
try {
|
|
282
|
+
await client.listMatchEvents(id, { limit: 1 });
|
|
283
|
+
tier = "PRO";
|
|
284
|
+
await client.getMatchAnalysis(id);
|
|
285
|
+
tier = "ULTRA";
|
|
286
|
+
} catch (err) {
|
|
287
|
+
if (!(err instanceof UpgradeRequired) && !(err instanceof NotFound)) throw err;
|
|
288
|
+
if (err instanceof NotFound && tier === "PRO") tier = "ULTRA";
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return text(
|
|
292
|
+
`API is reachable (status: ${health.status}, version: ${health.version}).
|
|
293
|
+
The configured key appears to be on the ${tier} plan.
|
|
294
|
+
|
|
295
|
+
BASIC = matches, scores, players, fixtures, history
|
|
296
|
+
PRO = + match events and market prices
|
|
297
|
+
ULTRA = + model analysis, win probability and the live feed`
|
|
298
|
+
);
|
|
299
|
+
} catch (err) {
|
|
300
|
+
return {
|
|
301
|
+
...text(`Could not reach the API: ${err instanceof Error ? err.message : String(err)}`),
|
|
302
|
+
isError: true
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
);
|
|
307
|
+
async function main() {
|
|
308
|
+
const transport = new StdioServerTransport();
|
|
309
|
+
await server.connect(transport);
|
|
310
|
+
console.error(`livetennisapi-mcp ${VERSION} ready${apiKey ? "" : " (no API key configured)"}`);
|
|
311
|
+
}
|
|
312
|
+
main().catch((err) => {
|
|
313
|
+
console.error("fatal:", err);
|
|
314
|
+
process.exit(1);
|
|
315
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "livetennisapi-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server for the Live Tennis API \u2014 give Claude, Cursor and other LLM agents real-time tennis scores, players, odds and model win-probability.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"mcp",
|
|
7
|
+
"modelcontextprotocol",
|
|
8
|
+
"model-context-protocol",
|
|
9
|
+
"mcp-server",
|
|
10
|
+
"claude",
|
|
11
|
+
"llm",
|
|
12
|
+
"ai-agent",
|
|
13
|
+
"tennis",
|
|
14
|
+
"tennis-api",
|
|
15
|
+
"live-scores",
|
|
16
|
+
"sports",
|
|
17
|
+
"sports-api",
|
|
18
|
+
"sports-data",
|
|
19
|
+
"atp",
|
|
20
|
+
"wta"
|
|
21
|
+
],
|
|
22
|
+
"homepage": "https://livetennisapi.com",
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/livetennisapi/livetennisapi-mcp.git"
|
|
26
|
+
},
|
|
27
|
+
"bugs": {
|
|
28
|
+
"url": "https://github.com/livetennisapi/livetennisapi-mcp/issues"
|
|
29
|
+
},
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"author": "Live Tennis API",
|
|
32
|
+
"type": "module",
|
|
33
|
+
"bin": {
|
|
34
|
+
"livetennisapi-mcp": "./dist/index.js"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist",
|
|
38
|
+
"README.md",
|
|
39
|
+
"LICENSE"
|
|
40
|
+
],
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=18"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "tsup",
|
|
46
|
+
"typecheck": "tsc --noEmit",
|
|
47
|
+
"prepublishOnly": "npm run build"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
51
|
+
"livetennisapi": "^1.0.0",
|
|
52
|
+
"zod": "^3.23.0"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@types/node": "^22.0.0",
|
|
56
|
+
"tsup": "^8.0.0",
|
|
57
|
+
"typescript": "^5.5.0"
|
|
58
|
+
},
|
|
59
|
+
"publishConfig": {
|
|
60
|
+
"access": "public",
|
|
61
|
+
"provenance": true
|
|
62
|
+
}
|
|
63
|
+
}
|