第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引数 uriURL オブジェクト(文字列ではありません)。返すときは 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つあるのは、決め打ちのURInote://readme.md のような固定のもの)と、テンプレートnote://{filename} のような穴あきのもの)が別扱いだからです。今回書いた ResourceTemplate は後者なので、resources/templates/list に出ます

💡 これは実際に目で見られます。 第4章の MCP Inspector を起動すると、Resources / Prompts / Tools というタブが並んでいるはずです。あのタブが、そのままこの表に対応しています。

npx @modelcontextprotocol/inspector node /絶対パス/memo/build/index.js

Claude 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のわざとの設計です。

contentcontents を間違える

地味に痛い間違いです。

どこ 正しいキー
ツールの戻り値 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系)の registerResourceregisterPrompt で書いて。非推奨の server.resource() / server.prompt() は使わないで」 argsSchema は zod のフィールドを並べた素のオブジェクトで。z.object() では包まないで」

そして——出てきたコードを鵜呑みにしないでください。 この章のハマりどころは、ほぼ全部「型が合わない」形で現れます。つまり、

npm run build が通るかどうかが、最初の関門です。

型チェックが通らないコードは、AIが何と言おうと間違っています。逆に言えば、ビルドが通れば形は合っているということ。TypeScript を使っている一番の恩恵がここです。

それでも不安なら、公式ドキュメントを開いて見比べてください。1分で済みます。


📗 ことばメモ

ことば よみ 意味
リソース サーバーが差し出す読むだけのデータ。使うかどうかはアプリが決める
プロンプト 人間が選ぶ定型の頼み方。会話の書き出しを作るだけで、何も実行しない
統制主体 とうせいしゅたい 「誰が使うと決めるか」。ツール=モデル/リソース=アプリ/プロンプト=ユーザー
URI ユーアールアイ ものの居場所を表す文字列。note://meeting.md など。URLの仲間
URIテンプレート note://{filename} のようにが空いたURI。埋めて使う
スキーム URIの :// の前の部分(httpfilenote など)。独自に決めてよい
MIMEタイプ マイムタイプ 中身の種類を表す文字列。text/markdownapplication/json など
deprecated デプリケイテッド 非推奨。まだ動くが、新しく書くときは使わないほうがよい印

➡️ 次へ

これで、MCPサーバーが差し出せる3種類すべてが揃いました。第1章で配った地図の、最後の空白が埋まったことになります。

そして、ここまでずっとあなたのパソコンの中で、stdio(標準入出力)という1本の道だけを使ってきました。Claude Desktop があなたのプログラムを起動し、文字をやりとりし、終了させる——という形です。

でも、もしサーバーをインターネットの向こうに置きたいとしたら? 複数の人に使わせたいとしたら? プログラムを起動してもらう、というやり方はもう使えません。

第8章 HTTPで動かす — もう1つの繋ぎ方 では、もう1つのトランスポート Streamable HTTP を扱います。そして同時に、いままで考えなくてよかった問題が一気に押し寄せます——誰でもアクセスできてしまう、という問題です。

関連ページ