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) { diff --git a/src/auth/auth-flow.ts b/src/auth/auth-flow.ts new file mode 100644 index 0000000..9977a65 --- /dev/null +++ b/src/auth/auth-flow.ts @@ -0,0 +1,84 @@ +/** + * 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" }; + } + + const username = await applyValidatedCredentials( + service, + credentials.csrftoken, + credentials.LEETCODE_SESSION + ); + + if (!username) { + logger.info( + "Saved credentials are no longer valid; user will need to re-authenticate." + ); + return { status: "invalid", reason: "expired" }; + } + + 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/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/leetcode/leetcode-global-service.ts b/src/leetcode/leetcode-global-service.ts index c20d2e8..52ac972 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 ?? null, + avatar: res?.avatar ?? null, + 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,42 @@ 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 { + const problem = (await this.leetCodeApi.problem( + 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 { + async fetchProblemSimplified( + titleSlug: string + ): Promise { const problem = await this.fetchProblem(titleSlug); - if (!problem) { - throw new Error(`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 +272,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,36 +303,50 @@ 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) + }) + ) }; } - async fetchUserProgressQuestionList(options?: { - offset?: number; - limit?: number; + async fetchUserProgressQuestionList(filters: { + offset: number; + 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" ); } - const filters = { - skip: options?.offset || 0, - limit: options?.limit || 20, - questionStatus: options?.questionStatus as any, - difficulty: options?.difficulty as any[] + // Cast through unknown because leetcode-query types these as enums + // (LeetCodeQuestionStatus / LeetCodeDifficulty) but accepts the raw + // strings we forward from MCP tool inputs. + 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); + return (await this.leetCodeApi.user_progress_questions( + upstreamFilters + )) as unknown as UserProgressQuestionList; } /** @@ -294,9 +358,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 +369,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 +410,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 +455,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 +472,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 +493,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 +532,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 +562,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 +633,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 +688,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..db3e605 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,16 +62,16 @@ 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; limit: number; questionSlug?: string; lastKey?: string; - lang?: string | null; - status?: string | null; - }): Promise; + lang?: string; + status?: string; + }): 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,16 +150,18 @@ 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. * * @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; + fetchProblem(titleSlug: string): Promise; /** * Searches for problems matching specified criteria. @@ -144,7 +181,7 @@ export interface LeetcodeServiceInterface { limit?: number, offset?: number, searchKeywords?: string - ): Promise; + ): Promise; /** * Determines if the current service has valid authentication credentials. @@ -171,8 +208,8 @@ export interface LeetcodeServiceInterface { */ fetchQuestionSolutionArticles( questionSlug: string, - options?: any - ): Promise; + options?: SolutionArticlesQueryOptions + ): Promise; /** * Retrieves detailed information about a specific solution. @@ -180,7 +217,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. 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; diff --git a/src/mcp/tools/auth-tools.ts b/src/mcp/tools/auth-tools.ts index 1b51c86..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 { diff --git a/src/types/errors.ts b/src/types/errors.ts new file mode 100644 index 0000000..2159028 --- /dev/null +++ b/src/types/errors.ts @@ -0,0 +1,55 @@ +/** + * 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; + + constructor(code: ErrorCodeValue, message: string, cause?: unknown) { + // 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; + } +} + +/** 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..455dab6 --- /dev/null +++ b/src/types/user.ts @@ -0,0 +1,113 @@ +/** + * User / submission-history / contest type contracts. + */ + +/** Result of `fetchUserStatus()` (called for the authenticated user). */ +export interface UserStatus { + isSignedIn: boolean; + /** `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; +} + +/** 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; +} diff --git a/tests/auth/auth-flow.test.ts b/tests/auth/auth-flow.test.ts new file mode 100644 index 0000000..766b601 --- /dev/null +++ b/tests/auth/auth-flow.test.ts @@ -0,0 +1,161 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +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"; + +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("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")) + }); + const storage = makeStorage({ + exists: vi.fn().mockResolvedValue(true), + load: vi.fn().mockResolvedValue({ + csrftoken: "csrf", + LEETCODE_SESSION: "session" + }) + }); + + await expect(restoreCredentials(service, storage)).rejects.toThrow( + "boom" + ); + 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" + ); + }); +}); + +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" + ); + }); +}); 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 () => {