Read Transcript
graph_read_transcriptParse Claude Code JSONL transcript files into normalized messages with extracted text content. Use instead of raw JSONL for format compatibility.
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:1538-1605 (registration)Registers the graph_read_transcript tool with the MCP server. Handles resolving either a session_id (searched across ~/.claude/projects/ subdirectories) or file_path, then delegates to parseTranscriptFile for parsing and returns normalized messages.
// ─── 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, })), }); }); - Core parsing logic for Claude Code JSONL transcript files. Reads the file, detects the format version, iterates each JSON line, and extracts standardized TranscriptMessage objects with role, content, timestamp, metadata, and extracted text content.
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); } - Helper used by graph_read_transcript to filter messages to only those with extractable text content, skipping pure tool call messages.
/** 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); } - src/mcp-server/index.ts:1546-1561 (schema)Input schema for graph_read_transcript: accepts session_id (searched in project dirs), file_path (absolute path, takes precedence), and text_only flag (defaults to true) to filter messages with text content.
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 },