/** * Shared storage path resolution, validation, and atomic file I/O. * Used by OAuth file persistence and the remote server's /api/storage routes. */ import * as path from "node:path"; import * as fs from "node:fs/promises"; import { readFile, writeFile } from "atomically"; // Re-export the pure id validator so existing `store-io` importers are // unaffected; the implementation lives in the Node-free `store-id` module so // isomorphic code can reuse it without pulling Node deps into the browser. export { validateStoreId } from "./store-id.js"; // Likewise re-export the pure JSON (de)serializers from the Node-free // `store-serialize` module so existing `store-io` importers are unaffected. export { serializeStore, parseStore } from "./store-serialize.js"; /** * Default storage directory (~/.mcp-inspector/storage or %USERPROFILE%\.mcp-inspector\storage on Windows). */ export function getDefaultStorageDir(): string { const homeDir = process.env.HOME || process.env.USERPROFILE || "."; return path.join(homeDir, ".mcp-inspector", "storage"); } /** * Default path for the user's server list file * (~/.mcp-inspector/mcp.json or %USERPROFILE%\.mcp-inspector\mcp.json on Windows). */ export function getDefaultMcpConfigPath(): string { const homeDir = process.env.HOME || process.env.USERPROFILE || "."; return path.join(homeDir, ".mcp-inspector", "mcp.json"); } /** * Path for a store ID under the given storage directory. * Callers must pass a validated storeId. */ export function getStoreFilePath(storageDir: string, storeId: string): string { return path.join(storageDir, `${storeId}.json`); } /** * Read store file atomically. Returns null if the file does not exist (ENOENT). * @throws on other read errors or parse errors (caller may use parseStore on the string). */ export async function readStoreFile(filePath: string): Promise { try { const data = await readFile(filePath, { encoding: "utf-8" }); return data; } catch (error) { const err = error as NodeJS.ErrnoException; if (err.code === "ENOENT") { return null; } throw error; } } /** * In-flight writeStoreFile() promises, keyed by resolved path. Lets callers * await persistence completion via flushStoreFileWrites() instead of polling * the file. Entries are removed once their write settles. */ const pendingWrites = new Map>(); /** * Write store file atomically (temp file + rename). Ensures parent directory exists. * Uses mode 0o600 for the file. * * The returned promise is also tracked per path so flushStoreFileWrites() can * await it. Writes to the same path are chained so a flush awaits every queued * write (and a later write's completion implies all earlier ones settled). */ export async function writeStoreFile( filePath: string, data: string, ): Promise { const key = path.resolve(filePath); const run = async (): Promise => { const dir = path.dirname(filePath); await fs.mkdir(dir, { recursive: true }); await writeFile(filePath, data, { encoding: "utf-8", mode: 0o600, }); }; const prior = pendingWrites.get(key); const tracked = (prior ?? Promise.resolve()).catch(() => {}).then(run); pendingWrites.set(key, tracked); try { await tracked; } finally { if (pendingWrites.get(key) === tracked) { pendingWrites.delete(key); } } } /** * Await pending writeStoreFile() writes — those for `filePath` if given, else * all of them. Use in tests after triggering persistence instead of polling * the file, and for graceful shutdown. Resolves immediately when nothing is in flight. */ export async function flushStoreFileWrites(filePath?: string): Promise { if (filePath !== undefined) { await pendingWrites.get(path.resolve(filePath)); return; } await Promise.all(pendingWrites.values()); } /** * Delete store file. Ignores ENOENT (already deleted). */ export async function deleteStoreFile(filePath: string): Promise { try { await fs.unlink(filePath); } catch (error) { const err = error as NodeJS.ErrnoException; if (err.code !== "ENOENT") { throw error; } } }