第7章 道具だけじゃない — リソースとプロンプト
📖 MCPサーバーを作って学ぶ AIに道具を持たせる入門 — はじめに・目次 ← 目次に戻る 第5章・第6章で、メモを検索し、読み、書くツールを3つ作りました。この章では、第1章で予告した残り2種類——リソースとプロンプト——を扱います。新しい機能を足す章というより、「これはツールにすべきか?」を判断できるようになる章です。
📱 Claude ではこう見える 🟢
Claude Desktop で、こんな操作をしたことはありませんか。
- 入力欄の左下にある「+」(ファイルやコネクタを追加するボタン)を押すと、繋いだコネクタの中身が一覧で出てきて、そこから選んで会話に添付できる
- 同じメニューに、そのコネクタが用意した「決まった頼み方」がメニュー項目として並んでいる
このとき、AIは何も判断していません。 選んだのは、あなたです。
一方、第5章で作った search_notes は違いました。あなたは「先週の会議のメモある?」と日本語で書いただけで、Claude が「じゃあ search_notes を使おう」と自分で決めて呼びました。
同じ「メモを扱う機能」なのに、決める人が違う。ここがこの章のすべてです。
🤔 なぜ3種類あるのか 🟢
分け方の軸は「誰が使うと決めるか」
MCPサーバーが差し出せるものは3種類ある、と第1章で書きました。表をもう一度、今度は本題の軸で出します。
| 機能 | 誰が使うと決めるか | 性格 | メモサーバーでの例 |
|---|---|---|---|
| ツール(tools) | モデル(AIが判断して呼ぶ) | 能動的。実行する | メモを検索する・書く |
| リソース(resources) | アプリケーション(ホストが選ぶ) | 受け身。読まれるだけ | メモそのものの中身 |
| プロンプト(prompts) | ユーザー(人間が明示的に選ぶ) | 定型。頼み方の型 | 「今週のメモを要約して」 |
「機能の違い」ではなく「統制主体(コントロールする人)の違い」で分かれている——これがMCPの設計です。
「何でもツールにしない」——3つの判断基準
初心者がいちばんやりがちなのは、全部ツールにしてしまうことです。実際、ツールだけでも動きます。第6章まではそれでやってきました。
では、いつ分けるべきか。判断はこの3つで足ります。
① AIに「やるかどうか」を決めさせたいか? → はい=ツール。いいえ=リソースかプロンプト。
② ただ読むだけのデータか?(実行するとどこかが変わる、ということが無いか) → はい=リソース。
③ 人間が毎回同じことを頼んでいるか? → はい=プロンプト。
具体的に当てはめると、こうなります。
| やりたいこと | どれ? | なぜ |
|---|---|---|
| キーワードでメモを探す | ツール | 何で探すかはAIが判断する。毎回違う |
| メモを保存する | ツール | 実行するとファイルが増える(=副作用がある) |
| 特定のメモの本文 | リソース | 読むだけ。どれを読むかは人/アプリが選べばいい |
| 「今週のメモを要約して」 | プロンプト | 人間が毎週まったく同じことを頼んでいる |
💡 迷ったらツールで構いません。 ツールは3種類の中でいちばん対応しているホストが多く、確実に動きます。リソースとプロンプトは「もっと良くするための引き出し」だと思ってください。ここで手が止まるくらいなら、ツールのまま先へ進むほうが健全です。
やらないとどうなる?
全部ツールにしたときに起きる、地味に困ることを挙げます。
- AIが余計なものを呼ぶ。 読むだけのデータまでツールにすると、ツール一覧が長くなります。ツールが増えるほどAIは選択を間違えます(第12章で扱います)。
- 人間が毎回同じ長文を打つ。 「今週のメモを全部読んで、決まったこととTODOに分けて…」を毎週打つのは、単純に無駄です。
- アプリ側が中身を見せられない。 リソースにしておけば、ホストはAIに渡す前に人間に「これを添付しますか?」と見せられます。ツールだとAIが呼ぶまで中身が分かりません。
🛠 こう作る 🟢
ここからコードです。第5章・第6章で作った memo プロジェクトの src/index.ts に足していきます。ツールはそのまま残してください。
⚠️ この章のAPIについて。
registerResource/registerPromptの書き方は、SDKのバージョンで変わってきた部分です。ここに載せるコードは@modelcontextprotocol/sdkの v1系(1.29.0)で実際に型チェックを通したものです。それでも、必ず公式ドキュメントで最新の書き方を確認してください。詳しくは後述の「⚠️ ハマりどころ」に書きます。
① リソース:note://{filename} でメモを読めるようにする
まず import を1つ増やします。ResourceTemplate を足すだけです。
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
そして、こう書きます。
server.registerResource(
"note",
new ResourceTemplate("note://{filename}", {
list: async () => {
if (!NOTES_DIR) return { resources: [] };
const files = await listNoteFiles(NOTES_DIR);
return {
resources: files.map((f) => ({
uri: `note://${f}`,
name: f,
mimeType: "text/markdown",
})),
};
},
}),
{
title: "メモ",
description: "notes フォルダの中の、1つのメモの本文",
mimeType: "text/markdown",
},
async (uri, { filename }) => {
if (!NOTES_DIR) {
return {
contents: [
{
uri: uri.href,
mimeType: "text/markdown",
text: "メモ置き場が設定されていません。設定ファイルの env で NOTES_DIR に絶対パスを指定してください。",
},
],
};
}
const text = await fs.readFile(path.join(NOTES_DIR, String(filename)), "utf-8");
return { contents: [{ uri: uri.href, mimeType: "text/markdown", text }] };
},
);
引数は4つです。1つずつ見ます。
第1引数 "note" — このリソースの名前です。ツール名と同じで、サーバー内で重複しなければ何でも構いません。
第2引数 new ResourceTemplate("note://{filename}", { ... }) — ここが肝です。
note://{filename}が URIテンプレート。{filename}の部分が穴になっていて、note://meeting.mdのように埋めて使います- Webの URL とよく似ていますが、
note://はあなたが勝手に決めた独自のスキームです。ブラウザで開けるものではありません listは「このテンプレートに当てはまるものを全部並べて」と聞かれたときの返事です。ここではNOTES_DIRの.mdを全部返していますlistNoteFiles(...)は第5章で書いた関数をそのまま使い回しています。 同じsrc/index.tsの中にもうあるので、fs.readdirを書き直す必要はありませんif (!NOTES_DIR)は第5章・第6章と同じガードです。process.env.NOTES_DIRは未設定のことがあるので、使う前に必ず確かめます(これが無いと"strict": trueでビルドが通りません)listは省略できません。 一覧が要らないときも{ list: undefined }と明示的に書く必要があります。「うっかり書き忘れて一覧が出ない」を防ぐための、SDKのわざとの仕様です
第3引数 — 説明のかたまりです。title は人間に見せる名前、description は説明文、mimeType は中身の種類(Markdownなので text/markdown)。
💡 ここでも AIとアプリが読むのは説明文だけです。この教材の通奏低音がまた出てきました。
第4引数 — 実際に読むときに呼ばれる関数です。
- 第1引数
uriはURLオブジェクト(文字列ではありません)。返すときはuri.hrefで文字列に戻します - 第2引数で
{ filename }と、テンプレートの穴に入っていた値を受け取れます String(filename)としているのは、この値が「文字列 または 文字列の配列」という型になっているためです。素直に文字列として使うために包んでいます- ここでも冒頭で
NOTES_DIRを確かめています。ツールのときと同じで、理由を文章で返すのが親切です - 返す形は
{ contents: [{ uri, mimeType, text }] }。ツールの{ content: [...] }と似ていますが別物です(contents、複数形)
② プロンプト:summarize_week
次はプロンプトです。「今週のメモを要約して」という定型の頼み方を、メニューに1つ用意します。
server.registerPrompt(
"summarize_week",
{
title: "今週のメモを要約",
description: "今週書いたメモを読んで、決まったこととTODOに分けて要約します",
argsSchema: {
focus: z.string().optional().describe("とくに知りたいテーマ(省略可)"),
},
},
({ focus }) => ({
messages: [
{
role: "user",
content: {
type: "text",
text: `今週のメモを search_notes で探して読み、次の形式でまとめてください。
1. 決まったこと
2. やりかけのこと
3. TODO
${focus ? `とくに「${focus}」に注目してください。` : ""}`,
},
},
],
}),
);
引数は3つです。
第1引数 "summarize_week" — プロンプトの名前。人間がメニューで選ぶときの識別子になります。
第2引数 — 説明と、受け取る引数の定義。
argsSchemaは、zod のフィールドを並べた素のオブジェクトです。ツールのinputSchemaとまったく同じ書き方で、z.object(...)で包みません.optional()を付けると省略できる引数になります- ここでも
.describe()を付けます
第3引数 — 選ばれたときに呼ばれる関数。返すのは { messages: [...] } です。
ここがプロンプトのいちばん大事な性質です。
プロンプトは「処理」ではありません。「会話の書き出しを作る」だけです。
この関数は何も実行しません。ファイルも読みません。ただ「Claudeへの言いつけの文面」を組み立てて返すだけ。それが会話に投入され、そこから先はいつものClaudeが動きます。上の例では、その文面の中で search_notes(第5章で作ったツール)を使うようAIに指示しています。
つまり——プロンプトは、ツールの使い方をあなたが台本にしたものです。
role は "user"(=ユーザーが言ったことにする)が基本です。"assistant" にすると「AIがこう言いかけた」という形も作れます。
③ どちらもビルドして再起動
いつもどおりです。ここを忘れると何も変わりません。
npm run build
そして Claude Desktop を完全終了して再起動(ウィンドウを閉じるだけでは足りません)。
🔧 プロトコル上は何が起きているか 🔧
読み飛ばしても構いませんが、第4章の Inspector を触るときに効いてきます。
第1章で「MCPは、決まった順番で決まった形の文字列をやりとりする約束事」と書きました。3種類それぞれに、対になる操作があります。
| 機能 | 一覧をもらう | 中身をもらう |
|---|---|---|
| ツール | tools/list |
tools/call |
| リソース | resources/list(決め打ちURIの一覧)resources/templates/list(テンプレートの一覧) |
resources/read |
| プロンプト | prompts/list |
prompts/get |
きれいに「一覧 → 実行/取得」の2段構えになっています。
リソースだけ一覧が2つあるのは、決め打ちのURI(note://readme.md のような固定のもの)と、テンプレート(note://{filename} のような穴あきのもの)が別扱いだからです。今回書いた ResourceTemplate は後者なので、resources/templates/list に出ます。
💡 これは実際に目で見られます。 第4章の MCP Inspector を起動すると、Resources / Prompts / Tools というタブが並んでいるはずです。あのタブが、そのままこの表に対応しています。
npx @modelcontextprotocol/inspector node /絶対パス/memo/build/index.jsClaude Desktop で見つからないときも、Inspector に出ていればサーバー側は正しい——という切り分けができます。これが次のハマりどころに直結します。
⚠️ ハマりどころ 🟢
①【最重要】リソースは、AIが勝手に読んでくれるわけではない
これがこの章で断トツにハマるところです。
リソースを登録して、Claude に「メモを読んで」と言っても——たぶん何も起きません。壊れているわけではありません。それが仕様どおりです。
もう一度、表の一行目を思い出してください。
リソースの統制主体は「アプリケーション」です。モデルではありません。
リソースは「ここにデータがあります。使いたければどうぞ」と置いてあるだけの、受け身のデータです。それを取りに行くかどうかを決めるのはホスト(アプリ)であって、AIではありません。
ホストがどう扱うかは、アプリごとに自由です。仕様はUIを決めていません。
- 人間が「+」メニューから選んで添付する(Claude Desktop はこの形)
- アプリが会話の内容から自動で選んで入れる
- 検索して関連するものだけ入れる
そして重要なのは、ホストによっては、リソースにまったく対応していないこともあるということです。ツールにしか対応していないホストは珍しくありません。
✅ 対処:まず Inspector の Resources タブを見てください。
- Inspector に出ている → サーバーは正しい。 ホスト側の扱いの問題です。Claude Desktop なら「+」メニューを探してください
- Inspector にも出ていない → サーバー側の問題。 ビルド忘れか、
listの書き忘れか、コードのエラーです「AIに読ませたい」が目的なら、素直に
read_noteツール(第6章)を使わせるのが正解です。リソースとツールはどちらか一方ではなく、両方あってよいものです。実際、多くのサーバーが同じデータをツールとリソースの両方で出しています。
②「プロンプト、どこに出るの?」問題
プロンプトを登録したのに見つからない——これも定番です。
Claude Desktop では、入力欄の左下にある「+」(ファイルやコネクタを追加するメニュー)の中に、繋いだサーバーごとのプロンプトが並びます。チャット欄に /summarize_week と打っても出てきません(そういうスラッシュコマンドとは別の仕組みです)。
⚠️ ただし、UIの位置はアプリのバージョンで変わります。 MCPの仕様はUIを規定していないので、「ここにある」と断言できません。見つからないときは、まず Inspector の Prompts タブで確認してください。そこに出ていればサーバーは正しく、あとは探す場所の問題です。
③ list: undefined を書き忘れる
ResourceTemplate の第2引数を丸ごと省略するとエラーになります。一覧が要らないときも、こう書いてください。
new ResourceTemplate("note://{filename}", { list: undefined })
「書き忘れ」と「意図的に無し」を区別するための、SDKのわざとの設計です。
④ content と contents を間違える
地味に痛い間違いです。
| どこ | 正しいキー |
|---|---|
| ツールの戻り値 | content(単数)[{ type: "text", text }] |
| リソースの戻り値 | contents(複数)[{ uri, mimeType, text }] |
| プロンプトの戻り値 | messages [{ role, content: { type, text } }] |
3つとも違います。エディタの補完と型チェックに頼るのがいちばん確実です。
⑤ uri は文字列ではなく URL オブジェクト
リソースの読み取り関数が受け取る uri は、URL オブジェクトです。そのまま文字列として繋ごうとすると変な結果になります。返すときは uri.href を使ってください。
⑥ リソースにもパスの危険がある
note://{filename} の {filename} は、外から来る値です。第6章の read_note とまったく同じ問題を抱えています。
note://../../.ssh/id_rsa のようなURIを投げられたら、どうなるでしょうか。上のコードは、そのまま読んでしまいます。
これは第9章で、ツールとリソースの両方まとめて塞ぎます。 いまは「リソースも同じ穴を持っている」とだけ覚えておいてください。ツールだけ守っても意味がありません。
⑦ ビルドと再起動を忘れる
もはや恒例ですが、「新しく足した機能が出てこない」の原因の大半はこれです。npm run build を実行し、Claude Desktop を完全終了して再起動してください。
🤖 AIに頼むなら 🟢
この章は、AIがいちばん古い書き方を出してくる章です。 理由がはっきりしています。
registerResource / registerPrompt には、server.resource() / server.prompt() という古い書き方がありました。いまも動きますが、SDKの型定義には @deprecated(非推奨) と明記されています。ネット上の記事も、AIの学習データも、まだ古い書き方だらけです。
さらにややこしいことに、SDKには新しい系統(パッケージが分割され、書き方も変わったもの)も登場しています。AIはこれらを混ぜて出してくることがあります。
なので、頼むときはバージョンまで指定してください。
「
@modelcontextprotocol/sdk(v1系)のregisterResourceとregisterPromptで書いて。非推奨のserver.resource()/server.prompt()は使わないで」 「argsSchemaは zod のフィールドを並べた素のオブジェクトで。z.object()では包まないで」
そして——出てきたコードを鵜呑みにしないでください。 この章のハマりどころは、ほぼ全部「型が合わない」形で現れます。つまり、
npm run buildが通るかどうかが、最初の関門です。
型チェックが通らないコードは、AIが何と言おうと間違っています。逆に言えば、ビルドが通れば形は合っているということ。TypeScript を使っている一番の恩恵がここです。
それでも不安なら、公式ドキュメントを開いて見比べてください。1分で済みます。
📗 ことばメモ
| ことば | よみ | 意味 |
|---|---|---|
| リソース | — | サーバーが差し出す読むだけのデータ。使うかどうかはアプリが決める |
| プロンプト | — | 人間が選ぶ定型の頼み方。会話の書き出しを作るだけで、何も実行しない |
| 統制主体 | とうせいしゅたい | 「誰が使うと決めるか」。ツール=モデル/リソース=アプリ/プロンプト=ユーザー |
| URI | ユーアールアイ | ものの居場所を表す文字列。note://meeting.md など。URLの仲間 |
| URIテンプレート | — | note://{filename} のように穴が空いたURI。埋めて使う |
| スキーム | — | URIの :// の前の部分(http、file、note など)。独自に決めてよい |
| MIMEタイプ | マイムタイプ | 中身の種類を表す文字列。text/markdown、application/json など |
| deprecated | デプリケイテッド | 非推奨。まだ動くが、新しく書くときは使わないほうがよい印 |
➡️ 次へ
これで、MCPサーバーが差し出せる3種類すべてが揃いました。第1章で配った地図の、最後の空白が埋まったことになります。
そして、ここまでずっとあなたのパソコンの中で、stdio(標準入出力)という1本の道だけを使ってきました。Claude Desktop があなたのプログラムを起動し、文字をやりとりし、終了させる——という形です。
でも、もしサーバーをインターネットの向こうに置きたいとしたら? 複数の人に使わせたいとしたら? プログラムを起動してもらう、というやり方はもう使えません。
第8章 HTTPで動かす — もう1つの繋ぎ方 では、もう1つのトランスポート Streamable HTTP を扱います。そして同時に、いままで考えなくてよかった問題が一気に押し寄せます——誰でもアクセスできてしまう、という問題です。
関連ページ
- MCPサーバーを作って学ぶ AIに道具を持たせる入門 — はじめに・目次 — 目次
- 第1章 「コネクタ」の正体 — MCPは道具の共通規格 — 3種類の予告はここ
- 第4章 デバッグの作法 — 見えない通信を覗く — Inspector の Resources / Prompts タブ
- 第6章 道具を増やす — 読む・書く —
read_note/write_note - 第9章 安全 — 書ける道具は、危ない道具 — リソースのパスもここで塞ぎます