第6章 道具を増やす — 読む・書く

📖 MCPサーバーを作って学ぶ AIに道具を持たせる入門 — はじめに・目次 ← 目次に戻る 第5章で作った memo サーバーには、検索する道具が1つだけありました。この章で 読む道具書く道具を足して、3つにします。道具が3つ揃うと、Claude はそれらを勝手につなげて使いはじめます


📱 Claude ではこう見える 🟢

第5章の状態で Claude に「先週の打ち合わせのメモある?」と聞くと、こう返ってきます。

会議-0712.md会議-0705.md が見つかりました。中身が必要でしたら、ファイルを開いていただけますか?

——見つけたのに、読めない。歯がゆいですよね。

これは Claude がサボっているのではありません。あなたが「読む道具」を渡していないからです。第1章で書いたとおり、AIが知っているのはあなたが用意した道具の説明文だけ。メニューに載っていない料理は、頼めません。

この章が終わると、こう変わります。

会議-0712.md を読みました。決まったのは3点で……。この内容をまとめて 打ち合わせ-0719.md として保存しておきました。」

検索する → 読む → 書く。3つの道具が、1回の依頼のなかで連鎖します。ここがMCPの面白いところです。


🤔 なぜ道具を分けるのか 🟢

「最初から search_notes が中身も全部返せばいいのでは?」と思ったかもしれません。もっともな疑問です。分ける理由は2つあります。

① 全部返すと、あっという間に読みきれなくなる

検索が10件ヒットして、それぞれ5000字あったら、5万字がまるごと Claude に流れ込みます。AIが一度に読める量には限りがあり、しかも長いほど大事なところを見落とします

だから MCP の道具は、まず軽い情報だけ返して、必要になったら詳しく取りに行くという形にします。検索は「ファイル名と抜粋」だけ。全文が要るときだけ read_note を呼ぶ。人間が図書館でやっていることと同じです。

② AIに「選ぶ余地」を残せる

道具が分かれていると、AIは状況に応じて組み合わせられます。

  • 「メモある?」→ 検索だけで終わる
  • 「あのメモの3番目の項目なんだっけ」→ 検索 → 読む
  • 「まとめて保存して」→ 検索 → 読む → 書く

1つの巨大な道具では、この使い分けができません。小さくて役割のはっきりした道具を並べるほうが、AIは賢く振る舞います。

⚠️ ただし、やりすぎると逆効果です。道具を30個も並べると、AIはどれを使えばいいか分からなくなり、見当違いのものを呼びはじめます。この問題は第12章で正面から扱います。この教材では3つで止めます。


🛠 こう作る 🟢

準備:第5章のコードを思い出す

memo/src/index.ts は、いまこうなっているはずです(第5章の完成形。search_notes の中身は省略します)。

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import fs from "node:fs/promises";
import path from "node:path";

const NOTES_DIR = process.env.NOTES_DIR;

const server = new McpServer({ name: "memo", version: "1.0.0" });

// メモ置き場にある .md ファイルの名前を集める(第5章で書いたもの)
async function listNoteFiles(dir: string): Promise<string[]> {
  const entries = await fs.readdir(dir, { withFileTypes: true });
  return entries
    .filter((entry) => entry.isFile() && entry.name.endsWith(".md"))
    .map((entry) => entry.name);
}

server.registerTool(
  "search_notes",
  {
    description:
      "ユーザーのローカルのメモ(.mdファイル)を全文検索する。ユーザーが自分の過去のメモ・議事録・日記・書きかけの文章について尋ねたときに使う。ヒットしたファイル名と、その前後の抜粋を返す。",
    inputSchema: {
      query: z
        .string()
        .describe(
          "メモの中から探す検索語。日本語でよい。文章ではなく、1〜2語のキーワードにすること(例: 「打ち合わせ」「リリース日」)",
        ),
    },
  },
  async ({ query }) => {
    if (!NOTES_DIR) {
      return {
        content: [
          {
            type: "text",
            text: "メモ置き場が設定されていません。設定ファイルの env で NOTES_DIR に絶対パスを指定してください。",
          },
        ],
      };
    }

    // …第5章で書いた中身…
    return { content: [{ type: "text", text: "…結果…" }] };
  },
);

