アーティスト・クリエイターの廣川政樹です。壁紙アプリのアセットを Claude から直接扱えるようにしたくて、MCP サーバーを書き始めたときのことを振り返ります。最初に作ったサーバーは、機能をすべて tool として実装していました。アセット一覧の取得も、メタデータの読み取りも、定型のレビュー手順も、キャプション生成も、全部 tool です。動きはしたのですが、tool の数が14個まで膨らんだあたりで、Claude がどれを呼べばよいか迷い始め、私自身もどこに何があるのか把握しきれなくなりました。
このとき気づいたのは、MCP には tool のほかに resource・prompt・sampling という能力が用意されていて、本来そちらで表現すべきものまで tool に押し込んでいた、ということでした。14個あった tool を5個まで減らした実際のリファクタを下敷きに、3つの primitive をどう使い分けるかを順に整理します。題材は私が2014年から個人開発を続けている壁紙アプリ群(iOS / Android 合わせて累計5,000万ダウンロードを超えました)のアセット管理サーバーです。
tool に何でも詰め込むと何が起きるか
MCP を学び始めると、最初に手に取るのはほぼ確実に tool です。server.tool() で関数を登録すれば Claude がそれを呼べる、という分かりやすさがあるからです。私もそうでした。ただ、tool だけで全部を表現しようとすると、いくつかの具体的な問題が出てきます。
ひとつ目は、Claude の選択精度が落ちることです。tool は「副作用のある操作」としてモデルに提示されます。読み取り専用のデータ取得まで tool にすると、モデルは「これは呼んでも安全なのか」を毎回判断する必要が生まれ、似た名前の tool が増えるほど取り違えが起きます。実際、私のサーバーでは get_wallpaper_meta と get_wallpaper_info という似た tool を両方残してしまい、Claude がしばしば逆を呼ぶ事故が起きていました。
ふたつ目は、定型ワークフローが再利用できないことです。「この壁紙のメタデータを App Store 向けにレビューして」という作業は、毎回ほぼ同じ手順を踏みます。これを tool にすると、手順そのものはプロンプトとして私が毎回書き直すことになり、サーバー側に蓄積されません。
みっつ目は、サーバー側で軽い推論をしたいときに行き場がないことです。たとえば壁紙のファイル名から人間向けのキャプションを生成したい場合、サーバー自身が API キーを持って Claude を呼ぶ実装も可能ですが、それだとサーバーが課金主体になり、個人開発のコスト管理が一気に複雑になります。
下記は、リファクタ前の「何でも tool」状態を簡略化したものです。
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" ;
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" ;
import { z } from "zod" ;
const server = new McpServer ({ name: "wallpaper-assets" , version: "1.0.0" });
// 読み取り専用のはずなのに、すべて tool
server. tool ( "list_wallpapers" , {}, async () => ({
content: [{ type: "text" , text: JSON . stringify ( await db. listAll ()) }],
}));
server. tool ( "get_wallpaper_meta" , { id: z. string () }, async ({ id }) => ({
content: [{ type: "text" , text: JSON . stringify ( await db. meta (id)) }],
}));
// 定型レビュー手順も tool(毎回プロンプトを呼び出し側で書くことになる)
server. tool ( "review_metadata" , { id: z. string () }, async ({ id }) => ({
content: [{ type: "text" , text: `次のメタデータをレビュー: ${ JSON . stringify ( await db . meta ( id )) }` }],
}));
// ……このあと 11 個の tool が続く
この review_metadata が象徴的です。やっていることは「レビュー用のプロンプトを組み立てて返す」だけで、実体は操作ではなくテンプレートです。本来これは prompt として表現すべきものでした。
resources:読み取り専用のデータは「アドレス」で渡す
resource は、サーバーが持つ読み取り可能なデータに URI を与えて公開する仕組みです。wallpaper://1024 のようなアドレスでアクセスでき、副作用がないことが型として表現されます。Claude から見ると「呼ぶかどうか判断する操作」ではなく「参照できる素材」になるため、tool の選択ノイズから切り離せます。
壁紙アセットの一覧と個別メタデータは、まさに読み取り専用データです。これを resource に移します。
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js" ;
// 一覧は固定 URI の resource
server. resource ( "wallpaper-list" , "wallpaper://list" , async ( uri ) => ({
contents: [{
uri: uri.href,
mimeType: "application/json" ,
text: JSON . stringify ( await db. listAll ()),
}],
}));
// 個別メタデータはテンプレート URI で動的に
server. resource (
"wallpaper" ,
new ResourceTemplate ( "wallpaper://{id}" , { list: undefined }),
async ( uri , { id }) => ({
contents: [{
uri: uri.href,
mimeType: "application/json" ,
text: JSON . stringify ( await db. meta ( String (id))),
}],
})
);
この変更だけで list_wallpapers・get_wallpaper_meta・get_wallpaper_info の3つの tool が消えました。データの取得は resource に一本化され、似た名前の tool による取り違えも自然に解消しました。私はこの「読み取りは resource、書き込み・操作は tool」という線引きを設計の最初の分岐点として置くことを強く推奨します。tool に残すべきは、分類の再実行やサムネイルの再生成のように、状態を変える操作だけです。
ひとつ注意点があります。resource は「クライアントが明示的に読みに来る」モデルです。Claude が会話の流れで勝手に全 resource を読むわけではありません。会話の中で特定の壁紙を扱わせたいときは、クライアント側でその resource を文脈に添付するか、resource を参照するよう促す必要があります。ここを誤解すると「resource にしたのに Claude が見てくれない」と感じますが、それは設計通りの挙動です。
prompts:定型ワークフローをサーバーに蓄積する
prompt は、再利用可能なメッセージのテンプレートをサーバー側に登録する仕組みです。引数を受け取り、組み立て済みのメッセージ列を返します。クライアントでは多くの場合スラッシュコマンドのように一覧表示され、ユーザーが選んで起動できます。
先ほどの review_metadata tool が本来やりたかったのは、これです。tool から prompt へ移すと、レビュー手順そのものがサーバーの資産になり、呼び出し側でプロンプトを書き直す必要がなくなります。
server. prompt (
"review_for_store" ,
{ id: z. string (), locale: z. string (). default ( "ja" ) },
async ({ id , locale }) => {
const meta = await db. meta (id);
return {
messages: [
{
role: "user" ,
content: {
type: "text" ,
text: [
`次の壁紙メタデータを ${ locale } の App Store / Google Play 向けにレビューしてください。` ,
"観点: タイトルの訴求力、説明文の長さ制限、禁止表現の有無、カテゴリの妥当性。" ,
"改善案は箇条書きではなく、修正後の文面そのものを提示してください。" ,
"" ,
JSON . stringify (meta, null , 2 ),
]. join ( " \n " ),
},
},
],
};
}
);
prompt にしておく利点は、手順を一度きちんと書けば、6本のアプリすべてで同じ品質のレビューが回せることです。私の場合、ストア審査でリジェクトされやすい表現(誇大な「最高」「No.1」など)のチェック観点をこの prompt に集約しました。以前は tool 経由で毎回プロンプトを手書きしていたため、アプリごとに観点がぶれていましたが、prompt に固定したことでぶれが消えました。
prompt と tool の違いを一言でいうと、prompt は「ユーザーが起点になる定型の依頼」、tool は「モデルが必要に応じて呼ぶ操作」です。レビュー・要約・命名規則のチェックのように、人間が「これをやって」と起動する種類の作業は prompt が向いています。
sampling:サーバーからクライアントの推論を借りる
sampling は、3つのなかで最も誤解されやすい能力です。これはサーバーが「クライアント側の LLM に推論をお願いする」ための仕組みです。つまりサーバーが自前で API キーを持って Claude を呼ぶのではなく、sampling/createMessage というリクエストをクライアントに送り、クライアントが(ユーザーの承認を経て)手元のモデルで生成し、結果をサーバーに返します。
これが効くのは、サーバー処理の途中で軽い自然言語生成を挟みたい場面です。私のケースでは、新しい壁紙を取り込んだときにファイル名や色情報から人間向けのキャプション案を作りたい、という要件がありました。サーバーが課金主体にならずにこれを実現できるのが sampling の価値です。
server. tool (
"ingest_wallpaper" ,
{ id: z. string (), filename: z. string () },
async ({ id , filename }) => {
const palette = await analyzePalette (id); // 既存の色解析
// クライアントの LLM にキャプション案を生成してもらう
const result = await server.server. createMessage ({
messages: [{
role: "user" ,
content: {
type: "text" ,
text: `ファイル名「${ filename }」と主要色 ${ palette . join ( ", " ) } から、` +
`壁紙アプリ向けの日本語キャプションを3案、各20文字以内で出してください。` ,
},
}],
maxTokens: 200 ,
});
const caption = result.content.type === "text" ? result.content.text : "" ;
await db. saveCaptionDraft (id, caption);
return { content: [{ type: "text" , text: `キャプション案を保存しました: \n ${ caption }` }] };
}
);
ここで重要なのは、server.server.createMessage() の呼び出しが成功するかどうかは、つないでいるクライアントが sampling 機能に対応しているかに完全に依存する、という点です。対応していないクライアントに送るとエラーになります。次の節で、この現実的な制約への対処を扱います。
3つをどう使い分けるか
ここまでを判断基準としてまとめます。私が設計時に自問する順番は決まっています。
第一に、それは状態を変える操作か、それとも読み取りか。読み取りなら resource、状態を変えるなら tool を検討します。第二に、それは人間が起点になる定型の依頼か。レビューや要約のように「これをやって」と起動する作業なら prompt にします。第三に、サーバー処理の途中で自然言語生成を挟みたいか。挟みたいなら sampling を検討し、ただしクライアント対応を必ず確認します。
具体的な対応づけを書き出すと、次のようになります。
壁紙一覧・個別メタデータの取得 → resource(副作用なし、参照素材)
分類の再実行・サムネイル再生成・公開フラグの更新 → tool(状態を変える)
ストア向けメタデータレビュー・命名規則チェック → prompt(人間起点の定型依頼)
取り込み時のキャプション案生成 → tool の内部で sampling を併用
この整理を当てはめた結果、14個あった tool は「再分類」「サムネイル再生成」「公開フラグ更新」「取り込み」「タグ一括置換」の5個まで減りました。読み取り系の8個は resource へ、レビュー系は prompt へ移り、サーバーの責務がはっきり分かれました。Claude の tool 取り違えはこのリファクタ以降ほぼゼロになっています。
壁紙アプリのアセット管理サーバーで実際にどう組み直したか
実際の移行は一度に全部やろうとすると壊すので、私は段階を踏みました。最初に resource を追加し、tool 版の読み取り系と並走させます。Claude が resource を正しく読めることを会話で確認してから、tool 版を削除します。この並走期間を置くことで、resource にしたら Claude が参照しなくなった、という落とし穴に早く気づけます。
次に prompt への移行です。レビュー系の tool を prompt に書き換えるとき、引数のデフォルト値を丁寧に設計しました。locale を ja 既定にしておくと、英語ストア向けのときだけ en を渡せばよく、日常作業の摩擦が減ります。個人開発では、こうした小さな摩擦の削減が積み重なって効きます。
sampling の併用はいちばん慎重に進めました。キャプション生成はあくまで「案」であり、最終決定は私が行う前提にしています。AdMob の収益に直結するストア掲載文を完全自動で書き換えるような設計は、品質のばらつきが怖くて採用しませんでした。生成はドラフトとして保存し、私が確認して採用する運用です。ここは個人開発のリスク許容度に合わせた判断で、正解はひとつではありません。
色解析やサムネイル生成のような重い処理は、これまで通りサーバー側のネイティブ実装に残しました。sampling はあくまで自然言語の軽い生成に限定し、画像処理を LLM に投げるような使い方はしていません。primitive を増やすこと自体が目的化すると複雑性が膨らむので、「この処理に本当に推論が要るか」を毎回問い直すようにしています。
クライアント対応状況という現実的な制約
設計が美しくても、つなぐクライアントが対応していなければ動きません。これは sampling で特に顕在化します。tool と resource はほとんどのクライアントが対応していますが、prompt と sampling は対応状況にばらつきがあります。本番でいきなり sampling に依存すると、クライアントを変えた途端に取り込み機能が丸ごと落ちる、という事故が起こり得ます。
私が採用しているのは、sampling が使えない場合に静かに縮退するフォールバックです。createMessage の呼び出しを try/catch で囲み、失敗したらキャプション生成だけスキップして取り込み自体は完了させます。
async function tryGenerateCaption ( filename : string , palette : string []) : Promise < string | null > {
try {
const result = await server.server. createMessage ({
messages: [{
role: "user" ,
content: { type: "text" , text: `「${ filename }」と色 ${ palette . join ( ", " ) } からキャプションを1案、20文字以内で。` },
}],
maxTokens: 100 ,
});
return result.content.type === "text" ? result.content.text : null ;
} catch {
// sampling 非対応クライアント、またはユーザーが拒否した場合
return null ; // 取り込み本体は止めない
}
}
この設計にしてから、Claude Desktop でも Claude Code でも、対応の差を意識せずに同じサーバーをつなげるようになりました。「使える環境では便利機能が効き、使えない環境でも壊れない」という縮退の発想は、個人開発で複数の環境を行き来する人ほど効いてくると感じています。
もうひとつの現実的な注意点は、sampling はユーザー承認を挟む設計になっていることです。クライアントによっては毎回確認ダイアログが出るため、バッチ的に大量の壁紙を一度に取り込むワークフローには向きません。私は取り込みを1枚ずつ手で確認する運用にしているので問題になりませんでしたが、無人での一括処理を狙うなら、sampling ではなくサーバー側の独立した API 呼び出しを選ぶべき場面もあります。ここでもやはり「自動化の範囲をどこまで広げるか」という運用判断が先に来ます。
次に試すなら、手元の MCP サーバーの tool 一覧を一度書き出し、それぞれが「読み取りか・操作か・定型依頼か・推論か」のどれに当たるかを分類してみてください。読み取り系がいくつ tool に紛れているかが見えると、最初に動かすべきリファクタがはっきりします。同じように tool が増えすぎて見通しが悪くなっている方の参考になれば嬉しいです。