@mcpeak/cli 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,7 +22,7 @@ mcpeak test packages/cli/tests/fixtures/weather-suite.json \
22
22
  --arg examples/weather-server/server.mjs
23
23
  ```
24
24
 
25
- 문법은 `mcpeak test <suite.json> --command <executable> [--arg <value> ...] [--json] [--junit <path>] [--stderr-lines <N>]`입니다. 위 예시의 command와 arg는 `node examples/weather-server/server.mjs`로 실행됩니다. `--arg`는 반복할 수 있고, 하이픈으로 시작하는 값은 `--arg=-m`, 빈 값은 `--arg=`로 전달합니다.
25
+ 문법은 `mcpeak test <suite.json> --command <executable> [--arg <value> ...] [--json] [--junit <path>] [--stderr-lines <N>] [--record-session <path> | --session <path>]`입니다. 위 예시의 command와 arg는 `node examples/weather-server/server.mjs`로 실행됩니다. `--arg`는 반복할 수 있고, 하이픈으로 시작하는 값은 `--arg=-m`, 빈 값은 `--arg=`로 전달합니다.
26
26
 
27
27
  stdout에는 보고서만 나갑니다. 기본은 사람이 읽는 보고서이고, `--json`을 주면 `RunnerReport` JSON이 나갑니다. CLI 오류와 서버 프로세스 진단은 stderr로만 나가므로 `--json > report.json`이 깨지지 않습니다. 모든 테스트가 통과하면 종료 코드 0을, failed 또는 aborted report와 입력, 연결, 종료 오류에는 1을 반환합니다.
28
28
 
@@ -40,6 +40,11 @@ XML을 만드는 단계에서 실패하면 `CLI_INTERNAL_ERROR`와 함께 종료
40
40
 
41
41
  `RunnerReport`에는 시간 정보가 없어 모든 `time` 속성은 `0`입니다. "0초 걸렸다"가 아니라 "시간 정보를 갖고 있지 않다"는 뜻입니다. 근거는 [ADR-0016](../../docs/adr/0016-junit-time-속성-고정.md), 플래그 설계는 [ADR-0019](../../docs/adr/0019-junit-리포터-cli-노출-방식.md)에 있습니다.
42
42
 
43
+ `--record-session <path>`는 서버가 `globalThis.fetch`로 호출한 외부 HTTP 응답을 녹화하고,
44
+ `--session <path>`는 그 녹화본을 재생합니다. 서버는 재생 때도 실제로 실행됩니다. `node:http`와
45
+ `node:https` 계층 호출은 범위 밖이라 실제 네트워크로 나갈 수 있으며, CLI는 녹화 0건이나 재생
46
+ 0건처럼 범위 밖 호출이 의심되는 실행을 stderr 알림으로 드러냅니다.
47
+
43
48
  `--stderr-lines <N>`은 실패했거나 서버가 비정상 종료·중단했을 때, 진단 내용이 있으면 stderr에 붙는 서버 프로세스 진단 블록의 stderr 표시 줄 수입니다. 기본값은 20이고, `0`을 주면 블록을 완전히 끕니다(그때 출력 바이트는 이 기능이 없던 때와 같습니다). 블록에는 종료 코드와 시그널, 서버가 남긴 stderr의 마지막 N줄이 담기며 잘린 사실은 헤더에 적힙니다. 서버가 정상 종료했고 stderr도 비어 있으면 보여줄 근거가 없으므로 블록을 쓰지 않습니다.
44
49
 
45
50
  ### 승인 지문 대조
@@ -60,7 +65,7 @@ XML을 만드는 단계에서 실패하면 `CLI_INTERNAL_ERROR`와 함께 종료
60
65
 
61
66
  지문이 없는 명세도 정상입니다. 손으로 쓴 명세와 이 기능 이전에 만든 명세가 여기에 해당하며 실패로 취급하지 않습니다.
62
67
 
63
- 현재는 UTF-8 JSON 단일 명세와 stdio 서버만 지원합니다. shell 문법, 여러 명세, TypeScript 모듈 명세는 지원하지 않습니다. `record`, `replay`, `mock` 명령은 아직 구현되지 않았습니다.
68
+ 현재는 UTF-8 JSON 단일 명세와 stdio 서버만 지원합니다. shell 문법, 여러 명세, TypeScript 모듈 명세는 지원하지 않습니다. `record`, `mock` 명령은 아직 구현되지 않았습니다.
64
69
 
65
70
  ## generate
66
71
 
package/dist/cli.cjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  //#region src/cli.ts
3
- require("./src-Df8147pD.cjs").run(process.argv.slice(2)).then((code) => {
3
+ require("./src-CwhDFSWk.cjs").run(process.argv.slice(2)).then((code) => {
4
4
  process.exitCode = code;
5
5
  });
6
6
  //#endregion
package/dist/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { r as run } from "./src-D1NW6ckv.mjs";
2
+ import { r as run } from "./src-BzVS8KXQ.mjs";
3
3
  //#region src/cli.ts
4
4
  run(process.argv.slice(2)).then((code) => {
5
5
  process.exitCode = code;
package/dist/commands.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_test_command = require("./test-command-BZ_94UmI.cjs");
2
+ const require_test_command = require("./test-command-BbjJIX7j.cjs");
3
3
  exports.nodeGenerateDependencies = require_test_command.nodeGenerateDependencies;
4
4
  exports.nodeReviewIO = require_test_command.nodeReviewIO;
5
5
  exports.parseRepairCommand = require_test_command.parseRepairCommand;
@@ -1,6 +1,7 @@
1
1
  import { a as RepairCommandDependencies, c as runRepairCommand, d as ReviewIO, f as nodeGenerateDependencies, i as runReplayCommand, l as GenerateCommandDependencies, m as runGenerateCommand, n as ReplayCommandInput, o as RepairCommandInput, p as nodeReviewIO, r as parseReplayCommand, s as parseRepairCommand, t as ReplayCommandDependencies, u as GenerateCommandInput } from "./replay-command-V0ZSJPiD.cjs";
2
2
  import { McpStdioConnection } from "@mcpeak/core";
3
3
  import { CheckDeterminismOptions, DeterminismResult, FinalizeRunnerExecutionOptions, InputContractOptions, RunSuiteOptions, RunnerExecution, RunnerReport, SpecFindingsResult, SuiteValidationIssue, SuiteValidationResult, TestSuiteSpec } from "@mcpeak/runner";
4
+ import "@mcpeak/record/external";
4
5
  //#region src/test-command.d.ts
5
6
  interface TestCommandInput {
6
7
  readonly suitePath: string;
@@ -15,9 +16,13 @@ interface TestCommandInput {
15
16
  readonly determinism: boolean;
16
17
  /** `--reset-cmd` 로 받은 초기화 명령. 각 회차 전에 1번씩 실행한다. */
17
18
  readonly resetCmd: string | undefined;
19
+ /** `--session`. External 세션을 재생한다. */
20
+ readonly sessionPath: string | undefined;
21
+ /** `--record-session`. External 세션을 녹화한다. */
22
+ readonly recordSessionPath: string | undefined;
18
23
  readonly stderrLines: number;
19
24
  }
20
- type CliErrorCode = "CLI_USAGE" | "COMMAND_NOT_IMPLEMENTED" | "SUITE_FORMAT_UNSUPPORTED" | "SUITE_READ_FAILED" | "SUITE_ENCODING_INVALID" | "SUITE_JSON_INVALID" | "SUITE_VALIDATION_FAILED" | "MCP_CONNECTION_FAILED" | "RUNNER_EXECUTION_FAILED" | "RUNNER_FINALIZATION_FAILED" | "JUNIT_WRITE_FAILED" | "REPAIR_BUNDLE_WRITE_FAILED" | "RESET_COMMAND_FAILED" | "CLI_INTERNAL_ERROR";
25
+ type CliErrorCode = "CLI_USAGE" | "COMMAND_NOT_IMPLEMENTED" | "SUITE_FORMAT_UNSUPPORTED" | "SUITE_READ_FAILED" | "SUITE_ENCODING_INVALID" | "SUITE_JSON_INVALID" | "SUITE_VALIDATION_FAILED" | "MCP_CONNECTION_FAILED" | "RUNNER_EXECUTION_FAILED" | "RUNNER_FINALIZATION_FAILED" | "JUNIT_WRITE_FAILED" | "REPAIR_BUNDLE_WRITE_FAILED" | "RESET_COMMAND_FAILED" | "EXTERNAL_SESSION_FAILED" | "CLI_INTERNAL_ERROR";
21
26
  interface CliFailure {
22
27
  readonly code: CliErrorCode;
23
28
  readonly message: string;
@@ -31,6 +36,8 @@ interface TestCommandDependencies {
31
36
  connect(options: {
32
37
  command: string;
33
38
  args: readonly string[];
39
+ /** External 배선이 만든 자식 환경 변수. Bootstrap 주입이 여기 실린다. */
40
+ env?: Readonly<Record<string, string>>;
34
41
  }): Promise<McpStdioConnection>;
35
42
  startRunner(options: RunSuiteOptions): RunnerExecution;
36
43
  finalize(options: FinalizeRunnerExecutionOptions): Promise<RunnerReport>;
@@ -62,6 +69,15 @@ interface TestCommandDependencies {
62
69
  writeStderr(text: string): void;
63
70
  }
64
71
  declare function parseTestCommand(argv: readonly string[]): TestCommandInput;
72
+ /**
73
+ * `test` 실행에 External Record/Replay 수명주기를 씌운다.
74
+ *
75
+ * Coordinator 를 **먼저 열고 마지막에 닫는다**(ADR-0052). 그 사이에 들어가는 것이 기존 실행
76
+ * 전부라, 성공·실패·예외 어느 경로로 빠져나가도 `finish` 가 불려야 한다. 안 불리면 SQLite
77
+ * 파일 핸들이 남고 Record 세션이 `running` 인 채로 남아 다음 실행이 이어 쓸 수 없다.
78
+ *
79
+ * 세션 옵션이 없으면 배선을 아예 만들지 않는다 — 기존 실행 경로가 한 글자도 달라지지 않는다.
80
+ */
65
81
  declare function runCli(argv: readonly string[], dependencies: TestCommandDependencies): Promise<number>;
66
82
  //#endregion
67
83
  export { type CliErrorCode, type CliFailure, type GenerateCommandDependencies, type GenerateCommandInput, type RepairCommandDependencies, type RepairCommandInput, type ReplayCommandDependencies, type ReplayCommandInput, type ReviewIO, type TestCommandDependencies, type TestCommandInput, nodeGenerateDependencies, nodeReviewIO, parseRepairCommand, parseReplayCommand, parseTestCommand, runCli, runGenerateCommand, runRepairCommand, runReplayCommand };
@@ -1,6 +1,7 @@
1
1
  import { a as RepairCommandDependencies, c as runRepairCommand, d as ReviewIO, f as nodeGenerateDependencies, i as runReplayCommand, l as GenerateCommandDependencies, m as runGenerateCommand, n as ReplayCommandInput, o as RepairCommandInput, p as nodeReviewIO, r as parseReplayCommand, s as parseRepairCommand, t as ReplayCommandDependencies, u as GenerateCommandInput } from "./replay-command-BAtSzf8R.mjs";
2
2
  import { CheckDeterminismOptions, DeterminismResult, FinalizeRunnerExecutionOptions, InputContractOptions, RunSuiteOptions, RunnerExecution, RunnerReport, SpecFindingsResult, SuiteValidationIssue, SuiteValidationResult, TestSuiteSpec } from "@mcpeak/runner";
3
3
  import { McpStdioConnection } from "@mcpeak/core";
4
+ import "@mcpeak/record/external";
4
5
  //#region src/test-command.d.ts
5
6
  interface TestCommandInput {
6
7
  readonly suitePath: string;
@@ -15,9 +16,13 @@ interface TestCommandInput {
15
16
  readonly determinism: boolean;
16
17
  /** `--reset-cmd` 로 받은 초기화 명령. 각 회차 전에 1번씩 실행한다. */
17
18
  readonly resetCmd: string | undefined;
19
+ /** `--session`. External 세션을 재생한다. */
20
+ readonly sessionPath: string | undefined;
21
+ /** `--record-session`. External 세션을 녹화한다. */
22
+ readonly recordSessionPath: string | undefined;
18
23
  readonly stderrLines: number;
19
24
  }
20
- type CliErrorCode = "CLI_USAGE" | "COMMAND_NOT_IMPLEMENTED" | "SUITE_FORMAT_UNSUPPORTED" | "SUITE_READ_FAILED" | "SUITE_ENCODING_INVALID" | "SUITE_JSON_INVALID" | "SUITE_VALIDATION_FAILED" | "MCP_CONNECTION_FAILED" | "RUNNER_EXECUTION_FAILED" | "RUNNER_FINALIZATION_FAILED" | "JUNIT_WRITE_FAILED" | "REPAIR_BUNDLE_WRITE_FAILED" | "RESET_COMMAND_FAILED" | "CLI_INTERNAL_ERROR";
25
+ type CliErrorCode = "CLI_USAGE" | "COMMAND_NOT_IMPLEMENTED" | "SUITE_FORMAT_UNSUPPORTED" | "SUITE_READ_FAILED" | "SUITE_ENCODING_INVALID" | "SUITE_JSON_INVALID" | "SUITE_VALIDATION_FAILED" | "MCP_CONNECTION_FAILED" | "RUNNER_EXECUTION_FAILED" | "RUNNER_FINALIZATION_FAILED" | "JUNIT_WRITE_FAILED" | "REPAIR_BUNDLE_WRITE_FAILED" | "RESET_COMMAND_FAILED" | "EXTERNAL_SESSION_FAILED" | "CLI_INTERNAL_ERROR";
21
26
  interface CliFailure {
22
27
  readonly code: CliErrorCode;
23
28
  readonly message: string;
@@ -31,6 +36,8 @@ interface TestCommandDependencies {
31
36
  connect(options: {
32
37
  command: string;
33
38
  args: readonly string[];
39
+ /** External 배선이 만든 자식 환경 변수. Bootstrap 주입이 여기 실린다. */
40
+ env?: Readonly<Record<string, string>>;
34
41
  }): Promise<McpStdioConnection>;
35
42
  startRunner(options: RunSuiteOptions): RunnerExecution;
36
43
  finalize(options: FinalizeRunnerExecutionOptions): Promise<RunnerReport>;
@@ -62,6 +69,15 @@ interface TestCommandDependencies {
62
69
  writeStderr(text: string): void;
63
70
  }
64
71
  declare function parseTestCommand(argv: readonly string[]): TestCommandInput;
72
+ /**
73
+ * `test` 실행에 External Record/Replay 수명주기를 씌운다.
74
+ *
75
+ * Coordinator 를 **먼저 열고 마지막에 닫는다**(ADR-0052). 그 사이에 들어가는 것이 기존 실행
76
+ * 전부라, 성공·실패·예외 어느 경로로 빠져나가도 `finish` 가 불려야 한다. 안 불리면 SQLite
77
+ * 파일 핸들이 남고 Record 세션이 `running` 인 채로 남아 다음 실행이 이어 쓸 수 없다.
78
+ *
79
+ * 세션 옵션이 없으면 배선을 아예 만들지 않는다 — 기존 실행 경로가 한 글자도 달라지지 않는다.
80
+ */
65
81
  declare function runCli(argv: readonly string[], dependencies: TestCommandDependencies): Promise<number>;
66
82
  //#endregion
67
83
  export { type CliErrorCode, type CliFailure, type GenerateCommandDependencies, type GenerateCommandInput, type RepairCommandDependencies, type RepairCommandInput, type ReplayCommandDependencies, type ReplayCommandInput, type ReviewIO, type TestCommandDependencies, type TestCommandInput, nodeGenerateDependencies, nodeReviewIO, parseRepairCommand, parseReplayCommand, parseTestCommand, runCli, runGenerateCommand, runRepairCommand, runReplayCommand };
package/dist/commands.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { a as runReplayCommand, c as nodeGenerateDependencies, i as parseReplayCommand, l as nodeReviewIO, n as runCli, o as parseRepairCommand, s as runRepairCommand, t as parseTestCommand, u as runGenerateCommand } from "./test-command-CsJfojut.mjs";
1
+ import { a as runReplayCommand, c as nodeGenerateDependencies, i as parseReplayCommand, l as nodeReviewIO, n as runCli, o as parseRepairCommand, s as runRepairCommand, t as parseTestCommand, u as runGenerateCommand } from "./test-command-CtQbeyhy.mjs";
2
2
  export { nodeGenerateDependencies, nodeReviewIO, parseRepairCommand, parseReplayCommand, parseTestCommand, runCli, runGenerateCommand, runRepairCommand, runReplayCommand };
package/dist/index.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_src = require("./src-Df8147pD.cjs");
2
+ const require_src = require("./src-CwhDFSWk.cjs");
3
3
  exports.COMMANDS = require_src.COMMANDS;
4
4
  exports.nodeRepairDependencies = require_src.nodeRepairDependencies;
5
5
  exports.run = require_src.run;
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { i as unavailableReplayDependencies, n as nodeRepairDependencies, r as run, t as COMMANDS } from "./src-D1NW6ckv.mjs";
1
+ import { i as unavailableReplayDependencies, n as nodeRepairDependencies, r as run, t as COMMANDS } from "./src-BzVS8KXQ.mjs";
2
2
  export { COMMANDS, nodeRepairDependencies, run, unavailableReplayDependencies };
@@ -1,4 +1,4 @@
1
- import { a as runReplayCommand, c as nodeGenerateDependencies, d as GLOBAL_HELP, f as VERIFY_USAGE_HINT, l as nodeReviewIO, m as version, n as runCli, p as commandHelp, r as ReplayRuntimeUnavailableError, s as runRepairCommand, t as parseTestCommand, u as runGenerateCommand } from "./test-command-CsJfojut.mjs";
1
+ import { a as runReplayCommand, c as nodeGenerateDependencies, d as GLOBAL_HELP, f as VERIFY_USAGE_HINT, l as nodeReviewIO, m as version, n as runCli, p as commandHelp, r as ReplayRuntimeUnavailableError, s as runRepairCommand, t as parseTestCommand, u as runGenerateCommand } from "./test-command-CtQbeyhy.mjs";
2
2
  import { readFile, writeFile } from "node:fs/promises";
3
3
  //#region src/verify-command.ts
4
4
  /**
@@ -1,4 +1,4 @@
1
- const require_test_command = require("./test-command-BZ_94UmI.cjs");
1
+ const require_test_command = require("./test-command-BbjJIX7j.cjs");
2
2
  let node_fs_promises = require("node:fs/promises");
3
3
  //#region src/verify-command.ts
4
4
  /**
@@ -6,7 +6,7 @@ let _mcpeak_record = require("@mcpeak/record");
6
6
  let node_child_process = require("node:child_process");
7
7
  let _mcpeak_core = require("@mcpeak/core");
8
8
  //#region package.json
9
- var version = "0.9.0";
9
+ var version = "0.10.0";
10
10
  //#endregion
11
11
  //#region src/cassette-wiring.ts
12
12
  /**
@@ -320,7 +320,7 @@ const FINDING_GROUP = {
320
320
  };
321
321
  //#endregion
322
322
  //#region src/help.ts
323
- const TEST_USAGE = "사용법: mcpeak test <suite.json> --command <executable> [--arg <value> ...] [--determinism] [--reset-cmd <command>] [--json] [--junit <path>] [--repair-bundle <path>] [--stderr-lines <N>]";
323
+ const TEST_USAGE = "사용법: mcpeak test <suite.json> --command <executable> [--arg <value> ...] [--determinism] [--reset-cmd <command>] [--json] [--junit <path>] [--repair-bundle <path>] [--stderr-lines <N>] [--session <path> | --record-session <path>]";
324
324
  /**
325
325
  * 시험 실행 옵션 설명. `--determinism` 은 툴을 2회 호출하므로 부작용이 있는 서버에서 모르고
326
326
  * 쓰면 사고가 난다. 그 경고가 사용법 한 줄에는 들어가지 않는다.
@@ -331,7 +331,31 @@ const TEST_OPTIONS = `옵션:
331
331
  --reset-cmd 와 함께 쓰면 결정론성 확인이 되고, 없으면