ここに追記していきます。ファイルを作り直す必要はありません。

⚠️ NOTES_DIR の型に注意してください。 process.env.NOTES_DIR は「文字列 または 未設定(undefined)」です。だから第5章では、ハンドラの先頭で if (!NOTES_DIR)確かめてから使いました。この章で足す道具でも、同じガードを毎回いちばん最初に書きます。これを省いて path.join(NOTES_DIR, …) と書くと、tsconfig.json"strict": true に引っかかってビルドが通りませんTS2345)。 ?? "" で空文字を入れて逃げる書き方もありますが、やめてください。空文字を渡すと path.join("", "a.md") は「サーバーが起動した場所」を基準にしてしまい、どこに書き込まれるか分からなくなります。この危なさは第9章で改めて扱います。


入力スキーマの書き方 🟢

新しい道具を書く前に、inputSchema(受け取るものの決めごと)の書き方を整理します。ここが第6章のいちばん大事なところです。

inputSchema には、zod(ゾッド) というライブラリで「どんな値を受け取るか」を書きます。よく使うのは次の5つです。

書き方 意味
z.string() 文字列を受け取る
z.number() 数値を受け取る
z.string().optional() 省略してよい文字列
z.enum(["a", "b"]) 決められた選択肢のどれか
.describe("…") AI向けの説明文(後述。最重要)

書き方の例です。

inputSchema: {
  filename: z.string().describe("読みたいメモのファイル名。例: 会議-0712.md"),
  maxLines: z.number().optional().describe("先頭から何行まで返すか。省略時は全文"),
  format: z.enum(["text", "markdown"]).optional().describe("返す形式。既定は markdown"),
},

1行ずつ見ます。

  • filename: — 引数の名前。この名前がそのままハンドラの { filename } になります
  • z.string() — 文字列である、という決めごと
  • .optional()これが付いていない引数は「必須」。付いていれば省略できる
  • .describe("…") — この引数が何なのかの説明文

⚠️ inputSchema は zod のフィールドを並べた素のオブジェクトです。z.object(...) で包まないでください。 ここは間違えやすく、包むとツールが正しく登録されません。 また、zod は v3 を使ってください(第5章でインストール済み)。v4 は書き方が違います。

必須と任意の違いは、AIの動きを変える

.optional() の有無は、単なる型の話ではありません。AIの振る舞いが変わります。

  • 必須の引数:AIは、値が分からなければあなたに聞いてきます(「どのファイルですか?」)
  • 任意の引数:AIは、必要と判断したときだけ付けてきます

つまり .optional() は、「これは無くても道具は動く」という意思表示です。無いと動かないものを任意にすると、AIは省略してきて、道具が壊れます。迷ったら必須にしてください。

.describe() は飾りではありません 🟢

これだけは、太字で覚えてください。

AIがあなたの引数について知っているのは、.describe() に書いた文だけです。

第2章を思い出してください。天気サーバーの get_alerts"Two-letter state code"(2文字の州コード)と書いてあったから、Claude は「東京」を渡さなかったのでした。逆に言えば——書いてないことは、AIには一生分かりません。

.describe() の良し悪しで、こんなに変わります。

説明文 AIの動き
.describe("ファイル名") 会議 会議.txt /Users/…/会議-0712.md など、バラバラな値を渡してくる
.describe("読みたいメモのファイル名。拡張子 .md を含める。例: 会議-0712.md") ほぼ確実に 会議-0712.md の形で渡してくる

書くのは1行。効果は絶大です。 例(例: 会議-0712.md)を1つ入れておくだけで、精度がはっきり上がります。


道具その2:read_note — 1ファイルの全文を返す 🟢

では書きます。search_notes の下に追記してください。

