-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add file-based session persistence #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -9,11 +9,10 @@ import { | |||||||||||||||||
| type ServerResult, | ||||||||||||||||||
| } from '@modelcontextprotocol/sdk/types.js'; | ||||||||||||||||||
| import { spawn } from 'node:child_process'; | ||||||||||||||||||
| import { existsSync } from 'node:fs'; | ||||||||||||||||||
| import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; | ||||||||||||||||||
| import { homedir } from 'node:os'; | ||||||||||||||||||
| import { join, resolve as pathResolve } from 'node:path'; | ||||||||||||||||||
| import * as path from 'path'; | ||||||||||||||||||
| import { readFileSync } from 'node:fs'; | ||||||||||||||||||
|
|
||||||||||||||||||
| // Server version - update this when releasing new versions | ||||||||||||||||||
| const SERVER_VERSION = "1.10.12"; | ||||||||||||||||||
|
|
@@ -75,24 +74,132 @@ interface ClaudeCliResponse { | |||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| /** | ||||||||||||||||||
| * Session mapping: parentSessionId -> claudeCliSessionId | ||||||||||||||||||
| * Limited to MAX_SESSIONS to prevent memory leaks in long-running servers | ||||||||||||||||||
| * Session entry with timestamp for expiration | ||||||||||||||||||
| */ | ||||||||||||||||||
| interface SessionEntry { | ||||||||||||||||||
| claudeSessionId: string; | ||||||||||||||||||
| updatedAt: string; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| /** | ||||||||||||||||||
| * Session storage configuration | ||||||||||||||||||
| */ | ||||||||||||||||||
| const MAX_SESSIONS = 1000; | ||||||||||||||||||
| const sessionMap = new Map<string, string>(); | ||||||||||||||||||
| const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours | ||||||||||||||||||
| const SESSION_DIR = join(homedir(), '.config', 'claude-code-mcp'); | ||||||||||||||||||
| const SESSION_FILE = join(SESSION_DIR, 'sessions.json'); | ||||||||||||||||||
|
|
||||||||||||||||||
| /** | ||||||||||||||||||
| * In-memory session cache, loaded from file on startup | ||||||||||||||||||
| */ | ||||||||||||||||||
| let sessionMap: Map<string, SessionEntry> = new Map(); | ||||||||||||||||||
|
|
||||||||||||||||||
| /** | ||||||||||||||||||
| * Ensure the session directory exists | ||||||||||||||||||
| */ | ||||||||||||||||||
| function ensureSessionDir(): void { | ||||||||||||||||||
| if (!existsSync(SESSION_DIR)) { | ||||||||||||||||||
| mkdirSync(SESSION_DIR, { recursive: true }); | ||||||||||||||||||
| debugLog(`[Debug] Created session directory: ${SESSION_DIR}`); | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| /** | ||||||||||||||||||
| * Load sessions from file into memory | ||||||||||||||||||
| */ | ||||||||||||||||||
| function loadSessions(): void { | ||||||||||||||||||
| try { | ||||||||||||||||||
| if (existsSync(SESSION_FILE)) { | ||||||||||||||||||
| const data = readFileSync(SESSION_FILE, 'utf-8'); | ||||||||||||||||||
| const parsed = JSON.parse(data); | ||||||||||||||||||
| sessionMap = new Map(Object.entries(parsed)); | ||||||||||||||||||
| debugLog(`[Debug] Loaded ${sessionMap.size} sessions from ${SESSION_FILE}`); | ||||||||||||||||||
|
|
||||||||||||||||||
| // Clean up expired sessions on load | ||||||||||||||||||
| cleanExpiredSessions(); | ||||||||||||||||||
| } | ||||||||||||||||||
| } catch (e) { | ||||||||||||||||||
| debugLog('[Debug] Failed to load sessions file, starting fresh:', e); | ||||||||||||||||||
| sessionMap = new Map(); | ||||||||||||||||||
| } | ||||||||||||||||||
|
Comment on lines
+121
to
+124
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Failures in loading the session file are logged using
Suggested change
|
||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| /** | ||||||||||||||||||
| * Save sessions from memory to file | ||||||||||||||||||
| */ | ||||||||||||||||||
| function saveSessions(): void { | ||||||||||||||||||
| try { | ||||||||||||||||||
| ensureSessionDir(); | ||||||||||||||||||
| const data = Object.fromEntries(sessionMap); | ||||||||||||||||||
| writeFileSync(SESSION_FILE, JSON.stringify(data, null, 2)); | ||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since 🤖 Was this useful? React with 👍 or 👎 |
||||||||||||||||||
| debugLog(`[Debug] Saved ${sessionMap.size} sessions to ${SESSION_FILE}`); | ||||||||||||||||||
| } catch (e) { | ||||||||||||||||||
| debugLog('[Debug] Failed to save sessions file:', e); | ||||||||||||||||||
| } | ||||||||||||||||||
|
Comment on lines
+136
to
+138
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Failures in saving the session file are logged using
Suggested change
|
||||||||||||||||||
| } | ||||||||||||||||||
|
Comment on lines
+110
to
+139
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The current implementation of loading from and saving to
This will lead to session data loss. To prevent this, you should implement a file locking mechanism around the read-modify-write cycle to ensure atomic updates to the session file. Libraries like
Comment on lines
+130
to
+139
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The use of All file I/O should be performed asynchronously using |
||||||||||||||||||
|
|
||||||||||||||||||
| /** | ||||||||||||||||||
| * Store a session mapping with LRU-style eviction | ||||||||||||||||||
| * Remove sessions older than TTL | ||||||||||||||||||
| */ | ||||||||||||||||||
| function cleanExpiredSessions(): void { | ||||||||||||||||||
| const now = Date.now(); | ||||||||||||||||||
| let cleaned = 0; | ||||||||||||||||||
|
|
||||||||||||||||||
| for (const [parentId, entry] of sessionMap) { | ||||||||||||||||||
| const age = now - new Date(entry.updatedAt).getTime(); | ||||||||||||||||||
| if (age > SESSION_TTL_MS) { | ||||||||||||||||||
| sessionMap.delete(parentId); | ||||||||||||||||||
| cleaned++; | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| if (cleaned > 0) { | ||||||||||||||||||
| debugLog(`[Debug] Cleaned ${cleaned} expired sessions`); | ||||||||||||||||||
| saveSessions(); | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| /** | ||||||||||||||||||
| * Get a session mapping | ||||||||||||||||||
| */ | ||||||||||||||||||
| function getSessionMapping(parentId: string): string | undefined { | ||||||||||||||||||
| const entry = sessionMap.get(parentId); | ||||||||||||||||||
| if (entry) { | ||||||||||||||||||
| // Check if expired | ||||||||||||||||||
| const age = Date.now() - new Date(entry.updatedAt).getTime(); | ||||||||||||||||||
| if (age > SESSION_TTL_MS) { | ||||||||||||||||||
| sessionMap.delete(parentId); | ||||||||||||||||||
| saveSessions(); | ||||||||||||||||||
| return undefined; | ||||||||||||||||||
| } | ||||||||||||||||||
| return entry.claudeSessionId; | ||||||||||||||||||
| } | ||||||||||||||||||
| return undefined; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| /** | ||||||||||||||||||
| * Store a session mapping with LRU-style eviction and file persistence | ||||||||||||||||||
| */ | ||||||||||||||||||
|
Comment on lines
+181
to
182
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The comment describes "LRU-style eviction", but the implementation is actually FIFO (First-In, First-Out), as it removes the oldest entry by insertion order (
Suggested change
|
||||||||||||||||||
| function setSessionMapping(parentId: string, claudeId: string): void { | ||||||||||||||||||
| // LRU eviction if at capacity | ||||||||||||||||||
| if (sessionMap.size >= MAX_SESSIONS) { | ||||||||||||||||||
| // Remove oldest entry (first inserted) | ||||||||||||||||||
| const firstKey = sessionMap.keys().next().value; | ||||||||||||||||||
| if (firstKey) sessionMap.delete(firstKey); | ||||||||||||||||||
| } | ||||||||||||||||||
| sessionMap.set(parentId, claudeId); | ||||||||||||||||||
|
|
||||||||||||||||||
| sessionMap.set(parentId, { | ||||||||||||||||||
| claudeSessionId: claudeId, | ||||||||||||||||||
| updatedAt: new Date().toISOString() | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| // Persist to file | ||||||||||||||||||
| saveSessions(); | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| // Load sessions on module initialization | ||||||||||||||||||
| loadSessions(); | ||||||||||||||||||
|
|
||||||||||||||||||
| /** | ||||||||||||||||||
| * Determines the Claude CLI command/path based on the following precedence: | ||||||||||||||||||
| * 1. An absolute path specified in the `CLAUDE_CLI_NAME` environment variable. | ||||||||||||||||||
|
|
@@ -432,7 +539,7 @@ export class ClaudeCodeServer { | |||||||||||||||||
| const claudeProcessArgs: string[] = ['--dangerously-skip-permissions']; | ||||||||||||||||||
|
|
||||||||||||||||||
| if (!stateless && parentSessionId) { | ||||||||||||||||||
| const existingClaudeSessionId = sessionMap.get(parentSessionId); | ||||||||||||||||||
| const existingClaudeSessionId = getSessionMapping(parentSessionId); | ||||||||||||||||||
|
|
||||||||||||||||||
| if (existingClaudeSessionId) { | ||||||||||||||||||
| debugLog(`[Debug] Resuming Claude CLI session: ${existingClaudeSessionId} for parent session: ${parentSessionId}`); | ||||||||||||||||||
|
|
||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because
sessions.jsonis effectively external input,Object.entries(parsed)can populatesessionMapwith values missing a validupdatedAt/claudeSessionId; thennew Date(entry.updatedAt).getTime()becomesNaNand TTL expiry won’t work (or resumes can silently fail). Consider validating/coercing loaded entries before assigning tosessionMap.🤖 Was this useful? React with 👍 or 👎