From 9060b2f78212f90a1f46880c4b953d30a61c11d3 Mon Sep 17 00:00:00 2001 From: Owl <32782746+SPerekrestova@users.noreply.github.com> Date: Wed, 6 May 2026 23:56:46 +0000 Subject: [PATCH 1/9] Phase 1: add concrete types in src/types/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - problem.ts: Problem, SimplifiedProblem, ProblemSummary, ProblemSearchResult, DailyChallenge, CodeSnippet, TopicTag, SimilarQuestion - user.ts: UserStatus, UserProfile, UserContestInfo, UserAllSubmissions, UserRecentSubmissions, UserRecentACSubmissions, UserSubmissionDetail, UserProgressQuestionList, SubmissionRow - solution.ts: SolutionArticleSummary, SolutionArticleList, SolutionArticleDetail - errors.ts: ErrorCode discriminated union, LeetCodeError class, isLeetCodeError type guard - index.ts: re-exports Types describe the shapes returned by LeetcodeServiceInterface methods — the projected envelopes the service emits, not the raw upstream GraphQL payloads. Existing src/types/credentials.ts and src/types/submission.ts unchanged (their shapes already match the interface). No behavior change; no consumers wired up yet. --- src/types/errors.ts | 54 ++++++++++++++++++++ src/types/index.ts | 12 +++++ src/types/problem.ts | 94 +++++++++++++++++++++++++++++++++++ src/types/solution.ts | 40 +++++++++++++++ src/types/user.ts | 111 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 311 insertions(+) create mode 100644 src/types/errors.ts create mode 100644 src/types/index.ts create mode 100644 src/types/problem.ts create mode 100644 src/types/solution.ts create mode 100644 src/types/user.ts diff --git a/src/types/errors.ts b/src/types/errors.ts new file mode 100644 index 0000000..4b40f77 --- /dev/null +++ b/src/types/errors.ts @@ -0,0 +1,54 @@ +/** + * Structured error codes for the LeetCode MCP server. + * + * Tools and the service layer should throw `LeetCodeError` with one of these + * codes instead of stringly-typed `Error`s, so the MCP layer can map them to + * predictable, machine-readable error envelopes. + */ +export const ErrorCode = { + /** Caller is not authenticated — credentials missing or invalid. */ + AUTH_REQUIRED: "AUTH_REQUIRED", + /** Stored credentials were rejected by LeetCode (expired / revoked). */ + AUTH_INVALID: "AUTH_INVALID", + /** Requested LeetCode problem slug doesn't exist. */ + PROBLEM_NOT_FOUND: "PROBLEM_NOT_FOUND", + /** Requested solution article doesn't exist. */ + SOLUTION_NOT_FOUND: "SOLUTION_NOT_FOUND", + /** Submission language isn't supported. */ + LANGUAGE_UNSUPPORTED: "LANGUAGE_UNSUPPORTED", + /** LeetCode rejected the request as rate-limited. */ + RATE_LIMITED: "RATE_LIMITED", + /** Submission polling timed out before LeetCode produced a verdict. */ + SUBMISSION_TIMEOUT: "SUBMISSION_TIMEOUT", + /** Network failure talking to LeetCode (DNS, connection refused, etc). */ + NETWORK_ERROR: "NETWORK_ERROR", + /** LeetCode returned a payload that didn't match the expected schema. */ + UPSTREAM_PAYLOAD_INVALID: "UPSTREAM_PAYLOAD_INVALID", + /** Catch-all for unexpected upstream errors. */ + UPSTREAM_ERROR: "UPSTREAM_ERROR" +} as const; + +export type ErrorCodeValue = (typeof ErrorCode)[keyof typeof ErrorCode]; + +/** + * Error thrown by the service layer with a structured, machine-readable code. + * + * Catchers can dispatch on `error.code` to render appropriate user-facing + * messages without parsing free-form `error.message` strings. + */ +export class LeetCodeError extends Error { + public readonly code: ErrorCodeValue; + public readonly cause?: unknown; + + constructor(code: ErrorCodeValue, message: string, cause?: unknown) { + super(message); + this.name = "LeetCodeError"; + this.code = code; + this.cause = cause; + } +} + +/** Type-narrowing helper for `instanceof LeetCodeError` checks. */ +export function isLeetCodeError(value: unknown): value is LeetCodeError { + return value instanceof LeetCodeError; +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..47627b1 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,12 @@ +/** + * Re-export hub for the type contracts used across the codebase. + * + * Prefer `import { Problem } from "./types/index.js"` over digging into the + * individual files — keeps imports stable as we reorganize. + */ +export * from "./credentials.js"; +export * from "./errors.js"; +export * from "./problem.js"; +export * from "./solution.js"; +export * from "./submission.js"; +export * from "./user.js"; diff --git a/src/types/problem.ts b/src/types/problem.ts new file mode 100644 index 0000000..690a767 --- /dev/null +++ b/src/types/problem.ts @@ -0,0 +1,94 @@ +/** + * Problem-related type contracts. + * + * These describe the shapes returned by the LeetcodeServiceInterface methods + * (`fetchProblem`, `fetchProblemSimplified`, `searchProblems`, etc.) — not the + * raw GraphQL payloads from `leetcode-query`. The service layer is responsible + * for projecting the upstream data into these shapes. + */ + +/** A `langSlug -> starter code` snippet attached to a problem. */ +export interface CodeSnippet { + lang: string; + langSlug: string; + code: string; +} + +/** A topic tag on a LeetCode problem (e.g. `array`, `hash-table`). */ +export interface TopicTag { + name?: string; + slug: string; +} + +/** A neighbour problem reference returned by `similarQuestions`. */ +export interface SimilarQuestion { + titleSlug: string; + difficulty: string; +} + +/** + * Full problem payload as returned by the upstream leetcode-query library. + * + * Many fields are optional because LeetCode populates different subsets + * depending on whether the caller is authenticated and whether the problem is + * paid-only. + */ +export interface Problem { + questionId: string; + questionFrontendId?: string; + title: string; + titleSlug: string; + difficulty: string; + content?: string | null; + isPaidOnly?: boolean; + topicTags?: TopicTag[]; + codeSnippets?: CodeSnippet[]; + hints?: string[]; + sampleTestCase?: string; + exampleTestcases?: string; + /** JSON-encoded array of similar-question metadata. */ + similarQuestions?: string; + stats?: string; + metaData?: string; + [key: string]: unknown; +} + +/** + * Trimmed-down problem payload returned by `fetchProblemSimplified` — + * the fields most useful to the AI agent without the upstream noise. + */ +export interface SimplifiedProblem { + titleSlug: string; + questionId: string; + title: string; + content?: string | null; + difficulty: string; + topicTags: string[]; + codeSnippets: CodeSnippet[]; + exampleTestcases?: string; + hints?: string[]; + similarQuestions: SimilarQuestion[]; +} + +/** A row in the search-problems result list. */ +export interface ProblemSummary { + title: string; + titleSlug: string; + difficulty: string; + acRate: number; + topicTags: string[]; +} + +/** Result envelope for `searchProblems`. */ +export interface ProblemSearchResult { + total: number; + questions: ProblemSummary[]; +} + +/** The daily-challenge envelope returned by `fetchDailyChallenge`. */ +export interface DailyChallenge { + date?: string; + link?: string; + question?: Problem; + [key: string]: unknown; +} diff --git a/src/types/solution.ts b/src/types/solution.ts new file mode 100644 index 0000000..5a3f91e --- /dev/null +++ b/src/types/solution.ts @@ -0,0 +1,40 @@ +/** + * Solution-article type contracts. + * + * Solutions are community-written walkthroughs ("Solution articles") that live + * under `https://leetcode.com/problems//solutions/`. The service layer + * fetches them via GraphQL and projects the results into these shapes. + */ + +/** A single solution article in a list. */ +export interface SolutionArticleSummary { + topicId?: number | string; + slug?: string; + title?: string; + summary?: string; + articleUrl?: string; + canSee?: boolean; + author?: { + username?: string; + userSlug?: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +/** Result envelope for `fetchQuestionSolutionArticles`. */ +export interface SolutionArticleList { + totalNum: number; + hasNextPage: boolean; + articles: SolutionArticleSummary[]; +} + +/** Detailed solution article returned by `fetchSolutionArticleDetail`. */ +export interface SolutionArticleDetail { + topicId?: number | string; + title?: string; + slug?: string; + summary?: string; + content?: string; + [key: string]: unknown; +} diff --git a/src/types/user.ts b/src/types/user.ts new file mode 100644 index 0000000..23ddadb --- /dev/null +++ b/src/types/user.ts @@ -0,0 +1,111 @@ +/** + * User / submission-history / contest type contracts. + */ + +/** Result of `fetchUserStatus()` (called for the authenticated user). */ +export interface UserStatus { + isSignedIn: boolean; + username: string; + avatar: string; + isAdmin: boolean; +} + +/** Result of `fetchUserProfile(username)`. */ +export interface UserProfile { + username: string; + realName?: string | null; + userAvatar?: string | null; + countryName?: string | null; + githubUrl?: string | null; + company?: string | null; + school?: string | null; + ranking?: number | null; + /** + * Per-difficulty solved counts (LeetCode returns an array with rows for + * `All`, `Easy`, `Medium`, `Hard`). + */ + totalSubmissionNum?: Array<{ + difficulty: string; + count: number; + submissions: number; + }>; + [key: string]: unknown; +} + +/** A single contest a user attended (or skipped). */ +export interface ContestRankingHistoryEntry { + attended: boolean; + rating?: number; + ranking?: number; + trendDirection?: string; + problemsSolved?: number; + totalProblems?: number; + finishTimeInSeconds?: number; + contest?: { + title?: string; + startTime?: number; + }; + [key: string]: unknown; +} + +/** Result of `fetchUserContestRanking(username, attended)`. */ +export interface UserContestInfo { + userContestRanking?: { + attendedContestsCount?: number; + rating?: number; + globalRanking?: number; + totalParticipants?: number; + topPercentage?: number; + [key: string]: unknown; + } | null; + userContestRankingHistory: ContestRankingHistoryEntry[]; + [key: string]: unknown; +} + +/** A single submission row returned by `fetchUserAllSubmissions`. */ +export interface SubmissionRow { + id?: string | number; + title?: string; + titleSlug?: string; + timestamp?: string | number; + statusDisplay?: string; + lang?: string; + runtime?: string; + memory?: string; + [key: string]: unknown; +} + +/** Result envelope for `fetchUserAllSubmissions`. */ +export interface UserAllSubmissions { + submissions: SubmissionRow[] | { [key: string]: unknown }; + [key: string]: unknown; +} + +/** Result envelope for `fetchUserRecentSubmissions`. */ +export interface UserRecentSubmissions { + [key: string]: unknown; + recentSubmissionList?: SubmissionRow[]; +} + +/** Result of `fetchUserRecentACSubmissions` — raw GraphQL passthrough. */ +export interface UserRecentACSubmissions { + [key: string]: unknown; +} + +/** Result of `fetchUserSubmissionDetail`. */ +export interface UserSubmissionDetail { + id?: number; + code?: string; + lang?: string; + runtime?: string; + memory?: string; + statusDisplay?: string; + [key: string]: unknown; +} + +/** Result of `fetchUserProgressQuestionList`. */ +export interface UserProgressQuestionList { + questions?: Array<{ [key: string]: unknown }>; + totalNum?: number; + [key: string]: unknown; +} From 15268c9c29b23dcf6221ffa352bd15f32f62dfef Mon Sep 17 00:00:00 2001 From: Owl <32782746+SPerekrestova@users.noreply.github.com> Date: Wed, 6 May 2026 23:57:07 +0000 Subject: [PATCH 2/9] Phase 1: add zod runtime schemas for LeetCode API responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/leetcode/schemas.ts exports zod validators for the four LeetCode response shapes the server actually parses today: - SubmitResponseSchema: response from POST /problems//submit/ - CheckResponseSchema: response from GET /submissions/detail//check/ - QuestionIdResponseSchema: response from the questionId GraphQL query - ValidateCredentialsResponseSchema: response from the userStatus GraphQL query Each schema uses .passthrough() so unexpected fields don't fail the parse — only missing required fields do. CheckResponse is intentionally loose on status_msg, code_answer, and expected_answer because LeetCode omits / changes their shape between PENDING and SUCCESS states. z.infer<> types are exported alongside each schema for use in the service impl. No behavior change; schemas not yet consumed. --- src/leetcode/schemas.ts | 81 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 src/leetcode/schemas.ts diff --git a/src/leetcode/schemas.ts b/src/leetcode/schemas.ts new file mode 100644 index 0000000..3a62f9c --- /dev/null +++ b/src/leetcode/schemas.ts @@ -0,0 +1,81 @@ +/** + * Runtime validators for payloads coming back from LeetCode. + * + * These are the *minimum* schemas needed to tell whether the upstream still + * speaks the contract our types describe. They use `passthrough()` so unknown + * fields are kept (we re-emit some payloads verbatim to MCP clients), and they + * mark fields optional when LeetCode has been observed to omit them. + * + * Use `parse` (throws `ZodError`) at the service boundary when we want to fail + * loudly, and `safeParse` when we want to log-and-fall-back. Translate any + * `ZodError` into `new LeetCodeError(ErrorCode.UPSTREAM_PAYLOAD_INVALID, ...)` + * so the MCP layer can render a structured error. + */ +import { z } from "zod"; + +export const SubmitResponseSchema = z + .object({ + submission_id: z.number() + }) + .passthrough(); + +export const CheckResponseSchema = z + .object({ + state: z.string(), + // LeetCode omits status_msg on PENDING/STARTED responses; only + // populated once `state === "SUCCESS"`. + status_msg: z.string().optional(), + status_code: z.number().optional(), + runtime: z.string().optional(), + memory: z.string().optional(), + runtime_percentile: z.number().nullable().optional(), + memory_percentile: z.number().nullable().optional(), + // LeetCode has been observed to return both an array of strings (one + // per test case) and a single JSON-encoded string here; accept both. + code_answer: z.union([z.array(z.string()), z.string()]).optional(), + expected_answer: z.union([z.array(z.string()), z.string()]).optional(), + input: z.string().optional(), + std_output: z.string().optional(), + compile_error: z.string().optional(), + full_compile_error: z.string().optional(), + runtime_error: z.string().optional(), + full_runtime_error: z.string().optional(), + total_correct: z.number().nullable().optional(), + total_testcases: z.number().nullable().optional() + }) + .passthrough(); + +export const QuestionIdResponseSchema = z + .object({ + data: z + .object({ + question: z + .object({ + questionId: z.string(), + questionFrontendId: z.string().optional() + }) + .nullable() + .optional() + }) + .passthrough() + }) + .passthrough(); + +export const ValidateCredentialsResponseSchema = z + .object({ + data: z + .object({ + userStatus: z + .object({ + username: z.string().nullable().optional(), + isSignedIn: z.boolean() + }) + .passthrough() + .optional() + }) + .passthrough() + }) + .passthrough(); + +export type SubmitResponse = z.infer; +export type CheckResponse = z.infer; From 899ee5d3de81ac2922e0d48c9fa079423056dde5 Mon Sep 17 00:00:00 2001 From: Owl <32782746+SPerekrestova@users.noreply.github.com> Date: Wed, 6 May 2026 23:57:29 +0000 Subject: [PATCH 3/9] Phase 1: tighten LeetcodeServiceInterface and propagate concrete types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LeetcodeServiceInterface: - Replace every Promise with concrete typed returns drawn from src/types/{problem,user,solution,submission} - Add SolutionArticlesQueryOptions for fetchQuestionSolutionArticles - Method signatures unchanged in name/arity; only return types tightened LeetCodeGlobalService: - Implement all methods against the new return types - Replace stringly-typed throws with LeetCodeError(ErrorCode.AUTH_REQUIRED, …) on every authenticated path; LeetCodeError(ErrorCode.PROBLEM_NOT_FOUND, …) on missing problems - Validate the four LeetCode response shapes via zod at the wire boundary: submitSolution() now safeParses both SubmitResponse and CheckResponse and throws LeetCodeError(ErrorCode.UPSTREAM_PAYLOAD_INVALID, …) on schema failure with the zod issue list attached as .cause - getQuestionId() validates QuestionIdResponseSchema and surfaces a typed error instead of returning a stringified shape - validateCredentials() validates ValidateCredentialsResponseSchema; on schema failure logs a warning and returns null (preserves the previous null-on-failure contract for the auth path) - Project nullable upstream fields (runtime_percentile, total_correct, etc.) into the SubmissionResult shape via ?? undefined so consumers see number | undefined, not number | null | undefined This commit removes 15+ Promise from the interface and ~20 'as any' casts from the implementation. fetchSolutionArticleDetail() now returns SolutionArticleDetail | null (LeetCode actually returns null for unknown topicIds; previous 'as ... | undefined' was a lie). --- src/leetcode/leetcode-global-service.ts | 363 +++++++++++++-------- src/leetcode/leetcode-service-interface.ts | 76 +++-- 2 files changed, 287 insertions(+), 152 deletions(-) diff --git a/src/leetcode/leetcode-global-service.ts b/src/leetcode/leetcode-global-service.ts index c20d2e8..2ac683c 100644 --- a/src/leetcode/leetcode-global-service.ts +++ b/src/leetcode/leetcode-global-service.ts @@ -1,15 +1,44 @@ import axios, { AxiosError } from "axios"; import { Credential, LeetCode } from "leetcode-query"; +import { ErrorCode, LeetCodeError } from "../types/errors.js"; import { - LeetCodeCheckResponse, - LeetCodeSubmitResponse, - SubmissionResult -} from "../types/submission.js"; + DailyChallenge, + Problem, + ProblemSearchResult, + SimilarQuestion, + SimplifiedProblem, + TopicTag +} from "../types/problem.js"; +import { + SolutionArticleDetail, + SolutionArticleList, + SolutionArticleSummary +} from "../types/solution.js"; +import { SubmissionResult } from "../types/submission.js"; +import { + UserAllSubmissions, + UserContestInfo, + UserProfile, + UserProgressQuestionList, + UserRecentACSubmissions, + UserRecentSubmissions, + UserStatus, + UserSubmissionDetail +} from "../types/user.js"; import logger from "../utils/logger.js"; import { SEARCH_PROBLEMS_QUERY } from "./graphql/search-problems.js"; import { SOLUTION_ARTICLE_DETAIL_QUERY } from "./graphql/solution-article-detail.js"; import { SOLUTION_ARTICLES_QUERY } from "./graphql/solution-articles.js"; -import { LeetcodeServiceInterface } from "./leetcode-service-interface.js"; +import { + LeetcodeServiceInterface, + SolutionArticlesQueryOptions +} from "./leetcode-service-interface.js"; +import { + CheckResponseSchema, + QuestionIdResponseSchema, + SubmitResponseSchema, + ValidateCredentialsResponseSchema +} from "./schemas.js"; const LANGUAGE_MAP: Record = { java: "java", @@ -50,27 +79,32 @@ export class LeetCodeGlobalService implements LeetcodeServiceInterface { this.credential = credential; } - async fetchUserSubmissionDetail(id: number): Promise { + async fetchUserSubmissionDetail(id: number): Promise { if (!this.isAuthenticated()) { - throw new Error( + throw new LeetCodeError( + ErrorCode.AUTH_REQUIRED, "Authentication required to fetch user submission detail" ); } - return await this.leetCodeApi.submission(id); + return (await this.leetCodeApi.submission( + id + )) as unknown as UserSubmissionDetail; } - async fetchUserStatus(): Promise { + async fetchUserStatus(): Promise { if (!this.isAuthenticated()) { - throw new Error("Authentication required to fetch user status"); + throw new LeetCodeError( + ErrorCode.AUTH_REQUIRED, + "Authentication required to fetch user status" + ); } - return await this.leetCodeApi.whoami().then((res) => { - return { - isSignedIn: res?.isSignedIn ?? false, - username: res?.username ?? "", - avatar: res?.avatar ?? "", - isAdmin: res?.isAdmin ?? false - }; - }); + const res = await this.leetCodeApi.whoami(); + return { + isSignedIn: res?.isSignedIn ?? false, + username: res?.username ?? "", + avatar: res?.avatar ?? "", + isAdmin: res?.isAdmin ?? false + }; } async fetchUserAllSubmissions(options: { @@ -80,9 +114,10 @@ export class LeetCodeGlobalService implements LeetcodeServiceInterface { lastKey?: string; lang?: string; status?: string; - }): Promise { + }): Promise { if (!this.isAuthenticated()) { - throw new Error( + throw new LeetCodeError( + ErrorCode.AUTH_REQUIRED, "Authentication required to fetch user submissions" ); } @@ -91,21 +126,26 @@ export class LeetCodeGlobalService implements LeetcodeServiceInterface { limit: options.limit ?? 20, slug: options.questionSlug }); - return { submissions }; + return { + submissions + } as unknown as UserAllSubmissions; } async fetchUserRecentSubmissions( username: string, limit?: number - ): Promise { - return await this.leetCodeApi.recent_submissions(username, limit); + ): Promise { + return (await this.leetCodeApi.recent_submissions( + username, + limit + )) as unknown as UserRecentSubmissions; } async fetchUserRecentACSubmissions( username: string, limit?: number - ): Promise { - return await this.leetCodeApi.graphql({ + ): Promise { + return (await this.leetCodeApi.graphql({ query: ` query ($username: String!, $limit: Int) { recentAcSubmissionList(username: $username, limit: $limit) { @@ -124,10 +164,10 @@ export class LeetCodeGlobalService implements LeetcodeServiceInterface { username, limit } - }); + })) as unknown as UserRecentACSubmissions; } - async fetchUserProfile(username: string): Promise { + async fetchUserProfile(username: string): Promise { const profile = await this.leetCodeApi.user(username); if (profile && profile.matchedUser) { const { matchedUser } = profile; @@ -137,29 +177,29 @@ export class LeetCodeGlobalService implements LeetcodeServiceInterface { realName: matchedUser.profile.realName, userAvatar: matchedUser.profile.userAvatar, countryName: matchedUser.profile.countryName, - githubUrl: matchedUser.githubUrl, + githubUrl: matchedUser.githubUrl ?? undefined, company: matchedUser.profile.company, school: matchedUser.profile.school, ranking: matchedUser.profile.ranking, totalSubmissionNum: matchedUser.submitStats?.totalSubmissionNum }; } - return profile; + return profile as unknown as UserProfile; } async fetchUserContestRanking( username: string, attended: boolean = true - ): Promise { - const contestInfo = await this.leetCodeApi.user_contest_info(username); + ): Promise { + const contestInfo = (await this.leetCodeApi.user_contest_info( + username + )) as unknown as UserContestInfo; if (contestInfo.userContestRankingHistory) { if (attended) { contestInfo.userContestRankingHistory = - contestInfo.userContestRankingHistory.filter( - (contest: any) => { - return contest && contest.attended; - } - ); + contestInfo.userContestRankingHistory.filter((contest) => { + return contest && contest.attended; + }); } } else { contestInfo.userContestRankingHistory = []; @@ -167,32 +207,41 @@ export class LeetCodeGlobalService implements LeetcodeServiceInterface { return contestInfo; } - async fetchDailyChallenge(): Promise { - return await this.leetCodeApi.daily(); + async fetchDailyChallenge(): Promise { + return (await this.leetCodeApi.daily()) as unknown as DailyChallenge; } - async fetchProblem(titleSlug: string): Promise { - return await this.leetCodeApi.problem(titleSlug); + async fetchProblem(titleSlug: string): Promise { + return (await this.leetCodeApi.problem( + titleSlug + )) as unknown as Problem; } - async fetchProblemSimplified(titleSlug: string): Promise { + async fetchProblemSimplified( + titleSlug: string + ): Promise { const problem = await this.fetchProblem(titleSlug); if (!problem) { - throw new Error(`Problem ${titleSlug} not found`); + throw new LeetCodeError( + ErrorCode.PROBLEM_NOT_FOUND, + `Problem ${titleSlug} not found` + ); } - const filteredTopicTags = - problem.topicTags?.map((tag: any) => tag.slug) || []; + const filteredTopicTags: string[] = + problem.topicTags?.map((tag: TopicTag) => tag.slug) ?? []; - const filteredCodeSnippets = problem.codeSnippets || []; + const filteredCodeSnippets = problem.codeSnippets ?? []; - let parsedSimilarQuestions: any[] = []; + let parsedSimilarQuestions: SimilarQuestion[] = []; if (problem.similarQuestions) { try { - const allQuestions = JSON.parse(problem.similarQuestions); + const allQuestions: SimilarQuestion[] = JSON.parse( + problem.similarQuestions + ); parsedSimilarQuestions = allQuestions .slice(0, 3) - .map((q: any) => ({ + .map((q: SimilarQuestion) => ({ titleSlug: q.titleSlug, difficulty: q.difficulty })); @@ -222,8 +271,8 @@ export class LeetCodeGlobalService implements LeetcodeServiceInterface { limit: number = 10, offset: number = 0, searchKeywords?: string - ): Promise { - const filters: any = {}; + ): Promise { + const filters: Record = {}; if (difficulty) { filters.difficulty = difficulty.toUpperCase(); } @@ -253,13 +302,21 @@ export class LeetCodeGlobalService implements LeetcodeServiceInterface { } return { total: questionList.total, - questions: questionList.questions.map((question: any) => ({ - title: question.title, - titleSlug: question.titleSlug, - difficulty: question.difficulty, - acRate: question.acRate, - topicTags: question.topicTags.map((tag: any) => tag.slug) - })) + questions: questionList.questions.map( + (question: { + title: string; + titleSlug: string; + difficulty: string; + acRate: number; + topicTags: TopicTag[]; + }) => ({ + title: question.title, + titleSlug: question.titleSlug, + difficulty: question.difficulty, + acRate: question.acRate, + topicTags: question.topicTags.map((tag) => tag.slug) + }) + ) }; } @@ -268,21 +325,27 @@ export class LeetCodeGlobalService implements LeetcodeServiceInterface { limit?: number; questionStatus?: string; difficulty?: string[]; - }): Promise { + }): Promise { if (!this.isAuthenticated()) { - throw new Error( + throw new LeetCodeError( + ErrorCode.AUTH_REQUIRED, "Authentication required to fetch user progress question list" ); } + // Cast through unknown because leetcode-query types these as enums + // (LeetCodeQuestionStatus / LeetCodeDifficulty) but accepts the raw + // strings we forward from MCP tool inputs. const filters = { skip: options?.offset || 0, limit: options?.limit || 20, - questionStatus: options?.questionStatus as any, - difficulty: options?.difficulty as any[] + questionStatus: options?.questionStatus as unknown as undefined, + difficulty: options?.difficulty as unknown as undefined }; - return await this.leetCodeApi.user_progress_questions(filters); + return (await this.leetCodeApi.user_progress_questions( + filters + )) as unknown as UserProgressQuestionList; } /** @@ -294,9 +357,9 @@ export class LeetCodeGlobalService implements LeetcodeServiceInterface { */ async fetchQuestionSolutionArticles( questionSlug: string, - options?: any - ): Promise { - const variables: any = { + options?: SolutionArticlesQueryOptions + ): Promise { + const variables = { questionSlug, first: options?.limit || 5, skip: options?.skip || 0, @@ -305,42 +368,39 @@ export class LeetCodeGlobalService implements LeetcodeServiceInterface { tagSlugs: options?.tagSlugs ?? [] }; - return await this.leetCodeApi - .graphql({ - query: SOLUTION_ARTICLES_QUERY, - variables - }) - .then((res) => { - const ugcArticleSolutionArticles = - res.data?.ugcArticleSolutionArticles; - if (!ugcArticleSolutionArticles) { - return { - totalNum: 0, - hasNextPage: false, - articles: [] - }; - } + const res = await this.leetCodeApi.graphql({ + query: SOLUTION_ARTICLES_QUERY, + variables + }); + const ugcArticleSolutionArticles = res.data?.ugcArticleSolutionArticles; + if (!ugcArticleSolutionArticles) { + return { + totalNum: 0, + hasNextPage: false, + articles: [] + }; + } - return { - totalNum: ugcArticleSolutionArticles?.totalNum || 0, - hasNextPage: - ugcArticleSolutionArticles?.pageInfo?.hasNextPage || - false, - articles: - ugcArticleSolutionArticles?.edges - ?.map((edge: any) => { - if ( - edge?.node && - edge.node.topicId && - edge.node.slug - ) { - edge.node.articleUrl = `https://leetcode.com/problems/${questionSlug}/solutions/${edge.node.topicId}/${edge.node.slug}`; - } - return edge.node; - }) - .filter((node: any) => node && node.canSee) || [] - }; - }); + return { + totalNum: ugcArticleSolutionArticles?.totalNum || 0, + hasNextPage: + ugcArticleSolutionArticles?.pageInfo?.hasNextPage || false, + articles: + ugcArticleSolutionArticles?.edges + ?.map((edge: { node?: SolutionArticleSummary | null }) => { + const node = edge?.node; + if (node && node.topicId && node.slug) { + node.articleUrl = `https://leetcode.com/problems/${questionSlug}/solutions/${node.topicId}/${node.slug}`; + } + return node; + }) + .filter( + ( + node: SolutionArticleSummary | null | undefined + ): node is SolutionArticleSummary => + !!node && !!node.canSee + ) || [] + }; } /** @@ -349,17 +409,17 @@ export class LeetCodeGlobalService implements LeetcodeServiceInterface { * @param topicId - The topic ID of the solution * @returns Promise resolving to the solution detail data */ - async fetchSolutionArticleDetail(topicId: string): Promise { - return await this.leetCodeApi - .graphql({ - query: SOLUTION_ARTICLE_DETAIL_QUERY, - variables: { - topicId - } - }) - .then((response) => { - return response.data?.ugcArticleSolutionArticle; - }); + async fetchSolutionArticleDetail( + topicId: string + ): Promise { + const response = await this.leetCodeApi.graphql({ + query: SOLUTION_ARTICLE_DETAIL_QUERY, + variables: { + topicId + } + }); + return (response.data?.ugcArticleSolutionArticle ?? + null) as SolutionArticleDetail | null; } async submitSolution( @@ -394,7 +454,7 @@ export class LeetCodeGlobalService implements LeetcodeServiceInterface { // Submit solution const submitUrl = `${baseUrl}/problems/${problemSlug}/submit/`; - const submitResponse = await axios.post( + const submitResponse = await axios.post( submitUrl, { lang: leetcodeLang, @@ -411,7 +471,17 @@ export class LeetCodeGlobalService implements LeetcodeServiceInterface { } ); - const submissionId = submitResponse.data.submission_id; + const parsedSubmit = SubmitResponseSchema.safeParse( + submitResponse.data + ); + if (!parsedSubmit.success) { + throw new LeetCodeError( + ErrorCode.UPSTREAM_PAYLOAD_INVALID, + `Submit response did not match expected schema: ${parsedSubmit.error.message}`, + parsedSubmit.error + ); + } + const submissionId = parsedSubmit.data.submission_id; // Poll for results const checkUrl = `${baseUrl}/submissions/detail/${submissionId}/check/`; @@ -422,16 +492,23 @@ export class LeetCodeGlobalService implements LeetcodeServiceInterface { // Wait 1 second between polls await new Promise((resolve) => setTimeout(resolve, 1000)); - const checkResponse = await axios.get( - checkUrl, - { - headers: { - Cookie: `csrftoken=${this.credential.csrf}; LEETCODE_SESSION=${this.credential.session}` - } + const checkResponse = await axios.get(checkUrl, { + headers: { + Cookie: `csrftoken=${this.credential.csrf}; LEETCODE_SESSION=${this.credential.session}` } - ); + }); - const result = checkResponse.data; + const parsedCheck = CheckResponseSchema.safeParse( + checkResponse.data + ); + if (!parsedCheck.success) { + throw new LeetCodeError( + ErrorCode.UPSTREAM_PAYLOAD_INVALID, + `Check response did not match expected schema: ${parsedCheck.error.message}`, + parsedCheck.error + ); + } + const result = parsedCheck.data; if ( result.state !== "SUCCESS" && @@ -454,10 +531,12 @@ export class LeetCodeGlobalService implements LeetcodeServiceInterface { accepted: true, runtime: result.runtime, memory: result.memory, - runtimePercentile: result.runtime_percentile, - memoryPercentile: result.memory_percentile, - totalCorrect: result.total_correct, - totalTestcases: result.total_testcases, + runtimePercentile: + result.runtime_percentile ?? undefined, + memoryPercentile: + result.memory_percentile ?? undefined, + totalCorrect: result.total_correct ?? undefined, + totalTestcases: result.total_testcases ?? undefined, statusMessage: "Accepted" }; } else { @@ -482,11 +561,11 @@ export class LeetCodeGlobalService implements LeetcodeServiceInterface { return { accepted: false, - statusMessage: result.status_msg, + statusMessage: result.status_msg ?? "Unknown", failedTestCase, errorMessage, - totalCorrect: result.total_correct, - totalTestcases: result.total_testcases + totalCorrect: result.total_correct ?? undefined, + totalTestcases: result.total_testcases ?? undefined }; } } @@ -553,9 +632,18 @@ export class LeetCodeGlobalService implements LeetcodeServiceInterface { } }); - const question = response.data.data?.question; + const parsed = QuestionIdResponseSchema.safeParse(response.data); + if (!parsed.success) { + throw new LeetCodeError( + ErrorCode.UPSTREAM_PAYLOAD_INVALID, + `Question-id response did not match expected schema: ${parsed.error.message}`, + parsed.error + ); + } + const question = parsed.data.data?.question; if (!question) { - throw new Error( + throw new LeetCodeError( + ErrorCode.PROBLEM_NOT_FOUND, `Problem slug "${problemSlug}" not found or invalid.` ); } @@ -599,9 +687,18 @@ export class LeetCodeGlobalService implements LeetcodeServiceInterface { } ); - // Check if user is signed in and return username - const userStatus = response.data?.data?.userStatus; - if (userStatus?.isSignedIn === true && userStatus?.username) { + const parsed = ValidateCredentialsResponseSchema.safeParse( + response.data + ); + if (!parsed.success) { + logger.warn( + "validateCredentials: upstream payload did not match schema: %s", + parsed.error.message + ); + return null; + } + const userStatus = parsed.data.data?.userStatus; + if (userStatus?.isSignedIn === true && userStatus.username) { return userStatus.username; } return null; diff --git a/src/leetcode/leetcode-service-interface.ts b/src/leetcode/leetcode-service-interface.ts index afcedf2..692565d 100644 --- a/src/leetcode/leetcode-service-interface.ts +++ b/src/leetcode/leetcode-service-interface.ts @@ -1,4 +1,33 @@ +import { + DailyChallenge, + Problem, + ProblemSearchResult, + SimplifiedProblem +} from "../types/problem.js"; +import { + SolutionArticleDetail, + SolutionArticleList +} from "../types/solution.js"; import { SubmissionResult } from "../types/submission.js"; +import { + UserAllSubmissions, + UserContestInfo, + UserProfile, + UserProgressQuestionList, + UserRecentACSubmissions, + UserRecentSubmissions, + UserStatus, + UserSubmissionDetail +} from "../types/user.js"; + +/** Optional sort/pagination knobs for `fetchQuestionSolutionArticles`. */ +export interface SolutionArticlesQueryOptions { + limit?: number; + skip?: number; + orderBy?: string; + userInput?: string; + tagSlugs?: string[]; +} /** * Base interface for LeetCode API service implementations. @@ -11,16 +40,16 @@ export interface LeetcodeServiceInterface { * @param username - The LeetCode username to fetch profile data for * @returns Promise resolving to the user's profile data */ - fetchUserProfile(username: string): Promise; + fetchUserProfile(username: string): Promise; /** * Retrieves the authenticated user's status information. * Includes login status, subscription details, and user identification information. * * @returns Promise resolving to the user's status information - * @throws Error if not authenticated + * @throws LeetCodeError(AUTH_REQUIRED) if not authenticated */ - fetchUserStatus(): Promise; + fetchUserStatus(): Promise; /** * Retrieves the authenticated user's submission history with various filtering options. @@ -33,7 +62,7 @@ export interface LeetcodeServiceInterface { * @param options.lang - Optional filter for programming language * @param options.status - Optional filter for submission status * @returns Promise resolving to the filtered submission data - * @throws Error if not authenticated + * @throws LeetCodeError(AUTH_REQUIRED) if not authenticated */ fetchUserAllSubmissions(options: { offset: number; @@ -42,7 +71,7 @@ export interface LeetcodeServiceInterface { lastKey?: string; lang?: string | null; status?: string | null; - }): Promise; + }): Promise; /** * Retrieves the authenticated user's progress on problems with filtering options. @@ -53,14 +82,14 @@ export interface LeetcodeServiceInterface { * @param filters.questionStatus - Optional filter for problem status (e.g., "ATTEMPTED", "SOLVED") * @param filters.difficulty - Optional array of difficulty levels to filter by * @returns Promise resolving to the user's progress data - * @throws Error if not authenticated + * @throws LeetCodeError(AUTH_REQUIRED) if not authenticated */ fetchUserProgressQuestionList(filters: { offset: number; limit: number; questionStatus?: string; difficulty?: string[]; - }): Promise; + }): Promise; /** * Retrieves a user's recent submissions (both accepted and failed). @@ -69,7 +98,10 @@ export interface LeetcodeServiceInterface { * @param limit - Optional maximum number of submissions to return * @returns Promise resolving to the recent submissions data */ - fetchUserRecentSubmissions(username: string, limit?: number): Promise; + fetchUserRecentSubmissions( + username: string, + limit?: number + ): Promise; /** * Retrieves a user's recent accepted (AC) submissions only. @@ -81,7 +113,7 @@ export interface LeetcodeServiceInterface { fetchUserRecentACSubmissions( username: string, limit?: number - ): Promise; + ): Promise; /** * Retrieves detailed information about a specific submission. @@ -89,9 +121,9 @@ export interface LeetcodeServiceInterface { * * @param id - Numeric submission ID * @returns Promise resolving to the submission details - * @throws Error if not authenticated or submission not found + * @throws LeetCodeError(AUTH_REQUIRED) if not authenticated */ - fetchUserSubmissionDetail(id: number): Promise; + fetchUserSubmissionDetail(id: number): Promise; /** * Retrieves a user's contest ranking information and participation history. @@ -100,14 +132,17 @@ export interface LeetcodeServiceInterface { * @param attended - Whether to include only contests the user participated in * @returns Promise resolving to the contest ranking data */ - fetchUserContestRanking(username: string, attended: boolean): Promise; + fetchUserContestRanking( + username: string, + attended: boolean + ): Promise; /** * Retrieves today's LeetCode Daily Challenge problem. * * @returns Promise resolving to the daily challenge problem data */ - fetchDailyChallenge(): Promise; + fetchDailyChallenge(): Promise; /** * Retrieves simplified information about a specific problem. @@ -115,8 +150,9 @@ export interface LeetcodeServiceInterface { * * @param titleSlug - Problem identifier/slug as used in the LeetCode URL * @returns Promise resolving to the simplified problem details + * @throws LeetCodeError(PROBLEM_NOT_FOUND) if the slug doesn't exist */ - fetchProblemSimplified(titleSlug: string): Promise; + fetchProblemSimplified(titleSlug: string): Promise; /** * Retrieves detailed information about a specific problem. @@ -124,7 +160,7 @@ export interface LeetcodeServiceInterface { * @param titleSlug - Problem identifier/slug as used in the LeetCode URL * @returns Promise resolving to the problem details */ - fetchProblem(titleSlug: string): Promise; + fetchProblem(titleSlug: string): Promise; /** * Searches for problems matching specified criteria. @@ -144,7 +180,7 @@ export interface LeetcodeServiceInterface { limit?: number, offset?: number, searchKeywords?: string - ): Promise; + ): Promise; /** * Determines if the current service has valid authentication credentials. @@ -171,8 +207,8 @@ export interface LeetcodeServiceInterface { */ fetchQuestionSolutionArticles( questionSlug: string, - options?: any - ): Promise; + options?: SolutionArticlesQueryOptions + ): Promise; /** * Retrieves detailed information about a specific solution. @@ -180,7 +216,9 @@ export interface LeetcodeServiceInterface { * @param identifier - The identifier of the solution (topicId or slug) * @returns Promise resolving to the solution detail data */ - fetchSolutionArticleDetail(identifier: string): Promise; + fetchSolutionArticleDetail( + identifier: string + ): Promise; /** * Submits a solution to a problem and polls for the result. From 5e41b703ba8bdf6c81d20e2fbac02e1b8ebc531b Mon Sep 17 00:00:00 2001 From: Owl <32782746+SPerekrestova@users.noreply.github.com> Date: Wed, 6 May 2026 23:57:48 +0000 Subject: [PATCH 4/9] Phase 1: restore saved credentials at startup; push validated creds in-memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the silent-logout-on-restart bug from the assessment: the server loaded ~/.leetcode-mcp/credentials.json from disk, validated them, and told the user they were 'authenticated' — but never actually pushed the cookies into the in-memory Credential the LeetCode client reads from. Result: every authenticated tool call after a restart failed with 'Authentication required' until the user re-pasted their cookies. Three pieces: - src/auth/auth-flow.ts (new): restoreCredentials(service, storage) loads the persisted credentials, calls service.validateCredentials() to confirm they're still good, and on success calls service.updateCredentials() to push them into the running service. Returns a typed RestoreOutcome ('no_credentials' | 'invalid' | 'restored') for logging. Never throws. - src/index.ts: invoke restoreCredentials(leetcodeService) at startup before tools/prompts are registered. Best-effort: any failure leaves the server unauthenticated as before. - src/mcp/tools/auth-tools.ts: in check_auth_status, when validation succeeds, also call leetcodeService.updateCredentials() so the very next authenticated tool call works without forcing a server restart. --- src/auth/auth-flow.ts | 74 +++++++++++++++++++++++++++++++++++++ src/index.ts | 6 +++ src/mcp/tools/auth-tools.ts | 8 ++++ 3 files changed, 88 insertions(+) create mode 100644 src/auth/auth-flow.ts diff --git a/src/auth/auth-flow.ts b/src/auth/auth-flow.ts new file mode 100644 index 0000000..e925f4a --- /dev/null +++ b/src/auth/auth-flow.ts @@ -0,0 +1,74 @@ +/** + * Authentication helpers that bridge the on-disk credentials store and the + * in-memory `LeetcodeServiceInterface`. + * + * Closes the silent-logout-on-restart gap where saved credentials existed in + * `~/.leetcode-mcp/credentials.json` but the running server never re-hydrated + * them, so every authenticated tool failed with "Authentication required" until + * the user pasted their cookies again. + */ +import { LeetcodeServiceInterface } from "../leetcode/leetcode-service-interface.js"; +import { CredentialsStorage } from "../types/credentials.js"; +import { credentialsStorage as defaultStorage } from "../utils/credentials.js"; +import logger from "../utils/logger.js"; + +/** Outcome of an `restoreCredentials` call — useful in tests and logs. */ +export type RestoreOutcome = + | { status: "no_credentials" } + | { status: "invalid"; reason: "load_failed" | "expired" } + | { status: "restored"; username: string }; + +/** + * Loads saved credentials from disk, validates them against LeetCode, and + * pushes them into the running service if they're still good. + * + * Safe to call at server startup; never throws — failures are logged and the + * outcome is returned for callers that want to react. + */ +export async function restoreCredentials( + service: LeetcodeServiceInterface, + storage: CredentialsStorage = defaultStorage +): Promise { + if (!(await storage.exists())) { + return { status: "no_credentials" }; + } + + const credentials = await storage.load(); + if (!credentials) { + logger.warn( + "Saved credentials file exists but could not be parsed; ignoring." + ); + return { status: "invalid", reason: "load_failed" }; + } + + let username: string | null = null; + try { + username = await service.validateCredentials( + credentials.csrftoken, + credentials.LEETCODE_SESSION + ); + } catch (error) { + logger.warn( + "Saved credentials could not be validated against LeetCode: %s", + error instanceof Error ? error.message : String(error) + ); + return { status: "invalid", reason: "expired" }; + } + + if (!username) { + logger.info( + "Saved credentials are no longer valid; user will need to re-authenticate." + ); + return { status: "invalid", reason: "expired" }; + } + + service.updateCredentials( + credentials.csrftoken, + credentials.LEETCODE_SESSION + ); + logger.info( + "Restored LeetCode session for %s from saved credentials.", + username + ); + return { status: "restored", username }; +} diff --git a/src/index.ts b/src/index.ts index ecc098e..41d42c5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import minimist from "minimist"; import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { restoreCredentials } from "./auth/auth-flow.js"; import { LeetCodeGlobalService } from "./leetcode/leetcode-global-service.js"; import { LeetcodeServiceInterface } from "./leetcode/leetcode-service-interface.js"; import { registerAuthPrompts } from "./mcp/prompts/auth-prompts.js"; @@ -121,6 +122,11 @@ async function main() { credential ); + // Re-hydrate saved credentials from disk so authenticated tools work + // immediately after a server restart without forcing the user to paste + // their cookies again. + await restoreCredentials(leetcodeService); + // Register MCP prompts for learning mode and workspace guidance registerLearningPrompts(server, leetcodeService); diff --git a/src/mcp/tools/auth-tools.ts b/src/mcp/tools/auth-tools.ts index 1b51c86..a9ae104 100644 --- a/src/mcp/tools/auth-tools.ts +++ b/src/mcp/tools/auth-tools.ts @@ -214,6 +214,14 @@ export class AuthToolRegistry extends ToolRegistry { }; } + // Push validated creds into the running service so the + // very next authenticated tool call works without a + // server restart. + this.leetcodeService.updateCredentials( + credentials.csrftoken, + credentials.LEETCODE_SESSION + ); + // Calculate credential age const createdAt = credentials.createdAt ? new Date(credentials.createdAt) From 2f12e5fbb9297e45117c16d50bd37b7334289b01 Mon Sep 17 00:00:00 2001 From: Owl <32782746+SPerekrestova@users.noreply.github.com> Date: Wed, 6 May 2026 23:58:03 +0000 Subject: [PATCH 5/9] Phase 1: tests for auth restore + new types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tests/auth/auth-flow.test.ts (new, 5 tests): exercises restoreCredentials with a mocked service+storage across the four outcome paths (no_credentials / load_failed / expired / restored) plus the validate-throws path - tests/mcp/tools/auth-tools.test.ts: extend the existing check_auth_status happy-path test to assert service.updateCredentials() is called (regression guard for the silent-logout bug) - tests/services/{problem,solution}-services.test.ts: tighten access patterns now that return types are concrete (optional chaining where upstream fields can legitimately be missing). solution test asserts toBeNull() for the invalid-topicId case to match fetchSolutionArticleDetail's actual return. Test count: 146 → 151 (5 new auth-flow tests). All 151 pass; test:types passes; build clean; npm audit reports 0. --- tests/auth/auth-flow.test.ts | 116 +++++++++++++++++++++++ tests/mcp/tools/auth-tools.test.ts | 6 ++ tests/services/problem-services.test.ts | 4 +- tests/services/solution-services.test.ts | 17 +++- 4 files changed, 136 insertions(+), 7 deletions(-) create mode 100644 tests/auth/auth-flow.test.ts diff --git a/tests/auth/auth-flow.test.ts b/tests/auth/auth-flow.test.ts new file mode 100644 index 0000000..7607e06 --- /dev/null +++ b/tests/auth/auth-flow.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { restoreCredentials } from "../../src/auth/auth-flow.js"; +import type { LeetcodeServiceInterface } from "../../src/leetcode/leetcode-service-interface.js"; +import type { CredentialsStorage } from "../../src/types/credentials.js"; + +function makeStorage( + overrides: Partial = {} +): CredentialsStorage { + return { + exists: vi.fn().mockResolvedValue(false), + load: vi.fn().mockResolvedValue(null), + save: vi.fn().mockResolvedValue(undefined), + clear: vi.fn().mockResolvedValue(undefined), + ...overrides + }; +} + +function makeService( + overrides: Partial = {} +): LeetcodeServiceInterface { + return { + validateCredentials: vi.fn().mockResolvedValue("alice"), + updateCredentials: vi.fn(), + isAuthenticated: vi.fn().mockReturnValue(false), + ...overrides + } as unknown as LeetcodeServiceInterface; +} + +describe("restoreCredentials", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns no_credentials when no creds file exists", async () => { + const service = makeService(); + const storage = makeStorage({ + exists: vi.fn().mockResolvedValue(false) + }); + + const outcome = await restoreCredentials(service, storage); + + expect(outcome).toEqual({ status: "no_credentials" }); + expect(service.validateCredentials).not.toHaveBeenCalled(); + expect(service.updateCredentials).not.toHaveBeenCalled(); + }); + + it("returns invalid/load_failed when file exists but cannot be parsed", async () => { + const service = makeService(); + const storage = makeStorage({ + exists: vi.fn().mockResolvedValue(true), + load: vi.fn().mockResolvedValue(null) + }); + + const outcome = await restoreCredentials(service, storage); + + expect(outcome).toEqual({ status: "invalid", reason: "load_failed" }); + expect(service.updateCredentials).not.toHaveBeenCalled(); + }); + + it("returns invalid/expired when LeetCode rejects the saved cookies", async () => { + const service = makeService({ + validateCredentials: vi.fn().mockResolvedValue(null) + }); + const storage = makeStorage({ + exists: vi.fn().mockResolvedValue(true), + load: vi.fn().mockResolvedValue({ + csrftoken: "csrf", + LEETCODE_SESSION: "session" + }) + }); + + const outcome = await restoreCredentials(service, storage); + + expect(outcome).toEqual({ status: "invalid", reason: "expired" }); + expect(service.updateCredentials).not.toHaveBeenCalled(); + }); + + it("returns invalid/expired and swallows the error when validate throws", async () => { + const service = makeService({ + validateCredentials: vi.fn().mockRejectedValue(new Error("boom")) + }); + const storage = makeStorage({ + exists: vi.fn().mockResolvedValue(true), + load: vi.fn().mockResolvedValue({ + csrftoken: "csrf", + LEETCODE_SESSION: "session" + }) + }); + + const outcome = await restoreCredentials(service, storage); + + expect(outcome).toEqual({ status: "invalid", reason: "expired" }); + expect(service.updateCredentials).not.toHaveBeenCalled(); + }); + + it("returns restored and pushes creds into the service when validation succeeds", async () => { + const service = makeService({ + validateCredentials: vi.fn().mockResolvedValue("alice") + }); + const storage = makeStorage({ + exists: vi.fn().mockResolvedValue(true), + load: vi.fn().mockResolvedValue({ + csrftoken: "csrf-token", + LEETCODE_SESSION: "session-token" + }) + }); + + const outcome = await restoreCredentials(service, storage); + + expect(outcome).toEqual({ status: "restored", username: "alice" }); + expect(service.updateCredentials).toHaveBeenCalledWith( + "csrf-token", + "session-token" + ); + }); +}); diff --git a/tests/mcp/tools/auth-tools.test.ts b/tests/mcp/tools/auth-tools.test.ts index 73e5548..c88541f 100644 --- a/tests/mcp/tools/auth-tools.test.ts +++ b/tests/mcp/tools/auth-tools.test.ts @@ -234,6 +234,12 @@ describe("AuthToolRegistry", () => { const response = JSON.parse(result.content[0].text); expect(response.authenticated).toBe(true); expect(response.username).toBe("testuser"); + // Validated credentials must be pushed into the running service + // so the next authenticated tool call works without a restart. + expect(mockLeetCodeService.updateCredentials).toHaveBeenCalledWith( + "test-csrf", + "test-session" + ); }); it("should return expired status for invalid credentials", async () => { diff --git a/tests/services/problem-services.test.ts b/tests/services/problem-services.test.ts index ebf6e35..233fa10 100644 --- a/tests/services/problem-services.test.ts +++ b/tests/services/problem-services.test.ts @@ -14,8 +14,8 @@ describe("LeetCode Problem Services", () => { expect(result).toBeDefined(); expect(result.question).toBeDefined(); - expect(result.question.title).toBeDefined(); - expect(result.question.questionId).toBeDefined(); + expect(result.question?.title).toBeDefined(); + expect(result.question?.questionId).toBeDefined(); }, 30000); }); diff --git a/tests/services/solution-services.test.ts b/tests/services/solution-services.test.ts index 4b10a40..7c34f91 100644 --- a/tests/services/solution-services.test.ts +++ b/tests/services/solution-services.test.ts @@ -70,15 +70,22 @@ describe("LeetCode Solution Services", () => { return; } - const topicId = solutionsResult.articles[0].topicId; + const topicId = solutionsResult.articles[0]?.topicId; + if (topicId === undefined) { + logger.info( + "First article had no topicId, skipping detail fetch test" + ); + return; + } logger.info(`Using topicId: ${topicId} for detail fetch`); - const result = - await service.fetchSolutionArticleDetail(topicId); + const result = await service.fetchSolutionArticleDetail( + String(topicId) + ); expect(result).toBeDefined(); - expect(result.title).toBeDefined(); - expect(result.content).toBeDefined(); + expect(result?.title).toBeDefined(); + expect(result?.content).toBeDefined(); }, 30000); it("should handle errors properly for invalid topicIds", async () => { From a345a83f04c9d7d4a63af2cff25e92f8e1b20b0f Mon Sep 17 00:00:00 2001 From: Owl <32782746+SPerekrestova@users.noreply.github.com> Date: Wed, 6 May 2026 23:58:16 +0000 Subject: [PATCH 6/9] chore: prettier sweep on scripts/sync-version.cjs Drive-by formatting fix from running 'npm run format' during Phase 1. No behavior change. --- scripts/sync-version.cjs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/scripts/sync-version.cjs b/scripts/sync-version.cjs index 8e50a8f..d1d87a4 100644 --- a/scripts/sync-version.cjs +++ b/scripts/sync-version.cjs @@ -49,11 +49,10 @@ if (fs.existsSync(marketplacePath)) { const skillPaths = [ "skills/interactive-leetcode-mcp/SKILL.md", ".claude/skills/using-interactive-leetcode-mcp/SKILL.md", - "clawhub-skill/interactive-leetcode-mcp/SKILL.md", + "clawhub-skill/interactive-leetcode-mcp/SKILL.md" ]; -const versionPattern = - /@sperekrestova\/interactive-leetcode-mcp@[\w.-]+/g; +const versionPattern = /@sperekrestova\/interactive-leetcode-mcp@[\w.-]+/g; const versionReplacement = `@sperekrestova/interactive-leetcode-mcp@${version}`; for (const rel of skillPaths) { From a62a2e5a5055402f8ee2c8d3f3ba34bc6deeda4b Mon Sep 17 00:00:00 2001 From: Owl <32782746+SPerekrestova@users.noreply.github.com> Date: Thu, 7 May 2026 18:10:51 +0000 Subject: [PATCH 7/9] Phase 1 review: align signatures, tighten contracts on returns Address review comments on PR #36: - fetchUserAllSubmissions: drop `| null` from interface lang/status so it matches the impl. No callers ever pass null. - fetchUserProgressQuestionList: tighten impl to require `filters: { offset: number; limit: number; ... }` matching the interface. The single caller (get_user_progress_questions tool) zod-defaults offset/limit, so they're always passed. - fetchProblem: now throws LeetCodeError(PROBLEM_NOT_FOUND) on missing upstream rather than returning a typed-but-actually-null Problem. Removes the contradictory shape where the impl returned null but the signature claimed `Promise`. fetchProblemSimplified no longer needs its own null-check. - UserStatus.username and .avatar are now `string | null` (was `string` defaulted to ""). With `isSignedIn: false` and `username: ""` consumers couldn't distinguish 'signed out' from 'signed in, no display name'. No behavior change for happy paths; error paths now use the structured LeetCodeError instead of a stringified misshape. --- src/leetcode/leetcode-global-service.ts | 39 +++++++++++----------- src/leetcode/leetcode-service-interface.ts | 5 +-- src/types/user.ts | 6 ++-- 3 files changed, 27 insertions(+), 23 deletions(-) diff --git a/src/leetcode/leetcode-global-service.ts b/src/leetcode/leetcode-global-service.ts index 2ac683c..52ac972 100644 --- a/src/leetcode/leetcode-global-service.ts +++ b/src/leetcode/leetcode-global-service.ts @@ -101,8 +101,8 @@ export class LeetCodeGlobalService implements LeetcodeServiceInterface { const res = await this.leetCodeApi.whoami(); return { isSignedIn: res?.isSignedIn ?? false, - username: res?.username ?? "", - avatar: res?.avatar ?? "", + username: res?.username ?? null, + avatar: res?.avatar ?? null, isAdmin: res?.isAdmin ?? false }; } @@ -212,21 +212,22 @@ export class LeetCodeGlobalService implements LeetcodeServiceInterface { } async fetchProblem(titleSlug: string): Promise { - return (await this.leetCodeApi.problem( + const problem = (await this.leetCodeApi.problem( titleSlug - )) as unknown as Problem; - } - - async fetchProblemSimplified( - titleSlug: string - ): Promise { - const problem = await this.fetchProblem(titleSlug); + )) as unknown as Problem | null | undefined; if (!problem) { throw new LeetCodeError( ErrorCode.PROBLEM_NOT_FOUND, `Problem ${titleSlug} not found` ); } + return problem; + } + + async fetchProblemSimplified( + titleSlug: string + ): Promise { + const problem = await this.fetchProblem(titleSlug); const filteredTopicTags: string[] = problem.topicTags?.map((tag: TopicTag) => tag.slug) ?? []; @@ -320,9 +321,9 @@ export class LeetCodeGlobalService implements LeetcodeServiceInterface { }; } - async fetchUserProgressQuestionList(options?: { - offset?: number; - limit?: number; + async fetchUserProgressQuestionList(filters: { + offset: number; + limit: number; questionStatus?: string; difficulty?: string[]; }): Promise { @@ -336,15 +337,15 @@ export class LeetCodeGlobalService implements LeetcodeServiceInterface { // Cast through unknown because leetcode-query types these as enums // (LeetCodeQuestionStatus / LeetCodeDifficulty) but accepts the raw // strings we forward from MCP tool inputs. - const filters = { - skip: options?.offset || 0, - limit: options?.limit || 20, - questionStatus: options?.questionStatus as unknown as undefined, - difficulty: options?.difficulty as unknown as undefined + const upstreamFilters = { + skip: filters.offset, + limit: filters.limit, + questionStatus: filters.questionStatus as unknown as undefined, + difficulty: filters.difficulty as unknown as undefined }; return (await this.leetCodeApi.user_progress_questions( - filters + upstreamFilters )) as unknown as UserProgressQuestionList; } diff --git a/src/leetcode/leetcode-service-interface.ts b/src/leetcode/leetcode-service-interface.ts index 692565d..db3e605 100644 --- a/src/leetcode/leetcode-service-interface.ts +++ b/src/leetcode/leetcode-service-interface.ts @@ -69,8 +69,8 @@ export interface LeetcodeServiceInterface { limit: number; questionSlug?: string; lastKey?: string; - lang?: string | null; - status?: string | null; + lang?: string; + status?: string; }): Promise; /** @@ -159,6 +159,7 @@ export interface LeetcodeServiceInterface { * * @param titleSlug - Problem identifier/slug as used in the LeetCode URL * @returns Promise resolving to the problem details + * @throws LeetCodeError(PROBLEM_NOT_FOUND) if the slug doesn't exist */ fetchProblem(titleSlug: string): Promise; diff --git a/src/types/user.ts b/src/types/user.ts index 23ddadb..455dab6 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -5,8 +5,10 @@ /** Result of `fetchUserStatus()` (called for the authenticated user). */ export interface UserStatus { isSignedIn: boolean; - username: string; - avatar: string; + /** `null` when signed out, or signed in but no display username set. */ + username: string | null; + /** `null` when signed out, or signed in but no avatar set. */ + avatar: string | null; isAdmin: boolean; } From 5191a31179ac16a7348dc95893a35197ff1f8fac Mon Sep 17 00:00:00 2001 From: Owl <32782746+SPerekrestova@users.noreply.github.com> Date: Thu, 7 May 2026 18:11:05 +0000 Subject: [PATCH 8/9] Phase 1 review: forward cause through ES2022 Error.cause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review on PR #36: redeclaring 'public readonly cause?: unknown' on LeetCodeError shadowed the native ES2022 Error.cause field via the class-field installer (under useDefineForClassFields). Anything walking the standard chain via `err.cause` saw the right thing only by accident. Use `super(message, { cause })` and drop the field — same external API, but err.cause now refers to the actual native chain so loggers and debuggers that expect ES2022 semantics work consistently. --- src/types/errors.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/types/errors.ts b/src/types/errors.ts index 4b40f77..2159028 100644 --- a/src/types/errors.ts +++ b/src/types/errors.ts @@ -38,13 +38,14 @@ export type ErrorCodeValue = (typeof ErrorCode)[keyof typeof ErrorCode]; */ export class LeetCodeError extends Error { public readonly code: ErrorCodeValue; - public readonly cause?: unknown; constructor(code: ErrorCodeValue, message: string, cause?: unknown) { - super(message); + // Forward `cause` to the native ES2022 `Error` field so loggers and + // stack-walkers that rely on the standard chain see it without us + // shadowing it via a redeclared class field. + super(message, cause === undefined ? undefined : { cause }); this.name = "LeetCodeError"; this.code = code; - this.cause = cause; } } From 6f3d1b39fc3e986fafb920f10bd0d6f2bb3191c8 Mon Sep 17 00:00:00 2001 From: Owl <32782746+SPerekrestova@users.noreply.github.com> Date: Thu, 7 May 2026 18:11:18 +0000 Subject: [PATCH 9/9] Phase 1 review: extract applyValidatedCredentials helper; drop dead try/catch Addresses review on PR #36: - Extract validate->updateCredentials sequence into a small reusable helper applyValidatedCredentials(service, csrf, session) so both restoreCredentials() and check_auth_status share one implementation. - Drop the try/catch in restoreCredentials that mapped any throw to {status: 'invalid', reason: 'expired'}. The interface contract for validateCredentials is Promise; the catch was dead code against any conforming impl. If a future impl violates the contract by throwing, surfacing the error is more useful than silently swallowing it. - Update auth-flow tests: replace the dead 'swallows the error when validate throws' test with one asserting that exceptions propagate; add unit tests for the new applyValidatedCredentials helper. Test count: 153 (was 151). All passing. --- src/auth/auth-flow.ts | 44 ++++++++++++++++++----------- src/mcp/tools/auth-tools.ts | 23 ++++++--------- tests/auth/auth-flow.test.ts | 55 ++++++++++++++++++++++++++++++++---- 3 files changed, 86 insertions(+), 36 deletions(-) diff --git a/src/auth/auth-flow.ts b/src/auth/auth-flow.ts index e925f4a..9977a65 100644 --- a/src/auth/auth-flow.ts +++ b/src/auth/auth-flow.ts @@ -41,19 +41,11 @@ export async function restoreCredentials( return { status: "invalid", reason: "load_failed" }; } - let username: string | null = null; - try { - username = await service.validateCredentials( - credentials.csrftoken, - credentials.LEETCODE_SESSION - ); - } catch (error) { - logger.warn( - "Saved credentials could not be validated against LeetCode: %s", - error instanceof Error ? error.message : String(error) - ); - return { status: "invalid", reason: "expired" }; - } + const username = await applyValidatedCredentials( + service, + credentials.csrftoken, + credentials.LEETCODE_SESSION + ); if (!username) { logger.info( @@ -62,13 +54,31 @@ export async function restoreCredentials( return { status: "invalid", reason: "expired" }; } - service.updateCredentials( - credentials.csrftoken, - credentials.LEETCODE_SESSION - ); logger.info( "Restored LeetCode session for %s from saved credentials.", username ); return { status: "restored", username }; } + +/** + * Validates `csrf` / `session` against LeetCode and, on success, pushes them + * into the running service so the very next authenticated tool call works + * without forcing a server restart. + * + * Returns the validated username, or `null` if LeetCode rejected the cookies. + * Trusts the `validateCredentials` interface contract (`Promise`) + * and does not catch — any exception thrown by the service propagates. + */ +export async function applyValidatedCredentials( + service: LeetcodeServiceInterface, + csrf: string, + session: string +): Promise { + const username = await service.validateCredentials(csrf, session); + if (!username) { + return null; + } + service.updateCredentials(csrf, session); + return username; +} diff --git a/src/mcp/tools/auth-tools.ts b/src/mcp/tools/auth-tools.ts index a9ae104..be96de1 100644 --- a/src/mcp/tools/auth-tools.ts +++ b/src/mcp/tools/auth-tools.ts @@ -1,5 +1,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; +import { applyValidatedCredentials } from "../../auth/auth-flow.js"; import { LeetcodeServiceInterface } from "../../leetcode/leetcode-service-interface.js"; import { openDefaultBrowser } from "../../utils/browser-launcher.js"; import { credentialsStorage } from "../../utils/credentials.js"; @@ -191,12 +192,14 @@ export class AuthToolRegistry extends ToolRegistry { }; } - // Validate credentials are still valid - const username = - await this.leetcodeService.validateCredentials( - credentials.csrftoken, - credentials.LEETCODE_SESSION - ); + // Validate credentials and, on success, push them into + // the running service so the very next authenticated + // tool call works without a server restart. + const username = await applyValidatedCredentials( + this.leetcodeService, + credentials.csrftoken, + credentials.LEETCODE_SESSION + ); if (!username) { return { @@ -214,14 +217,6 @@ export class AuthToolRegistry extends ToolRegistry { }; } - // Push validated creds into the running service so the - // very next authenticated tool call works without a - // server restart. - this.leetcodeService.updateCredentials( - credentials.csrftoken, - credentials.LEETCODE_SESSION - ); - // Calculate credential age const createdAt = credentials.createdAt ? new Date(credentials.createdAt) diff --git a/tests/auth/auth-flow.test.ts b/tests/auth/auth-flow.test.ts index 7607e06..766b601 100644 --- a/tests/auth/auth-flow.test.ts +++ b/tests/auth/auth-flow.test.ts @@ -1,5 +1,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { restoreCredentials } from "../../src/auth/auth-flow.js"; +import { + applyValidatedCredentials, + restoreCredentials +} from "../../src/auth/auth-flow.js"; import type { LeetcodeServiceInterface } from "../../src/leetcode/leetcode-service-interface.js"; import type { CredentialsStorage } from "../../src/types/credentials.js"; @@ -75,7 +78,10 @@ describe("restoreCredentials", () => { expect(service.updateCredentials).not.toHaveBeenCalled(); }); - it("returns invalid/expired and swallows the error when validate throws", async () => { + it("propagates exceptions thrown by validateCredentials", async () => { + // The interface contract is `Promise`; `restoreCredentials` + // does not catch. If a service impl violates that contract by throwing, + // surface the error rather than silently swallowing. const service = makeService({ validateCredentials: vi.fn().mockRejectedValue(new Error("boom")) }); @@ -87,9 +93,9 @@ describe("restoreCredentials", () => { }) }); - const outcome = await restoreCredentials(service, storage); - - expect(outcome).toEqual({ status: "invalid", reason: "expired" }); + await expect(restoreCredentials(service, storage)).rejects.toThrow( + "boom" + ); expect(service.updateCredentials).not.toHaveBeenCalled(); }); @@ -114,3 +120,42 @@ describe("restoreCredentials", () => { ); }); }); + +describe("applyValidatedCredentials", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns null and does not update when LeetCode rejects the cookies", async () => { + const service = makeService({ + validateCredentials: vi.fn().mockResolvedValue(null) + }); + + const username = await applyValidatedCredentials( + service, + "csrf", + "session" + ); + + expect(username).toBeNull(); + expect(service.updateCredentials).not.toHaveBeenCalled(); + }); + + it("returns the username and pushes creds into the service when valid", async () => { + const service = makeService({ + validateCredentials: vi.fn().mockResolvedValue("alice") + }); + + const username = await applyValidatedCredentials( + service, + "csrf", + "session" + ); + + expect(username).toBe("alice"); + expect(service.updateCredentials).toHaveBeenCalledWith( + "csrf", + "session" + ); + }); +});