codechef-mcp 0.0.0-stage → 0.1.1

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,243 @@
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
+ [![CI](https://github.com/sarthaksec/codechef-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/sarthaksec/codechef-mcp/actions/workflows/ci.yml)
5
+ [![license](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
6
+ [![node](https://img.shields.io/badge/node-%3E%3D20-brightgreen)](package.json)
7
+
8
+ An MCP server that turns CodeChef user profiles into **structured JSON** for LLMs — no more
9
+ dumping 180 KB of HTML into the context window and hoping the model finds the rating.
10
+
11
+ Written in plain Node.js (ESM). No build step.
12
+
13
+ ## Why
14
+
15
+ A CodeChef profile page is ~180 KB of markup. Most of the useful data is actually already
16
+ machine-readable inside it, just buried:
17
+
18
+ | Data | Where it lives |
19
+ | --- | --- |
20
+ | Contest rating history | `var all_rating = [...]` |
21
+ | Submission heat map | `userDailySubmissionsStats = [...]` |
22
+ | Name, stars, country, role, organisation | `.user-details` sidebar |
23
+ | Rating, division, global/country rank | `#rating-block-all` |
24
+ | Problems solved total | `Total Problems Solved: N` |
25
+ | Per-contest solved problems | `.problems-solved .content` |
26
+
27
+ This server extracts those into typed fields, normalizes dates/numbers, computes deltas, and
28
+ returns compact JSON (a few KB) instead of raw HTML.
29
+
30
+ ## Tools
31
+
32
+ OpenCode prefixes tool names with the server name, so a server named `codechef` exposes them as
33
+ `codechef_get_profile`, `codechef_get_rating_history`, and so on.
34
+
35
+ | Tool | Returns |
36
+ | --- | --- |
37
+ | `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 |
38
+ | `get_rating_history` | Newest-first contest list with `rating`, `ratingChange` delta and `rank` |
39
+ | `get_activity` | Submission heat map summary: total submissions, active days, average per active day, busiest day, monthly totals (daily detail when `days <= 60`) |
40
+ | `get_contests` | Recent contests with the problems solved in each, merged with rank and rating delta |
41
+ | `get_solved_problems` | Unique problem names with their contest, plus the profile's reported total |
42
+ | `compare_users` | Compact side-by-side profiles for 2–5 handles |
43
+
44
+ All tools take a `handle` (CodeChef username, e.g. `gennady.korotkevich`).
45
+
46
+ ## Install
47
+
48
+ ```sh
49
+ npx codechef-mcp
50
+ ```
51
+
52
+ Or globally:
53
+
54
+ ```sh
55
+ npm install -g codechef-mcp
56
+ ```
57
+
58
+ ## Wiring it into OpenCode (V2)
59
+
60
+ Config uses `mcp.servers`. Either run:
61
+
62
+ ```sh
63
+ opencode mcp add codechef --global -- npx -y codechef-mcp
64
+ ```
65
+
66
+ or add it by hand to `~/.config/opencode/opencode.jsonc`:
67
+
68
+ ```jsonc
69
+ {
70
+ "$schema": "https://opencode.ai/config.json",
71
+ "mcp": {
72
+ "servers": {
73
+ "codechef": {
74
+ "type": "local",
75
+ "command": ["npx", "-y", "codechef-mcp"]
76
+ }
77
+ }
78
+ }
79
+ }
80
+ ```
81
+
82
+ Then check it with `opencode mcp list`. Under Code Mode the tools appear as
83
+ `tools.codechef.get_profile(...)`.
84
+
85
+ ## Claude Desktop
86
+
87
+ `claude_desktop_config.json`:
88
+
89
+ ```json
90
+ {
91
+ "mcpServers": {
92
+ "codechef": {
93
+ "command": "npx",
94
+ "args": ["-y", "codechef-mcp"]
95
+ }
96
+ }
97
+ }
98
+ ```
99
+
100
+ On Windows, if the client cannot find `npx`, use the shell form:
101
+
102
+ ```json
103
+ {
104
+ "mcpServers": {
105
+ "codechef": {
106
+ "command": "cmd",
107
+ "args": ["/c", "npx", "-y", "codechef-mcp"]
108
+ }
109
+ }
110
+ }
111
+ ```
112
+
113
+ ## Run from source
114
+
115
+ ```sh
116
+ git clone https://github.com/sarthaksec/codechef-mcp.git
117
+ cd codechef-mcp
118
+ npm install
119
+ npm start # speaks MCP over stdio
120
+ ```
121
+
122
+ Point your MCP client at `src/server.js` inside the clone, or call the scraper directly:
123
+
124
+ ```sh
125
+ node test/smoke.js aryanc403
126
+ ```
127
+
128
+ ## Development
129
+
130
+ ```sh
131
+ npm install
132
+ npm run test:unit # offline parser + security tests
133
+ npm run test:live # live CodeChef scraper checks
134
+ npm run test:mcp # end-to-end MCP client handshake
135
+ npm test # all three
136
+ ```
137
+
138
+ `test:unit` is fully offline and runs in CI on Node 20, 22 and 24. The live suites
139
+ depend on CodeChef being reachable and are not run in CI.
140
+
141
+ ## Releasing
142
+
143
+ Releases are published from GitHub Actions using **trusted publishing (OIDC)** — there is
144
+ no `NPM_TOKEN` secret and no long-lived npm credential.
145
+
146
+ ```sh
147
+ npm version patch # or minor / major
148
+ git push --follow-tags
149
+ ```
150
+
151
+ Pushing the tag (for example `v0.1.1`) triggers `.github/workflows/publish.yml`, which:
152
+
153
+ 1. checks that the tag matches `package.json`'s version,
154
+ 2. installs with `npm ci` and runs the offline tests,
155
+ 3. runs `npm publish` authenticated by a short-lived OIDC token,
156
+ 4. publishes **provenance attestations automatically** (public package, public repo).
157
+
158
+ ### One-time trusted publisher setup
159
+
160
+ On [npmjs.com](https://www.npmjs.com/package/codechef-mcp) → package **Settings** →
161
+ **Trusted publishing** → **Add trusted publisher** → GitHub Actions:
162
+
163
+ | Field | Value |
164
+ | --- | --- |
165
+ | Organization or user | `sarthaksec` |
166
+ | Repository | `codechef-mcp` |
167
+ | Workflow filename | `publish.yml` |
168
+ | Environment name | *(leave blank)* |
169
+ | Allowed actions | ✅ **Allow npm publish** (and optionally **Allow npm dist-tag**) |
170
+
171
+ Newer configurations default to *stage-only* publishing. If **Allow npm publish** is not
172
+ selected, the workflow's `npm publish` step is rejected; either enable it or change the
173
+ workflow to `npm stage publish` and approve each release manually with 2FA.
174
+
175
+ The first publish must succeed within **2 days** of creating the configuration, otherwise it
176
+ expires and has to be recreated.
177
+
178
+ ### Verifying provenance
179
+
180
+ ```sh
181
+ npm audit signatures
182
+ ```
183
+
184
+ Or open the package on npmjs.com — a **Provenance** badge links the published tarball to the
185
+ exact commit and workflow run that built it.
186
+
187
+ ## Behavior
188
+
189
+ - **Cache:** parsed pages are cached for 10 minutes per handle (max 200 entries).
190
+ - **Throttle:** at least 800 ms between outbound requests.
191
+ - **Retries:** up to 3 attempts with backoff on `403`, `429`, `5xx` and Cloudflare challenges.
192
+ - **Errors:** typed codes — `INVALID_HANDLE`, `USER_NOT_FOUND`, `BLOCKED`, `UNPARSEABLE`,
193
+ `FETCH_FAILED` — returned with `isError: true` so the model can react instead of retrying blindly.
194
+ - **User-Agent:** identifies the client; requests are one profile page at a time.
195
+
196
+ ## Security
197
+
198
+ - **Outbound HTTPS only, to `www.codechef.com`.** Responses redirected to any other host are rejected.
199
+ - **TLS verification** is on (Node's default `fetch`; no `rejectUnauthorized: false` anywhere).
200
+ - **Bounded resources:** 5 MB response cap (streamed, aborts mid-body), 25 s timeout, 3 retries,
201
+ 200-entry cache.
202
+ - **No `eval`, no `new Function`, no child processes, no filesystem or environment access.**
203
+ - **No credentials** are read, stored, or sent — requests are anonymous.
204
+ - **Handle whitelisting:** handles must match `^[A-Za-z0-9._-]{1,64}$` and are URL-encoded, so a
205
+ handle cannot alter the request path.
206
+ - **Null-prototype maps** for page-derived keys, so a crafted label cannot touch `Object.prototype`.
207
+ - Publishing is limited by the `files` field: only `src/` + docs ship to npm.
208
+ - **Releases use OIDC trusted publishing**, not a long-lived npm token, and ship with provenance
209
+ attestations from GitHub Actions.
210
+
211
+ ## Caveats
212
+
213
+ - **Unofficial.** CodeChef's HTML is not a contract. If they change markup, `test/unit.js` and
214
+ `test/smoke.js` will tell you which field broke.
215
+ - **Cloudflare.** CodeChef fronts the site with Cloudflare. A burst of requests can get a
216
+ "Just a moment..." challenge; the server detects it and reports `BLOCKED` rather than
217
+ returning garbage. Wait a minute and retry.
218
+ - **`robots.txt`.** `/users/*` is not disallowed, which is what this server reads. The
219
+ `/rankings/*` leaderboard **is** disallowed, so global/country rank are taken from the profile
220
+ page instead of scraping rankings.
221
+ - **Counts can differ.** `get_solved_problems.uniqueProblemsSolved` (from the per-contest list) can
222
+ be lower than `reportedTotalSolved`, because the profile only lists problems for the contests it
223
+ renders.
224
+ - No login-only data (submissions, code, unsolved problems) — that needs a session and is out of
225
+ scope.
226
+
227
+ ## Layout
228
+
229
+ ```
230
+ src/server.js MCP entry point (stdio)
231
+ src/tools.js tool registration + schemas
232
+ src/api.js derived data (deltas, summaries)
233
+ src/parse.js HTML/embedded-JSON parsing
234
+ src/http.js fetch, cache, throttle, retries
235
+ test/unit.js offline parser tests
236
+ test/smoke.js live scraper test
237
+ test/mcp.js end-to-end MCP client handshake
238
+ ```
239
+
240
+ ## License
241
+
242
+ MIT © Sarthak Agnihotri ([sarthaksec](https://github.com/sarthaksec)).
243
+ 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.1",
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
+ }