MCP제작기 마지막

한상우·2026년 4월 30일

AI

목록 보기
11/11
post-thumbnail

MCP를 다섯 번 만들고 나서 (6) — swagger-mcp, 그리고 시리즈를 끝내며

시리즈 6편, 마지막 편. 다섯 번째로 만든 swagger-mcp를 파헤친다. 이 편은 사실 절반만 swagger-mcp 이야기다. 나머지 절반은 다섯 개 MCP를 다 만들고 나서 정리된 생각들 — 재사용은 추상화가 아니라 패턴 복제라는 결론, 그리고 다섯 번 만들어서 비로소 보이는 것들.

왜 다섯 번째로 만들었나

postman-mcp를 만들고 나서 보니까, 같은 구조를 조금만 바꾸면 OpenAPI로도 되겠다 싶었다. 회사에서 Postman 컬렉션이 없는 외부 API를 쓸 때도 많았다 — 대부분 OpenAPI(혹은 Swagger) 스펙은 공개돼 있으니, 그걸 Claude에게 먹일 수 있으면 "이 API에 어떤 엔드포인트 있어?" 같은 질문이 바로 해결된다.

postman-mcp를 만들 때 이걸 의식하고 분리 설계를 한 건 아니었다. 그냥 parserapi-client를 분리했을 뿐인데, swagger-mcp를 만들기 시작하니까 api-client.ts를 거의 그대로 복사해도 되는 상황이 됐다. 분리의 가치는 다음 프로젝트에서 회수된다 — 5편 마지막에 썼던 이 말이 여기서 증명됐다.

5개째라 만드는 건 하루 걸렸다. 새로 쓴 건 openapi-parser.ts 하나, 나머지는 조정.

도구 8개

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 전용이 됐다.

첫 번째 이야기: 70%를 복제한다는 것

두 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-mcpswagger-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편에서 스페이스 키 자동 해석을 칭찬했던 것과 같은 맥락 — 사용자가 알아낼 필요가 없는 정보는 내가 대신 알아낸다. 이게 반복해서 돌아오는 원칙이다.

세 번째 이야기: $ref 해석

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에게는 평탄화된 형태로 주는 게 낫다. 형식은 다르지만 해결 패턴은 같다.

네 번째 이야기: Swagger 2.0을 적당히 지원

OpenAPI 3.x가 타겟이지만, 아직 Swagger 2.0 스펙도 실무에서 마주친다. 특히 오래된 내부 시스템들. 3.x와 2.0은 스펙 구조가 꽤 다르다:

  • 3.x: servers[].url, components.schemas, requestBody
  • 2.0: host + basePath + schemes, definitions, parameters 안에 body 포함

둘 다 지원할지 — 고민했다. 결론은 2.0은 최소한만. 엔드포인트 리스팅과 파라미터 파악 정도만 되고, 스키마 탐색은 3.x만 본격 지원한다.

왜 이렇게 선을 그었나. 모든 버전을 완전히 지원하려는 시도는 파서를 두 배로 키우고, 테스트 케이스를 두 배로 늘린다. 실무에서 마주치는 2.0 스펙은 대부분 "엔드포인트 리스트 보고, 경로와 파라미터 확인하고, 호출해본다" 수준까지만 필요했다. 고급 기능(discriminator, oneOf, polymorphism 같은 것)은 3.x에서도 애매하게 동작하는데 2.0까지 완전 지원하는 건 비용 대비 효용이 너무 낮았다.

범위 결정은 중요한 설계 결정이다. "다 지원한다"는 야심이 자주 프로젝트를 침몰시킨다. 내가 쓸 범위를 명확히 하고, 그 바깥은 의도적으로 가볍게 두는 게 유지 가능한 소프트웨어의 조건이다.

CLI와 등록 — postman-mcp와 동일

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 자체에 대한 이야기. 이제 시리즈 전체의 마무리.

다섯 번 만들어서 비로소 보인 것들

1. 골격은 수렴한다

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를 만들 때 골격 고민은 안 해도 된다. 도메인 고민에만 집중하면 된다.

2. 분리 기준 세 가지