server.registerTool(
  "read_note",
  {
    description: "メモを1つ選んで、その全文を返す。search_notes で見つけたファイル名を渡す",
    inputSchema: {
      filename: z
        .string()
        .describe("読みたいメモのファイル名。拡張子 .md を含める。例: 会議-0712.md"),
    },
  },
  async ({ filename }) => {
    if (!NOTES_DIR) {
      return {
        content: [
          {
            type: "text",
            text: "メモ置き場が設定されていません。設定ファイルの env で NOTES_DIR に絶対パスを指定してください。",
          },
        ],
      };
    }

    const safeName = filename.endsWith(".md") ? filename : `${filename}.md`;
    const filePath = path.join(NOTES_DIR, safeName);

    try {
      const text = await fs.readFile(filePath, "utf-8");
      return { content: [{ type: "text", text }] };
    } catch {
      return {
        content: [
          {
            type: "text",
            text: `「${safeName}」というメモは見つかりませんでした。ファイル名を確認してください。search_notes で検索すると、正しいファイル名が分かります。`,
          },
        ],
      };
    }
  },
);

順に読みます。

  • "read_note"道具の名前。スペックどおり。変えないでください
  • description:道具そのものの説明。ここに「search_notes で見つけたファイル名を渡す」と使い方まで書いているのがポイントです。AIはこれを読んで、検索 → 読むの順番を思いつきます
  • filename: z.string().describe(...) — 必須の文字列。例つきの説明
  • if (!NOTES_DIR) { … }第5章と同じガード。メモ置き場が設定されていなければ、その旨を文章で返して終わります。これを書いておかないと、この下の path.join(NOTES_DIR, …) でビルドが通りません
  • filename.endsWith(".md") ? … : ….md が付いていなければ、こちらで付けてあげる。AIは付けたり付けなかったりします(後述のハマりどころ)
  • path.join(NOTES_DIR, safeName) — メモ置き場のパスと繋げる
  • fs.readFile(filePath, "utf-8") — ファイルを文字列として読む
  • return { content: [{ type: "text", text }] }戻り値の形は必ずこれ
  • catch { … }ここが本題です。次の節で詳しくやります

⭐ エラーの返し方 — throw しない 🟢

この章でいちばん覚えて帰ってほしいのが、ここです。

ふつうのプログラムなら、ファイルが無ければ例外を投げます(throw)。MCPの道具では、それをしません。

// ❌ やってはいけない
async ({ filename }) => {
  const text = await fs.readFile(path.join(NOTES_DIR, filename), "utf-8");
  //  ↑ ファイルが無いと、ここで例外が飛ぶ
  return { content: [{ type: "text", text }] };
}
// ✅ こうする
try {
  const text = await fs.readFile(filePath, "utf-8");
  return { content: [{ type: "text", text }] };
} catch {
  return {
    content: [{ type: "text", text: "「…」というメモは見つかりませんでした。ファイル名を確認してください。" }],
  };
}

なぜ? AIは「文章」しか読めないからです

道具を呼ぶのはAIです。そしてAIが受け取れるのは、text に入れた文字列だけです。

  • throw した場合:AIのところには「エラーが起きました」程度の情報しか届きません。何が悪かったのか分からないので、まったく同じ呼び出しをもう一度試すか、諦めてあなたに謝るかのどちらかになります
  • 文章で返した場合:AIは「あ、ファイル名が違ったのか。じゃあ先に search_notes で正しい名前を調べよう」と、自分で次の手を考えられます

つまり——

エラーメッセージは、AIへの指示書です。 「失敗した」だけでなく、「なぜ失敗したか」と「次に何をすればいいか」まで書いてください。

さきほどのコードの文面を、もう一度見てください。

「会議.md」というメモは見つかりませんでした。ファイル名を確認してください。
search_notes で検索すると、正しいファイル名が分かります。

3つ入っています。①何が起きたか ②何を確認すべきか ③次に呼ぶべき道具。これを読んだ Claude は、高い確率で自分で search_notes を呼び直して、正しいファイル名にたどり着きます。

道具が親切だと、AIは賢くなる。 これは第2章から続いている「AIが読むのは説明文だけ」の、実践編です。

💡 人間向けのエラーメッセージとは書き方が違います。 人間なら「ENOENT: no such file or directory」で調べがつきますが、AIには「じゃあどうすればいいの?」が伝わりません。次の一手を書く——これがコツです。


