MCP 서버로 외부 앱을 조작하게 만들기 — 도구 스키마 설계 실무

AI 기술

MCP도구 스키마AI 에이전트API 설계JSON Schema

이 글은 누구를 위한 것인가

  • 자사 서비스를 AI 클라이언트(Claude, Cursor 등)에서 다루게 만들고 싶은 팀
  • MCP 서버를 붙였는데 모델이 도구를 엉뚱하게 호출하는 문제를 겪는 개발자
  • 기존 REST API를 그대로 MCP 도구로 노출했다가 실패한 경험이 있는 경우

들어가며

MCP(Model Context Protocol)는 AI 클라이언트가 외부 시스템의 도구를 표준화된 방식으로 호출하게 하는 규약이다. 서버를 하나 붙이면 Claude나 Cursor 같은 클라이언트가 내 서비스를 직접 조작할 수 있다.

문제는 "기존 API를 그대로 노출하면 된다"고 생각하고 시작하면 거의 실패한다는 점이다.

  REST API는 개발자가 문서를 읽고 호출한다
    → 파라미터가 30개여도 필요한 것만 골라 쓴다

  MCP 도구는 모델이 스키마만 보고 호출한다
    → 파라미터가 30개면 모델도 30개를 다 고민한다
    → 그리고 절반쯤 틀린다

이 글은 그 간극을 메우는 설계 결정들을 다룬다.


1. 도구 분해 단위 — API 엔드포인트와 1:1이 아니다

가장 먼저 정할 것은 도구를 몇 개로 쪼갤 것인가다.

[너무 잘게 쪼갠 경우]

  board.create
  board.setTitle
  board.setDescription
  board.setTheme
  card.create
  card.setPosition
  card.setSize
  card.setContent
  ...

  → 보드 하나 만드는 데 도구 호출 20회
  → 중간에 하나 실패하면 반쯤 만들어진 상태로 남음
[너무 뭉친 경우]

  board.doEverything(spec: object)

  → 스키마가 거대해져서 모델이 형식을 자주 틀림
  → 실패해도 어디가 틀렸는지 알려주기 어려움

기준은 "사용자가 한 문장으로 요청할 만한 단위"다.

  "TWICE 팬보드 만들어줘"
    → board.create(title, description, cards[])   ← 한 번에

  "거기에 뮤비 링크 3개 더 넣어줘"
    → card.append(boardId, cards[])                ← 추가는 별도

  "공개해줘"
    → publish.create(boardId, slug?)               ← 상태 전이는 별도

생성·추가·공개를 나누는 이유는 각각 실패 처리와 되돌리기 방식이 다르기 때문이다. 공개는 되돌릴 수 있어야 하고, 생성은 원자적이어야 하고, 추가는 부분 성공을 허용해도 된다.

실제 사례를 보면 감이 빠르다. 캔버스형 큐레이션 보드를 만드는 Linkme의 MCP 팬보드 가이드는 이 분해를 그대로 따른다 — 보드 생성과 카드 배치를 한 번에 처리하고, 공개(publish.create)를 별도 단계로 둔다. 만드는 것과 내보내는 것을 분리하면, 모델이 잘못 만들었을 때 공개 전에 사람이 확인할 여지가 생긴다.


2. 입력 스키마 — 모델이 읽는다는 전제로 쓴다

MCP 도구의 입력은 JSON Schema로 정의한다. 여기서 흔한 실수가 스키마를 타입 선언으로만 쓰는 것이다.

// ❌ 형식만 있고 의미가 없다
{
  "name": "card.create",
  "inputSchema": {
    "type": "object",
    "properties": {
      "boardId": { "type": "string" },
      "type": { "type": "string" },
      "x": { "type": "number" },
      "y": { "type": "number" },
      "w": { "type": "number" },
      "h": { "type": "number" },
      "payload": { "type": "object" }
    },
    "required": ["boardId", "type", "x", "y"]
  }
}

모델은 이 스키마를 보고 x, y가 픽셀인지 그리드 단위인지, 원점이 어디인지, type에 무엇이 들어갈 수 있는지 알 수 없다. 결과적으로 카드가 화면 밖에 배치되거나 서로 겹친다.

// ✅ 제약과 의미를 스키마에 담는다
{
  "name": "card.create",
  "description": "보드에 카드 하나를 추가합니다. 링크·이미지·텍스트 카드를 캔버스의 지정 위치에 배치할 때 사용하세요. 여러 장을 한 번에 넣을 때는 card.append를 쓰는 편이 빠릅니다.",
  "inputSchema": {
    "type": "object",
    "additionalProperties": false,
    "properties": {
      "boardId": {
        "type": "string",
        "description": "board.create가 반환한 ID"
      },
      "type": {
        "type": "string",
        "enum": ["link", "image", "text", "video"],
        "description": "카드 종류. link는 url, image는 src, text는 body가 필요합니다."
      },
      "col": {
        "type": "integer", "minimum": 0, "maximum": 11,
        "description": "12칸 그리드의 시작 열. 0이 가장 왼쪽입니다."
      },
      "row": {
        "type": "integer", "minimum": 0,
        "description": "그리드 행. 0이 최상단이며 아래로 갈수록 증가합니다."
      },
      "colSpan": {
        "type": "integer", "minimum": 1, "maximum": 12, "default": 3,
        "description": "차지할 열 수. 12를 넘지 않도록 col + colSpan <= 12."
      }
    },
    "required": ["boardId", "type", "col", "row"]
  }
}