파일을 쪼갤지 말지는 반복해서 돌아온 질문이었다. 5개 만들면서 정리된 기준 셋:

  1. 이 로직을 단독으로 테스트하고 싶은가? (bitbucket-mcp의 coderabbit.ts 분리 근거)
  2. 이 로직이 다른 프로젝트에서 재사용될 가능성이 있는가? (postman-mcp의 api-client.ts 분리 근거, swagger-mcp에서 회수됨)
  3. 이 로직이 의존하는 도메인이 다른가? (postman-mcp의 parser vs client, OpenAPI vs HTTP)

셋 중 하나라도 "예"면 분리, 셋 다 "아니오"면 통합. jira-mcp/confluence-mcp는 셋 다 "아니오"여서 3-파일, postman/swagger-mcp는 2번과 3번이 "예"여서 4-파일, bitbucket-mcp는 1번이 "예"여서 4-파일.

3. 복잡도는 이유가 있을 때만 받아들인다

5편에서 다룬 주제다. 단순함이 기본값이지만, 신성한 가치는 아니다:

  • stateful로 가라 — 호출 간 공유할 가치가 있는 비싼 자원이 있을 때만
  • 도구 수를 늘려라 — 조합이 자연스러워질 때만
  • 여러 API 버전을 지원하라 — 실무에서 마주치는 범위까지만

반대로, 이유가 없는 복잡도는 끝까지 거부해야 한다. 라이브러리 추가, 베이스 패키지 추출, 추상화 레이어… 각각에 "왜"가 있어야 한다. 없으면 복붙과 자제가 낫다.

4. 재사용은 추상화가 아니라 패턴 복제다

이게 이 시리즈의 핵심 주장 중 하나다. 다시 정리하면:

  • 다섯 개 MCP가 공유하는 것: 머릿속의 템플릿 (파일 구조, CLI 3단, 환경변수 폴백, 인증 코드 세 줄)
  • 공유하지 않는 것: 런타임 의존성 (@my/mcp-base 같은 패키지는 없다)

코드는 복사된다. 복사된 코드가 서서히 갈라진다. 갈라져도 문제없다 — 각자의 맥락에 맞게 진화한다. 베이스 패키지로 묶었으면 이 자연스러운 갈라짐이 깨졌을 거다.

이 아이디어는 다른 곳에서도 자주 보인다 — Rails의 "Convention over Configuration", 혹은 cookiecutter 같은 프로젝트 템플릿 도구들. 프레임워크보다 컨벤션이 더 강력할 때가 있다. 다섯 개 MCP가 그 예시였다.

5. LLM이 있기에 생긴 도구들

3편에서 짧게 언급했지만 이게 제일 큰 깨달음일지도 모른다. get_weekly_summary, get_coderabbit_review의 마크다운 포맷, get_schema로 Claude가 자동으로 부르는 스키마 탐색… 이 도구들은 LLM이 없었으면 만들지 않았을 도구다.

기존 API 래핑은 Postman이나 공식 SDK로도 할 수 있다. LLM의 등장이 바꾼 건 — 사용자가 자연어로 의도를 말하고, Claude가 도구들을 조합해서 실현하는 패러다임이다. 이 흐름 속에서 좋은 MCP 도구는:

  • 자주 쓰는 패턴을 단축 도구로 제공한다 (get_my_tickets, get_weekly_summary)
  • Claude가 다른 도구와 조합하기 쉽게 결과를 돌려준다 (JSON이 아니라 사람이 읽는 문자열)
  • 잘 알려진 DSL은 패스스루한다 (CQL, JQL — Claude가 이미 안다)
  • API의 이상함을 사용자에게 떠넘기지 않는다 (ADF 변환, 스페이스 키 해석, 바디 모드)

이 네 원칙이 다섯 편을 관통하는 뼈대였다.

시리즈를 닫으며

첫 MCP를 만들 때는 "내 PR 리뷰 좀 편하게 보자"였다. 다섯째를 만들 때는 "이 패턴이 어디까지 갈까"였다.

돌아보면 다섯 개는 각자 하나의 질문에 답하는 챕터였다:

  • bitbucket-mcp — 책임을 어떻게 나눌 것인가
  • jira-mcp — API의 이상함을 어디에 가둘 것인가
  • confluence-mcp — 복제된 코드베이스를 어떻게 다룰 것인가
  • postman-mcp — 언제 단순함을 포기할 것인가
  • swagger-mcp — 재사용이란 무엇인가

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]에서 볼 수 있습니다.

profile
안녕하세요

0개의 댓글