mcp: add daemon data root layout

This commit is contained in:
shum committed 2026-10-24 13:33:26 +00:00
1 parent d497c90d79
commit 5a4c6e4b83
2 files changed
+175 -17

No files matched your search

+85 -8
View File
@@ -1,8 +1,76 @@
import {randomBytes} from "crypto"
import {chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync} from "fs"
import {join} from "path"
import {createHash, randomBytes} from "crypto"
import {chmodSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, statSync, truncateSync, unlinkSync, writeFileSync} from "fs"
import {homedir, tmpdir} from "os"
import {join, resolve} from "path"
import {ExitError} from "./log"
const sessionIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
// sockaddr_un.sun_path holds 104 bytes on macOS (108 on Linux), including the terminating NUL
const maxSocketPath = 103
const maxLogBytes = 10 * 1_048_576
export interface SessionFile {
number: number
name: string
project: string
created: string
}
export class LockedError extends ExitError {}
export function dataRoot(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): string {
const given = env.SIMPLEX_MCP_HOME
return given ? resolve(given) : join(home, ".simplex-mcp")
}
export function hubFolder(root: string): string {
return join(root, "_hub")
}
export function isSessionId(id: string): boolean {
return sessionIdPattern.test(id)
}
export function sessionFolder(root: string, id: string): string {
if (!isSessionId(id)) throw new ExitError(`Not a Claude Code session ID: ${id}`, 2)
return join(sessionsFolder(root), id.toLowerCase())
}
export function readSessionFile(folder: string): SessionFile | undefined {
const file = join(folder, "session.json")
return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : undefined
}
export function writeSessionFile(folder: string, session: SessionFile): void {
writePrivateFile(join(folder, "session.json"), `${JSON.stringify(session)}\n`)
}
export function sessionNumbers(root: string): number[] {
const folder = sessionsFolder(root)
if (!existsSync(folder)) return []
return readdirSync(folder).flatMap(name => {
const session = isSessionId(name) ? readSessionFile(join(folder, name)) : undefined
return session === undefined ? [] : [session.number]
})
}
export function socketPath(root: string, platform: NodeJS.Platform = process.platform): string {
const hash = createHash("sha256").update(root).digest("hex").slice(0, 16)
if (platform === "win32") return `\\\\.\\pipe\\simplex-mcp-${hash}`
const path = join(root, "daemon.sock")
return Buffer.byteLength(path) <= maxSocketPath ? path : join(tmpdir(), `simplex-mcp-${hash}.sock`)
}
export function logFile(root: string): string {
return join(root, "daemon.log")
}
export function openLog(root: string): number {
const file = logFile(root)
if (existsSync(file) && statSync(file).size > maxLogBytes) truncateSync(file)
return openSync(file, "a", 0o600)
}
export function createDataFolder(dir: string): void {
mkdirSync(dir, {recursive: true, mode: 0o700})
if (process.platform !== "win32") chmodSync(dir, 0o700)
@@ -21,12 +89,21 @@ export function releaseLock(dir: string): void {
unlinkSync(join(dir, "lock"))
}
export function serverToken(dir: string, renew: boolean): string {
export function serverToken(dir: string): string {
const file = join(dir, "token")
if (!renew && existsSync(file)) return readFileSync(file, "utf8").trim()
const token = randomBytes(32).toString("base64url")
writePrivateFile(file, `${token}\n`)
return token
try {
writeFileSync(file, `${token}\n`, {flag: "wx", mode: 0o600})
return token
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error
if (process.platform !== "win32") chmodSync(file, 0o600)
return readFileSync(file, "utf8").trim()
}
}
function sessionsFolder(root: string): string {
return join(root, "_sessions")
}
function writePrivateFile(file: string, text: string): void {
@@ -35,7 +112,7 @@ function writePrivateFile(file: string, text: string): void {
}
function lockedError(dir: string, file: string, pid: number | undefined): ExitError {
return new ExitError(`Another server uses ${dir} (PID ${pid}). If no server runs, remove ${file}.`, 1)
return new LockedError(`Another daemon uses ${dir} (PID ${pid}). If none runs, remove ${file}.`, 1)
}
function createLock(file: string): boolean {
@@ -1,10 +1,12 @@
import {spawnSync} from "child_process"
import {chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync} from "fs"
import {chmodSync, closeSync, existsSync, fstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync} from "fs"
import {tmpdir} from "os"
import {join} from "path"
import {acquireLock, createDataFolder, releaseLock, serverToken} from "../../src/files"
import {LockedError, acquireLock, createDataFolder, dataRoot, hubFolder, logFile, openLog, readSessionFile, releaseLock, serverToken, sessionFolder, sessionNumbers, socketPath, writeSessionFile} from "../../src/files"
import {ExitError} from "../../src/log"
const id = "34B7AEBF-E707-4D6F-ADD0-ECDFB4FFE281"
let dir: string
beforeEach(() => {
@@ -26,7 +28,10 @@ describe("lock", () => {
it("refuses a lock of a running process", () => {
const runningPid = process.ppid
writeFileSync(join(dir, "lock"), `${runningPid}\n`)
expect(() => acquireLock(dir)).toThrow(new ExitError(`Another server uses ${dir} (PID ${runningPid}). If no server runs, remove ${join(dir, "lock")}.`, 1))
const locked = new LockedError(`Another daemon uses ${dir} (PID ${runningPid}). If none runs, remove ${join(dir, "lock")}.`, 1)
expect(() => acquireLock(dir)).toThrow(locked)
expect(() => acquireLock(dir)).toThrow(LockedError)
expect(locked).toBeInstanceOf(ExitError)
})
it("replaces a lock of a stopped process or of this process", () => {
@@ -50,19 +55,95 @@ describe("data folder", () => {
expect(statSync(dir).mode & 0o777).toBe(0o700)
})
it("keeps the token until renewed", () => {
const token = serverToken(dir, false)
it("keeps the token", () => {
const token = serverToken(dir)
expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/)
expect(statSync(join(dir, "token")).mode & 0o777).toBe(0o600)
expect(serverToken(dir, false)).toBe(token)
expect(serverToken(dir, true)).not.toBe(token)
expect(serverToken(dir)).toBe(token)
})
it("makes the token private again when it exists", () => {
const file = join(dir, "token")
writeFileSync(file, "{}\n")
writeFileSync(file, "existing\n")
chmodSync(file, 0o644)
serverToken(dir, true)
expect(serverToken(dir)).toBe("existing")
expect(statSync(file).mode & 0o777).toBe(0o600)
})
})
describe("data root", () => {
it("is ~/.simplex-mcp unless SIMPLEX_MCP_HOME is set", () => {
expect(dataRoot({}, "/home/u")).toBe("/home/u/.simplex-mcp")
expect(dataRoot({SIMPLEX_MCP_HOME: "/data/mcp"}, "/home/u")).toBe("/data/mcp")
expect(dataRoot({SIMPLEX_MCP_HOME: ""}, "/home/u")).toBe("/home/u/.simplex-mcp")
})
it("keeps the hub and the sessions in folders 0.1 agent names can't take", () => {
expect(hubFolder("/r")).toBe("/r/_hub")
expect(sessionFolder("/r", id)).toBe("/r/_sessions/34b7aebf-e707-4d6f-add0-ecdfb4ffe281")
})
it("refuses session IDs that are not UUIDs", () => {
for (const bad of ["", "../x", "34b7aebf", `${id}/..`]) {
expect(() => sessionFolder("/r", bad)).toThrow(new ExitError(`Not a Claude Code session ID: ${bad}`, 2))
}
})
})
describe("session files", () => {
it("round-trip privately and list their numbers", () => {
const root = mkdtempSync(join(tmpdir(), "simplex-mcp-files-"))
const folder = sessionFolder(root, id)
mkdirSync(folder, {recursive: true})
expect(readSessionFile(folder)).toBeUndefined()
const file = {number: 3, name: "simplex-chat-6", project: "/p/simplex-chat-6", created: "2026-10-24T10:00:00.000Z"}
writeSessionFile(folder, file)
expect(readSessionFile(folder)).toEqual(file)
expect(statSync(join(folder, "session.json")).mode & 0o777).toBe(0o600)
mkdirSync(join(root, "_sessions", "00000000-0000-0000-0000-000000000000"))
expect(sessionNumbers(root)).toEqual([3])
expect(sessionNumbers(join(root, "missing"))).toEqual([])
rmSync(root, {recursive: true, force: true})
})
})
describe("socket path", () => {
it("is in the root, in tmpdir when too long, and a named pipe on Windows", () => {
expect(socketPath("/home/u/.simplex-mcp", "linux")).toBe("/home/u/.simplex-mcp/daemon.sock")
const long = `/home/${"u".repeat(120)}/.simplex-mcp`
expect(socketPath(long, "darwin")).toMatch(new RegExp(`^${tmpdir().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/simplex-mcp-[0-9a-f]{16}\\.sock$`))
expect(socketPath("C:\\Users\\u\\.simplex-mcp", "win32")).toMatch(/^\\\\\.\\pipe\\simplex-mcp-[0-9a-f]{16}$/)
expect(socketPath("/a", "win32")).not.toBe(socketPath("/b", "win32"))
})
})
describe("token creation", () => {
it("is exclusive", () => {
const root = mkdtempSync(join(tmpdir(), "simplex-mcp-token-"))
const token = serverToken(root)
expect(serverToken(root)).toBe(token)
expect(readFileSync(join(root, "token"), "utf8")).toBe(`${token}\n`)
rmSync(root, {recursive: true, force: true})
})
})
describe("daemon log", () => {
it("is opened for append and truncated when over 10 MB", () => {
const root = mkdtempSync(join(tmpdir(), "simplex-mcp-log-"))
writeFileSync(logFile(root), "x".repeat(10 * 1_048_576 + 1))
const fd = openLog(root)
expect(fstatSync(fd).size).toBe(0)
closeSync(fd)
writeFileSync(logFile(root), "kept\n")
closeSync(openLog(root))
expect(readFileSync(logFile(root), "utf8")).toBe("kept\n")
rmSync(root, {recursive: true, force: true})
})
it("is created private", () => {
const root = mkdtempSync(join(tmpdir(), "simplex-mcp-log-"))
closeSync(openLog(root))
expect(statSync(logFile(root)).mode & 0o777).toBe(0o600)
rmSync(root, {recursive: true, force: true})
})
})