odds-api-mcp-server 1.0.0 → 1.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 +2 -2
- package/dist/index.d.ts +40 -1
- package/dist/index.js +501 -329
- package/package.json +12 -6
- package/src/index.test.ts +556 -0
- package/src/index.ts +601 -335
- package/tsconfig.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "odds-api-mcp-server",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "MCP server for Odds-API.io - Access sports betting odds data from AI tools like Claude, Cursor, and VS Code",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
"scripts": {
|
|
10
10
|
"build": "tsc",
|
|
11
11
|
"start": "node dist/index.js",
|
|
12
|
-
"dev": "ts-node src/index.ts"
|
|
12
|
+
"dev": "ts-node src/index.ts",
|
|
13
|
+
"test": "vitest run",
|
|
14
|
+
"test:watch": "vitest"
|
|
13
15
|
},
|
|
14
16
|
"keywords": [
|
|
15
17
|
"mcp",
|
|
@@ -25,16 +27,20 @@
|
|
|
25
27
|
"license": "MIT",
|
|
26
28
|
"repository": {
|
|
27
29
|
"type": "git",
|
|
28
|
-
"url": "https://github.com/
|
|
30
|
+
"url": "https://github.com/odds-api-io/odds-api-mcp-server"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://odds-api.io",
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/odds-api-io/odds-api-mcp-server/issues"
|
|
29
35
|
},
|
|
30
36
|
"dependencies": {
|
|
31
|
-
"@modelcontextprotocol/sdk": "^0.5.0"
|
|
32
|
-
"zod": "^3.22.4"
|
|
37
|
+
"@modelcontextprotocol/sdk": "^0.5.0"
|
|
33
38
|
},
|
|
34
39
|
"devDependencies": {
|
|
35
40
|
"@types/node": "^20.10.0",
|
|
36
41
|
"ts-node": "^10.9.2",
|
|
37
|
-
"typescript": "^5.3.2"
|
|
42
|
+
"typescript": "^5.3.2",
|
|
43
|
+
"vitest": "^4.1.2"
|
|
38
44
|
},
|
|
39
45
|
"engines": {
|
|
40
46
|
"node": ">=18.0.0"
|
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Must run before any module evaluation (vi.hoisted is lifted above imports)
|
|
4
|
+
vi.hoisted(() => {
|
|
5
|
+
process.env.ODDS_API_KEY = "test-api-key";
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
// Mock the MCP SDK so module-level server setup doesn't fail
|
|
9
|
+
vi.mock("@modelcontextprotocol/sdk/server/index.js", () => {
|
|
10
|
+
class MockServer {
|
|
11
|
+
setRequestHandler = vi.fn();
|
|
12
|
+
connect = vi.fn();
|
|
13
|
+
}
|
|
14
|
+
return { Server: MockServer };
|
|
15
|
+
});
|
|
16
|
+
vi.mock("@modelcontextprotocol/sdk/server/stdio.js", () => {
|
|
17
|
+
class MockTransport {}
|
|
18
|
+
return { StdioServerTransport: MockTransport };
|
|
19
|
+
});
|
|
20
|
+
vi.mock("@modelcontextprotocol/sdk/types.js", () => ({
|
|
21
|
+
CallToolRequestSchema: Symbol("CallToolRequestSchema"),
|
|
22
|
+
ListToolsRequestSchema: Symbol("ListToolsRequestSchema"),
|
|
23
|
+
ListResourcesRequestSchema: Symbol("ListResourcesRequestSchema"),
|
|
24
|
+
ReadResourceRequestSchema: Symbol("ReadResourceRequestSchema"),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
import { tools, toolMap, apiRequest, jsonResponse, textResponse, errorResponse } from "./index.js";
|
|
28
|
+
|
|
29
|
+
// ── Test Helpers ─────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
function mockFetchJson(data: unknown, status = 200) {
|
|
32
|
+
return vi.fn().mockResolvedValue({
|
|
33
|
+
ok: status >= 200 && status < 300,
|
|
34
|
+
status,
|
|
35
|
+
json: () => Promise.resolve(data),
|
|
36
|
+
text: () => Promise.resolve(JSON.stringify(data)),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function mockFetchText(text: string, status = 200) {
|
|
41
|
+
return vi.fn().mockResolvedValue({
|
|
42
|
+
ok: status >= 200 && status < 300,
|
|
43
|
+
status,
|
|
44
|
+
text: () => Promise.resolve(text),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Tool Registry ────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
describe("Tool Registry", () => {
|
|
51
|
+
const EXPECTED_TOOLS = [
|
|
52
|
+
"get_sports",
|
|
53
|
+
"get_bookmakers",
|
|
54
|
+
"get_selected_bookmakers",
|
|
55
|
+
"select_bookmakers",
|
|
56
|
+
"clear_selected_bookmakers",
|
|
57
|
+
"get_leagues",
|
|
58
|
+
"get_events",
|
|
59
|
+
"get_event",
|
|
60
|
+
"get_live_events",
|
|
61
|
+
"search_events",
|
|
62
|
+
"get_odds",
|
|
63
|
+
"get_multi_odds",
|
|
64
|
+
"get_odds_movements",
|
|
65
|
+
"get_updated_odds",
|
|
66
|
+
"get_historical_events",
|
|
67
|
+
"get_historical_odds",
|
|
68
|
+
"get_value_bets",
|
|
69
|
+
"get_arbitrage_bets",
|
|
70
|
+
"get_participants",
|
|
71
|
+
"get_participant",
|
|
72
|
+
"get_documentation",
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
it("has all 21 tools registered", () => {
|
|
76
|
+
expect(tools).toHaveLength(21);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("has no duplicate tool names", () => {
|
|
80
|
+
const names = tools.map((t) => t.name);
|
|
81
|
+
expect(new Set(names).size).toBe(names.length);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it.each(EXPECTED_TOOLS)("includes tool: %s", (name) => {
|
|
85
|
+
expect(toolMap.has(name)).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("every tool has a non-empty description", () => {
|
|
89
|
+
for (const tool of tools) {
|
|
90
|
+
expect(tool.description.length).toBeGreaterThan(0);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("every tool has a valid inputSchema", () => {
|
|
95
|
+
for (const tool of tools) {
|
|
96
|
+
expect(tool.inputSchema.type).toBe("object");
|
|
97
|
+
expect(tool.inputSchema).toHaveProperty("properties");
|
|
98
|
+
expect(tool.inputSchema).toHaveProperty("required");
|
|
99
|
+
expect(Array.isArray(tool.inputSchema.required)).toBe(true);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("every required field exists in properties", () => {
|
|
104
|
+
for (const tool of tools) {
|
|
105
|
+
for (const field of tool.inputSchema.required) {
|
|
106
|
+
expect(tool.inputSchema.properties).toHaveProperty(field);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("toolMap provides O(1) lookup for all tools", () => {
|
|
112
|
+
expect(toolMap.size).toBe(tools.length);
|
|
113
|
+
for (const tool of tools) {
|
|
114
|
+
expect(toolMap.get(tool.name)).toBe(tool);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// ── Required Parameters ──────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
describe("Tool Schemas - Required Parameters", () => {
|
|
122
|
+
const cases: Array<[string, string[]]> = [
|
|
123
|
+
["get_sports", []],
|
|
124
|
+
["get_bookmakers", []],
|
|
125
|
+
["get_selected_bookmakers", []],
|
|
126
|
+
["select_bookmakers", ["bookmakers"]],
|
|
127
|
+
["clear_selected_bookmakers", []],
|
|
128
|
+
["get_leagues", ["sport"]],
|
|
129
|
+
["get_events", ["sport"]],
|
|
130
|
+
["get_event", ["id"]],
|
|
131
|
+
["get_live_events", []],
|
|
132
|
+
["search_events", ["query"]],
|
|
133
|
+
["get_odds", ["eventId", "bookmakers"]],
|
|
134
|
+
["get_multi_odds", ["eventIds", "bookmakers"]],
|
|
135
|
+
["get_odds_movements", ["eventId", "bookmaker", "market"]],
|
|
136
|
+
["get_updated_odds", ["since", "bookmaker", "sport"]],
|
|
137
|
+
["get_historical_events", ["sport", "league", "from", "to"]],
|
|
138
|
+
["get_historical_odds", ["eventId", "bookmakers"]],
|
|
139
|
+
["get_value_bets", ["bookmaker"]],
|
|
140
|
+
["get_arbitrage_bets", ["bookmakers"]],
|
|
141
|
+
["get_participants", ["sport"]],
|
|
142
|
+
["get_participant", ["id"]],
|
|
143
|
+
["get_documentation", []],
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
it.each(cases)("%s requires %j", (name, required) => {
|
|
147
|
+
const tool = toolMap.get(name)!;
|
|
148
|
+
expect(tool.inputSchema.required).toEqual(required);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// ── Response Helpers ─────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
describe("Response Helpers", () => {
|
|
155
|
+
it("jsonResponse wraps data as pretty-printed JSON text", () => {
|
|
156
|
+
const result = jsonResponse({ foo: "bar" });
|
|
157
|
+
expect(result.content).toHaveLength(1);
|
|
158
|
+
expect(result.content[0].type).toBe("text");
|
|
159
|
+
expect(JSON.parse(result.content[0].text)).toEqual({ foo: "bar" });
|
|
160
|
+
expect(result.content[0].text).toContain("\n"); // pretty-printed
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("textResponse wraps raw text", () => {
|
|
164
|
+
const result = textResponse("hello world");
|
|
165
|
+
expect(result.content).toHaveLength(1);
|
|
166
|
+
expect(result.content[0].type).toBe("text");
|
|
167
|
+
expect(result.content[0].text).toBe("hello world");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("errorResponse includes isError flag", () => {
|
|
171
|
+
const result = errorResponse("something broke");
|
|
172
|
+
expect(result.content[0].text).toBe("Error: something broke");
|
|
173
|
+
expect(result.isError).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ── API Client ───────────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
describe("apiRequest", () => {
|
|
180
|
+
let originalFetch: typeof globalThis.fetch;
|
|
181
|
+
|
|
182
|
+
beforeEach(() => {
|
|
183
|
+
originalFetch = globalThis.fetch;
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
afterEach(() => {
|
|
187
|
+
globalThis.fetch = originalFetch;
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("builds URL with endpoint and API key", async () => {
|
|
191
|
+
const fetchMock = mockFetchJson({ ok: true });
|
|
192
|
+
globalThis.fetch = fetchMock;
|
|
193
|
+
|
|
194
|
+
await apiRequest("/sports");
|
|
195
|
+
|
|
196
|
+
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
|
|
197
|
+
expect(calledUrl.pathname).toBe("/v3/sports");
|
|
198
|
+
expect(calledUrl.searchParams.get("apiKey")).toBe("test-api-key");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("appends query params to URL", async () => {
|
|
202
|
+
const fetchMock = mockFetchJson([]);
|
|
203
|
+
globalThis.fetch = fetchMock;
|
|
204
|
+
|
|
205
|
+
await apiRequest("/events", { sport: "football", league: "epl" });
|
|
206
|
+
|
|
207
|
+
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
|
|
208
|
+
expect(calledUrl.searchParams.get("sport")).toBe("football");
|
|
209
|
+
expect(calledUrl.searchParams.get("league")).toBe("epl");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("filters out undefined params", async () => {
|
|
213
|
+
const fetchMock = mockFetchJson([]);
|
|
214
|
+
globalThis.fetch = fetchMock;
|
|
215
|
+
|
|
216
|
+
await apiRequest("/events", { sport: "football", league: undefined });
|
|
217
|
+
|
|
218
|
+
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
|
|
219
|
+
expect(calledUrl.searchParams.get("sport")).toBe("football");
|
|
220
|
+
expect(calledUrl.searchParams.has("league")).toBe(false);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("converts numeric params to strings", async () => {
|
|
224
|
+
const fetchMock = mockFetchJson([]);
|
|
225
|
+
globalThis.fetch = fetchMock;
|
|
226
|
+
|
|
227
|
+
await apiRequest("/events", { sport: "football", limit: 10, skip: 0 });
|
|
228
|
+
|
|
229
|
+
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
|
|
230
|
+
expect(calledUrl.searchParams.get("limit")).toBe("10");
|
|
231
|
+
expect(calledUrl.searchParams.get("skip")).toBe("0");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("converts boolean true to string", async () => {
|
|
235
|
+
const fetchMock = mockFetchJson([]);
|
|
236
|
+
globalThis.fetch = fetchMock;
|
|
237
|
+
|
|
238
|
+
await apiRequest("/leagues", { sport: "football", all: true });
|
|
239
|
+
|
|
240
|
+
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
|
|
241
|
+
expect(calledUrl.searchParams.get("all")).toBe("true");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("uses GET method by default", async () => {
|
|
245
|
+
const fetchMock = mockFetchJson({});
|
|
246
|
+
globalThis.fetch = fetchMock;
|
|
247
|
+
|
|
248
|
+
await apiRequest("/sports");
|
|
249
|
+
|
|
250
|
+
expect(fetchMock.mock.calls[0][1]).toEqual({ method: "GET" });
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("supports PUT method", async () => {
|
|
254
|
+
const fetchMock = mockFetchJson({});
|
|
255
|
+
globalThis.fetch = fetchMock;
|
|
256
|
+
|
|
257
|
+
await apiRequest("/bookmakers/selected/clear", {}, "PUT");
|
|
258
|
+
|
|
259
|
+
expect(fetchMock.mock.calls[0][1]).toEqual({ method: "PUT" });
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("throws on non-OK response", async () => {
|
|
263
|
+
globalThis.fetch = mockFetchJson({ error: "not found" }, 404);
|
|
264
|
+
|
|
265
|
+
await expect(apiRequest("/events/999999")).rejects.toThrow("API error 404");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// ── Tool Handlers ────────────────────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
describe("Tool Handlers", () => {
|
|
273
|
+
let originalFetch: typeof globalThis.fetch;
|
|
274
|
+
|
|
275
|
+
beforeEach(() => {
|
|
276
|
+
originalFetch = globalThis.fetch;
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
afterEach(() => {
|
|
280
|
+
globalThis.fetch = originalFetch;
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("get_sports calls /sports", async () => {
|
|
284
|
+
const mockData = [{ name: "Football", slug: "football" }];
|
|
285
|
+
const fetchMock = mockFetchJson(mockData);
|
|
286
|
+
globalThis.fetch = fetchMock;
|
|
287
|
+
|
|
288
|
+
const result = await toolMap.get("get_sports")!.handler({});
|
|
289
|
+
|
|
290
|
+
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
|
|
291
|
+
expect(calledUrl.pathname).toBe("/v3/sports");
|
|
292
|
+
expect(JSON.parse(result.content[0].text)).toEqual(mockData);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("get_events passes all optional params", async () => {
|
|
296
|
+
const fetchMock = mockFetchJson([]);
|
|
297
|
+
globalThis.fetch = fetchMock;
|
|
298
|
+
|
|
299
|
+
await toolMap.get("get_events")!.handler({
|
|
300
|
+
sport: "football",
|
|
301
|
+
league: "england-premier-league",
|
|
302
|
+
participantId: 38,
|
|
303
|
+
status: "pending,live",
|
|
304
|
+
from: "2025-01-01T00:00:00Z",
|
|
305
|
+
to: "2025-01-31T23:59:59Z",
|
|
306
|
+
bookmaker: "Bet365",
|
|
307
|
+
limit: 20,
|
|
308
|
+
skip: 10,
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
|
|
312
|
+
expect(calledUrl.searchParams.get("sport")).toBe("football");
|
|
313
|
+
expect(calledUrl.searchParams.get("league")).toBe("england-premier-league");
|
|
314
|
+
expect(calledUrl.searchParams.get("participantId")).toBe("38");
|
|
315
|
+
expect(calledUrl.searchParams.get("status")).toBe("pending,live");
|
|
316
|
+
expect(calledUrl.searchParams.get("from")).toBe("2025-01-01T00:00:00Z");
|
|
317
|
+
expect(calledUrl.searchParams.get("to")).toBe("2025-01-31T23:59:59Z");
|
|
318
|
+
expect(calledUrl.searchParams.get("bookmaker")).toBe("Bet365");
|
|
319
|
+
expect(calledUrl.searchParams.get("limit")).toBe("20");
|
|
320
|
+
expect(calledUrl.searchParams.get("skip")).toBe("10");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("get_events omits undefined optional params", async () => {
|
|
324
|
+
const fetchMock = mockFetchJson([]);
|
|
325
|
+
globalThis.fetch = fetchMock;
|
|
326
|
+
|
|
327
|
+
await toolMap.get("get_events")!.handler({ sport: "football" });
|
|
328
|
+
|
|
329
|
+
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
|
|
330
|
+
expect(calledUrl.searchParams.get("sport")).toBe("football");
|
|
331
|
+
expect(calledUrl.searchParams.has("league")).toBe(false);
|
|
332
|
+
expect(calledUrl.searchParams.has("participantId")).toBe(false);
|
|
333
|
+
expect(calledUrl.searchParams.has("limit")).toBe(false);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("get_event uses path parameter", async () => {
|
|
337
|
+
const fetchMock = mockFetchJson({ id: 12345 });
|
|
338
|
+
globalThis.fetch = fetchMock;
|
|
339
|
+
|
|
340
|
+
await toolMap.get("get_event")!.handler({ id: 12345 });
|
|
341
|
+
|
|
342
|
+
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
|
|
343
|
+
expect(calledUrl.pathname).toBe("/v3/events/12345");
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("get_participant uses path parameter", async () => {
|
|
347
|
+
const fetchMock = mockFetchJson({ id: 38, name: "Chelsea" });
|
|
348
|
+
globalThis.fetch = fetchMock;
|
|
349
|
+
|
|
350
|
+
await toolMap.get("get_participant")!.handler({ id: 38 });
|
|
351
|
+
|
|
352
|
+
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
|
|
353
|
+
expect(calledUrl.pathname).toBe("/v3/participants/38");
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("get_leagues passes all param when true", async () => {
|
|
357
|
+
const fetchMock = mockFetchJson([]);
|
|
358
|
+
globalThis.fetch = fetchMock;
|
|
359
|
+
|
|
360
|
+
await toolMap.get("get_leagues")!.handler({ sport: "football", all: true });
|
|
361
|
+
|
|
362
|
+
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
|
|
363
|
+
expect(calledUrl.searchParams.get("all")).toBe("true");
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("get_leagues omits all param when false", async () => {
|
|
367
|
+
const fetchMock = mockFetchJson([]);
|
|
368
|
+
globalThis.fetch = fetchMock;
|
|
369
|
+
|
|
370
|
+
await toolMap.get("get_leagues")!.handler({ sport: "football", all: false });
|
|
371
|
+
|
|
372
|
+
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
|
|
373
|
+
expect(calledUrl.searchParams.has("all")).toBe(false);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("get_value_bets sends includeEventDetails only when true", async () => {
|
|
377
|
+
const fetchMock = mockFetchJson([]);
|
|
378
|
+
globalThis.fetch = fetchMock;
|
|
379
|
+
|
|
380
|
+
await toolMap.get("get_value_bets")!.handler({ bookmaker: "Bet365", includeEventDetails: true });
|
|
381
|
+
const url1 = new URL(fetchMock.mock.calls[0][0]);
|
|
382
|
+
expect(url1.searchParams.get("includeEventDetails")).toBe("true");
|
|
383
|
+
|
|
384
|
+
await toolMap.get("get_value_bets")!.handler({ bookmaker: "Bet365", includeEventDetails: false });
|
|
385
|
+
const url2 = new URL(fetchMock.mock.calls[1][0]);
|
|
386
|
+
expect(url2.searchParams.has("includeEventDetails")).toBe(false);
|
|
387
|
+
|
|
388
|
+
await toolMap.get("get_value_bets")!.handler({ bookmaker: "Bet365" });
|
|
389
|
+
const url3 = new URL(fetchMock.mock.calls[2][0]);
|
|
390
|
+
expect(url3.searchParams.has("includeEventDetails")).toBe(false);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("get_arbitrage_bets sends includeEventDetails only when true", async () => {
|
|
394
|
+
const fetchMock = mockFetchJson([]);
|
|
395
|
+
globalThis.fetch = fetchMock;
|
|
396
|
+
|
|
397
|
+
await toolMap.get("get_arbitrage_bets")!.handler({ bookmakers: "Bet365,SingBet", includeEventDetails: true });
|
|
398
|
+
const url = new URL(fetchMock.mock.calls[0][0]);
|
|
399
|
+
expect(url.searchParams.get("includeEventDetails")).toBe("true");
|
|
400
|
+
|
|
401
|
+
await toolMap.get("get_arbitrage_bets")!.handler({ bookmakers: "Bet365,SingBet" });
|
|
402
|
+
const url2 = new URL(fetchMock.mock.calls[1][0]);
|
|
403
|
+
expect(url2.searchParams.has("includeEventDetails")).toBe(false);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("select_bookmakers uses PUT method", async () => {
|
|
407
|
+
const fetchMock = mockFetchJson({ success: true });
|
|
408
|
+
globalThis.fetch = fetchMock;
|
|
409
|
+
|
|
410
|
+
await toolMap.get("select_bookmakers")!.handler({ bookmakers: "Bet365,SingBet" });
|
|
411
|
+
|
|
412
|
+
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
|
|
413
|
+
expect(calledUrl.pathname).toBe("/v3/bookmakers/selected/select");
|
|
414
|
+
expect(fetchMock.mock.calls[0][1]).toEqual({ method: "PUT" });
|
|
415
|
+
expect(calledUrl.searchParams.get("bookmakers")).toBe("Bet365,SingBet");
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("clear_selected_bookmakers uses PUT method", async () => {
|
|
419
|
+
const fetchMock = mockFetchJson({ success: true });
|
|
420
|
+
globalThis.fetch = fetchMock;
|
|
421
|
+
|
|
422
|
+
await toolMap.get("clear_selected_bookmakers")!.handler({});
|
|
423
|
+
|
|
424
|
+
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
|
|
425
|
+
expect(calledUrl.pathname).toBe("/v3/bookmakers/selected/clear");
|
|
426
|
+
expect(fetchMock.mock.calls[0][1]).toEqual({ method: "PUT" });
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it("get_odds_movements passes market and optional marketLine", async () => {
|
|
430
|
+
const fetchMock = mockFetchJson({});
|
|
431
|
+
globalThis.fetch = fetchMock;
|
|
432
|
+
|
|
433
|
+
await toolMap.get("get_odds_movements")!.handler({
|
|
434
|
+
eventId: "123",
|
|
435
|
+
bookmaker: "Bet365",
|
|
436
|
+
market: "Spread",
|
|
437
|
+
marketLine: "0.5",
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
|
|
441
|
+
expect(calledUrl.pathname).toBe("/v3/odds/movements");
|
|
442
|
+
expect(calledUrl.searchParams.get("market")).toBe("Spread");
|
|
443
|
+
expect(calledUrl.searchParams.get("marketLine")).toBe("0.5");
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it("get_updated_odds passes since as number", async () => {
|
|
447
|
+
const fetchMock = mockFetchJson([]);
|
|
448
|
+
globalThis.fetch = fetchMock;
|
|
449
|
+
|
|
450
|
+
const since = 1700000000;
|
|
451
|
+
await toolMap.get("get_updated_odds")!.handler({
|
|
452
|
+
since,
|
|
453
|
+
bookmaker: "Bet365",
|
|
454
|
+
sport: "football",
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
|
|
458
|
+
expect(calledUrl.pathname).toBe("/v3/odds/updated");
|
|
459
|
+
expect(calledUrl.searchParams.get("since")).toBe(String(since));
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it("get_historical_events passes all required params", async () => {
|
|
463
|
+
const fetchMock = mockFetchJson([]);
|
|
464
|
+
globalThis.fetch = fetchMock;
|
|
465
|
+
|
|
466
|
+
await toolMap.get("get_historical_events")!.handler({
|
|
467
|
+
sport: "football",
|
|
468
|
+
league: "england-premier-league",
|
|
469
|
+
from: "2026-01-01T00:00:00Z",
|
|
470
|
+
to: "2026-01-31T23:59:59Z",
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
|
|
474
|
+
expect(calledUrl.pathname).toBe("/v3/historical/events");
|
|
475
|
+
expect(calledUrl.searchParams.get("sport")).toBe("football");
|
|
476
|
+
expect(calledUrl.searchParams.get("league")).toBe("england-premier-league");
|
|
477
|
+
expect(calledUrl.searchParams.get("from")).toBe("2026-01-01T00:00:00Z");
|
|
478
|
+
expect(calledUrl.searchParams.get("to")).toBe("2026-01-31T23:59:59Z");
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it("get_documentation fetches from docs URL and returns text", async () => {
|
|
482
|
+
const docsText = "# Odds API Documentation\nThis is the docs.";
|
|
483
|
+
const fetchMock = mockFetchText(docsText);
|
|
484
|
+
globalThis.fetch = fetchMock;
|
|
485
|
+
|
|
486
|
+
const result = await toolMap.get("get_documentation")!.handler({});
|
|
487
|
+
|
|
488
|
+
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
|
|
489
|
+
expect(calledUrl.href).toBe("https://docs.odds-api.io/llms-full.txt");
|
|
490
|
+
expect(result.content[0].text).toBe(docsText);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it("handler throws on API failure", async () => {
|
|
494
|
+
globalThis.fetch = mockFetchJson({ error: "bad request" }, 400);
|
|
495
|
+
|
|
496
|
+
await expect(toolMap.get("get_sports")!.handler({})).rejects.toThrow(
|
|
497
|
+
"API error 400",
|
|
498
|
+
);
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// ── Edge Cases ───────────────────────────────────────────────────────────────
|
|
503
|
+
|
|
504
|
+
describe("Edge Cases", () => {
|
|
505
|
+
let originalFetch: typeof globalThis.fetch;
|
|
506
|
+
|
|
507
|
+
beforeEach(() => {
|
|
508
|
+
originalFetch = globalThis.fetch;
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
afterEach(() => {
|
|
512
|
+
globalThis.fetch = originalFetch;
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it("get_events with skip=0 sends the parameter", async () => {
|
|
516
|
+
const fetchMock = mockFetchJson([]);
|
|
517
|
+
globalThis.fetch = fetchMock;
|
|
518
|
+
|
|
519
|
+
await toolMap.get("get_events")!.handler({ sport: "football", skip: 0 });
|
|
520
|
+
|
|
521
|
+
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
|
|
522
|
+
expect(calledUrl.searchParams.get("skip")).toBe("0");
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it("search_events passes query through", async () => {
|
|
526
|
+
const fetchMock = mockFetchJson([]);
|
|
527
|
+
globalThis.fetch = fetchMock;
|
|
528
|
+
|
|
529
|
+
await toolMap.get("search_events")!.handler({ query: "Liverpool" });
|
|
530
|
+
|
|
531
|
+
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
|
|
532
|
+
expect(calledUrl.searchParams.get("query")).toBe("Liverpool");
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it("get_multi_odds passes comma-separated IDs", async () => {
|
|
536
|
+
const fetchMock = mockFetchJson([]);
|
|
537
|
+
globalThis.fetch = fetchMock;
|
|
538
|
+
|
|
539
|
+
await toolMap.get("get_multi_odds")!.handler({
|
|
540
|
+
eventIds: "1,2,3",
|
|
541
|
+
bookmakers: "Bet365,Unibet",
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
|
|
545
|
+
expect(calledUrl.searchParams.get("eventIds")).toBe("1,2,3");
|
|
546
|
+
expect(calledUrl.searchParams.get("bookmakers")).toBe("Bet365,Unibet");
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it("get_documentation throws on fetch failure", async () => {
|
|
550
|
+
globalThis.fetch = mockFetchText("", 500);
|
|
551
|
+
|
|
552
|
+
await expect(toolMap.get("get_documentation")!.handler({})).rejects.toThrow(
|
|
553
|
+
"Failed to fetch documentation: 500",
|
|
554
|
+
);
|
|
555
|
+
});
|
|
556
|
+
});
|