はじめに
当社(合同会社ジョインクラス)は、launchd の17ジョブと Claude Code で社内業務の約98%を自動で回しています。月のAI運用コストは約¥20,000です。その中で毎日動いているのが、note の記事統計やマガジン管理を担う自作の MCP サーバー note-api です。
最初に作ったとき、僕は全部を Tools として実装しました。動きはしました。ただ、読むだけの処理まで「ツール呼び出し」になったので、--allowedTools の管理が面倒になり、プロンプトもスクリプトごとに少しずつ違う形でコピーされていきました。
MCP には Tools / Resources / Prompts の3つの機能があります。この記事では、実際に動いている構成をもとに、次の内容を手順付きで書きます。
- 3機能をそれぞれどんな場面で使うか(判断基準)
- TypeScript SDK での最小実装
- Claude Code からの呼び出しと検証方法
- 実際にはまった落とし穴
3機能の違い(結論)
| 機能 | 誰が使うか決める | 副作用 | 向いている処理 |
|---|---|---|---|
| Tools | モデル(LLM) | あり得る | 投稿、追加、更新、外部APIの実行 |
| Resources | クライアント/ユーザー | なし(読むだけ) | 設定値、統計、ドキュメントなどのコンテキスト |
| Prompts | ユーザー(スラッシュコマンド) | なし | 定型タスクの指示テンプレート |
判断に使う問いは1つだけです。「それはモデルに勝手に実行させてよい"動詞"か?」
- 動詞で、副作用がある → Tool
- 名詞で、読むだけ → Resource
- 人間が毎回同じ依頼文を書いている → Prompt
実際の運用:Tools は cron から呼ばれている
当社では、launchd から起動するシェルスクリプトが Claude Code を headless で動かし、MCP の Tools を呼んでいます。以下は実際に動いているスクリプトの抜粋です(.company/scripts/note/add-notes-to-magazine.sh)。
# Claude Code CLI で MCP API を実行
claude --effort low \
--allowedTools "mcp__note-api__get-my-notes,mcp__note-api__add-magazine-note" \
-p "
noteアカウント joinclass の全公開記事を取得し、以下の12記事**以外**の記事を全てマガジン「${MAGAZINE_ID}」に追加してください。
...
"
もう1本は毎朝9:00に動くマーケ部門の日次レポートです(auto-dept-marketing.sh)。
RESULT=$(claude -p "
1. note-api MCPを使って最新の記事の統計を確認(ビュー数、スキ数)
...
" --allowedTools "Read,Bash,mcp__note-api*" 2>/dev/null | tail -20)
ここが改善のきっかけでした。後者は読むだけなのに mcp__note-api* とワイルドカードで全ツールを許可しています。つまり、統計を見るだけのジョブが、理屈の上では add-magazine-note も実行できる状態でした。
ここから「書き込みは Tools、読み取りは Resources、定型の依頼は Prompts」という分け方に整理しました。
手順1:プロジェクト作成
mkdir note-mcp && cd note-mcp
npm init -y
npm i @modelcontextprotocol/sdk zod
npm i -D typescript @types/node tsx
npx tsc --init --target es2022 --module nodenext --moduleResolution nodenext --outDir dist
package.json に "type": "module" を追加します。
手順2:3機能を1つのサーバーに実装する
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { fetchMyNotes, fetchNoteStats, addToMagazine } from "./note-client.js";
const server = new McpServer({ name: "note-api", version: "1.0.0" });
// ---------- Tools:副作用のある"動詞" ----------
server.registerTool(
"add-magazine-note",
{
title: "マガジンに記事を追加",
description: "指定した記事をマガジンに追加する(書き込み操作)",
inputSchema: {
magazineId: z.string().describe("例: m87293889dd94"),
noteId: z.string().describe("例: n57c7f1dc35d5"),
},
},
async ({ magazineId, noteId }) => {
await addToMagazine(magazineId, noteId);
return { content: [{ type: "text", text: `added ${noteId} -> ${magazineId}` }] };
}
);
// ---------- Resources:読むだけの"名詞" ----------
// 固定URI:自分の記事一覧
server.registerResource(
"my-notes",
"note://me/notes",
{ title: "自分の公開記事一覧", mimeType: "application/json" },
async (uri) => ({
contents: [{ uri: uri.href, text: JSON.stringify(await fetchMyNotes()) }],
})
);
// テンプレートURI:記事ごとの統計
server.registerResource(
"note-stats",
new ResourceTemplate("note://notes/{noteId}/stats", { list: undefined }),
{ title: "記事統計(ビュー・スキ)", mimeType: "application/json" },
async (uri, { noteId }) => ({
contents: [{ uri: uri.href, text: JSON.stringify(await fetchNoteStats(String(noteId))) }],
})
);
// ---------- Prompts:定型依頼のテンプレート ----------
server.registerPrompt(
"daily-report",
{
title: "マーケ日次レポート",
description: "記事統計から日次レポートを作る",
argsSchema: { days: z.string().optional() },
},
({ days }) => ({
messages: [
{
role: "user",
content: {
type: "text",
text: [
`直近${days ?? "7"}日のnote記事について、note://me/notes を読み、`,
"ビュー数・スキ数の上位3件と前週比を報告してください。",
"書き込み系ツールは使わないこと。",
].join("\n"),
},
},
],
})
);
await server.connect(new StdioServerTransport());
note-client.ts は note の API を叩く薄いラッパーなので省略します。自社APIや社内DBに置き換えても、構造はそのまま使えます。
手順3:Claude Code に登録する
npx tsc
claude mcp add note-api -- node /absolute/path/to/note-mcp/dist/server.js
claude mcp list # note-api: ✓ Connected になればOK
Claude Code の中から使う方法はそれぞれ違います。
-
Tools:自然文で頼むと、モデルが
mcp__note-api__add-magazine-noteを選んで呼ぶ -
Resources:プロンプトに
@note-api:note://me/notesと書くと内容が添付される -
Prompts:
/mcp__note-api__daily-report 14のようにスラッシュコマンドとして呼ぶ
手順4:Inspector で検証する
Claude Code に組み込む前に、MCP Inspector で3機能をそれぞれ単体で確認します。
npx @modelcontextprotocol/inspector node dist/server.js
ブラウザで開いたら、次の3点を確認してください。
-
Tools タブ:
add-magazine-noteの inputSchema が表示され、実行できる -
Resources タブ:
note://me/notesが一覧に出る。テンプレートのほうは Resource Templates 側に出る -
Prompts タブ:
daily-reportに引数daysを渡すと、展開後のメッセージが表示される
headless(cron)運用での使い分け
当社のように claude -p で無人実行する場合、効いてくるのは権限の絞り込みです。読み取りを Resources と Prompts に寄せると、日次レポートのジョブは次のように書けます(当社で移行を進めている形です)。
claude -p "/mcp__note-api__daily-report 7" \
--allowedTools "Read" 2>/dev/null | tail -20
書き込みツールを許可リストから外せたので、レポート用のジョブが誤ってマガジンを書き換える可能性が、設定の段階でなくなります。書き込みが必要なジョブだけ mcp__note-api__add-magazine-note を名前で明示します。ワイルドカード mcp__note-api* はもう使いません。
はまった落とし穴
1. Resource を「モデルが勝手に読む」と思い込む
Resources は基本的にクライアント側から添付するものです。モデルが自分で取りに行くことを期待すると、何も読まれずに推測で答えが返ってきます。headless で確実に読ませたいなら、Prompt の本文で URI を指定するか、Resource 化せず読み取り専用の Tool として残してください。
2. Tool の description に「読み取り専用」と書けば安全だと思う
description はモデルへのヒントにすぎず、制約にはなりません。安全性は --allowedTools と機能の分け方で担保します。
3. Prompt の引数はすべて文字列
argsSchema に z.number() を書くとクライアントによっては失敗します。数値は文字列で受け取り、本文側で解釈するようにします(上の days がその例です)。
4. stdio サーバーで console.log を使う
stdout は JSON-RPC の通信路なので、ログを出すとプロトコルが壊れて Connected になりません。ログは console.error に出します。
5. 相対パスで claude mcp add する
launchd から起動すると作業ディレクトリが違うため、サーバーが見つからなくなります。パスは必ず絶対パスで書きます。
チェックリスト
- 副作用のある処理は Tool だけになっているか
- 読むだけの処理を Tool にしていないか(Resource にできないか)
- 同じ依頼文を2回以上書いていたら Prompt にしたか
-
cron ジョブの
--allowedToolsがワイルドカードになっていないか - Inspector で3タブすべてを確認したか
おわりに
僕の経営上の方針は「AIは実行に使う。判断は人間。」です。MCP の3機能はこの分担にそのまま当てはまります。Tools は AI に任せる実行、Resources は判断材料、Prompts は人間が決めた依頼の型です。分け方が決まると、権限設計もプロンプトの管理もかなり楽になります。
まずは手元の MCP サーバーで、「読むだけなのに Tool になっているもの」を1つ探して Resource に移すところから始めてみてください。
もっと深く学びたい方へ
MCP サーバーの設計、実装、テスト、Claude Code との連携までを体系的にまとめた書籍を出しています。
📘 『Claude Code × MCP サーバー開発入門』
Tools/Resources/Prompts の設計パターン、認証、エラーハンドリング、運用時の権限設計まで解説しています。
書籍一覧はこちら → https://zenn.dev/joinclass?tab=books