Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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