바뀐 점이 넷이다.

  1. 픽셀 좌표(x,y) → 그리드 좌표(col,row)
     모델은 절대 픽셀을 잘 다루지 못한다. 이산 그리드가 훨씬 안정적이다.

  2. enum 으로 가능한 값을 못 박음
     자유 문자열이면 모델이 "링크", "LINK", "hyperlink" 를 섞어 쓴다.

  3. minimum/maximum 으로 범위 제약
     검증 실패를 줄이는 것보다, 모델이 애초에 범위를 알게 하는 게 낫다.

  4. description 에 "언제 이 도구를 쓰는가"를 명시
     무엇을 하는 도구인지보다 중요하다.

1번이 특히 효과가 크다. 좌표계를 픽셀에서 그리드로 바꾸는 것만으로 배치 오류가 크게 준다. 모델이 "화면 폭이 1200px이니 절반은 600px"을 계산하는 것보다 "12칸 중 6칸"이 훨씬 정확하다.

도구 정의의 필드 구성과 응답 형식은 MCP 명세의 tools 절에 규정돼 있다. 클라이언트 쪽에서 이 스키마가 어떻게 소비되는지는 Claude의 도구 사용 문서를 함께 보면 그림이 완성된다.


3. description이 실제로 정확도를 좌우한다

스키마의 description은 문서가 아니라 모델에게 주는 유일한 안내문이다.

[description 작성 원칙]

  ✓ 언제 호출하는지를 먼저 쓴다
      "사용자가 보드를 공개하거나 링크를 요청할 때 호출합니다."

  ✓ 언제 호출하면 안 되는지도 쓴다
      "초안 상태를 확인만 하려면 board.get을 쓰세요."

  ✓ 다른 도구와의 관계를 쓴다
      "board.create 이후에만 유효합니다."

  ✗ 구현 세부는 쓰지 않는다
      "내부적으로 S3에 업로드 후 CDN URL을 반환합니다" — 모델에겐 무의미

"언제 쓰면 안 되는가"를 넣는 것이 과호출을 줄이는 데 특히 효과적이다. 도구가 많아질수록 모델은 비슷한 도구 사이에서 헤매고, 경계를 명시하지 않으면 매번 다르게 고른다.

파라미터 설명도 마찬가지다.

"slug": {
  "type": "string",
  "pattern": "^[a-z0-9-]{3,32}$",
  "description": "공개 URL의 마지막 경로. 생략하면 자동 생성됩니다. 한글·공백·대문자는 쓸 수 없습니다."
}

pattern만 있으면 모델은 한글 슬러그를 만들고 실패한다. 금지 사항을 자연어로 한 번 더 적어주는 것이 검증 실패를 눈에 띄게 줄인다.


4. 오류는 모델이 고칠 수 있게 돌려준다

MCP 도구의 오류 응답은 사람이 아니라 모델이 읽는다. 모델이 다음 시도에서 고칠 수 있는 형태여야 한다.

// ❌ 모델이 무엇을 고쳐야 할지 모른다
return { isError: true, content: [{ type: 'text', text: 'Validation failed' }] };

// ❌ 스택 트레이스는 토큰만 먹는다
return { isError: true, content: [{ type: 'text', text: err.stack }] };

// ✅ 무엇이 왜 틀렸고 어떻게 고치는지
return {
  isError: true,
  content: [{
    type: 'text',
    text: [
      '카드 3개 중 1개가 배치되지 않았습니다.',
      '',
      '- cards[2]: col=10, colSpan=4 → col + colSpan = 14 로 12를 넘습니다.',
      '  col을 8 이하로 줄이거나 colSpan을 2 이하로 하세요.',
      '',
      '나머지 2개는 정상 생성됐습니다 (cardId: c_a1, c_a2).',
      '실패한 카드만 다시 요청하세요.',
    ].join('\n'),
  }],
};

세 가지가 들어 있다.

  1. 무엇이 실패했는지 — 인덱스로 특정
  2. — 제약 위반을 수식으로
  3. 어떻게 고치는지 — 구체적 대안값

그리고 부분 성공을 명시하는 것이 중요하다. 이게 없으면 모델은 전부 다시 만들고, 중복 카드가 생긴다.


5. 부분 실패와 멱등성

