Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion src/domain/session-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,13 @@ export class SessionService {
);
}

private async requireSession(slug: string): Promise<SessionState> {
/**
* Public variant of the session lookup — throws `SESSION_NOT_FOUND`
* when the user never opened the slug. Used by the runner-tools
* layer to keep `run_local_tests` aligned with the pedagogy state
* machine (no orphaned runs).
*/
async requireSession(slug: string): Promise<SessionState> {
const session = await this.store.load(slug);
if (!session) {
throw new LeetCodeError(
Expand All @@ -142,4 +148,24 @@ export class SessionService {
}
return session;
}

/**
* Updates the session after a `run_local_tests` invocation.
* Increments `attempts`, sets `lastLocalRunPassed`, and bumps
* `status` to "attempting" on the first run (so subsequent
* resets-then-runs keep the lifecycle accurate).
*/
async recordLocalRun(slug: string, passed: boolean): Promise<SessionState> {
const session = await this.requireSession(slug);
const next: SessionState = {
...session,
attempts: session.attempts + 1,
lastLocalRunPassed: passed,
status:
session.status === "started" ? "attempting" : session.status,
updatedAt: new Date().toISOString()
};
await this.store.save(next);
return next;
}
}
10 changes: 9 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ 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 { registerRunnerTools } from "./mcp/tools/runner-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";
import { SubprocessRunner } from "./runner/subprocess-runner.js";
import logger from "./utils/logger.js";

/**
Expand Down Expand Up @@ -145,6 +147,11 @@ async function main() {
// returning content.
const sessions = new SessionService();

// Local subprocess runner: probes python3 / go / java on first use,
// wraps with bwrap / firejail / sandbox-exec where available, and
// backs the `run_local_tests` tool. Phase 4a ships python3 only.
const runner = new SubprocessRunner();

// Register MCP prompts for learning mode and workspace guidance
registerLearningPrompts(server, leetcodeService);

Expand All @@ -158,8 +165,9 @@ async function main() {
registerContestTools(server, leetcodeService);
registerSessionTools(server, leetcodeService, sessions);
registerSolutionTools(server, leetcodeService, sessions);
registerRunnerTools(server, leetcodeService, sessions, runner);
registerAuthTools(server, leetcodeService);
registerSubmissionTools(server, leetcodeService);
registerSubmissionTools(server, leetcodeService, sessions);

registerProblemResources(server, leetcodeService);
registerSolutionResources(server, leetcodeService);
Expand Down
188 changes: 188 additions & 0 deletions src/mcp/tools/runner-tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
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 {
IMPLEMENTED_LANGUAGES,
SUPPORTED_LANGUAGES,
type LocalRunner
} from "../../runner/runner.js";
import type { RunnerLanguage } from "../../types/index.js";
import { ErrorCode, LeetCodeError } from "../../types/index.js";
import { errorEnvelope } from "./session-tools.js";
import { ToolRegistry } from "./tool-registry.js";

/**
* Local-runner tools introduced in Phase 4.
*
* `run_local_tests` is the inner-loop primitive: agent passes code,
* runner spawns a sandboxed subprocess, captures stdout/stderr/exit
* code, and reports back. The session's `lastLocalRunPassed` flag is
* updated as a side effect so `submit_solution`'s strict-mode gate
* (Phase 6) and any future analytics have a stable hook.
*
* v1 deliberately does *not* parse `exampleTestcases` server-side or
* synthesize a per-problem harness. The agent — which already has the
* problem in context after `start_problem` — is responsible for adding
* test invocations to the code it submits to the runner. That keeps
* the wire surface tiny, language-agnostic, and free of LeetCode-
* specific signature parsing.
*/
export class RunnerToolRegistry extends ToolRegistry {
constructor(
server: McpServer,
leetcodeService: LeetcodeServiceInterface,
private readonly sessions: SessionService,
private readonly runner: LocalRunner
) {
super(server, leetcodeService);
}

protected registerPublic(): void {
this.registerRunLocalTests();
this.registerDoctor();
}

private registerRunLocalTests(): void {
const supportedLiteral = z.enum(
SUPPORTED_LANGUAGES as unknown as [string, ...string[]]
);
this.server.registerTool(
"run_local_tests",
{
description:
"Runs the user's code locally in an isolated subprocess, captures stdout / stderr / exit code, and updates the session's lastLocalRunPassed flag. Use this in the inner loop instead of submit_solution — it costs no LeetCode submission and turns around in seconds. The agent is responsible for including test invocations (e.g. `print(Solution().twoSum([2,7,11,15], 9))`) in the code passed in. Phase 4a ships python3; go and java land in Phase 4b/4c.",
inputSchema: {
titleSlug: z
.string()
.min(1)
.describe(
"The URL slug of the problem (must match an active session opened with start_problem)."
),
language: supportedLiteral.describe(
`Language to execute as. Currently runnable: ${IMPLEMENTED_LANGUAGES.join(
", "
)}. Other LeetCode languages remain valid for submit_solution.`
),
code: z
.string()
.min(1)
.describe(
"Complete source code to execute. Should include test invocations that print results / raise on failure."
),
timeoutMs: z
.number()
.int()
.min(100)
.max(60_000)
.optional()
.describe(
"Optional wall-clock budget in milliseconds. Defaults to 5000."
)
}
},
async ({ titleSlug, language, code, timeoutMs }) => {
try {
// Require a session — keeps the runner aligned with
// the pedagogy state machine (and gives us a sane
// place to record `attempts` / `lastLocalRunPassed`).
await this.sessions.requireSession(titleSlug);

const result = await this.runner.run({
titleSlug,
language: language as RunnerLanguage,
code,
timeoutMs
});

await this.sessions.recordLocalRun(
titleSlug,
result.passed
);

return {
content: [
{
type: "text" as const,
text: JSON.stringify({
titleSlug,
language,
result
})
}
]
};
} catch (error) {
return errorEnvelope(
"Failed to run local tests",
wrapTimeout(error)
);
}
}
);
}

private registerDoctor(): void {
this.server.registerTool(
"runner_doctor",
{
description:
"Reports which language runtimes (python3, go, java) and OS sandbox tools (bwrap, firejail, sandbox-exec) are detected on this host. Useful for diagnosing 'LANGUAGE_RUNTIME_NOT_FOUND' errors and confirming whether run_local_tests will be sandboxed.",
inputSchema: {}
},
async () => {
try {
const capabilities = await this.runner.capabilities();
return {
content: [
{
type: "text" as const,
text: JSON.stringify(capabilities)
}
]
};
} catch (error) {
return errorEnvelope(
"Failed to inspect runner capabilities",
error
);
}
}
);
}
}

/**
* `RUNNER_TIMEOUT` is reported as a plain `RunResult` with `timedOut: true`,
* not as a thrown error — but `run` itself can throw for the runtime-
* not-found / language-not-implemented cases. Anything else is normalised
* into `UPSTREAM_ERROR` by the shared envelope.
*/
function wrapTimeout(error: unknown): unknown {
if (error instanceof LeetCodeError) {
return error;
}
if (error instanceof Error && /timed out/i.test(error.message)) {
return new LeetCodeError(
ErrorCode.RUNNER_TIMEOUT,
error.message,
error
);
}
return error;
}

export function registerRunnerTools(
server: McpServer,
leetcodeService: LeetcodeServiceInterface,
sessions: SessionService,
runner: LocalRunner
): void {
const registry = new RunnerToolRegistry(
server,
leetcodeService,
sessions,
runner
);
registry.register();
}
65 changes: 49 additions & 16 deletions src/mcp/tools/submission-tools.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,41 @@
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, LeetCodeError } from "../../types/index.js";
import { errorEnvelope } from "./session-tools.js";
import { ToolRegistry } from "./tool-registry.js";

/**
* Submission tool registry class that handles registration of LeetCode submission tools.
*
* Phase 4 wires the strict-mode gate (`LEETCODE_MCP_STRICT_MODE=1`):
* when enabled, `submit_solution` refuses to spend a real LeetCode
* submission unless the active session's `lastLocalRunPassed === true`.
* Default is *off* (preserves current behaviour); session is optional
* so existing flows without `start_problem` aren't broken.
*/
export class SubmissionToolRegistry extends ToolRegistry {
constructor(
server: McpServer,
leetcodeService: LeetcodeServiceInterface,
private readonly sessions?: SessionService
) {
super(server, leetcodeService);
}

private isStrictMode(): boolean {
const value = process.env.LEETCODE_MCP_STRICT_MODE;
return value === "1" || value === "true";
}

protected registerPublic(): void {
// Submission tool
this.server.registerTool(
"submit_solution",
{
description:
"Submit a solution to a LeetCode problem and get results. Returns acceptance status, runtime/memory stats, or failed test case details.",
"Submit a solution to a LeetCode problem and get results. Returns acceptance status, runtime/memory stats, or failed test case details. When LEETCODE_MCP_STRICT_MODE=1 is set, requires `run_local_tests` to have last passed for the problem first — saves real LeetCode submissions for solutions that pass examples locally.",
inputSchema: {
problemSlug: z
.string()
Expand Down Expand Up @@ -51,6 +73,21 @@ export class SubmissionToolRegistry extends ToolRegistry {
},
async ({ problemSlug, code, language }) => {
try {
if (this.isStrictMode() && this.sessions) {
// The strict gate only fires when the user has
// actually opened a session for this slug. If
// they never called `start_problem`, the
// pre-strict-mode behaviour is preserved (so
// strict mode is non-disruptive for ad-hoc
// calls outside the tutoring flow).
const session = await this.sessions.get(problemSlug);
if (session && session.lastLocalRunPassed !== true) {
throw new LeetCodeError(
ErrorCode.LOCAL_TESTS_NOT_PASSED,
"Strict mode is enabled and the most recent run_local_tests for this problem did not pass. Run it again and submit only when locals are green."
);
}
}
const result = await this.leetcodeService.submitSolution(
problemSlug,
code,
Expand All @@ -59,23 +96,13 @@ export class SubmissionToolRegistry extends ToolRegistry {
return {
content: [
{
type: "text",
type: "text" as const,
text: JSON.stringify(result, null, 2)
}
]
};
} catch (error: any) {
return {
content: [
{
type: "text",
text: JSON.stringify({
error: "Failed to submit solution",
message: error.message
})
}
]
};
} catch (error) {
return errorEnvelope("Failed to submit solution", error);
}
}
);
Expand All @@ -87,11 +114,17 @@ export class SubmissionToolRegistry extends ToolRegistry {
*
* @param server - The MCP server instance to register tools with
* @param leetcodeService - The LeetCode service implementation to use for API calls
* @param sessions - Optional session service used for the strict-mode gate
*/
export function registerSubmissionTools(
server: McpServer,
leetcodeService: LeetcodeServiceInterface
leetcodeService: LeetcodeServiceInterface,
sessions?: SessionService
): void {
const registry = new SubmissionToolRegistry(server, leetcodeService);
const registry = new SubmissionToolRegistry(
server,
leetcodeService,
sessions
);
registry.register();
}
Loading
Loading