332
332
  "2회 결과가 같았다" 까지만 확인합니다
333
333
  --reset-cmd <command> 각 시험 실행 전에 이 명령을 한 번 실행합니다. 셸을 거치지
334
- 않으므로 파이프나 && 는 쓸 수 없습니다`;
334
+ 않으므로 파이프나 && 는 쓸 수 없습니다
335
+ --record-session <path>
336
+ 서버가 \`globalThis.fetch\` 로 밖에 부른 HTTP 호출만 녹화합니다.
337
+ 서버는 실제로 실행됩니다. \`node:http\`·\`node:https\` 같은 범위 밖 호출은
338
+ 잡히지 않아 실제 네트워크로 나갈 수 있습니다
339
+ --session <path> 녹화한 외부 호출을 재생합니다. 서버는 실제로 실행되지만 외부
340
+ API 호출은 \`globalThis.fetch\` 범위 안에서만 가로챕니다.
341
+ 범위 밖 호출은 실제 네트워크로 나갈 수 있으며, 범위 밖 호출이
342
+ 의심되는 경우 종료 때 알립니다
343
+
344
+ 두 세션 옵션은 서로, 그리고 \`--determinism\` 과 함께 쓸 수 없습니다. \`--determinism\` 은 서버에
345
+ 2회 연결하는데 세션은 연결 하나에 묶여 있어, 2회차가 같은 세션을 쓰면 반복 호출 순번이
346
+ 어긋나고 새 세션을 쓰면 비교 기준이 갈라집니다.
347
+
348
+ 세션은 \`--cassette\` 카세트와 다릅니다. **카세트는 우리가 서버에게 물어본 결과**를 남기고,
349
+ **세션은 그 서버가 밖에 물어본 결과**를 남깁니다. 둘은 섞이지 않으며 파일도 따로입니다.
350
+
351
+ 세션 파일에는 외부 API 응답이 저장되므로 .gitignore 를 확인하세요. \`token\`·\`apiKey\` 같은
352
+ 이름의 값은 저장 전에 가려집니다.
353
+
354
+ 런타임에 따라 세션 옵션을 쓴 실행에서 \`ExperimentalWarning: SQLite is an experimental
355
+ feature\` 가 stderr 에 한 줄 찍힙니다(실측: Node 22.18.0 에서 나오고 24.16.0 에서는 안 납니다).
356
+ \`node:sqlite\` 를 처음 로드할 때 한 번 나오므로 실행당 한 줄이며, 세션 옵션이 없는 실행에는
357
+ 붙지 않습니다. Node 가 그 모듈을 아직 실험적으로 표시한다는 뜻이고, **저장된 녹화는 표준
358
+ SQLite 파일이라 영향받지 않습니다.**`;
335
359
  const GENERATE_USAGE = "사용법: mcpeak generate --suite-id <id> --name <name> --out <suite.json> --command <executable> [--arg <value> ...] [--baseline-only] [--provider <codex|claude>] [--model <model>] [--no-dry-run] [--cassette <path>] [--record] [--reset-cmd <command>] [--no-repair] [--force]";
