From 39e437ea73ac8fb36f4b5d14e20df5c34b8aa9bd Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Thu, 26 Feb 2026 02:26:50 +0100 Subject: [PATCH] feat(mcp): add cursor_session_context tool Read Cursor agent session transcripts from ~/.cursor/projects// agent-transcripts/. Supports selecting a specific session by UUID or defaulting to the most recent one. Follows the same pattern as the existing claude_code_session_context tool. Co-Authored-By: Claude Opus 4.6 --- .../agent-memory/archgate-developer/MEMORY.md | 2 +- CONTRIBUTING.md | 3 +- README.md | 3 +- docs/03-technical-plan.md | 16 +- docs/06-cursor-integration-plan.md | 21 +- src/mcp/tools/cursor-session-context.ts | 218 ++++++++++++++++++ src/mcp/tools/index.ts | 2 + tests/mcp/tools.test.ts | 4 +- .../mcp/tools/cursor-session-context.test.ts | 35 +++ 9 files changed, 280 insertions(+), 24 deletions(-) create mode 100644 src/mcp/tools/cursor-session-context.ts create mode 100644 tests/mcp/tools/cursor-session-context.test.ts diff --git a/.claude/agent-memory/archgate-developer/MEMORY.md b/.claude/agent-memory/archgate-developer/MEMORY.md index a40f27cf..8cee1508 100644 --- a/.claude/agent-memory/archgate-developer/MEMORY.md +++ b/.claude/agent-memory/archgate-developer/MEMORY.md @@ -59,7 +59,7 @@ Skipping steps 2 or 3 is a workflow violation. The user should NEVER have to inv ## MCP Tools Structure -- MCP tools live in `src/mcp/tools/` — one file per tool (check, list-adrs, review-context, claude-code-session-context) +- MCP tools live in `src/mcp/tools/` — one file per tool (check, list-adrs, review-context, claude-code-session-context, cursor-session-context) - ADR CRUD moved to CLI: `archgate adr create`, `archgate adr update`, `archgate init` (no longer MCP tools) - `src/mcp/tools/index.ts` composes all registrations via `registerTools()` (contains real logic, not a barrel per ARCH-004) - `src/mcp/server.ts` imports from `./tools/index` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e1b9f770..72059f4c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -102,7 +102,8 @@ src/ ├── check.ts # check tool ├── list-adrs.ts # list_adrs tool ├── review-context.ts # review_context tool - └── claude-code-session-context.ts # claude_code_session_context tool + ├── claude-code-session-context.ts # claude_code_session_context tool + └── cursor-session-context.ts # cursor_session_context tool tests/ # Mirrors src/ structure .archgate/adrs/ # Self-governance ADRs ``` diff --git a/README.md b/README.md index 8fdca1ef..b39ad80e 100644 --- a/README.md +++ b/README.md @@ -183,7 +183,7 @@ Start the MCP server for AI agent integration. archgate mcp ``` -Exposes four tools to MCP-compatible clients (Claude Code, Cursor, etc.): +Exposes five tools to MCP-compatible clients (Claude Code, Cursor, etc.): | Tool | Description | | ----------------------------- | ----------------------------------------------------------------- | @@ -191,6 +191,7 @@ Exposes four tools to MCP-compatible clients (Claude Code, Cursor, etc.): | `list_adrs` | List all ADRs with metadata | | `review_context` | Get changed files grouped by domain with applicable ADR briefings | | `claude_code_session_context` | Read the current Claude Code session transcript | +| `cursor_session_context` | Read Cursor agent session transcripts | Also exposes `adr://{id}` resources for reading individual ADRs by ID. diff --git a/docs/03-technical-plan.md b/docs/03-technical-plan.md index cbeb0d86..b2daaeb9 100644 --- a/docs/03-technical-plan.md +++ b/docs/03-technical-plan.md @@ -108,7 +108,8 @@ archgate/cli/ │ │ ├── check.ts │ │ ├── list-adrs.ts │ │ ├── review-context.ts -│ │ └── claude-code-session-context.ts +│ │ ├── claude-code-session-context.ts +│ │ └── cursor-session-context.ts │ ├── formats/ # Zod schemas + parsers (single source of truth) │ │ ├── adr.ts # ADR frontmatter schema, parsing, validation │ │ └── rules.ts # Rule file schema + defineRules() @@ -264,12 +265,13 @@ archgate check ### Tools -| Tool | Description | Input | -| ----------------------------- | ------------------------------------------------------------------------------ | ----------------------- | -| `check` | Run ADR compliance checks | `adrId?`, `staged?` | -| `list_adrs` | List all active ADRs with metadata | `domain?` | -| `review_context` | Pre-compute review context: changed files grouped by domain with ADR briefings | `staged?`, `runChecks?` | -| `claude_code_session_context` | Read Claude Code session transcript for context recovery | `maxEntries?` | +| Tool | Description | Input | +| ----------------------------- | ------------------------------------------------------------------------------ | --------------------------- | +| `check` | Run ADR compliance checks | `adrId?`, `staged?` | +| `list_adrs` | List all active ADRs with metadata | `domain?` | +| `review_context` | Pre-compute review context: changed files grouped by domain with ADR briefings | `staged?`, `runChecks?` | +| `claude_code_session_context` | Read Claude Code session transcript for context recovery | `maxEntries?` | +| `cursor_session_context` | Read Cursor agent session transcripts | `maxEntries?`, `sessionId?` | ### Resources diff --git a/docs/06-cursor-integration-plan.md b/docs/06-cursor-integration-plan.md index 37466bca..e0e0bab6 100644 --- a/docs/06-cursor-integration-plan.md +++ b/docs/06-cursor-integration-plan.md @@ -216,18 +216,15 @@ Installable via `/add-plugin` in Cursor. ## MCP Tool Compatibility -The `session_context` tool has been renamed to `claude_code_session_context` to signal that it reads Claude Code session transcripts (from `~/.claude/projects/`). This tool is Claude Code-specific and will not function in Cursor. - -The remaining three tools are fully editor-agnostic: - -| Tool | Editor-agnostic? | Notes | -| ----------------------------- | :--------------: | --------------------------------------- | -| `check` | Yes | Runs ADR compliance checks | -| `list_adrs` | Yes | Lists ADRs with metadata | -| `review_context` | Yes | Changed files + ADR briefings | -| `claude_code_session_context` | No | Reads `~/.claude/projects/` JSONL files | - -A future `cursor_session_context` tool could be added if Cursor exposes session transcript data. +The `session_context` tool has been renamed to `claude_code_session_context` to signal that it reads Claude Code session transcripts (from `~/.claude/projects/`). The `cursor_session_context` tool reads Cursor agent transcripts (from `~/.cursor/projects/`). + +| Tool | Editor-agnostic? | Notes | +| ----------------------------- | :--------------: | --------------------------------------------------- | +| `check` | Yes | Runs ADR compliance checks | +| `list_adrs` | Yes | Lists ADRs with metadata | +| `review_context` | Yes | Changed files + ADR briefings | +| `claude_code_session_context` | No | Reads `~/.claude/projects/` JSONL files | +| `cursor_session_context` | No | Reads `~/.cursor/projects/` agent-transcripts JSONL | --- diff --git a/src/mcp/tools/cursor-session-context.ts b/src/mcp/tools/cursor-session-context.ts new file mode 100644 index 00000000..7241a975 --- /dev/null +++ b/src/mcp/tools/cursor-session-context.ts @@ -0,0 +1,218 @@ +import { z } from "zod"; +import { readdirSync, statSync } from "node:fs"; +import { join, basename } from "node:path"; +import { homedir } from "node:os"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +/** Encode a project root path into the Cursor projects directory name. */ +function encodeProjectPath(projectRoot: string): string { + return projectRoot.replaceAll("/", "-"); +} + +/** Roles we care about from the Cursor transcript. */ +const RELEVANT_ROLES = new Set(["user", "assistant"]); + +interface ContentBlock { + type: string; + text?: string; + [key: string]: unknown; +} + +interface TranscriptEntry { + role: string; + message?: { + content?: unknown; + }; + [key: string]: unknown; +} + +interface SessionSummary { + sessionId: string; + sessionFile: string; + totalEntries: number; + relevantEntries: number; + transcript: Array<{ + role: string; + contentPreview: string; + }>; +} + +/** Extract a concise content preview from a transcript entry. */ +function getContentPreview(entry: TranscriptEntry): string { + const content = entry.message?.content; + if (typeof content === "string") { + return content.length > 500 ? content.slice(0, 500) + "..." : content; + } + if (Array.isArray(content)) { + const parts: string[] = []; + for (const block of content as ContentBlock[]) { + if (typeof block !== "object" || block === null) continue; + if (block.type === "text" && typeof block.text === "string") { + const text = block.text; + parts.push(text.length > 300 ? text.slice(0, 300) + "..." : text); + } else if (block.type === "tool_use") { + parts.push(`[tool_use: ${block.name}]`); + } else if (block.type === "tool_result") { + parts.push( + `[tool_result: ${String(block.tool_use_id ?? "").slice(0, 20)}]` + ); + } + } + return parts.join(" | "); + } + return ""; +} + +export function registerCursorSessionContextTool( + server: McpServer, + projectRoot: string | null +) { + server.registerTool( + "cursor_session_context", + { + description: + "Read Cursor agent session transcripts for the project. Returns filtered entries (user + assistant messages) from Cursor's agent-transcripts JSONL files. Use this to access context from Cursor agent conversations.", + inputSchema: { + maxEntries: z + .number() + .optional() + .describe( + "Maximum number of relevant entries to return (default: 200). Returns the most recent entries." + ), + sessionId: z + .string() + .optional() + .describe( + "Specific session UUID to read. If omitted, reads the most recent session." + ), + }, + }, + async ({ maxEntries, sessionId }) => { + const limit = maxEntries ?? 200; + const encodedPath = encodeProjectPath(projectRoot ?? process.cwd()); + const transcriptsDir = join( + homedir(), + ".cursor", + "projects", + encodedPath, + "agent-transcripts" + ); + + // Find all session directories sorted by modification time (most recent first) + let sessionDirs: Array<{ name: string; mtime: number }>; + try { + sessionDirs = readdirSync(transcriptsDir) + .map((name) => { + const fullPath = join(transcriptsDir, name); + try { + const stat = statSync(fullPath); + return stat.isDirectory() ? { name, mtime: stat.mtimeMs } : null; + } catch { + return null; + } + }) + .filter((d): d is { name: string; mtime: number } => d !== null) + .sort((a, b) => b.mtime - a.mtime); + } catch { + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ + error: "No Cursor agent-transcripts directory found", + path: transcriptsDir, + }), + }, + ], + }; + } + + if (sessionDirs.length === 0) { + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ + error: "No session directories found", + path: transcriptsDir, + }), + }, + ], + }; + } + + // Pick the target session + const targetDir = sessionId + ? sessionDirs.find((d) => d.name === sessionId) + : sessionDirs[0]; + + if (!targetDir) { + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ + error: `Session not found: ${sessionId}`, + available: sessionDirs.map((d) => d.name), + }), + }, + ], + }; + } + + // Read the JSONL file inside the session directory (/.jsonl) + const sessionFile = join( + transcriptsDir, + targetDir.name, + `${targetDir.name}.jsonl` + ); + + let entries: TranscriptEntry[]; + try { + const raw = await Bun.file(sessionFile).text(); + entries = Bun.JSONL.parse(raw) as TranscriptEntry[]; + } catch { + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ + error: "Failed to read session file", + file: sessionFile, + }), + }, + ], + }; + } + + // Filter for relevant roles + const relevant: Array<{ role: string; contentPreview: string }> = []; + + for (const entry of entries) { + if (!RELEVANT_ROLES.has(entry.role)) continue; + relevant.push({ + role: entry.role, + contentPreview: getContentPreview(entry), + }); + } + + // Return the most recent entries up to the limit + const trimmed = + relevant.length > limit ? relevant.slice(-limit) : relevant; + + const summary: SessionSummary = { + sessionId: targetDir.name, + sessionFile: basename(sessionFile), + totalEntries: entries.length, + relevantEntries: relevant.length, + transcript: trimmed, + }; + + return { + content: [ + { type: "text" as const, text: JSON.stringify(summary, null, 2) }, + ], + }; + } + ); +} diff --git a/src/mcp/tools/index.ts b/src/mcp/tools/index.ts index 7333065e..9702bb1c 100644 --- a/src/mcp/tools/index.ts +++ b/src/mcp/tools/index.ts @@ -3,10 +3,12 @@ import { registerCheckTool } from "./check"; import { registerListAdrsTool } from "./list-adrs"; import { registerReviewContextTool } from "./review-context"; import { registerClaudeCodeSessionContextTool } from "./claude-code-session-context"; +import { registerCursorSessionContextTool } from "./cursor-session-context"; export function registerTools(server: McpServer, projectRoot: string | null) { registerCheckTool(server, projectRoot); registerListAdrsTool(server, projectRoot); registerReviewContextTool(server, projectRoot); registerClaudeCodeSessionContextTool(server, projectRoot); + registerCursorSessionContextTool(server, projectRoot); } diff --git a/tests/mcp/tools.test.ts b/tests/mcp/tools.test.ts index ab766e89..54aadde3 100644 --- a/tests/mcp/tools.test.ts +++ b/tests/mcp/tools.test.ts @@ -27,8 +27,8 @@ describe("registerTools", () => { test("registers all expected tools", () => { const registerSpy = spyOn(server, "registerTool"); registerTools(server, tempDir); - // The tools module registers 4 tools: check, list_adrs, review_context, claude_code_session_context - expect(registerSpy).toHaveBeenCalledTimes(4); + // The tools module registers 5 tools: check, list_adrs, review_context, claude_code_session_context, cursor_session_context + expect(registerSpy).toHaveBeenCalledTimes(5); registerSpy.mockRestore(); }); diff --git a/tests/mcp/tools/cursor-session-context.test.ts b/tests/mcp/tools/cursor-session-context.test.ts new file mode 100644 index 00000000..67522b1b --- /dev/null +++ b/tests/mcp/tools/cursor-session-context.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"; +import { mkdtempSync, rmSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerCursorSessionContextTool } from "../../../src/mcp/tools/cursor-session-context"; + +describe("registerCursorSessionContextTool", () => { + let tempDir: string; + let server: McpServer; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-mcp-cursor-session-test-")); + mkdirSync(join(tempDir, ".archgate", "adrs"), { recursive: true }); + server = new McpServer({ name: "test", version: "0.0.0" }); + }); + + afterEach(async () => { + await server.close(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + test("does not throw when registering", () => { + expect(() => + registerCursorSessionContextTool(server, tempDir) + ).not.toThrow(); + }); + + test("registers exactly one tool", () => { + const registerSpy = spyOn(server, "registerTool"); + registerCursorSessionContextTool(server, tempDir); + expect(registerSpy).toHaveBeenCalledTimes(1); + registerSpy.mockRestore(); + }); +});