시리즈 6편, 마지막 편. 다섯 번째로 만든
swagger-mcp를 파헤친다. 이 편은 사실 절반만 swagger-mcp 이야기다. 나머지 절반은 다섯 개 MCP를 다 만들고 나서 정리된 생각들 — 재사용은 추상화가 아니라 패턴 복제라는 결론, 그리고 다섯 번 만들어서 비로소 보이는 것들.
postman-mcp를 만들고 나서 보니까, 같은 구조를 조금만 바꾸면 OpenAPI로도 되겠다 싶었다. 회사에서 Postman 컬렉션이 없는 외부 API를 쓸 때도 많았다 — 대부분 OpenAPI(혹은 Swagger) 스펙은 공개돼 있으니, 그걸 Claude에게 먹일 수 있으면 "이 API에 어떤 엔드포인트 있어?" 같은 질문이 바로 해결된다.
postman-mcp를 만들 때 이걸 의식하고 분리 설계를 한 건 아니었다. 그냥 parser와 api-client를 분리했을 뿐인데, swagger-mcp를 만들기 시작하니까 api-client.ts를 거의 그대로 복사해도 되는 상황이 됐다. 분리의 가치는 다음 프로젝트에서 회수된다 — 5편 마지막에 썼던 이 말이 여기서 증명됐다.
5개째라 만드는 건 하루 걸렸다. 새로 쓴 건 openapi-parser.ts 하나, 나머지는 조정.
swagger_load_spec 스펙 로드 (file / URL)
swagger_set_config 런타임 설정 변경
swagger_list_endpoints 엔드포인트를 태그별 그룹핑
swagger_get_endpoint 엔드포인트 상세 (파라미터/바디/응답 스키마)
swagger_list_schemas 정의된 모델 목록
swagger_get_schema 스키마 필드 구조
swagger_call_api 실제 호출 — 주력
swagger_status 로드 상태
postman-mcp의 7개에서 스키마 탐색 2개(list_schemas, get_schema)가 추가된 게 가장 큰 차이. OpenAPI는 components.schemas에 데이터 모델을 정의해두는데, 이걸 따로 조회할 수 있으면 "User 스키마 필드 뭐였지?" 같은 질문이 바로 해결된다. Postman 컬렉션에는 이런 스키마 정의가 없어서 이 두 도구는 자연스럽게 OpenAPI 전용이 됐다.
두 MCP 파일 트리를 나란히 놓으면:
postman-mcp/ swagger-mcp/
├── src/ ├── src/
│ ├── index.ts │ ├── index.ts
│ ├── cli.ts │ ├── cli.ts
│ ├── postman-parser.ts │ ├── openapi-parser.ts ← 이것만 새로 씀
│ └── api-client.ts │ └── api-client.ts ← 거의 복붙
api-client.ts가 얼마나 똑같냐면, diff를 떠보니까 20줄 정도 바뀌었다. 주로 변수 치환 방식의 차이 때문이다. index.ts도 도구 스키마 정의와 디스패치 switch문만 조정됐다.
재사용은 이 두 파일을 실제로 복사해서 붙여넣고 필요한 만큼만 고치는 방식으로 했다. @my/api-client-base 같은 공용 패키지를 만들지 않았다. 4편 confluence-mcp에서 추상화를 거부했던 이유와 같은 이유 — 두 번 복붙은 추상화를 정당화하지 않는다. 그리고 결과적으로 이게 옳았다. postman과 swagger의 api-client.ts가 20줄 차이로 갈라졌는데, 이 20줄이 공용 패키지 안에 있었다면 if (kind === 'openapi') ... else ... 같은 분기가 들어와서 원본이 더러워졌을 거다.
재사용은 추상화가 아니라 패턴 복제다. 이게 내가 다섯 개를 만들면서 얻은 가장 확실한 결론 중 하나다.
"패턴 복제"의 의미는 — 같은 구조를 다른 파일에서 다시 쓴다. 코드는 복사되지만, 머릿속의 템플릿은 공유된다. 파일 수, 책임 분할, 인증 방식, CLI 3단, 환경변수 폴백… 이 수준의 일관성은 package.json 의존성으로 묶는 게 아니라, 개발자의 판단으로 유지되는 일관성이다.
postman-mcp와 swagger-mcp의 가장 큰 코드 차이는 변수 치환 방식이다:
| postman-mcp | swagger-mcp | |
|---|---|---|
| 경로 변수 | {{user_id}} 또는 :id | {user_id} (OpenAPI 표준) |
| 환경 변수 | {{base_url}} (중괄호 두 번) | 없음 |
| 기본 baseUrl | 컬렉션 변수 | servers[0].url |
OpenAPI는 path parameter만 변수 개념을 가진다. Postman처럼 {{base_url}} 같은 환경 변수를 스펙 안에서 치환할 장치는 없다. 대신 servers 배열이 따로 있고, 거기서 base URL을 가져간다.
// swagger-mcp의 baseUrl 결정 로직
const loadSpec = async (source, token?) => {
if (isUrl(source)) {
// ... 스펙 다운로드 ...
const serverUrl = parser.getServerUrl(); // servers[0].url
const autoBaseUrl = serverUrl ?? extractBaseUrl(source); // 폴백: 스펙 URL의 호스트
client.updateConfig({ baseUrl: autoBaseUrl });
if (token) client.updateConfig({ token });
}
return parser.loadFromFile(source);
};
자동 baseUrl 결정이 postman-mcp보다 한 단계 더 있다. OpenAPI 3.x의 servers[0].url을 우선 쓰고, 없으면 스펙을 다운로드한 URL 자체의 protocol://host를 쓴다. 이렇게 하면 사용자가 스펙 URL만 던져도 base URL이 자동 설정된다 — 스펙이 https://api.example.com/docs/openapi.json에 있으면 https://api.example.com이 base가 된다.
이 작은 자동화가 사용성을 결정한다. 4편에서 스페이스 키 자동 해석을 칭찬했던 것과 같은 맥락 — 사용자가 알아낼 필요가 없는 정보는 내가 대신 알아낸다. 이게 반복해서 돌아오는 원칙이다.
OpenAPI의 고유한 짜증: 스키마 안에서 다른 스키마를 참조하는 $ref.
{
"User": {
"type": "object",
"properties": {
"id": { "type": "integer" },
"address": { "$ref": "#/components/schemas/Address" }
}
},
"Address": {
"type": "object",
"properties": {
"street": { "type": "string" },
"city": { "type": "string" }
}
}
}
swagger_get_schema("User")를 호출하면 address 필드가 "$ref: #/components/schemas/Address"라고만 나오면 안 된다. 실제로 Address의 구조까지 풀어서 보여줘야 의미 있다.
// 간략화된 $ref 해석
const resolveRef = (schema: unknown, components: Record<string, unknown>): unknown => {
if (typeof schema !== 'object' || schema === null) return schema;
const s = schema as Record<string, unknown>;
if (typeof s.$ref === 'string') {
const path = s.$ref.replace('#/components/schemas/', '');
return resolveRef(components[path], components); // 재귀
}
if (s.properties) {
const resolved = { ...s };
resolved.properties = Object.fromEntries(
Object.entries(s.properties as Record<string, unknown>)
.map(([k, v]) => [k, resolveRef(v, components)])
);
return resolved;
}
return s;
};
재귀적으로 풀어낸다. 단, 순환 참조가 있을 수 있다 — User가 Post를 참조하고 Post가 User를 다시 참조하는 식. 이걸 그대로 재귀하면 무한 루프. 처음엔 순환 방어 없이 짜다가 한 API에서 실제로 무한 루프를 맞아봤다. 방어 로직을 추가한 버전이 지금 돌아가는 것 — visited 세트를 들고 다니면서 이미 해석한 경로는 단축 표기로 남긴다.
이건 3편 Jira의 ADF와 비슷하다 — API가 트리 구조로 데이터를 주고받는데, Claude에게는 평탄화된 형태로 주는 게 낫다. 형식은 다르지만 해결 패턴은 같다.
OpenAPI 3.x가 타겟이지만, 아직 Swagger 2.0 스펙도 실무에서 마주친다. 특히 오래된 내부 시스템들. 3.x와 2.0은 스펙 구조가 꽤 다르다:
servers[].url, components.schemas, requestBodyhost + basePath + schemes, definitions, parameters 안에 body 포함둘 다 지원할지 — 고민했다. 결론은 2.0은 최소한만. 엔드포인트 리스팅과 파라미터 파악 정도만 되고, 스키마 탐색은 3.x만 본격 지원한다.
왜 이렇게 선을 그었나. 모든 버전을 완전히 지원하려는 시도는 파서를 두 배로 키우고, 테스트 케이스를 두 배로 늘린다. 실무에서 마주치는 2.0 스펙은 대부분 "엔드포인트 리스트 보고, 경로와 파라미터 확인하고, 호출해본다" 수준까지만 필요했다. 고급 기능(discriminator, oneOf, polymorphism 같은 것)은 3.x에서도 애매하게 동작하는데 2.0까지 완전 지원하는 건 비용 대비 효용이 너무 낮았다.
범위 결정은 중요한 설계 결정이다. "다 지원한다"는 야심이 자주 프로젝트를 침몰시킨다. 내가 쓸 범위를 명확히 하고, 그 바깥은 의도적으로 가볍게 두는 게 유지 가능한 소프트웨어의 조건이다.
swagger-mcp setup # 스펙 소스(URL/파일) / 토큰 / 헤더
swagger-mcp register
swagger-mcp status
claude mcp add \
--transport stdio \
--scope user \
swagger -- \
node /Users/<user>/.claude/mcp-servers/swagger-mcp/dist/index.js
mcp__swagger__list_endpoints, mcp__swagger__call_api, mcp__swagger__get_schema 등으로 노출.
특히 get_schema가 Claude와 궁합이 좋다. "User 생성 API 호출하려는데 body 뭐가 필요해?" 같은 질문에 Claude가 알아서 get_schema를 부르고 필요한 필드를 다 채워서 call_api를 부른다. 8개 도구의 조합이 "API 탐색 → 스키마 확인 → 호출" 흐름을 자연스럽게 그린다.
여기까지가 swagger-mcp 자체에 대한 이야기. 이제 시리즈 전체의 마무리.
5개 MCP의 파일 구조를 겹쳐보면 거의 같은 형태로 수렴했다:
├── index.ts # MCP 서버 + 도구 디스패치
├── cli.ts # setup / register / status
├── <domain>-client.ts # HTTP 레이어
└── (선택) formatter/parser
CLI 3단, 환경변수 → config.json → 죽기의 폴백 우선순위, 의존성 하나(@modelcontextprotocol/sdk), fetch 네이티브, tsc 단독 빌드. 이 골격이 다섯 번 반복되는 동안 한 번도 깨지지 않았다.
이게 우연이 아니다. MCP가 stdio 기반 장기 프로세스라는 사실, 도구가 문자열을 반환한다는 프로토콜, Claude Code의 claude mcp add 워크플로우 — 이 셋의 제약이 자연스럽게 이 골격으로 수렴시킨다. 새 MCP를 만들 때 골격 고민은 안 해도 된다. 도메인 고민에만 집중하면 된다.
파일을 쪼갤지 말지는 반복해서 돌아온 질문이었다. 5개 만들면서 정리된 기준 셋:
coderabbit.ts 분리 근거)api-client.ts 분리 근거, swagger-mcp에서 회수됨)셋 중 하나라도 "예"면 분리, 셋 다 "아니오"면 통합. jira-mcp/confluence-mcp는 셋 다 "아니오"여서 3-파일, postman/swagger-mcp는 2번과 3번이 "예"여서 4-파일, bitbucket-mcp는 1번이 "예"여서 4-파일.
5편에서 다룬 주제다. 단순함이 기본값이지만, 신성한 가치는 아니다:
반대로, 이유가 없는 복잡도는 끝까지 거부해야 한다. 라이브러리 추가, 베이스 패키지 추출, 추상화 레이어… 각각에 "왜"가 있어야 한다. 없으면 복붙과 자제가 낫다.
이게 이 시리즈의 핵심 주장 중 하나다. 다시 정리하면:
@my/mcp-base 같은 패키지는 없다)코드는 복사된다. 복사된 코드가 서서히 갈라진다. 갈라져도 문제없다 — 각자의 맥락에 맞게 진화한다. 베이스 패키지로 묶었으면 이 자연스러운 갈라짐이 깨졌을 거다.
이 아이디어는 다른 곳에서도 자주 보인다 — Rails의 "Convention over Configuration", 혹은 cookiecutter 같은 프로젝트 템플릿 도구들. 프레임워크보다 컨벤션이 더 강력할 때가 있다. 다섯 개 MCP가 그 예시였다.
3편에서 짧게 언급했지만 이게 제일 큰 깨달음일지도 모른다. get_weekly_summary, get_coderabbit_review의 마크다운 포맷, get_schema로 Claude가 자동으로 부르는 스키마 탐색… 이 도구들은 LLM이 없었으면 만들지 않았을 도구다.
기존 API 래핑은 Postman이나 공식 SDK로도 할 수 있다. LLM의 등장이 바꾼 건 — 사용자가 자연어로 의도를 말하고, Claude가 도구들을 조합해서 실현하는 패러다임이다. 이 흐름 속에서 좋은 MCP 도구는:
get_my_tickets, get_weekly_summary)이 네 원칙이 다섯 편을 관통하는 뼈대였다.
첫 MCP를 만들 때는 "내 PR 리뷰 좀 편하게 보자"였다. 다섯째를 만들 때는 "이 패턴이 어디까지 갈까"였다.
돌아보면 다섯 개는 각자 하나의 질문에 답하는 챕터였다:
MCP 자체는 1편에서 말했듯 단순하다. 50줄 스켈레톤이면 된다. 진짜 일은 도메인과의 대화 — Atlassian의 인증 이상함, ADF 왕복, CQL 패스스루, 변수 치환 3단, 스키마 $ref 해석. 이 다섯 편은 결국 각 도메인에서 내가 어떻게 깨졌고 무엇을 배웠는지에 대한 기록이다.
MCP 한두 개를 만들어 볼 생각이라면, 내가 드리고 싶은 조언은 단 하나다 — 작게 시작하세요. 도구 3-4개, API 하나. bitbucket-mcp 같은 크기. 그 작은 캔버스에서 패턴을 발견하고, 다음 MCP로 가져가세요. 다섯 번째쯤 되면 본인만의 템플릿이 머릿속에 자리 잡고, 새 MCP 만드는 데 하루면 됩니다.
그게 이 시리즈의 결론입니다. 읽어주셔서 감사합니다.
시리즈 완결. 다섯 개 MCP의 소스 코드는 [bitbucket-mcp / jira-mcp / confluence-mcp / postman-mcp / swagger-mcp]에서 볼 수 있습니다.