@mcpeak/mock
v0.4.1
Published
목 MCP 서버 (Streamable HTTP · stdio) · 응답 주입
Readme
@mcpeak/mock
목 MCP 서버 · 응답 주입. Streamable HTTP 와 stdio 두 가지로 뜬다.
- 오너:
@storyrago(③ mock server 파트) - 의존:
@mcpeak/core·@modelcontextprotocol/sdk(catalog, 1.x 고정)
실제 MCP 서버 없이, MCP 를 사용하는 프로그램을 테스트하기 위한 것이다. 외부 API 키도 실제 데이터도 없이 원하는 상황을 그대로 세워둘 수 있다.
서버를 만들기 전에 설계를 먼저 검증하려면 → 설계 우선 워크플로
어느 쪽을 쓰나
| | 대상 | 응답 주입 |
|---|---|---|
| HTTP createMockServer | MCP 를 사용하는 외부 프로그램 | 띄운 뒤 on() 으로 |
| stdio mcpeak-mock | 우리 도구(mcpeak test) | 정의 파일에 미리 |
core.connect() 는 HTTP 도 안다 (ADR-0020). 갈리는 이유는 CLI 다 — mcpeak test 가
core.connectStdio 를 하드코딩하고 --url 이 없다 (packages/cli/src/index.ts:132·234).
자세한 배경은 ADR-0007.
목이 아니라 External 세션이 맞는 경우
둘 다 "진짜를 가짜로 바꾼다" 인데 바꾸는 층이 다르다. 헷갈리면 엉뚱한 것을 세우게 된다.
| | 무엇을 대신하나 | 언제 |
|---|---|---|
| mock | MCP 서버 자체 | 서버가 아직 없다 · 서버를 쓰는 쪽을 테스트한다 |
| External 세션mcpeak test --record-session | 서버가 부르는 외부 API | 서버는 진짜로 돌리고, 그 서버가 나가는 네트워크만 없애고 싶다 |
[우리 도구] ──(1)── [MCP 서버] ──(2)── [외부 API]
↑ ↑
mock 이 대신함 External 세션이 대신함목을 External 세션으로 녹화하면 녹화 건수가 0 이다. 목은 정의 파일에 적힌 응답만
돌려주고 밖으로 나가는 코드가 없기 때문이다(src/ 에 HTTP 클라이언트 호출 0건). 세션 파일은
생기지만 아무 호출도 막지 못하고, 도구도 그렇게 알려준다(ADR-0057).
서버 코드를 실제로 돌려야 하는데 그 서버가 유료 API 나 부작용 있는 endpoint 를 부르는 상황이 External 세션의 자리다.
세션은 @mcpeak/record 소관이다. mcpeak help test 와 그 패키지 README 를 본다.
HTTP — 외부 프로그램용
라이브러리로 부르는 경로라 프로젝트에 설치해야 합니다. 전역 설치(npm i -g)는 실행 파일만
놓으므로 import 가 ERR_MODULE_NOT_FOUND 로 깨집니다.
npm install --save-dev @mcpeak/mockimport { ANY, createMockServer } from "@mcpeak/mock";
const mock = await createMockServer({ tools }); // tools: ToolDef[]
mock.on("add", { a: 1, b: 2 }, { sum: 3 }); // 인자를 지정
mock.on("add", ANY, { sum: 0 }); // 나머지 전부
console.log(mock.url); // http://127.0.0.1:53211/mcp
// ...테스트 대상 프로그램을 이 주소에 연결한다...
await mock.close();stdio — 우리 도구용
정의 파일을 만들고,
{
"tools": [
{ "name": "add", "inputSchema": { "type": "object", "properties": { "a": { "type": "number" }, "b": { "type": "number" } } } }
],
"responses": [
{ "tool": "add", "args": { "a": 1, "b": 2 }, "result": { "sum": 3 } },
{ "tool": "add", "result": { "sum": 0 } }
]
}mcpeak test 의 대상으로 지정한다.
mcpeak test suite.json --command mcpeak-mock --arg definition.json프로세스가 곧 서버라 실행 중에는 응답을 주입할 수 없다. 그래서 정의 파일에 미리 적는다.
설계 우선 워크플로
MCP 서버를 만들기 전에 설계를 먼저 검증하는 것이 목의 주된 쓰임이다. 구현 0 줄에서 시작해 구현자에게 넘길 계약까지 만든다.
① 설계 ──→ ② 체험 ──→ ③ 명세 ──→ ( 구현 ) ──→ ④ 판정
정의 파일 실제 suite 생성 같은 suite 를
JSON 클라이언트에 (계약 초안) 실물 서버에
붙여본다① 설계 — 정의 파일을 쓴다
툴 스키마와 예상 응답을 적는다. 실패 응답도 같이 적는다 — 실패 UX 도 계약의 절반이다.
{
"tools": [
{ "name": "get_weather",
"inputSchema": {
"type": "object",
"properties": { "city": { "type": "string" } },
"required": ["city"]
} }
],
"responses": [
{ "tool": "get_weather", "args": { "city": "서울" },
"result": { "city": "서울", "temp": 21, "condition": "맑음" } },
{ "tool": "get_weather", "args": { "city": "없는도시" },
"result": "→ '없는도시' 는 모르는 도시입니다. 아는 도시: 서울, 부산, 제주",
"isError": true }
]
}② 체험 — 진짜 클라이언트에 붙인다
{
"mcpServers": {
"weather-design": {
"command": "mcpeak-mock",
"args": ["/절대/경로/weather.mock.json"]
}
}
}Claude Desktop 설정에 위처럼 넣는다. 경로는 절대경로여야 한다 — 클라이언트가 어느 디렉터리에서 띄울지 알 수 없다.
여기서 볼 것은 응답 내용이 아니다. 응답은 내가 적은 것이라 볼 게 없다. 봐야 하는 것은 클라이언트가 내 스키마를 어떻게 다루는가다.
- 이 툴을 언제 고르나 —
description이 부족하면 엉뚱할 때 고르거나 아예 안 고른다 - 인자를 어떻게 채우나 — 필드 이름만 보고 맞게 채우는지
- 거절을 만났을 때 뭐라고 하나 — 내가 쓴 오류 문장이 사용자에게 도움이 되는지
이 셋은 서버를 다 만든 뒤에 고치면 비싸다. 스키마와 문장을 바꾸는 일이라 구현 전이 가장 싸다.
③ 명세 — 목에서 계약 초안을 뽑는다
mcpeak generate --suite-id weather --name "날씨 서버 계약" \
--out contract.suite.json \
--command mcpeak-mock --arg weather.mock.json --baseline-onlybaseline suite를 저장했습니다: contract.suite.json
커버리지 1 tools, 3 axes 전부 검증이 suite 가 구현자에게 넘기는 계약 초안이다. 정상 케이스 하나와 위반 케이스들이 들어 있다.
⚠️ 정상 케이스가 실패하면
ANY폴백이 없는 것이다.generate는 정상 입력을 스키마에서 합성하므로{ "city": "example" }같은 값이 나온다. 내가"서울"만 적어뒀으면 그 호출은 표에 없다.✗ get-weather-success get_weather가 오류 없이 응답한다 → 툴 'get_weather' 을(를) 인자 {"city":"example"} 로 호출했지만 주입된 응답이 없습니다. → 이 툴에 주입된 인자: {"city":"서울"}, {"city":"없는도시"}
args를 생략한 줄(=ANY)을 하나 두면 된다. 위반 인자는ANY가 먹지 않으므로 (「인자 검사」 참조) 거절 케이스는 그대로 동작한다.
④ 판정 — 같은 suite 를 실물 서버에
구현이 끝나면 같은 파일을 진짜 서버에 돌린다. 통과하면 구현이 설계 계약을 지켰다는 증명이다.
mcpeak test contract.suite.json --command node --arg ./server.js목에서 초록인 것은 구현이 맞다는 뜻이 아니다. 같은 suite 를 예제 서버에 돌린 실제 결과:
| | 결과 |
|---|---|
| 목 | 3 passed |
| 실물 (examples/weather-server) | 2 passed, 1 failed |
실물은 서울·부산·제주만 아는데 합성 입력이 "example" 이라 정상 케이스가 깨졌다. 목은 ANY
폴백이 다 받아서 초록이었다. ③까지의 초록은 "목 기준 확인" 이고, 계약 판정은 ④에서 난다.
알아둘 것
거절 근거를 확인하지 못했습니다경고는 목에서 항상 뜬다.runner는 거절이 SDK 입력 검증에서 나온 것인지 오류 문장의 접두어로 판별하는데(MCP error -32602:등), 목의 거절문은 그 목록에 없다. 케이스는 통과하고 경고만 붙는다.- 완성된 MCP 를 실제로 쓰는 것은 목이 아니다. 목은 구현 전 설계 검증과, 실물을 붙일 수
없는 환경(외부 API 키 없음, 응답이 매번 다름)의 대역이다. 내 서버를 목으로 테스트하는 것은
내가 적은 답이 나오는지 보는 것이라 순환이다 — 그건
@mcpeak/record가 한다.
응답 매칭 규칙
두 진입점이 같은 규칙을 쓴다.
주입 시점에도 같다 — tools 에 없는 툴 이름은 정의 파일이든 mock.on() 이든 그 자리에서
거절한다. 오타가 주입에 성공한 것처럼 보이면, 실제 호출이 미스로 떨어질 때까지 아무 신호도
받지 못한다.
- 인자를 지정한 응답이 우선한다. 스키마 검사보다도 앞이다.
- 없으면
inputSchema로 인자를 검사한다. 어기면isError: true로 거절한다. - 통과하면
ANY(정의 파일에서는args생략)가 받는다. - 그것도 없으면
isError: true와 함께 무엇이 등록돼 있는지 알려준다.
ANY 는 편하지만 스키마가 허용하는 범위에서는 어떤 인자로 불러도 통과하게 만든다.
기본은 인자 지정이고 ANY 는 예외로 쓴다.
result 는 MCP 와이어 포맷이 아니라 알맹이다. content: [{ type: "text", ... }] 포장은 목이 한다.
인자 검사
tools/list 로 광고한 inputSchema 를 실제 호출에 대조한다. 네 축을 최상위 필드에서만 본다
(ADR-0048).
| 축 | 스키마의 어디 | 위반 예 |
|---|---|---|
| required | "required": ["city"] | city 를 안 보냄 |
| type | { "type": "string" } | { "city": 0 } |
| enum | { "enum": ["c", "f"] } | { "unit": "k" } |
| range | minimum · maximum · exclusiveMinimum · exclusiveMaximum · minLength · maxLength · minItems · maxItems | { "days": 99 } |
→ 툴 'get_weather' 의 'city' 은(는) string 이어야 합니다. 받은 값: 0 (number)
→ 이 툴이 tools/list 로 선언한 inputSchema 가 그렇게 요구합니다.
→ 거절이 의도한 것이면 responses 에 이 인자를 넣어 응답을 지정하세요.의도한 거절을 설계에 넣으려면 그 인자를 지정해 주입한다. 1번이 2번보다 앞이라 이 응답이 이긴다.
{ "tool": "get_weather", "args": { "city": 0 }, "result": "도시 이름이 잘못됐습니다" }검사하지 않는 것: 중첩 객체와 배열 원소 내부, additionalProperties. 조합자
(anyOf · oneOf · allOf · not · $ref · if)나 배열 type 이 있으면 — 루트에 있으면 그 툴
전체를, 필드에 있으면 그 필드만 — 건너뛴다. 툴 전체를 건너뛴 경우는 서버를 띄울 때 stderr 로
한 번 고지한다.
→ 다음 툴은 inputSchema 를 해석할 수 없어 인자 검사를 건너뜁니다:
'search' — 해석할 수 없는 키워드: anyOf거절 응답 주입
실패도 계약의 절반이다. isError: true 를 붙이면 내가 정한 문장으로 거절할 수 있다.
{ "tool": "get_meeting", "args": { "id": "m-99" },
"result": { "error": "→ 'm-99' 회의록이 없습니다" }, "isError": true }코드에서는 네 번째 인자로 넘긴다.
mock.on("get_meeting", { id: "m-99" }, { error: "→ 'm-99' 회의록이 없습니다" }, { isError: true });생략하면 성공이다. isError: false 를 명시해도 같다 — 참일 때만 와이어에 싣는다.
매칭 미스의 isError 와는 다른 것이다.
| | 언제 | 본문 |
|---|---|---|
| 주입한 거절 | isError: true 로 선언한 인자 | 내가 쓴 result |
| 스키마 위반 | 표에 없고 inputSchema 를 어긴 인자 | 목이 만든 위반 진단문 (… 이어야 합니다) |
| 매칭 미스 | 표에 없고 스키마는 지킨 인자 | 목이 만든 안내문 (주입된 응답이 없습니다) |
셋 다 isError: true 지만 본문으로 구분된다. 뒤의 둘은 "목이 판단한 것" 이고, 첫 번째만
"서버가 이렇게 거절한다" 는 설계다. 위반 진단문 대신 내 문장을 내보내고 싶으면 그 인자를
표에 적으면 된다 — 매칭 규칙 1번이 2번보다 앞이다.
키로 만들 수 없는 인자
아래는 주입 시점에 거부됩니다. MCP 호출은 JSON 으로 오므로 어떤 호출로도 도달할 수 없는 값이고, 그대로 두면 주입은 성공한 것처럼 보이는데 영영 안 맞거나 다른 주입과 같은 키가 됩니다.
| 값 | 예 |
|---|---|
| 순환 참조 | o.self = o |
| 희소 배열 | [1, , 3] |
| NaN · Infinity | { n: NaN } |
| JSON 이 아닌 값 | Date · 함수 · 심볼 · BigInt · Map |
undefined 는 거부하지 않습니다 — 객체 프로퍼티면 빼고, 배열 원소면 null 로 둡니다.
중첩 깊이 상한은 512 입니다. 호출 인자가 이를 넘으면 서버를 죽이지 않고 isError: true
응답으로 알려줍니다.
거부 집합은 record 의 카세트 매칭 키(ADR-0003)와 같습니다. 다만 record 는 키를 SHA-256 으로
해시하고 목은 하지 않습니다 — 목의 키는 파일에 남지 않고 실패 메시지에 그대로 찍히기 때문입니다.
배경은 ADR-0029.
설계 메모
- HTTP 는 stateless 로 띄운다.
sessionIdGenerator: undefined. stateful 로 가면 SDK 가randomUUID()로 세션 ID 를 만들어 결정론성이 깨진다. 대신 stateless 는 요청마다Server/transport 를 새로 만들어야 한다 (SDK 제약). - 포트 기본값은 0 이다. 빈 포트를 자동으로 받는다. 고정 포트는 이미 물려 있을 때 실패하고 병렬 실행 시 충돌한다.
- 매칭 키는 객체 키 순서에 영향받지 않는다.
{a:1,b:2}와{b:2,a:1}이 같은 응답을 찾는다.JSON.stringify를 그대로 쓰면 삽입 순서를 타서 결정론성이 깨진다. ToolDef.inputSchema는 JSON Schema 그대로 나간다. 저수준Server를 쓰기 때문에 Zod 변환이 필요 없고, 따라서 zod 의존성도 없다.src/stdio.ts는 top-level await 를 쓰지 않는다. 빌드가 cjs 도 함께 내는데 그쪽에서 지원되지 않는다.packages/cli/src/cli.ts도 같은 이유로 같은 형태다.
실패했을 때
실패 메시지가 곧 제품이다 (CLAUDE.md).
주입되지 않은 호출
→ 툴 'get_weather' 을(를) 인자 {"city":"제주"} 로 호출했지만 주입된 응답이 없습니다.
→ 이 툴에 주입된 인자: {"city":"서울"}
→ mock.on(툴이름, 인자, 응답) 의 인자가 호출과 일치하는지 확인하세요.
→ 인자를 가리지 않으려면 mock.on(툴이름, ANY, 응답) — 정의 파일에서는 args 생략.정의 파일이 잘못됐을 때
→ weather.mock.json 가 올바르지 않습니다: responses[0] 의 툴 '없는툴' 이 tools 에 없습니다. 있는 툴: get_weather, add
→ 형식: { "tools": [ { "name": ..., "inputSchema": ... } ], "responses": [ { "tool": ..., "result": ... } ] }assertMockDefinition(value, source?) 을 직접 불러 검증할 수도 있다.
미결
- 목 응답은 사람이 지정한 결정론적 값을 쓴다 (ADR-0005 에서 확정). 스키마 기반 랜덤 생성은 폐기됐고, 고정 시드 생성은 보류 상태다 — 툴이 많은 서버의 스모크 테스트 수요가 실제로 확인되면 그때 별도로 결정한다.
- CLI 의 HTTP 연결 —
core.connect()쪽은 됐다 (ADR-0020, #16).mcpeak test에--url이 생기면 우리 러너가 HTTP 목에도 붙는다. 지금은 CLI 가 stdio 로 고정돼 있다
