fix(replay): rewrite response ids during replay (#192)
Some checks failed
Some checks failed
* feat(record): capture MCP call streams to NDJSON and replay deterministically mcporter record <session> wraps the runtime transport and appends every JSON-RPC request, response, and notification to a per-session NDJSON file under ~/.mcporter/recordings/. mcporter replay <session> reconstructs an in-memory transport from the recording and matches requests by method + deep-equal params, returning the recorded response without contacting the live server. Use cases: - Reproduce MCP-backed agent bugs offline (no live Linear quota, no Vercel API rate limits) - Build test fixtures from real call sequences - Share a session for a postmortem without sharing credentials The format is plain JSON-RPC over NDJSON with a small _meta field (direction, server, timestamp). No proprietary blob. Env-var passthrough (MCPORTER_RECORD=<name>, MCPORTER_REPLAY=<name>) lets the existing runtime constructor wrap any transport when set. * fix(replay): attach cause to wrapped errors to satisfy preserve-caught-error lint * fix(replay): rewrite response ids during replay * fix(replay): harden record replay modes Clear conflicting record/replay env vars when spawning wrapped commands, force those commands off the daemon fast path, truncate each recording file at session start, and fail replay close when recorded requests remain unused. * fix(cli): preserve wrapped command flags Stop global flag extraction at -- so record/replay wrappers do not consume child command flags, and drop the release-owned changelog entry from the PR diff. * fix(replay): propagate cleanup failures through cli Ensure replay-mode transport close failures escape normal runtime and CLI cleanup after best-effort shutdown has completed. Add runtime and CLI regressions for partial recordings that leave requests unreplayed. * fix: harden record replay runtime paths * test: align replay fixtures with windows home --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
parent
56be50f763
commit
2bf7a5eab2
@ -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)
|
||||
|
||||
|
||||
@ -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).
|
||||
|
||||
|
||||
52
docs/record-replay.md
Normal file
52
docs/record-replay.md
Normal file
@ -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.
|
||||
140
src/cli.ts
140
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<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(
|
||||
@ -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,
|
||||
@ -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)) {
|
||||
@ -281,48 +308,70 @@ export async function runCli(argv: string[]): Promise<void> {
|
||||
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<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) {
|
||||
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<void> {
|
||||
await runCli(process.argv.slice(2));
|
||||
@ -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;
|
||||
@ -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' ||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>]',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
150
src/cli/record-command.ts
Normal file
150
src/cli/record-command.ts
Normal file
@ -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<void> {
|
||||
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 <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 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<void> {
|
||||
const [command, ...commandArgs] = parsed.command;
|
||||
if (!command) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((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} <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 };
|
||||
}
|
||||
46
src/cli/record-replay-env.ts
Normal file
46
src/cli/record-replay-env.ts
Normal file
@ -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<string, string | undefined>, 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;
|
||||
}
|
||||
84
src/cli/replay-command.ts
Normal file
84
src/cli/replay-command.ts
Normal file
@ -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<void> {
|
||||
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 <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: NodeJS.ProcessEnv): Promise<void> {
|
||||
const [command, ...args] = commandAndArgs;
|
||||
if (!command) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((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();
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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<void> }
|
||||
transport: Transport & { close(): Promise<void> },
|
||||
options: CloseTransportAndWaitOptions = {}
|
||||
): Promise<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -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<void> {
|
||||
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<void> {
|
||||
if (!shouldResetConnection(error)) {
|
||||
return;
|
||||
|
||||
181
src/runtime/record-transport.ts
Normal file
181
src/runtime/record-transport.ts
Normal file
@ -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<string, Promise<void>>();
|
||||
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<void>;
|
||||
|
||||
private writes: Promise<void> = Promise.resolve();
|
||||
private closeRecorded = false;
|
||||
|
||||
constructor(private readonly opts: RecordTransportOptions) {
|
||||
this.sessionId = opts.inner.sessionId;
|
||||
const finishAuth = (opts.inner as { finishAuth?: (authorizationCode: string) => Promise<void> }).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<void> {
|
||||
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<void> {
|
||||
await this.appendLine(this.withMeta(message, 'send'));
|
||||
await this.opts.inner.send(message, options);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
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<void> {
|
||||
await this.appendLine(
|
||||
this.withMeta(
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
method: `$transport/${event}`,
|
||||
},
|
||||
'lifecycle'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private async appendCloseOnce(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
198
src/runtime/replay-transport.ts
Normal file
198
src/runtime/replay-transport.ts
Normal file
@ -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<string, unknown>;
|
||||
|
||||
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<void> {}
|
||||
|
||||
async send(message: JSONRPCMessage, _options?: TransportSendOptions): Promise<void> {
|
||||
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<void> {
|
||||
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<string, ExpectedSend>();
|
||||
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}.`;
|
||||
}
|
||||
@ -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<string, string> | undefined): Record<string, string> | undefined {
|
||||
@ -136,6 +140,38 @@ async function closeOAuthSession(oauthSession?: OAuthSession): Promise<void> {
|
||||
await oauthSession?.close().catch(() => {});
|
||||
}
|
||||
|
||||
function shouldUseModeForServer(definition: ServerDefinition, serverFilter: string | undefined): boolean {
|
||||
return !serverFilter || serverFilter === definition.name;
|
||||
}
|
||||
|
||||
function wrapRecordTransport<TTransport extends Transport>(
|
||||
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<ClientContext> {
|
||||
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<ServerDefinition['command'], { kind: 'stdio' }> },
|
||||
logger: Logger
|
||||
logger: Logger,
|
||||
options: CreateClientContextOptions
|
||||
): Promise<ClientContext> {
|
||||
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<ClientContext> {
|
||||
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<ClientContext> {
|
||||
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<ServerDefinition['command'], { kind: 'stdio' }> },
|
||||
logger
|
||||
logger,
|
||||
options
|
||||
);
|
||||
}
|
||||
return retryHttpTransportWithFallback(client, activeDefinition, logger, options);
|
||||
|
||||
@ -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();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@ -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/);
|
||||
|
||||
109
tests/record-replay-cli-close.test.ts
Normal file
109
tests/record-replay-cli-close.test.ts
Normal file
@ -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<void> {
|
||||
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;
|
||||
}
|
||||
129
tests/record-replay-cli.test.ts
Normal file
129
tests/record-replay-cli.test.ts
Normal file
@ -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<void> {
|
||||
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);
|
||||
}
|
||||
459
tests/record-replay.test.ts
Normal file
459
tests/record-replay.test.ts
Normal file
@ -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<void> {}
|
||||
|
||||
async send(message: JSONRPCMessage, _options?: TransportSendOptions): Promise<void> {
|
||||
this.sent.push(message);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
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<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-record-replay-'));
|
||||
return path.join(dir, 'session.ndjson');
|
||||
}
|
||||
|
||||
async function writeRecording(entries: RecordedMessage[]): Promise<string> {
|
||||
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<RecordedMessage[]> {
|
||||
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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user