fix: harden concurrent config writes
This commit is contained in:
parent
8d962fbd79
commit
23565e2166
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,4 +1,5 @@
|
||||
node_modules
|
||||
.pnpm-store
|
||||
.DS_Store
|
||||
dist
|
||||
dist-bun
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,26 +109,27 @@ export function resolveEphemeralServer(spec: EphemeralServerSpec): EphemeralServ
|
||||
|
||||
export async function persistEphemeralServer(resolution: EphemeralServerResolution, rawPath: string): Promise<void> {
|
||||
const resolvedPath = path.resolve(expandHome(rawPath));
|
||||
let existing: Record<string, unknown>;
|
||||
try {
|
||||
const buffer = await fs.readFile(resolvedPath, 'utf8');
|
||||
existing = JSON.parse(buffer) as Record<string, unknown>;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error;
|
||||
await withFileLock(resolvedPath, async () => {
|
||||
let existing: Record<string, unknown>;
|
||||
try {
|
||||
const buffer = await fs.readFile(resolvedPath, 'utf8');
|
||||
existing = JSON.parse(buffer) as Record<string, unknown>;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
existing = { mcpServers: {} };
|
||||
}
|
||||
existing = { mcpServers: {} };
|
||||
}
|
||||
|
||||
if (typeof existing.mcpServers !== 'object' || existing.mcpServers === null) {
|
||||
existing.mcpServers = {};
|
||||
}
|
||||
const servers = existing.mcpServers as Record<string, unknown>;
|
||||
servers[resolution.name] = resolution.persistedEntry;
|
||||
if (typeof existing.mcpServers !== 'object' || existing.mcpServers === null) {
|
||||
existing.mcpServers = {};
|
||||
}
|
||||
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');
|
||||
const serialized = `${JSON.stringify(existing, null, 2)}\n`;
|
||||
await writeTextFileAtomic(resolvedPath, serialized);
|
||||
});
|
||||
}
|
||||
|
||||
function inferNameFromUrl(url: URL): string {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
await writeRawConfig(configPath, nextConfig);
|
||||
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}`);
|
||||
}
|
||||
|
||||
|
||||
@ -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,15 +53,20 @@ export async function handleImportCommand(options: ConfigCliOptions, args: strin
|
||||
}
|
||||
}
|
||||
if (flags.copy) {
|
||||
const { config, path: configPath } = await loadOrCreateConfig(options.loadOptions);
|
||||
const nextConfig = cloneConfig(config);
|
||||
if (!nextConfig.mcpServers) {
|
||||
nextConfig.mcpServers = {};
|
||||
}
|
||||
for (const item of entries) {
|
||||
nextConfig.mcpServers[item.name] = structuredClone(item.entry);
|
||||
}
|
||||
await writeRawConfig(configPath, nextConfig);
|
||||
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 = {};
|
||||
}
|
||||
for (const item of entries) {
|
||||
nextConfig.mcpServers[item.name] = structuredClone(item.entry);
|
||||
}
|
||||
await writeRawConfig(configPath, nextConfig);
|
||||
});
|
||||
console.log(`Copied ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'} to ${configPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
throw new CliUsageError(`Server '${name}' does not exist in ${configPath}.`);
|
||||
}
|
||||
const nextConfig = cloneConfig(config);
|
||||
delete nextConfig.mcpServers[targetName];
|
||||
await writeRawConfig(configPath, nextConfig);
|
||||
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}.`);
|
||||
}
|
||||
targetName = matched;
|
||||
const nextConfig = cloneConfig(loaded.config);
|
||||
delete nextConfig.mcpServers[targetName];
|
||||
await writeRawConfig(configPath, nextConfig);
|
||||
});
|
||||
console.log(`Removed '${targetName}' from ${configPath}`);
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,23 +157,15 @@ export async function runDaemonHost(options: DaemonHostOptions): Promise<void> {
|
||||
});
|
||||
});
|
||||
|
||||
await fs.writeFile(
|
||||
options.metadataPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
pid: process.pid,
|
||||
socketPath: options.socketPath,
|
||||
configPath: options.configPath,
|
||||
configLayers,
|
||||
startedAt: Date.now(),
|
||||
logPath: options.logPath ?? null,
|
||||
configMtimeMs,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
await writeJsonFile(options.metadataPath, {
|
||||
pid: process.pid,
|
||||
socketPath: options.socketPath,
|
||||
configPath: options.configPath,
|
||||
configLayers,
|
||||
startedAt: Date.now(),
|
||||
logPath: options.logPath ?? null,
|
||||
configMtimeMs,
|
||||
});
|
||||
|
||||
let shuttingDown = false;
|
||||
const shutdown = async (): Promise<void> => {
|
||||
|
||||
230
src/fs-json.ts
230
src/fs-json.ts
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error instanceof SyntaxError)) {
|
||||
throw error;
|
||||
}
|
||||
return { vault: emptyVault(), needsRepair: true };
|
||||
}
|
||||
const empty: VaultFile = { version: 1, entries: {} };
|
||||
if (shouldRewrite) {
|
||||
await writeVault(empty);
|
||||
}
|
||||
return empty;
|
||||
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,49 +80,56 @@ export async function loadVaultEntry(definition: ServerDefinition): Promise<Vaul
|
||||
}
|
||||
|
||||
export async function saveVaultEntry(definition: ServerDefinition, patch: Partial<VaultEntry>): Promise<void> {
|
||||
const vault = await readVault();
|
||||
const key = vaultKeyForDefinition(definition);
|
||||
const current = vault.entries[key] ?? {
|
||||
serverName: definition.name,
|
||||
serverUrl: definition.command.kind === 'http' ? definition.command.url.toString() : undefined,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
vault.entries[key] = {
|
||||
...current,
|
||||
...patch,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await writeVault(vault);
|
||||
await withFileLock(getOAuthVaultPath(), async () => {
|
||||
const vault = await readVault();
|
||||
const key = vaultKeyForDefinition(definition);
|
||||
const current = vault.entries[key] ?? {
|
||||
serverName: definition.name,
|
||||
serverUrl: definition.command.kind === 'http' ? definition.command.url.toString() : undefined,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
vault.entries[key] = {
|
||||
...current,
|
||||
...patch,
|
||||
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);
|
||||
const existing = vault.entries[key];
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
if (scope === 'all') {
|
||||
delete vault.entries[key];
|
||||
} else {
|
||||
const updated: VaultEntry = { ...existing };
|
||||
if (scope === 'tokens') {
|
||||
delete updated.tokens;
|
||||
await withFileLock(getOAuthVaultPath(), async () => {
|
||||
const { vault, needsRepair } = await readVaultState();
|
||||
const existing = vault.entries[key];
|
||||
if (!existing) {
|
||||
if (needsRepair) {
|
||||
await writeVault(vault);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (scope === 'client') {
|
||||
delete updated.clientInfo;
|
||||
if (scope === 'all') {
|
||||
delete vault.entries[key];
|
||||
} else {
|
||||
const updated: VaultEntry = { ...existing };
|
||||
if (scope === 'tokens') {
|
||||
delete updated.tokens;
|
||||
}
|
||||
if (scope === 'client') {
|
||||
delete updated.clientInfo;
|
||||
}
|
||||
if (scope === 'verifier') {
|
||||
delete updated.codeVerifier;
|
||||
}
|
||||
if (scope === 'state') {
|
||||
delete updated.state;
|
||||
}
|
||||
updated.updatedAt = new Date().toISOString();
|
||||
vault.entries[key] = updated;
|
||||
}
|
||||
if (scope === 'verifier') {
|
||||
delete updated.codeVerifier;
|
||||
}
|
||||
if (scope === 'state') {
|
||||
delete updated.state;
|
||||
}
|
||||
updated.updatedAt = new Date().toISOString();
|
||||
vault.entries[key] = updated;
|
||||
}
|
||||
await writeVault(vault);
|
||||
await writeVault(vault);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
process.env.XDG_CONFIG_HOME = originalXdg;
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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: {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user