diff --git a/CHANGELOG.md b/CHANGELOG.md index d2f2b96..2fa8291 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,9 @@ ## [0.11.2] - Unreleased -- Nothing yet. +### Added + +- Add `mcporter record` / `mcporter replay` for capturing MCP JSON-RPC traffic to NDJSON and replaying exact sessions offline. ## [0.11.1] - 2026-05-14 diff --git a/README.md b/README.md index efb84a4..c6906f9 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 shareable 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..fd818f0 --- /dev/null +++ b/docs/record-replay.md @@ -0,0 +1,50 @@ +--- +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 +``` + +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..2c01291 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -154,6 +154,28 @@ export async function runCli(argv: string[]): Promise { return; } + if (command === 'record') { + const { handleRecordCli, printRecordHelp } = await import('./cli/record-command.js'); + if (consumeHelpTokens(args)) { + printRecordHelp(); + process.exitCode = 0; + return; + } + await handleRecordCli(args); + return; + } + + if (command === 'replay') { + const { handleReplayCli, printReplayHelp } = await import('./cli/replay-command.js'); + if (consumeHelpTokens(args)) { + printReplayHelp(); + process.exitCode = 0; + return; + } + await handleReplayCli(args); + return; + } + if (command === 'config') { const { handleConfigCli } = await import('./cli/config-command.js'); await handleConfigCli( @@ -454,6 +476,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/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..a2afaf1 --- /dev/null +++ b/src/cli/record-command.ts @@ -0,0 +1,141 @@ +import { spawn } from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { resolveRecordingConfigPath, resolveRecordingPath } from '../runtime/record-transport.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, { + MCPORTER_RECORD: parsed.sessionName, + MCPORTER_RECORD_SERVER: parsed.server, + }); + return; + } + + await writeModeConfig(parsed, { + mode: 'record', + recordPath, + env: { + MCPORTER_RECORD: parsed.sessionName, + ...(parsed.server ? { MCPORTER_RECORD_SERVER: parsed.server } : {}), + }, + }); + console.log(`Recording configuration written to ${resolveRecordingConfigPath(parsed.sessionName)}`); + console.log(`Set MCPORTER_RECORD=${parsed.sessionName} 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 fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile( + configPath, + `${JSON.stringify( + { + session: parsed.sessionName, + server: parsed.server, + ...extra, + }, + null, + 2 + )}\n`, + 'utf8' + ); +} + +async function runWithRecordingEnv(parsed: ParsedRecordArgs, env: Record): Promise { + const [command, ...commandArgs] = parsed.command; + if (!command) { + return; + } + await new Promise((resolve, reject) => { + const child = spawn(command, commandArgs, { + stdio: 'inherit', + env: { + ...process.env, + ...Object.fromEntries(Object.entries(env).filter((entry): entry is [string, string] => Boolean(entry[1]))), + }, + }); + 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/replay-command.ts b/src/cli/replay-command.ts new file mode 100644 index 0000000..33ec9e9 --- /dev/null +++ b/src/cli/replay-command.ts @@ -0,0 +1,75 @@ +import { spawn } from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { resolveRecordingConfigPath, resolveRecordingPath } from '../runtime/record-transport.js'; +import { parseReplayArgs } from './record-command.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, { + MCPORTER_REPLAY: parsed.sessionName, + MCPORTER_REPLAY_SERVER: parsed.server, + }); + return; + } + + const configPath = resolveRecordingConfigPath(parsed.sessionName); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + 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 } : {}), + }, + }, + null, + 2 + )}\n`, + 'utf8' + ); + console.log(`Replay configuration written to ${configPath}`); + console.log(`Set MCPORTER_REPLAY=${parsed.sessionName} 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: Record): Promise { + const [command, ...args] = commandAndArgs; + if (!command) { + return; + } + await new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: 'inherit', + env: { + ...process.env, + ...Object.fromEntries(Object.entries(env).filter((entry): entry is [string, string] => Boolean(entry[1]))), + }, + }); + 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.ts b/src/runtime.ts index 6965744..fba4711 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -6,6 +6,7 @@ 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 { type ClientContext, createClientContext } from './runtime/transport.js'; import { normalizeTimeout, raceWithTimeout } from './runtime/utils.js'; import { filterTools, isToolAllowed, validateToolFilters } from './tool-filters.js'; @@ -107,6 +108,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) { @@ -119,6 +122,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. @@ -291,6 +301,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) { diff --git a/src/runtime/record-transport.ts b/src/runtime/record-transport.ts new file mode 100644 index 0000000..2818130 --- /dev/null +++ b/src/runtime/record-transport.ts @@ -0,0 +1,131 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +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; +}; + +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); + } + } + + async start(): Promise { + 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 { + await fs.mkdir(path.dirname(this.opts.recordPath), { recursive: true }); + const line = `${JSON.stringify(message)}\n`; + this.writes = this.writes.then(() => fs.appendFile(this.opts.recordPath, line, 'utf8')); + await this.writes; + } +} + +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..b1f5cda --- /dev/null +++ b/src/runtime/replay-transport.ts @@ -0,0 +1,168 @@ +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 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) { + queueMicrotask(() => this.onmessage?.(expected.response as JSONRPCMessage)); + } + } + + async close(): Promise { + 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, + }; + 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; +} + +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 id = (message as JsonRpcRecord).id; + return typeof id === 'string' || typeof id === 'number' ? id : undefined; +} + +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}.`; +} 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/record-replay.test.ts b/tests/record-replay.test.ts new file mode 100644 index 0000000..17150a6 --- /dev/null +++ b/tests/record-replay.test.ts @@ -0,0 +1,194 @@ +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 { 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('replays matching requests by method and params', 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: 1, + result: { content: [{ type: 'text', text: 'recorded' }] }, + }, + ]); + }); + + 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('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; +}