diff --git a/src/domain/hint-state-machine.ts b/src/domain/hint-state-machine.ts new file mode 100644 index 0000000..946520f --- /dev/null +++ b/src/domain/hint-state-machine.ts @@ -0,0 +1,82 @@ +/** + * Pure-logic hint state machine. + * + * The "tutor, not solution oracle" contract is enforced **here** — not in + * prompts, not in tool descriptions, not in the agent's instruction-following. + * Every transition that affects hint progression flows through these + * functions, and gated tools call {@link assertSolutionUnlocked} before + * returning content. If a code path bypasses this module it is a bug. + * + * Intentionally has no IO: takes a {@link SessionState}, returns a new one + * (or throws). The session store handles persistence. + */ +import { + ErrorCode, + LeetCodeError, + MAX_HINT_LEVEL, + type HintLevel, + type SessionState +} from "../types/index.js"; + +/** Hint level at which the canonical solution becomes callable. */ +export const SOLUTION_HINT_LEVEL: HintLevel = MAX_HINT_LEVEL; + +/** + * Bumps `session.hintLevel` by one (clamped at {@link MAX_HINT_LEVEL}) and + * stamps `updatedAt`. Returns a new object — the input is not mutated. + * + * Bumping at the maximum level is a no-op rather than an error: callers + * that want a different behaviour should check `session.hintLevel` before + * calling. + */ +export function advanceHint(session: SessionState): SessionState { + const next: HintLevel = + session.hintLevel >= MAX_HINT_LEVEL + ? MAX_HINT_LEVEL + : ((session.hintLevel + 1) as HintLevel); + return { + ...session, + hintLevel: next, + updatedAt: new Date().toISOString() + }; +} + +/** + * Resets the session back to its level-0 initial state, preserving the + * slug / language / workspace so the user can re-attempt from scratch. + * + * `attempts` and `lastLocalRunPassed` are zeroed too, because resetting + * the hint level without resetting effort would mislead future hint + * generation about how much the user has already tried. + */ +export function resetSession(session: SessionState): SessionState { + return { + ...session, + hintLevel: 0, + attempts: 0, + lastLocalRunPassed: null, + status: "started", + updatedAt: new Date().toISOString() + }; +} + +/** + * Throws `LeetCodeError(HINT_LEVEL_TOO_LOW)` unless the session has + * reached the level required to unlock the canonical solution. + * + * `list_problem_solutions` and `get_problem_solution` MUST call this + * before returning content. If the session doesn't exist (the user + * never called `start_problem`) callers should throw + * `SESSION_NOT_FOUND` themselves — that's a different failure mode and + * the agent should react differently to it. + */ +export function assertSolutionUnlocked(session: SessionState): void { + if (session.hintLevel < SOLUTION_HINT_LEVEL) { + throw new LeetCodeError( + ErrorCode.HINT_LEVEL_TOO_LOW, + `Solution is gated behind hint level ${SOLUTION_HINT_LEVEL}; ` + + `session for "${session.slug}" is at level ${session.hintLevel}. ` + + `Drive the user through \`request_hint\` until they have engaged with each level.` + ); + } +} diff --git a/src/domain/pedagogy.ts b/src/domain/pedagogy.ts new file mode 100644 index 0000000..5cfdff6 --- /dev/null +++ b/src/domain/pedagogy.ts @@ -0,0 +1,72 @@ +/** + * Generates per-level hint text for a given problem. + * + * Phase 3 ships generic hints derived from the problem's existing + * `hints` and `topicTags`. Phase 5 (workspace awareness) extends this by + * accepting the user's actual code so the level-2/3 messages can + * critique what they wrote rather than describing the problem in the + * abstract. The `userCode` parameter is already in the signature to keep + * the contract stable across phases. + */ +import type { HintLevel, SimplifiedProblem } from "../types/index.js"; + +/** + * Pure projection from problem + level → hint text. No IO. + * + * The contract per level matches `HintLevel`'s docstring: + * 1 — clarification (restate, edge cases) + * 2 — approach (paradigm / data structure) + * 3 — implementation sketch (pseudocode-level) + * 4 — optimal (full solution; the agent should call + * `get_problem_solution` once this level is reached, not paraphrase) + * + * Level 0 is "no hint requested yet" and is never produced by this + * function — callers should never ask for it. + * + * `userCode` is reserved for Phase 5; if provided, future levels will + * incorporate it. The Phase 3 implementation ignores it. + */ +export function generateHint( + problem: SimplifiedProblem, + level: Exclude, + _userCode?: string +): string { + switch (level) { + case 1: + return level1(problem); + case 2: + return level2(problem); + case 3: + return level3(problem); + case 4: + return level4(problem); + } +} + +function level1(problem: SimplifiedProblem): string { + const examples = problem.exampleTestcases?.trim(); + const examplePart = examples + ? `\n\nWalk through the example inputs and the expected outputs in your own words:\n\n\`\`\`\n${examples}\n\`\`\`\n\nWhat invariants must hold? What edge cases worry you?` + : "\n\nWhat invariants must hold? What edge cases worry you?"; + return `Level 1 — Clarification.\n\nRestate **${problem.title}** in your own words. What are the inputs and outputs? What constraints does the problem impose on size, value range, or duplicates?${examplePart}`; +} + +function level2(problem: SimplifiedProblem): string { + const tags = problem.topicTags?.join(", "); + const tagPart = tags + ? ` The problem is tagged: \`${tags}\`. Which of those is the most natural fit?` + : ""; + return `Level 2 — Approach.\n\nWhat data structure or algorithmic paradigm does this map onto?${tagPart}\n\nThink about the asymptotic cost of the obvious O(n²) brute force and what structure would let you get to O(n) or O(n log n) — without writing any code yet.`; +} + +function level3(problem: SimplifiedProblem): string { + const upstream = problem.hints?.[0]?.trim(); + const upstreamPart = upstream + ? `\n\nLeetCode's own first hint:\n\n> ${upstream}` + : ""; + return `Level 3 — Implementation sketch.\n\nNow draft the algorithm at pseudocode level. Walk through the data structures you'll allocate, the loop boundaries, what each iteration updates, and how you produce the final answer. Don't write language syntax yet — just the steps.${upstreamPart}`; +} + +function level4(problem: SimplifiedProblem): string { + return `Level 4 — Solution unlocked.\n\nThe session for **${problem.title}** has reached the maximum hint level. \`get_problem_solution\` and \`list_problem_solutions\` are now callable — prefer fetching the canonical solution over paraphrasing it.`; +} diff --git a/src/domain/session-service.ts b/src/domain/session-service.ts new file mode 100644 index 0000000..7c37477 --- /dev/null +++ b/src/domain/session-service.ts @@ -0,0 +1,145 @@ +/** + * Application-layer wrapper around the session store + state machine + + * hint generator. Tools should depend on this, not on the lower-level + * pieces directly — it's the seam that makes the gate uniform. + */ +import { + ErrorCode, + LeetCodeError, + type HintLevel, + type SessionState, + type SimplifiedProblem +} from "../types/index.js"; +import { + advanceHint, + assertSolutionUnlocked, + resetSession +} from "./hint-state-machine.js"; +import { generateHint } from "./pedagogy.js"; +import { FileSessionStore, type SessionStore } from "./session-store.js"; + +export interface StartProblemInput { + slug: string; + language?: string; +} + +export class SessionService { + constructor( + private readonly store: SessionStore = new FileSessionStore() + ) {} + + /** + * Returns the existing session for a slug, or creates a fresh + * level-0 session if none exists. Idempotent: starting a problem the + * user already started just returns the in-progress session + * unchanged (so you don't lose hint progress by re-running + * `start_problem`). + */ + async startOrResume(input: StartProblemInput): Promise { + const existing = await this.store.load(input.slug); + if (existing) { + // Update language only if the caller specified one and we + // didn't have one before — never silently overwrite. + if (input.language && !existing.language) { + const updated: SessionState = { + ...existing, + language: input.language, + updatedAt: new Date().toISOString() + }; + await this.store.save(updated); + return updated; + } + return existing; + } + const now = new Date().toISOString(); + const fresh: SessionState = { + slug: input.slug, + language: input.language, + hintLevel: 0, + attempts: 0, + lastLocalRunPassed: null, + status: "started", + createdAt: now, + updatedAt: now + }; + await this.store.save(fresh); + return fresh; + } + + /** Returns the session, or `null` if `start_problem` was never called. */ + async get(slug: string): Promise { + return this.store.load(slug); + } + + /** + * Advances the hint level by one and returns the new session + + * generated hint text. The text is produced from the supplied + * problem so callers don't need to load it twice. + * + * Throws `SESSION_NOT_FOUND` if the user never opened the problem. + */ + async advance( + slug: string, + problem: SimplifiedProblem + ): Promise<{ session: SessionState; hint: string; level: HintLevel }> { + const session = await this.requireSession(slug); + const next = advanceHint(session); + await this.store.save(next); + const level = next.hintLevel; + if (level === 0) { + // Unreachable — advanceHint never returns 0 — but the type + // narrows from HintLevel to 1..4 only with this guard. + throw new LeetCodeError( + ErrorCode.UPSTREAM_ERROR, + "Hint level transition produced level 0" + ); + } + return { + session: next, + level, + hint: generateHint(problem, level) + }; + } + + /** Resets the session back to the level-0 initial state. */ + async reset(slug: string): Promise { + const session = await this.requireSession(slug); + const next = resetSession(session); + await this.store.save(next); + return next; + } + + /** + * Throws if the canonical solution is not yet unlocked for `slug`. + * If `slug` is undefined, accepts the operation when *any* known + * session has reached the maximum level — the only way for the + * agent to obtain a `topicId` is via `list_problem_solutions`, + * which IS slug-gated, so this is sufficient for the typical flow. + */ + async assertSolutionUnlocked(slug?: string): Promise { + if (slug) { + const session = await this.requireSession(slug); + assertSolutionUnlocked(session); + return; + } + // No slug provided. We can't enumerate sessions without a + // discovery API on the store; defer to the caller to provide + // slug context. This branch is reserved for future expansion. + throw new LeetCodeError( + ErrorCode.HINT_LEVEL_TOO_LOW, + "Cannot determine session context without titleSlug. " + + "Provide titleSlug to verify the session has reached the required hint level." + ); + } + + private async requireSession(slug: string): Promise { + const session = await this.store.load(slug); + if (!session) { + throw new LeetCodeError( + ErrorCode.SESSION_NOT_FOUND, + `No active session for "${slug}". Call start_problem first.` + ); + } + return session; + } +} diff --git a/src/domain/session-store.ts b/src/domain/session-store.ts new file mode 100644 index 0000000..507cae9 --- /dev/null +++ b/src/domain/session-store.ts @@ -0,0 +1,100 @@ +/** + * Per-problem session persistence: one JSON file per slug under + * `~/.leetcode-mcp/sessions/.json`. + * + * The store is intentionally minimal — no migrations, no schemas — because + * the data is local and rebuildable. If a file is unreadable or malformed + * we treat it as "no session" and let the caller create a fresh one. + */ +import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import type { SessionState } from "../types/index.js"; +import logger from "../utils/logger.js"; + +/** + * Slugs come from the LeetCode URL and are already filesystem-safe in + * practice, but defend against a malicious / typo'd input that could + * traverse the sessions directory. + */ +function assertSafeSlug(slug: string): void { + if (!/^[a-z0-9-]+$/.test(slug)) { + throw new Error( + `Invalid session slug: ${JSON.stringify(slug)}. ` + + `Expected lowercase alphanumeric and hyphens only.` + ); + } +} + +export interface SessionStore { + /** Resolves to the saved session, or `null` if none exists / is unreadable. */ + load(slug: string): Promise; + /** Persists the session, creating the sessions directory if needed. */ + save(session: SessionState): Promise; + /** Removes the file. Idempotent — missing file is not an error. */ + delete(slug: string): Promise; + /** Absolute path of the file backing a given slug. */ + pathFor(slug: string): string; +} + +export interface FileSessionStoreOptions { + /** + * Override the directory the store writes to. Defaults to + * `${homedir()}/.leetcode-mcp/sessions`. Tests pass a temp directory. + */ + dir?: string; +} + +/** + * Default filesystem-backed implementation. Writes are atomic-ish — same + * pattern as `credentialsStorage`: write JSON, mode 0o600 (sessions are + * not secrets but neither are they other-readable by intent). + */ +export class FileSessionStore implements SessionStore { + private readonly dir: string; + + constructor(options: FileSessionStoreOptions = {}) { + this.dir = options.dir ?? join(homedir(), ".leetcode-mcp", "sessions"); + } + + pathFor(slug: string): string { + assertSafeSlug(slug); + return resolve(this.dir, `${slug}.json`); + } + + async load(slug: string): Promise { + const path = this.pathFor(slug); + try { + await stat(path); + } catch { + return null; + } + try { + const raw = await readFile(path, "utf-8"); + return JSON.parse(raw) as SessionState; + } catch (err) { + // Corrupt session file is recoverable — log and return null so + // the caller can rebuild from `start_problem`. + logger.warn( + "Discarding malformed session file %s: %s", + path, + err instanceof Error ? err.message : String(err) + ); + return null; + } + } + + async save(session: SessionState): Promise { + const path = this.pathFor(session.slug); + await mkdir(this.dir, { recursive: true, mode: 0o700 }); + await writeFile(path, JSON.stringify(session, null, 2), { + encoding: "utf-8", + mode: 0o600 + }); + } + + async delete(slug: string): Promise { + const path = this.pathFor(slug); + await rm(path, { force: true }); + } +} diff --git a/src/index.ts b/src/index.ts index 41d42c5..b9c316d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,16 +7,19 @@ import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { restoreCredentials } from "./auth/auth-flow.js"; +import { SessionService } from "./domain/session-service.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"; import { registerLearningPrompts } from "./mcp/prompts/learning-prompts.js"; import { registerProblemResources } from "./mcp/resources/problem-resources.js"; import { registerSolutionResources } from "./mcp/resources/solution-resources.js"; +import { SERVER_INSTRUCTIONS } from "./mcp/server-instructions.js"; import { registerAuthTools } from "./mcp/tools/auth-tools.js"; import { registerContestTools } from "./mcp/tools/contest-tools.js"; import { registerOnboardingTools } from "./mcp/tools/onboarding-tools.js"; import { registerProblemTools } from "./mcp/tools/problem-tools.js"; +import { registerSessionTools } from "./mcp/tools/session-tools.js"; import { registerSolutionTools } from "./mcp/tools/solution-tools.js"; import { registerSubmissionTools } from "./mcp/tools/submission-tools.js"; import { registerUserTools } from "./mcp/tools/user-tools.js"; @@ -111,10 +114,18 @@ async function main() { const packageJSON = getPackageJson(); - const server = new McpServer({ - name: "LeetCode MCP Server", - version: packageJSON.version - }); + const server = new McpServer( + { + name: "LeetCode MCP Server", + version: packageJSON.version + }, + { + // Delivered to clients at handshake. Replaces the prompt-based + // "remember to invoke X first" dance with rules the agent + // receives once and keeps for the session. + instructions: SERVER_INSTRUCTIONS + } + ); const credential: Credential = new Credential(); const leetcodeService: LeetcodeServiceInterface = new LeetCodeGlobalService( @@ -127,6 +138,13 @@ async function main() { // their cookies again. await restoreCredentials(leetcodeService); + // Pedagogy state machine: per-problem session JSON under + // ~/.leetcode-mcp/sessions/.json. The session service is the + // single owner of hint progression and the gate that + // list_problem_solutions / get_problem_solution check before + // returning content. + const sessions = new SessionService(); + // Register MCP prompts for learning mode and workspace guidance registerLearningPrompts(server, leetcodeService); @@ -138,7 +156,8 @@ async function main() { registerProblemTools(server, leetcodeService); registerUserTools(server, leetcodeService); registerContestTools(server, leetcodeService); - registerSolutionTools(server, leetcodeService); + registerSessionTools(server, leetcodeService, sessions); + registerSolutionTools(server, leetcodeService, sessions); registerAuthTools(server, leetcodeService); registerSubmissionTools(server, leetcodeService); diff --git a/src/mcp/server-instructions.ts b/src/mcp/server-instructions.ts new file mode 100644 index 0000000..9dc2a82 --- /dev/null +++ b/src/mcp/server-instructions.ts @@ -0,0 +1,28 @@ +/** + * The MCP `instructions` field — a single block delivered to clients at + * handshake. Replaces the SKILL-style "remember to invoke prompt X first" + * dance with rules the agent receives once and keeps for the session. + * + * Kept as a small constant so it can be unit-tested independently and + * is easy to evolve as the rest of the redesign lands. + */ +export const SERVER_INSTRUCTIONS: string = ` +You are connected to the LeetCode MCP server, an AI tutor — not a solution oracle. + +# Pedagogy contract (server-enforced) + +- Every problem the user works on lives in a session. Open one with **start_problem({ titleSlug, language? })** before any other problem-specific call. +- Hints are progressive and gated. Use **request_hint({ titleSlug })** to advance the user from clarification → approach → implementation sketch → optimal solution. Do not paraphrase later levels before they are unlocked. +- The community-solutions tools (\`list_problem_solutions\`, \`get_problem_solution\`) are gated by the server. They will reject with \`HINT_LEVEL_TOO_LOW\` until the session has reached the maximum hint level. Drive the user there through hints rather than trying to bypass the gate. +- Inspect progress with **get_session_state({ titleSlug })**; restart a problem with **reset_session({ titleSlug })**. + +# Authoring style + +- Match the user's language. The session remembers it; honour it. +- When you produce hints yourself (vs. paraphrasing the server's hint payload), reference what the user has actually written when possible — generic hints are worse than no hint. +- Submissions cost the user a real LeetCode submission. Prefer reasoning + (in future phases) local test runs before calling \`submit_solution\`. + +# Authentication + +- Credentials are auto-restored at startup if the user has saved them. If \`check_auth_status\` reports unauthenticated, point the user at \`start_leetcode_auth\` and the **leetcode_authentication_guide** prompt. +`.trim(); diff --git a/src/mcp/tools/session-tools.ts b/src/mcp/tools/session-tools.ts new file mode 100644 index 0000000..0726e71 --- /dev/null +++ b/src/mcp/tools/session-tools.ts @@ -0,0 +1,246 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { SessionService } from "../../domain/session-service.js"; +import { LeetcodeServiceInterface } from "../../leetcode/leetcode-service-interface.js"; +import { ErrorCode, isLeetCodeError } from "../../types/index.js"; +import { ToolRegistry } from "./tool-registry.js"; + +/** + * Pedagogy-flow tools: session lifecycle + hint progression. + * + * These four tools replace the prompt-based "remember to invoke X" flow + * with explicit, server-tracked state. The agent calls `start_problem` + * once, then drives `request_hint` until the user has engaged with each + * level, and only then are the solution-returning tools callable. + */ +export class SessionToolRegistry extends ToolRegistry { + constructor( + server: McpServer, + leetcodeService: LeetcodeServiceInterface, + private readonly sessions: SessionService + ) { + super(server, leetcodeService); + } + + protected registerPublic(): void { + this.registerStartProblem(); + this.registerRequestHint(); + this.registerGetSessionState(); + this.registerResetSession(); + } + + private registerStartProblem(): void { + this.server.registerTool( + "start_problem", + { + description: + "Opens (or resumes) a tutoring session for a LeetCode problem. Must be called before request_hint, list_problem_solutions, or get_problem_solution. Idempotent: re-running on a slug the user is already mid-way through preserves their hint progress.", + inputSchema: { + titleSlug: z + .string() + .min(1) + .describe( + "The URL slug of the problem (e.g., 'two-sum')." + ), + language: z + .string() + .optional() + .describe( + "Optional: the language the user is solving in. Recorded on the session for future workspace / runner phases." + ) + } + }, + async ({ titleSlug, language }) => { + try { + const problem = + await this.leetcodeService.fetchProblemSimplified( + titleSlug + ); + const session = await this.sessions.startOrResume({ + slug: titleSlug, + language + }); + return { + content: [ + { + type: "text", + text: JSON.stringify({ + titleSlug, + session, + problem + }) + } + ] + }; + } catch (error) { + return errorEnvelope("Failed to start problem", error); + } + } + ); + } + + private registerRequestHint(): void { + this.server.registerTool( + "request_hint", + { + description: + "Advances the hint level for an active session and returns the next hint. Levels: 1 clarification → 2 approach → 3 implementation sketch → 4 solution unlock. The community-solutions tools become callable only after this has been driven to level 4.", + inputSchema: { + titleSlug: z + .string() + .min(1) + .describe( + "The URL slug of the problem the user is working on." + ) + } + }, + async ({ titleSlug }) => { + try { + const problem = + await this.leetcodeService.fetchProblemSimplified( + titleSlug + ); + const result = await this.sessions.advance( + titleSlug, + problem + ); + return { + content: [ + { + type: "text", + text: JSON.stringify({ + titleSlug, + level: result.level, + hint: result.hint, + session: result.session + }) + } + ] + }; + } catch (error) { + return errorEnvelope("Failed to request hint", error); + } + } + ); + } + + private registerGetSessionState(): void { + this.server.registerTool( + "get_session_state", + { + description: + "Returns the persisted session for a problem, or null if the user has not called start_problem for it. Useful for restoring context after a restart.", + inputSchema: { + titleSlug: z + .string() + .min(1) + .describe("The URL slug of the problem.") + } + }, + async ({ titleSlug }) => { + try { + const session = await this.sessions.get(titleSlug); + return { + content: [ + { + type: "text", + text: JSON.stringify({ + titleSlug, + session + }) + } + ] + }; + } catch (error) { + return errorEnvelope("Failed to read session", error); + } + } + ); + } + + private registerResetSession(): void { + this.server.registerTool( + "reset_session", + { + description: + "Resets the tutoring session for a problem back to hint level 0. Use when the user wants to re-attempt the problem from scratch.", + inputSchema: { + titleSlug: z + .string() + .min(1) + .describe("The URL slug of the problem to reset.") + } + }, + async ({ titleSlug }) => { + try { + const session = await this.sessions.reset(titleSlug); + return { + content: [ + { + type: "text", + text: JSON.stringify({ + titleSlug, + session + }) + } + ] + }; + } catch (error) { + return errorEnvelope("Failed to reset session", error); + } + } + ); + } +} + +/** + * Renders a `LeetCodeError` (or any unknown failure) into the MCP + * tool-result envelope shape, with the structured `code` field surfaced + * alongside the human-readable message so clients can dispatch on it. + * + * Returns the MCP SDK tool-result shape; widened from the literal + * single-content-item type so handler signatures unify with the SDK's + * inferred return type. + */ +function errorEnvelope(fallbackMessage: string, error: unknown) { + if (isLeetCodeError(error)) { + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ + error: fallbackMessage, + code: error.code, + message: error.message + }) + } + ] + }; + } + const message = + error instanceof Error ? error.message : String(error ?? "unknown"); + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ + error: fallbackMessage, + code: ErrorCode.UPSTREAM_ERROR, + message + }) + } + ] + }; +} +// Re-exported so other tool registries can render the same shape when +// gating on the session service throws. +export { errorEnvelope }; + +export function registerSessionTools( + server: McpServer, + leetcodeService: LeetcodeServiceInterface, + sessions: SessionService +): void { + const registry = new SessionToolRegistry(server, leetcodeService, sessions); + registry.register(); +} diff --git a/src/mcp/tools/solution-tools.ts b/src/mcp/tools/solution-tools.ts index f30272a..1767bb0 100644 --- a/src/mcp/tools/solution-tools.ts +++ b/src/mcp/tools/solution-tools.ts @@ -1,59 +1,71 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; +import type { SessionService } from "../../domain/session-service.js"; import { LeetcodeServiceInterface } from "../../leetcode/leetcode-service-interface.js"; +import { errorEnvelope } from "./session-tools.js"; import { ToolRegistry } from "./tool-registry.js"; /** - * Solution tool registry class that handles registration of LeetCode solution-related tools. - * This class manages tools for accessing solutions, filtering solutions, and reading solution details. + * Solution tool registry — community-solution access. + * + * Both tools are gated by the pedagogy state machine: they reject with + * `HINT_LEVEL_TOO_LOW` until the active session for the slug has reached + * the maximum hint level. The agent is expected to drive the user + * through `request_hint` first. */ export class SolutionToolRegistry extends ToolRegistry { + constructor( + server: McpServer, + leetcodeService: LeetcodeServiceInterface, + private readonly sessions: SessionService + ) { + super(server, leetcodeService); + } + protected registerPublic(): void { - // Problem solutions listing tool (Global-specific) this.server.registerTool( "list_problem_solutions", { description: - "Retrieves a list of community solutions for a specific LeetCode problem, including only metadata like topicId. To view the full content of a solution, use the 'get_problem_solution' tool with the topicId returned by this tool.", - + "Retrieves community solution metadata (topicIds) for a problem. GATED: rejects with HINT_LEVEL_TOO_LOW unless the active session for the slug has reached the maximum hint level. Drive the user through request_hint until that level is reached.", inputSchema: { questionSlug: z .string() .describe( - "The URL slug/identifier of the problem to retrieve solutions for (e.g., 'two-sum', 'add-two-numbers'). This is the same string that appears in the LeetCode problem URL after '/problems/'" + "The URL slug of the problem (e.g., 'two-sum')." ), limit: z .number() .optional() .default(10) .describe( - "Maximum number of solutions to return per request. Used for pagination and controlling response size. Default is 10 if not specified. Must be a positive integer." + "Maximum number of solutions to return per request. Default 10. Must be a positive integer." ), skip: z .number() .optional() .describe( - "Number of solutions to skip before starting to collect results. Used in conjunction with 'limit' for implementing pagination. Default is 0 if not specified. Must be a non-negative integer." + "Number of solutions to skip before collecting results. Used with `limit` for pagination." ), orderBy: z .enum(["HOT", "MOST_RECENT", "MOST_VOTES"]) .default("HOT") .optional() .describe( - "Sorting criteria for the returned solutions. 'DEFAULT' sorts by LeetCode's default algorithm (typically a combination of recency and popularity), 'MOST_VOTES' sorts by the number of upvotes (highest first), and 'MOST_RECENT' sorts by publication date (newest first)." + "Sorting criteria. 'HOT' is LeetCode's default (recency × popularity), 'MOST_VOTES' = upvotes, 'MOST_RECENT' = newest." ), userInput: z .string() .optional() .describe( - "Search term to filter solutions by title, content, or author name. Case insensitive. Useful for finding specific approaches or algorithms mentioned in solutions." + "Search term to filter solutions by title, content, or author name. Case-insensitive." ), tagSlugs: z .array(z.string()) .optional() .default([]) .describe( - "Array of tag identifiers to filter solutions by programming languages (e.g., 'python', 'java') or problem algorithm/data-structure tags (e.g., 'dynamic-programming', 'recursion'). Only solutions tagged with at least one of the specified tags will be returned." + "Tag slugs to filter by (languages or algorithm tags). Solutions must match at least one tag." ) } }, @@ -66,20 +78,12 @@ export class SolutionToolRegistry extends ToolRegistry { tagSlugs }) => { try { - const options = { - limit, - skip, - orderBy, - userInput, - tagSlugs - }; - + await this.sessions.assertSolutionUnlocked(questionSlug); const data = await this.leetcodeService.fetchQuestionSolutionArticles( questionSlug, - options + { limit, skip, orderBy, userInput, tagSlugs } ); - return { content: [ { @@ -91,83 +95,69 @@ export class SolutionToolRegistry extends ToolRegistry { } ] }; - } catch (error: any) { - return { - content: [ - { - type: "text", - text: JSON.stringify({ - error: "Failed to fetch solutions", - message: error.message - }) - } - ] - }; + } catch (error) { + return errorEnvelope("Failed to fetch solutions", error); } } ); - // Solution article detail tool (Global-specific) this.server.registerTool( "get_problem_solution", { description: - "Retrieves the complete content and metadata of a specific solution, including the full article text, author information, and related navigation links. This returns a FULL community solution — only call this after the user has exhausted progressive hints or has explicitly requested the solution after receiving earlier hints.", - + "Retrieves the full content of a specific community solution. GATED: rejects with HINT_LEVEL_TOO_LOW unless the session for `titleSlug` has reached the maximum hint level. Pass the topicId returned by `list_problem_solutions`.", inputSchema: { topicId: z .string() .describe( - "The unique topic ID of the solution to retrieve. This ID can be obtained from the 'topicId' field in the response of the 'list_problem_solutions' tool. Format is typically a string of numbers and letters that uniquely identifies the solution in LeetCode's database." + "The unique topic ID of the solution, returned by list_problem_solutions." + ), + titleSlug: z + .string() + .describe( + "The URL slug of the problem the solution belongs to. Required to verify the session has reached the unlock level." ) } }, - async ({ topicId }) => { + async ({ topicId, titleSlug }) => { try { + await this.sessions.assertSolutionUnlocked(titleSlug); const data = await this.leetcodeService.fetchSolutionArticleDetail( topicId ); - return { content: [ { type: "text", text: JSON.stringify({ topicId, + titleSlug, solution: data }) } ] }; - } catch (error: any) { - return { - content: [ - { - type: "text", - text: JSON.stringify({ - error: "Failed to fetch solution detail", - message: error.message - }) - } - ] - }; + } catch (error) { + return errorEnvelope( + "Failed to fetch solution detail", + error + ); } } ); } } -/** - * Registers all solution-related tools with the MCP server. - * - * @param server - The MCP server instance to register tools with - * @param leetcodeService - The LeetCode service implementation to use for API calls - */ export function registerSolutionTools( server: McpServer, - leetcodeService: LeetcodeServiceInterface + leetcodeService: LeetcodeServiceInterface, + sessions: SessionService ): void { - const registry = new SolutionToolRegistry(server, leetcodeService); + const registry = new SolutionToolRegistry( + server, + leetcodeService, + sessions + ); registry.register(); } diff --git a/src/types/errors.ts b/src/types/errors.ts index 2159028..971b640 100644 --- a/src/types/errors.ts +++ b/src/types/errors.ts @@ -25,7 +25,20 @@ export const ErrorCode = { /** 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" + UPSTREAM_ERROR: "UPSTREAM_ERROR", + /** + * Tutoring gate: the caller asked for content (typically a full + * solution) that is gated behind a higher hint level than the active + * session has reached. The pedagogy state machine refuses; the agent + * is expected to drive the user through `request_hint` first. + */ + HINT_LEVEL_TOO_LOW: "HINT_LEVEL_TOO_LOW", + /** + * Tutoring gate: the operation requires an active session for a + * particular problem slug, but no `start_problem` has been called for + * it (or the session was reset). + */ + SESSION_NOT_FOUND: "SESSION_NOT_FOUND" } as const; export type ErrorCodeValue = (typeof ErrorCode)[keyof typeof ErrorCode]; diff --git a/src/types/index.ts b/src/types/index.ts index 47627b1..7e56d5f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -7,6 +7,7 @@ export * from "./credentials.js"; export * from "./errors.js"; export * from "./problem.js"; +export * from "./session.js"; export * from "./solution.js"; export * from "./submission.js"; export * from "./user.js"; diff --git a/src/types/session.ts b/src/types/session.ts new file mode 100644 index 0000000..639c290 --- /dev/null +++ b/src/types/session.ts @@ -0,0 +1,63 @@ +/** + * Per-problem session state — the durable record the pedagogy state machine + * reads and writes. + * + * One file per problem slug under `~/.leetcode-mcp/sessions/.json`. + * Persisted across restarts so the user can step away from a problem and + * resume at the same hint level / attempt count later. + */ + +/** + * Discrete hint progression. Higher values strictly subsume lower ones — + * a session at level 4 has access to everything level 1 unlocked. + * + * - **0** Initial state after `start_problem`. No hints, no solutions. + * - **1** Clarification — restate the problem in the user's own words, + * surface invariants and edge cases. No algorithmic direction yet. + * - **2** Approach — high-level paradigm or data structure to consider + * ("what lookup is O(1)?"). No code, no pseudocode. + * - **3** Implementation sketch — pseudocode-level structure of a working + * solution. Still does not unlock the canonical full solution. + * - **4** Optimal — the canonical full solution and the community + * solutions tools (`get_problem_solution`, `list_problem_solutions`) + * become callable. + */ +export type HintLevel = 0 | 1 | 2 | 3 | 4; + +export const MAX_HINT_LEVEL = 4 as const; + +/** + * Lifecycle of a per-problem session. The state machine moves through + * these labels as the user (or agent) drives `start_problem` → + * `request_hint` ↔ `submit_solution` → `solved`. + */ +export type SessionStatus = "started" | "attempting" | "solved" | "abandoned"; + +export interface SessionState { + /** LeetCode problem slug (matches `Problem.titleSlug`). */ + slug: string; + /** Language the user is solving in, when `start_problem` is given one. */ + language?: string; + /** Current hint level. Bumped by `request_hint`. */ + hintLevel: HintLevel; + /** Total submission attempts the session has driven so far. */ + attempts: number; + /** + * Outcome of the most recent local-runner invocation. `null` until the + * user runs locally for the first time. Wired by Phase 4 (local + * runner); kept here so Phase 3 sets the contract. + */ + lastLocalRunPassed: boolean | null; + /** Lifecycle label — see {@link SessionStatus}. */ + status: SessionStatus; + /** + * Absolute path of the workspace file `start_problem` created for the + * user, if any. Workspace awareness lands in Phase 5; this field is + * already part of the contract so the file shape is stable. + */ + workspacePath?: string; + /** ISO-8601 of session creation. */ + createdAt: string; + /** ISO-8601 of the most recent state transition. */ + updatedAt: string; +} diff --git a/tests/domain/hint-state-machine.test.ts b/tests/domain/hint-state-machine.test.ts new file mode 100644 index 0000000..4f81523 --- /dev/null +++ b/tests/domain/hint-state-machine.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { + SOLUTION_HINT_LEVEL, + advanceHint, + assertSolutionUnlocked, + resetSession +} from "../../src/domain/hint-state-machine.js"; +import { + ErrorCode, + LeetCodeError, + type HintLevel, + type SessionState +} from "../../src/types/index.js"; + +function makeSession(overrides: Partial = {}): SessionState { + return { + slug: "two-sum", + hintLevel: 0, + attempts: 0, + lastLocalRunPassed: null, + status: "started", + createdAt: "2025-01-01T00:00:00.000Z", + updatedAt: "2025-01-01T00:00:00.000Z", + ...overrides + }; +} + +describe("advanceHint", () => { + it("bumps the hint level by one", () => { + const next = advanceHint(makeSession({ hintLevel: 1 })); + expect(next.hintLevel).toBe(2); + }); + + it("stamps updatedAt", () => { + const before = makeSession(); + const next = advanceHint(before); + expect(next.updatedAt).not.toBe(before.updatedAt); + }); + + it("does not mutate the input", () => { + const before = makeSession({ hintLevel: 1 }); + advanceHint(before); + expect(before.hintLevel).toBe(1); + }); + + it("clamps at the maximum level rather than overflowing", () => { + const next = advanceHint( + makeSession({ hintLevel: SOLUTION_HINT_LEVEL }) + ); + expect(next.hintLevel).toBe(SOLUTION_HINT_LEVEL); + }); +}); + +describe("resetSession", () => { + it("returns to a level-0, started state", () => { + const before = makeSession({ + hintLevel: 3, + attempts: 5, + lastLocalRunPassed: true, + status: "attempting" + }); + const next = resetSession(before); + expect(next.hintLevel).toBe(0); + expect(next.attempts).toBe(0); + expect(next.lastLocalRunPassed).toBeNull(); + expect(next.status).toBe("started"); + }); + + it("preserves slug / language / workspacePath", () => { + const before = makeSession({ + language: "python3", + workspacePath: "/tmp/two-sum.py", + hintLevel: 4 + }); + const next = resetSession(before); + expect(next.slug).toBe("two-sum"); + expect(next.language).toBe("python3"); + expect(next.workspacePath).toBe("/tmp/two-sum.py"); + }); +}); + +describe("assertSolutionUnlocked", () => { + it("does not throw when the session is at the maximum hint level", () => { + expect(() => + assertSolutionUnlocked( + makeSession({ hintLevel: SOLUTION_HINT_LEVEL }) + ) + ).not.toThrow(); + }); + + it.each([0, 1, 2, 3] satisfies HintLevel[] as HintLevel[])( + "throws HINT_LEVEL_TOO_LOW when the session is at level %d", + (level) => { + try { + assertSolutionUnlocked(makeSession({ hintLevel: level })); + throw new Error("did not throw"); + } catch (err) { + expect(err).toBeInstanceOf(LeetCodeError); + expect((err as LeetCodeError).code).toBe( + ErrorCode.HINT_LEVEL_TOO_LOW + ); + } + } + ); +}); diff --git a/tests/domain/pedagogy.test.ts b/tests/domain/pedagogy.test.ts new file mode 100644 index 0000000..012ff98 --- /dev/null +++ b/tests/domain/pedagogy.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { generateHint } from "../../src/domain/pedagogy.js"; +import type { SimplifiedProblem } from "../../src/types/index.js"; + +const TWO_SUM: SimplifiedProblem = { + titleSlug: "two-sum", + questionId: "1", + title: "Two Sum", + content: "

Find two indices that sum to target.

", + difficulty: "Easy", + topicTags: ["array", "hash-table"], + codeSnippets: [], + exampleTestcases: "[2,7,11,15]\n9", + hints: ["A hash map gives O(1) lookup."], + similarQuestions: [] +}; + +describe("generateHint", () => { + it("level 1 restates the problem and surfaces example testcases", () => { + const hint = generateHint(TWO_SUM, 1); + expect(hint).toContain("Level 1"); + expect(hint).toContain("Two Sum"); + expect(hint).toContain("[2,7,11,15]"); + }); + + it("level 2 references the topic tags but does not give code", () => { + const hint = generateHint(TWO_SUM, 2); + expect(hint).toContain("Level 2"); + expect(hint).toContain("array"); + expect(hint).toContain("hash-table"); + // No literal code blocks should appear at level 2. + expect(hint).not.toMatch(/```python|```js|```ts/); + }); + + it("level 3 surfaces the upstream LeetCode hint when available", () => { + const hint = generateHint(TWO_SUM, 3); + expect(hint).toContain("Level 3"); + expect(hint).toContain("hash map"); + }); + + it("level 4 announces solution unlock", () => { + const hint = generateHint(TWO_SUM, 4); + expect(hint).toContain("Level 4"); + expect(hint).toContain("get_problem_solution"); + }); + + it("does not crash on a problem with no hints / examples / tags", () => { + const sparse: SimplifiedProblem = { + ...TWO_SUM, + topicTags: [], + hints: [], + exampleTestcases: "" + }; + for (const level of [1, 2, 3, 4] as const) { + expect(generateHint(sparse, level).length).toBeGreaterThan(0); + } + }); +}); diff --git a/tests/domain/session-store.test.ts b/tests/domain/session-store.test.ts new file mode 100644 index 0000000..9283757 --- /dev/null +++ b/tests/domain/session-store.test.ts @@ -0,0 +1,75 @@ +import { mkdtemp, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { FileSessionStore } from "../../src/domain/session-store.js"; +import type { SessionState } from "../../src/types/index.js"; + +function makeSession(overrides: Partial = {}): SessionState { + return { + slug: "two-sum", + hintLevel: 0, + attempts: 0, + lastLocalRunPassed: null, + status: "started", + createdAt: "2025-01-01T00:00:00.000Z", + updatedAt: "2025-01-01T00:00:00.000Z", + ...overrides + }; +} + +describe("FileSessionStore", () => { + let dir: string; + let store: FileSessionStore; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "leetcode-mcp-session-")); + store = new FileSessionStore({ dir }); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("returns null for a slug that has never been saved", async () => { + expect(await store.load("two-sum")).toBeNull(); + }); + + it("round-trips a saved session through load()", async () => { + const session = makeSession({ hintLevel: 2, attempts: 1 }); + await store.save(session); + expect(await store.load("two-sum")).toEqual(session); + }); + + it("creates the sessions directory on save", async () => { + const subdir = join(dir, "nested", "sessions"); + const subStore = new FileSessionStore({ dir: subdir }); + await subStore.save(makeSession()); + const info = await stat(subdir); + expect(info.isDirectory()).toBe(true); + }); + + it("delete is idempotent — removing a missing session does not throw", async () => { + await expect(store.delete("never-saved")).resolves.toBeUndefined(); + }); + + it("delete removes a saved session", async () => { + await store.save(makeSession()); + await store.delete("two-sum"); + expect(await store.load("two-sum")).toBeNull(); + }); + + it("rejects slugs with path-traversal characters", () => { + expect(() => store.pathFor("../etc/passwd")).toThrow( + /Invalid session slug/ + ); + expect(() => store.pathFor("two_sum")).toThrow(/Invalid session slug/); + expect(() => store.pathFor("Two-Sum")).toThrow(/Invalid session slug/); + }); + + it("returns null when the JSON file is malformed", async () => { + // Write garbage to where load() will look. + await writeFile(store.pathFor("two-sum"), "not json {", "utf-8"); + expect(await store.load("two-sum")).toBeNull(); + }); +}); diff --git a/tests/e2e/lifecycle.test.ts b/tests/e2e/lifecycle.test.ts index 2cdfcfb..a7851d3 100644 --- a/tests/e2e/lifecycle.test.ts +++ b/tests/e2e/lifecycle.test.ts @@ -43,14 +43,18 @@ describe("e2e: server lifecycle", () => { "get_problem_submission_report", "get_recent_ac_submissions", "get_recent_submissions", + "get_session_state", "get_started", "get_user_contest_ranking", "get_user_profile", "get_user_status", "list_problem_solutions", + "request_hint", + "reset_session", "save_leetcode_credentials", "search_problems", "start_leetcode_auth", + "start_problem", "submit_solution" ]; diff --git a/tests/e2e/pedagogy-gate.test.ts b/tests/e2e/pedagogy-gate.test.ts new file mode 100644 index 0000000..2d823e4 --- /dev/null +++ b/tests/e2e/pedagogy-gate.test.ts @@ -0,0 +1,164 @@ +/** + * Pedagogy state machine e2e: spawn the real server, drive a problem + * through `start_problem` → `request_hint` × 4, and assert the + * solution-returning tools are gated until the maximum hint level. + * + * Locks in the Phase 3 contract end-to-end: the rules are enforced by + * the wire, not by prompts the agent might forget to read. + */ +import { afterEach, describe, expect, it } from "vitest"; +import { spawnServer, type SpawnedServer } from "./harness/spawn-server.js"; + +interface ToolTextResult { + content: Array<{ type: string; text: string }>; +} + +const TWO_SUM_PROBLEM = { + questionId: "1", + questionFrontendId: "1", + title: "Two Sum", + titleSlug: "two-sum", + difficulty: "Easy", + isPaidOnly: false, + content: + "

Given an array of integers nums and an integer target...

", + topicTags: [{ name: "Array", slug: "array" }], + codeSnippets: [ + { + lang: "Python3", + langSlug: "python3", + code: "class Solution:\n def twoSum(self, nums, target):\n pass\n" + } + ], + similarQuestions: "[]", + exampleTestcases: "[2,7,11,15]\n9", + hints: ["Try a hash map for O(n) lookup"], + stats: '{"totalAccepted":"10M","totalSubmission":"20M","acRate":"50.0%"}' +}; + +const SOLUTION_LIST_PAYLOAD = { + data: { + ugcArticleSolutionArticles: { + edges: [{ node: { topicId: "topic-42", title: "Hash map O(n)" } }], + totalNum: 1, + pageInfo: { hasNextPage: false } + } + } +}; + +/** + * The fixture serves the same canned GraphQL payload for every request + * that contains the matching field selector — `start_problem` and each + * `request_hint` both refetch the problem, so the question fixture must + * be replayable. + */ +const FIXTURE = { + graphql: [ + { + operationContains: "question(titleSlug:", + response: { data: { question: TWO_SUM_PROBLEM } } + }, + { + operationContains: "ugcArticleSolutionArticles", + response: SOLUTION_LIST_PAYLOAD + } + ] +}; + +describe("e2e: pedagogy gate", () => { + let spawned: SpawnedServer | undefined; + + afterEach(async () => { + if (spawned) { + await spawned.cleanup(); + spawned = undefined; + } + }); + + it("gates list_problem_solutions until request_hint reaches level 4", async () => { + spawned = await spawnServer({ fixture: FIXTURE }); + + // 1. No session yet — solutions must reject with SESSION_NOT_FOUND. + const noSession = (await spawned.client.callTool({ + name: "list_problem_solutions", + arguments: { questionSlug: "two-sum" } + })) as ToolTextResult; + const noSessionPayload = JSON.parse(noSession.content[0].text); + expect(noSessionPayload.code).toBe("SESSION_NOT_FOUND"); + + // 2. Open a session and assert the initial state. + const start = (await spawned.client.callTool({ + name: "start_problem", + arguments: { titleSlug: "two-sum", language: "python3" } + })) as ToolTextResult; + const startPayload = JSON.parse(start.content[0].text); + expect(startPayload.session.hintLevel).toBe(0); + expect(startPayload.session.status).toBe("started"); + + // 3. Walk the hint flow up to (but not at) the unlock level. + for (let expectedLevel = 1; expectedLevel < 4; expectedLevel++) { + const hint = (await spawned.client.callTool({ + name: "request_hint", + arguments: { titleSlug: "two-sum" } + })) as ToolTextResult; + const payload = JSON.parse(hint.content[0].text); + expect(payload.level).toBe(expectedLevel); + expect(typeof payload.hint).toBe("string"); + expect(payload.hint.length).toBeGreaterThan(0); + + // At each pre-unlock level, list_problem_solutions still rejects. + const stillGated = (await spawned.client.callTool({ + name: "list_problem_solutions", + arguments: { questionSlug: "two-sum" } + })) as ToolTextResult; + const stillGatedPayload = JSON.parse(stillGated.content[0].text); + expect(stillGatedPayload.code).toBe("HINT_LEVEL_TOO_LOW"); + } + + // 4. Final hint bump unlocks the solutions tool. + const finalHint = (await spawned.client.callTool({ + name: "request_hint", + arguments: { titleSlug: "two-sum" } + })) as ToolTextResult; + const finalPayload = JSON.parse(finalHint.content[0].text); + expect(finalPayload.level).toBe(4); + + // 5. Now the gate opens. + const unlocked = (await spawned.client.callTool({ + name: "list_problem_solutions", + arguments: { questionSlug: "two-sum" } + })) as ToolTextResult; + const unlockedPayload = JSON.parse(unlocked.content[0].text); + expect(unlockedPayload.questionSlug).toBe("two-sum"); + expect(unlockedPayload.solutionArticles).toBeDefined(); + }); + + it("reset_session clamps hint level back to 0 and re-engages the gate", async () => { + spawned = await spawnServer({ fixture: FIXTURE }); + + await spawned.client.callTool({ + name: "start_problem", + arguments: { titleSlug: "two-sum" } + }); + for (let i = 0; i < 4; i++) { + await spawned.client.callTool({ + name: "request_hint", + arguments: { titleSlug: "two-sum" } + }); + } + + const reset = (await spawned.client.callTool({ + name: "reset_session", + arguments: { titleSlug: "two-sum" } + })) as ToolTextResult; + const resetPayload = JSON.parse(reset.content[0].text); + expect(resetPayload.session.hintLevel).toBe(0); + + const gatedAgain = (await spawned.client.callTool({ + name: "list_problem_solutions", + arguments: { questionSlug: "two-sum" } + })) as ToolTextResult; + const gatedAgainPayload = JSON.parse(gatedAgain.content[0].text); + expect(gatedAgainPayload.code).toBe("HINT_LEVEL_TOO_LOW"); + }); +}); diff --git a/tests/integration/solution-tools-integration.test.ts b/tests/integration/solution-tools-integration.test.ts index ba5a4bc..6bbc1c4 100644 --- a/tests/integration/solution-tools-integration.test.ts +++ b/tests/integration/solution-tools-integration.test.ts @@ -1,9 +1,20 @@ /** * Solution Tools Integration Tests - * Tests all solution-related tools through MCP protocol + * + * Validates wire-level behaviour of `list_problem_solutions` and + * `get_problem_solution` through the MCP protocol — including the + * pedagogy gate added in Phase 3 (rejection with `HINT_LEVEL_TOO_LOW` + * when the active session has not reached the maximum hint level). */ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { SessionService } from "../../src/domain/session-service.js"; +import { FileSessionStore } from "../../src/domain/session-store.js"; import { registerSolutionTools } from "../../src/mcp/tools/solution-tools.js"; +import type { SessionState } from "../../src/types/index.js"; +import { ErrorCode, MAX_HINT_LEVEL } from "../../src/types/index.js"; import { createMockLeetCodeService } from "../helpers/mock-leetcode.js"; import type { TestClientPair } from "../helpers/test-client.js"; import { createTestClient } from "../helpers/test-client.js"; @@ -12,12 +23,19 @@ import { INTEGRATION_TEST_TIMEOUT, assertions } from "./setup.js"; describe("Solution Tools Integration", () => { let testClient: TestClientPair; let mockService: ReturnType; + let sessions: SessionService; + let sessionDir: string; beforeEach(async () => { mockService = createMockLeetCodeService(); + // Sessions live in a per-test temp dir so specs don't leak state. + sessionDir = await mkdtemp(join(tmpdir(), "leetcode-mcp-itest-")); + sessions = new SessionService( + new FileSessionStore({ dir: sessionDir }) + ); testClient = await createTestClient({}, (server) => { - registerSolutionTools(server, mockService as any); + registerSolutionTools(server, mockService as any, sessions); }); }, INTEGRATION_TEST_TIMEOUT); @@ -25,8 +43,32 @@ describe("Solution Tools Integration", () => { if (testClient) { await testClient.cleanup(); } + await rm(sessionDir, { recursive: true, force: true }); }); + /** + * Helper — drops a session for `slug` at the given level into the + * store. Bypasses `start_problem` so the gate can be tested in + * isolation. + */ + async function seedSession( + slug: string, + hintLevel: number = MAX_HINT_LEVEL + ): Promise { + const now = new Date().toISOString(); + const session: SessionState = { + slug, + hintLevel: hintLevel as SessionState["hintLevel"], + attempts: 0, + lastLocalRunPassed: null, + status: "started", + createdAt: now, + updatedAt: now + }; + const store = new FileSessionStore({ dir: sessionDir }); + await store.save(session); + } + describe("list_problem_solutions", () => { it( "should list list_problem_solutions tool", @@ -37,19 +79,59 @@ describe("Solution Tools Integration", () => { (t) => t.name === "list_problem_solutions" ); expect(tool).toBeDefined(); - expect(tool?.description).toContain("solutions"); + expect(tool?.description).toContain("solution"); }, INTEGRATION_TEST_TIMEOUT ); it( - "should execute list_problem_solutions successfully", + "should reject when no session has reached the unlock level", async () => { const result: any = await testClient.client.callTool({ name: "list_problem_solutions", arguments: { questionSlug: "two-sum", limit: 5 } }); + assertions.hasToolResultStructure(result); + const payload = JSON.parse(result.content[0].text); + expect(payload.code).toBe(ErrorCode.SESSION_NOT_FOUND); + expect( + mockService.fetchQuestionSolutionArticles + ).not.toHaveBeenCalled(); + }, + INTEGRATION_TEST_TIMEOUT + ); + + it( + "should reject when session is below the unlock level", + async () => { + await seedSession("two-sum", 2); + + const result: any = await testClient.client.callTool({ + name: "list_problem_solutions", + arguments: { questionSlug: "two-sum", limit: 5 } + }); + + assertions.hasToolResultStructure(result); + const payload = JSON.parse(result.content[0].text); + expect(payload.code).toBe(ErrorCode.HINT_LEVEL_TOO_LOW); + expect( + mockService.fetchQuestionSolutionArticles + ).not.toHaveBeenCalled(); + }, + INTEGRATION_TEST_TIMEOUT + ); + + it( + "should execute list_problem_solutions when session is at unlock level", + async () => { + await seedSession("two-sum"); + + const result: any = await testClient.client.callTool({ + name: "list_problem_solutions", + arguments: { questionSlug: "two-sum", limit: 5 } + }); + assertions.hasToolResultStructure(result); expect( mockService.fetchQuestionSolutionArticles @@ -67,6 +149,8 @@ describe("Solution Tools Integration", () => { it( "should handle list_problem_solutions with filters", async () => { + await seedSession("two-sum"); + const result: any = await testClient.client.callTool({ name: "list_problem_solutions", arguments: { @@ -102,17 +186,38 @@ describe("Solution Tools Integration", () => { ); expect(tool).toBeDefined(); expect(tool?.description).toContain("solution"); - expect(tool?.description).toContain("hints"); }, INTEGRATION_TEST_TIMEOUT ); it( - "should execute get_problem_solution successfully", + "should reject when titleSlug session is below unlock level", + async () => { + await seedSession("two-sum", 3); + + const result: any = await testClient.client.callTool({ + name: "get_problem_solution", + arguments: { topicId: "12345", titleSlug: "two-sum" } + }); + + assertions.hasToolResultStructure(result); + const payload = JSON.parse(result.content[0].text); + expect(payload.code).toBe(ErrorCode.HINT_LEVEL_TOO_LOW); + expect( + mockService.fetchSolutionArticleDetail + ).not.toHaveBeenCalled(); + }, + INTEGRATION_TEST_TIMEOUT + ); + + it( + "should execute get_problem_solution when session is unlocked", async () => { + await seedSession("two-sum"); + const result: any = await testClient.client.callTool({ name: "get_problem_solution", - arguments: { topicId: "12345" } + arguments: { topicId: "12345", titleSlug: "two-sum" } }); assertions.hasToolResultStructure(result);