336
360
  /**
337
361
  * 시험 실행 옵션 설명. 사용법 한 줄로는 `--cassette` 가 무엇을 파일로 남기는지, `--reset-cmd`
@@ -403,7 +427,7 @@ auto 모드(\`--record\` 없는 실행)는 카세트에 있는 요청이면 서
403
427
  마스킹해 비교하므로, 비밀값 자체만 바뀐 경우는 감지되지 않습니다.
404
428
 
405
429
  불일치나 호출 실패가 하나라도 있으면 종료 코드 1 입니다. 확인불가는 실패로 보지 않습니다.`;
406
- const GLOBAL_HELP = `OhMyMCP — MCP 서버 테스트 프레임워크
430
+ const GLOBAL_HELP = `MCPeak — MCP 서버 테스트 프레임워크
407
431
 
408
432
  사용법: mcpeak <명령> [옵션]
409
433
  mcpeak help [명령]
@@ -833,6 +857,18 @@ function dropSkippedTools(suite, skips) {
833
857
  }
834
858
  //#endregion
835
859
  //#region src/process-diagnostics.ts
860
+ /**
861
+ * core 의 stdio 프로세스 진단인지 구조와 값의 범위로 확인한다.
862
+ * 일부 필드만 있거나 프로세스 상태로 표현할 수 없는 값은 렌더하지 않는다.
863
+ */
864
+ function processDiagnostics(value) {
865
+ if (typeof value !== "object" || value === null) return void 0;
866
+ if (!("stderr" in value) || typeof value.stderr !== "string") return void 0;
867
+ if (!("stderrTruncated" in value) || typeof value.stderrTruncated !== "boolean") return void 0;
868
+ if (!("exitCode" in value) || !(value.exitCode === null || typeof value.exitCode === "number" && Number.isInteger(value.exitCode))) return void 0;
869
+ if (!("signal" in value) || !(value.signal === null || typeof value.signal === "string" && value.signal.length > 0)) return void 0;
870
+ return value;
871
+ }
836
872
  /** TAB. 이 모듈만 이스케이프 대상에서 빼는 문자다. 아래 escapeTokens 주석에 근거가 있다. */
837
873
  const TAB = 9;
838
874
  /**
@@ -2793,8 +2829,14 @@ async function runGenerateCommand(argv, deps) {
2793
2829
  else if (error instanceof OutputReplaceError) outputReplaceFailure(deps, error.path, error.code);
2794
2830
  else if (error instanceof LinkUnsupportedError) linkUnsupportedFailure(deps, error.path, error.code);
2795
2831
  else if (deps.GenerateTestsError !== void 0 && error instanceof deps.GenerateTestsError) generateTestsFailure(deps, error);
2796
- else if (isCoreClientError(error)) deps.writeStderr(`오류 [GENERATE_CONNECT_FAILED/${error.code}]: ${error.message}\n해결: ${error.hint}\n`);
2797
- else deps.writeStderr("오류 [GENERATE_FAILED]: baseline suite를 생성하거나 저장하지 못했습니다.\n해결: MCP 서버와 출력 경로를 확인한 뒤 다시 실행하세요.\n");
2832
+ else if (isCoreClientError(error)) {
2833
+ deps.writeStderr(`오류 [GENERATE_CONNECT_FAILED/${error.code}]: ${error.message}\n해결: ${error.hint}\n`);
2834
+ const diagnostics = processDiagnostics(error.diagnostics);
2835
+ if (diagnostics !== void 0 && hasDiagnosticContent(diagnostics)) {
2836
+ const block = renderProcessDiagnostics(diagnostics, { maxLines: DRY_RUN_STDERR_LINES });
2837
+ if (block !== "") deps.writeStderr(`\n${block}`);
2838
+ }
2839
+ } else deps.writeStderr("오류 [GENERATE_FAILED]: baseline suite를 생성하거나 저장하지 못했습니다.\n해결: MCP 서버와 출력 경로를 확인한 뒤 다시 실행하세요.\n");
2798
2840
  return 1;
2799
2841
  }
2800
2842
  try {
@@ -3689,6 +3731,51 @@ function createDeterminismCapture(inner) {
3689
3731
  };
3690
3732
  }
3691
3733
  //#endregion
3734
+ //#region src/external-wiring.ts
3735
+ /**
3736
+ * `@mcpeak/record/external` 은 **세션 옵션을 실제로 쓸 때만** 불러온다.
3737
+ *
3738
+ * 정적 import 로 두면 `node:sqlite` 가 CLI 를 띄우는 것만으로 로드되고, Node 22.x 는 그때
3739
+ * `ExperimentalWarning: SQLite is an experimental feature` 를 stderr 에 찍는다. 세션을 쓰지도
3740
+ * 않은 `mcpeak test` 실행마다 그 줄이 붙는다 — 터미널 출력이 곧 UI 인 도구에서 그냥 손해다.
3741
+ * (실제로 `dist-cli-e2e` 의 "stderr 가 비어 있다" 단언이 이걸 잡았다.)
3742
+ *
3743
+ * 지연 로딩이면 경고를 보는 사람은 External 을 직접 켠 사용자뿐이다. 그 경고를 아예 지울지는
3744
+ * ADR-0054 가 남겨 둔 별도 결정이며, 라이브러리가 아니라 CLI 가 정할 몫이다.
3745
+ */
3746
+ const loadExternal = () => import("@mcpeak/record/external");
3747
+ /** 세션 파일 하나가 세션 하나이므로 식별자를 사용자에게 묻지 않는다. */
3748
+ const SESSION_ID = "default";
3749
+ async function startExternalWiring(options) {
3750
+ const { createSqliteSessionStore, startExternalCoordinator } = await loadExternal();
3751
+ const store = createSqliteSessionStore({ path: options.sessionPath });
3752
+ let handle;
3753
+ try {
3754
+ handle = options.mode === "record" ? await startExternalCoordinator({
3755
+ mode: "record",
3756
+ sessionId: SESSION_ID,
3757
+ store,
3758
+ ...options.existingNodeOptions === void 0 ? {} : { existingNodeOptions: options.existingNodeOptions }
3759
+ }) : await startExternalCoordinator({
3760
+ mode: "replay",
3761
+ sourceSessionId: SESSION_ID,
3762
+ store,
3763
+ ...options.existingNodeOptions === void 0 ? {} : { existingNodeOptions: options.existingNodeOptions }
3764
+ });
3765
+ } catch (error) {
3766
+ store.close();
3767
+ throw error;
3768
+ }
3769
+ let settled;
3770
+ return {
3771
+ env: handle.childEnvironment,
3772
+ finish(status) {
3773
+ settled ??= handle.finish(status).finally(() => store.close());
3774
+ return settled;
3775
+ }
3776
+ };
3777
+ }
3778
+ //#endregion
3692
3779
  //#region src/test-command.ts
3693
3780
  /** --stderr-lines 기본값. 설계 문서 §6. */
3694
3781
  const DEFAULT_STDERR_LINES = 20;
@@ -3737,6 +3824,10 @@ const dictionary = {
3737
3824
  message: "초기화 명령이 실패했습니다.",
3738
3825
  hint: "`--reset-cmd` 명령이 단독으로 성공하는지 확인한 뒤 다시 실행하세요."
3739
3826
  },
3827
+ EXTERNAL_SESSION_FAILED: {
3828
+ message: "External 세션 처리에 실패했습니다.",
3829
+ hint: "세션 파일 경로의 디렉터리가 있는지와 쓰기 권한을 확인하세요."
3830
+ },
3740
3831
  CLI_INTERNAL_ERROR: {
3741
3832
  message: "예상하지 못한 CLI 내부 오류가 발생했습니다.",
3742
3833
  hint: "다시 실행한 뒤 재현 정보와 함께 이슈를 보고하세요."
@@ -3756,6 +3847,30 @@ const fail = (message) => {
3756
3847
  hint: TEST_USAGE_HINT
3757
3848
  });