道具その3:write_note — メモを保存する 🟢

最後の道具です。同じく追記してください。

server.registerTool(
  "write_note",
  {
    description: "新しいメモを保存する。すでに同じ名前のメモがある場合は上書きされる",
    inputSchema: {
      filename: z
        .string()
        .describe("保存するファイル名。拡張子 .md を含める。例: 打ち合わせ-0719.md"),
      content: z.string().describe("メモの本文。Markdown 形式"),
    },
  },
  async ({ filename, content }) => {
    if (!NOTES_DIR) {
      return {
        content: [
          {
            type: "text",
            text: "メモ置き場が設定されていません。設定ファイルの env で NOTES_DIR に絶対パスを指定してください。",
          },
        ],
      };
    }

    const safeName = filename.endsWith(".md") ? filename : `${filename}.md`;
    const filePath = path.join(NOTES_DIR, safeName);

    try {
      await fs.mkdir(NOTES_DIR, { recursive: true });
      await fs.writeFile(filePath, content, "utf-8");
      return {
        content: [{ type: "text", text: `「${safeName}」を保存しました。` }],
      };
    } catch (error) {
      return {
        content: [
          {
            type: "text",
            text: `「${safeName}」の保存に失敗しました。理由: ${String(error)}。ファイル名に使えない文字が含まれていないか確認してください。`,
          },
        ],
      };
    }
  },
);

新しいところだけ見ます。

  • 引数が2つになりました。filenamecontent。どちらも必須です(.optional() が付いていない)
  • if (!NOTES_DIR) { … }ここでも先頭にガード。書き込む道具なので、置き場が決まっていないまま先に進ませないことが、とくに大事です
  • description に「上書きされる」と正直に書いています。これは重要です。ここに書いておくと、Claude は保存前に「上書きしますがよろしいですか?」と確認してくることが増えます
  • fs.mkdir(NOTES_DIR, { recursive: true })メモ置き場のフォルダが無ければ作るrecursive: true は「途中のフォルダもまとめて作る/既にあってもエラーにしない」という意味です。これが無いと、フォルダが無いときに保存が失敗します
  • fs.writeFile(filePath, content, "utf-8") — 書き込み
  • catch (error) — ここでも throw せず、理由つきの文章で返します

ビルドして、Claude Desktop を完全終了 → 再起動してください。

npm run build

⚠️ ビルド忘れと再起動忘れは、この教材でいちばん多い「動かない」の原因です。道具を足したのに Claude に出てこないときは、まずこの2つを疑ってください。


🚨 書ける道具は、危ない道具 🟢

ここで一度、手を止めてください。

あなたはいま、AIにファイルを書き込む力を渡しました。これは、これまでの道具とは質が違います。読むだけの道具は、最悪でも「余計なものを見られた」で済みます。書ける道具は——消えます。

いまの write_note には、こんな穴があります。

  • 上書きし放題会議-0712.md という名前で保存すると、元のメモは何の確認もなく消えます。AIが既存のファイル名を「ちょうどいい名前だ」と思ってしまったら、それで終わりです
  • 変な場所に書けるfilename../../デスクトップ/大事な資料.md のような値が来たら、path.join はそれを素直に繋いでしまい、メモ置き場の外に書き込まれます
  • 確認がない:AIが呼んだ瞬間に、もう書かれています

⚠️ いまの段階では、NOTES_DIR に「消えても泣かないフォルダ」を指定してください。 練習用に新しく notes フォルダを作って、そこだけを渡すのが安全です。仕事の資料が入ったフォルダを繋ぐのは、まだやめておきましょう。

安心してください。この穴は塞ぎます。ただし、やることが多いのでまるまる1章使います

📌 第9章 安全 — 書ける道具は、危ない道具 で、 ①path.resolve を使ったフォルダの封じ込めNOTES_DIR の外には絶対に書けなくする) ②上書き前の確認 ③そもそも権限を最小にする考え方 を扱います。それまでは、大事なフォルダを繋がないでください。

これは第3弾(CLIエージェント)の第9章と同じ話です。AIに手を持たせた瞬間に、安全の話が始まる。 道具を作る人の宿命だと思ってください。


