Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/agent-memory/archgate-developer/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,14 +183,15 @@ 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 |
| ----------------------------- | ----------------------------------------------------------------- |
| `check` | Run ADR compliance checks, optionally on staged files only |
| `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.

Expand Down
16 changes: 9 additions & 7 deletions docs/03-technical-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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

Expand Down
21 changes: 9 additions & 12 deletions docs/06-cursor-integration-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---

Expand Down
218 changes: 218 additions & 0 deletions src/mcp/tools/cursor-session-context.ts
Original file line number Diff line number Diff line change
@@ -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 (<uuid>/<uuid>.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) },
],
};
}
);
}
2 changes: 2 additions & 0 deletions src/mcp/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
4 changes: 2 additions & 2 deletions tests/mcp/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down
35 changes: 35 additions & 0 deletions tests/mcp/tools/cursor-session-context.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});