Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url>` and the CLI promotes the definition to OAuth on the fly. See [docs/adhoc.md](docs/adhoc.md).

Expand Down
50 changes: 50 additions & 0 deletions docs/record-replay.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 24 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,28 @@ export async function runCli(argv: string[]): Promise<void> {
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(
Expand Down Expand Up @@ -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' ||
Expand Down
10 changes: 10 additions & 0 deletions src/cli/help-output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,16 @@ function buildCommandSections(colorize: boolean): string[] {
summary: 'Seed or clear OAuth credentials non-interactively',
usage: 'mcporter vault set <server> --tokens-file <path>',
},
{
name: 'record',
summary: 'Capture MCP JSON-RPC traffic to NDJSON',
usage: 'mcporter record <session-name> [--server <name>] [-- <command>]',
},
{
name: 'replay',
summary: 'Replay recorded MCP JSON-RPC traffic deterministically',
usage: 'mcporter replay <session-name> [--server <name>] [-- <command>]',
},
],
},
{
Expand Down
141 changes: 141 additions & 0 deletions src/cli/record-command.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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 <session-name> [--server <name>] [-- <command-to-run>]

Capture MCP JSON-RPC traffic to ~/.mcporter/recordings/<session-name>.ndjson.

Flags:
--server <name> 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<string, unknown>): Promise<void> {
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<string, string | undefined>): Promise<void> {
const [command, ...commandArgs] = parsed.command;
if (!command) {
return;
}
await new Promise<void>((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} <session-name> [--server <name>] [-- <command-to-run>]`);
}
if (remaining.length > 1) {
throw new Error(`Unexpected ${commandName} argument '${remaining[1]}'. Put commands after '--'.`);
}
return { sessionName, server, command };
}
75 changes: 75 additions & 0 deletions src/cli/replay-command.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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 <session-name> [--server <name>] [-- <command-to-run>]

Replay MCP JSON-RPC traffic from ~/.mcporter/recordings/<session-name>.ndjson.

Flags:
--server <name> Restrict replay to one configured server.`);
}

async function runWithReplayEnv(commandAndArgs: string[], env: Record<string, string | undefined>): Promise<void> {
const [command, ...args] = commandAndArgs;
if (!command) {
return;
}
await new Promise<void>((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();
});
});
}
12 changes: 12 additions & 0 deletions src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand All @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading