@pipeworx/mcp-cricsheet 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +201 -2
- package/bin/cli.js +17 -0
- package/package.json +30 -4
- package/server.json +18 -0
- package/src/index.ts +423 -0
- package/src/server.ts +48 -0
- package/src/store.ts +372 -0
- package/tsconfig.json +18 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mojibake Inc.
|
|
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
CHANGED
|
@@ -1,3 +1,202 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @pipeworx/cricsheet
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Ball-by-ball cricket data collapsed to the questions people actually ask: a
|
|
4
|
+
match's result and scorecard, a player's career batting/bowling aggregates,
|
|
5
|
+
and a head-to-head record between two teams. Covers Test matches, One Day
|
|
6
|
+
Internationals, T20 Internationals, and the Indian Premier League. Fleet
|
|
7
|
+
#2975.
|
|
8
|
+
|
|
9
|
+
Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1784+ live data sources. This is an independent, unofficial integration — not affiliated with, endorsed by, or published by the upstream provider.
|
|
10
|
+
|
|
11
|
+
## Tools
|
|
12
|
+
|
|
13
|
+
- `cricsheet_match(match_id)` — one match's full result plus, for every
|
|
14
|
+
innings, the batting card (each batter's runs/balls/4s/6s/how out) and
|
|
15
|
+
bowling card (each bowler's overs/runs/wickets). Use
|
|
16
|
+
`cricsheet_find_matches` first to get a `match_id`.
|
|
17
|
+
- `cricsheet_player_stats(player, format?)` — a cricketer's career batting
|
|
18
|
+
and bowling aggregates (matches, runs, average, strike rate, 100s/50s;
|
|
19
|
+
wickets, bowling average, economy). `player` is matched case-insensitively
|
|
20
|
+
as a substring against the short-form names this pack stores (e.g. "Kohli"
|
|
21
|
+
for "V Kohli"); an ambiguous query returns a candidate list instead of
|
|
22
|
+
guessing. Optionally scope to one `format`.
|
|
23
|
+
- `cricsheet_head_to_head(team_a, team_b, format?, limit?, offset?)` — total
|
|
24
|
+
matches, each team's wins, ties/no-results, and the most recent matches
|
|
25
|
+
with their results. Team names must match Cricsheet's own strings (e.g.
|
|
26
|
+
`"India"`, `"Mumbai Indians"`).
|
|
27
|
+
- `cricsheet_find_matches(team?, format?, season?, venue?, player?, limit?, offset?)`
|
|
28
|
+
— search the match index to discover `match_id`s. With no filters, returns
|
|
29
|
+
the most recent matches held across every format.
|
|
30
|
+
|
|
31
|
+
`format` accepts `"Test"`, `"ODI"`, `"T20I"` (international T20), or `"IPL"`
|
|
32
|
+
(case-insensitive); omit it to search across all four.
|
|
33
|
+
|
|
34
|
+
**What this pack does not do:** it does not return individual deliveries
|
|
35
|
+
(ball-by-ball). Every match file is parsed ball-by-ball once, at load time,
|
|
36
|
+
and collapsed into match / innings / batting-card / bowling-card rows —
|
|
37
|
+
storing every ball for ~11,000 matches would multiply the row count by
|
|
38
|
+
roughly 150-200x for a question ("who scored what, who took which wickets")
|
|
39
|
+
that the scorecard already answers.
|
|
40
|
+
|
|
41
|
+
## Auth
|
|
42
|
+
|
|
43
|
+
Keyless. The tools answer only through the Pipeworx gateway, which holds the
|
|
44
|
+
data; a standalone `npm install` has no data to read, and every tool throws
|
|
45
|
+
a clear "not bound in this runtime" error there instead of returning an
|
|
46
|
+
empty list.
|
|
47
|
+
|
|
48
|
+
## Data sources
|
|
49
|
+
|
|
50
|
+
- Cricsheet — ball-by-ball match data for Test cricket, One Day
|
|
51
|
+
Internationals, T20 Internationals, and the Indian Premier League.
|
|
52
|
+
**Licence: ODC Attribution License (ODC-BY)** — free to share, adapt and
|
|
53
|
+
use (including commercially) with attribution to Cricsheet. Cricsheet
|
|
54
|
+
publishes bulk per-match files only (no live API), so this pack builds a
|
|
55
|
+
copy (`scripts/cricsheet/load-data.mjs`) into a D1 database rather than
|
|
56
|
+
proxying per request, per CLAUDE.md's "build the copy" rule for a
|
|
57
|
+
bulk-only source.
|
|
58
|
+
|
|
59
|
+
### Known gaps — read before extending this pack
|
|
60
|
+
|
|
61
|
+
**Only four of Cricsheet's ~60 competitions are loaded.** Cricsheet also
|
|
62
|
+
publishes first-class, List A, and ~50 domestic T20 leagues (Big Bash, T20
|
|
63
|
+
Blast, Pakistan Super League, Caribbean Premier League, The Hundred, and so
|
|
64
|
+
on) as separate bulk files. This pack loads the four with the clearest
|
|
65
|
+
cross-format demand (international Test/ODI/T20I plus the IPL, the league
|
|
66
|
+
named explicitly in the originating demand signal). Adding another league is
|
|
67
|
+
one more entry in `BULK_FILES` in the loader, using the same
|
|
68
|
+
match-type-agnostic parser.
|
|
69
|
+
|
|
70
|
+
**Women's matches are included wherever Cricsheet's own bulk file includes
|
|
71
|
+
them**, distinguished by the `gender` field Cricsheet carries per match — not
|
|
72
|
+
filtered out, not separately tooled.
|
|
73
|
+
|
|
74
|
+
**Player identity is the raw name string Cricsheet uses** (e.g. "V Kohli",
|
|
75
|
+
"SR Watson") — short forms, not full names, and this pack does not maintain
|
|
76
|
+
its own name-to-person mapping beyond what `cricsheet_player_stats`'s
|
|
77
|
+
substring match provides. Two different people sharing a short-form name
|
|
78
|
+
(rare, but not impossible across ~8,400 distinct names) would merge under
|
|
79
|
+
that string; nothing in Cricsheet's own files beyond the registry ID (not
|
|
80
|
+
currently loaded) resolves that.
|
|
81
|
+
|
|
82
|
+
**Super overs are stored as extra innings entries**, exactly as Cricsheet's
|
|
83
|
+
own `innings` array represents them (a tied T20 can have 3 or 4 innings
|
|
84
|
+
entries) — `cricsheet_match` returns whatever innings Cricsheet's file has,
|
|
85
|
+
including super-over ones.
|
|
86
|
+
|
|
87
|
+
### Refreshing or extending the data
|
|
88
|
+
|
|
89
|
+
Re-run `node scripts/cricsheet/load-data.mjs` (needs `wrangler` logged in to
|
|
90
|
+
the Pipeworx Cloudflare account, and `CLOUDFLARE_ACCOUNT_ID` set when more
|
|
91
|
+
than one account is visible) — it re-fetches every format live and replaces
|
|
92
|
+
every table (DROP + reload), because Cricsheet corrects historical files
|
|
93
|
+
rather than versioning them. `--only <Test|ODI|T20I|IPL>` reloads a single
|
|
94
|
+
format; `--dry-run` parses and prints counts without touching D1, useful
|
|
95
|
+
after changing the parser. Cricsheet adds new matches continuously (for
|
|
96
|
+
international and IPL cricket, typically within a day of the match); re-run
|
|
97
|
+
the loader periodically to pick those up — `data_as_of` in every tool
|
|
98
|
+
response names the load it is reading.
|
|
99
|
+
|
|
100
|
+
## Quick Start
|
|
101
|
+
|
|
102
|
+
Add to your MCP client (Claude Desktop, Cursor, Windsurf, etc.):
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{
|
|
106
|
+
"mcpServers": {
|
|
107
|
+
"cricsheet": {
|
|
108
|
+
"url": "https://gateway.pipeworx.io/cricsheet/mcp"
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### What this endpoint actually serves
|
|
115
|
+
|
|
116
|
+
`tools/list` at `https://gateway.pipeworx.io/cricsheet/mcp` returns the tools in the table
|
|
117
|
+
above **plus the shared Pipeworx meta-tools** — `ask_pipeworx`,
|
|
118
|
+
`discover_tools`, `search_within`, `remember`/`recall` and the rest of the
|
|
119
|
+
gateway-wide set. So the tool count you see is larger than this table: a
|
|
120
|
+
single-pack endpoint currently lists roughly 30 shared tools alongside the
|
|
121
|
+
pack's own. The connection's `initialize` response states its exact scope, and
|
|
122
|
+
is the authoritative answer for a given day.
|
|
123
|
+
|
|
124
|
+
This is deliberate, not multiplexing by accident. The meta-tools are what let a
|
|
125
|
+
scoped connection answer a question this pack does not cover — via
|
|
126
|
+
`ask_pipeworx`, which routes across the whole catalog — without you adding a
|
|
127
|
+
second MCP server. There is currently no way to mount a pack endpoint without
|
|
128
|
+
them; if the extra schemas cost you more context than the routing is worth,
|
|
129
|
+
connect to the full gateway once rather than to several pack endpoints.
|
|
130
|
+
|
|
131
|
+
Or connect to the full Pipeworx gateway to get every pack's tools listed
|
|
132
|
+
directly, instead of just this one's:
|
|
133
|
+
|
|
134
|
+
```json
|
|
135
|
+
{
|
|
136
|
+
"mcpServers": {
|
|
137
|
+
"pipeworx": {
|
|
138
|
+
"url": "https://gateway.pipeworx.io/mcp"
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Both URLs reach the same gateway and the same 1784+ data sources. The
|
|
145
|
+
only difference is which pack's tools are listed **directly**; `ask_pipeworx`
|
|
146
|
+
reaches all of them from either one.
|
|
147
|
+
|
|
148
|
+
## No MCP client? Call it over HTTP
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
curl -X POST https://gateway.pipeworx.io/v1/tools/cricsheet_match \
|
|
152
|
+
-H 'Content-Type: application/json' \
|
|
153
|
+
-d '{"match_id":1082591}'
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
No account needed for the first calls. Inspect any tool: `GET https://gateway.pipeworx.io/v1/tools/cricsheet_match`. Find one: `POST https://gateway.pipeworx.io/v1/tools/search_packs` with `{"query":"..."}`.
|
|
157
|
+
|
|
158
|
+
## Standalone (no gateway account)
|
|
159
|
+
|
|
160
|
+
This package also runs as a local stdio MCP server — no Pipeworx account, no
|
|
161
|
+
gateway round-trip:
|
|
162
|
+
|
|
163
|
+
```json
|
|
164
|
+
{
|
|
165
|
+
"mcpServers": {
|
|
166
|
+
"cricsheet": {
|
|
167
|
+
"command": "npx",
|
|
168
|
+
"args": ["-y", "@pipeworx/mcp-cricsheet"]
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Or run it directly to confirm it starts:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
npx -y @pipeworx/mcp-cricsheet
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
It speaks MCP over stdin/stdout and answers `initialize`/`tools/list`/`tools/call`
|
|
181
|
+
for **only** this pack's tools — none of the shared meta-tools the gateway
|
|
182
|
+
connection above adds. Same source, same tools, no ask_pipeworx routing.
|
|
183
|
+
|
|
184
|
+
## Using with ask_pipeworx
|
|
185
|
+
|
|
186
|
+
Instead of calling tools directly, you can ask questions in plain English —
|
|
187
|
+
this works on the pack endpoint above as well as on the full gateway:
|
|
188
|
+
|
|
189
|
+
```
|
|
190
|
+
ask_pipeworx({ question: "your question about Cricsheet data" })
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
The gateway picks the right tool and fills the arguments automatically.
|
|
194
|
+
|
|
195
|
+
## More
|
|
196
|
+
|
|
197
|
+
- [Docs and guides](https://pipeworx.io/docs)
|
|
198
|
+
- [pipeworx.io](https://pipeworx.io)
|
|
199
|
+
|
|
200
|
+
## License
|
|
201
|
+
|
|
202
|
+
MIT
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
//
|
|
3
|
+
// Entry point for `npx @pipeworx/mcp-<slug>`.
|
|
4
|
+
//
|
|
5
|
+
// Packs ship as raw TypeScript (no build step — see publish-pack.sh for why:
|
|
6
|
+
// tsx sidesteps every extensionless-import / bare-JSON-import edge case a
|
|
7
|
+
// per-pack tsc build would have to solve one pack at a time). This file
|
|
8
|
+
// registers tsx's ESM loader programmatically, then hands off to src/server.ts,
|
|
9
|
+
// which wraps the pack's {tools, callTool} export in a stdio MCP server.
|
|
10
|
+
//
|
|
11
|
+
// Copied verbatim into every published pack repo by scripts/publish-pack.sh —
|
|
12
|
+
// edit this file, not a per-pack copy.
|
|
13
|
+
import { register } from 'tsx/esm/api';
|
|
14
|
+
|
|
15
|
+
register();
|
|
16
|
+
|
|
17
|
+
await import('../src/server.ts');
|
package/package.json
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pipeworx/mcp-cricsheet",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "cricsheet -- ball-by-ball cricket data collapsed to match / player / team questions (fleet #2975).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"mcp-cricsheet": "bin/cli.js"
|
|
10
|
+
},
|
|
11
|
+
"keywords": ["mcp", "mcp-server", "model-context-protocol", "pipeworx", "cricsheet"],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/pipeworx-io/mcp-cricsheet.git"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"typecheck": "tsc --noEmit"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
22
|
+
"tsx": "^4.19.0"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"typescript": "^5.9.3",
|
|
26
|
+
"@cloudflare/workers-types": "^4.20260405.1"
|
|
27
|
+
},
|
|
28
|
+
"pipeworx": {
|
|
29
|
+
"sourceHash": "v1-a408bcb18921b17d5b636ede895773b52861f20e397029fca5407c02b9210876",
|
|
30
|
+
"sourceCommit": "3723e46c6d115bd4a1fd2175e6bf758555e7cb50"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/server.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.pipeworx-io/cricsheet",
|
|
4
|
+
"title": "Cricsheet",
|
|
5
|
+
"description": "cricsheet -- ball-by-ball cricket data collapsed to match / player / team questions (fleet #2975).",
|
|
6
|
+
"version": "0.1.0",
|
|
7
|
+
"websiteUrl": "https://pipeworx.io/packs/cricsheet",
|
|
8
|
+
"repository": {
|
|
9
|
+
"url": "https://github.com/pipeworx-io/mcp-cricsheet",
|
|
10
|
+
"source": "github"
|
|
11
|
+
},
|
|
12
|
+
"remotes": [
|
|
13
|
+
{
|
|
14
|
+
"type": "streamable-http",
|
|
15
|
+
"url": "https://gateway.pipeworx.io/cricsheet/mcp"
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
interface McpToolDefinition {
|
|
2
|
+
name: string;
|
|
3
|
+
description: string;
|
|
4
|
+
/** Human-facing one-liner (fleet #1967). Optional; consumers fall back to
|
|
5
|
+
* description. Kept in step with shared/src/types.ts — scripts/lib/
|
|
6
|
+
* check-inlined-types.mjs reports drift at publish time. */
|
|
7
|
+
summary?: string;
|
|
8
|
+
inputSchema: {
|
|
9
|
+
type: 'object';
|
|
10
|
+
properties: Record<string, unknown>;
|
|
11
|
+
required?: string[];
|
|
12
|
+
anyOf?: Array<{ required: string[] }>;
|
|
13
|
+
oneOf?: Array<{ required: string[] }>;
|
|
14
|
+
allOf?: Array<{ required: string[] }>;
|
|
15
|
+
};
|
|
16
|
+
outputSchema?: Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface McpToolExport {
|
|
20
|
+
tools: McpToolDefinition[];
|
|
21
|
+
callTool: (name: string, args: Record<string, unknown>) => Promise<unknown>;
|
|
22
|
+
meter?: { credits: number };
|
|
23
|
+
cost?: Record<string, unknown>;
|
|
24
|
+
provider?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* cricsheet -- ball-by-ball cricket data collapsed to match / player / team
|
|
29
|
+
* questions (fleet #2975).
|
|
30
|
+
*
|
|
31
|
+
* Data: Cricsheet (cricsheet.org) -- per-match JSON files for Test, One Day
|
|
32
|
+
* International and Twenty20 International cricket plus the Indian Premier
|
|
33
|
+
* League (ODC Attribution License, ODC-BY; attribution: Cricsheet). Cricsheet
|
|
34
|
+
* publishes bulk files only, so this pack builds a copy
|
|
35
|
+
* (scripts/cricsheet/load-data.mjs) into a D1 database the gateway hands it
|
|
36
|
+
* as `_d1` (pack-manifest "d1Binding": "CRICSHEET_DB"). Design note: this
|
|
37
|
+
* file + store.ts.
|
|
38
|
+
*
|
|
39
|
+
* Every match file is parsed ball-by-ball IN THE LOADER and collapsed to
|
|
40
|
+
* match, innings, and batting/bowling scorecard rows -- individual
|
|
41
|
+
* deliveries are not stored or served (CLAUDE.md "prefer the smallest store
|
|
42
|
+
* that answers the tool questions; do not store every ball if aggregates
|
|
43
|
+
* answer the questions").
|
|
44
|
+
*
|
|
45
|
+
* Every response carries `source`, `source_url`, `license` and `data_as_of`.
|
|
46
|
+
*/
|
|
47
|
+
import {
|
|
48
|
+
battingAggregate,
|
|
49
|
+
bowlingAggregate,
|
|
50
|
+
dataAsOf,
|
|
51
|
+
findMatches,
|
|
52
|
+
findMatchesCount,
|
|
53
|
+
getBattingCards,
|
|
54
|
+
getBowlingCards,
|
|
55
|
+
getInnings,
|
|
56
|
+
getMatch,
|
|
57
|
+
headToHeadCount,
|
|
58
|
+
headToHeadMatches,
|
|
59
|
+
requireDb,
|
|
60
|
+
resolveFormat,
|
|
61
|
+
resolvePlayer,
|
|
62
|
+
type D1Like,
|
|
63
|
+
type Format,
|
|
64
|
+
} from './store.js';
|
|
65
|
+
|
|
66
|
+
const SOURCE = 'Cricsheet, ball-by-ball cricket match data';
|
|
67
|
+
const SOURCE_URL = 'https://cricsheet.org';
|
|
68
|
+
const LICENSE = 'ODC-BY (Open Data Commons Attribution License); attribution: Cricsheet';
|
|
69
|
+
|
|
70
|
+
const FORMAT_DESC = 'Match format: "Test", "ODI", "T20I" (international T20), or "IPL". Omit to search across every format this pack holds.';
|
|
71
|
+
|
|
72
|
+
const tools: McpToolExport['tools'] = [
|
|
73
|
+
{
|
|
74
|
+
name: 'cricsheet_match',
|
|
75
|
+
description:
|
|
76
|
+
'Full result and scorecard for one cricket match: teams, venue, date, toss, result, and for every innings the batting card (each batter\'s runs/balls/4s/6s/how out) and bowling card (each bowler\'s overs/runs/wickets). Covers Test, ODI and T20 International cricket plus the IPL (Cricsheet, ODC-BY). Use cricsheet_find_matches first to get a match_id if you don\'t already have one.',
|
|
77
|
+
summary: "One match's full result and batting/bowling scorecards by match_id.",
|
|
78
|
+
inputSchema: {
|
|
79
|
+
type: 'object' as const,
|
|
80
|
+
properties: {
|
|
81
|
+
match_id: { type: 'number', description: 'Cricsheet match id, e.g. 1082591. Get one from cricsheet_find_matches.' },
|
|
82
|
+
},
|
|
83
|
+
required: ['match_id'],
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: 'cricsheet_player_stats',
|
|
88
|
+
description:
|
|
89
|
+
'A cricketer\'s career batting and bowling aggregates (matches, runs, average, strike rate, 100s/50s; wickets, bowling average, economy) from Cricsheet\'s Test/ODI/T20I/IPL match data. Pass a name or partial name ("Kohli", "V Kohli") -- Cricsheet stores short-form names, so this matches case-insensitively and returns a candidate list if the name is ambiguous. Optionally scope to one format with `format`. Covers only matches Cricsheet has scored ball-by-ball, not a player\'s entire career.',
|
|
90
|
+
summary: "A cricketer's batting/bowling career aggregates, optionally scoped to one format.",
|
|
91
|
+
inputSchema: {
|
|
92
|
+
type: 'object' as const,
|
|
93
|
+
properties: {
|
|
94
|
+
player: { type: 'string', description: 'Player name or partial name, e.g. "Kohli" or "V Kohli".' },
|
|
95
|
+
format: { type: 'string', description: FORMAT_DESC },
|
|
96
|
+
},
|
|
97
|
+
required: ['player'],
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
name: 'cricsheet_head_to_head',
|
|
102
|
+
description:
|
|
103
|
+
'Head-to-head record between two cricket teams from Cricsheet\'s Test/ODI/T20I/IPL match data: total matches, each team\'s wins, ties/no-results, and the most recent matches with their results. Pass team names as Cricsheet uses them, e.g. "India", "Australia", "Mumbai Indians" (IPL). Optionally scope to one format with `format`.',
|
|
104
|
+
summary: 'Win/loss head-to-head record between two teams, optionally scoped to one format.',
|
|
105
|
+
inputSchema: {
|
|
106
|
+
type: 'object' as const,
|
|
107
|
+
properties: {
|
|
108
|
+
team_a: { type: 'string', description: 'First team name, e.g. "India".' },
|
|
109
|
+
team_b: { type: 'string', description: 'Second team name, e.g. "Australia".' },
|
|
110
|
+
format: { type: 'string', description: FORMAT_DESC },
|
|
111
|
+
limit: { type: 'number', description: 'Max recent matches to list, 1-100. Default 10.' },
|
|
112
|
+
offset: { type: 'number', description: 'Pagination offset into the recent-matches list. Default 0.' },
|
|
113
|
+
},
|
|
114
|
+
required: ['team_a', 'team_b'],
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: 'cricsheet_find_matches',
|
|
119
|
+
description:
|
|
120
|
+
'Search Cricsheet\'s Test/ODI/T20I/IPL match index by team, format, season, venue and/or player, returning match_id plus date/teams/venue/result for each hit -- use this to discover match_ids for cricsheet_match. With no filters, returns the most recent matches across every format. Provide at least one filter to narrow a specific search.',
|
|
121
|
+
summary: 'Search matches by team/format/season/venue/player to get match_ids.',
|
|
122
|
+
inputSchema: {
|
|
123
|
+
type: 'object' as const,
|
|
124
|
+
properties: {
|
|
125
|
+
team: { type: 'string', description: 'Team name, e.g. "England" or "Chennai Super Kings" (IPL).' },
|
|
126
|
+
format: { type: 'string', description: FORMAT_DESC },
|
|
127
|
+
season: { type: 'string', description: 'Season label as Cricsheet records it, e.g. "2023" or "2007/08".' },
|
|
128
|
+
venue: { type: 'string', description: 'Venue name or partial venue name, e.g. "Lord\'s".' },
|
|
129
|
+
player: { type: 'string', description: 'Exact player name as Cricsheet stores it (see cricsheet_player_stats to resolve a partial name first).' },
|
|
130
|
+
limit: { type: 'number', description: 'Max matches to return, 1-200. Default 50.' },
|
|
131
|
+
offset: { type: 'number', description: 'Pagination offset. Default 0.' },
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
function clampInt(v: unknown, lo: number, hi: number, d: number): number {
|
|
138
|
+
const n = Math.floor(Number(v));
|
|
139
|
+
return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : d;
|
|
140
|
+
}
|
|
141
|
+
function str(v: unknown): string | null {
|
|
142
|
+
return v != null && v !== '' ? String(v).trim() : null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function serializeMatch(m: {
|
|
146
|
+
match_id: number;
|
|
147
|
+
format: string;
|
|
148
|
+
gender: string;
|
|
149
|
+
team1: string;
|
|
150
|
+
team2: string;
|
|
151
|
+
match_date: string;
|
|
152
|
+
season: string | null;
|
|
153
|
+
venue: string | null;
|
|
154
|
+
city: string | null;
|
|
155
|
+
event_name: string | null;
|
|
156
|
+
toss_winner: string | null;
|
|
157
|
+
toss_decision: string | null;
|
|
158
|
+
outcome_winner: string | null;
|
|
159
|
+
outcome_by_runs: number | null;
|
|
160
|
+
outcome_by_wickets: number | null;
|
|
161
|
+
outcome_by_innings: number | null;
|
|
162
|
+
outcome_method: string | null;
|
|
163
|
+
result: string | null;
|
|
164
|
+
player_of_match: string | null;
|
|
165
|
+
}) {
|
|
166
|
+
return {
|
|
167
|
+
match_id: m.match_id,
|
|
168
|
+
format: m.format,
|
|
169
|
+
gender: m.gender,
|
|
170
|
+
team1: m.team1,
|
|
171
|
+
team2: m.team2,
|
|
172
|
+
match_date: m.match_date,
|
|
173
|
+
season: m.season,
|
|
174
|
+
venue: m.venue,
|
|
175
|
+
city: m.city,
|
|
176
|
+
event: m.event_name,
|
|
177
|
+
toss: m.toss_winner ? { winner: m.toss_winner, decision: m.toss_decision } : null,
|
|
178
|
+
outcome: {
|
|
179
|
+
winner: m.outcome_winner,
|
|
180
|
+
by_runs: m.outcome_by_runs,
|
|
181
|
+
by_wickets: m.outcome_by_wickets,
|
|
182
|
+
by_innings: m.outcome_by_innings === 1,
|
|
183
|
+
method: m.outcome_method,
|
|
184
|
+
result: m.result,
|
|
185
|
+
},
|
|
186
|
+
player_of_match: m.player_of_match,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function cricsheetMatchTool(db: D1Like, args: Record<string, unknown>) {
|
|
191
|
+
const asOf = await dataAsOf(db);
|
|
192
|
+
const matchId = Math.floor(Number(args.match_id));
|
|
193
|
+
if (!Number.isFinite(matchId)) {
|
|
194
|
+
return { found: false, reason: 'invalid_match_id', message: 'match_id must be a number.', data_as_of: asOf };
|
|
195
|
+
}
|
|
196
|
+
const match = await getMatch(db, matchId);
|
|
197
|
+
if (!match) {
|
|
198
|
+
return {
|
|
199
|
+
found: false,
|
|
200
|
+
reason: 'not_found',
|
|
201
|
+
match_id: matchId,
|
|
202
|
+
message: `No match held with match_id=${matchId}.`,
|
|
203
|
+
hint: 'Call cricsheet_find_matches to search by team/format/season/venue and get a valid match_id.',
|
|
204
|
+
data_as_of: asOf,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
const [innings, batting, bowling] = await Promise.all([getInnings(db, matchId), getBattingCards(db, matchId), getBowlingCards(db, matchId)]);
|
|
208
|
+
const inningsOut = innings.map((inn) => ({
|
|
209
|
+
innings: inn.innings_num,
|
|
210
|
+
batting_team: inn.batting_team,
|
|
211
|
+
total_runs: inn.total_runs,
|
|
212
|
+
total_wickets: inn.total_wickets,
|
|
213
|
+
overs: `${Math.floor(inn.legal_balls / 6)}.${inn.legal_balls % 6}`,
|
|
214
|
+
batting_card: batting
|
|
215
|
+
.filter((b) => b.innings_num === inn.innings_num)
|
|
216
|
+
.map((b) => ({
|
|
217
|
+
position: b.position,
|
|
218
|
+
player: b.player,
|
|
219
|
+
runs: b.runs,
|
|
220
|
+
balls: b.balls,
|
|
221
|
+
fours: b.fours,
|
|
222
|
+
sixes: b.sixes,
|
|
223
|
+
not_out: b.not_out === 1,
|
|
224
|
+
dismissal: b.not_out === 1 ? 'not out' : b.dismissal_kind ? `${b.dismissal_kind}${b.dismissal_bowler ? ` b ${b.dismissal_bowler}` : ''}` : null,
|
|
225
|
+
})),
|
|
226
|
+
bowling_card: bowling
|
|
227
|
+
.filter((b) => b.innings_num === inn.innings_num)
|
|
228
|
+
.map((b) => ({
|
|
229
|
+
order: b.order_num,
|
|
230
|
+
player: b.player,
|
|
231
|
+
overs: `${Math.floor(b.balls / 6)}.${b.balls % 6}`,
|
|
232
|
+
runs_conceded: b.runs_conceded,
|
|
233
|
+
wickets: b.wickets,
|
|
234
|
+
economy: b.balls > 0 ? Math.round((b.runs_conceded / (b.balls / 6)) * 100) / 100 : null,
|
|
235
|
+
})),
|
|
236
|
+
}));
|
|
237
|
+
return {
|
|
238
|
+
found: true,
|
|
239
|
+
...serializeMatch(match),
|
|
240
|
+
innings: inningsOut,
|
|
241
|
+
source: SOURCE,
|
|
242
|
+
source_url: SOURCE_URL,
|
|
243
|
+
license: LICENSE,
|
|
244
|
+
data_as_of: asOf,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function cricsheetPlayerStatsTool(db: D1Like, args: Record<string, unknown>) {
|
|
249
|
+
const asOf = await dataAsOf(db);
|
|
250
|
+
const query = str(args.player);
|
|
251
|
+
if (!query) {
|
|
252
|
+
return { found: false, reason: 'missing_player', message: 'player is required.', data_as_of: asOf };
|
|
253
|
+
}
|
|
254
|
+
let format: Format | null = null;
|
|
255
|
+
if (args.format != null && args.format !== '') {
|
|
256
|
+
format = resolveFormat(args.format);
|
|
257
|
+
if (!format) {
|
|
258
|
+
return { found: false, reason: 'unknown_format', message: `${JSON.stringify(args.format)} is not a recognized format. Use "Test", "ODI", "T20I", or "IPL".`, data_as_of: asOf };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const { resolved, candidates } = await resolvePlayer(db, query);
|
|
262
|
+
if (!resolved) {
|
|
263
|
+
if (!candidates.length) {
|
|
264
|
+
return {
|
|
265
|
+
found: false,
|
|
266
|
+
reason: 'no_match',
|
|
267
|
+
message: `No player matching ${JSON.stringify(query)} held in this pack.`,
|
|
268
|
+
hint: 'Cricsheet uses short-form names (e.g. "V Kohli", "SR Watson") -- try a surname or a shorter fragment.',
|
|
269
|
+
data_as_of: asOf,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
return {
|
|
273
|
+
found: false,
|
|
274
|
+
reason: 'ambiguous',
|
|
275
|
+
message: `${JSON.stringify(query)} matches ${candidates.length} players.`,
|
|
276
|
+
candidates: candidates.map((c) => ({ player: c.player, matches: c.matches })),
|
|
277
|
+
hint: 'Call again with the exact player string from candidates.',
|
|
278
|
+
data_as_of: asOf,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
const [batting, bowling] = await Promise.all([battingAggregate(db, resolved, format), bowlingAggregate(db, resolved, format)]);
|
|
282
|
+
if (batting.innings === 0 && bowling.innings === 0) {
|
|
283
|
+
return {
|
|
284
|
+
found: false,
|
|
285
|
+
reason: 'no_data_for_format',
|
|
286
|
+
player: resolved,
|
|
287
|
+
format: format ?? 'all',
|
|
288
|
+
message: `${resolved} has no ${format ?? 'Cricsheet'} innings held in this pack.`,
|
|
289
|
+
hint: format ? 'Try again without format to search across every format.' : undefined,
|
|
290
|
+
data_as_of: asOf,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
found: true,
|
|
295
|
+
player: resolved,
|
|
296
|
+
format: format ?? 'all',
|
|
297
|
+
batting,
|
|
298
|
+
bowling,
|
|
299
|
+
source: SOURCE,
|
|
300
|
+
source_url: SOURCE_URL,
|
|
301
|
+
license: LICENSE,
|
|
302
|
+
data_as_of: asOf,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function cricsheetHeadToHeadTool(db: D1Like, args: Record<string, unknown>) {
|
|
307
|
+
const asOf = await dataAsOf(db);
|
|
308
|
+
const teamA = str(args.team_a);
|
|
309
|
+
const teamB = str(args.team_b);
|
|
310
|
+
if (!teamA || !teamB) {
|
|
311
|
+
return { found: false, reason: 'missing_team', message: 'team_a and team_b are both required.', data_as_of: asOf };
|
|
312
|
+
}
|
|
313
|
+
let format: Format | null = null;
|
|
314
|
+
if (args.format != null && args.format !== '') {
|
|
315
|
+
format = resolveFormat(args.format);
|
|
316
|
+
if (!format) {
|
|
317
|
+
return { found: false, reason: 'unknown_format', message: `${JSON.stringify(args.format)} is not a recognized format. Use "Test", "ODI", "T20I", or "IPL".`, data_as_of: asOf };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const limit = clampInt(args.limit, 1, 100, 10);
|
|
321
|
+
const offset = Math.max(0, Math.floor(Number(args.offset) || 0));
|
|
322
|
+
const filter = { team_a: teamA, team_b: teamB, format, limit, offset };
|
|
323
|
+
const [rows, total] = await Promise.all([headToHeadMatches(db, filter), headToHeadCount(db, filter)]);
|
|
324
|
+
if (!total) {
|
|
325
|
+
return {
|
|
326
|
+
found: false,
|
|
327
|
+
reason: 'no_match',
|
|
328
|
+
team_a: teamA,
|
|
329
|
+
team_b: teamB,
|
|
330
|
+
format: format ?? 'all',
|
|
331
|
+
message: `No matches held between ${teamA} and ${teamB}${format ? ` in ${format}` : ''}.`,
|
|
332
|
+
hint: 'Check team spelling (Cricsheet uses full names, e.g. "India" not "IND") or call cricsheet_find_matches with team= to see what is held.',
|
|
333
|
+
data_as_of: asOf,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
let winsA = 0, winsB = 0, ties = 0, noResult = 0;
|
|
337
|
+
for (const m of rows) {
|
|
338
|
+
if (m.result === 'tie') ties++;
|
|
339
|
+
else if (m.result) noResult++;
|
|
340
|
+
else if (m.outcome_winner && m.outcome_winner.toLowerCase() === teamA.toLowerCase()) winsA++;
|
|
341
|
+
else if (m.outcome_winner && m.outcome_winner.toLowerCase() === teamB.toLowerCase()) winsB++;
|
|
342
|
+
}
|
|
343
|
+
const truncated = rows.length < total;
|
|
344
|
+
return {
|
|
345
|
+
found: true,
|
|
346
|
+
team_a: teamA,
|
|
347
|
+
team_b: teamB,
|
|
348
|
+
format: format ?? 'all',
|
|
349
|
+
total_matching: total,
|
|
350
|
+
returned: rows.length,
|
|
351
|
+
truncated,
|
|
352
|
+
...(truncated ? { hint: 'More matches exist than were returned (recent-matches list only). Page with limit/offset.' } : {}),
|
|
353
|
+
recent_matches_summary: { wins_in_returned_set: { [teamA]: winsA, [teamB]: winsB }, ties, no_result_or_draw: noResult },
|
|
354
|
+
recent_matches: rows.map(serializeMatch),
|
|
355
|
+
source: SOURCE,
|
|
356
|
+
source_url: SOURCE_URL,
|
|
357
|
+
license: LICENSE,
|
|
358
|
+
data_as_of: asOf,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function cricsheetFindMatchesTool(db: D1Like, args: Record<string, unknown>) {
|
|
363
|
+
const asOf = await dataAsOf(db);
|
|
364
|
+
let format: Format | null = null;
|
|
365
|
+
if (args.format != null && args.format !== '') {
|
|
366
|
+
format = resolveFormat(args.format);
|
|
367
|
+
if (!format) {
|
|
368
|
+
return { found: false, reason: 'unknown_format', message: `${JSON.stringify(args.format)} is not a recognized format. Use "Test", "ODI", "T20I", or "IPL".`, data_as_of: asOf };
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const limit = clampInt(args.limit, 1, 200, 50);
|
|
372
|
+
const offset = Math.max(0, Math.floor(Number(args.offset) || 0));
|
|
373
|
+
const filter = {
|
|
374
|
+
team: str(args.team) ?? undefined,
|
|
375
|
+
format,
|
|
376
|
+
season: str(args.season) ?? undefined,
|
|
377
|
+
venue: str(args.venue) ?? undefined,
|
|
378
|
+
player: str(args.player) ?? undefined,
|
|
379
|
+
limit,
|
|
380
|
+
offset,
|
|
381
|
+
};
|
|
382
|
+
const [rows, total] = await Promise.all([findMatches(db, filter), findMatchesCount(db, filter)]);
|
|
383
|
+
if (!total) {
|
|
384
|
+
return {
|
|
385
|
+
found: false,
|
|
386
|
+
reason: 'no_match',
|
|
387
|
+
message: 'No matches held matching those filters.',
|
|
388
|
+
hint: 'Loosen a filter -- venue is substring-matched, but team/season/player require the exact string Cricsheet uses.',
|
|
389
|
+
data_as_of: asOf,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
const truncated = rows.length < total;
|
|
393
|
+
return {
|
|
394
|
+
found: true,
|
|
395
|
+
total_matching: total,
|
|
396
|
+
returned: rows.length,
|
|
397
|
+
truncated,
|
|
398
|
+
...(truncated ? { hint: 'More matches exist than were returned. Narrow with team/format/season/venue/player, or page with limit/offset.' } : {}),
|
|
399
|
+
matches: rows.map(serializeMatch),
|
|
400
|
+
source: SOURCE,
|
|
401
|
+
source_url: SOURCE_URL,
|
|
402
|
+
license: LICENSE,
|
|
403
|
+
data_as_of: asOf,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
|
|
408
|
+
const bound = requireDb(args._d1);
|
|
409
|
+
switch (name) {
|
|
410
|
+
case 'cricsheet_match':
|
|
411
|
+
return cricsheetMatchTool(bound, args);
|
|
412
|
+
case 'cricsheet_player_stats':
|
|
413
|
+
return cricsheetPlayerStatsTool(bound, args);
|
|
414
|
+
case 'cricsheet_head_to_head':
|
|
415
|
+
return cricsheetHeadToHeadTool(bound, args);
|
|
416
|
+
case 'cricsheet_find_matches':
|
|
417
|
+
return cricsheetFindMatchesTool(bound, args);
|
|
418
|
+
default:
|
|
419
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export default { tools, callTool, meter: { credits: 1 } } satisfies McpToolExport;
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stdio MCP server entry point for @pipeworx/mcp-cricsheet.
|
|
3
|
+
* Generated by scripts/publish-pack.sh — do not hand-edit in the pack repo;
|
|
4
|
+
* edit scripts/publish-pack.sh (the server.ts heredoc) and republish instead.
|
|
5
|
+
*/
|
|
6
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
7
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
8
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
9
|
+
import pack from './index.js';
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
const server = new Server(
|
|
13
|
+
{ name: '@pipeworx/mcp-cricsheet', version: '0.1.0' },
|
|
14
|
+
{ capabilities: { tools: {} } },
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
18
|
+
tools: pack.tools.map((t) => ({
|
|
19
|
+
name: t.name,
|
|
20
|
+
description: t.description,
|
|
21
|
+
inputSchema: t.inputSchema,
|
|
22
|
+
})),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
26
|
+
const { name, arguments: args } = request.params;
|
|
27
|
+
try {
|
|
28
|
+
const callArgs = { ...((args ?? {}) as Record<string, unknown>) };
|
|
29
|
+
|
|
30
|
+
const result = await pack.callTool(name, callArgs);
|
|
31
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
32
|
+
} catch (err) {
|
|
33
|
+
return {
|
|
34
|
+
content: [{ type: 'text', text: err instanceof Error ? err.message : String(err) }],
|
|
35
|
+
isError: true,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
async function main() {
|
|
41
|
+
const transport = new StdioServerTransport();
|
|
42
|
+
await server.connect(transport);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
main().catch((err) => {
|
|
46
|
+
console.error('Fatal error running server:', err);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
});
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads for cricsheet (fleet #2975).
|
|
3
|
+
*
|
|
4
|
+
* Data: Cricsheet (cricsheet.org) -- per-match ball-by-ball JSON files for
|
|
5
|
+
* Test, ODI and T20 International cricket plus the Indian Premier League
|
|
6
|
+
* (ODC Attribution License, ODC-BY). Cricsheet publishes bulk files only, so
|
|
7
|
+
* this pack builds a copy (scripts/cricsheet/load-data.mjs) into a D1
|
|
8
|
+
* database the gateway hands it as `_d1` (pack-manifest "d1Binding":
|
|
9
|
+
* "CRICSHEET_DB"). Each match file is parsed ball-by-ball IN THE LOADER and
|
|
10
|
+
* collapsed to match / innings / batting-card / bowling-card rows --
|
|
11
|
+
* individual deliveries are never stored, per CLAUDE.md "prefer the
|
|
12
|
+
* smallest store that answers the tool questions."
|
|
13
|
+
*
|
|
14
|
+
* Design note: this file + index.ts.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** The slice of Cloudflare's D1Database this pack uses, declared here so the
|
|
18
|
+
* pack needs no workers-types and a test can pass a node:sqlite stub. */
|
|
19
|
+
export interface D1Like {
|
|
20
|
+
prepare(sql: string): D1StmtLike;
|
|
21
|
+
}
|
|
22
|
+
export interface D1StmtLike {
|
|
23
|
+
bind(...values: unknown[]): D1StmtLike;
|
|
24
|
+
all<T = Record<string, unknown>>(): Promise<{ results: T[] }>;
|
|
25
|
+
first<T = Record<string, unknown>>(): Promise<T | null>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class StoreUnavailableError extends Error {
|
|
29
|
+
constructor() {
|
|
30
|
+
super(
|
|
31
|
+
'cricsheet: the match store is not bound in this runtime (no _d1). These tools only answer through the Pipeworx gateway; a standalone install has no data to read.',
|
|
32
|
+
);
|
|
33
|
+
this.name = 'StoreUnavailableError';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function requireDb(d1: unknown): D1Like {
|
|
37
|
+
if (!d1 || typeof (d1 as D1Like).prepare !== 'function') throw new StoreUnavailableError();
|
|
38
|
+
return d1 as D1Like;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const FORMATS = ['Test', 'ODI', 'T20I', 'IPL'] as const;
|
|
42
|
+
export type Format = (typeof FORMATS)[number];
|
|
43
|
+
|
|
44
|
+
export function resolveFormat(input: unknown): Format | null {
|
|
45
|
+
if (input == null || input === '') return null;
|
|
46
|
+
const s = String(input).trim().toUpperCase();
|
|
47
|
+
const map: Record<string, Format> = {
|
|
48
|
+
TEST: 'Test', TESTS: 'Test',
|
|
49
|
+
ODI: 'ODI', ODIS: 'ODI', 'ONE DAY INTERNATIONAL': 'ODI', 'ONE-DAY INTERNATIONAL': 'ODI',
|
|
50
|
+
T20I: 'T20I', T20IS: 'T20I', T20: 'T20I', 'T20 INTERNATIONAL': 'T20I', 'T20 INTERNATIONALS': 'T20I',
|
|
51
|
+
IPL: 'IPL', 'INDIAN PREMIER LEAGUE': 'IPL',
|
|
52
|
+
};
|
|
53
|
+
return map[s] ?? null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function dataAsOf(db: D1Like): Promise<string | null> {
|
|
57
|
+
const r = await db.prepare(`SELECT value FROM cricsheet_meta WHERE key = 'loaded_at'`).first<{ value: string }>();
|
|
58
|
+
return r?.value ?? null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface MatchRow {
|
|
62
|
+
match_id: number;
|
|
63
|
+
format: string;
|
|
64
|
+
gender: string;
|
|
65
|
+
team1: string;
|
|
66
|
+
team2: string;
|
|
67
|
+
match_date: string;
|
|
68
|
+
season: string | null;
|
|
69
|
+
venue: string | null;
|
|
70
|
+
city: string | null;
|
|
71
|
+
event_name: string | null;
|
|
72
|
+
toss_winner: string | null;
|
|
73
|
+
toss_decision: string | null;
|
|
74
|
+
outcome_winner: string | null;
|
|
75
|
+
outcome_by_runs: number | null;
|
|
76
|
+
outcome_by_wickets: number | null;
|
|
77
|
+
outcome_by_innings: number | null;
|
|
78
|
+
outcome_method: string | null;
|
|
79
|
+
result: string | null;
|
|
80
|
+
player_of_match: string | null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function getMatch(db: D1Like, matchId: number): Promise<MatchRow | null> {
|
|
84
|
+
return db
|
|
85
|
+
.prepare(
|
|
86
|
+
`SELECT match_id, format, gender, team1, team2, match_date, season, venue, city, event_name,
|
|
87
|
+
toss_winner, toss_decision, outcome_winner, outcome_by_runs, outcome_by_wickets,
|
|
88
|
+
outcome_by_innings, outcome_method, result, player_of_match
|
|
89
|
+
FROM matches WHERE match_id = ?`,
|
|
90
|
+
)
|
|
91
|
+
.bind(matchId)
|
|
92
|
+
.first<MatchRow>();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface InningsRow {
|
|
96
|
+
innings_num: number;
|
|
97
|
+
batting_team: string;
|
|
98
|
+
total_runs: number;
|
|
99
|
+
total_wickets: number;
|
|
100
|
+
legal_balls: number;
|
|
101
|
+
}
|
|
102
|
+
export async function getInnings(db: D1Like, matchId: number): Promise<InningsRow[]> {
|
|
103
|
+
const r = await db
|
|
104
|
+
.prepare(`SELECT innings_num, batting_team, total_runs, total_wickets, legal_balls FROM innings WHERE match_id = ? ORDER BY innings_num`)
|
|
105
|
+
.bind(matchId)
|
|
106
|
+
.all<InningsRow>();
|
|
107
|
+
return r.results;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface BattingCardRow {
|
|
111
|
+
innings_num: number;
|
|
112
|
+
position: number;
|
|
113
|
+
player: string;
|
|
114
|
+
runs: number;
|
|
115
|
+
balls: number;
|
|
116
|
+
fours: number;
|
|
117
|
+
sixes: number;
|
|
118
|
+
not_out: number;
|
|
119
|
+
dismissal_kind: string | null;
|
|
120
|
+
dismissal_bowler: string | null;
|
|
121
|
+
}
|
|
122
|
+
export async function getBattingCards(db: D1Like, matchId: number): Promise<BattingCardRow[]> {
|
|
123
|
+
const r = await db
|
|
124
|
+
.prepare(
|
|
125
|
+
`SELECT innings_num, position, player, runs, balls, fours, sixes, not_out, dismissal_kind, dismissal_bowler
|
|
126
|
+
FROM batting_cards WHERE match_id = ? ORDER BY innings_num, position`,
|
|
127
|
+
)
|
|
128
|
+
.bind(matchId)
|
|
129
|
+
.all<BattingCardRow>();
|
|
130
|
+
return r.results;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface BowlingCardRow {
|
|
134
|
+
innings_num: number;
|
|
135
|
+
order_num: number;
|
|
136
|
+
player: string;
|
|
137
|
+
balls: number;
|
|
138
|
+
runs_conceded: number;
|
|
139
|
+
wickets: number;
|
|
140
|
+
}
|
|
141
|
+
export async function getBowlingCards(db: D1Like, matchId: number): Promise<BowlingCardRow[]> {
|
|
142
|
+
const r = await db
|
|
143
|
+
.prepare(
|
|
144
|
+
`SELECT innings_num, order_num, player, balls, runs_conceded, wickets
|
|
145
|
+
FROM bowling_cards WHERE match_id = ? ORDER BY innings_num, order_num`,
|
|
146
|
+
)
|
|
147
|
+
.bind(matchId)
|
|
148
|
+
.all<BowlingCardRow>();
|
|
149
|
+
return r.results;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Resolve a free-text player query against the small `players` table
|
|
153
|
+
* (one row per distinct name string Cricsheet uses). Cricsheet names are
|
|
154
|
+
* short forms ("V Kohli", "SR Watson") -- matching is case-insensitive
|
|
155
|
+
* substring, ranked by how many matches that name appears in, so a common
|
|
156
|
+
* surname like "Kohli" resolves unambiguously while a genuinely ambiguous
|
|
157
|
+
* query (e.g. a shared surname) comes back as a candidate list rather than
|
|
158
|
+
* picking one silently. */
|
|
159
|
+
export interface PlayerCandidate {
|
|
160
|
+
player: string;
|
|
161
|
+
matches: number;
|
|
162
|
+
}
|
|
163
|
+
export async function resolvePlayer(db: D1Like, query: string): Promise<{ resolved: string | null; candidates: PlayerCandidate[] }> {
|
|
164
|
+
const exact = await db
|
|
165
|
+
.prepare(`SELECT player, matches FROM players WHERE player = ? COLLATE NOCASE`)
|
|
166
|
+
.bind(query)
|
|
167
|
+
.first<PlayerCandidate>();
|
|
168
|
+
if (exact) return { resolved: exact.player, candidates: [exact] };
|
|
169
|
+
|
|
170
|
+
const r = await db
|
|
171
|
+
.prepare(`SELECT player, matches FROM players WHERE player LIKE ? COLLATE NOCASE ORDER BY matches DESC LIMIT 20`)
|
|
172
|
+
.bind(`%${query}%`)
|
|
173
|
+
.all<PlayerCandidate>();
|
|
174
|
+
if (r.results.length === 1) return { resolved: r.results[0].player, candidates: r.results };
|
|
175
|
+
return { resolved: null, candidates: r.results };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface BattingAgg {
|
|
179
|
+
innings: number;
|
|
180
|
+
runs: number;
|
|
181
|
+
balls: number;
|
|
182
|
+
outs: number;
|
|
183
|
+
not_outs: number;
|
|
184
|
+
fours: number;
|
|
185
|
+
sixes: number;
|
|
186
|
+
high_score: number | null;
|
|
187
|
+
hundreds: number;
|
|
188
|
+
fifties: number;
|
|
189
|
+
average: number | null;
|
|
190
|
+
strike_rate: number | null;
|
|
191
|
+
}
|
|
192
|
+
export interface BowlingAgg {
|
|
193
|
+
innings: number;
|
|
194
|
+
balls: number;
|
|
195
|
+
runs_conceded: number;
|
|
196
|
+
wickets: number;
|
|
197
|
+
best_wickets: number | null;
|
|
198
|
+
average: number | null;
|
|
199
|
+
economy: number | null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function battingAggregate(db: D1Like, player: string, format: Format | null): Promise<BattingAgg> {
|
|
203
|
+
const where = format ? 'player = ? AND format = ?' : 'player = ?';
|
|
204
|
+
const params = format ? [player, format] : [player];
|
|
205
|
+
const row = await db
|
|
206
|
+
.prepare(
|
|
207
|
+
`SELECT COUNT(*) AS innings, COALESCE(SUM(runs),0) AS runs, COALESCE(SUM(balls),0) AS balls,
|
|
208
|
+
COALESCE(SUM(CASE WHEN not_out = 0 THEN 1 ELSE 0 END),0) AS outs,
|
|
209
|
+
COALESCE(SUM(CASE WHEN not_out = 1 THEN 1 ELSE 0 END),0) AS not_outs,
|
|
210
|
+
COALESCE(SUM(fours),0) AS fours, COALESCE(SUM(sixes),0) AS sixes,
|
|
211
|
+
MAX(runs) AS high_score,
|
|
212
|
+
COALESCE(SUM(CASE WHEN runs >= 100 THEN 1 ELSE 0 END),0) AS hundreds,
|
|
213
|
+
COALESCE(SUM(CASE WHEN runs >= 50 AND runs < 100 THEN 1 ELSE 0 END),0) AS fifties
|
|
214
|
+
FROM batting_cards WHERE ${where}`,
|
|
215
|
+
)
|
|
216
|
+
.bind(...params)
|
|
217
|
+
.first<{ innings: number; runs: number; balls: number; outs: number; not_outs: number; fours: number; sixes: number; high_score: number | null; hundreds: number; fifties: number }>();
|
|
218
|
+
const innings = row?.innings ?? 0;
|
|
219
|
+
const runs = row?.runs ?? 0;
|
|
220
|
+
const balls = row?.balls ?? 0;
|
|
221
|
+
const outs = row?.outs ?? 0;
|
|
222
|
+
return {
|
|
223
|
+
innings,
|
|
224
|
+
runs,
|
|
225
|
+
balls,
|
|
226
|
+
outs,
|
|
227
|
+
not_outs: row?.not_outs ?? 0,
|
|
228
|
+
fours: row?.fours ?? 0,
|
|
229
|
+
sixes: row?.sixes ?? 0,
|
|
230
|
+
high_score: row?.high_score ?? null,
|
|
231
|
+
hundreds: row?.hundreds ?? 0,
|
|
232
|
+
fifties: row?.fifties ?? 0,
|
|
233
|
+
average: outs > 0 ? round2(runs / outs) : null,
|
|
234
|
+
strike_rate: balls > 0 ? round2((runs / balls) * 100) : null,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export async function bowlingAggregate(db: D1Like, player: string, format: Format | null): Promise<BowlingAgg> {
|
|
239
|
+
const where = format ? 'player = ? AND format = ?' : 'player = ?';
|
|
240
|
+
const params = format ? [player, format] : [player];
|
|
241
|
+
const row = await db
|
|
242
|
+
.prepare(
|
|
243
|
+
`SELECT COUNT(*) AS innings, COALESCE(SUM(balls),0) AS balls, COALESCE(SUM(runs_conceded),0) AS runs_conceded,
|
|
244
|
+
COALESCE(SUM(wickets),0) AS wickets, MAX(wickets) AS best_wickets
|
|
245
|
+
FROM bowling_cards WHERE ${where}`,
|
|
246
|
+
)
|
|
247
|
+
.bind(...params)
|
|
248
|
+
.first<{ innings: number; balls: number; runs_conceded: number; wickets: number; best_wickets: number | null }>();
|
|
249
|
+
const innings = row?.innings ?? 0;
|
|
250
|
+
const balls = row?.balls ?? 0;
|
|
251
|
+
const runs_conceded = row?.runs_conceded ?? 0;
|
|
252
|
+
const wickets = row?.wickets ?? 0;
|
|
253
|
+
return {
|
|
254
|
+
innings,
|
|
255
|
+
balls,
|
|
256
|
+
runs_conceded,
|
|
257
|
+
wickets,
|
|
258
|
+
best_wickets: row?.best_wickets ?? null,
|
|
259
|
+
average: wickets > 0 ? round2(runs_conceded / wickets) : null,
|
|
260
|
+
economy: balls > 0 ? round2(runs_conceded / (balls / 6)) : null,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function round2(n: number): number {
|
|
265
|
+
return Math.round(n * 100) / 100;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export interface HeadToHeadFilter {
|
|
269
|
+
team_a: string;
|
|
270
|
+
team_b: string;
|
|
271
|
+
format: Format | null;
|
|
272
|
+
limit: number;
|
|
273
|
+
offset: number;
|
|
274
|
+
}
|
|
275
|
+
export async function headToHeadMatches(db: D1Like, f: HeadToHeadFilter): Promise<MatchRow[]> {
|
|
276
|
+
const where = ['((team1 = ? COLLATE NOCASE AND team2 = ? COLLATE NOCASE) OR (team1 = ? COLLATE NOCASE AND team2 = ? COLLATE NOCASE))'];
|
|
277
|
+
const params: unknown[] = [f.team_a, f.team_b, f.team_b, f.team_a];
|
|
278
|
+
if (f.format) {
|
|
279
|
+
where.push('format = ?');
|
|
280
|
+
params.push(f.format);
|
|
281
|
+
}
|
|
282
|
+
const sql = `SELECT match_id, format, gender, team1, team2, match_date, season, venue, city, event_name,
|
|
283
|
+
toss_winner, toss_decision, outcome_winner, outcome_by_runs, outcome_by_wickets,
|
|
284
|
+
outcome_by_innings, outcome_method, result, player_of_match
|
|
285
|
+
FROM matches WHERE ${where.join(' AND ')} ORDER BY match_date DESC LIMIT ? OFFSET ?`;
|
|
286
|
+
params.push(f.limit, f.offset);
|
|
287
|
+
const r = await db.prepare(sql).bind(...params).all<MatchRow>();
|
|
288
|
+
return r.results;
|
|
289
|
+
}
|
|
290
|
+
export async function headToHeadCount(db: D1Like, f: Omit<HeadToHeadFilter, 'limit' | 'offset'>): Promise<number> {
|
|
291
|
+
const where = ['((team1 = ? COLLATE NOCASE AND team2 = ? COLLATE NOCASE) OR (team1 = ? COLLATE NOCASE AND team2 = ? COLLATE NOCASE))'];
|
|
292
|
+
const params: unknown[] = [f.team_a, f.team_b, f.team_b, f.team_a];
|
|
293
|
+
if (f.format) {
|
|
294
|
+
where.push('format = ?');
|
|
295
|
+
params.push(f.format);
|
|
296
|
+
}
|
|
297
|
+
const r = await db.prepare(`SELECT COUNT(*) AS n FROM matches WHERE ${where.join(' AND ')}`).bind(...params).first<{ n: number }>();
|
|
298
|
+
return r?.n ?? 0;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export interface FindMatchesFilter {
|
|
302
|
+
team?: string;
|
|
303
|
+
format?: Format | null;
|
|
304
|
+
season?: string;
|
|
305
|
+
venue?: string;
|
|
306
|
+
player?: string;
|
|
307
|
+
limit: number;
|
|
308
|
+
offset: number;
|
|
309
|
+
}
|
|
310
|
+
export async function findMatches(db: D1Like, f: FindMatchesFilter): Promise<MatchRow[]> {
|
|
311
|
+
const where: string[] = [];
|
|
312
|
+
const params: unknown[] = [];
|
|
313
|
+
if (f.team) {
|
|
314
|
+
where.push('(team1 = ? COLLATE NOCASE OR team2 = ? COLLATE NOCASE)');
|
|
315
|
+
params.push(f.team, f.team);
|
|
316
|
+
}
|
|
317
|
+
if (f.format) {
|
|
318
|
+
where.push('format = ?');
|
|
319
|
+
params.push(f.format);
|
|
320
|
+
}
|
|
321
|
+
if (f.season) {
|
|
322
|
+
where.push('season = ? COLLATE NOCASE');
|
|
323
|
+
params.push(f.season);
|
|
324
|
+
}
|
|
325
|
+
if (f.venue) {
|
|
326
|
+
where.push('venue LIKE ? COLLATE NOCASE');
|
|
327
|
+
params.push(`%${f.venue}%`);
|
|
328
|
+
}
|
|
329
|
+
if (f.player) {
|
|
330
|
+
where.push(
|
|
331
|
+
'match_id IN (SELECT match_id FROM batting_cards WHERE player = ? COLLATE NOCASE UNION SELECT match_id FROM bowling_cards WHERE player = ? COLLATE NOCASE)',
|
|
332
|
+
);
|
|
333
|
+
params.push(f.player, f.player);
|
|
334
|
+
}
|
|
335
|
+
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
|
336
|
+
const sql = `SELECT match_id, format, gender, team1, team2, match_date, season, venue, city, event_name,
|
|
337
|
+
toss_winner, toss_decision, outcome_winner, outcome_by_runs, outcome_by_wickets,
|
|
338
|
+
outcome_by_innings, outcome_method, result, player_of_match
|
|
339
|
+
FROM matches ${whereSql} ORDER BY match_date DESC LIMIT ? OFFSET ?`;
|
|
340
|
+
params.push(f.limit, f.offset);
|
|
341
|
+
const r = await db.prepare(sql).bind(...params).all<MatchRow>();
|
|
342
|
+
return r.results;
|
|
343
|
+
}
|
|
344
|
+
export async function findMatchesCount(db: D1Like, f: Omit<FindMatchesFilter, 'limit' | 'offset'>): Promise<number> {
|
|
345
|
+
const where: string[] = [];
|
|
346
|
+
const params: unknown[] = [];
|
|
347
|
+
if (f.team) {
|
|
348
|
+
where.push('(team1 = ? COLLATE NOCASE OR team2 = ? COLLATE NOCASE)');
|
|
349
|
+
params.push(f.team, f.team);
|
|
350
|
+
}
|
|
351
|
+
if (f.format) {
|
|
352
|
+
where.push('format = ?');
|
|
353
|
+
params.push(f.format);
|
|
354
|
+
}
|
|
355
|
+
if (f.season) {
|
|
356
|
+
where.push('season = ? COLLATE NOCASE');
|
|
357
|
+
params.push(f.season);
|
|
358
|
+
}
|
|
359
|
+
if (f.venue) {
|
|
360
|
+
where.push('venue LIKE ? COLLATE NOCASE');
|
|
361
|
+
params.push(`%${f.venue}%`);
|
|
362
|
+
}
|
|
363
|
+
if (f.player) {
|
|
364
|
+
where.push(
|
|
365
|
+
'match_id IN (SELECT match_id FROM batting_cards WHERE player = ? COLLATE NOCASE UNION SELECT match_id FROM bowling_cards WHERE player = ? COLLATE NOCASE)',
|
|
366
|
+
);
|
|
367
|
+
params.push(f.player, f.player);
|
|
368
|
+
}
|
|
369
|
+
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
|
370
|
+
const r = await db.prepare(`SELECT COUNT(*) AS n FROM matches ${whereSql}`).bind(...params).first<{ n: number }>();
|
|
371
|
+
return r?.n ?? 0;
|
|
372
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"types": ["@cloudflare/workers-types"],
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"resolveJsonModule": true,
|
|
12
|
+
"outDir": "dist",
|
|
13
|
+
"rootDir": "src",
|
|
14
|
+
"declaration": true
|
|
15
|
+
},
|
|
16
|
+
"include": ["src"],
|
|
17
|
+
"exclude": ["src/server.ts"]
|
|
18
|
+
}
|