fix: harden concurrent config writes

This commit is contained in:
Peter Steinberger 2026-05-14 16:32:37 +01:00
parent 8d962fbd79
commit 23565e2166
No known key found for this signature in database
19 changed files with 849 additions and 150 deletions

1
.gitignore vendored
View File

@ -1,4 +1,5 @@
node_modules
.pnpm-store
.DS_Store
dist
dist-bun

View File

@ -10,6 +10,8 @@
### CLI
- Add `mcporter serve`, exposing daemon-managed keep-alive servers as one MCP bridge with readable `server__tool` names for stdio and Streamable HTTP clients. (PR #172, thanks @zm2231)
- Prefer MCP `structuredContent` nested inside JSON-RPC result envelopes so `mcporter call --output json` stays parseable for dual text/structured tool responses. (Issue #168, thanks @mar-zh)
- Serialize read-modify-write config and OAuth vault updates, and write JSON/cache metadata atomically to avoid lost entries under parallel invocations. (Issue #167, thanks @alexminza)
- Patch `chrome-devtools-mcp --autoConnect` launches at runtime so `mcporter call chrome-devtools.list_pages` can keep using a logged-in Chrome profile while upstream DevTools-window detection can hang on busy profiles.
### OAuth

View File

@ -3,6 +3,7 @@ import path from 'node:path';
import type { CommandSpec, ServerDefinition } from '../config.js';
import { __configInternals } from '../config.js';
import { expandHome } from '../env.js';
import { withFileLock, writeTextFileAtomic } from '../fs-json.js';
import { canonicalKeepAliveName, resolveLifecycle } from '../lifecycle.js';
export interface EphemeralServerSpec {
@ -108,6 +109,7 @@ export function resolveEphemeralServer(spec: EphemeralServerSpec): EphemeralServ
export async function persistEphemeralServer(resolution: EphemeralServerResolution, rawPath: string): Promise<void> {
const resolvedPath = path.resolve(expandHome(rawPath));
await withFileLock(resolvedPath, async () => {
let existing: Record<string, unknown>;
try {
const buffer = await fs.readFile(resolvedPath, 'utf8');
@ -125,9 +127,9 @@ export async function persistEphemeralServer(resolution: EphemeralServerResoluti
const servers = existing.mcpServers as Record<string, unknown>;
servers[resolution.name] = resolution.persistedEntry;
await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
const serialized = `${JSON.stringify(existing, null, 2)}\n`;
await fs.writeFile(resolvedPath, serialized, 'utf8');
await writeTextFileAtomic(resolvedPath, serialized);
});
}
function inferNameFromUrl(url: URL): string {

View File

@ -1,8 +1,8 @@
import path from 'node:path';
import type { LoadConfigOptions, RawEntry } from '../../config.js';
import { writeRawConfig } from '../../config.js';
import { writeRawConfig, type LoadConfigOptions, type RawEntry } from '../../config.js';
import { pathsForImport, readExternalEntries } from '../../config-imports.js';
import { expandHome } from '../../env.js';
import { withFileLock } from '../../fs-json.js';
import { mcporterDir } from '../../paths.js';
import { CliUsageError } from '../errors.js';
import { cloneConfig, loadOrCreateConfig } from './shared.js';
@ -44,9 +44,6 @@ export async function handleAddCommand(options: ConfigCliOptions, args: string[]
const targetPath = resolveWriteTarget(flags, options.loadOptions, options.loadOptions.rootDir ?? process.cwd());
const effectiveLoadOptions: LoadConfigOptions = { ...options.loadOptions, configPath: targetPath };
const { config, path: configPath } = await loadOrCreateConfig(effectiveLoadOptions);
const nextConfig = cloneConfig(config);
const baseEntry = await resolveBaseEntry(flags.copyFrom, options.loadOptions);
const entry: RawEntry = baseEntry ? { ...baseEntry } : {};
@ -72,18 +69,23 @@ export async function handleAddCommand(options: ConfigCliOptions, args: string[]
throw new CliUsageError('Server definitions require either a --url/target or a stdio command.');
}
if (!nextConfig.mcpServers) {
nextConfig.mcpServers = {};
}
nextConfig.mcpServers[name] = entry;
if (flags.dryRun) {
console.log(JSON.stringify({ [name]: entry }, null, 2));
console.log('(dry-run) No changes were written.');
return;
}
let configPath = targetPath;
await withFileLock(targetPath, async () => {
const loaded = await loadOrCreateConfig(effectiveLoadOptions);
configPath = loaded.path;
const nextConfig = cloneConfig(loaded.config);
if (!nextConfig.mcpServers) {
nextConfig.mcpServers = {};
}
nextConfig.mcpServers[name] = entry;
await writeRawConfig(configPath, nextConfig);
});
console.log(`Added '${name}' to ${configPath}`);
}

View File

@ -1,8 +1,8 @@
import path from 'node:path';
import type { RawEntry } from '../../config.js';
import { writeRawConfig } from '../../config.js';
import { resolveConfigPath, writeRawConfig, type RawEntry } from '../../config.js';
import { pathsForImport, readExternalEntries } from '../../config-imports.js';
import { expandHome } from '../../env.js';
import { withFileLock } from '../../fs-json.js';
import { CliUsageError } from '../errors.js';
import { cloneConfig, loadOrCreateConfig } from './shared.js';
import type { ConfigCliOptions } from './types.js';
@ -53,8 +53,12 @@ export async function handleImportCommand(options: ConfigCliOptions, args: strin
}
}
if (flags.copy) {
const { config, path: configPath } = await loadOrCreateConfig(options.loadOptions);
const nextConfig = cloneConfig(config);
const lockPath = resolveConfigPath(options.loadOptions.configPath, rootDir).path;
let configPath = lockPath;
await withFileLock(lockPath, async () => {
const loaded = await loadOrCreateConfig({ ...options.loadOptions, configPath: lockPath });
configPath = loaded.path;
const nextConfig = cloneConfig(loaded.config);
if (!nextConfig.mcpServers) {
nextConfig.mcpServers = {};
}
@ -62,6 +66,7 @@ export async function handleImportCommand(options: ConfigCliOptions, args: strin
nextConfig.mcpServers[item.name] = structuredClone(item.entry);
}
await writeRawConfig(configPath, nextConfig);
});
console.log(`Copied ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'} to ${configPath}`);
}
}

View File

@ -1,4 +1,5 @@
import { writeRawConfig } from '../../config.js';
import { resolveConfigPath, writeRawConfig } from '../../config.js';
import { withFileLock } from '../../fs-json.js';
import { CliUsageError } from '../errors.js';
import { cloneConfig, findServerNameWithFuzzyMatch, loadOrCreateConfig } from './shared.js';
import type { ConfigCliOptions } from './types.js';
@ -8,13 +9,21 @@ export async function handleRemoveCommand(options: ConfigCliOptions, args: strin
if (!name) {
throw new CliUsageError('Usage: mcporter config remove <name>');
}
const { config, path: configPath } = await loadOrCreateConfig(options.loadOptions);
const targetName = findServerNameWithFuzzyMatch(name, Object.keys(config.mcpServers ?? {}));
if (!targetName) {
const rootDir = options.loadOptions.rootDir ?? process.cwd();
const lockPath = resolveConfigPath(options.loadOptions.configPath, rootDir).path;
let configPath = lockPath;
let targetName = name;
await withFileLock(lockPath, async () => {
const loaded = await loadOrCreateConfig({ ...options.loadOptions, configPath: lockPath });
configPath = loaded.path;
const matched = findServerNameWithFuzzyMatch(name, Object.keys(loaded.config.mcpServers ?? {}));
if (!matched) {
throw new CliUsageError(`Server '${name}' does not exist in ${configPath}.`);
}
const nextConfig = cloneConfig(config);
targetName = matched;
const nextConfig = cloneConfig(loaded.config);
delete nextConfig.mcpServers[targetName];
await writeRawConfig(configPath, nextConfig);
});
console.log(`Removed '${targetName}' from ${configPath}`);
}

View File

@ -1,4 +1,3 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import {
listConfigLayerPaths as discoverConfigLayerPaths,
@ -17,6 +16,7 @@ import {
type ServerSource,
} from './config-schema.js';
import { expandHome } from './env.js';
import { writeTextFileAtomic } from './fs-json.js';
export { toFileUrl } from './config-imports.js';
export { __configInternals } from './config-normalize.js';
@ -121,9 +121,8 @@ export async function listConfigLayerPaths(
}
export async function writeRawConfig(targetPath: string, config: RawConfig): Promise<void> {
await fs.mkdir(path.dirname(targetPath), { recursive: true });
const serialized = `${JSON.stringify(config, null, 2)}\n`;
await fs.writeFile(targetPath, serialized, 'utf8');
await writeTextFileAtomic(targetPath, serialized);
}
export function resolveConfigPath(

View File

@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
import net from 'node:net';
import path from 'node:path';
import type { ServerDefinition } from '../config.js';
import { writeJsonFile } from '../fs-json.js';
import { isKeepAliveServer } from '../lifecycle.js';
import { createRuntime, type Runtime } from '../runtime.js';
import { collectConfigLayers, statConfigMtime } from './config-layers.js';
@ -156,10 +157,7 @@ export async function runDaemonHost(options: DaemonHostOptions): Promise<void> {
});
});
await fs.writeFile(
options.metadataPath,
JSON.stringify(
{
await writeJsonFile(options.metadataPath, {
pid: process.pid,
socketPath: options.socketPath,
configPath: options.configPath,
@ -167,12 +165,7 @@ export async function runDaemonHost(options: DaemonHostOptions): Promise<void> {
startedAt: Date.now(),
logPath: options.logPath ?? null,
configMtimeMs,
},
null,
2
),
'utf8'
);
});
let shuttingDown = false;
const shutdown = async (): Promise<void> => {

View File

@ -1,6 +1,14 @@
import crypto from 'node:crypto';
import { constants } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
const DEFAULT_LOCK_TIMEOUT_MS = 30_000;
const LOCK_POLL_MS = 25;
const MALFORMED_LOCK_STALE_MS = 1_000;
const MAX_SYMLINK_DEPTH = 40;
const DEFAULT_ATOMIC_FILE_MODE = 0o600;
// readJsonFile reads a JSON file and returns undefined when the file does not exist.
export async function readJsonFile<T = unknown>(filePath: string): Promise<T | undefined> {
try {
@ -14,8 +22,226 @@ export async function readJsonFile<T = unknown>(filePath: string): Promise<T | u
}
}
// writeTextFileAtomic writes a file via same-directory temp file and rename.
export async function writeTextFileAtomic(filePath: string, data: string): Promise<void> {
const target = await resolveAtomicWriteTarget(filePath);
await fs.mkdir(path.dirname(target.path), { recursive: true });
const tempPath = path.join(
path.dirname(target.path),
`.${path.basename(target.path)}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`
);
try {
if (target.mode !== undefined) {
await fs.access(target.path, constants.W_OK);
}
await fs.writeFile(tempPath, data, {
encoding: 'utf8',
flag: 'wx',
mode: target.mode ?? DEFAULT_ATOMIC_FILE_MODE,
});
if (target.mode !== undefined) {
await fs.chmod(tempPath, target.mode);
}
await fs.rename(tempPath, target.path);
} catch (error) {
await fs.unlink(tempPath).catch(() => {});
if (target.mode !== undefined && isPermissionError(error)) {
await fs.writeFile(filePath, data, 'utf8');
return;
}
throw error;
}
}
// writeJsonFile writes a JSON object to disk, ensuring parent directories are created first.
export async function writeJsonFile(filePath: string, data: unknown): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf8');
await writeTextFileAtomic(filePath, JSON.stringify(data, null, 2));
}
export async function withFileLock<T>(
filePath: string,
task: () => Promise<T>,
options: { timeoutMs?: number } = {}
): Promise<T> {
const lockTargetPath = await resolvePathFollowingSymlinks(filePath);
await fs.mkdir(path.dirname(lockTargetPath), { recursive: true });
let lockPath = `${lockTargetPath}.lock`;
const fallbackLockPath = lockTargetPath !== filePath ? `${filePath}.lock` : undefined;
const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
const startedAt = Date.now();
let acquired = false;
while (!acquired) {
try {
await fs.writeFile(lockPath, `${process.pid}\n${new Date().toISOString()}\n`, {
encoding: 'utf8',
flag: 'wx',
});
acquired = true;
break;
} catch (error) {
if (fallbackLockPath && lockPath !== fallbackLockPath && isPermissionError(error)) {
await fs.mkdir(path.dirname(fallbackLockPath), { recursive: true });
lockPath = fallbackLockPath;
continue;
}
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
throw error;
}
if (await removeRecoverableLock(lockPath)) {
continue;
}
if (Date.now() - startedAt > timeoutMs) {
throw new Error(`Timed out waiting for file lock ${lockPath}`, { cause: error });
}
await sleep(LOCK_POLL_MS);
}
}
try {
return await task();
} finally {
await fs.unlink(lockPath).catch((error) => {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
});
}
}
function isPermissionError(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException).code;
return code === 'EACCES' || code === 'EPERM';
}
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function resolveAtomicWriteTarget(filePath: string): Promise<{ path: string; mode?: number }> {
try {
const stats = await fs.lstat(filePath);
if (stats.isSymbolicLink()) {
const targetPath = await resolvePathFollowingSymlinks(filePath);
return { path: targetPath, mode: await readMode(targetPath) };
}
return { path: filePath, mode: stats.mode & 0o777 };
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return { path: filePath };
}
throw error;
}
}
async function resolvePathFollowingSymlinks(filePath: string): Promise<string> {
let currentPath = await canonicalizeParentDirectory(filePath);
for (let depth = 0; depth < MAX_SYMLINK_DEPTH; depth += 1) {
let stats;
try {
stats = await fs.lstat(currentPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return await canonicalizeParentDirectory(currentPath);
}
throw error;
}
if (!stats.isSymbolicLink()) {
return currentPath;
}
const link = await fs.readlink(currentPath);
currentPath = await canonicalizeParentDirectory(
path.isAbsolute(link) ? link : path.resolve(path.dirname(currentPath), link)
);
}
throw new Error(`Too many symbolic links while resolving ${filePath}`);
}
async function canonicalizeParentDirectory(filePath: string): Promise<string> {
try {
return path.join(await fs.realpath(path.dirname(filePath)), path.basename(filePath));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return filePath;
}
throw error;
}
}
async function readMode(filePath: string): Promise<number | undefined> {
try {
const stats = await fs.stat(filePath);
return stats.mode & 0o777;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return undefined;
}
throw error;
}
}
async function removeRecoverableLock(lockPath: string): Promise<boolean> {
const breakerPath = `${lockPath}.break`;
try {
await fs.writeFile(breakerPath, `${process.pid}\n${new Date().toISOString()}\n`, {
encoding: 'utf8',
flag: 'wx',
});
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
return false;
}
if (!(await isLockRecoverable(breakerPath))) {
return false;
}
await fs.unlink(breakerPath).catch(() => {});
return false;
}
try {
if (!(await isLockRecoverable(lockPath))) {
return false;
}
await fs.unlink(lockPath);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code === 'ENOENT';
} finally {
await fs.unlink(breakerPath).catch(() => {});
}
}
async function isLockRecoverable(lockPath: string): Promise<boolean> {
let contents: string;
try {
contents = await fs.readFile(lockPath, 'utf8');
} catch (error) {
return (error as NodeJS.ErrnoException).code === 'ENOENT';
}
if (contents.length === 0) {
return await isMalformedLockStale(lockPath);
}
const pid = Number.parseInt(contents.split(/\r?\n/, 1)[0] ?? '', 10);
if (Number.isInteger(pid) && pid > 0) {
return !isProcessRunning(pid);
}
return await isMalformedLockStale(lockPath);
}
function isProcessRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code === 'EPERM';
}
}
async function isMalformedLockStale(lockPath: string): Promise<boolean> {
try {
const stats = await fs.stat(lockPath);
return Date.now() - stats.mtimeMs > MALFORMED_LOCK_STALE_MS;
} catch {
return false;
}
}

