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>
110 lines
3.6 KiB
TypeScript
110 lines
3.6 KiB
TypeScript
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;
|
|
}
|