배치 도구(card.append처럼 여러 개를 한 번에)는 전부 아니면 전무로 처리할지, 부분 성공을 허용할지 먼저 정해야 한다.

[전부 아니면 전무]
  장점: 상태가 명확. 재시도가 단순
  단점: 10개 중 1개 실패로 9개를 버림
  적합: 순서·의존성이 있는 작업

[부분 성공 허용]
  장점: 낭비가 없음
  단점: 모델이 "무엇이 남았는지" 추적해야 함
  적합: 서로 독립적인 항목 (카드 배치 등)

부분 성공을 택했다면 응답에 성공/실패를 인덱스로 명확히 나눠 담아야 한다.

{
  "created": [
    { "index": 0, "cardId": "c_a1" },
    { "index": 1, "cardId": "c_a2" }
  ],
  "failed": [
    { "index": 2, "reason": "GRID_OVERFLOW", "hint": "col + colSpan <= 12" }
  ]
}

멱등성도 필요하다. 모델은 응답을 못 받으면 재시도하고, 그러면 카드가 두 번 생긴다.

"clientRequestId": {
  "type": "string",
  "description": "재시도 시 중복 생성을 막기 위한 고유 ID. 같은 값으로 다시 호출하면 이전 결과를 반환합니다."
}

모델이 이 값을 성실히 채우도록 하려면 description에 이유를 적어야 한다. "고유 ID"라고만 쓰면 매번 새 값을 만든다.


6. 권한 경계 — 모델이 할 수 있는 일을 좁힌다

MCP 서버는 사용자의 자격 증명으로 동작한다. 모델이 실수하거나 프롬프트 주입을 당했을 때의 피해 범위를 설계 단계에서 정해야 한다.

[도구별 위험도 분류]

  읽기          board.get, board.list
                → 자유 허용

  생성·수정     board.create, card.append
                → 허용. 되돌리기 가능하면 위험 낮음

  공개          publish.create
                → 확인 후 실행 권장. 외부에 노출되는 행위

  삭제·이전     board.delete, board.transferOwner
                → 아예 노출하지 않는 것도 방법

삭제 도구를 MCP로 노출하지 않는 선택은 소극적인 게 아니라 합리적이다. 삭제는 사용자가 앱에서 직접 하면 되고, 모델이 대신 해야 할 이유가 거의 없다. 노출하는 도구 목록 자체가 권한 설계다.

공개처럼 되돌릴 수 있어야 하는 행위는 되돌리는 도구를 쌍으로 제공한다.

  publish.create   →   publish.revoke

한쪽만 있으면 모델은 실수를 스스로 수습할 수 없고, 사용자에게 "앱에서 직접 취소하세요"라고 말하게 된다.


7. 응답 크기 — 컨텍스트를 아낀다

도구 응답은 그대로 모델의 컨텍스트에 들어간다. 보드 하나를 조회했는데 카드 200개의 전체 내용이 돌아오면, 그 뒤 대화가 전부 비싸진다.

// ❌ 전체를 그대로
return { content: [{ type: 'text', text: JSON.stringify(board) }] };

// ✅ 요약 + 필요 시 상세 조회
return {
  content: [{
    type: 'text',
    text: [
      `보드 "${board.title}" (${board.id})`,
      `카드 ${board.cards.length}개 · 공개 ${board.published ? 'O' : 'X'}`,
      '',
      '카드 목록 (앞 10개):',
      ...board.cards.slice(0, 10).map((c, i) => `  ${i}. [${c.type}] ${c.title ?? c.url}`),
      board.cards.length > 10 ? `  ... 외 ${board.cards.length - 10}개` : '',
      '',
      '특정 카드 상세는 card.get(cardId)로 조회하세요.',
    ].filter(Boolean).join('\n'),
  }],
};

"더 보려면 이 도구를 쓰세요"를 응답에 넣는 것이 핵심이다. 모델이 필요할 때만 상세를 가져가게 하면, 대부분의 대화에서 토큰이 절약된다.


8. 정리

  1. 도구 단위는 "사용자가 한 문장으로 요청할 만한 것"
  2. 좌표는 픽셀이 아니라 이산 그리드로
  3. enum·minimum/maximum으로 제약을 스키마에 담는다
  4. description에 "언제 쓰는가"와 "언제 쓰면 안 되는가"를 쓴다
  5. 오류는 무엇/왜/어떻게 고칠지 + 부분 성공 명시
  6. 재시도 대비 멱등 키, 이유를 description에 설명
  7. 삭제는 노출하지 않는 것도 설계. 공개에는 취소를 쌍으로
  8. 응답은 요약 + 상세 조회 안내

이 중 가장 효과가 큰 건 2번과 4번이다. 좌표계를 바꾸고 description에 호출 조건을 적는 것만으로, 같은 모델이 같은 작업에서 훨씬 적게 틀린다.