mdr_request_review
Request human review of markdown files. Start a new review by providing file paths, or continue an existing session by passing the session ID. Blocks until feedback is received.
Instructions
Open markdown files in mdr (md-redline) for human review, or continue an existing review session. To start a new review, pass filePaths. To continue after addressing a batch of comments, pass the sessionId from the previous result. Blocks until the user sends comments or finishes the review.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| filePaths | No | Absolute paths to markdown files to review (for new sessions). | |
| enableResolve | No | Whether to use the resolve workflow (open/resolved states). | |
| sessionId | No | Session ID from a previous batch result. Pass this (without filePaths) to wait for the next batch of comments after addressing the previous batch. |
Implementation Reference
- server/mcp-stdio/server.ts:26-59 (registration)Registration of the 'mdr_request_review' tool with the MCP SDK. Lists the tool name, description, and inputSchema (filePaths, enableResolve, sessionId) via ListToolsRequestSchema handler.
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: 'mdr_request_review', description: 'Open markdown files in mdr (md-redline) for human review, or continue ' + 'an existing review session. To start a new review, pass filePaths. ' + 'To continue after addressing a batch of comments, pass the sessionId ' + 'from the previous result. Blocks until the user sends comments or ' + 'finishes the review.', inputSchema: { type: 'object', properties: { filePaths: { type: 'array', items: { type: 'string' }, minItems: 1, description: 'Absolute paths to markdown files to review (for new sessions).', }, enableResolve: { type: 'boolean', description: 'Whether to use the resolve workflow (open/resolved states).', }, sessionId: { type: 'string', description: 'Session ID from a previous batch result. Pass this (without filePaths) ' + 'to wait for the next batch of comments after addressing the previous batch.', }, }, }, }, ], })); - server/mcp-stdio/handler.ts:43-143 (handler)Core handler function 'handleRequestReviewToolCall'. Implements the tool logic: grant access, create session, open browser, long-poll waitForSession, handle batch/done/aborted results.
export async function handleRequestReviewToolCall( input: RequestReviewInput, ctx: ToolCallContext, ): Promise<ToolCallResult> { // Continue mode: skip session creation, just re-poll for next batch if (input.mode === 'continue') { return handleContinueReviewToolCall(input.sessionId, { client: ctx.client, sendProgress: ctx.sendProgress, signal: ctx.signal, }); } await ctx.client.grantAccess(input.filePaths); const session = await ctx.client.createSession({ filePaths: input.filePaths, enableResolve: input.enableResolve, }); const fullUrl = `${ctx.baseUrl.replace(/\/$/, '')}${session.url}`; // Skip opening the browser when the server returned an existing session // for the same files — the tab is already open from the first call. if (session.created !== false) { await ctx.openInBrowser(fullUrl).catch(() => { // Browser open failures are non-fatal — the user can copy the URL. }); } // Immediate "waiting" status so the client sees something right away. ctx.sendProgress?.(`mdr: waiting for your review at ${fullUrl}`); // Periodic progress updates while the long-poll is in flight. These keep // the tool call visibly alive in Claude Code's UI even though we have no // quantifiable progress to report. let elapsed = 0; const progressTimer = ctx.sendProgress ? setInterval(() => { elapsed += PROGRESS_INTERVAL_MS / 1000; ctx.sendProgress?.(`mdr: still waiting for your review (${elapsed}s elapsed)`); }, PROGRESS_INTERVAL_MS) : null; if (progressTimer && 'unref' in progressTimer) { (progressTimer as { unref: () => void }).unref(); } // If the MCP client cancels the tool call, release the server-side session // immediately so we don't leave a 30-second orphan waiting for the // heartbeat sweep. The /abort POST resolves the waiter promise, which lets // the long-poll return with {status: 'aborted'}. const cancelListener = () => { void ctx.client.abortSession(session.sessionId).catch(() => { // Already released, network error, or the long-poll resolved first — // in any case the waiter will come back and we'll read the status below. }); }; if (ctx.signal?.aborted) { cancelListener(); } else { ctx.signal?.addEventListener('abort', cancelListener, { once: true }); } let result: WaitResult; try { result = await ctx.client.waitForSession(session.sessionId); } finally { if (progressTimer) clearInterval(progressTimer); ctx.signal?.removeEventListener('abort', cancelListener); } if (result.status === 'batch') { return { content: [{ type: 'text', text: BATCH_PREAMBLE(session.sessionId) + result.prompt }], }; } if (result.status === 'done') { if (result.prompt) { return { content: [{ type: 'text', text: DONE_PREAMBLE + result.prompt }], }; } return { content: [{ type: 'text', text: DONE_NO_COMMENTS }], }; } // status === 'aborted' const reasonText = result.reason === 'browser_disconnected' ? 'the mdr browser tab was closed before review was completed' : 'the user cancelled the review'; return { content: [ { type: 'text', text: `Review was not completed (${reasonText}). No comments to address. Continue with your original plan.`, }, ], }; } - server/mcp-stdio/handler.ts:145-208 (handler)Secondary handler 'handleContinueReviewToolCall' for continue mode (re-using a sessionId to poll for the next batch of comments).
export async function handleContinueReviewToolCall( sessionId: string, ctx: Pick<ToolCallContext, 'client' | 'sendProgress' | 'signal'>, ): Promise<ToolCallResult> { ctx.sendProgress?.(`mdr: waiting for next review batch (session ${sessionId})`); let elapsed = 0; const progressTimer = ctx.sendProgress ? setInterval(() => { elapsed += PROGRESS_INTERVAL_MS / 1000; ctx.sendProgress?.(`mdr: still waiting for next batch (${elapsed}s elapsed)`); }, PROGRESS_INTERVAL_MS) : null; if (progressTimer && 'unref' in progressTimer) { (progressTimer as { unref: () => void }).unref(); } const cancelListener = () => { void ctx.client.abortSession(sessionId).catch(() => {}); }; if (ctx.signal?.aborted) { cancelListener(); } else { ctx.signal?.addEventListener('abort', cancelListener, { once: true }); } let result: WaitResult; try { result = await ctx.client.waitForSession(sessionId); } finally { if (progressTimer) clearInterval(progressTimer); ctx.signal?.removeEventListener('abort', cancelListener); } if (result.status === 'batch') { return { content: [{ type: 'text', text: BATCH_PREAMBLE(sessionId) + result.prompt }], }; } if (result.status === 'done') { if (result.prompt) { return { content: [{ type: 'text', text: DONE_PREAMBLE + result.prompt }], }; } return { content: [{ type: 'text', text: DONE_NO_COMMENTS }], }; } const reasonText = result.reason === 'browser_disconnected' ? 'the mdr browser tab was closed before review was completed' : 'the user cancelled the review'; return { content: [ { type: 'text', text: `Review was not completed (${reasonText}). No comments to address. Continue with your original plan.`, }, ], }; } - server/mcp-stdio/validate.ts:24-57 (schema)Input validation function 'validateRequestReviewInput'. Accepts either {filePaths, enableResolve?} for new sessions or {sessionId} for continue mode.
export function validateRequestReviewInput(raw: unknown): ValidationResult<RequestReviewInput> { if (typeof raw !== 'object' || raw === null) { return { ok: false, error: 'input must be an object' }; } const obj = raw as { filePaths?: unknown; enableResolve?: unknown; sessionId?: unknown }; // Continue mode: sessionId is provided if (typeof obj.sessionId === 'string' && obj.sessionId.length > 0) { if (Array.isArray(obj.filePaths) && obj.filePaths.length > 0) { return { ok: false, error: 'provide either filePaths or sessionId, not both' }; } return { ok: true, value: { mode: 'continue', sessionId: obj.sessionId } }; } // New session mode: filePaths is required if (!Array.isArray(obj.filePaths)) { return { ok: false, error: 'filePaths must be an array (or provide sessionId to continue a session)' }; } if (obj.filePaths.length === 0) { return { ok: false, error: 'filePaths must be non-empty' }; } if (obj.filePaths.some((p) => typeof p !== 'string' || p.length === 0)) { return { ok: false, error: 'filePaths must contain non-empty strings' }; } return { ok: true, value: { mode: 'new', filePaths: obj.filePaths as string[], enableResolve: obj.enableResolve === true, }, }; } - server/mcp-stdio/client.ts:13-65 (helper)HTTP client helper 'createMdrClient' providing grantAccess, createSession, waitForSession, and abortSession methods used by the handler.
export function createMdrClient(baseUrl: string): MdrClient { const url = (p: string) => `${baseUrl.replace(/\/$/, '')}${p}`; return { async grantAccess(paths) { for (const p of paths) { const res = await fetch(url('/api/grant-access'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path: p }), }); if (!res.ok) { const body = (await res.json().catch(() => ({}))) as { error?: string }; throw new Error(body.error ?? `grant-access failed for ${p} (HTTP ${res.status})`); } } }, async createSession(input: CreateSessionInput) { const res = await fetch(url('/api/review-sessions'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(input), }); if (!res.ok) { const body = (await res.json().catch(() => ({}))) as { error?: string }; throw new Error(body.error ?? `createSession failed (HTTP ${res.status})`); } return (await res.json()) as CreateSessionResult; }, async waitForSession(sessionId: string) { const res = await fetch(url(`/api/review-sessions/${sessionId}/wait`), { method: 'GET', }); if (!res.ok) { throw new Error(`wait failed (HTTP ${res.status})`); } return (await res.json()) as WaitResult; }, async abortSession(sessionId: string) { const res = await fetch(url(`/api/review-sessions/${sessionId}/abort`), { method: 'POST', headers: { 'content-type': 'application/json' }, }); if (!res.ok) { const body = (await res.json().catch(() => ({}))) as { error?: string }; throw new Error(body.error ?? `abort failed for ${sessionId} (HTTP ${res.status})`); } }, }; }