View File

@ -9,7 +9,7 @@ import type {
} from '@modelcontextprotocol/sdk/shared/auth.js';
import { checkResourceAllowed, resourceUrlFromServerUrl } from '@modelcontextprotocol/sdk/shared/auth-utils.js';
import type { ServerDefinition } from './config.js';
import { readJsonFile, writeJsonFile } from './fs-json.js';
import { readJsonFile, writeJsonFile, writeTextFileAtomic } from './fs-json.js';
import type { Logger } from './logging.js';
import { buildStaticClientInformation } from './oauth-client-info.js';
import { clearVaultEntry, getOAuthVaultPath, loadVaultEntry, saveVaultEntry } from './oauth-vault.js';
@ -141,7 +141,7 @@ class DirectoryPersistence implements OAuthPersistence {
async saveCodeVerifier(value: string): Promise<void> {
await this.ensureDir();
await fs.writeFile(this.codeVerifierPath, value, 'utf8');
await writeTextFileAtomic(this.codeVerifierPath, value);
}
async readState(): Promise<string | undefined> {

View File

@ -1,9 +1,8 @@
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import type { OAuthClientInformationMixed, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';
import type { ServerDefinition } from './config.js';
import { readJsonFile, writeJsonFile } from './fs-json.js';
import { readJsonFile, withFileLock, writeJsonFile } from './fs-json.js';
import { mcporterDir } from './paths.js';
type VaultKey = string;
@ -23,35 +22,43 @@ interface VaultFile {
entries: Record<VaultKey, VaultEntry>;
}
interface VaultReadState {
vault: VaultFile;
needsRepair: boolean;
}
export function getOAuthVaultPath(): string {
return path.join(mcporterDir('data'), 'credentials.json');
}
async function readVault(): Promise<VaultFile> {
let shouldRewrite = false;
async function readVaultState(): Promise<VaultReadState> {
try {
const existing = await readJsonFile<VaultFile>(getOAuthVaultPath());
if (existing && existing.version === 1 && existing.entries && typeof existing.entries === 'object') {
return existing;
return { vault: existing, needsRepair: false };
}
// Unexpected shape; rewrite.
shouldRewrite = true;
} catch {
// Corrupt or unreadable vault; reset to empty.
shouldRewrite = true;
if (existing !== undefined) {
return { vault: emptyVault(), needsRepair: true };
}
const empty: VaultFile = { version: 1, entries: {} };
if (shouldRewrite) {
await writeVault(empty);
} catch (error) {
if (!(error instanceof SyntaxError)) {
throw error;
}
return empty;
return { vault: emptyVault(), needsRepair: true };
}
return { vault: emptyVault(), needsRepair: false };
}
async function readVault(): Promise<VaultFile> {
return (await readVaultState()).vault;
}
function emptyVault(): VaultFile {
return { version: 1, entries: {} };
}
async function writeVault(contents: VaultFile): Promise<void> {
const filePath = getOAuthVaultPath();
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true });
await writeJsonFile(filePath, contents);
await writeJsonFile(getOAuthVaultPath(), contents);
}
export function vaultKeyForDefinition(definition: ServerDefinition): VaultKey {
@ -73,6 +80,7 @@ export async function loadVaultEntry(definition: ServerDefinition): Promise<Vaul
}
export async function saveVaultEntry(definition: ServerDefinition, patch: Partial<VaultEntry>): Promise<void> {
await withFileLock(getOAuthVaultPath(), async () => {
const vault = await readVault();
const key = vaultKeyForDefinition(definition);
const current = vault.entries[key] ?? {
@ -86,16 +94,21 @@ export async function saveVaultEntry(definition: ServerDefinition, patch: Partia
updatedAt: new Date().toISOString(),
};
await writeVault(vault);
});
}
export async function clearVaultEntry(
definition: ServerDefinition,
scope: 'all' | 'tokens' | 'client' | 'verifier' | 'state'
): Promise<void> {
const vault = await readVault();
const key = vaultKeyForDefinition(definition);
await withFileLock(getOAuthVaultPath(), async () => {
const { vault, needsRepair } = await readVaultState();
const existing = vault.entries[key];
if (!existing) {
if (needsRepair) {
await writeVault(vault);
}
return;
}
if (scope === 'all') {
@ -118,4 +131,5 @@ export async function clearVaultEntry(
vault.entries[key] = updated;
}
await writeVault(vault);
});
}

View File

@ -30,32 +30,37 @@ interface CollectedCallContent {
}
function extractEnvelope(raw: unknown): ExtractedEnvelope {
return collectEnvelopeFields(raw, { content: null, structuredContent: null }, 0);
}
function collectEnvelopeFields(raw: unknown, envelope: ExtractedEnvelope, depth: number): ExtractedEnvelope {
if (!raw || typeof raw !== 'object') {
return { content: null, structuredContent: null };
return envelope;
}
const obj = raw as Record<string, unknown>;
let content: unknown[] | null = null;
let structuredContent: unknown = null;
let { content, structuredContent } = envelope;
if ('content' in obj && Array.isArray(obj.content)) {
if (!content && 'content' in obj && Array.isArray(obj.content)) {
content = obj.content as unknown[];
}
if ('structuredContent' in obj) {
if (structuredContent === null && 'structuredContent' in obj) {
structuredContent = obj.structuredContent;
}
if ('raw' in obj && obj.raw && typeof obj.raw === 'object') {
const nested = obj.raw as Record<string, unknown>;
if (!content && 'content' in nested && Array.isArray(nested.content)) {
content = nested.content as unknown[];
}
if (structuredContent === null && 'structuredContent' in nested) {
structuredContent = nested.structuredContent;
}
const updated = { content, structuredContent };
if (depth >= 2) {
return updated;
}
return { content, structuredContent };
let nested = updated;
if ('raw' in obj) {
nested = collectEnvelopeFields(obj.raw, nested, depth + 1);
}
if ('result' in obj) {
nested = collectEnvelopeFields(obj.result, nested, depth + 1);
}
return nested;
}
// asString converts known content/value shapes into plain strings.

View File

@ -1,6 +1,6 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import type { ServerDefinition } from './config.js';
import { readJsonFile, writeJsonFile } from './fs-json.js';
import { mcporterDir } from './paths.js';
const SCHEMA_FILENAME = 'schema.json';
@ -24,8 +24,7 @@ export function resolveSchemaCachePath(definition: ServerDefinition): string {
export async function readSchemaCache(definition: ServerDefinition): Promise<SchemaCacheSnapshot | undefined> {
const filePath = resolveSchemaCachePath(definition);
try {
const raw = await fs.readFile(filePath, 'utf8');
const parsed = JSON.parse(raw) as SchemaCacheSnapshot;
const parsed = await readJsonFile<SchemaCacheSnapshot>(filePath);
if (!parsed || typeof parsed !== 'object') {
return undefined;
}
@ -43,7 +42,5 @@ export async function readSchemaCache(definition: ServerDefinition): Promise<Sch
// writeSchemaCache persists the latest tool schema snapshot for a server.
export async function writeSchemaCache(definition: ServerDefinition, snapshot: SchemaCacheSnapshot): Promise<void> {
const filePath = resolveSchemaCachePath(definition);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, JSON.stringify(snapshot, null, 2), 'utf8');
await writeJsonFile(resolveSchemaCachePath(definition), snapshot);
}

View File

@ -10,16 +10,28 @@ describe('mcporter config CLI', () => {
let tempDir: string;
let configPath: string;
let originalXdg: string | undefined;
let originalXdgData: string | undefined;
beforeEach(async () => {
originalXdg = process.env.XDG_CONFIG_HOME;
originalXdgData = process.env.XDG_DATA_HOME;
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-config-'));
configPath = path.join(tempDir, 'config', 'mcporter.json');
process.env.XDG_DATA_HOME = path.join(tempDir, 'xdg-data');
});
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
if (originalXdg === undefined) {
delete process.env.XDG_CONFIG_HOME;
} else {
process.env.XDG_CONFIG_HOME = originalXdg;
}
if (originalXdgData === undefined) {
delete process.env.XDG_DATA_HOME;
} else {
process.env.XDG_DATA_HOME = originalXdgData;
}
vi.restoreAllMocks();
});

View File

@ -44,6 +44,30 @@ describe('printCallOutput format selection', () => {
});
},
],
[
'auto prints structuredContent nested inside a result wrapper',
'auto',
{
result: {
content: [
{
type: 'text',
text: '{\n "entities": [],\n "relations": []\n}',
},
],
structuredContent: {
entities: [],
relations: [],
},
},
},
(logged: unknown) => {
expect(JSON.parse(String(logged))).toEqual({
entities: [],
relations: [],
});
},
],
[
'text prefers text over markdown/json',
'text',

View File

@ -9,13 +9,21 @@ import * as importModule from '../src/config-imports.js';
let tempDir: string;
let loadOptions: LoadConfigOptions;
let originalXdgConfigHome: string | undefined;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-import-'));
loadOptions = { rootDir: tempDir };
originalXdgConfigHome = process.env.XDG_CONFIG_HOME;
process.env.XDG_CONFIG_HOME = path.join(tempDir, 'xdg-config');
});
afterEach(async () => {
if (originalXdgConfigHome === undefined) {
delete process.env.XDG_CONFIG_HOME;
} else {
process.env.XDG_CONFIG_HOME = originalXdgConfigHome;
}
await fs.rm(tempDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
@ -43,6 +51,42 @@ describe('config import', () => {
expect(writtenConfig?.mcpServers?.skip).toBeUndefined();
});
it('copies into the locked config path if another default appears while waiting', async () => {
const xdgConfigHome = path.join(tempDir, 'xdg-config');
vi.spyOn(importModule, 'pathsForImport').mockReturnValue([path.join(tempDir, 'imports', 'cursor.json')]);
vi.spyOn(importModule, 'readExternalEntries').mockResolvedValue(
new Map([['keep', { baseUrl: 'https://example.com/mcp' }]]) as never
);
const projectConfigPath = path.join(tempDir, 'config', 'mcporter.json');
const homeConfigPath = path.join(xdgConfigHome, 'mcporter', 'mcporter.json');
const lockPath = `${projectConfigPath}.lock`;
await fs.mkdir(path.dirname(projectConfigPath), { recursive: true });
await fs.writeFile(lockPath, `${process.pid}\n2026-01-01T00:00:00.000Z\n`, 'utf8');
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
try {
const command = handleImportCommand({ loadOptions } as never, ['cursor', '--copy']);
await new Promise((resolve) => setTimeout(resolve, 50));
await fs.mkdir(path.dirname(homeConfigPath), { recursive: true });
await fs.writeFile(
homeConfigPath,
JSON.stringify({ mcpServers: { home: { baseUrl: 'https://home.example/mcp' } } }),
'utf8'
);
await fs.unlink(lockPath);
await command;
} finally {
logSpy.mockRestore();
await fs.unlink(lockPath).catch(() => {});
}
const projectConfig = JSON.parse(await fs.readFile(projectConfigPath, 'utf8')) as RawConfig;
const homeConfig = JSON.parse(await fs.readFile(homeConfigPath, 'utf8')) as RawConfig;
expect(projectConfig.mcpServers?.keep).toBeDefined();
expect(homeConfig.mcpServers?.keep).toBeUndefined();
});
it('emits JSON when --json is provided', async () => {
vi.spyOn(importModule, 'pathsForImport').mockReturnValue([path.join(tempDir, 'imports', 'cursor.json')]);
vi.spyOn(importModule, 'readExternalEntries').mockResolvedValue(

View File

@ -2,7 +2,7 @@ import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { readJsonFile, writeJsonFile } from '../src/fs-json.js';
import { readJsonFile, withFileLock, writeJsonFile } from '../src/fs-json.js';
describe('fs-json helpers', () => {
let tempDir: string;
@ -32,4 +32,268 @@ describe('fs-json helpers', () => {
const raw = await fs.readFile(nestedPath, 'utf8');
expect(raw).toContain('\n "apiKey"');
});
it.runIf(process.platform !== 'win32')('preserves existing file mode during atomic writes', async () => {
const targetPath = path.join(tempDir, 'credentials.json');
await fs.writeFile(targetPath, '{}', 'utf8');
await fs.chmod(targetPath, 0o600);
await writeJsonFile(targetPath, { token: 'secret' });
const stats = await fs.stat(targetPath);
expect(stats.mode & 0o777).toBe(0o600);
expect(await readJsonFile(targetPath)).toEqual({ token: 'secret' });
});
it.runIf(process.platform !== 'win32')('creates new files with private permissions', async () => {
const targetPath = path.join(tempDir, 'new-credentials.json');
await writeJsonFile(targetPath, { token: 'secret' });
const stats = await fs.stat(targetPath);
expect(stats.mode & 0o777).toBe(0o600);
});
it.runIf(process.platform !== 'win32')('does not replace existing read-only files', async () => {
const targetPath = path.join(tempDir, 'readonly.json');
await fs.writeFile(targetPath, '{"locked":true}', 'utf8');
await fs.chmod(targetPath, 0o400);
try {
await expect(writeJsonFile(targetPath, { locked: false })).rejects.toThrow();
expect(await fs.readFile(targetPath, 'utf8')).toBe('{"locked":true}');
} finally {
await fs.chmod(targetPath, 0o600).catch(() => {});
}
});
it.runIf(process.platform !== 'win32')(
'falls back to direct writes when the target directory is read-only',
async () => {
const readOnlyDir = path.join(tempDir, 'readonly-dir');
const targetPath = path.join(readOnlyDir, 'config.json');
await fs.mkdir(readOnlyDir, { recursive: true });
await fs.writeFile(targetPath, '{}', 'utf8');
await fs.chmod(targetPath, 0o600);
try {
await fs.chmod(readOnlyDir, 0o555);
await writeJsonFile(targetPath, { fallback: true });
} finally {
await fs.chmod(readOnlyDir, 0o755).catch(() => {});
}
expect(await readJsonFile(targetPath)).toEqual({ fallback: true });
}
);
it.runIf(process.platform !== 'win32')('writes through symlinks without replacing them', async () => {
const realPath = path.join(tempDir, 'real.json');
const symlinkPath = path.join(tempDir, 'linked.json');
await fs.writeFile(realPath, '{}', 'utf8');
await fs.symlink(realPath, symlinkPath);
await writeJsonFile(symlinkPath, { linked: true });
expect((await fs.lstat(symlinkPath)).isSymbolicLink()).toBe(true);
expect(await readJsonFile(realPath)).toEqual({ linked: true });
});
it.runIf(process.platform !== 'win32')('writes through symlink chains without replacing links', async () => {
const realPath = path.join(tempDir, 'real.json');
const middleSymlinkPath = path.join(tempDir, 'middle.json');
const symlinkPath = path.join(tempDir, 'linked.json');
await fs.writeFile(realPath, '{}', 'utf8');
await fs.symlink(realPath, middleSymlinkPath);
await fs.symlink(middleSymlinkPath, symlinkPath);
await writeJsonFile(symlinkPath, { chained: true });
expect((await fs.lstat(symlinkPath)).isSymbolicLink()).toBe(true);
expect((await fs.lstat(middleSymlinkPath)).isSymbolicLink()).toBe(true);
expect(await readJsonFile(realPath)).toEqual({ chained: true });
});
it.runIf(process.platform !== 'win32')('writes through symlink chains whose target does not exist yet', async () => {
const realPath = path.join(tempDir, 'real.json');
const middleSymlinkPath = path.join(tempDir, 'middle.json');
const symlinkPath = path.join(tempDir, 'linked.json');
await fs.symlink(realPath, middleSymlinkPath);
await fs.symlink(middleSymlinkPath, symlinkPath);
await writeJsonFile(symlinkPath, { created: true });
expect((await fs.lstat(symlinkPath)).isSymbolicLink()).toBe(true);
expect((await fs.lstat(middleSymlinkPath)).isSymbolicLink()).toBe(true);
expect(await readJsonFile(realPath)).toEqual({ created: true });
});
it.runIf(process.platform !== 'win32')('uses the same lock for symlinks and their real target', async () => {
const realPath = path.join(tempDir, 'shared.json');
const symlinkPath = path.join(tempDir, 'linked.json');
await writeJsonFile(realPath, []);
await fs.symlink(realPath, symlinkPath);
const appendWithLock = async (targetPath: string, value: string) =>
withFileLock(targetPath, async () => {
const current = (await readJsonFile<string[]>(realPath)) ?? [];
await new Promise((resolve) => setTimeout(resolve, 20));
current.push(value);
await writeJsonFile(targetPath, current);
});
await Promise.all([appendWithLock(realPath, 'real'), appendWithLock(symlinkPath, 'link')]);
expect((await readJsonFile<string[]>(realPath))?.toSorted()).toEqual(['link', 'real']);
expect((await fs.lstat(symlinkPath)).isSymbolicLink()).toBe(true);
});
it.runIf(process.platform !== 'win32')('uses the same lock through symlinked parent directories', async () => {
const realDir = path.join(tempDir, 'real');
const linkDir = path.join(tempDir, 'linked-dir');
const realPath = path.join(realDir, 'shared.json');
const linkedPath = path.join(linkDir, 'shared.json');
await fs.mkdir(realDir, { recursive: true });
await writeJsonFile(realPath, []);
await fs.symlink(realDir, linkDir);
const appendWithLock = async (targetPath: string, value: string) =>
withFileLock(targetPath, async () => {
const current = (await readJsonFile<string[]>(realPath)) ?? [];
await new Promise((resolve) => setTimeout(resolve, 20));
current.push(value);
await writeJsonFile(targetPath, current);
});
await Promise.all([appendWithLock(realPath, 'real'), appendWithLock(linkedPath, 'link')]);
expect((await readJsonFile<string[]>(realPath))?.toSorted()).toEqual(['link', 'real']);
expect((await fs.lstat(linkDir)).isSymbolicLink()).toBe(true);
});
it.runIf(process.platform !== 'win32')(
'falls back to direct symlink writes when target dir is read-only',
async () => {
const realDir = path.join(tempDir, 'real');
const linkDir = path.join(tempDir, 'links');
const realPath = path.join(realDir, 'config.json');
const symlinkPath = path.join(linkDir, 'config.json');
await fs.mkdir(realDir, { recursive: true });
await fs.mkdir(linkDir, { recursive: true });
await fs.writeFile(realPath, '{}', 'utf8');
await fs.symlink(realPath, symlinkPath);
try {
await fs.chmod(realDir, 0o555);
await writeJsonFile(symlinkPath, { fallback: true });
} finally {
await fs.chmod(realDir, 0o755).catch(() => {});
}
expect((await fs.lstat(symlinkPath)).isSymbolicLink()).toBe(true);
expect(await readJsonFile(realPath)).toEqual({ fallback: true });
}
);
it.runIf(process.platform !== 'win32')('falls back to symlink-side locks when target dir is read-only', async () => {
const realDir = path.join(tempDir, 'real');
const linkDir = path.join(tempDir, 'links');
const realPath = path.join(realDir, 'config.json');
const symlinkPath = path.join(linkDir, 'config.json');
await fs.mkdir(realDir, { recursive: true });
await fs.mkdir(linkDir, { recursive: true });
await fs.writeFile(realPath, '{}', 'utf8');
await fs.symlink(realPath, symlinkPath);
let ran = false;
try {
await fs.chmod(realDir, 0o555);
await withFileLock(symlinkPath, async () => {
ran = true;
});
} finally {
await fs.chmod(realDir, 0o755).catch(() => {});
}
expect(ran).toBe(true);
await expect(fs.access(`${symlinkPath}.lock`)).rejects.toThrow();
});
it('serializes concurrent tasks with a file lock', async () => {
const lockTarget = path.join(tempDir, 'shared.json');
const order: number[] = [];
await Promise.all(
Array.from({ length: 5 }, async (_, index) =>
withFileLock(lockTarget, async () => {
const snapshot = [...order];
await new Promise((resolve) => setTimeout(resolve, 5));
expect(order).toEqual(snapshot);
order.push(index);
})
)
);
expect(order).toHaveLength(5);
await expect(fs.access(`${lockTarget}.lock`)).rejects.toThrow();
});
it('recovers lock files left by dead processes', async () => {
const lockTarget = path.join(tempDir, 'shared.json');
await fs.writeFile(`${lockTarget}.lock`, '99999999\n2026-01-01T00:00:00.000Z\n', 'utf8');
let ran = false;
await withFileLock(lockTarget, async () => {
ran = true;
});
expect(ran).toBe(true);
await expect(fs.access(`${lockTarget}.lock`)).rejects.toThrow();
});
it('does not recover fresh empty lock files', async () => {
const lockTarget = path.join(tempDir, 'shared.json');
await fs.writeFile(`${lockTarget}.lock`, '', 'utf8');
await expect(withFileLock(lockTarget, async () => {}, { timeoutMs: 75 })).rejects.toThrow(
/Timed out waiting for file lock/
);
expect(await fs.readFile(`${lockTarget}.lock`, 'utf8')).toBe('');
});
it('recovers stale empty lock files left before metadata is written', async () => {
const lockTarget = path.join(tempDir, 'shared.json');
const lockPath = `${lockTarget}.lock`;
await fs.writeFile(lockPath, '', 'utf8');
const staleDate = new Date(Date.now() - 2_000);
await fs.utimes(lockPath, staleDate, staleDate);
let ran = false;
await withFileLock(lockTarget, async () => {
ran = true;
});
expect(ran).toBe(true);
await expect(fs.access(`${lockTarget}.lock`)).rejects.toThrow();
});
it('serializes waiters while recovering a stale lock', async () => {
const lockTarget = path.join(tempDir, 'shared.json');
const order: number[] = [];
await fs.writeFile(`${lockTarget}.lock`, '99999999\n2026-01-01T00:00:00.000Z\n', 'utf8');
await Promise.all(
Array.from({ length: 2 }, async (_, index) =>
withFileLock(lockTarget, async () => {
const snapshot = [...order];
await new Promise((resolve) => setTimeout(resolve, 20));
expect(order).toEqual(snapshot);
order.push(index);
})
)
);
expect(order).toHaveLength(2);
await expect(fs.access(`${lockTarget}.lock`)).rejects.toThrow();
});
});

View File

@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ServerDefinition } from '../src/config.js';
import { readJsonFile } from '../src/fs-json.js';
import { buildOAuthPersistence, clearOAuthCaches, readCachedAccessToken } from '../src/oauth-persistence.js';
import { loadVaultEntry, vaultKeyForDefinition } from '../src/oauth-vault.js';
import { clearVaultEntry, loadVaultEntry, saveVaultEntry, vaultKeyForDefinition } from '../src/oauth-vault.js';
const authMocks = vi.hoisted(() => ({
discoverOAuthServerInfo: vi.fn(),
@ -129,6 +129,83 @@ describe('oauth persistence', () => {
expect(vault?.entries[key]?.tokens?.access_token).toBe('xdg-token');
});
it('serializes concurrent shared vault writes for different servers', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-vault-race-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(path.join(tmp, 'home'));
hasSpy = true;
process.env.XDG_DATA_HOME = path.join(tmp, 'data');
const definitions = Array.from({ length: 12 }, (_, index) => mkDef(`service-${index}`));
await Promise.all(
definitions.map((definition, index) =>
saveVaultEntry(definition, {
tokens: { access_token: `token-${index}`, token_type: 'Bearer' },
})
)
);
const vaultPath = path.join(tmp, 'data', 'mcporter', 'credentials.json');
const vault = (await readJsonFile(vaultPath)) as
| { entries: Record<string, { tokens?: { access_token?: string } }> }
| undefined;
expect(Object.keys(vault?.entries ?? {})).toHaveLength(definitions.length);
for (const [index, definition] of definitions.entries()) {
expect(vault?.entries[vaultKeyForDefinition(definition)]?.tokens?.access_token).toBe(`token-${index}`);
}
});
it('does not create a vault file when clearing a missing vault entry', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-vault-clear-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(path.join(tmp, 'home'));
hasSpy = true;
process.env.XDG_DATA_HOME = path.join(tmp, 'data');
const vaultPath = path.join(tmp, 'data', 'mcporter', 'credentials.json');
await clearVaultEntry(mkDef('missing'), 'all');
await expect(fs.access(vaultPath)).rejects.toThrow();
await expect(fs.access(`${vaultPath}.lock`)).rejects.toThrow();
});
it('rewrites a corrupt vault file when clearing a missing vault entry', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-vault-corrupt-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(path.join(tmp, 'home'));
hasSpy = true;
process.env.XDG_DATA_HOME = path.join(tmp, 'data');
const vaultPath = path.join(tmp, 'data', 'mcporter', 'credentials.json');
await fs.mkdir(path.dirname(vaultPath), { recursive: true });
await fs.writeFile(vaultPath, '{"version":1,"entries": { bad', 'utf8');
await clearVaultEntry(mkDef('missing'), 'all');
expect(await readJsonFile(vaultPath)).toEqual({ version: 1, entries: {} });
await expect(fs.access(`${vaultPath}.lock`)).rejects.toThrow();
});
it.runIf(process.platform !== 'win32')('surfaces unreadable vault files', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-vault-unreadable-'));
tempRoots.push(tmp);
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(path.join(tmp, 'home'));
hasSpy = true;
process.env.XDG_DATA_HOME = path.join(tmp, 'data');
const definition = mkDef('unreadable');
const vaultPath = path.join(tmp, 'data', 'mcporter', 'credentials.json');
await fs.mkdir(path.dirname(vaultPath), { recursive: true });
await fs.writeFile(vaultPath, JSON.stringify({ version: 1, entries: {} }), 'utf8');
try {
await fs.chmod(vaultPath, 0o000);
await expect(loadVaultEntry(definition)).rejects.toThrow();
} finally {
await fs.chmod(vaultPath, 0o600).catch(() => {});
}
});
it('clears vault, legacy, tokenCacheDir, and provider-specific caches', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-'));
tempRoots.push(tmp);

View File

@ -216,6 +216,29 @@ describe('createCallResult json extraction', () => {
expect(result.json()).toEqual({ nested: true });
});
it('prefers structuredContent nested inside a result wrapper over parsed text content', () => {
const response = {
result: {
content: [
{
type: 'text',
text: '{\n "entities": [],\n "relations": []\n}',
},
],
structuredContent: {
entities: [],
relations: [],
},
},
};
const result = createCallResult(response);
expect(result.text()).toBe('{\n "entities": [],\n "relations": []\n}');
expect(result.json()).toEqual({
entities: [],
relations: [],
});
});
it('returns plain structuredContent objects even when they are not wrapped', () => {
const response = {
structuredContent: {