테스트 목록

PRISM JOURNAL

CMS MCP 리팩터링: CRUD 설계 원칙

CMS용 MCP 서버를 리팩터링하며 포스트와 페이지를 한 도구로 섞어 다룰 때 생기는 문제를 정리했습니다. 공식 MCP 사례를 바탕으로 콘텐츠 CRUD 도구를 포맷별로 분리하는 원칙을 설명합니다.

AI 에이전트가 블로그 글도 쓰고, 랜딩 페이지도 만들 수 있는 MCP 서버를 설계하면서 겪은 시행착오와 Sanity, Notion 등 공식 MCP에서 배운 패턴들을 정리합니다.

문제: "하나의 도구가 너무 많은 일을 한다"

우리 CMS에는 두 종류의 콘텐츠가 있습니다.

  • 포스트: 블로그 글. Editor.js 기반의 블록 에디터로 작성
  • 페이지: 랜딩 페이지, 소개 페이지. GrapesJS 기반의 비주얼 빌더로 작성

처음에는 이 둘을 하나의 MCP 도구로 처리했습니다.

{
  "name": "create_post",
  "description": "포스트 또는 페이지를 생성합니다"
}

한 도구 안에서 markdown 필드가 있으면 포스트, grapes 필드가 있으면 페이지로 분기하는 구조였습니다. 부분 수정도 마찬가지로, 8개의 operation 타입을 하나의 patch_content 도구에 몰아넣었습니다.

{
  "name": "patch_content",
  "operations": [
    { "op": "replace_text", "search": "...", "replace": "..." },
    { "op": "insert_block", "blockIndex": 2, "block": { "type": "paragraph", "data": { "text": "..." } } },
    { "op": "replace_section", "selector": "#hero", "html": "..." },
    { "op": "update_css", "css": "..." }
  ]
}

블록 에디터용 op와 비주얼 빌더용 op가 섞여 있으니, AI 에이전트 입장에서는 어떤 op를 써야 하는지 혼란스럽고, 잘못된 타입의 op를 보내면 "포스트 전용입니다"라는 에러가 돌아올 뿐이었습니다.


리서치: Sanity, Notion, Contentful은 어떻게 했나

리팩터링 전에 주요 CMS의 공식 MCP 서버를 조사했습니다.

노션은 CMS가 아니지만요. 복잡한 블럭 json을 어떻게 처리했는지가 궁금해서 함께 리서치했습니다.

Sanity — "예측 가능성 > AI 마법"

Sanity MCP는 v2.6.0에서 "AI-powered mutation tools"을 제거하고, 직접적인 필드 수준 패치로 교체했습니다. 그 이유가 인상적이었습니다:

"more predictable, easier to debug"

또한 입력 포맷을 도구 이름에 명시합니다:

도구용도
create_documents_from_jsonJSON으로 문서 생성
create_documents_from_markdown마크다운으로 문서 생성
patch_document_from_jsonJSON으로 부분 수정

포맷이 다르면 도구를 나눕니다. 하나의 도구가 여러 포맷을 자동 감지하게 만들지 않습니다.

Notion — "Markdown을 추상화 레이어로"

Notion MCP는 내부적으로 28개 이상의 블록 타입을 사용하지만, MCP 도구는 Notion-flavored Markdown을 입출력 포맷으로 사용합니다. 블록 JSON은 토큰을 10~20배 더 소비하기 때문입니다.

부분 수정은 두 가지 명령으로 단순화했습니다:

  • update_content — 텍스트 검색/치환
  • replace_content — 전체 본문 교체

8개의 op 타입을 넣는 대신, 명확하게 두 가지만 제공합니다.

Contentful — "발행은 별도 도구"

Contentful은 create_entry, update_entry와 별개로 publish_entry를 독립 도구로 분리합니다. 상태 변경을 필드 수정에 섞지 않는 패턴입니다.

공통 패턴 정리

원칙설명
포맷별 도구 분리입력 형식이 다르면 도구를 나눈다
예측 가능성 우선자동 감지보다 명시적 선택
발행/삭제 분리상태 전환은 CRUD와 별개 도구
스키마 조회 제공에이전트가 먼저 구조를 파악하게 한다
5~15개 도구 권장컨텍스트 예산 관리

리팩터링: 혼합형에서 포맷 분리형으로

Before (v2.x) — 혼합형 3개

create_post     → 포스트도 되고 페이지도 되고
update_post     → 마크다운도 받고 html/css도 받고
patch_content   → 8개 op 타입이 한 도구에

AI가 이 도구를 쓰려면 "이 콘텐츠가 포스트인지 페이지인지" 먼저 확인하고, 맞는 필드를 골라서 보내야 했습니다. 잘못 보내면 조용히 무시되거나, 런타임에야 에러가 발생했습니다.

