Read Transcript
graph_read_transcriptRead Claude Code JSONL transcripts and output normalized text messages, ensuring compatibility with future format updates.
Instructions
Read and parse a Claude Code JSONL transcript file through the canonical transcript parser. Returns normalized messages with text content extracted. Use this instead of reading raw JSONL directly — if the transcript format changes, only this tool needs updating.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| session_id | No | Session UUID (filename without .jsonl). Searches ~/.claude/projects/ for a match. | |
| file_path | No | Absolute path to the .jsonl file. Takes precedence over session_id. | |
| text_only | No | If true (default), return only messages that have extractable text content. |
Implementation Reference
- src/mcp-server/index.ts:1541-1608 (registration)Tool 'graph_read_transcript' is registered with the MCP server, with inputSchema defining session_id, file_path, and text_only parameters.
// ─── Tool: graph_read_transcript ─── server.registerTool("graph_read_transcript", { title: "Read Transcript", description: "Read and parse a Claude Code JSONL transcript file through the canonical transcript parser. " + "Returns normalized messages with text content extracted. Use this instead of reading raw JSONL " + "directly — if the transcript format changes, only this tool needs updating.", inputSchema: { session_id: z .string() .optional() .describe("Session UUID (filename without .jsonl). Searches ~/.claude/projects/ for a match."), file_path: z .string() .optional() .describe("Absolute path to the .jsonl file. Takes precedence over session_id."), text_only: z .boolean() .optional() .default(true) .describe("If true (default), return only messages that have extractable text content."), }, annotations: { readOnlyHint: true }, }, async ({ session_id, file_path, text_only = true }) => { if (!session_id && !file_path) { return toolError("Provide either session_id or file_path"); } let resolvedPath = file_path; if (!resolvedPath && session_id) { const { readdirSync: rd, statSync: st } = await import("node:fs"); const { homedir } = await import("node:os"); const projectsDir = join(homedir(), ".claude", "projects"); try { const projectDirs = rd(projectsDir, { withFileTypes: true }); for (const dir of projectDirs) { if (!dir.isDirectory()) continue; const candidate = join(projectsDir, dir.name, `${session_id}.jsonl`); try { st(candidate); resolvedPath = candidate; break; } catch { /* not here */ } } } catch { /* projects dir missing */ } if (!resolvedPath) { return toolError(`No transcript found for session_id: ${session_id}`); } } const result = parseTranscriptFile(resolvedPath!); const messages = text_only ? getTextMessages(result) : result.messages; return toolResult({ session_id: result.sessionId, cwd: result.cwd, format_version: result.formatVersion, line_count: result.lineCount, message_count: result.messages.length, text_message_count: getTextMessages(result).length, warnings: result.warnings, messages: messages.map((m) => ({ role: m.role, timestamp: m.timestamp, uuid: m.uuid, parentUuid: m.parentUuid, text: m.textContent, })), }); }); - src/mcp-server/index.ts:1565-1608 (handler)The handler function for graph_read_transcript resolves either session_id (by searching ~/.claude/projects/) or file_path, then calls parseTranscriptFile() and returns normalized messages with text content.
}, async ({ session_id, file_path, text_only = true }) => { if (!session_id && !file_path) { return toolError("Provide either session_id or file_path"); } let resolvedPath = file_path; if (!resolvedPath && session_id) { const { readdirSync: rd, statSync: st } = await import("node:fs"); const { homedir } = await import("node:os"); const projectsDir = join(homedir(), ".claude", "projects"); try { const projectDirs = rd(projectsDir, { withFileTypes: true }); for (const dir of projectDirs) { if (!dir.isDirectory()) continue; const candidate = join(projectsDir, dir.name, `${session_id}.jsonl`); try { st(candidate); resolvedPath = candidate; break; } catch { /* not here */ } } } catch { /* projects dir missing */ } if (!resolvedPath) { return toolError(`No transcript found for session_id: ${session_id}`); } } const result = parseTranscriptFile(resolvedPath!); const messages = text_only ? getTextMessages(result) : result.messages; return toolResult({ session_id: result.sessionId, cwd: result.cwd, format_version: result.formatVersion, line_count: result.lineCount, message_count: result.messages.length, text_message_count: getTextMessages(result).length, warnings: result.warnings, messages: messages.map((m) => ({ role: m.role, timestamp: m.timestamp, uuid: m.uuid, parentUuid: m.parentUuid, text: m.textContent, })), }); }); - parseTranscriptFile() is the canonical transcript parser that reads a JSONL file, parses each line, extracts messages with role/content/timestamp/uuid, and returns a ParseResult with format version detection and warnings.
export function parseTranscriptFile(filePath: string): ParseResult { const warnings: string[] = []; const messages: TranscriptMessage[] = []; let sessionId: string | null = null; let cwd: string | null = null; let raw: string; try { raw = readFileSync(filePath, "utf-8"); } catch (err) { return { messages: [], sessionId: null, cwd: null, formatVersion: "unknown", lineCount: 0, warnings: [`Failed to read file: ${err instanceof Error ? err.message : String(err)}`], }; } const lines = raw.split("\n").filter((l) => l.trim().length > 0); const lineCount = lines.length; const sample: unknown[] = []; for (const line of lines.slice(0, 5)) { try { sample.push(JSON.parse(line)); } catch { /* skip */ } } const formatVersion = detectFormatVersion(sample); if (formatVersion === "unknown") { warnings.push( "Unrecognized transcript format — expected fields type/message/sessionId not found. " + "The Claude Code JSONL format may have changed; update transcript-parser.ts.", ); } for (let i = 0; i < lines.length; i++) { let parsed: unknown; try { parsed = JSON.parse(lines[i]); } catch { warnings.push(`Line ${i + 1}: invalid JSON, skipped`); continue; } if (!parsed || typeof parsed !== "object") { warnings.push(`Line ${i + 1}: not an object, skipped`); continue; } const obj = parsed as Record<string, unknown>; if (!("type" in obj)) { warnings.push(`Line ${i + 1}: missing "type" field, skipped`); continue; } const type = obj["type"] as string; // Skip known non-message record types silently if (KNOWN_NON_MESSAGE_TYPES.has(type)) continue; // Skip unknown record types without warning — future format additions shouldn't be noisy if (!MESSAGE_TYPES.has(type)) continue; const msg = obj["message"] as Record<string, unknown> | undefined; // system records may be structural metadata (e.g. stop_hook_summary) with no message payload if (!msg || typeof msg !== "object") continue; const role = (msg["role"] as string) || type; if (!MESSAGE_TYPES.has(role)) continue; const content = normalizeContent(msg["content"] ?? []); const sid = (obj["sessionId"] as string) ?? ""; const cwdVal = (obj["cwd"] as string) ?? ""; if (!sessionId && sid) sessionId = sid; if (!cwd && cwdVal) cwd = cwdVal; messages.push({ role: role as "user" | "assistant" | "system", content, timestamp: (obj["timestamp"] as string) ?? "", sessionId: sid, cwd: cwdVal, uuid: (obj["uuid"] as string) ?? "", parentUuid: obj["parentUuid"] as string | undefined, model: obj["model"] as string | undefined, textContent: extractText(content), }); } return { messages, sessionId, cwd, formatVersion, lineCount, warnings }; } /** Returns only messages that have extractable text content (skips pure tool calls). */ export function getTextMessages(result: ParseResult): TranscriptMessage[] { return result.messages.filter((m) => m.textContent.length > 0); } - getTextMessages() filters parsed messages to only those with extractable text content (skips pure tool calls).
export function getTextMessages(result: ParseResult): TranscriptMessage[] { return result.messages.filter((m) => m.textContent.length > 0); }