CMS向けMCPのリファクタリング: CRUD設計原則
AIエージェントがブログ記事を書き、ランディングページまで作れるMCPサーバーを設計する中で経験した試行錯誤と、SanityやNotionなどの公式MCPから学んだパターンを整理します。
課題: 「1つのツールが多くをやりすぎている」
私たちのCMSには2種類のコンテンツがあります。
- ポスト: Editor.jsベースのブロックエディタで書くブログ記事
- ページ: GrapesJSベースのビジュアルビルダーで作るランディングページや紹介ページ
最初は、この2つを1つのMCPツールで扱っていました。
{
"name": "create_post",
"description": "ポストまたはページを作成します"
}
1つのツールの中で、markdown フィールドがあればポスト、grapes フィールドがあればページとして分岐する構造でした。部分修正も同じで、8種類のoperationを1つの 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": "..." }
]
}
ブロックエディタ向けのoperationとビジュアルビルダー向けのoperationが混ざっているため、AIエージェントの立場ではどのoperationを使うべきかが分かりにくく、間違ったタイプを送ると「ポスト専用です」というエラーが返るだけでした。
調査: Sanity、Notion、Contentfulはどう設計していたか
リファクタリングの前に、主要CMSの公式MCPサーバーを調べました。
Sanity: 「AIの魔法」より「予測可能性」
Sanity MCPはv2.6.0で AI-powered mutation tools を削除し、直接的なフィールド単位のパッチに置き換えました。その理由が印象的でした。
"more predictable, easier to debug"
さらに、入力フォーマットをツール名に明示しています。
| ツール | 用途 |
|---|---|
create_documents_from_json | JSONでドキュメントを作成 |
create_documents_from_markdown | Markdownでドキュメントを作成 |
patch_document_from_json | JSONで部分修正 |
フォーマットが違えば、ツールも分けます。1つのツールに複数フォーマットの自動判定をさせません。
Notion: 「Markdownを抽象化レイヤーにする」
Notion MCPは内部では28種類以上のブロックタイプを使っていますが、MCPツールでは Notion-flavored Markdown を入出力フォーマットに使います。ブロックJSONはトークンを10〜20倍消費するからです。
部分修正は次の2つに単純化していました。
update_content— テキストの検索と置換replace_content— 本文全体の置き換え
8種類のoperationを並べる代わりに、明確な2つの選択肢だけを提供しています。
Contentful: 「公開は別ツール」
Contentfulは create_entry や update_entry とは別に publish_entry を独立したツールとして分けています。状態変更をフィールド更新に混ぜない設計です。
共通パターンの整理
| 原則 | 説明 |
|---|---|
| フォーマット別にツールを分ける | 入力形式が違えばツールを分ける |
| 予測可能性を優先する | 自動判定より明示的な選択 |
| 公開/削除を分離する | 状態遷移はCRUDとは別ツールにする |
| スキーマ参照を用意する | エージェントが先に構造を把握できるようにする |
| ツール数は5〜15個を目安にする | コンテキスト予算を守る |
リファクタリング: 混合型からフォーマット分離型へ
Before (v2.x): 混合型3ツール
create_post → ポストもページも扱う
update_post → markdownもhtml/cssも受け取る
patch_content → 8種類のoperationが1つに入っている
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 を1つにまとめたself-containedなHTMLにして、別途CSSを読み込まなくてもすぐに表示できるようにしました。
部分修正が難しい理由
「タイトルのテキストを変えてください」は簡単そうに見えますが、内部ではコンテンツの種類ごとにまったく違う処理になります。
ポスト (ブロックベース):
{
"op": "update",
"blockIndex": 0,
"block": { "data": { "text": "新しいタイトル" } }
}
ブロックのインデックスで対象を見つけて、data フィールドをマージします。
ページ (HTMLベース):
{
"op": "replace_section",
"selector": "#hero",
"html": "新しいタイトル
"
}
CSSセレクタでDOM要素を見つけて、セクションごと差し替えます。これはサーバーサイドでDOMパーサーなしに処理する必要があったため、idセレクタをベースにした正規表現マッチングを使いました。
この違いがあるため、1つの patch ツールにまとめるとどうしても複雑になります。Sanityの原則に従って分けたのは正しい判断でした。
本当に共通化できるのは テキストの検索と置換 (patch_text) だけです。これはフォーマットが違っても同じ操作だからです。
CMS向けMCPツール設計のチェックリスト
今回の作業を通して、次の設計原則に整理されました。
- 入力フォーマットが違うならツールを分ける。 「このツールはMarkdownだけを受け取る」のほうが、「MarkdownまたはHTML/CSSを受け取る」よりAIには明確です。
- 自動判定より明示的な選択を優先する。 コンテンツタイプを実行時に推論するのではなく、ツールを選ぶ時点で決まるようにします。
- 状態遷移は別ツールに分ける。
publishやarchiveをupdateに混ぜません。 - 部分修正はコンテンツ構造に合わせる。 ブロックエディタならブロック単位、ビジュアルビルダーならセレクタ単位で扱います。広く共通化できるのはテキスト置換だけです。
- 読み取りレスポンスはエージェント向けに整形する。 生のJSONをそのまま返すのではなく、すぐ使える形にして返します。
- ツール名そのものがドキュメントになる。
create_post、create_page、patch_post_blocksのように、名前だけで対象とフォーマットが伝わるようにします。
結果
ツール数は3個から7個に増えましたが、それぞれの入力スキーマが明確になったことで、AIエージェントのミスは減りました。「ポストにGrapesのフィールドを送る」や「ページにブロックoperationを送る」といった型の不一致は、構造的に起こりにくくなりました。
SanityがAI-powered mutationをやめて明示的なパッチに戻した理由を、今回のリファクタリングで実感しました。AIツールは賢いほど良いのではなく、予測可能であるほど良いのです。