Skip to main content
Glama

Read Transcript

graph_read_transcript
Read-only

Parse 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

TableJSON Schema
NameRequiredDescriptionDefault
session_idNoSession UUID (filename without .jsonl). Searches ~/.claude/projects/ for a match.
file_pathNoAbsolute path to the .jsonl file. Takes precedence over session_id.
text_onlyNoIf true (default), return only messages that have extractable text content.

Implementation Reference

  • 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);
    }
  • 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 },
Behavior4/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Annotations already indicate readOnlyHint=true, and the description adds context about the canonical parser and normalized message extraction. No disclosure of potential side effects beyond reading, which is consistent with read-only intent.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Two concise sentences with no unnecessary words. Front-loaded with purpose, followed by usage guidance. Every sentence adds value.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a simple read-only tool with comprehensive schema and annotations, the description fully captures what the tool does, how it works, and when to use it. No gaps given the context.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters3/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Input schema provides 100% description coverage for all three parameters. The tool description does not add additional parameter meaning beyond what the schema already offers, so baseline score applies.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

Clearly states the tool reads and parses a Claude Code JSONL transcript file, returning normalized messages. Distinguishes from sibling tools which focus on different graph operations, so purpose is specific and unambiguous.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines5/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

Explicitly advises using this tool instead of reading raw JSONL directly, and explains the benefit (only this tool needs updating if format changes). Provides clear context for when to use it.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Install Server

Other Tools

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/stevepridemore/graph-memory'

If you have feedback or need assistance with the MCP directory API, please join our Discord server