codechef-mcp 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 +173 -2
- package/package.json +47 -4
- package/src/api.js +210 -0
- package/src/errors.js +9 -0
- package/src/http.js +219 -0
- package/src/parse.js +163 -0
- package/src/server.js +17 -0
- package/src/tools.js +162 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sarthak Agnihotri
|
|
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,174 @@
|
|
|
1
|
-
#
|
|
1
|
+
# codechef-mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/codechef-mcp)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](package.json)
|
|
6
|
+
|
|
7
|
+
An MCP server that turns CodeChef user profiles into **structured JSON** for LLMs — no more
|
|
8
|
+
dumping 180 KB of HTML into the context window and hoping the model finds the rating.
|
|
9
|
+
|
|
10
|
+
Written in plain Node.js (ESM). No build step.
|
|
11
|
+
|
|
12
|
+
## Why
|
|
13
|
+
|
|
14
|
+
A CodeChef profile page is ~180 KB of markup. Most of the useful data is actually already
|
|
15
|
+
machine-readable inside it, just buried:
|
|
16
|
+
|
|
17
|
+
| Data | Where it lives |
|
|
18
|
+
| --- | --- |
|
|
19
|
+
| Contest rating history | `var all_rating = [...]` |
|
|
20
|
+
| Submission heat map | `userDailySubmissionsStats = [...]` |
|
|
21
|
+
| Name, stars, country, role, organisation | `.user-details` sidebar |
|
|
22
|
+
| Rating, division, global/country rank | `#rating-block-all` |
|
|
23
|
+
| Problems solved total | `Total Problems Solved: N` |
|
|
24
|
+
| Per-contest solved problems | `.problems-solved .content` |
|
|
25
|
+
|
|
26
|
+
This server extracts those into typed fields, normalizes dates/numbers, computes deltas, and
|
|
27
|
+
returns compact JSON (a few KB) instead of raw HTML.
|
|
28
|
+
|
|
29
|
+
## Tools
|
|
30
|
+
|
|
31
|
+
OpenCode prefixes tool names with the server name, so a server named `codechef` exposes them as
|
|
32
|
+
`codechef_get_profile`, `codechef_get_rating_history`, and so on.
|
|
33
|
+
|
|
34
|
+
| Tool | Returns |
|
|
35
|
+
| --- | --- |
|
|
36
|
+
| `get_profile` | Name, stars, country, role, organisation, current/highest rating, division, global rank, country rank, problems solved, contest count, latest contest, best rating, last 5 contests |
|
|
37
|
+
| `get_rating_history` | Newest-first contest list with `rating`, `ratingChange` delta and `rank` |
|
|
38
|
+
| `get_activity` | Submission heat map summary: total submissions, active days, average per active day, busiest day, monthly totals (daily detail when `days <= 60`) |
|
|
39
|
+
| `get_contests` | Recent contests with the problems solved in each, merged with rank and rating delta |
|
|
40
|
+
| `get_solved_problems` | Unique problem names with their contest, plus the profile's reported total |
|
|
41
|
+
| `compare_users` | Compact side-by-side profiles for 2–5 handles |
|
|
42
|
+
|
|
43
|
+
All tools take a `handle` (CodeChef username, e.g. `gennady.korotkevich`).
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
npx codechef-mcp
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Or globally:
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
npm install -g codechef-mcp
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Quick start
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
cd codechef-mcp
|
|
61
|
+
npm install
|
|
62
|
+
npm test # unit + live + MCP handshake
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Run the server directly (it speaks MCP over stdio):
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
npm start
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Try the scraper without MCP:
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
node test/smoke.js aryanc403
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Wiring it into OpenCode (V2)
|
|
78
|
+
|
|
79
|
+
Config uses `mcp.servers`. Either run:
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
opencode mcp add codechef --global -- node "/path/to/codechef-mcp/src/server.js"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
or add it by hand to `~/.config/opencode/opencode.jsonc`:
|
|
86
|
+
|
|
87
|
+
```jsonc
|
|
88
|
+
{
|
|
89
|
+
"$schema": "https://opencode.ai/config.json",
|
|
90
|
+
"mcp": {
|
|
91
|
+
"servers": {
|
|
92
|
+
"codechef": {
|
|
93
|
+
"type": "local",
|
|
94
|
+
"command": ["node", "/path/to/codechef-mcp/src/server.js"],
|
|
95
|
+
"cwd": "/path/to/codechef-mcp"
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Then check it with `opencode mcp list`. Under Code Mode the tools appear as
|
|
103
|
+
`tools.codechef.get_profile(...)`.
|
|
104
|
+
|
|
105
|
+
## Claude Desktop
|
|
106
|
+
|
|
107
|
+
`claude_desktop_config.json`:
|
|
108
|
+
|
|
109
|
+
```json
|
|
110
|
+
{
|
|
111
|
+
"mcpServers": {
|
|
112
|
+
"codechef": {
|
|
113
|
+
"command": "node",
|
|
114
|
+
"args": ["/path/to/codechef-mcp/src/server.js"]
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Behavior
|
|
121
|
+
|
|
122
|
+
- **Cache:** parsed pages are cached for 10 minutes per handle (max 200 entries).
|
|
123
|
+
- **Throttle:** at least 800 ms between outbound requests.
|
|
124
|
+
- **Retries:** up to 3 attempts with backoff on `403`, `429`, `5xx` and Cloudflare challenges.
|
|
125
|
+
- **Errors:** typed codes — `INVALID_HANDLE`, `USER_NOT_FOUND`, `BLOCKED`, `UNPARSEABLE`,
|
|
126
|
+
`FETCH_FAILED` — returned with `isError: true` so the model can react instead of retrying blindly.
|
|
127
|
+
- **User-Agent:** identifies the client; requests are one profile page at a time.
|
|
128
|
+
|
|
129
|
+
## Security
|
|
130
|
+
|
|
131
|
+
- **Outbound HTTPS only, to `www.codechef.com`.** Responses redirected to any other host are rejected.
|
|
132
|
+
- **TLS verification** is on (Node's default `fetch`; no `rejectUnauthorized: false` anywhere).
|
|
133
|
+
- **Bounded resources:** 5 MB response cap (streamed, aborts mid-body), 25 s timeout, 3 retries,
|
|
134
|
+
200-entry cache.
|
|
135
|
+
- **No `eval`, no `new Function`, no child processes, no filesystem or environment access.**
|
|
136
|
+
- **No credentials** are read, stored, or sent — requests are anonymous.
|
|
137
|
+
- **Handle whitelisting:** handles must match `^[A-Za-z0-9._-]{1,64}$` and are URL-encoded, so a
|
|
138
|
+
handle cannot alter the request path.
|
|
139
|
+
- **Null-prototype maps** for page-derived keys, so a crafted label cannot touch `Object.prototype`.
|
|
140
|
+
- Publishing is limited by the `files` field: only `src/` + docs ship to npm.
|
|
141
|
+
|
|
142
|
+
## Caveats
|
|
143
|
+
|
|
144
|
+
- **Unofficial.** CodeChef's HTML is not a contract. If they change markup, `test/unit.js` and
|
|
145
|
+
`test/smoke.js` will tell you which field broke.
|
|
146
|
+
- **Cloudflare.** CodeChef fronts the site with Cloudflare. A burst of requests can get a
|
|
147
|
+
"Just a moment..." challenge; the server detects it and reports `BLOCKED` rather than
|
|
148
|
+
returning garbage. Wait a minute and retry.
|
|
149
|
+
- **`robots.txt`.** `/users/*` is not disallowed, which is what this server reads. The
|
|
150
|
+
`/rankings/*` leaderboard **is** disallowed, so global/country rank are taken from the profile
|
|
151
|
+
page instead of scraping rankings.
|
|
152
|
+
- **Counts can differ.** `get_solved_problems.uniqueProblemsSolved` (from the per-contest list) can
|
|
153
|
+
be lower than `reportedTotalSolved`, because the profile only lists problems for the contests it
|
|
154
|
+
renders.
|
|
155
|
+
- No login-only data (submissions, code, unsolved problems) — that needs a session and is out of
|
|
156
|
+
scope.
|
|
157
|
+
|
|
158
|
+
## Layout
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
src/server.js MCP entry point (stdio)
|
|
162
|
+
src/tools.js tool registration + schemas
|
|
163
|
+
src/api.js derived data (deltas, summaries)
|
|
164
|
+
src/parse.js HTML/embedded-JSON parsing
|
|
165
|
+
src/http.js fetch, cache, throttle, retries
|
|
166
|
+
test/unit.js offline parser tests
|
|
167
|
+
test/smoke.js live scraper test
|
|
168
|
+
test/mcp.js end-to-end MCP client handshake
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## License
|
|
172
|
+
|
|
173
|
+
MIT © Sarthak Agnihotri ([sarthaksec](https://github.com/sarthaksec)).
|
|
174
|
+
Not affiliated with or endorsed by CodeChef.
|
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codechef-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server that turns CodeChef user profiles into structured JSON for LLMs",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/server.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"codechef-mcp": "src/server.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"start": "node src/server.js",
|
|
15
|
+
"test": "node test/unit.js && node test/smoke.js && node test/mcp.js",
|
|
16
|
+
"test:unit": "node test/unit.js",
|
|
17
|
+
"test:live": "node test/smoke.js",
|
|
18
|
+
"test:mcp": "node test/mcp.js"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"mcp",
|
|
22
|
+
"model-context-protocol",
|
|
23
|
+
"codechef",
|
|
24
|
+
"competitive-programming",
|
|
25
|
+
"scraper",
|
|
26
|
+
"llm"
|
|
27
|
+
],
|
|
28
|
+
"author": "Sarthak Agnihotri (sarthaksec)",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/sarthaksec/codechef-mcp.git"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://github.com/sarthaksec/codechef-mcp#readme",
|
|
35
|
+
"bugs": {
|
|
36
|
+
"url": "https://github.com/sarthaksec/codechef-mcp/issues"
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=20"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
46
|
+
"cheerio": "^1.2.0",
|
|
47
|
+
"zod": "^4.6.7"
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/api.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { fetchUserPage } from "./http.js";
|
|
2
|
+
import { parseUserPage } from "./parse.js";
|
|
3
|
+
|
|
4
|
+
async function load(handle, opts) {
|
|
5
|
+
const { html, fromCache, url } = await fetchUserPage(handle, opts);
|
|
6
|
+
const data = parseUserPage(html, handle);
|
|
7
|
+
return { data, meta: { profileUrl: url, fromCache } };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function sortedHistory(history) {
|
|
11
|
+
return [...history]
|
|
12
|
+
.filter((h) => h.date)
|
|
13
|
+
.sort((a, b) => a.date.localeCompare(b.date));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function withDeltas(history) {
|
|
17
|
+
const sorted = sortedHistory(history);
|
|
18
|
+
return sorted.map((entry, i) => ({
|
|
19
|
+
...entry,
|
|
20
|
+
ratingChange: i === 0 || entry.rating == null || sorted[i - 1].rating == null
|
|
21
|
+
? null
|
|
22
|
+
: entry.rating - sorted[i - 1].rating,
|
|
23
|
+
}));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function getProfile(handle) {
|
|
27
|
+
const { data, meta } = await load(handle);
|
|
28
|
+
const history = withDeltas(data.ratingHistory);
|
|
29
|
+
|
|
30
|
+
const latest = history.at(-1) ?? null;
|
|
31
|
+
const best = history.reduce(
|
|
32
|
+
(acc, h) => (h.rating != null && (!acc || h.rating > acc.rating) ? h : acc),
|
|
33
|
+
null,
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
username: data.username,
|
|
38
|
+
name: data.name,
|
|
39
|
+
stars: data.stars,
|
|
40
|
+
country: data.country,
|
|
41
|
+
countryCode: data.countryCode,
|
|
42
|
+
role: data.role,
|
|
43
|
+
organisation: data.organisation,
|
|
44
|
+
rating: data.rating,
|
|
45
|
+
problemsSolved: data.problemsSolved,
|
|
46
|
+
contestsParticipated: history.length,
|
|
47
|
+
latestContest: latest
|
|
48
|
+
? {
|
|
49
|
+
contest: latest.contest,
|
|
50
|
+
date: latest.date,
|
|
51
|
+
rating: latest.rating,
|
|
52
|
+
ratingChange: latest.ratingChange,
|
|
53
|
+
rank: latest.rank,
|
|
54
|
+
}
|
|
55
|
+
: null,
|
|
56
|
+
bestRating: best
|
|
57
|
+
? { rating: best.rating, contest: best.contest, date: best.date }
|
|
58
|
+
: null,
|
|
59
|
+
recentForm: history.slice(-5).map((h) => ({
|
|
60
|
+
date: h.date,
|
|
61
|
+
rating: h.rating,
|
|
62
|
+
ratingChange: h.ratingChange,
|
|
63
|
+
rank: h.rank,
|
|
64
|
+
})),
|
|
65
|
+
profileUrl: meta.profileUrl,
|
|
66
|
+
fetchedAt: new Date().toISOString(),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function getRatingHistory(handle, { limit = 20 } = {}) {
|
|
71
|
+
const { data, meta } = await load(handle);
|
|
72
|
+
const history = withDeltas(data.ratingHistory);
|
|
73
|
+
const recent = history.slice(-limit).reverse();
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
username: data.username,
|
|
77
|
+
totalContests: history.length,
|
|
78
|
+
currentRating: data.rating.current,
|
|
79
|
+
highestRating: data.rating.highest,
|
|
80
|
+
contests: recent.map((h) => ({
|
|
81
|
+
contest: h.contest,
|
|
82
|
+
code: h.code,
|
|
83
|
+
date: h.date,
|
|
84
|
+
rating: h.rating,
|
|
85
|
+
ratingChange: h.ratingChange,
|
|
86
|
+
rank: h.rank,
|
|
87
|
+
})),
|
|
88
|
+
profileUrl: meta.profileUrl,
|
|
89
|
+
fetchedAt: new Date().toISOString(),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function getActivity(handle, { days = 90 } = {}) {
|
|
94
|
+
const { data, meta } = await load(handle);
|
|
95
|
+
const cutoff = new Date(Date.now() - days * 86_400_000)
|
|
96
|
+
.toISOString()
|
|
97
|
+
.slice(0, 10);
|
|
98
|
+
|
|
99
|
+
const window = data.heatmap
|
|
100
|
+
.filter((d) => d.date >= cutoff)
|
|
101
|
+
.sort((a, b) => a.date.localeCompare(b.date));
|
|
102
|
+
|
|
103
|
+
const totalSubmissions = window.reduce((s, d) => s + d.submissions, 0);
|
|
104
|
+
const activeDays = window.filter((d) => d.submissions > 0).length;
|
|
105
|
+
const busiest = window.reduce(
|
|
106
|
+
(acc, d) => (d.submissions > (acc?.submissions ?? 0) ? d : acc),
|
|
107
|
+
null,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const byMonth = {};
|
|
111
|
+
for (const d of window) {
|
|
112
|
+
const month = d.date.slice(0, 7);
|
|
113
|
+
byMonth[month] = (byMonth[month] ?? 0) + d.submissions;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
username: data.username,
|
|
118
|
+
windowDays: days,
|
|
119
|
+
since: cutoff,
|
|
120
|
+
totalSubmissions,
|
|
121
|
+
activeDays,
|
|
122
|
+
avgSubmissionsPerActiveDay: activeDays
|
|
123
|
+
? Number((totalSubmissions / activeDays).toFixed(1))
|
|
124
|
+
: 0,
|
|
125
|
+
busiestDay: busiest,
|
|
126
|
+
byMonth,
|
|
127
|
+
daily: days <= 60 ? window : undefined,
|
|
128
|
+
profileUrl: meta.profileUrl,
|
|
129
|
+
fetchedAt: new Date().toISOString(),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function getContests(handle, { limit = 10 } = {}) {
|
|
134
|
+
const { data, meta } = await load(handle);
|
|
135
|
+
const byName = new Map(
|
|
136
|
+
withDeltas(data.ratingHistory).map((h) => [h.contest, h]),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const contests = [...data.contests]
|
|
140
|
+
.reverse()
|
|
141
|
+
.slice(0, limit)
|
|
142
|
+
.map((c) => {
|
|
143
|
+
const rating = byName.get(c.contest);
|
|
144
|
+
return {
|
|
145
|
+
contest: c.contest,
|
|
146
|
+
problemsSolved: c.problemsSolved,
|
|
147
|
+
problems: c.problems,
|
|
148
|
+
date: rating?.date ?? null,
|
|
149
|
+
rank: rating?.rank ?? null,
|
|
150
|
+
ratingAfter: rating?.rating ?? null,
|
|
151
|
+
ratingChange: rating?.ratingChange ?? null,
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
username: data.username,
|
|
157
|
+
totalContestsWithSolutions: data.contests.length,
|
|
158
|
+
contests,
|
|
159
|
+
profileUrl: meta.profileUrl,
|
|
160
|
+
fetchedAt: new Date().toISOString(),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function getSolvedProblems(handle, { limit = 200 } = {}) {
|
|
165
|
+
const { data, meta } = await load(handle);
|
|
166
|
+
const seen = new Map();
|
|
167
|
+
|
|
168
|
+
for (const c of data.contests) {
|
|
169
|
+
for (const p of c.problems) {
|
|
170
|
+
if (!seen.has(p)) seen.set(p, c.contest);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const all = [...seen.entries()].map(([problem, contest]) => ({ problem, contest }));
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
username: data.username,
|
|
178
|
+
uniqueProblemsSolved: all.length,
|
|
179
|
+
reportedTotalSolved: data.problemsSolved,
|
|
180
|
+
returned: Math.min(limit, all.length),
|
|
181
|
+
problems: all.slice(0, limit),
|
|
182
|
+
profileUrl: meta.profileUrl,
|
|
183
|
+
fetchedAt: new Date().toISOString(),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function compareUsers(handles) {
|
|
188
|
+
const results = [];
|
|
189
|
+
for (const handle of handles) {
|
|
190
|
+
try {
|
|
191
|
+
const p = await getProfile(handle);
|
|
192
|
+
results.push({
|
|
193
|
+
username: p.username,
|
|
194
|
+
name: p.name,
|
|
195
|
+
stars: p.stars,
|
|
196
|
+
country: p.country,
|
|
197
|
+
rating: p.rating,
|
|
198
|
+
problemsSolved: p.problemsSolved,
|
|
199
|
+
contestsParticipated: p.contestsParticipated,
|
|
200
|
+
bestRating: p.bestRating?.rating ?? null,
|
|
201
|
+
});
|
|
202
|
+
} catch (err) {
|
|
203
|
+
results.push({ username: handle, error: err.message });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
users: results,
|
|
208
|
+
fetchedAt: new Date().toISOString(),
|
|
209
|
+
};
|
|
210
|
+
}
|
package/src/errors.js
ADDED
package/src/http.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { CodeChefError } from "./errors.js";
|
|
2
|
+
|
|
3
|
+
const BASE = "https://www.codechef.com/users/";
|
|
4
|
+
const ALLOWED_HOST = "www.codechef.com";
|
|
5
|
+
const USER_AGENT =
|
|
6
|
+
"codechef-mcp/0.1 (+https://github.com/sarthaksec/codechef-mcp)";
|
|
7
|
+
const CACHE_TTL_MS = 10 * 60 * 1000;
|
|
8
|
+
const MIN_INTERVAL_MS = 800;
|
|
9
|
+
const REQUEST_TIMEOUT_MS = 25_000;
|
|
10
|
+
const MAX_RETRIES = 3;
|
|
11
|
+
const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
|
|
12
|
+
|
|
13
|
+
const cache = new Map();
|
|
14
|
+
let lastRequestAt = 0;
|
|
15
|
+
|
|
16
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
17
|
+
|
|
18
|
+
async function throttle() {
|
|
19
|
+
const wait = MIN_INTERVAL_MS - (Date.now() - lastRequestAt);
|
|
20
|
+
if (wait > 0) await sleep(wait);
|
|
21
|
+
lastRequestAt = Date.now();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isValidHandle(handle) {
|
|
25
|
+
return typeof handle === "string" && /^[A-Za-z0-9._-]{1,64}$/.test(handle);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Fetch a CodeChef user profile page, with caching, global throttling and
|
|
30
|
+
* retries for transient/Cloudflare responses.
|
|
31
|
+
*/
|
|
32
|
+
export async function fetchUserPage(handle, { force = false } = {}) {
|
|
33
|
+
if (!isValidHandle(handle)) {
|
|
34
|
+
throw new CodeChefError(
|
|
35
|
+
`Invalid CodeChef handle: ${JSON.stringify(handle)}`,
|
|
36
|
+
{ code: "INVALID_HANDLE" },
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const key = handle.toLowerCase();
|
|
41
|
+
const cached = cache.get(key);
|
|
42
|
+
if (!force && cached && Date.now() - cached.at < CACHE_TTL_MS) {
|
|
43
|
+
return { html: cached.html, fromCache: true, url: cached.url };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const url = BASE + encodeURIComponent(handle);
|
|
47
|
+
let lastErr;
|
|
48
|
+
|
|
49
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
50
|
+
await throttle();
|
|
51
|
+
try {
|
|
52
|
+
const res = await fetch(url, {
|
|
53
|
+
headers: {
|
|
54
|
+
"User-Agent": USER_AGENT,
|
|
55
|
+
Accept: "text/html,application/xhtml+xml",
|
|
56
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
57
|
+
},
|
|
58
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
59
|
+
redirect: "follow",
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
if (res.status === 404) {
|
|
63
|
+
throw new CodeChefError(`CodeChef user "${handle}" not found (HTTP 404).`, {
|
|
64
|
+
code: "USER_NOT_FOUND",
|
|
65
|
+
status: 404,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (res.status === 429 || res.status === 403 || res.status >= 500) {
|
|
69
|
+
lastErr = new CodeChefError(
|
|
70
|
+
`CodeChef responded with HTTP ${res.status}.`,
|
|
71
|
+
{ code: "UPSTREAM", status: res.status, retryable: true },
|
|
72
|
+
);
|
|
73
|
+
await sleep(500 * 2 ** attempt);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (!res.ok) {
|
|
77
|
+
throw new CodeChefError(`Unexpected HTTP ${res.status} from CodeChef.`, {
|
|
78
|
+
code: "UPSTREAM",
|
|
79
|
+
status: res.status,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Never follow a redirect off CodeChef (defends against an upstream
|
|
84
|
+
// redirect being used to point the client at another host).
|
|
85
|
+
if (new URL(res.url).hostname !== ALLOWED_HOST) {
|
|
86
|
+
throw new CodeChefError(
|
|
87
|
+
`Refusing response redirected to unexpected host: ${new URL(res.url).hostname}`,
|
|
88
|
+
{ code: "UNEXPECTED_REDIRECT" },
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const html = await readTextCapped(res, MAX_RESPONSE_BYTES);
|
|
93
|
+
|
|
94
|
+
if (isCloudflareChallenge(html)) {
|
|
95
|
+
lastErr = new CodeChefError(
|
|
96
|
+
"CodeChef's Cloudflare protection served a bot challenge instead of the profile. " +
|
|
97
|
+
"Wait a minute and retry, or lower the request rate.",
|
|
98
|
+
{ code: "BLOCKED", retryable: true },
|
|
99
|
+
);
|
|
100
|
+
await sleep(1500 * 2 ** attempt);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (isNotFoundPage(html)) {
|
|
105
|
+
throw new CodeChefError(`CodeChef user "${handle}" does not exist.`, {
|
|
106
|
+
code: "USER_NOT_FOUND",
|
|
107
|
+
status: 404,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!looksLikeProfile(html)) {
|
|
112
|
+
throw new CodeChefError(
|
|
113
|
+
"CodeChef returned a page without profile data (possible markup change or outage).",
|
|
114
|
+
{ code: "UNPARSEABLE", retryable: true },
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
cache.set(key, { html, at: Date.now(), url });
|
|
119
|
+
pruneCache();
|
|
120
|
+
return { html, fromCache: false, url };
|
|
121
|
+
} catch (err) {
|
|
122
|
+
if (err instanceof CodeChefError && !err.retryable) throw err;
|
|
123
|
+
lastErr = err;
|
|
124
|
+
await sleep(500 * 2 ** attempt);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (lastErr instanceof CodeChefError && lastErr.code === "BLOCKED") {
|
|
129
|
+
throw lastErr;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
throw new CodeChefError(
|
|
133
|
+
`Could not fetch CodeChef profile for "${handle}" after ${MAX_RETRIES} attempts: ${
|
|
134
|
+
lastErr?.message ?? "unknown error"
|
|
135
|
+
}`,
|
|
136
|
+
{ code: "FETCH_FAILED", retryable: true },
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function readTextCapped(res, maxBytes) {
|
|
141
|
+
const declared = Number(res.headers.get("content-length") ?? 0);
|
|
142
|
+
if (declared > maxBytes) {
|
|
143
|
+
throw new CodeChefError(`Response too large (${declared} bytes).`, {
|
|
144
|
+
code: "RESPONSE_TOO_LARGE",
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
if (!res.body) return res.text();
|
|
148
|
+
|
|
149
|
+
const reader = res.body.getReader();
|
|
150
|
+
const decoder = new TextDecoder();
|
|
151
|
+
let total = 0;
|
|
152
|
+
let out = "";
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
for (;;) {
|
|
156
|
+
const { done, value } = await reader.read();
|
|
157
|
+
if (done) break;
|
|
158
|
+
total += value.byteLength;
|
|
159
|
+
if (total > maxBytes) {
|
|
160
|
+
await reader.cancel().catch(() => {});
|
|
161
|
+
throw new CodeChefError(
|
|
162
|
+
`Response exceeded ${maxBytes} bytes; aborted to protect memory.`,
|
|
163
|
+
{ code: "RESPONSE_TOO_LARGE" },
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
out += decoder.decode(value, { stream: true });
|
|
167
|
+
}
|
|
168
|
+
out += decoder.decode();
|
|
169
|
+
return out;
|
|
170
|
+
} finally {
|
|
171
|
+
try {
|
|
172
|
+
reader.releaseLock();
|
|
173
|
+
} catch {
|
|
174
|
+
/* already released */
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function looksLikeProfile(html) {
|
|
180
|
+
return (
|
|
181
|
+
typeof html === "string" &&
|
|
182
|
+
(html.includes('class="user-details"') || html.includes("h2-style")) &&
|
|
183
|
+
html.includes("all_rating")
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function isCloudflareChallenge(html) {
|
|
188
|
+
return (
|
|
189
|
+
typeof html === "string" &&
|
|
190
|
+
(html.includes("_cf_chl_opt") ||
|
|
191
|
+
html.includes("Just a moment...") ||
|
|
192
|
+
html.includes("Enable JavaScript and cookies to continue"))
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function isNotFoundPage(html) {
|
|
197
|
+
if (typeof html !== "string") return false;
|
|
198
|
+
if (looksLikeProfile(html)) return false;
|
|
199
|
+
if (/does not exist|user not found|no such user|invalid user/i.test(html)) {
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
// Unknown handles soft-404 to the generic CodeChef home shell with HTTP 200.
|
|
203
|
+
return (
|
|
204
|
+
/<title>\s*CodeChef\s*-\s*Learn and Practice Coding with Problems\s*<\/title>/i.test(html) ||
|
|
205
|
+
/property="og:url"\s+content="https:\/\/www\.codechef\.com\/"/i.test(html)
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export { isCloudflareChallenge };
|
|
210
|
+
|
|
211
|
+
function pruneCache() {
|
|
212
|
+
if (cache.size <= 200) return;
|
|
213
|
+
const oldest = [...cache.entries()].sort((a, b) => a[1].at - b[1].at);
|
|
214
|
+
for (const [k] of oldest.slice(0, cache.size - 200)) cache.delete(k);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function clearCache() {
|
|
218
|
+
cache.clear();
|
|
219
|
+
}
|
package/src/parse.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import * as cheerio from "cheerio";
|
|
2
|
+
|
|
3
|
+
const clean = (s = "") =>
|
|
4
|
+
s.replace(/\u00a0/g, " ").replace(/\s+/g, " ").trim();
|
|
5
|
+
|
|
6
|
+
const num = (v) => {
|
|
7
|
+
if (v == null) return null;
|
|
8
|
+
const n = Number(String(v).replace(/[^0-9-]/g, ""));
|
|
9
|
+
return Number.isFinite(n) ? n : null;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Extract a JS array literal that follows `marker`, e.g.
|
|
14
|
+
* var all_rating = [ ... ];
|
|
15
|
+
* Uses bracket matching so nested objects/strings don't break it.
|
|
16
|
+
*/
|
|
17
|
+
export function extractJsonArray(source, marker) {
|
|
18
|
+
const at = source.indexOf(marker);
|
|
19
|
+
if (at === -1) return null;
|
|
20
|
+
const start = source.indexOf("[", at);
|
|
21
|
+
if (start === -1) return null;
|
|
22
|
+
|
|
23
|
+
let depth = 0;
|
|
24
|
+
let inString = false;
|
|
25
|
+
let escaped = false;
|
|
26
|
+
|
|
27
|
+
for (let i = start; i < source.length; i++) {
|
|
28
|
+
const ch = source[i];
|
|
29
|
+
if (escaped) {
|
|
30
|
+
escaped = false;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (inString) {
|
|
34
|
+
if (ch === "\\") escaped = true;
|
|
35
|
+
else if (ch === '"') inString = false;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (ch === '"') inString = true;
|
|
39
|
+
else if (ch === "[") depth++;
|
|
40
|
+
else if (ch === "]") {
|
|
41
|
+
depth--;
|
|
42
|
+
if (depth === 0) return source.slice(start, i + 1);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function safeParseArray(source, marker) {
|
|
49
|
+
const raw = extractJsonArray(source, marker);
|
|
50
|
+
if (!raw) return [];
|
|
51
|
+
try {
|
|
52
|
+
const parsed = JSON.parse(raw);
|
|
53
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
54
|
+
} catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function normalizeDate(value) {
|
|
60
|
+
if (!value) return null;
|
|
61
|
+
const m = /^(\d{4})-(\d{1,2})-(\d{1,2})/.exec(String(value).trim());
|
|
62
|
+
if (!m) return null;
|
|
63
|
+
return `${m[1]}-${String(m[2]).padStart(2, "0")}-${String(m[3]).padStart(2, "0")}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function parseUserPage(html, handle) {
|
|
67
|
+
const $ = cheerio.load(html);
|
|
68
|
+
|
|
69
|
+
const name = clean($("h1.h2-style").first().text()) || null;
|
|
70
|
+
const username = clean($(".m-username--link").first().text()) || handle;
|
|
71
|
+
|
|
72
|
+
// Sidebar: Username / Country / Student-Professional / Organisation / Pro Plan
|
|
73
|
+
const sidebar = Object.create(null);
|
|
74
|
+
$(".user-details li").each((_, li) => {
|
|
75
|
+
const $li = $(li);
|
|
76
|
+
const label = clean($li.find("label").first().text())
|
|
77
|
+
.replace(/:$/, "")
|
|
78
|
+
.toLowerCase();
|
|
79
|
+
if (!label) return;
|
|
80
|
+
const clone = $li.clone();
|
|
81
|
+
clone.find("label").remove();
|
|
82
|
+
clone.find("a").remove();
|
|
83
|
+
const value = clean(clone.text()).replace(/View Details$/i, "").trim();
|
|
84
|
+
if (value) sidebar[label] = value;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const starsText = clean($(".user-details .rating").first().text());
|
|
88
|
+
const stars = num(/(\d+)/.exec(starsText)?.[1]);
|
|
89
|
+
const starCount = $("#rating-block-all .rating-star span").length || null;
|
|
90
|
+
|
|
91
|
+
const block = $("#rating-block-all");
|
|
92
|
+
const ratingText = clean(block.find(".rating-number").first().text());
|
|
93
|
+
const currentRating = /^\d+$/.test(ratingText) ? Number(ratingText) : null;
|
|
94
|
+
|
|
95
|
+
const headerText = clean(block.find(".rating-header").first().text());
|
|
96
|
+
const division = /\(Div\s*(\d+)\)/i.exec(headerText)?.[1] ?? null;
|
|
97
|
+
const highestRating = num(/(\d+)/.exec(clean(block.find("small").first().text()))?.[1]);
|
|
98
|
+
|
|
99
|
+
const ranks = Object.create(null);
|
|
100
|
+
block.find(".rating-ranks li").each((_, li) => {
|
|
101
|
+
const $li = $(li);
|
|
102
|
+
const value = clean($li.find("strong").first().text());
|
|
103
|
+
const label = clean($li.text()).replace(value, "").trim().toLowerCase();
|
|
104
|
+
const key = label.includes("global")
|
|
105
|
+
? "global"
|
|
106
|
+
: label.includes("country")
|
|
107
|
+
? "country"
|
|
108
|
+
: label || "unknown";
|
|
109
|
+
ranks[key] = /^\d+$/.test(value) ? Number(value) : value || null;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
let problemsSolved = null;
|
|
113
|
+
$("h3").each((_, h) => {
|
|
114
|
+
const m = /Total Problems Solved:\s*([\d,]+)/i.exec(clean($(h).text()));
|
|
115
|
+
if (m) problemsSolved = Number(m[1].replace(/,/g, ""));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const contests = [];
|
|
119
|
+
$(".problems-solved .content").each((_, el) => {
|
|
120
|
+
const contest = clean($(el).find("h5").first().text());
|
|
121
|
+
const problems = clean($(el).find("p").first().text())
|
|
122
|
+
.split(",")
|
|
123
|
+
.map((p) => p.trim())
|
|
124
|
+
.filter(Boolean);
|
|
125
|
+
if (contest) contests.push({ contest, problemsSolved: problems.length, problems });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const ratingHistory = safeParseArray(html, "var all_rating")
|
|
129
|
+
.map((r) => ({
|
|
130
|
+
code: r.code ?? null,
|
|
131
|
+
contest: clean(r.name) || null,
|
|
132
|
+
date: normalizeDate(r.end_date) ?? normalizeDate(`${r.getyear}-${r.getmonth}-${r.getday}`),
|
|
133
|
+
rating: num(r.rating),
|
|
134
|
+
rank: num(r.rank),
|
|
135
|
+
reason: r.reason ?? null,
|
|
136
|
+
}))
|
|
137
|
+
.filter((r) => r.date || r.rating != null);
|
|
138
|
+
|
|
139
|
+
const heatmap = safeParseArray(html, "userDailySubmissionsStats")
|
|
140
|
+
.map((d) => ({ date: normalizeDate(d.date), submissions: num(d.value) ?? 0 }))
|
|
141
|
+
.filter((d) => d.date);
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
username,
|
|
145
|
+
name,
|
|
146
|
+
stars: stars ?? starCount,
|
|
147
|
+
country: clean($(".user-country-name").first().text()) || null,
|
|
148
|
+
countryCode: $("img.user-country-flag").first().attr("title") || null,
|
|
149
|
+
role: sidebar["student/professional"] ?? null,
|
|
150
|
+
organisation: sidebar["organisation"] ?? sidebar["institution"] ?? null,
|
|
151
|
+
rating: {
|
|
152
|
+
current: currentRating,
|
|
153
|
+
highest: highestRating,
|
|
154
|
+
division: division ? Number(division) : null,
|
|
155
|
+
globalRank: ranks.global ?? null,
|
|
156
|
+
countryRank: ranks.country ?? null,
|
|
157
|
+
},
|
|
158
|
+
problemsSolved,
|
|
159
|
+
contests,
|
|
160
|
+
ratingHistory,
|
|
161
|
+
heatmap,
|
|
162
|
+
};
|
|
163
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { registerTools } from "./tools.js";
|
|
5
|
+
|
|
6
|
+
const server = new McpServer({
|
|
7
|
+
name: "codechef-mcp",
|
|
8
|
+
version: "0.1.0",
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
registerTools(server);
|
|
12
|
+
|
|
13
|
+
const transport = new StdioServerTransport();
|
|
14
|
+
await server.connect(transport);
|
|
15
|
+
|
|
16
|
+
// stdout is reserved for the MCP protocol; diagnostics go to stderr.
|
|
17
|
+
console.error("[codechef-mcp] running on stdio");
|
package/src/tools.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import {
|
|
3
|
+
compareUsers,
|
|
4
|
+
getActivity,
|
|
5
|
+
getContests,
|
|
6
|
+
getProfile,
|
|
7
|
+
getRatingHistory,
|
|
8
|
+
getSolvedProblems,
|
|
9
|
+
} from "./api.js";
|
|
10
|
+
|
|
11
|
+
const handleSchema = z
|
|
12
|
+
.string()
|
|
13
|
+
.min(1)
|
|
14
|
+
.max(64)
|
|
15
|
+
.describe("CodeChef username/handle, e.g. 'gennady.korotkevich'");
|
|
16
|
+
|
|
17
|
+
const json = (data) => ({
|
|
18
|
+
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const fail = (err) => ({
|
|
22
|
+
isError: true,
|
|
23
|
+
content: [
|
|
24
|
+
{
|
|
25
|
+
type: "text",
|
|
26
|
+
text: `CodeChef MCP error (${err.code ?? "ERROR"}): ${err.message}`,
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const wrap = (fn) => async (args) => {
|
|
32
|
+
try {
|
|
33
|
+
return json(await fn(args));
|
|
34
|
+
} catch (err) {
|
|
35
|
+
return fail(err);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function registerTools(server) {
|
|
40
|
+
server.registerTool(
|
|
41
|
+
"get_profile",
|
|
42
|
+
{
|
|
43
|
+
title: "Get CodeChef profile",
|
|
44
|
+
description:
|
|
45
|
+
"Fetch a CodeChef user's profile as compact structured JSON: name, star rating, " +
|
|
46
|
+
"current and highest rating, division, global rank, country rank, problems solved, " +
|
|
47
|
+
"contests participated, latest contest and recent form. Prefer this over downloading " +
|
|
48
|
+
"and parsing the profile HTML page.",
|
|
49
|
+
inputSchema: { handle: handleSchema },
|
|
50
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
51
|
+
},
|
|
52
|
+
wrap(({ handle }) => getProfile(handle)),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
server.registerTool(
|
|
56
|
+
"get_rating_history",
|
|
57
|
+
{
|
|
58
|
+
title: "Get CodeChef rating history",
|
|
59
|
+
description:
|
|
60
|
+
"Return a user's contest rating history (newest first) with rating after each contest, " +
|
|
61
|
+
"rating change (delta) and rank. Useful for trends, streaks and 'how has X improved' questions.",
|
|
62
|
+
inputSchema: {
|
|
63
|
+
handle: handleSchema,
|
|
64
|
+
limit: z
|
|
65
|
+
.number()
|
|
66
|
+
.int()
|
|
67
|
+
.min(1)
|
|
68
|
+
.max(200)
|
|
69
|
+
.optional()
|
|
70
|
+
.describe("How many recent contests to return (default 20)."),
|
|
71
|
+
},
|
|
72
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
73
|
+
},
|
|
74
|
+
wrap(({ handle, limit }) => getRatingHistory(handle, { limit: limit ?? 20 })),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
server.registerTool(
|
|
78
|
+
"get_activity",
|
|
79
|
+
{
|
|
80
|
+
title: "Get CodeChef submission activity",
|
|
81
|
+
description:
|
|
82
|
+
"Summarize a user's submission heat map over a time window: total submissions, active days, " +
|
|
83
|
+
"average per active day, busiest day and monthly totals. Answers 'how active is X' without " +
|
|
84
|
+
"loading the heat map image or HTML.",
|
|
85
|
+
inputSchema: {
|
|
86
|
+
handle: handleSchema,
|
|
87
|
+
days: z
|
|
88
|
+
.number()
|
|
89
|
+
.int()
|
|
90
|
+
.min(7)
|
|
91
|
+
.max(3650)
|
|
92
|
+
.optional()
|
|
93
|
+
.describe("Look-back window in days (default 90). Daily breakdown included only when <= 60."),
|
|
94
|
+
},
|
|
95
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
96
|
+
},
|
|
97
|
+
wrap(({ handle, days }) => getActivity(handle, { days: days ?? 90 })),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
server.registerTool(
|
|
101
|
+
"get_contests",
|
|
102
|
+
{
|
|
103
|
+
title: "Get CodeChef contest results",
|
|
104
|
+
description:
|
|
105
|
+
"List a user's contests (newest first) with the problems solved in each, plus rank and rating change " +
|
|
106
|
+
"where available. Use this for 'what has X solved recently' or per-contest performance.",
|
|
107
|
+
inputSchema: {
|
|
108
|
+
handle: handleSchema,
|
|
109
|
+
limit: z
|
|
110
|
+
.number()
|
|
111
|
+
.int()
|
|
112
|
+
.min(1)
|
|
113
|
+
.max(100)
|
|
114
|
+
.optional()
|
|
115
|
+
.describe("How many recent contests to return (default 10)."),
|
|
116
|
+
},
|
|
117
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
118
|
+
},
|
|
119
|
+
wrap(({ handle, limit }) => getContests(handle, { limit: limit ?? 10 })),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
server.registerTool(
|
|
123
|
+
"get_solved_problems",
|
|
124
|
+
{
|
|
125
|
+
title: "Get CodeChef solved problems",
|
|
126
|
+
description:
|
|
127
|
+
"Return the unique problem names a user has solved (as listed on their profile), with the contest " +
|
|
128
|
+
"each came from, plus their reported total solved count. Capped by 'limit' to protect context size.",
|
|
129
|
+
inputSchema: {
|
|
130
|
+
handle: handleSchema,
|
|
131
|
+
limit: z
|
|
132
|
+
.number()
|
|
133
|
+
.int()
|
|
134
|
+
.min(1)
|
|
135
|
+
.max(1000)
|
|
136
|
+
.optional()
|
|
137
|
+
.describe("Maximum number of problem names to return (default 200)."),
|
|
138
|
+
},
|
|
139
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
140
|
+
},
|
|
141
|
+
wrap(({ handle, limit }) => getSolvedProblems(handle, { limit: limit ?? 200 })),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
server.registerTool(
|
|
145
|
+
"compare_users",
|
|
146
|
+
{
|
|
147
|
+
title: "Compare CodeChef users",
|
|
148
|
+
description:
|
|
149
|
+
"Fetch compact side-by-side profiles for 2-5 CodeChef handles: rating, stars, ranks, problems solved " +
|
|
150
|
+
"and contest count. Handy for 'who is better' or team-selection questions.",
|
|
151
|
+
inputSchema: {
|
|
152
|
+
handles: z
|
|
153
|
+
.array(z.string().min(1).max(64))
|
|
154
|
+
.min(2)
|
|
155
|
+
.max(5)
|
|
156
|
+
.describe("Two to five CodeChef handles to compare."),
|
|
157
|
+
},
|
|
158
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
159
|
+
},
|
|
160
|
+
wrap(({ handles }) => compareUsers(handles)),
|
|
161
|
+
);
|
|
162
|
+
}
|