Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

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 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 <url>` and the CLI promotes the definition to OAuth on the fly. See [docs/adhoc.md](docs/adhoc.md).

Expand Down
52 changes: 52 additions & 0 deletions docs/record-replay.md
Original file line number Diff line number Diff line change
@@ -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.
136 changes: 95 additions & 41 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -154,6 +155,28 @@ export async function runCli(argv: string[]): Promise<void> {
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(
Expand Down Expand Up @@ -197,14 +220,17 @@ export async function runCli(argv: string[]): Promise<void> {
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<string>()
: 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,
Expand All @@ -221,6 +247,7 @@ export async function runCli(argv: string[]): Promise<void> {
const resolvedCommand = inference.command;
const resolvedArgs = inference.args;

let primaryError: unknown;
try {
if (resolvedCommand === 'list') {
if (consumeHelpTokens(resolvedArgs)) {
Expand Down Expand Up @@ -281,46 +308,68 @@ export async function runCli(argv: string[]): Promise<void> {
await importedHandleResource(runtime, resolvedArgs);
return;
}
} catch (error) {
primaryError = error;
throw error;
} finally {
const closeStart = Date.now();
await closeRuntimeAfterCommand(runtime, { suppressReplayCloseError: primaryError !== undefined });
}
printHelp(`Unknown command '${resolvedCommand}'.`);
process.exit(1);
}

async function closeRuntimeAfterCommand(
runtime: Runtime,
options: { readonly suppressReplayCloseError?: boolean } = {}
): Promise<void> {
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) {
logInfo('[debug] beginning runtime.close()');
dumpActiveHandles('before runtime.close');
const duration = Date.now() - closeStart;
logInfo(`[debug] runtime.close() completed in ${duration}ms`);
dumpActiveHandles('after 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);
} 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);
}
}
printHelp(`Unknown command '${resolvedCommand}'.`);
process.exit(1);
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.
Expand Down Expand Up @@ -360,6 +409,9 @@ async function maybeHandleDaemonFastCall(
configResolution: { path: string; explicit: boolean },
rootDir: string | undefined
): Promise<boolean> {
if (isRecordReplayModeActive()) {
return false;
}
const callArgs = resolveDaemonFastCallArgs(command, args);
if (!callArgs) {
return false;
Expand Down Expand Up @@ -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' ||
Expand Down
3 changes: 3 additions & 0 deletions src/cli/flag-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
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
Loading
Loading