第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つになりました。
filenameとcontent。どちらも必須です(.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段構えです。
.describe()に「拡張子 .md を含める。例: 会議-0712.md」と書く(予防)- コード側で、付いていなければ付ける(
filename.endsWith(".md") ? filename : \${filename}.md``) - それでも見つからなければ、「ファイル名を確認してください」と文章で返す(回復)
💡 「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 に出てこない
順番に確認してください。
npm run buildを実行したか(TypeScriptのままでは動きません)- Claude Desktop を完全終了して再起動したか(ウィンドウを閉じるだけでは不十分)
console.logを書いていないか(第3章。1行でサーバーが丸ごと壊れます)- それでも駄目なら 第4章 の切り分け手順へ
⑥ inputSchema を z.object() で包んでしまった
// ❌ 包まない
inputSchema: z.object({ filename: z.string() })
// ✅ フィールドを並べるだけ
inputSchema: { filename: z.string().describe("…") }
AIに書かせると、高確率でこの間違いをします(z.object で包む書き方が世の中に多いため)。見つけたら直してください。
🤖 AIに頼むなら 🟢
この章のコードをAIに書かせるときの、実用的なプロンプトです。
「
@modelcontextprotocol/sdkのMcpServer.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点だけ目視で確認してください。
inputSchemaがz.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章で「書ける道具の危なさ」を扱っています