🎬 3つ揃うと、こうなる 🟢

道具が3つになりました。実際に Claude に頼んでみましょう。

あなた: 今日の打ち合わせのメモを作って。先週の会議の続きだから、前回の内容を踏まえてね。

このとき、Claude の中ではこういうことが起きます

① AIが考える  「先週の会議のメモを探さないと」
   ↓
   🔧 search_notes({ query: "会議" })
   ← 「会議-0712.md(抜粋:…次回までに見積もりを…)」
       「会議-0705.md(抜粋:…体制について…)」

② AIが考える  「7/12 のほうが新しい。中身を読もう」
   ↓
   🔧 read_note({ filename: "会議-0712.md" })
   ← (全文)決定事項3点、宿題2件、次回 7/19 …

③ AIが考える  「内容が分かった。今日のメモの下書きを作って保存しよう」
   ↓
   🔧 write_note({
        filename: "打ち合わせ-0719.md",
        content: "# 打ち合わせ 2026-07-19\n\n## 前回からの持ち越し\n- 見積もり提出…"
      })
   ← 「「打ち合わせ-0719.md」を保存しました。」

④ AIがあなたに答える
   「前回(7/12)の宿題を引き継いだ形で下書きを作り、
    打ち合わせ-0719.md として保存しました。」

あなたが指示したのは1文だけです。「検索して」「読んで」「保存して」とは一言も言っていません。それでも3つの道具が順番に呼ばれました。

なぜこうなるのか——AIが、道具の説明文を読んで、自分で計画を立てたからです。

  • search_notes の説明に「見つかったファイル名と抜粋を返す」とある → まず探す
  • read_note の説明に「search_notes で見つけたファイル名を渡す」とある → 繋がりが分かる
  • write_note の説明に「新しいメモを保存する」とある → 最後に保存する

説明文が、AIにとっての手順書になっているわけです。第1章で「プログラミングの上手さより、説明の上手さが効く」と書いた意味が、ここで実感できるはずです。

🔧 実験してみてください。 read_note の説明文から「search_notes で見つけたファイル名を渡す」の一文を消して、ビルドし直してみましょう。Claude が検索を飛ばして、いきなり当てずっぽうのファイル名で read_note を呼びはじめることがあります。一文の差です。


⚠️ ハマりどころ 🟢

.md を付ける/付けない問題

いちばん多い失敗です。AIは 会議-0712 と渡してきたり、会議-0712.md と渡してきたりします。日によって変わります。

対処は3段構えです。

  1. .describe() に「拡張子 .md を含める。例: 会議-0712.md」と書く(予防)
  2. コード側で、付いていなければ付けるfilename.endsWith(".md") ? filename : \${filename}.md``)
  3. それでも見つからなければ、「ファイル名を確認してください」と文章で返す(回復)

💡 「AIがちゃんと渡してくれるはず」に賭けないでください。説明文で予防し、コードで吸収し、エラー文で回復させる。 この3枚重ねが、壊れない道具の基本形です。

② 存在しないファイルを読もうとする

AIは、記憶や推測でファイル名を作ってしまうことがあります(議事録.md など、それっぽいけど存在しない名前)。

throw しなければ、これは事故になりません。 「見つかりませんでした。search_notes で検索すると分かります」と返せば、AIは検索し直します。エラーが自己修復のきっかけになる——これが try/catch で文章を返す設計のご褒美です。

③ フォルダが無くて保存に失敗する

NOTES_DIR に指定したフォルダがまだ存在しないと、fs.writeFile は失敗します。上のコードで fs.mkdir(NOTES_DIR, { recursive: true }) を先に呼んでいるのは、そのためです。

recursive: true を忘れると、フォルダが既にある場合にエラーになります。付け忘れに注意してください。

④ 同じ名前で上書きしてしまった

やってしまってから気づきます。 これが write_note の怖さです。

いまできる予防策は3つ。

  • NOTES_DIR を、練習用の空フォルダにしておく
  • ファイル名に日付を入れる運用にする(打ち合わせ-0719.md
  • メモ置き場を git 管理下に置くgit init して、ときどき git commit。上書きされても戻せます)

