diff --git a/.gitignore b/.gitignore index 52fdc70..1cfc23f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules +.pnpm-store .DS_Store dist dist-bun diff --git a/CHANGELOG.md b/CHANGELOG.md index 09b3ccc..923f8b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/cli/adhoc-server.ts b/src/cli/adhoc-server.ts index bf33b02..8c7cb6d 100644 --- a/src/cli/adhoc-server.ts +++ b/src/cli/adhoc-server.ts @@ -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 { const resolvedPath = path.resolve(expandHome(rawPath)); - let existing: Record; - try { - const buffer = await fs.readFile(resolvedPath, 'utf8'); - existing = JSON.parse(buffer) as Record; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - throw error; + await withFileLock(resolvedPath, async () => { + let existing: Record; + try { + const buffer = await fs.readFile(resolvedPath, 'utf8'); + existing = JSON.parse(buffer) as Record; + } 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; - servers[resolution.name] = resolution.persistedEntry; + if (typeof existing.mcpServers !== 'object' || existing.mcpServers === null) { + existing.mcpServers = {}; + } + const servers = existing.mcpServers as Record; + 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 { diff --git a/src/cli/config/add.ts b/src/cli/config/add.ts index dd5ce29..3254292 100644 --- a/src/cli/config/add.ts +++ b/src/cli/config/add.ts @@ -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}`); } diff --git a/src/cli/config/import.ts b/src/cli/config/import.ts index 757daa0..a6d58de 100644 --- a/src/cli/config/import.ts +++ b/src/cli/config/import.ts @@ -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}`); } } diff --git a/src/cli/config/remove.ts b/src/cli/config/remove.ts index 52621dd..a0b27a0 100644 --- a/src/cli/config/remove.ts +++ b/src/cli/config/remove.ts @@ -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 '); } - 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}`); } diff --git a/src/config.ts b/src/config.ts index ed747a2..72a1e6e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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 { - 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( diff --git a/src/daemon/host.ts b/src/daemon/host.ts index 9dfe627..f7eaa24 100644 --- a/src/daemon/host.ts +++ b/src/daemon/host.ts @@ -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 { }); }); - 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 => { diff --git a/src/fs-json.ts b/src/fs-json.ts index 131f3d6..0c18ae8 100644 --- a/src/fs-json.ts +++ b/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(filePath: string): Promise { try { @@ -14,8 +22,226 @@ export async function readJsonFile(filePath: string): Promise { + 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 { - 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( + filePath: string, + task: () => Promise, + options: { timeoutMs?: number } = {} +): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + try { + const stats = await fs.stat(lockPath); + return Date.now() - stats.mtimeMs > MALFORMED_LOCK_STALE_MS; + } catch { + return false; + } } diff --git a/src/oauth-persistence.ts b/src/oauth-persistence.ts index b372684..1d4d8fa 100644 --- a/src/oauth-persistence.ts +++ b/src/oauth-persistence.ts @@ -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 { await this.ensureDir(); - await fs.writeFile(this.codeVerifierPath, value, 'utf8'); + await writeTextFileAtomic(this.codeVerifierPath, value); } async readState(): Promise { diff --git a/src/oauth-vault.ts b/src/oauth-vault.ts index 7995d61..4d4994c 100644 --- a/src/oauth-vault.ts +++ b/src/oauth-vault.ts @@ -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; } +interface VaultReadState { + vault: VaultFile; + needsRepair: boolean; +} + export function getOAuthVaultPath(): string { return path.join(mcporterDir('data'), 'credentials.json'); } -async function readVault(): Promise { - let shouldRewrite = false; +async function readVaultState(): Promise { try { const existing = await readJsonFile(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 { + return (await readVaultState()).vault; +} + +function emptyVault(): VaultFile { + return { version: 1, entries: {} }; } async function writeVault(contents: VaultFile): Promise { - 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): Promise { - 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 { - 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); + }); } diff --git a/src/result-utils.ts b/src/result-utils.ts index 569e5a9..a3fa605 100644 --- a/src/result-utils.ts +++ b/src/result-utils.ts @@ -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; - 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; - 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. diff --git a/src/schema-cache.ts b/src/schema-cache.ts index f8622e6..37663e4 100644 --- a/src/schema-cache.ts +++ b/src/schema-cache.ts @@ -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 { const filePath = resolveSchemaCachePath(definition); try { - const raw = await fs.readFile(filePath, 'utf8'); - const parsed = JSON.parse(raw) as SchemaCacheSnapshot; + const parsed = await readJsonFile(filePath); if (!parsed || typeof parsed !== 'object') { return undefined; } @@ -43,7 +42,5 @@ export async function readSchemaCache(definition: ServerDefinition): Promise { - 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); } diff --git a/tests/cli-config-command.test.ts b/tests/cli-config-command.test.ts index 336f33d..e5094c4 100644 --- a/tests/cli-config-command.test.ts +++ b/tests/cli-config-command.test.ts @@ -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(); }); diff --git a/tests/cli-output-utils.test.ts b/tests/cli-output-utils.test.ts index 2dde523..ffee685 100644 --- a/tests/cli-output-utils.test.ts +++ b/tests/cli-output-utils.test.ts @@ -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', diff --git a/tests/config-import.test.ts b/tests/config-import.test.ts index 247c2fb..e4e57c3 100644 --- a/tests/config-import.test.ts +++ b/tests/config-import.test.ts @@ -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( diff --git a/tests/fs-json.test.ts b/tests/fs-json.test.ts index 6c2d4c9..cdbae25 100644 --- a/tests/fs-json.test.ts +++ b/tests/fs-json.test.ts @@ -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(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(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(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(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(); + }); }); diff --git a/tests/oauth-persistence.test.ts b/tests/oauth-persistence.test.ts index 1920a6f..25a2b32 100644 --- a/tests/oauth-persistence.test.ts +++ b/tests/oauth-persistence.test.ts @@ -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 } + | 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); diff --git a/tests/result-utils.test.ts b/tests/result-utils.test.ts index 3398220..5615449 100644 --- a/tests/result-utils.test.ts +++ b/tests/result-utils.test.ts @@ -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: {