3758
3849
  };
3850
+ /**
3851
+ * `--name value` 와 `--name=value` 두 형태에서 값을 꺼낸다.
3852
+ *
3853
+ * 값이 `--` 로 시작하면 거절한다. `--session --json` 처럼 값을 빠뜨리면 다음 옵션이 경로로
3854
+ * 먹히고, 그러면 "그런 파일이 없습니다" 라는 엉뚱한 곳에서 실패한다.
3855
+ */
3856
+ const readOptionValue = (argv, token, name, index) => {
3857
+ let value;
3858
+ let next = index;
3859
+ if (token === name) {
3860
+ const candidate = argv[++next];
3861
+ if (candidate === void 0) throw new CliCommandError({
3862
+ code: "CLI_USAGE",
3863
+ message: `\`${name}\` 옵션 값이 필요합니다.`,
3864
+ hint: TEST_USAGE_HINT
3865
+ });
3866
+ value = candidate;
3867
+ } else value = token.slice(`${name}=`.length);
3868
+ if (value.trim() === "" || value.startsWith("--")) fail(`\`${name}\` 옵션 값이 필요합니다.`);
3869
+ return {
3870
+ value,
3871
+ index: next
3872
+ };
3873
+ };
3759
3874
  function parseTestCommand(argv) {
3760
3875
  const suitePath = argv[0] ?? "";
3761
3876
  if (suitePath === "") fail("테스트 명세 JSON 경로가 필요합니다.");
@@ -3766,6 +3881,8 @@ function parseTestCommand(argv) {
3766
3881
  let determinism = false;
3767
3882
  let resetCmd;
3768
3883
  let stderrLines;
3884
+ let sessionPath;
3885
+ let recordSessionPath;
3769
3886
  const args = [];
3770
3887
  for (let index = 1; index < argv.length; index += 1) {
3771
3888
  const token = argv[index] ?? "";
@@ -3859,6 +3976,16 @@ function parseTestCommand(argv) {
3859
3976
  const parsedLines = Number.parseInt(value, 10);
3860
3977
  if (!Number.isSafeInteger(parsedLines)) fail("`--stderr-lines` 값은 0 이상의 정수여야 합니다.");
3861
3978
  stderrLines = parsedLines;
3979
+ } else if (token === "--session" || token.startsWith("--session=")) {
3980
+ if (sessionPath !== void 0) fail("`--session`은 한 번만 사용할 수 있습니다.");
3981
+ const read = readOptionValue(argv, token, "--session", index);
3982
+ sessionPath = read.value;
3983
+ index = read.index;
3984
+ } else if (token === "--record-session" || token.startsWith("--record-session=")) {
3985
+ if (recordSessionPath !== void 0) fail("`--record-session`은 한 번만 사용할 수 있습니다.");
3986
+ const read = readOptionValue(argv, token, "--record-session", index);
3987
+ recordSessionPath = read.value;
3988
+ index = read.index;
3862
3989
  } else if (token === "--json") {
3863
3990
  if (json) fail("`--json`은 한 번만 사용할 수 있습니다.");
3864
3991
  json = true;
@@ -3871,6 +3998,8 @@ function parseTestCommand(argv) {
3871
3998
  message: "`--command` 옵션이 필요합니다.",
3872
3999
  hint: TEST_USAGE_HINT
3873
4000
  });
4001
+ if (sessionPath !== void 0 && recordSessionPath !== void 0) fail("`--session`과 `--record-session`은 함께 쓸 수 없습니다. 재생과 녹화 중 하나만 고르세요.");
4002
+ if (determinism && (sessionPath !== void 0 || recordSessionPath !== void 0)) fail("`--determinism`은 External 세션 옵션과 함께 쓸 수 없습니다.\n→ `--determinism`은 서버에 2회 연결하지만 External 세션은 연결 하나에 묶여 있습니다.\n→ 2회차가 같은 세션을 쓰면 반복 호출 순번이 어긋나고, 새 세션을 쓰면 비교 기준이 갈라집니다.");
3874
4003
  return Object.freeze({
3875
4004
  suitePath,
3876
4005
  command,
@@ -3880,6 +4009,8 @@ function parseTestCommand(argv) {
3880
4009
  repairBundlePath,
3881
4010
  determinism,
3882
4011
  resetCmd,
4012
+ sessionPath,
4013
+ recordSessionPath,
3883
4014
  stderrLines: stderrLines ?? DEFAULT_STDERR_LINES
3884
4015
  });
3885
4016
  }
@@ -3954,18 +4085,7 @@ function format(failure) {
3954
4085
  for (const issue of failure.issues ?? []) result += `\n- [${escapeTerminalText(issue.code)}] ${escapeTerminalText(issue.path)}: ${escapeTerminalText(issue.message)}\n 해결: ${escapeTerminalText(issue.hint)}`;
3955
4086
  return `${result}\n`;
3956
4087
  }
3957
- /**
3958
- * core 의 McpProcessDiagnostics 인지 구조로 확인한다. core 를 import 하지 않는다.
3959
- * 하나라도 어긋나면 undefined 다. 설계 문서 §4.3.1.
3960
- */
3961
- function processDiagnostics(value) {
3962
- if (typeof value !== "object" || value === null) return void 0;
3963
- if (!("stderr" in value) || typeof value.stderr !== "string") return void 0;
3964
- if (!("stderrTruncated" in value) || typeof value.stderrTruncated !== "boolean") return void 0;
3965
- if (!("exitCode" in value) || !(typeof value.exitCode === "number" || value.exitCode === null)) return void 0;
3966
- if (!("signal" in value) || !(typeof value.signal === "string" || value.signal === null)) return void 0;
3967
- return value;
3968
- }
4088
+ /** AggregateError 내부까지 내려가 core 오류와 검증된 프로세스 진단을 꺼낸다. */
3969
4089
  function coreError(error) {
3970
4090
  const seen = /* @__PURE__ */ new Set();
3971
4091
  const visit = (value) => {
@@ -3989,7 +4109,7 @@ function writeFailure(dependencies, failure) {
3989
4109
  dependencies.writeStderr(format(failure));
3990
4110
  return 1;
3991
4111
  }
3992
- async function runCli(argv, dependencies) {
4112
+ async function runCliCore(argv, dependencies, childEnv) {
3993
4113
  if (argv.length === 0) return writeFailure(dependencies, {
3994
4114
  code: "CLI_USAGE",
3995
4115
  message: "실행할 CLI 명령이 없습니다.",
@@ -4003,7 +4123,7 @@ async function runCli(argv, dependencies) {
4003
4123
  ].includes(argv[0] ?? "")) return writeFailure(dependencies, {
4004
4124
  code: "COMMAND_NOT_IMPLEMENTED",
4005
4125
  message: `'${argv[0]}' 명령은 아직 구현되지 않았습니다.`,
4006
- hint: "현재는 test 명령만 사용할 수 있습니다."
4126
+ hint: "사용 가능한 명령: test, generate, repair, replay, verify. 전체 도움말: mcpeak --help"
4007
4127
  });
4008
4128
  return writeFailure(dependencies, {
4009
4129
  code: "CLI_USAGE",
@@ -4088,7 +4208,8 @@ async function runCli(argv, dependencies) {
4088
4208
  try {
4089
4209
  connection = await dependencies.connect({
4090
4210
  command: input.command,
4091
- args: input.args
4211
+ args: input.args,
4212
+ ...childEnv === void 0 ? {} : { env: childEnv }
4092
4213
  });
4093
4214
  } catch (error) {
4094
4215
  const core = coreError(error);
@@ -4251,7 +4372,8 @@ async function runCli(argv, dependencies) {
4251
4372
  try {
4252
4373
  second = await dependencies.connect({
4253
4374
  command: input.command,
4254
- args: input.args
4375
+ args: input.args,
4376
+ ...childEnv === void 0 ? {} : { env: childEnv }
4255
4377
  });
4256
4378
  } catch (error) {
4257
4379
  const core = coreError(error);
@@ -4489,6 +4611,133 @@ async function runCli(argv, dependencies) {
4489
4611
  }
4490
4612
  return allPassed ? 0 : 1;
4491
4613
  }
4614
+ /**
4615
+ * 재생 원본에서 찾지 못한 호출들을 그린다. `misses` 가 비어 있으면 빈 문자열이다.
4616
+ *
4617
+ * **MCP 오류 채널을 타지 않는다(#259).** `record` 의 `REPLAY_MISS` 진단은 실패한 툴 호출의
4618
+ * MCP 오류 메시지로도 나가는데, 그 채널은 `runner` 가 "테스트 대상 서버가 보낸 텍스트"로
4619
+ * 취급해 이스케이프(개행 포함)와 200자 절단을 건다 — 우리 자신이 여러 줄로 공들여 쓴 진단이
4620
+ * 서버 텍스트와 똑같이 망가진다. 이 함수는 `record` 가 `finish()` 요약에 구조화해 담아 준
4621
+ * 값을 CLI 가 직접 stderr 로 쓴다. `runner` 도 그 이스케이프 규칙도 거치지 않는다.
4622
+ *
4623
+ * `method`·`url`·`matchKeyPrefix` 는 값 자체는 `record` 가 만들지만 원본은 테스트 대상
4624
+ * 서버가 시도한 요청이라, 혹시 모를 제어 문자에 대비해 필드 단위로 이스케이프한다(줄 구조를
4625
+ * 만드는 정적 문구는 이스케이프 대상이 아니다 — `process-diagnostics.ts` 와 같은 원칙).
4626
+ */
4627
+ function renderReplayMissDiagnostics(misses) {
4628
+ if (misses.length === 0) return "";
4629
+ const body = misses.map((miss) => ` ${escapeTerminalText(miss.method)} ${escapeTerminalText(miss.url)}\n occurrence ${miss.occurrence} · matchKey ${escapeTerminalText(miss.matchKeyPrefix)}…\n`).join("");
4630
+ return `\nExternal 진단: 재생 원본에서 찾지 못한 호출 ${misses.length}건\n` + body + "→ 이 호출이 녹화된 뒤에 추가되었거나, 요청이 녹화 때와 달라져 다른 matchKey가 되었습니다.\n→ 녹화를 다시 하거나, 요청이 실행마다 달라지는 값을 담고 있는지 확인하세요.\n";
4631
+ }
4632
+ /**
4633
+ * External 세션 요약을 사용자에게 보이는 한 문단으로 옮긴다. `undefined` 는 할 말이 없다는 뜻이다.
4634
+ *
4635
+ * **순서가 곧 계약이다**(ADR-0057). `consumedCount === 0` 과 `unusedCount > 0` 은 동시에 참일 수
4636
+ * 있어서, 조건을 각각 독립적으로 세우면 한 실행에 경고가 두 번 찍힌다. 아래 순서로 먼저 걸리는
4637
+ * 하나만 낸다.
4638
+ *
4639
+ * 갈래를 넷으로 나눈 이유는 **사용자가 다음에 볼 곳이 다르기 때문이다.** 원본이 비어 있는 것
4640
+ * (녹화 단계가 아무것도 못 잡았다)과 원본은 찼는데 하나도 안 쓴 것(세션 파일을 잘못 짚었거나
4641
+ * 서버의 호출 방식이 바뀌었다)은 같은 `consumedCount === 0` 이지만 원인이 정반대 방향에 있다.
4642
+ * 하나로 합치면 [#258](https://github.com/2026-Engineering-Contest/MCPeak/issues/258) 의 실제
4643
+ * 경로에서 "녹화된 호출이 재생되지 않았다" 고 말하게 되는데, 그 문장은 녹화가 있었다는 전제를
4644
+ * 깔아 사용자를 재생 쪽으로 보낸다. 정작 볼 곳은 그 앞 단계다.
4645
+ *
4646
+ * 판정만 하고 쓰기는 하지 않는다 — 순수 함수라 배타성 자체를 프로세스 없이 고정할 수 있다.
4647
+ */
4648
+ function externalSessionNotice(summary) {
4649
+ if (summary.mode === "record") {
4650
+ if (summary.interactionCount > 0) return void 0;
4651
+ return "\n알림: 이 실행에서 외부 호출이 하나도 녹화되지 않았습니다.\n→ 서버가 외부 API를 호출했다면 지원 범위를 벗어났는지 확인하세요.\n→ MCPeak은 서버가 `globalThis.fetch`로 부른 것만 잡습니다.\n";
4652
+ }
4653
+ if (summary.interactionCount === 0) return "\n알림: 이 세션에는 녹화된 외부 호출이 0건입니다. 재생할 것이 없었습니다.\n→ 녹화 실행이 외부 호출을 하나도 잡지 못했다는 뜻입니다. 이 세션은 아무 호출도 막지 못합니다.\n→ MCPeak은 서버가 `globalThis.fetch`로 부른 것만 잡습니다.\n";
4654
+ if (summary.consumedCount === 0) return "\n알림: 이 실행에서 녹화된 외부 호출이 하나도 재생되지 않았습니다.\n→ 지원 범위 밖의 호출은 실제 네트워크로 나갔을 수 있습니다.\n→ MCPeak은 서버가 `globalThis.fetch`로 부른 것만 잡습니다.\n";
4655
+ if (summary.unusedCount > 0) return `\n알림: 녹화된 외부 호출 ${summary.interactionCount}건 중 ${summary.unusedCount}건이 이번 실행에서 재생되지 않았습니다.\n→ 서버 코드나 실행 경로가 녹화 때와 달라졌을 수 있습니다.
4656
+ → MCPeak은 서버가 \`globalThis.fetch\`로 부른 것만 잡습니다.
4657
+ `;
4658
+ }
4659
+ /**
4660
+ * `test` 실행에 External Record/Replay 수명주기를 씌운다.
4661
+ *
4662
+ * Coordinator 를 **먼저 열고 마지막에 닫는다**(ADR-0052). 그 사이에 들어가는 것이 기존 실행
4663
+ * 전부라, 성공·실패·예외 어느 경로로 빠져나가도 `finish` 가 불려야 한다. 안 불리면 SQLite
4664
+ * 파일 핸들이 남고 Record 세션이 `running` 인 채로 남아 다음 실행이 이어 쓸 수 없다.
4665
+ *
4666
+ * 세션 옵션이 없으면 배선을 아예 만들지 않는다 — 기존 실행 경로가 한 글자도 달라지지 않는다.
4667
+ */
4668
+ async function runCli(argv, dependencies) {
4669
+ const mode = externalModeOf(argv);
4670
+ if (mode === void 0) return runCliCore(argv, dependencies);
4671
+ let wiring;
4672
+ try {
4673
+ wiring = await startExternalWiring({
4674
+ mode: mode.mode,
4675
+ sessionPath: mode.path,
4676
+ existingNodeOptions: process.env.NODE_OPTIONS
4677
+ });
4678
+ } catch (error) {
4679
+ return writeFailure(dependencies, {
4680
+ code: "EXTERNAL_SESSION_FAILED",
4681
+ message: "External 세션을 열지 못했습니다.",
4682
+ hint: `${error instanceof Error ? error.message : String(error)}\n→ 세션 파일 경로의 디렉터리가 있는지와 쓰기 권한을 확인하세요.`
4683
+ });
4684
+ }
4685
+ let exitCode;
4686
+ try {
4687
+ exitCode = await runCliCore(argv, dependencies, wiring.env);
4688
+ } catch (error) {
4689
+ await wiring.finish("failed").catch(() => void 0);
4690
+ throw error;
4691
+ }
4692
+ try {
4693
+ const summary = await wiring.finish(exitCode === 0 ? "completed" : "failed");
4694
+ if (summary.mode === "replay") {
4695
+ const missBlock = renderReplayMissDiagnostics(summary.misses);
4696
+ if (missBlock !== "") dependencies.writeStderr(missBlock);
4697
+ }
4698
+ const notice = externalSessionNotice(summary);
4699
+ if (notice !== void 0) dependencies.writeStderr(notice);
4700
+ } catch (error) {
4701
+ return writeFailure(dependencies, {
4702
+ code: "EXTERNAL_SESSION_FAILED",
4703
+ message: "External 세션을 닫지 못했습니다.",
4704
+ hint: error instanceof Error ? error.message : String(error)
4705
+ });
4706
+ }
4707
+ return exitCode;
4708
+ }
4709
+ /**
4710
+ * argv 에서 External 모드를 정한다. **파싱은 `parseTestCommand` 하나만 한다.**
4711
+ *
4712
+ * 한때 여기서 argv 를 따로 훑었다. 그러면 토큰 소비 규칙이 두 벌이 되고, 두 벌은 갈라진다.
4713
+ * 실제로 갈렸다 — `--arg` 는 하이픈으로 시작하는 값을 의도적으로 받으므로
4714
+ *
4715
+ * mcpeak test s.json --command node --arg --session=/tmp/x
4716
+ *
4717
+ * 에서 `parseTestCommand` 는 그 토큰을 **서버 인자**로 소비하는데, 따로 훑는 쪽은 같은 것을
4718
+ * replay 지시로 읽었다. 사용자가 요청한 적 없는 세션 파일이 열리고 Bootstrap 이 주입되며,
4719
+ * Replay 라 서버의 외부 호출이 전부 실패한다. 원인을 짐작할 방법이 없는 실패다.
4720
+ *
4721
+ * 파싱 오류는 여기서 삼킨다. 배선은 파싱보다 먼저 서지만 **오류 보고는 기존 경로의 몫**이다.
4722
+ * 여기서 던지면 `--session` 이 붙었다는 이유만으로 오류 문구가 달라진다.
4723
+ */
4724
+ function externalModeOf(argv) {
4725
+ if (argv[0] !== "test") return void 0;
4726
+ let input;
4727
+ try {
4728
+ input = parseTestCommand(argv.slice(1));
4729
+ } catch {
4730
+ return;
4731
+ }
4732
+ if (input.recordSessionPath !== void 0) return {
4733
+ mode: "record",
4734
+ path: input.recordSessionPath
4735
+ };
4736
+ if (input.sessionPath !== void 0) return {
4737
+ mode: "replay",
4738
+ path: input.sessionPath
4739
+ };
4740
+ }
4492
4741
  //#endregion
4493
4742
  Object.defineProperty(exports, "GLOBAL_HELP", {
4494
4743
  enumerable: true,
@@ -6,7 +6,7 @@ import { REDACTED as REDACTED$1, cassetteClient, loadCassette, matchKey, saveCas
6
6
  import { spawn } from "node:child_process";
7
7
  import { CommandParseError, tokenizeCommand } from "@mcpeak/core";
8
8
  //#region package.json
9
- var version = "0.9.0";
9
+ var version = "0.10.0";
10
10
  //#endregion
11
11
  //#region src/cassette-wiring.ts
12
12
  /**
@@ -320,7 +320,7 @@ const FINDING_GROUP = {
320
320
  };
321
321
  //#endregion
322
322
  //#region src/help.ts
323
- const TEST_USAGE = "사용법: mcpeak test <suite.json> --command <executable> [--arg <value> ...] [--determinism] [--reset-cmd <command>] [--json] [--junit <path>] [--repair-bundle <path>] [--stderr-lines <N>]";
323
+ const TEST_USAGE = "사용법: mcpeak test <suite.json> --command <executable> [--arg <value> ...] [--determinism] [--reset-cmd <command>] [--json] [--junit <path>] [--repair-bundle <path>] [--stderr-lines <N>] [--session <path> | --record-session <path>]";
324
324
  /**
325
325
  * 시험 실행 옵션 설명. `--determinism` 은 툴을 2회 호출하므로 부작용이 있는 서버에서 모르고
326
326
  * 쓰면 사고가 난다. 그 경고가 사용법 한 줄에는 들어가지 않는다.
@@ -331,7 +331,31 @@ const TEST_OPTIONS = `옵션:
331
331
  --reset-cmd 와 함께 쓰면 결정론성 확인이 되고, 없으면
332
332
  "2회 결과가 같았다" 까지만 확인합니다
333
333
  --reset-cmd <command> 각 시험 실행 전에 이 명령을 한 번 실행합니다. 셸을 거치지
334
- 않으므로 파이프나 && 는 쓸 수 없습니다`;
334
+ 않으므로 파이프나 && 는 쓸 수 없습니다
335
+ --record-session <path>
336
+ 서버가 \`globalThis.fetch\` 로 밖에 부른 HTTP 호출만 녹화합니다.
337
+ 서버는 실제로 실행됩니다. \`node:http\`·\`node:https\` 같은 범위 밖 호출은
338
+ 잡히지 않아 실제 네트워크로 나갈 수 있습니다
339
+ --session <path> 녹화한 외부 호출을 재생합니다. 서버는 실제로 실행되지만 외부
340
+ API 호출은 \`globalThis.fetch\` 범위 안에서만 가로챕니다.
341
+ 범위 밖 호출은 실제 네트워크로 나갈 수 있으며, 범위 밖 호출이
342
+ 의심되는 경우 종료 때 알립니다
343
+
344
+ 두 세션 옵션은 서로, 그리고 \`--determinism\` 과 함께 쓸 수 없습니다. \`--determinism\` 은 서버에
345
+ 2회 연결하는데 세션은 연결 하나에 묶여 있어, 2회차가 같은 세션을 쓰면 반복 호출 순번이
346
+ 어긋나고 새 세션을 쓰면 비교 기준이 갈라집니다.
347
+
348
+ 세션은 \`--cassette\` 카세트와 다릅니다. **카세트는 우리가 서버에게 물어본 결과**를 남기고,
349
+ **세션은 그 서버가 밖에 물어본 결과**를 남깁니다. 둘은 섞이지 않으며 파일도 따로입니다.
350
+
351
+ 세션 파일에는 외부 API 응답이 저장되므로 .gitignore 를 확인하세요. \`token\`·\`apiKey\` 같은
352
+ 이름의 값은 저장 전에 가려집니다.
353
+
354
+ 런타임에 따라 세션 옵션을 쓴 실행에서 \`ExperimentalWarning: SQLite is an experimental
355
+ feature\` 가 stderr 에 한 줄 찍힙니다(실측: Node 22.18.0 에서 나오고 24.16.0 에서는 안 납니다).
356
+ \`node:sqlite\` 를 처음 로드할 때 한 번 나오므로 실행당 한 줄이며, 세션 옵션이 없는 실행에는
357
+ 붙지 않습니다. Node 가 그 모듈을 아직 실험적으로 표시한다는 뜻이고, **저장된 녹화는 표준
358
+ SQLite 파일이라 영향받지 않습니다.**`;
335
359
  const GENERATE_USAGE = "사용법: mcpeak generate --suite-id <id> --name <name> --out <suite.json> --command <executable> [--arg <value> ...] [--baseline-only] [--provider <codex|claude>] [--model <model>] [--no-dry-run] [--cassette <path>] [--record] [--reset-cmd <command>] [--no-repair] [--force]";
336
360
  /**
337
361
  * 시험 실행 옵션 설명. 사용법 한 줄로는 `--cassette` 가 무엇을 파일로 남기는지, `--reset-cmd`
@@ -403,7 +427,7 @@ auto 모드(\`--record\` 없는 실행)는 카세트에 있는 요청이면 서
403
427
  마스킹해 비교하므로, 비밀값 자체만 바뀐 경우는 감지되지 않습니다.
404
428
 
405
429
  불일치나 호출 실패가 하나라도 있으면 종료 코드 1 입니다. 확인불가는 실패로 보지 않습니다.`;
406
- const GLOBAL_HELP = `OhMyMCP — MCP 서버 테스트 프레임워크
430
+ const GLOBAL_HELP = `MCPeak — MCP 서버 테스트 프레임워크
407
431
 
408
432
  사용법: mcpeak <명령> [옵션]
409
433
  mcpeak help [명령]
@@ -833,6 +857,18 @@ function dropSkippedTools(suite, skips) {
833
857
  }
834
858
  //#endregion
835
859
  //#region src/process-diagnostics.ts
860
+ /**
861
+ * core 의 stdio 프로세스 진단인지 구조와 값의 범위로 확인한다.
862
+ * 일부 필드만 있거나 프로세스 상태로 표현할 수 없는 값은 렌더하지 않는다.
863
+ */
864
+ function processDiagnostics(value) {
865
+ if (typeof value !== "object" || value === null) return void 0;
866
+ if (!("stderr" in value) || typeof value.stderr !== "string") return void 0;
867
+ if (!("stderrTruncated" in value) || typeof value.stderrTruncated !== "boolean") return void 0;
868
+ if (!("exitCode" in value) || !(value.exitCode === null || typeof value.exitCode === "number" && Number.isInteger(value.exitCode))) return void 0;
869
+ if (!("signal" in value) || !(value.signal === null || typeof value.signal === "string" && value.signal.length > 0)) return void 0;
870
+ return value;
871
+ }
836
872
  /** TAB. 이 모듈만 이스케이프 대상에서 빼는 문자다. 아래 escapeTokens 주석에 근거가 있다. */
837
873
  const TAB = 9;
838
874
  /**
@@ -2793,8 +2829,14 @@ async function runGenerateCommand(argv, deps) {
2793
2829
  else if (error instanceof OutputReplaceError) outputReplaceFailure(deps, error.path, error.code);
2794
2830
  else if (error instanceof LinkUnsupportedError) linkUnsupportedFailure(deps, error.path, error.code);
2795
2831
  else if (deps.GenerateTestsError !== void 0 && error instanceof deps.GenerateTestsError) generateTestsFailure(deps, error);
2796
- else if (isCoreClientError(error)) deps.writeStderr(`오류 [GENERATE_CONNECT_FAILED/${error.code}]: ${error.message}\n해결: ${error.hint}\n`);
2797
- else deps.writeStderr("오류 [GENERATE_FAILED]: baseline suite를 생성하거나 저장하지 못했습니다.\n해결: MCP 서버와 출력 경로를 확인한 뒤 다시 실행하세요.\n");
2832
+ else if (isCoreClientError(error)) {
2833
+ deps.writeStderr(`오류 [GENERATE_CONNECT_FAILED/${error.code}]: ${error.message}\n해결: ${error.hint}\n`);
2834
+ const diagnostics = processDiagnostics(error.diagnostics);
2835
+ if (diagnostics !== void 0 && hasDiagnosticContent(diagnostics)) {
2836
+ const block = renderProcessDiagnostics(diagnostics, { maxLines: DRY_RUN_STDERR_LINES });
2837
+ if (block !== "") deps.writeStderr(`\n${block}`);
2838
+ }
2839
+ } else deps.writeStderr("오류 [GENERATE_FAILED]: baseline suite를 생성하거나 저장하지 못했습니다.\n해결: MCP 서버와 출력 경로를 확인한 뒤 다시 실행하세요.\n");
2798
2840
  return 1;
2799
2841
  }
2800
2842
  try {
@@ -3689,6 +3731,51 @@ function createDeterminismCapture(inner) {
3689
3731
  };
3690
3732
  }
3691
3733
  //#endregion
3734
+ //#region src/external-wiring.ts
3735
+ /**
3736
+ * `@mcpeak/record/external` 은 **세션 옵션을 실제로 쓸 때만** 불러온다.
3737
+ *
3738
+ * 정적 import 로 두면 `node:sqlite` 가 CLI 를 띄우는 것만으로 로드되고, Node 22.x 는 그때
3739
+ * `ExperimentalWarning: SQLite is an experimental feature` 를 stderr 에 찍는다. 세션을 쓰지도
3740
+ * 않은 `mcpeak test` 실행마다 그 줄이 붙는다 — 터미널 출력이 곧 UI 인 도구에서 그냥 손해다.
3741
+ * (실제로 `dist-cli-e2e` 의 "stderr 가 비어 있다" 단언이 이걸 잡았다.)
3742
+ *
3743
+ * 지연 로딩이면 경고를 보는 사람은 External 을 직접 켠 사용자뿐이다. 그 경고를 아예 지울지는
3744
+ * ADR-0054 가 남겨 둔 별도 결정이며, 라이브러리가 아니라 CLI 가 정할 몫이다.
3745
+ */
3746
+ const loadExternal = () => import("@mcpeak/record/external");
3747
+ /** 세션 파일 하나가 세션 하나이므로 식별자를 사용자에게 묻지 않는다. */
3748
+ const SESSION_ID = "default";
3749
+ async function startExternalWiring(options) {
3750
+ const { createSqliteSessionStore, startExternalCoordinator } = await loadExternal();
3751
+ const store = createSqliteSessionStore({ path: options.sessionPath });
3752
+ let handle;
3753
+ try {
3754
+ handle = options.mode === "record" ? await startExternalCoordinator({
3755
+ mode: "record",
3756
+ sessionId: SESSION_ID,
3757
+ store,
3758
+ ...options.existingNodeOptions === void 0 ? {} : { existingNodeOptions: options.existingNodeOptions }
3759
+ }) : await startExternalCoordinator({
3760
+ mode: "replay",
3761
+ sourceSessionId: SESSION_ID,
3762
+ store,
3763
+ ...options.existingNodeOptions === void 0 ? {} : { existingNodeOptions: options.existingNodeOptions }
3764
+ });
3765
+ } catch (error) {
3766
+ store.close();
3767
+ throw error;
3768
+ }
3769
+ let settled;
3770
+ return {
3771
+ env: handle.childEnvironment,
3772
+ finish(status) {
3773
+ settled ??= handle.finish(status).finally(() => store.close());
3774
+ return settled;
3775
+ }
3776
+ };
3777
+ }
3778
+ //#endregion
3692
3779
  //#region src/test-command.ts
3693
3780
  /** --stderr-lines 기본값. 설계 문서 §6. */
3694
3781
  const DEFAULT_STDERR_LINES = 20;
@@ -3737,6 +3824,10 @@ const dictionary = {
3737
3824
  message: "초기화 명령이 실패했습니다.",
3738
3825
  hint: "`--reset-cmd` 명령이 단독으로 성공하는지 확인한 뒤 다시 실행하세요."
3739
3826
  },
3827
+ EXTERNAL_SESSION_FAILED: {
3828
+ message: "External 세션 처리에 실패했습니다.",
3829
+ hint: "세션 파일 경로의 디렉터리가 있는지와 쓰기 권한을 확인하세요."
3830
+ },
3740
3831
  CLI_INTERNAL_ERROR: {
3741
3832
  message: "예상하지 못한 CLI 내부 오류가 발생했습니다.",
3742
3833
  hint: "다시 실행한 뒤 재현 정보와 함께 이슈를 보고하세요."
@@ -3756,6 +3847,30 @@ const fail = (message) => {
3756
3847
  hint: TEST_USAGE_HINT
3757
3848
  });
3758
3849
  };
3850
+ /**
3851
+ * `--name value` 와 `--name=value` 두 형태에서 값을 꺼낸다.
3852
+ *
3853
+ * 값이 `--` 로 시작하면 거절한다. `--session --json` 처럼 값을 빠뜨리면 다음 옵션이 경로로
3854
+ * 먹히고, 그러면 "그런 파일이 없습니다" 라는 엉뚱한 곳에서 실패한다.
3855
+ */
3856
+ const readOptionValue = (argv, token, name, index) => {
3857
+ let value;
3858
+ let next = index;
3859
+ if (token === name) {
3860
+ const candidate = argv[++next];
3861
+ if (candidate === void 0) throw new CliCommandError({
3862
+ code: "CLI_USAGE",
3863
+ message: `\`${name}\` 옵션 값이 필요합니다.`,
3864
+ hint: TEST_USAGE_HINT
3865
+ });
3866
+ value = candidate;
3867
+ } else value = token.slice(`${name}=`.length);
3868
+ if (value.trim() === "" || value.startsWith("--")) fail(`\`${name}\` 옵션 값이 필요합니다.`);
3869
+ return {
3870
+ value,
3871
+ index: next
3872
+ };
3873
+ };
3759
3874
  function parseTestCommand(argv) {
3760
3875
  const suitePath = argv[0] ?? "";
3761
3876
  if (suitePath === "") fail("테스트 명세 JSON 경로가 필요합니다.");
@@ -3766,6 +3881,8 @@ function parseTestCommand(argv) {
3766
3881
  let determinism = false;
3767
3882
  let resetCmd;
3768
3883
  let stderrLines;
3884
+ let sessionPath;
3885
+ let recordSessionPath;
3769
3886
  const args = [];
3770
3887
  for (let index = 1; index < argv.length; index += 1) {
3771
3888
  const token = argv[index] ?? "";
@@ -3859,6 +3976,16 @@ function parseTestCommand(argv) {
3859
3976
  const parsedLines = Number.parseInt(value, 10);
3860
3977
  if (!Number.isSafeInteger(parsedLines)) fail("`--stderr-lines` 값은 0 이상의 정수여야 합니다.");
3861
3978
  stderrLines = parsedLines;
3979
+ } else if (token === "--session" || token.startsWith("--session=")) {
3980
+ if (sessionPath !== void 0) fail("`--session`은 한 번만 사용할 수 있습니다.");
3981
+ const read = readOptionValue(argv, token, "--session", index);
3982
+ sessionPath = read.value;
3983
+ index = read.index;
3984
+ } else if (token === "--record-session" || token.startsWith("--record-session=")) {
3985
+ if (recordSessionPath !== void 0) fail("`--record-session`은 한 번만 사용할 수 있습니다.");
3986
+ const read = readOptionValue(argv, token, "--record-session", index);
3987
+ recordSessionPath = read.value;
3988
+ index = read.index;
3862
3989
  } else if (token === "--json") {
3863
3990
  if (json) fail("`--json`은 한 번만 사용할 수 있습니다.");
3864
3991
  json = true;
@@ -3871,6 +3998,8 @@ function parseTestCommand(argv) {
3871
3998
  message: "`--command` 옵션이 필요합니다.",
3872
3999
  hint: TEST_USAGE_HINT
3873
4000
  });
4001
+ if (sessionPath !== void 0 && recordSessionPath !== void 0) fail("`--session`과 `--record-session`은 함께 쓸 수 없습니다. 재생과 녹화 중 하나만 고르세요.");
4002
+ if (determinism && (sessionPath !== void 0 || recordSessionPath !== void 0)) fail("`--determinism`은 External 세션 옵션과 함께 쓸 수 없습니다.\n→ `--determinism`은 서버에 2회 연결하지만 External 세션은 연결 하나에 묶여 있습니다.\n→ 2회차가 같은 세션을 쓰면 반복 호출 순번이 어긋나고, 새 세션을 쓰면 비교 기준이 갈라집니다.");
3874
4003
  return Object.freeze({
3875
4004
  suitePath,
3876
4005
  command,
@@ -3880,6 +4009,8 @@ function parseTestCommand(argv) {
3880
4009
  repairBundlePath,
3881
4010
  determinism,
3882
4011
  resetCmd,
4012
+ sessionPath,
4013
+ recordSessionPath,
3883
4014
  stderrLines: stderrLines ?? DEFAULT_STDERR_LINES
3884
4015
  });
3885
4016
  }
@@ -3954,18 +4085,7 @@ function format(failure) {
3954
4085
  for (const issue of failure.issues ?? []) result += `\n- [${escapeTerminalText(issue.code)}] ${escapeTerminalText(issue.path)}: ${escapeTerminalText(issue.message)}\n 해결: ${escapeTerminalText(issue.hint)}`;
3955
4086
  return `${result}\n`;
3956
4087
  }
3957
- /**
3958
- * core 의 McpProcessDiagnostics 인지 구조로 확인한다. core 를 import 하지 않는다.
3959
- * 하나라도 어긋나면 undefined 다. 설계 문서 §4.3.1.
3960
- */
3961
- function processDiagnostics(value) {
3962
- if (typeof value !== "object" || value === null) return void 0;
3963
- if (!("stderr" in value) || typeof value.stderr !== "string") return void 0;
3964
- if (!("stderrTruncated" in value) || typeof value.stderrTruncated !== "boolean") return void 0;
3965
- if (!("exitCode" in value) || !(typeof value.exitCode === "number" || value.exitCode === null)) return void 0;
3966
- if (!("signal" in value) || !(typeof value.signal === "string" || value.signal === null)) return void 0;
3967
- return value;
3968
- }
4088
+ /** AggregateError 내부까지 내려가 core 오류와 검증된 프로세스 진단을 꺼낸다. */
3969
4089
  function coreError(error) {
3970
4090
  const seen = /* @__PURE__ */ new Set();
3971
4091
  const visit = (value) => {
@@ -3989,7 +4109,7 @@ function writeFailure(dependencies, failure) {
3989
4109
  dependencies.writeStderr(format(failure));
3990
4110
  return 1;
3991
4111
  }
3992
- async function runCli(argv, dependencies) {
4112
+ async function runCliCore(argv, dependencies, childEnv) {
3993
4113
  if (argv.length === 0) return writeFailure(dependencies, {
3994
4114
  code: "CLI_USAGE",
3995
4115
  message: "실행할 CLI 명령이 없습니다.",
@@ -4003,7 +4123,7 @@ async function runCli(argv, dependencies) {
4003
4123
  ].includes(argv[0] ?? "")) return writeFailure(dependencies, {
4004
4124
  code: "COMMAND_NOT_IMPLEMENTED",
4005
4125
  message: `'${argv[0]}' 명령은 아직 구현되지 않았습니다.`,
4006
- hint: "현재는 test 명령만 사용할 수 있습니다."
4126
+ hint: "사용 가능한 명령: test, generate, repair, replay, verify. 전체 도움말: mcpeak --help"
4007
4127
  });
4008
4128
  return writeFailure(dependencies, {
4009
4129
  code: "CLI_USAGE",
@@ -4088,7 +4208,8 @@ async function runCli(argv, dependencies) {
4088
4208
  try {
4089
4209
  connection = await dependencies.connect({
4090
4210
  command: input.command,
4091
- args: input.args
4211
+ args: input.args,
4212
+ ...childEnv === void 0 ? {} : { env: childEnv }
4092
4213
  });
4093
4214
  } catch (error) {
4094
4215
  const core = coreError(error);
@@ -4251,7 +4372,8 @@ async function runCli(argv, dependencies) {
4251
4372
  try {
4252
4373
  second = await dependencies.connect({
4253
4374
  command: input.command,
4254
- args: input.args
4375
+ args: input.args,
4376
+ ...childEnv === void 0 ? {} : { env: childEnv }
4255
4377
  });
4256
4378
  } catch (error) {
4257
4379
  const core = coreError(error);
@@ -4489,5 +4611,132 @@ async function runCli(argv, dependencies) {
4489
4611
  }
4490
4612
  return allPassed ? 0 : 1;
4491
4613
  }
4614
+ /**
4615
+ * 재생 원본에서 찾지 못한 호출들을 그린다. `misses` 가 비어 있으면 빈 문자열이다.
4616
+ *
4617
+ * **MCP 오류 채널을 타지 않는다(#259).** `record` 의 `REPLAY_MISS` 진단은 실패한 툴 호출의
4618
+ * MCP 오류 메시지로도 나가는데, 그 채널은 `runner` 가 "테스트 대상 서버가 보낸 텍스트"로
4619
+ * 취급해 이스케이프(개행 포함)와 200자 절단을 건다 — 우리 자신이 여러 줄로 공들여 쓴 진단이
4620
+ * 서버 텍스트와 똑같이 망가진다. 이 함수는 `record` 가 `finish()` 요약에 구조화해 담아 준
4621
+ * 값을 CLI 가 직접 stderr 로 쓴다. `runner` 도 그 이스케이프 규칙도 거치지 않는다.
4622
+ *
4623
+ * `method`·`url`·`matchKeyPrefix` 는 값 자체는 `record` 가 만들지만 원본은 테스트 대상
4624
+ * 서버가 시도한 요청이라, 혹시 모를 제어 문자에 대비해 필드 단위로 이스케이프한다(줄 구조를
4625
+ * 만드는 정적 문구는 이스케이프 대상이 아니다 — `process-diagnostics.ts` 와 같은 원칙).
4626
+ */
4627
+ function renderReplayMissDiagnostics(misses) {
4628
+ if (misses.length === 0) return "";
4629
+ const body = misses.map((miss) => ` ${escapeTerminalText(miss.method)} ${escapeTerminalText(miss.url)}\n occurrence ${miss.occurrence} · matchKey ${escapeTerminalText(miss.matchKeyPrefix)}…\n`).join("");
4630
+ return `\nExternal 진단: 재생 원본에서 찾지 못한 호출 ${misses.length}건\n` + body + "→ 이 호출이 녹화된 뒤에 추가되었거나, 요청이 녹화 때와 달라져 다른 matchKey가 되었습니다.\n→ 녹화를 다시 하거나, 요청이 실행마다 달라지는 값을 담고 있는지 확인하세요.\n";
4631
+ }
4632
+ /**
4633
+ * External 세션 요약을 사용자에게 보이는 한 문단으로 옮긴다. `undefined` 는 할 말이 없다는 뜻이다.
4634
+ *
4635
+ * **순서가 곧 계약이다**(ADR-0057). `consumedCount === 0` 과 `unusedCount > 0` 은 동시에 참일 수
4636
+ * 있어서, 조건을 각각 독립적으로 세우면 한 실행에 경고가 두 번 찍힌다. 아래 순서로 먼저 걸리는
4637
+ * 하나만 낸다.
4638
+ *
4639
+ * 갈래를 넷으로 나눈 이유는 **사용자가 다음에 볼 곳이 다르기 때문이다.** 원본이 비어 있는 것
4640
+ * (녹화 단계가 아무것도 못 잡았다)과 원본은 찼는데 하나도 안 쓴 것(세션 파일을 잘못 짚었거나
4641
+ * 서버의 호출 방식이 바뀌었다)은 같은 `consumedCount === 0` 이지만 원인이 정반대 방향에 있다.
4642
+ * 하나로 합치면 [#258](https://github.com/2026-Engineering-Contest/MCPeak/issues/258) 의 실제
4643
+ * 경로에서 "녹화된 호출이 재생되지 않았다" 고 말하게 되는데, 그 문장은 녹화가 있었다는 전제를
4644
+ * 깔아 사용자를 재생 쪽으로 보낸다. 정작 볼 곳은 그 앞 단계다.
4645
+ *
4646
+ * 판정만 하고 쓰기는 하지 않는다 — 순수 함수라 배타성 자체를 프로세스 없이 고정할 수 있다.
4647
+ */
4648
+ function externalSessionNotice(summary) {
4649
+ if (summary.mode === "record") {
4650
+ if (summary.interactionCount > 0) return void 0;
4651
+ return "\n알림: 이 실행에서 외부 호출이 하나도 녹화되지 않았습니다.\n→ 서버가 외부 API를 호출했다면 지원 범위를 벗어났는지 확인하세요.\n→ MCPeak은 서버가 `globalThis.fetch`로 부른 것만 잡습니다.\n";
4652
+ }
4653
+ if (summary.interactionCount === 0) return "\n알림: 이 세션에는 녹화된 외부 호출이 0건입니다. 재생할 것이 없었습니다.\n→ 녹화 실행이 외부 호출을 하나도 잡지 못했다는 뜻입니다. 이 세션은 아무 호출도 막지 못합니다.\n→ MCPeak은 서버가 `globalThis.fetch`로 부른 것만 잡습니다.\n";
4654
+ if (summary.consumedCount === 0) return "\n알림: 이 실행에서 녹화된 외부 호출이 하나도 재생되지 않았습니다.\n→ 지원 범위 밖의 호출은 실제 네트워크로 나갔을 수 있습니다.\n→ MCPeak은 서버가 `globalThis.fetch`로 부른 것만 잡습니다.\n";
4655
+ if (summary.unusedCount > 0) return `\n알림: 녹화된 외부 호출 ${summary.interactionCount}건 중 ${summary.unusedCount}건이 이번 실행에서 재생되지 않았습니다.\n→ 서버 코드나 실행 경로가 녹화 때와 달라졌을 수 있습니다.
4656
+ → MCPeak은 서버가 \`globalThis.fetch\`로 부른 것만 잡습니다.
4657
+ `;
4658
+ }
4659
+ /**
4660
+ * `test` 실행에 External Record/Replay 수명주기를 씌운다.
4661
+ *
4662
+ * Coordinator 를 **먼저 열고 마지막에 닫는다**(ADR-0052). 그 사이에 들어가는 것이 기존 실행
4663
+ * 전부라, 성공·실패·예외 어느 경로로 빠져나가도 `finish` 가 불려야 한다. 안 불리면 SQLite
4664
+ * 파일 핸들이 남고 Record 세션이 `running` 인 채로 남아 다음 실행이 이어 쓸 수 없다.
4665
+ *
4666
+ * 세션 옵션이 없으면 배선을 아예 만들지 않는다 — 기존 실행 경로가 한 글자도 달라지지 않는다.
4667
+ */
4668
+ async function runCli(argv, dependencies) {
4669
+ const mode = externalModeOf(argv);
4670
+ if (mode === void 0) return runCliCore(argv, dependencies);
4671
+ let wiring;
4672
+ try {
4673
+ wiring = await startExternalWiring({
4674
+ mode: mode.mode,
4675
+ sessionPath: mode.path,
4676
+ existingNodeOptions: process.env.NODE_OPTIONS
4677
+ });
4678
+ } catch (error) {
4679
+ return writeFailure(dependencies, {
4680
+ code: "EXTERNAL_SESSION_FAILED",
4681
+ message: "External 세션을 열지 못했습니다.",
4682
+ hint: `${error instanceof Error ? error.message : String(error)}\n→ 세션 파일 경로의 디렉터리가 있는지와 쓰기 권한을 확인하세요.`
4683
+ });
4684
+ }
4685
+ let exitCode;
4686
+ try {
4687
+ exitCode = await runCliCore(argv, dependencies, wiring.env);
4688
+ } catch (error) {
4689
+ await wiring.finish("failed").catch(() => void 0);
4690
+ throw error;
4691
+ }
4692
+ try {
4693
+ const summary = await wiring.finish(exitCode === 0 ? "completed" : "failed");
4694
+ if (summary.mode === "replay") {
4695
+ const missBlock = renderReplayMissDiagnostics(summary.misses);
4696
+ if (missBlock !== "") dependencies.writeStderr(missBlock);
4697
+ }
4698
+ const notice = externalSessionNotice(summary);
4699
+ if (notice !== void 0) dependencies.writeStderr(notice);
4700
+ } catch (error) {
4701
+ return writeFailure(dependencies, {
4702
+ code: "EXTERNAL_SESSION_FAILED",
4703
+ message: "External 세션을 닫지 못했습니다.",
4704
+ hint: error instanceof Error ? error.message : String(error)
4705
+ });
4706
+ }
4707
+ return exitCode;
4708
+ }
4709
+ /**
4710
+ * argv 에서 External 모드를 정한다. **파싱은 `parseTestCommand` 하나만 한다.**
4711
+ *
4712
+ * 한때 여기서 argv 를 따로 훑었다. 그러면 토큰 소비 규칙이 두 벌이 되고, 두 벌은 갈라진다.
4713
+ * 실제로 갈렸다 — `--arg` 는 하이픈으로 시작하는 값을 의도적으로 받으므로
4714
+ *
4715
+ * mcpeak test s.json --command node --arg --session=/tmp/x
4716
+ *
4717
+ * 에서 `parseTestCommand` 는 그 토큰을 **서버 인자**로 소비하는데, 따로 훑는 쪽은 같은 것을
4718
+ * replay 지시로 읽었다. 사용자가 요청한 적 없는 세션 파일이 열리고 Bootstrap 이 주입되며,
4719
+ * Replay 라 서버의 외부 호출이 전부 실패한다. 원인을 짐작할 방법이 없는 실패다.
4720
+ *
4721
+ * 파싱 오류는 여기서 삼킨다. 배선은 파싱보다 먼저 서지만 **오류 보고는 기존 경로의 몫**이다.
4722
+ * 여기서 던지면 `--session` 이 붙었다는 이유만으로 오류 문구가 달라진다.
4723
+ */
4724
+ function externalModeOf(argv) {
4725
+ if (argv[0] !== "test") return void 0;
4726
+ let input;
4727
+ try {
4728
+ input = parseTestCommand(argv.slice(1));
4729
+ } catch {
4730
+ return;
4731
+ }
4732
+ if (input.recordSessionPath !== void 0) return {
4733
+ mode: "record",
4734
+ path: input.recordSessionPath
4735
+ };
4736
+ if (input.sessionPath !== void 0) return {
4737
+ mode: "replay",
4738
+ path: input.sessionPath
4739
+ };
4740
+ }
4492
4741
  //#endregion
4493
4742
  export { runReplayCommand as a, nodeGenerateDependencies as c, GLOBAL_HELP as d, VERIFY_USAGE_HINT as f, parseReplayCommand as i, nodeReviewIO as l, version as m, runCli as n, parseRepairCommand as o, commandHelp as p, ReplayRuntimeUnavailableError as r, runRepairCommand as s, parseTestCommand as t, runGenerateCommand as u };
package/package.json CHANGED
@@ -1,17 +1,20 @@
1
1
  {
2
2
  "name": "@mcpeak/cli",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "private": false,
5
5
  "type": "module",
6
- "description": "OhMyMCP 프레임워크의 CLI 진입점 (얇게 유지)",
6
+ "description": "MCPeak 프레임워크의 CLI 진입점 (얇게 유지)",
7
7
  "license": "MIT",
8
+ "engines": {
9
+ "node": ">=22.18.0"
10
+ },
8
11
  "repository": {
9
12
  "type": "git",
10
- "url": "git+https://github.com/2026-Engineering-Contest/OhMyMCP.git",
13
+ "url": "git+https://github.com/2026-Engineering-Contest/MCPeak.git",
11
14
  "directory": "packages/cli"
12
15
  },
13
- "homepage": "https://github.com/2026-Engineering-Contest/OhMyMCP#readme",
14
- "bugs": "https://github.com/2026-Engineering-Contest/OhMyMCP/issues",
16
+ "homepage": "https://github.com/2026-Engineering-Contest/MCPeak#readme",
17
+ "bugs": "https://github.com/2026-Engineering-Contest/MCPeak/issues",
15
18
  "publishConfig": {
16
19
  "access": "public"
17
20
  },
@@ -47,11 +50,11 @@
47
50
  "dist"
48
51
  ],
49
52
  "dependencies": {
50
- "@mcpeak/core": "0.3.0",
51
- "@mcpeak/runner": "0.8.0",
52
- "@mcpeak/generate": "0.5.1",
53
- "@mcpeak/record": "0.2.0",
54
- "@mcpeak/mock": "0.3.0"
53
+ "@mcpeak/core": "0.4.0",
54
+ "@mcpeak/runner": "0.9.0",
55
+ "@mcpeak/generate": "0.6.0",
56
+ "@mcpeak/record": "0.3.0",
57
+ "@mcpeak/mock": "0.4.0"
55
58
  },
56
59
  "scripts": {
57
60
  "build": "tsdown --config-loader native",