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 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
- # Temporary Holding Version
1
+ # codechef-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm](https://img.shields.io/npm/v/codechef-mcp)](https://www.npmjs.com/package/codechef-mcp)
4
+ [![license](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
5
+ [![node](https://img.shields.io/badge/node-%3E%3D20-brightgreen)](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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
@@ -0,0 +1,9 @@
1
+ export class CodeChefError extends Error {
2
+ constructor(message, { code = "ERROR", status, retryable = false } = {}) {
3
+ super(message);
4
+ this.name = "CodeChefError";
5
+ this.code = code;
6
+ this.status = status;
7
+ this.retryable = retryable;
8
+ }
9
+ }
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
+ }