diff --git a/CHANGELOG.md b/CHANGELOG.md index d19ccb8..0b89bc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### CLI +- Add `mcporter record` and `mcporter replay` helpers for capturing and replaying MCP JSON-RPC traffic, with server filters and daemon-safe manual env setup. (PR #192, thanks @LDMB123) - Reconcile keep-alive daemon metadata with the responding process and serialize daemon startup across parallel clients, preventing duplicate orphaned daemons. (Issue #191, thanks @dtmsyi) - Keep daemon-managed stdio servers warm across repeated `mcporter list` requests instead of treating non-interactive tool listing as a throwaway process. (Issue #188, thanks @robertoronderosjr) diff --git a/README.md b/README.md index 13503e0..56e4f50 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ MCPorter helps you lean into the "code execution" workflows highlighted in Anthr - **One-command CLI generation.** `mcporter generate-cli` turns any MCP server definition into a ready-to-run CLI, with optional bundling/compilation and metadata for easy regeneration. - **Typed tool clients.** `mcporter emit-ts` emits `.d.ts` interfaces or ready-to-run client wrappers so agents/tests can call MCP servers with strong TypeScript types without hand-writing plumbing. - **Friendly composable API.** `createServerProxy()` exposes tools as ergonomic camelCase methods, automatically applies JSON-schema defaults, validates required arguments, and hands back a `CallResult` with `.text()`, `.markdown()`, `.json()`, `.images()`, and `.content()` helpers. +- **Record/replay fixtures.** `mcporter record` captures MCP JSON-RPC traffic as NDJSON, and `mcporter replay` serves the same responses deterministically for offline debugging and redacted repros. - **OAuth and stdio ergonomics.** Built-in OAuth caching, log tailing, and stdio wrappers let you work with HTTP, SSE, and stdio transports from the same interface. - **Ad-hoc connections.** Point the CLI at _any_ MCP endpoint (HTTP or stdio) without touching config, then persist it later if you want. Hosted MCPs that expect a browser login (Supabase, Vercel, etc.) are auto-detected—just run `mcporter auth ` and the CLI promotes the definition to OAuth on the fly. See [docs/adhoc.md](docs/adhoc.md). diff --git a/docs/record-replay.md b/docs/record-replay.md new file mode 100644 index 0000000..a93588e --- /dev/null +++ b/docs/record-replay.md @@ -0,0 +1,52 @@ +--- +summary: 'How to record MCP JSON-RPC traffic to NDJSON and replay it deterministically for offline debugging.' +read_when: + - 'Debugging or reproducing MCP-backed tool calls without contacting the live server.' +--- + +# Record and replay MCP calls + +`mcporter record` captures the JSON-RPC traffic between the runtime and configured MCP servers. `mcporter replay` reads the captured stream and serves the recorded responses back to the same requests without contacting the live MCP server. + +Recordings live under `~/.mcporter/recordings/` as newline-delimited JSON: + +```bash +mcporter record demo-session -- mcporter call linear.list_issues limit:5 +mcporter replay demo-session -- mcporter call linear.list_issues limit:5 +``` + +Recordings contain raw JSON-RPC params and responses. Review and redact them before sharing, attaching to bug reports, or committing them to a repository because tool arguments and results can include credentials, private content, or customer data. + +To record or replay a later command, create the session configuration and export the matching environment variable: + +```bash +mcporter record demo-session +MCPORTER_RECORD=demo-session mcporter call linear.list_issues limit:5 + +mcporter replay demo-session +MCPORTER_REPLAY=demo-session mcporter call linear.list_issues limit:5 +``` + +Use `--server` when you only want one server's traffic: + +```bash +mcporter record demo-session --server linear -- mcporter call linear.list_issues limit:5 +mcporter replay demo-session --server linear -- mcporter call linear.list_issues limit:5 +``` + +## File format + +Each line is one JSON-RPC envelope with an added `_meta` object: + +```json +{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_issues","arguments":{"limit":5}},"_meta":{"dir":"send","server":"linear","ts":"2026-05-16T12:00:00.000Z"}} +{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"..."}]},"_meta":{"dir":"recv","server":"linear","ts":"2026-05-16T12:00:00.100Z"}} +``` + +`_meta.dir` is `send`, `recv`, or `lifecycle`. Replay strips `_meta` before delivering a response. Lifecycle events such as transport start and close are recorded for diagnostics but ignored during replay. + +## Deterministic matching + +Replay is strict. For each server, mcporter expects requests to arrive in the same order with the same JSON-RPC method and deeply equal `params`. If the next request differs, replay fails with an error that names the incoming request and the next recorded request it expected. + +This makes recordings useful as reproducible bug fixtures: a replay either follows the captured MCP exchange exactly or fails at the first point where the workflow diverges. diff --git a/src/cli.ts b/src/cli.ts index caafc3c..3695754 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,6 +4,7 @@ import { inferCommandRouting } from './cli/command-inference.js'; import { CliUsageError } from './cli/errors.js'; import { consumeHelpTokens, isHelpToken, isVersionToken, printHelp, printVersion } from './cli/help-output.js'; import { logError, logInfo } from './cli/logger-context.js'; +import { isRecordReplayModeActive, isReplayModeActive } from './cli/record-replay-env.js'; import { DEBUG_HANG, dumpActiveHandles, terminateChildProcesses } from './cli/runtime-debug.js'; import { resolveConfigPath } from './config/path-discovery.js'; import type { Runtime, RuntimeOptions } from './runtime.js'; @@ -154,6 +155,28 @@ export async function runCli(argv: string[]): Promise { return; } + if (command === 'record') { + const { handleRecordCli, printRecordHelp } = await import('./cli/record-command.js'); + if (consumeHelpTokens(wrapperArgsBeforeSeparator(args))) { + printRecordHelp(); + process.exitCode = 0; + return; + } + await handleRecordCli(args); + return; + } + + if (command === 'replay') { + const { handleReplayCli, printReplayHelp } = await import('./cli/replay-command.js'); + if (consumeHelpTokens(wrapperArgsBeforeSeparator(args))) { + printReplayHelp(); + process.exitCode = 0; + return; + } + await handleReplayCli(args); + return; + } + if (command === 'config') { const { handleConfigCli } = await import('./cli/config-command.js'); await handleConfigCli( @@ -197,14 +220,17 @@ export async function runCli(argv: string[]): Promise { import('./lifecycle.js'), ]); const baseRuntime = await createRuntime(runtimeOptionsWithPath); - const keepAliveServers = new Set( - baseRuntime - .getDefinitions() - .filter(isKeepAliveServer) - .map((entry) => entry.name) - ); + const recordReplayModeActive = isRecordReplayModeActive(); + const keepAliveServers = recordReplayModeActive + ? new Set() + : new Set( + baseRuntime + .getDefinitions() + .filter(isKeepAliveServer) + .map((entry) => entry.name) + ); const daemonClient = - keepAliveServers.size > 0 + !recordReplayModeActive && keepAliveServers.size > 0 ? new DaemonClient({ configPath: configResolution.path, configExplicit: configResolution.explicit, @@ -221,6 +247,7 @@ export async function runCli(argv: string[]): Promise { const resolvedCommand = inference.command; const resolvedArgs = inference.args; + let primaryError: unknown; try { if (resolvedCommand === 'list') { if (consumeHelpTokens(resolvedArgs)) { @@ -281,48 +308,70 @@ export async function runCli(argv: string[]): Promise { await importedHandleResource(runtime, resolvedArgs); return; } + } catch (error) { + primaryError = error; + throw error; } finally { - const closeStart = Date.now(); - if (DEBUG_HANG) { - logInfo('[debug] beginning runtime.close()'); - dumpActiveHandles('before runtime.close'); - } - try { - await runtime.close(); - if (DEBUG_HANG) { - const duration = Date.now() - closeStart; - logInfo(`[debug] runtime.close() completed in ${duration}ms`); - dumpActiveHandles('after runtime.close'); - } - } catch (error) { - if (DEBUG_HANG) { - logError('[debug] runtime.close() failed', error); - } - } finally { - terminateChildProcesses('runtime.finally'); - // By default we force an exit after cleanup so Node doesn't hang on lingering stdio handles - // (see typescript-sdk#579/#780/#1049). Opt out by exporting MCPORTER_NO_FORCE_EXIT=1. - const disableForceExit = process.env.MCPORTER_NO_FORCE_EXIT === '1'; - const shouldForceExit = !disableForceExit || process.env.MCPORTER_FORCE_EXIT === '1'; - const scheduleForcedExit = () => { - if (shouldForceExit) { - setTimeout(() => { - process.exit(process.exitCode ?? 0); - }, FORCE_EXIT_GRACE_MS); - } - }; - if (DEBUG_HANG) { - dumpActiveHandles('after terminateChildProcesses'); - scheduleForcedExit(); - } else { - setImmediate(scheduleForcedExit); - } - } + await closeRuntimeAfterCommand(runtime, { suppressReplayCloseError: primaryError !== undefined }); } printHelp(`Unknown command '${resolvedCommand}'.`); process.exit(1); } +async function closeRuntimeAfterCommand( + runtime: Runtime, + options: { readonly suppressReplayCloseError?: boolean } = {} +): Promise { + const closeStart = Date.now(); + let closeError: unknown; + if (DEBUG_HANG) { + logInfo('[debug] beginning runtime.close()'); + dumpActiveHandles('before runtime.close'); + } + try { + await runtime.close(); + if (DEBUG_HANG) { + const duration = Date.now() - closeStart; + logInfo(`[debug] runtime.close() completed in ${duration}ms`); + dumpActiveHandles('after runtime.close'); + } + } catch (error) { + if (DEBUG_HANG) { + logError('[debug] runtime.close() failed', error); + } + if (isReplayModeActive() && !options.suppressReplayCloseError) { + closeError = error; + } + } finally { + terminateChildProcesses('runtime.finally'); + // By default we force an exit after cleanup so Node doesn't hang on lingering stdio handles + // (see typescript-sdk#579/#780/#1049). Opt out by exporting MCPORTER_NO_FORCE_EXIT=1. + const disableForceExit = process.env.MCPORTER_NO_FORCE_EXIT === '1'; + const shouldForceExit = !disableForceExit || process.env.MCPORTER_FORCE_EXIT === '1'; + const scheduleForcedExit = () => { + if (shouldForceExit) { + setTimeout(() => { + process.exit(process.exitCode ?? 0); + }, FORCE_EXIT_GRACE_MS); + } + }; + if (DEBUG_HANG) { + dumpActiveHandles('after terminateChildProcesses'); + scheduleForcedExit(); + } else { + setImmediate(scheduleForcedExit); + } + } + if (closeError) { + throw closeError; + } +} + +function wrapperArgsBeforeSeparator(args: readonly string[]): string[] { + const separatorIndex = args.indexOf('--'); + return separatorIndex === -1 ? [...args] : args.slice(0, separatorIndex); +} + // main parses CLI flags and dispatches to list/call commands. async function main(): Promise { await runCli(process.argv.slice(2)); @@ -360,6 +409,9 @@ async function maybeHandleDaemonFastCall( configResolution: { path: string; explicit: boolean }, rootDir: string | undefined ): Promise { + if (isRecordReplayModeActive()) { + return false; + } const callArgs = resolveDaemonFastCallArgs(command, args); if (!callArgs) { return false; @@ -454,6 +506,8 @@ function isExplicitNonCallCommand(command: string): boolean { command === 'resources' || command === 'daemon' || command === 'serve' || + command === 'record' || + command === 'replay' || command === 'config' || command === 'emit-ts' || command === 'generate-cli' || diff --git a/src/cli/flag-utils.ts b/src/cli/flag-utils.ts index 8ae266a..52538e0 100644 --- a/src/cli/flag-utils.ts +++ b/src/cli/flag-utils.ts @@ -6,6 +6,9 @@ export function extractFlags(args: string[], keys: readonly string[]): FlagMap { let index = 0; while (index < args.length) { const token = args[index]; + if (token === '--') { + break; + } if (token === undefined || !keys.includes(token)) { index += 1; continue; diff --git a/src/cli/help-output.ts b/src/cli/help-output.ts index 52b6211..4c17725 100644 --- a/src/cli/help-output.ts +++ b/src/cli/help-output.ts @@ -72,6 +72,16 @@ function buildCommandSections(colorize: boolean): string[] { summary: 'Seed or clear OAuth credentials non-interactively', usage: 'mcporter vault set --tokens-file ', }, + { + name: 'record', + summary: 'Capture MCP JSON-RPC traffic to NDJSON', + usage: 'mcporter record [--server ] [-- ]', + }, + { + name: 'replay', + summary: 'Replay recorded MCP JSON-RPC traffic deterministically', + usage: 'mcporter replay [--server ] [-- ]', + }, ], }, { diff --git a/src/cli/record-command.ts b/src/cli/record-command.ts new file mode 100644 index 0000000..8ffba30 --- /dev/null +++ b/src/cli/record-command.ts @@ -0,0 +1,150 @@ +import { spawn } from 'node:child_process'; +import fs from 'node:fs/promises'; +import { + ensurePrivateRecordingDir, + PRIVATE_RECORDING_FILE_MODE, + resolveRecordingConfigPath, + resolveRecordingPath, +} from '../runtime/record-transport.js'; +import { buildRecordCommandEnv } from './record-replay-env.js'; + +export interface ParsedRecordArgs { + readonly sessionName: string; + readonly server?: string; + readonly command: string[]; +} + +export async function handleRecordCli(args: string[]): Promise { + const parsed = parseRecordArgs(args); + const recordPath = resolveRecordingPath(parsed.sessionName); + + if (parsed.command.length > 0) { + await runWithRecordingEnv(parsed, buildRecordCommandEnv(parsed.sessionName, parsed.server)); + return; + } + + await writeModeConfig(parsed, { + mode: 'record', + recordPath, + env: { + MCPORTER_RECORD: parsed.sessionName, + ...(parsed.server ? { MCPORTER_RECORD_SERVER: parsed.server } : {}), + MCPORTER_DISABLE_KEEPALIVE: '*', + }, + }); + console.log(`Recording configuration written to ${resolveRecordingConfigPath(parsed.sessionName)}`); + const envInstructions = [ + `MCPORTER_RECORD=${parsed.sessionName}`, + ...(parsed.server ? [`MCPORTER_RECORD_SERVER=${parsed.server}`] : []), + 'MCPORTER_DISABLE_KEEPALIVE=*', + ]; + console.log(`Set ${envInstructions.join(' and ')} before the next mcporter call to record ${recordPath}.`); +} + +export function printRecordHelp(): void { + console.log(`Usage: mcporter record [--server ] [-- ] + +Capture MCP JSON-RPC traffic to ~/.mcporter/recordings/.ndjson. + +Flags: + --server Restrict recording to one configured server.`); +} + +export function parseRecordArgs(args: string[]): ParsedRecordArgs { + return parseSessionCommandArgs(args, 'record'); +} + +export function parseReplayArgs(args: string[]): ParsedRecordArgs { + return parseSessionCommandArgs(args, 'replay'); +} + +async function writeModeConfig(parsed: ParsedRecordArgs, extra: Record): Promise { + const configPath = resolveRecordingConfigPath(parsed.sessionName); + await ensurePrivateRecordingDir(configPath); + await fs.writeFile( + configPath, + `${JSON.stringify( + { + session: parsed.sessionName, + server: parsed.server, + ...extra, + }, + null, + 2 + )}\n`, + { + encoding: 'utf8', + mode: PRIVATE_RECORDING_FILE_MODE, + } + ); + await fs.chmod(configPath, PRIVATE_RECORDING_FILE_MODE); +} + +async function runWithRecordingEnv(parsed: ParsedRecordArgs, env: NodeJS.ProcessEnv): Promise { + const [command, ...commandArgs] = parsed.command; + if (!command) { + return; + } + await new Promise((resolve, reject) => { + const child = spawn(command, commandArgs, { + stdio: 'inherit', + env, + }); + child.once('error', reject); + child.once('exit', (code, signal) => { + if (signal) { + reject(new Error(`Command '${command}' exited from signal ${signal}.`)); + return; + } + process.exitCode = code ?? 0; + resolve(); + }); + }); +} + +function parseSessionCommandArgs(args: string[], commandName: 'record' | 'replay'): ParsedRecordArgs { + let server: string | undefined; + const tokens = [...args]; + const commandSeparator = tokens.indexOf('--'); + const command = commandSeparator === -1 ? [] : tokens.splice(commandSeparator); + if (command[0] === '--') { + command.shift(); + } + + const remaining: string[] = []; + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (!token) { + continue; + } + if (token === '--server') { + const value = tokens[index + 1]; + if (!value) { + throw new Error("Flag '--server' requires a server name."); + } + server = value; + index += 1; + continue; + } + if (token.startsWith('--server=')) { + server = token.slice('--server='.length); + if (!server) { + throw new Error("Flag '--server' requires a server name."); + } + continue; + } + if (token.startsWith('-')) { + throw new Error(`Unknown ${commandName} flag '${token}'.`); + } + remaining.push(token); + } + + const sessionName = remaining[0]; + if (!sessionName) { + throw new Error(`Usage: mcporter ${commandName} [--server ] [-- ]`); + } + if (remaining.length > 1) { + throw new Error(`Unexpected ${commandName} argument '${remaining[1]}'. Put commands after '--'.`); + } + return { sessionName, server, command }; +} diff --git a/src/cli/record-replay-env.ts b/src/cli/record-replay-env.ts new file mode 100644 index 0000000..c568097 --- /dev/null +++ b/src/cli/record-replay-env.ts @@ -0,0 +1,46 @@ +const KEEP_ALIVE_DISABLED_FOR_MODE = '*'; + +export function buildRecordCommandEnv(sessionName: string, server: string | undefined): NodeJS.ProcessEnv { + return buildModeEnv( + { + MCPORTER_RECORD: sessionName, + MCPORTER_RECORD_SERVER: server, + MCPORTER_DISABLE_KEEPALIVE: KEEP_ALIVE_DISABLED_FOR_MODE, + }, + ['MCPORTER_REPLAY', 'MCPORTER_REPLAY_SERVER'] + ); +} + +export function buildReplayCommandEnv(sessionName: string, server: string | undefined): NodeJS.ProcessEnv { + return buildModeEnv( + { + MCPORTER_REPLAY: sessionName, + MCPORTER_REPLAY_SERVER: server, + MCPORTER_DISABLE_KEEPALIVE: KEEP_ALIVE_DISABLED_FOR_MODE, + }, + ['MCPORTER_RECORD', 'MCPORTER_RECORD_SERVER'] + ); +} + +export function isRecordReplayModeActive(env: NodeJS.ProcessEnv = process.env): boolean { + return Boolean(env.MCPORTER_RECORD || env.MCPORTER_REPLAY); +} + +export function isReplayModeActive(env: NodeJS.ProcessEnv = process.env): boolean { + return Boolean(!env.MCPORTER_RECORD && env.MCPORTER_REPLAY); +} + +function buildModeEnv(set: Record, unset: readonly string[]): NodeJS.ProcessEnv { + const env = { ...process.env }; + for (const key of unset) { + delete env[key]; + } + for (const [key, value] of Object.entries(set)) { + if (value) { + env[key] = value; + } else { + delete env[key]; + } + } + return env; +} diff --git a/src/cli/replay-command.ts b/src/cli/replay-command.ts new file mode 100644 index 0000000..b14a846 --- /dev/null +++ b/src/cli/replay-command.ts @@ -0,0 +1,84 @@ +import { spawn } from 'node:child_process'; +import fs from 'node:fs/promises'; +import { + ensurePrivateRecordingDir, + PRIVATE_RECORDING_FILE_MODE, + resolveRecordingConfigPath, + resolveRecordingPath, +} from '../runtime/record-transport.js'; +import { parseReplayArgs } from './record-command.js'; +import { buildReplayCommandEnv } from './record-replay-env.js'; + +export async function handleReplayCli(args: string[]): Promise { + const parsed = parseReplayArgs(args); + const replayPath = resolveRecordingPath(parsed.sessionName); + + if (parsed.command.length > 0) { + await runWithReplayEnv(parsed.command, buildReplayCommandEnv(parsed.sessionName, parsed.server)); + return; + } + + const configPath = resolveRecordingConfigPath(parsed.sessionName); + await ensurePrivateRecordingDir(configPath); + await fs.writeFile( + configPath, + `${JSON.stringify( + { + session: parsed.sessionName, + server: parsed.server, + mode: 'replay', + replayPath, + env: { + MCPORTER_REPLAY: parsed.sessionName, + ...(parsed.server ? { MCPORTER_REPLAY_SERVER: parsed.server } : {}), + MCPORTER_DISABLE_KEEPALIVE: '*', + }, + }, + null, + 2 + )}\n`, + { + encoding: 'utf8', + mode: PRIVATE_RECORDING_FILE_MODE, + } + ); + await fs.chmod(configPath, PRIVATE_RECORDING_FILE_MODE); + console.log(`Replay configuration written to ${configPath}`); + const envInstructions = [ + `MCPORTER_REPLAY=${parsed.sessionName}`, + ...(parsed.server ? [`MCPORTER_REPLAY_SERVER=${parsed.server}`] : []), + 'MCPORTER_DISABLE_KEEPALIVE=*', + ]; + console.log(`Set ${envInstructions.join(' and ')} before the next mcporter call to replay ${replayPath}.`); +} + +export function printReplayHelp(): void { + console.log(`Usage: mcporter replay [--server ] [-- ] + +Replay MCP JSON-RPC traffic from ~/.mcporter/recordings/.ndjson. + +Flags: + --server Restrict replay to one configured server.`); +} + +async function runWithReplayEnv(commandAndArgs: string[], env: NodeJS.ProcessEnv): Promise { + const [command, ...args] = commandAndArgs; + if (!command) { + return; + } + await new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: 'inherit', + env, + }); + child.once('error', reject); + child.once('exit', (code, signal) => { + if (signal) { + reject(new Error(`Command '${command}' exited from signal ${signal}.`)); + return; + } + process.exitCode = code ?? 0; + resolve(); + }); + }); +} diff --git a/src/runtime-process-utils.ts b/src/runtime-process-utils.ts index 535edd4..9b12ccc 100644 --- a/src/runtime-process-utils.ts +++ b/src/runtime-process-utils.ts @@ -4,26 +4,40 @@ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; import type { Logger } from './logging.js'; +export interface CloseTransportAndWaitOptions { + readonly throwOnCloseError?: boolean; +} + // closeTransportAndWait closes transports and ensures backing processes exit cleanly. export async function closeTransportAndWait( logger: Logger, - transport: Transport & { close(): Promise } + transport: Transport & { close(): Promise }, + options: CloseTransportAndWaitOptions = {} ): Promise { const pidBeforeClose = getTransportPid(transport); const childProcess = transport instanceof StdioClientTransport ? ((transport as unknown as { _process?: ChildProcess | null })._process ?? null) : null; + let closeError: unknown; try { await transport.close(); } catch (error) { - logger.warn(`Failed to close transport cleanly: ${(error as Error).message}`); + if (options.throwOnCloseError) { + closeError = error; + } else { + logger.warn(`Failed to close transport cleanly: ${(error as Error).message}`); + } } if (childProcess) { await waitForChildClose(childProcess, 1_000).catch(() => {}); } + if (closeError) { + throw closeError; + } + if (!pidBeforeClose) { return; } diff --git a/src/runtime.ts b/src/runtime.ts index 4d6a015..95d4769 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -6,6 +6,8 @@ import { closeTransportAndWait } from './runtime-process-utils.js'; import './sdk-patches.js'; import { shouldResetConnection } from './runtime/errors.js'; import { resolveOAuthTimeoutFromEnv } from './runtime/oauth.js'; +import { resolveRecordingPath } from './runtime/record-transport.js'; +import { ReplayTransport } from './runtime/replay-transport.js'; import { type ClientContext, createClientContext } from './runtime/transport.js'; import { normalizeTimeout, raceWithTimeout } from './runtime/utils.js'; import { filterTools, isToolAllowed, validateToolFilters } from './tool-filters.js'; @@ -113,6 +115,8 @@ class McpRuntime implements Runtime { private readonly logger: RuntimeLogger; private readonly clientInfo: { name: string; version: string }; private readonly oauthTimeoutMs?: number; + private readonly recordPath?: string; + private readonly replayPath?: string; constructor(servers: ServerDefinition[], options: RuntimeOptions = {}) { for (const server of servers) { @@ -125,6 +129,13 @@ class McpRuntime implements Runtime { version: MCPORTER_VERSION, }; this.oauthTimeoutMs = options.oauthTimeoutMs; + const recordSession = process.env.MCPORTER_RECORD; + const replaySession = process.env.MCPORTER_REPLAY; + if (recordSession && replaySession) { + this.logger.warn('Both MCPORTER_RECORD and MCPORTER_REPLAY are set; recording mode wins.'); + } + this.recordPath = recordSession ? resolveRecordingPath(recordSession) : undefined; + this.replayPath = !recordSession && replaySession ? resolveRecordingPath(replaySession) : undefined; } // listServers returns configured names sorted alphabetically for stable CLI output. @@ -184,8 +195,9 @@ class McpRuntime implements Runtime { allowCachedAuth: options.allowCachedAuth ?? true, oauthSessionOptions: options.oauthSessionOptions, }); + let closeError: unknown; + const tools: ServerToolInfo[] = []; try { - const tools: ServerToolInfo[] = []; let cursor: string | undefined; do { const response = await context.client.listTools(cursor ? { cursor } : undefined); @@ -199,8 +211,6 @@ class McpRuntime implements Runtime { ); cursor = response.nextCursor ?? undefined; } while (cursor); - - return filterTools(tools, this.definitions.get(server.trim())); } catch (error) { // Keep-alive STDIO transports often die when Chrome closes; drop the cached client // so the next call spins up a fresh process instead of reusing the broken handle. @@ -208,11 +218,18 @@ class McpRuntime implements Runtime { throw error; } finally { if (!autoAuthorize) { - await context.client.close().catch(() => {}); - await closeTransportAndWait(this.logger, context.transport).catch(() => {}); - await context.oauthSession?.close().catch(() => {}); + try { + await this.closeContext(context); + } catch (error) { + closeError = error; + } } } + if (closeError !== undefined) { + throw closeError; + } + + return filterTools(tools, this.definitions.get(server.trim())); } // callTool executes a tool using the args provided by the caller. @@ -302,6 +319,8 @@ class McpRuntime implements Runtime { onDefinitionPromoted: (promoted) => this.definitions.set(promoted.name, promoted), allowCachedAuth: options.allowCachedAuth, oauthSessionOptions: options.oauthSessionOptions, + recordPath: this.recordPath, + replayPath: this.replayPath, }); if (useCache) { @@ -326,25 +345,53 @@ class McpRuntime implements Runtime { return; } const context = await cached.promise; - await context.client.close().catch(() => {}); - await closeTransportAndWait(this.logger, context.transport).catch(() => {}); - await context.oauthSession?.close().catch(() => {}); - this.clients.delete(normalized); + try { + await this.closeContext(context); + } finally { + this.clients.delete(normalized); + } return; } for (const [name, cached] of this.clients.entries()) { try { const context = await cached.promise; - await context.client.close().catch(() => {}); - await closeTransportAndWait(this.logger, context.transport).catch(() => {}); - await context.oauthSession?.close().catch(() => {}); + await this.closeContext(context); } finally { this.clients.delete(name); } } } + private async closeContext(context: ClientContext): Promise { + const propagateReplayCloseErrors = context.transport instanceof ReplayTransport; + let closeError: unknown; + + try { + await context.client.close(); + } catch (error) { + if (propagateReplayCloseErrors) { + closeError ??= error; + } + } + + try { + await closeTransportAndWait(this.logger, context.transport, { + throwOnCloseError: propagateReplayCloseErrors, + }); + } catch (error) { + if (propagateReplayCloseErrors) { + closeError ??= error; + } + } + + await context.oauthSession?.close().catch(() => {}); + + if (closeError) { + throw closeError; + } + } + private async resetConnectionOnError(server: string, error: unknown): Promise { if (!shouldResetConnection(error)) { return; diff --git a/src/runtime/record-transport.ts b/src/runtime/record-transport.ts new file mode 100644 index 0000000..0b9be8d --- /dev/null +++ b/src/runtime/record-transport.ts @@ -0,0 +1,181 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type { ChildProcess } from 'node:child_process'; +import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; +import type { Transport, TransportSendOptions } from '@modelcontextprotocol/sdk/shared/transport.js'; +import { legacyMcporterDir } from '../paths.js'; + +export interface RecordTransportOptions { + readonly inner: Transport; + readonly recordPath: string; + readonly server: string; +} + +export interface RecordingMeta { + readonly dir: 'send' | 'recv' | 'lifecycle'; + readonly server: string; + readonly ts: string; +} + +export type RecordedMessage = JSONRPCMessage & { + readonly _meta?: RecordingMeta; +}; + +const initializedRecordingPaths = new Map>(); +export const PRIVATE_RECORDING_DIR_MODE = 0o700; +export const PRIVATE_RECORDING_FILE_MODE = 0o600; + +export class RecordTransport implements Transport { + onclose?: Transport['onclose']; + onerror?: Transport['onerror']; + onmessage?: Transport['onmessage']; + sessionId?: string; + finishAuth?: (authorizationCode: string) => Promise; + + private writes: Promise = Promise.resolve(); + private closeRecorded = false; + + constructor(private readonly opts: RecordTransportOptions) { + this.sessionId = opts.inner.sessionId; + const finishAuth = (opts.inner as { finishAuth?: (authorizationCode: string) => Promise }).finishAuth; + if (finishAuth) { + this.finishAuth = (authorizationCode) => finishAuth.call(opts.inner, authorizationCode); + } + } + + get pid(): number | null { + const pid = (this.opts.inner as { pid?: unknown }).pid; + return typeof pid === 'number' && pid > 0 ? pid : null; + } + + get _process(): ChildProcess | null { + return (this.opts.inner as { _process?: ChildProcess | null })._process ?? null; + } + + async start(): Promise { + await initializeRecordingFile(this.opts.recordPath); + this.opts.inner.onclose = () => { + void this.appendCloseOnce(); + this.onclose?.(); + }; + this.opts.inner.onerror = (error) => { + this.onerror?.(error); + }; + this.opts.inner.onmessage = (message) => { + void this.appendLine(this.withMeta(message, 'recv')); + this.onmessage?.(message); + }; + await this.appendLifecycle('start'); + await this.opts.inner.start(); + this.sessionId = this.opts.inner.sessionId; + } + + async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise { + await this.appendLine(this.withMeta(message, 'send')); + await this.opts.inner.send(message, options); + } + + async close(): Promise { + await this.appendCloseOnce(); + await this.opts.inner.close(); + await this.writes; + } + + setProtocolVersion(version: string): void { + this.opts.inner.setProtocolVersion?.(version); + } + + private async appendLifecycle(event: 'start' | 'close'): Promise { + await this.appendLine( + this.withMeta( + { + jsonrpc: '2.0', + method: `$transport/${event}`, + }, + 'lifecycle' + ) + ); + } + + private async appendCloseOnce(): Promise { + if (this.closeRecorded) { + return; + } + this.closeRecorded = true; + await this.appendLifecycle('close'); + } + + private withMeta(message: JSONRPCMessage, dir: RecordingMeta['dir']): RecordedMessage { + return { + ...message, + _meta: { + dir, + server: this.opts.server, + ts: new Date().toISOString(), + }, + }; + } + + private async appendLine(message: RecordedMessage): Promise { + const line = `${JSON.stringify(message)}\n`; + this.writes = this.writes.then(async () => { + await ensurePrivateRecordingDir(this.opts.recordPath); + await fs.appendFile(this.opts.recordPath, line, { + encoding: 'utf8', + mode: PRIVATE_RECORDING_FILE_MODE, + }); + }); + await this.writes; + } +} + +function initializeRecordingFile(recordPath: string): Promise { + const existing = initializedRecordingPaths.get(recordPath); + if (existing) { + return existing; + } + const initialization = ensurePrivateRecordingDir(recordPath) + .then(() => + fs.writeFile(recordPath, '', { + encoding: 'utf8', + mode: PRIVATE_RECORDING_FILE_MODE, + }) + ) + .then(() => fs.chmod(recordPath, PRIVATE_RECORDING_FILE_MODE)) + .catch((error) => { + initializedRecordingPaths.delete(recordPath); + throw error; + }); + initializedRecordingPaths.set(recordPath, initialization); + return initialization; +} + +export async function ensurePrivateRecordingDir(recordPath: string): Promise { + const recordingDir = path.dirname(recordPath); + await fs.mkdir(recordingDir, { + recursive: true, + mode: PRIVATE_RECORDING_DIR_MODE, + }); + await fs.chmod(recordingDir, PRIVATE_RECORDING_DIR_MODE); +} + +export function resolveRecordingPath(sessionName: string): string { + const normalized = normalizeRecordingSessionName(sessionName); + return path.join(legacyMcporterDir(), 'recordings', `${normalized}.ndjson`); +} + +export function resolveRecordingConfigPath(sessionName: string): string { + const normalized = normalizeRecordingSessionName(sessionName); + return path.join(legacyMcporterDir(), 'recordings', `${normalized}.config.json`); +} + +export function normalizeRecordingSessionName(sessionName: string): string { + const normalized = sessionName.trim(); + if (!normalized) { + throw new Error('Recording session name is required.'); + } + if (normalized.includes('/') || normalized.includes('\\') || normalized === '.' || normalized === '..') { + throw new Error(`Invalid recording session name '${sessionName}'. Use a simple file name without path separators.`); + } + return normalized; +} diff --git a/src/runtime/replay-transport.ts b/src/runtime/replay-transport.ts new file mode 100644 index 0000000..db2fb43 --- /dev/null +++ b/src/runtime/replay-transport.ts @@ -0,0 +1,198 @@ +import fs from 'node:fs'; +import { isDeepStrictEqual } from 'node:util'; +import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; +import type { Transport, TransportSendOptions } from '@modelcontextprotocol/sdk/shared/transport.js'; +import type { RecordedMessage } from './record-transport.js'; + +export interface ReplayTransportOptions { + readonly recordPath: string; + readonly server: string; +} + +interface ExpectedSend { + readonly method: string; + readonly params?: unknown; + readonly expectsResponse: boolean; + readonly response?: JSONRPCMessage; +} + +type JsonRpcRecord = Record; + +export class ReplayTransport implements Transport { + onclose?: Transport['onclose']; + onerror?: Transport['onerror']; + onmessage?: Transport['onmessage']; + sessionId?: string; + + private readonly expectedSends: ExpectedSend[]; + + constructor(private readonly opts: ReplayTransportOptions) { + this.expectedSends = buildReplayQueue(readRecordedMessages(opts.recordPath), opts.server); + } + + async start(): Promise {} + + async send(message: JSONRPCMessage, _options?: TransportSendOptions): Promise { + const request = requestDetails(message); + if (!request) { + return; + } + + const expected = this.expectedSends[0]; + if (!expected || expected.method !== request.method || !isDeepStrictEqual(expected.params, request.params)) { + throw new Error(formatReplayMismatch(this.opts.server, request, expected)); + } + + this.expectedSends.shift(); + if (expected.response) { + const response = withActiveRequestId(expected.response, request.id); + queueMicrotask(() => this.onmessage?.(response)); + } + } + + async close(): Promise { + if (this.expectedSends.length > 0) { + throw new Error(formatReplayRemainder(this.opts.server, this.expectedSends)); + } + this.onclose?.(); + } +} + +function readRecordedMessages(recordPath: string): RecordedMessage[] { + try { + const contents = fs.readFileSync(recordPath, 'utf8'); + return contents + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line, index) => { + try { + return JSON.parse(line) as RecordedMessage; + } catch (error) { + throw new Error( + `Invalid JSON on recording line ${index + 1} in ${recordPath}: ${ + error instanceof Error ? error.message : String(error) + }`, + { cause: error } + ); + } + }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error(`Replay recording not found: ${recordPath}`, { cause: error }); + } + throw error; + } +} + +function buildReplayQueue(messages: RecordedMessage[], server: string): ExpectedSend[] { + const pendingRequests = new Map(); + const expected: ExpectedSend[] = []; + + for (const entry of messages) { + if (entry._meta?.server !== server) { + continue; + } + if (entry._meta.dir === 'lifecycle') { + continue; + } + const clean = stripMeta(entry); + if (entry._meta.dir === 'send') { + const request = requestDetails(clean); + if (!request) { + continue; + } + const expectedSend: ExpectedSend = { + method: request.method, + params: request.params, + expectsResponse: request.id !== undefined, + }; + expected.push(expectedSend); + if (request.id !== undefined) { + pendingRequests.set(String(request.id), expectedSend); + } + continue; + } + if (entry._meta.dir === 'recv') { + const responseId = responseIdOf(clean); + if (responseId === undefined) { + continue; + } + const pending = pendingRequests.get(String(responseId)); + if (pending) { + pendingRequests.delete(String(responseId)); + (pending as { response?: JSONRPCMessage }).response = clean; + } + } + } + + return expected.filter((entry) => !entry.expectsResponse || entry.response); +} + +function stripMeta(message: RecordedMessage): JSONRPCMessage { + const { _meta, ...jsonrpc } = message; + return jsonrpc as JSONRPCMessage; +} + +function requestDetails(message: JSONRPCMessage): + | { + readonly id?: string | number; + readonly method: string; + readonly params?: unknown; + } + | undefined { + const record = message as JsonRpcRecord; + if (typeof record.method !== 'string') { + return undefined; + } + if (record.method.startsWith('$transport/')) { + return undefined; + } + return { + id: typeof record.id === 'string' || typeof record.id === 'number' ? record.id : undefined, + method: record.method, + params: record.params, + }; +} + +function responseIdOf(message: JSONRPCMessage): string | number | undefined { + const record = message as JsonRpcRecord; + if (!('result' in record) && !('error' in record)) { + return undefined; + } + const id = record.id; + return typeof id === 'string' || typeof id === 'number' ? id : undefined; +} + +function withActiveRequestId(response: JSONRPCMessage, requestId: string | number | undefined): JSONRPCMessage { + if (requestId === undefined) { + return response; + } + return { + ...(response as JsonRpcRecord), + id: requestId, + } as JSONRPCMessage; +} + +function formatReplayMismatch( + server: string, + request: { readonly method: string; readonly params?: unknown }, + expected: ExpectedSend | undefined +): string { + const expectedText = expected + ? `${expected.method} ${JSON.stringify(expected.params ?? {})}` + : 'no remaining recorded recv'; + return `Replay mismatch for server '${server}': request ${request.method} ${JSON.stringify( + request.params ?? {} + )} did not match next expected recv ${expectedText}.`; +} + +function formatReplayRemainder(server: string, expectedSends: readonly ExpectedSend[]): string { + const expected = expectedSends[0]; + const count = expectedSends.length; + const requestText = count === 1 ? 'request' : 'requests'; + const expectedText = expected + ? `${expected.method} ${JSON.stringify(expected.params ?? {})}` + : 'no remaining recorded recv'; + return `Replay ended for server '${server}' with ${count} recorded ${requestText} still unused; next expected recv ${expectedText}.`; +} diff --git a/src/runtime/transport.ts b/src/runtime/transport.ts index 6793620..339fc89 100644 --- a/src/runtime/transport.ts +++ b/src/runtime/transport.ts @@ -21,6 +21,8 @@ import { type OAuthCapableTransport, OAuthTimeoutError, } from './oauth.js'; +import { RecordTransport } from './record-transport.js'; +import { ReplayTransport } from './replay-transport.js'; import { resolveCommandArgument, resolveCommandArguments } from './utils.js'; const STDIO_TRACE_ENABLED = process.env.MCPORTER_STDIO_TRACE === '1'; @@ -84,6 +86,8 @@ export interface CreateClientContextOptions { readonly onDefinitionPromoted?: (definition: ServerDefinition) => void; readonly allowCachedAuth?: boolean; readonly oauthSessionOptions?: OAuthSessionOptions; + readonly recordPath?: string; + readonly replayPath?: string; } function removeAuthorizationHeader(headers: Record | undefined): Record | undefined { @@ -136,6 +140,38 @@ async function closeOAuthSession(oauthSession?: OAuthSession): Promise { await oauthSession?.close().catch(() => {}); } +function shouldUseModeForServer(definition: ServerDefinition, serverFilter: string | undefined): boolean { + return !serverFilter || serverFilter === definition.name; +} + +function wrapRecordTransport( + transport: TTransport, + definition: ServerDefinition, + options: CreateClientContextOptions +): TTransport { + if (!options.recordPath || !shouldUseModeForServer(definition, process.env.MCPORTER_RECORD_SERVER)) { + return transport; + } + return new RecordTransport({ + inner: transport, + recordPath: options.recordPath, + server: definition.name, + }) as unknown as TTransport; +} + +async function createReplayClientContext( + client: Client, + definition: ServerDefinition, + replayPath: string +): Promise { + const transport = new ReplayTransport({ + recordPath: replayPath, + server: definition.name, + }); + await client.connect(transport); + return { client, transport, definition, oauthSession: undefined }; +} + function shouldAbortSseFallback(error: unknown): boolean { if (isPostAuthConnectError(error)) { return !isLegacySseTransportMismatch(error); @@ -251,7 +287,8 @@ async function applyCachedAuthIfAvailable( async function createStdioClientContext( client: Client, definition: ServerDefinition & { command: Extract }, - logger: Logger + logger: Logger, + options: CreateClientContextOptions ): Promise { const resolvedEnvOverrides = definition.env && Object.keys(definition.env).length > 0 @@ -271,15 +308,16 @@ async function createStdioClientContext( if (compat.applied) { logger.info(`Injecting chrome-devtools-mcp --autoConnect compatibility patch from ${compat.patchPath}.`); } - const transport = new StdioClientTransport({ + const rawTransport = new StdioClientTransport({ command, args: commandArgs, cwd: definition.command.cwd, env: compat.env, }); if (STDIO_TRACE_ENABLED) { - attachStdioTraceLogging(transport, definition.name ?? definition.command.command); + attachStdioTraceLogging(rawTransport, definition.name ?? definition.command.command); } + const transport = wrapRecordTransport(rawTransport, definition, options); try { await client.connect(transport); } catch (error) { @@ -376,7 +414,8 @@ async function connectPrimaryHttpTransport( logger: Logger, options: CreateClientContextOptions ): Promise { - const createStreamableTransport = () => new StreamableHTTPClientTransport(command.url, transportOptions); + const createStreamableTransport = () => + wrapRecordTransport(new StreamableHTTPClientTransport(command.url, transportOptions), definition, options); const transport = await connectHttpTransport(client, createStreamableTransport(), oauthSession, logger, { serverName: definition.name, serverUrl: command.url, @@ -404,7 +443,7 @@ async function connectSseFallbackTransport( try { const transport = await connectHttpTransport( client, - new SSEClientTransport(command.url, transportOptions), + wrapRecordTransport(new SSEClientTransport(command.url, transportOptions), definition, options), oauthSession, logger, { @@ -441,6 +480,9 @@ export async function createClientContext( options: CreateClientContextOptions = {} ): Promise { const client = new Client(clientInfo); + if (options.replayPath && shouldUseModeForServer(definition, process.env.MCPORTER_REPLAY_SERVER)) { + return createReplayClientContext(client, definition, options.replayPath); + } const activeDefinition = await applyCachedAuthIfAvailable(definition, logger, options.allowCachedAuth); return withEnvOverrides(activeDefinition.env, async () => { @@ -448,7 +490,8 @@ export async function createClientContext( return createStdioClientContext( client, activeDefinition as ServerDefinition & { command: Extract }, - logger + logger, + options ); } return retryHttpTransportWithFallback(client, activeDefinition, logger, options); diff --git a/tests/cli-daemon-fast-path.test.ts b/tests/cli-daemon-fast-path.test.ts index e31f432..46f0f81 100644 --- a/tests/cli-daemon-fast-path.test.ts +++ b/tests/cli-daemon-fast-path.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; process.env.MCPORTER_DISABLE_AUTORUN = '1'; @@ -33,9 +33,12 @@ vi.mock('../src/runtime.js', () => ({ createRuntime: mocks.createRuntime, })); +const originalEnv = { ...process.env }; + describe('daemon call fast path', () => { beforeEach(() => { vi.restoreAllMocks(); + process.env = { ...originalEnv }; mocks.DaemonClient.mockClear(); mocks.createRuntime.mockClear(); mocks.daemonCallTool.mockReset().mockResolvedValue({ @@ -46,6 +49,10 @@ describe('daemon call fast path', () => { process.exitCode = undefined; }); + afterEach(() => { + process.env = { ...originalEnv }; + }); + it('routes explicit default keep-alive calls without building the full runtime', async () => { const { runCli } = await import('../src/cli.js'); const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); @@ -81,4 +88,20 @@ describe('daemon call fast path', () => { }) ); }); + + it.each(['MCPORTER_RECORD', 'MCPORTER_REPLAY'] as const)( + 'bypasses the daemon fast path while %s is active', + async (modeEnv) => { + process.env[modeEnv] = 'demo'; + mocks.createRuntime.mockRejectedValue(new Error('runtime path used')); + const { runCli } = await import('../src/cli.js'); + + await expect(runCli(['call', 'chrome-devtools.list_pages', '--output', 'json'])).rejects.toThrow( + 'runtime path used' + ); + + expect(mocks.createRuntime).toHaveBeenCalled(); + expect(mocks.daemonCallTool).not.toHaveBeenCalled(); + } + ); }); diff --git a/tests/cli-flag-utils.test.ts b/tests/cli-flag-utils.test.ts index 4112a64..d81e189 100644 --- a/tests/cli-flag-utils.test.ts +++ b/tests/cli-flag-utils.test.ts @@ -10,6 +10,13 @@ describe('cli flag utils', () => { expect(argv).toEqual(['list']); }); + it('preserves flags after the command separator for wrapped commands', () => { + const argv = ['record', 'demo', '--', 'node', 'dist/cli.js', '--config', '/tmp/child.json', 'call']; + const flags = extractFlags(argv, ['--config']); + expect(flags['--config']).toBeUndefined(); + expect(argv).toEqual(['record', 'demo', '--', 'node', 'dist/cli.js', '--config', '/tmp/child.json', 'call']); + }); + it('throws when a required flag value is missing', () => { expect(() => extractFlags(['--config'], ['--config'])).toThrow(/requires a value/); expect(() => expectValue('--output', undefined)).toThrow(/requires a value/); diff --git a/tests/record-replay-cli-close.test.ts b/tests/record-replay-cli-close.test.ts new file mode 100644 index 0000000..03ffe25 --- /dev/null +++ b/tests/record-replay-cli-close.test.ts @@ -0,0 +1,109 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { MCPORTER_VERSION } from '../src/runtime.js'; +import type { RecordedMessage } from '../src/runtime/record-transport.js'; + +process.env.MCPORTER_DISABLE_AUTORUN = '1'; +const cliModulePromise = import('../src/cli.js'); + +const originalEnv = { ...process.env }; + +describe('record/replay CLI close behavior', () => { + afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = undefined; + process.env = { ...originalEnv, MCPORTER_DISABLE_AUTORUN: '1' }; + }); + + it('fails replay commands when normal CLI cleanup leaves recorded requests unused', async () => { + const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-replay-cli-close-')); + const configPath = path.join(tempHome, 'mcporter.json'); + const recordingPath = path.join(tempHome, '.mcporter', 'recordings', 'partial.ndjson'); + await writeReplayFixture(configPath, recordingPath); + + process.env.HOME = tempHome; + process.env.USERPROFILE = tempHome; + process.env.MCPORTER_REPLAY = 'partial'; + process.env.MCPORTER_REPLAY_SERVER = 'linear'; + process.env.MCPORTER_NO_FORCE_EXIT = '1'; + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const { runCli } = await cliModulePromise; + + await expect(runCli(['--config', configPath, 'call', 'linear.first', '--output', 'json'])).rejects.toThrow( + 'Replay ended for server \'linear\' with 1 recorded request still unused; next expected recv tools/call {"name":"second","arguments":{}}.' + ); + expect(logSpy).toHaveBeenCalled(); + + await fs.rm(tempHome, { recursive: true, force: true }); + }); +}); + +async function writeReplayFixture(configPath: string, recordingPath: string): Promise { + await fs.writeFile( + configPath, + JSON.stringify({ + mcpServers: { + linear: { + description: 'Replay-only test server', + command: process.execPath, + args: ['-e', 'process.exit(1)'], + }, + }, + }), + 'utf8' + ); + await fs.mkdir(path.dirname(recordingPath), { recursive: true }); + await fs.writeFile( + recordingPath, + [ + send('linear', 0, 'initialize', { + protocolVersion: '2025-11-25', + capabilities: {}, + clientInfo: { name: 'mcporter', version: MCPORTER_VERSION }, + }), + recv('linear', 0, { + protocolVersion: '2025-11-25', + capabilities: { tools: {} }, + serverInfo: { name: 'replay-fixture', version: '1.0.0' }, + }), + notification('linear', 'notifications/initialized'), + send('linear', 1, 'tools/call', { name: 'first', arguments: {} }), + recv('linear', 1, { content: [] }), + send('linear', 2, 'tools/call', { name: 'second', arguments: {} }), + recv('linear', 2, { content: [] }), + ] + .map((entry) => JSON.stringify(entry)) + .join('\n') + '\n', + 'utf8' + ); +} + +function send(server: string, id: number | undefined, method: string, params: unknown): RecordedMessage { + return { + jsonrpc: '2.0', + ...(id === undefined ? {} : { id }), + method, + params, + _meta: { dir: 'send', server, ts: '2026-05-16T00:00:00.000Z' }, + } as RecordedMessage; +} + +function recv(server: string, id: number, result: unknown): RecordedMessage { + return { + jsonrpc: '2.0', + id, + result, + _meta: { dir: 'recv', server, ts: '2026-05-16T00:00:00.000Z' }, + } as RecordedMessage; +} + +function notification(server: string, method: string): RecordedMessage { + return { + jsonrpc: '2.0', + method, + _meta: { dir: 'send', server, ts: '2026-05-16T00:00:00.000Z' }, + } as RecordedMessage; +} diff --git a/tests/record-replay-cli.test.ts b/tests/record-replay-cli.test.ts new file mode 100644 index 0000000..67566b9 --- /dev/null +++ b/tests/record-replay-cli.test.ts @@ -0,0 +1,129 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { handleRecordCli } from '../src/cli/record-command.js'; +import { handleReplayCli } from '../src/cli/replay-command.js'; + +const spawnMock = vi.hoisted(() => { + const calls: Array<{ command: string; args: string[]; options: { env?: NodeJS.ProcessEnv } }> = []; + const spawn = vi.fn((command: string, args: string[], options: { env?: NodeJS.ProcessEnv }) => { + calls.push({ command, args, options }); + const child = { + once(event: string, handler: (codeOrError: number | Error | null, signal?: NodeJS.Signals | null) => void) { + if (event === 'exit') { + queueMicrotask(() => handler(0, null)); + } + return child; + }, + }; + return child; + }); + return { calls, spawn }; +}); + +vi.mock('node:child_process', () => ({ + spawn: spawnMock.spawn, +})); + +const originalEnv = { ...process.env }; + +describe('record/replay CLI command environments', () => { + beforeEach(() => { + spawnMock.calls.length = 0; + spawnMock.spawn.mockClear(); + process.exitCode = undefined; + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + vi.restoreAllMocks(); + }); + + it('clears replay mode and disables keep-alive fast paths while recording a command', async () => { + process.env.MCPORTER_REPLAY = 'stale'; + process.env.MCPORTER_REPLAY_SERVER = 'linear'; + + await handleRecordCli(['demo', '--server', 'github', '--', 'node', 'script.js']); + + expect(spawnMock.calls).toHaveLength(1); + const env = spawnMock.calls[0]?.options.env; + expect(env).toMatchObject({ + MCPORTER_RECORD: 'demo', + MCPORTER_RECORD_SERVER: 'github', + MCPORTER_DISABLE_KEEPALIVE: '*', + }); + expect(env).not.toHaveProperty('MCPORTER_REPLAY'); + expect(env).not.toHaveProperty('MCPORTER_REPLAY_SERVER'); + }); + + it('clears recording mode and disables keep-alive fast paths while replaying a command', async () => { + process.env.MCPORTER_RECORD = 'stale'; + process.env.MCPORTER_RECORD_SERVER = 'linear'; + + await handleReplayCli(['demo', '--server', 'github', '--', 'node', 'script.js']); + + expect(spawnMock.calls).toHaveLength(1); + const env = spawnMock.calls[0]?.options.env; + expect(env).toMatchObject({ + MCPORTER_REPLAY: 'demo', + MCPORTER_REPLAY_SERVER: 'github', + MCPORTER_DISABLE_KEEPALIVE: '*', + }); + expect(env).not.toHaveProperty('MCPORTER_RECORD'); + expect(env).not.toHaveProperty('MCPORTER_RECORD_SERVER'); + }); + + it('writes manual record config and instructions that disable keep-alive fast paths', async () => { + const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-record-cli-')); + vi.spyOn(os, 'homedir').mockReturnValue(tempHome); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await handleRecordCli(['demo', '--server', 'github']); + + const configPath = path.join(tempHome, '.mcporter', 'recordings', 'demo.config.json'); + const config = JSON.parse(await fs.readFile(configPath, 'utf8')); + expect(config.env).toMatchObject({ + MCPORTER_RECORD: 'demo', + MCPORTER_RECORD_SERVER: 'github', + MCPORTER_DISABLE_KEEPALIVE: '*', + }); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Set MCPORTER_RECORD=demo and MCPORTER_RECORD_SERVER=github and MCPORTER_DISABLE_KEEPALIVE=*' + ) + ); + await expectPrivateRecordingPermissions(configPath); + }); + + it('writes manual replay config and instructions that disable keep-alive fast paths', async () => { + const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-replay-cli-')); + vi.spyOn(os, 'homedir').mockReturnValue(tempHome); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await handleReplayCli(['demo', '--server', 'github']); + + const configPath = path.join(tempHome, '.mcporter', 'recordings', 'demo.config.json'); + const config = JSON.parse(await fs.readFile(configPath, 'utf8')); + expect(config.env).toMatchObject({ + MCPORTER_REPLAY: 'demo', + MCPORTER_REPLAY_SERVER: 'github', + MCPORTER_DISABLE_KEEPALIVE: '*', + }); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Set MCPORTER_REPLAY=demo and MCPORTER_REPLAY_SERVER=github and MCPORTER_DISABLE_KEEPALIVE=*' + ) + ); + await expectPrivateRecordingPermissions(configPath); + }); +}); + +async function expectPrivateRecordingPermissions(filePath: string): Promise { + if (process.platform === 'win32') { + return; + } + expect((await fs.stat(path.dirname(filePath))).mode & 0o777).toBe(0o700); + expect((await fs.stat(filePath)).mode & 0o777).toBe(0o600); +} diff --git a/tests/record-replay.test.ts b/tests/record-replay.test.ts new file mode 100644 index 0000000..8c9edb9 --- /dev/null +++ b/tests/record-replay.test.ts @@ -0,0 +1,459 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; +import type { Transport, TransportSendOptions } from '@modelcontextprotocol/sdk/shared/transport.js'; +import { describe, expect, it } from 'vitest'; +import { createRuntime, MCPORTER_VERSION } from '../src/runtime.js'; +import { RecordTransport, type RecordedMessage } from '../src/runtime/record-transport.js'; +import { ReplayTransport } from '../src/runtime/replay-transport.js'; + +class StubTransport implements Transport { + onclose?: Transport['onclose']; + onerror?: Transport['onerror']; + onmessage?: Transport['onmessage']; + sent: JSONRPCMessage[] = []; + + async start(): Promise {} + + async send(message: JSONRPCMessage, _options?: TransportSendOptions): Promise { + this.sent.push(message); + } + + async close(): Promise { + this.onclose?.(); + } +} + +describe('record/replay transports', () => { + it('records one NDJSON line per send and recv with metadata', async () => { + const recordPath = await tempRecordingPath(); + const inner = new StubTransport(); + const transport = new RecordTransport({ inner, recordPath, server: 'linear' }); + + await transport.start(); + await transport.send({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'list_issues', arguments: { limit: 1 } }, + }); + inner.onmessage?.({ + jsonrpc: '2.0', + id: 1, + result: { content: [{ type: 'text', text: 'ok' }] }, + } as JSONRPCMessage); + await transport.close(); + + const entries = await readRecording(recordPath); + const traffic = entries.filter((entry) => entry._meta?.dir === 'send' || entry._meta?.dir === 'recv'); + expect(traffic).toHaveLength(2); + expect(traffic.map((entry) => entry._meta?.dir)).toEqual(['send', 'recv']); + expect(traffic.every((entry) => entry._meta?.server === 'linear')).toBe(true); + }); + + it('starts each recording with a fresh session file', async () => { + const recordPath = await tempRecordingPath(); + await fs.writeFile( + recordPath, + `${JSON.stringify(send('linear', 1, 'tools/call', { name: 'stale', arguments: {} }))}\n`, + 'utf8' + ); + const inner = new StubTransport(); + const transport = new RecordTransport({ inner, recordPath, server: 'linear' }); + + await transport.start(); + await transport.send({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'fresh', arguments: {} }, + }); + await transport.close(); + + const entries = await readRecording(recordPath); + expect(entries.some((entry) => (entry as { params?: { name?: string } }).params?.name === 'stale')).toBe(false); + expect(entries.some((entry) => (entry as { params?: { name?: string } }).params?.name === 'fresh')).toBe(true); + }); + + it('creates recordings with private filesystem permissions', async () => { + if (process.platform === 'win32') { + return; + } + const recordPath = await tempRecordingPath(); + const inner = new StubTransport(); + const transport = new RecordTransport({ inner, recordPath, server: 'linear' }); + + await transport.start(); + await transport.send({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'secret_tool', arguments: { token: 'secret' } }, + }); + await transport.close(); + + expect((await fs.stat(path.dirname(recordPath))).mode & 0o777).toBe(0o700); + expect((await fs.stat(recordPath)).mode & 0o777).toBe(0o600); + }); + + it('exposes wrapped stdio process metadata for cleanup helpers', async () => { + const child = { pid: 12345 } as unknown as import('node:child_process').ChildProcess; + const inner = new StubTransport() as StubTransport & { + pid: number; + _process: import('node:child_process').ChildProcess; + }; + inner.pid = 12345; + inner._process = child; + + const transport = new RecordTransport({ + inner, + recordPath: await tempRecordingPath(), + server: 'linear', + }); + + expect(transport.pid).toBe(12345); + expect(transport._process).toBe(child); + }); + + it('replays matching requests by method and params using the active request id', async () => { + const recordPath = await writeRecording([ + send('linear', 1, 'tools/call', { name: 'list_issues', arguments: { limit: 1 } }), + recv('linear', 1, { content: [{ type: 'text', text: 'recorded' }] }), + ]); + const transport = new ReplayTransport({ recordPath, server: 'linear' }); + const received: JSONRPCMessage[] = []; + transport.onmessage = (message) => received.push(message); + + await transport.start(); + await transport.send({ + jsonrpc: '2.0', + id: 99, + method: 'tools/call', + params: { name: 'list_issues', arguments: { limit: 1 } }, + }); + await Promise.resolve(); + + expect(received).toEqual([ + { + jsonrpc: '2.0', + id: 99, + result: { content: [{ type: 'text', text: 'recorded' }] }, + }, + ]); + }); + + it('skips recorded requests that never received a response', async () => { + const recordPath = await writeRecording([ + send('linear', 1, 'initialize', { protocolVersion: '2025-11-25' }), + send('linear', 2, 'initialize', { protocolVersion: '2025-11-25' }), + recv('linear', 2, { protocolVersion: '2025-11-25', capabilities: {}, serverInfo: { name: 'ok' } }), + ]); + const transport = new ReplayTransport({ recordPath, server: 'linear' }); + const received: JSONRPCMessage[] = []; + transport.onmessage = (message) => received.push(message); + + await transport.send({ + jsonrpc: '2.0', + id: 99, + method: 'initialize', + params: { protocolVersion: '2025-11-25' }, + }); + await Promise.resolve(); + + expect(received).toHaveLength(1); + expect(received[0]).toMatchObject({ id: 99 }); + }); + + it('keeps replay order by request send order when responses arrive out of order', async () => { + const recordPath = await writeRecording([ + send('linear', 1, 'tools/call', { name: 'first', arguments: {} }), + send('linear', 2, 'tools/call', { name: 'second', arguments: {} }), + recv('linear', 2, { content: [{ type: 'text', text: 'second' }] }), + recv('linear', 1, { content: [{ type: 'text', text: 'first' }] }), + ]); + const transport = new ReplayTransport({ recordPath, server: 'linear' }); + const received: JSONRPCMessage[] = []; + transport.onmessage = (message) => received.push(message); + + await transport.send({ + jsonrpc: '2.0', + id: 10, + method: 'tools/call', + params: { name: 'first', arguments: {} }, + }); + await transport.send({ + jsonrpc: '2.0', + id: 11, + method: 'tools/call', + params: { name: 'second', arguments: {} }, + }); + await Promise.resolve(); + + expect( + received.map( + (message) => (message as { result?: { content?: Array<{ text?: string }> } }).result?.content?.[0]?.text + ) + ).toEqual(['first', 'second']); + }); + + it('does not treat server-initiated requests as responses', async () => { + const recordPath = await writeRecording([ + send('linear', 1, 'tools/call', { name: 'first', arguments: {} }), + { + jsonrpc: '2.0', + id: 1, + method: 'sampling/createMessage', + params: {}, + _meta: { dir: 'recv', server: 'linear', ts: '2026-01-01T00:00:00.000Z' }, + } satisfies RecordedMessage, + recv('linear', 1, { content: [{ type: 'text', text: 'first' }] }), + ]); + const transport = new ReplayTransport({ recordPath, server: 'linear' }); + const received: JSONRPCMessage[] = []; + transport.onmessage = (message) => received.push(message); + + await transport.send({ + jsonrpc: '2.0', + id: 9, + method: 'tools/call', + params: { name: 'first', arguments: {} }, + }); + await Promise.resolve(); + + expect(received).toEqual([ + { + jsonrpc: '2.0', + id: 9, + result: { content: [{ type: 'text', text: 'first' }] }, + }, + ]); + }); + + it('throws a clear mismatch error naming the request and next expected recv', async () => { + const recordPath = await writeRecording([ + send('linear', 1, 'tools/call', { name: 'list_issues', arguments: { limit: 1 } }), + recv('linear', 1, { content: [] }), + ]); + const transport = new ReplayTransport({ recordPath, server: 'linear' }); + + await expect( + transport.send({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'create_issue', arguments: { title: 'Bug' } }, + }) + ).rejects.toThrow( + 'Replay mismatch for server \'linear\': request tools/call {"name":"create_issue","arguments":{"title":"Bug"}} did not match next expected recv tools/call {"name":"list_issues","arguments":{"limit":1}}.' + ); + }); + + it('throws on close when recorded requests remain unreplayed', async () => { + const recordPath = await writeRecording([ + send('linear', 1, 'tools/call', { name: 'first', arguments: {} }), + recv('linear', 1, { content: [] }), + send('linear', 2, 'tools/call', { name: 'second', arguments: {} }), + recv('linear', 2, { content: [] }), + ]); + const transport = new ReplayTransport({ recordPath, server: 'linear' }); + + await transport.send({ + jsonrpc: '2.0', + id: 99, + method: 'tools/call', + params: { name: 'first', arguments: {} }, + }); + + await expect(transport.close()).rejects.toThrow( + 'Replay ended for server \'linear\' with 1 recorded request still unused; next expected recv tools/call {"name":"second","arguments":{}}.' + ); + }); + + it('surfaces unused recorded requests through normal runtime close', async () => { + const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-replay-runtime-')); + const configPath = path.join(tempHome, 'mcporter.json'); + const recordingPath = path.join(tempHome, '.mcporter', 'recordings', 'partial.ndjson'); + const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; + const originalReplay = process.env.MCPORTER_REPLAY; + const originalReplayServer = process.env.MCPORTER_REPLAY_SERVER; + + await fs.writeFile( + configPath, + JSON.stringify({ + mcpServers: { + linear: { + description: 'Replay-only test server', + command: process.execPath, + args: ['-e', 'process.exit(1)'], + }, + }, + }), + 'utf8' + ); + await fs.mkdir(path.dirname(recordingPath), { recursive: true }); + await fs.writeFile( + recordingPath, + [ + send('linear', 0, 'initialize', { + protocolVersion: '2025-11-25', + capabilities: {}, + clientInfo: { name: 'mcporter', version: MCPORTER_VERSION }, + }), + recv('linear', 0, { + protocolVersion: '2025-11-25', + capabilities: { tools: {} }, + serverInfo: { name: 'replay-fixture', version: '1.0.0' }, + }), + notification('linear', 'notifications/initialized'), + send('linear', 1, 'tools/call', { name: 'first', arguments: {} }), + recv('linear', 1, { content: [] }), + send('linear', 2, 'tools/call', { name: 'second', arguments: {} }), + recv('linear', 2, { content: [] }), + ] + .map((entry) => JSON.stringify(entry)) + .join('\n') + '\n', + 'utf8' + ); + + process.env.HOME = tempHome; + process.env.USERPROFILE = tempHome; + process.env.MCPORTER_REPLAY = 'partial'; + process.env.MCPORTER_REPLAY_SERVER = 'linear'; + + try { + const runtime = await createRuntime({ configPath }); + await runtime.callTool('linear', 'first'); + + await expect(runtime.close()).rejects.toThrow( + 'Replay ended for server \'linear\' with 1 recorded request still unused; next expected recv tools/call {"name":"second","arguments":{}}.' + ); + } finally { + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = originalUserProfile; + } + if (originalReplay === undefined) { + delete process.env.MCPORTER_REPLAY; + } else { + process.env.MCPORTER_REPLAY = originalReplay; + } + if (originalReplayServer === undefined) { + delete process.env.MCPORTER_REPLAY_SERVER; + } else { + process.env.MCPORTER_REPLAY_SERVER = originalReplayServer; + } + await fs.rm(tempHome, { recursive: true, force: true }); + } + }); + + it('keeps multi-server streams separated by metadata server', async () => { + const recordPath = await writeRecording([ + send('linear', 1, 'tools/call', { name: 'list_issues', arguments: { limit: 1 } }), + recv('linear', 1, { content: [{ type: 'text', text: 'linear' }] }), + send('github', 1, 'tools/call', { name: 'list_issues', arguments: { state: 'open' } }), + recv('github', 1, { content: [{ type: 'text', text: 'github' }] }), + ]); + const linear = new ReplayTransport({ recordPath, server: 'linear' }); + const github = new ReplayTransport({ recordPath, server: 'github' }); + const linearMessages: JSONRPCMessage[] = []; + const githubMessages: JSONRPCMessage[] = []; + linear.onmessage = (message) => linearMessages.push(message); + github.onmessage = (message) => githubMessages.push(message); + + await github.send({ + jsonrpc: '2.0', + id: 7, + method: 'tools/call', + params: { name: 'list_issues', arguments: { state: 'open' } }, + }); + await linear.send({ + jsonrpc: '2.0', + id: 8, + method: 'tools/call', + params: { name: 'list_issues', arguments: { limit: 1 } }, + }); + await Promise.resolve(); + + expect(githubMessages[0]).toMatchObject({ result: { content: [{ text: 'github' }] } }); + expect(linearMessages[0]).toMatchObject({ result: { content: [{ text: 'linear' }] } }); + }); + + it('ignores lifecycle events during replay', async () => { + const recordPath = await writeRecording([ + lifecycle('linear', '$transport/start'), + send('linear', undefined, 'notifications/initialized', {}), + lifecycle('linear', '$transport/close'), + ]); + const transport = new ReplayTransport({ recordPath, server: 'linear' }); + + await expect( + transport.send({ + jsonrpc: '2.0', + method: 'notifications/initialized', + params: {}, + }) + ).resolves.toBeUndefined(); + }); +}); + +async function tempRecordingPath(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-record-replay-')); + return path.join(dir, 'session.ndjson'); +} + +async function writeRecording(entries: RecordedMessage[]): Promise { + const recordPath = await tempRecordingPath(); + await fs.writeFile(recordPath, entries.map((entry) => JSON.stringify(entry)).join('\n') + '\n', 'utf8'); + return recordPath; +} + +async function readRecording(recordPath: string): Promise { + const contents = await fs.readFile(recordPath, 'utf8'); + return contents + .trim() + .split(/\r?\n/) + .map((line) => JSON.parse(line) as RecordedMessage); +} + +function send(server: string, id: number | undefined, method: string, params: unknown): RecordedMessage { + return { + jsonrpc: '2.0', + ...(id === undefined ? {} : { id }), + method, + params, + _meta: { dir: 'send', server, ts: '2026-05-16T00:00:00.000Z' }, + } as RecordedMessage; +} + +function recv(server: string, id: number, result: unknown): RecordedMessage { + return { + jsonrpc: '2.0', + id, + result, + _meta: { dir: 'recv', server, ts: '2026-05-16T00:00:00.000Z' }, + } as RecordedMessage; +} + +function lifecycle(server: string, method: string): RecordedMessage { + return { + jsonrpc: '2.0', + method, + _meta: { dir: 'lifecycle', server, ts: '2026-05-16T00:00:00.000Z' }, + } as RecordedMessage; +} + +function notification(server: string, method: string): RecordedMessage { + return { + jsonrpc: '2.0', + method, + _meta: { dir: 'send', server, ts: '2026-05-16T00:00:00.000Z' }, + } as RecordedMessage; +}