根本的な対策は第9章です。

⑤ ツールを足したのに Claude に出てこない

順番に確認してください。

  1. npm run build を実行したか(TypeScriptのままでは動きません)
  2. Claude Desktop を完全終了して再起動したか(ウィンドウを閉じるだけでは不十分)
  3. console.log を書いていないか(第3章。1行でサーバーが丸ごと壊れます)
  4. それでも駄目なら 第4章 の切り分け手順へ

inputSchemaz.object() で包んでしまった

// ❌ 包まない
inputSchema: z.object({ filename: z.string() })

// ✅ フィールドを並べるだけ
inputSchema: { filename: z.string().describe("") }

AIに書かせると、高確率でこの間違いをしますz.object で包む書き方が世の中に多いため)。見つけたら直してください。


🤖 AIに頼むなら 🟢

この章のコードをAIに書かせるときの、実用的なプロンプトです。

@modelcontextprotocol/sdkMcpServer.registerTool を使って、read_note(引数 filename: string)と write_note(引数 filename: string, content: string)を追加してください。条件: ・inputSchema は zod のフィールドを並べた素のオブジェクトにすること(z.object() で包まない) ・zod は v3 ・全フィールドに .describe() を日本語で、例つきで書くこと ・エラーは throw せず{ content: [{ type: 'text', text: '…' }] } の形で、次に何をすればいいかを含む日本語の文章として返すこと ・console.log は絶対に使わない(stdio が壊れるため)。ログは console.error

長いですが、この長さが必要です。とくに後半3つは、指定しないとほぼ守られません。

生成されたコードは、次の4点だけ目視で確認してください。

  • inputSchemaz.object() で包まれていないか
  • throw が混ざっていないか
  • console.log が無いか
  • import パスの末尾に .js が付いているか(…/server/mcp.js

⚠️ MCPは仕様の動きが速い分野です。 AIは古い書き方(旧SDKのAPI名など)を自信満々に出してくることがあります。おかしいと思ったら、modelcontextprotocol.io の最新ドキュメントを見てください。AIの記憶より、公式のほうが新しいです。


📗 ことばメモ

ことば よみ 意味
inputSchema インプットスキーマ 道具が受け取る引数の決めごと。zod のフィールドを並べた素のオブジェクトで書く
zod ゾッド 値の形を宣言するライブラリ。この教材では v3 を使う
.describe() ディスクライブ 引数の説明文。AIが読む唯一の手がかり。例を入れると精度が上がる
必須/任意 .optional()無ければ必須。必須の引数は、AIが分からなければ人間に聞いてくる
z.enum() イーナム 決められた選択肢のどれか、という指定
throw スロー 例外を投げること。MCPの道具ではやらない。AIに何も伝わらないため
recursive: true リカーシブ fs.mkdir の指定。途中のフォルダもまとめて作り、既にあってもエラーにしない
上書き うわがき 同名で保存して、元の中身を消すこと。write_note の最大の危険

➡️ 次へ

道具が3つ揃い、検索する → 読む → 書くが連鎖するようになりました。ここまでで、実用的な自分専用サーバーとしてはもう十分に使えます

ただし、MCPサーバーが差し出せるものはツールだけではありません。第1章で表にした3種類——ツール・リソース・プロンプトを覚えていますか。残りの2つが、まだ手つかずです。

第7章 道具だけじゃない — リソースとプロンプト では、この3種類を誰が使うと決めるのかという切り口で整理します。ツールはAIが決める、リソースはアプリが決める、プロンプトは人間が決める——この違いが分かると、「これはツールにすべきか、リソースにすべきか」を自分で判断できるようになります。

そして、書き込みの怖さは第9章まで持ち越しです。それまでは、大事なフォルダを NOTES_DIR に指定しないでください。

関連ページ

  • MCPサーバーを作って学ぶ AIに道具を持たせる入門 — はじめに・目次 — 目次
  • 自作CLIエージェントで学ぶ AIエージェント開発入門 — はじめに・目次 — 第3弾。第9章で「書ける道具の危なさ」を扱っています