After (v3.0) — 포맷별 7개

생성:

도구대상입력
create_post포스트markdown
create_page페이지html + css

본문 교체:

도구대상입력
update_post_content포스트markdown
update_page_content페이지html + css

부분 수정:

도구대상용도
patch_text공통텍스트 검색/치환
patch_post_blocks포스트블록 추가/수정/삭제/이동
patch_page_html페이지섹션 교체, CSS 수정

3개 → 7개로 도구 수는 늘었지만, 각 도구의 입력 스키마가 단순해졌습니다. AI 에이전트가 "이 도구는 이 포맷만 받는다"는 걸 이름만 보고 알 수 있습니다.


rawContent 통합 저장

포스트와 페이지는 DB 스키마를 공유합니다. 같은 page_translations 테이블의 raw_content 필드를 사용하되, 저장 포맷이 다릅니다:

포스트 (Editor.js):

{
  "blocks": [
    { "type": "header", "data": { "text": "제목", "level": 2 } },
    { "type": "paragraph", "data": { "text": "본문 텍스트입니다." } }
  ],
  "version": "2.28.0"
}

페이지 (GrapesJS):

{
  "type": "grapes",
  "builderState": { "pages": [{ "name": "Page", "component": "..." }] },
  "html": "
...
...
", "css": "#hero h1 { font-size: 3rem; } ..." }

type: "grapes" 필드가 판별자 역할을 합니다. 로드할 때 이 필드를 확인해서 어느 에디터로 열지 결정합니다.

content 필드에는 렌더링용 HTML을 저장합니다. 페이지의 경우 <style>CSS</style>HTML을 합쳐서 self-contained HTML로 만들어, 별도 CSS 로딩 없이 바로 렌더링할 수 있게 했습니다.


부분 수정이 어려운 이유

"제목 텍스트를 바꿔줘"는 간단해 보이지만, 내부적으로는 전혀 다른 작업입니다.

포스트 (블록 기반):

{
  "op": "update",
  "blockIndex": 0,
  "block": { "data": { "text": "새로운 제목" } }
}

블록 인덱스로 특정 블록을 찾아서, data 필드를 머지합니다.

페이지 (HTML 기반):

{
  "op": "replace_section",
  "selector": "#hero",
  "html": "

새로운 제목

" }

CSS 셀렉터로 DOM 요소를 찾아서, 통째로 교체합니다. 서버 사이드에서 DOM 파서 없이 처리해야 하므로, id 셀렉터 기반의 정규식 매칭을 사용합니다.

이 차이 때문에 하나의 patch 도구로 합치면 복잡해질 수밖에 없었고, Sanity의 원칙대로 분리하는 게 맞았습니다.

유일하게 공통으로 쓸 수 있는 건 텍스트 검색/치환(patch_text)입니다. 포맷에 관계없이 문자열을 찾아 바꾸는 건 동일하니까요.


MCP 도구 설계 시 체크리스트

이번 작업을 통해 정리한 CMS용 MCP 설계 원칙입니다:

  1. 입력 포맷이 다르면 도구를 나누자 — "이 도구는 markdown만 받는다"가 "markdown 또는 html/css를 받는다"보다 AI에게 명확하다.
  2. 자동 감지보다 명시적 선택 — 콘텐츠 타입을 런타임에 추론하지 말고, 도구 선택 시점에 결정되게 하라.
  3. 상태 전환은 별도 도구 — publish, archiveupdate에 섞지 마라.
  4. 부분 수정은 콘텐츠 구조에 맞게 — 블록 에디터는 블록 단위, 비주얼 빌더는 셀렉터 단위. 공통으로 묶을 수 있는 건 텍스트 치환 정도.
  5. 읽기 응답을 가공하자 — raw JSON을 그대로 던지지 말고, 에이전트가 바로 쓸 수 있는 형태로 변환해서 반환하라.
  6. 도구 이름이 문서다 — create_post, create_page, patch_post_blocks처럼 이름만 보고 대상과 포맷을 알 수 있게 하라.

도구 수는 3개에서 7개로 늘었지만, 각 도구의 입력 스키마가 명확해지면서 AI 에이전트의 실수가 줄었습니다. "포스트인데 grapes 필드를 보냈다"거나 "페이지인데 블록 op를 보냈다" 같은 타입 불일치 에러가 구조적으로 불가능해졌습니다.

Sanity가 AI-powered mutation을 걷어내고 명시적 패치로 돌아간 이유를 직접 체감한 리팩터링이었습니다. AI 도구는 똑똑할수록 좋은 게 아니라, 예측 가능할수록 좋습니다.

#CMS MCP#MCP 서버#콘텐츠 CRUD#도구 설계#리팩터링#Sanity MCP#Notion MCP
kojaen