はじめに
前回の記事で、AIエージェントの長期記憶を「Markdownファイル+git」で実装する方法を書きました。今回はその続きで、記憶ストアをMCP(Model Context Protocol)サーバーとして切り出す設計を扱います。
MCP化する動機は1つです。私たちは複数のAIエージェント(ブログ担当・QC担当・デプロイ担当)を運用していて、記憶を特定のクライアント実装に閉じ込めたくなかった。MCPサーバーにしておけば、Claude Code、Claude Desktop、自作のAPIクライアント、どこからでも同じ記憶を読み書きできます。モデルやツールを乗り換えても記憶は資産として残ります。
この記事では、6ヶ月の実運用で淘汰されて残った3つの設計パターンを、動くTypeScriptコードで解説します。
- パターン1: 1ファイル1事実ストア
- パターン2: 状態機械つき記憶
- パターン3: 引き継ぎ台帳(handoff ledger)
共通の骨格: MCPサーバー最小構成
まず土台です。公式SDK @modelcontextprotocol/sdk を使います。
mkdir agent-memory-mcp && cd agent-memory-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node
// src/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import * as fs from "node:fs/promises";
import * as path from "node:path";
const MEMORY_DIR = process.env.MEMORY_DIR ?? "./memory";
const server = new McpServer({
name: "agent-memory",
version: "1.0.0",
});
// ここに各パターンのツールを登録していく
const transport = new StdioServerTransport();
await server.connect(transport);
Claude Code側の登録は1行です。
claude mcp add agent-memory -e MEMORY_DIR=/srv/team-memory -- node dist/index.js
ここで最初の運用知見をひとつ。ストレージは共有ディレクトリ上のプレーンテキストにする。複数エージェントが同じ記憶を見るとき、DBサーバーを立てるより「全員が読めるディレクトリのMarkdown」のほうが、障害調査もレビューもgit差分でできて圧倒的に楽でした。
パターン1: 1ファイル1事実ストア
最初のパターンは記憶の最小単位の設計です。原則は「1ファイル = 1事実」。
アンチパターンは「learnings.md に追記し続ける」方式です。私たちも最初これをやって、3週間で誰も全文を読まない2,000行のファイルが生まれました。1ファイル1事実に分割すると、更新・廃止・検索・レビューの単位が揃います。
const StoreInput = {
name: z.string().regex(/^[a-z0-9-]+$/).describe("kebab-caseのスラッグ"),
description: z.string().max(200).describe("1行サマリ。検索はこの行に当たる"),
type: z.enum(["feedback", "project", "reference"]),
body: z.string().describe("本文。Why と How to apply を含めること"),
};
server.registerTool(
"memory_store",
{
title: "記憶を保存",
description:
"再利用価値のある事実を保存する。修正指示・確定した設計判断・再発防止策が対象。" +
"一時的な会話内容や、コードを読めば分かることは保存しない。",
inputSchema: StoreInput,
},
async ({ name, description, type, body }) => {
const file = path.join(MEMORY_DIR, `${type}_${name}.md`);
const front = [
"---",
`name: ${name}`,
`description: ${description}`,
`type: ${type}`,
`status: draft`,
`owner: ${process.env.AGENT_NAME ?? "unknown"}`,
`created: ${new Date().toISOString().slice(0, 10)}`,
"---",
].join("\n");
await fs.writeFile(file, `${front}\n\n${body}\n`, "utf-8");
return { content: [{ type: "text", text: `saved: ${file} (status=draft)` }] };
},
);
recall(読み出し)は、frontmatterの description 行への正規表現マッチです。前回も書きましたが、記憶が数百件規模ならベクトルDBは不要で、descriptionの語彙品質のほうが検索精度に効きます。
server.registerTool(
"memory_recall",
{
title: "記憶を検索",
description: "作業着手前に必ず呼ぶ。キーワードで関連する記憶を探す。",
inputSchema: { keywords: z.array(z.string()).min(1) },
},
async ({ keywords }) => {
const pattern = new RegExp(keywords.join("|"), "i");
const hits: string[] = [];
for (const f of await fs.readdir(MEMORY_DIR)) {
if (!f.endsWith(".md")) continue;
const text = await fs.readFile(path.join(MEMORY_DIR, f), "utf-8");
const desc = text.match(/^description: (.+)$/m)?.[1] ?? "";
if (pattern.test(desc) || pattern.test(f)) {
hits.push(`## ${f}\n${text}`);
}
}
return {
content: [{
type: "text",
text: hits.length ? hits.join("\n\n") : "該当する記憶なし",
}],
};
},
);
descriptionに「作業着手前に必ず呼ぶ」と書いてあるのは飾りではありません。実運用で一番多かった事故は「記憶は存在したのに、AIが読まずに作業した」です。消失したスクリプトの復旧時、正本仕様が記憶に残っていたのに参照されず、劣化版が再実装されました。tool descriptionは実質システムプロンプトの一部なので、呼び出しタイミングの規律はここに書きます。
パターン2: 状態機械つき記憶
2つ目のパターンは記憶の信頼度管理です。記憶にはライフサイクルがあります。
draft(仮説) ──検証──▶ confirmed(確定) ──陳腐化──▶ deprecated(廃止)
これを分けていないと何が起きるか。あるエージェントが会話中に立てた仮説を保存し、別のエージェントがそれを確定事項として参照して作業を進める、という事故が起きます。私たちの場合、いったん撤回された設計方針が「決定事項」として独り歩きし、数時間の手戻りになりました。
ルールは3つです。
- 新規保存は必ず
draftで入る(パターン1のコード参照) -
confirmedへの昇格は保存した本人以外(人間または別エージェント)が行う - 間違いと判明した記憶は削除せず
deprecatedにして残す。「なぜ間違いだったか」が次の事故を防ぐ
昇格ツールの実装です。自己承認ガードが肝になります。
server.registerTool(
"memory_promote",
{
title: "記憶を昇格/廃止",
description: "検証済みの記憶をconfirmedに昇格、または間違いをdeprecatedにする。",
inputSchema: {
name: z.string(),
to: z.enum(["confirmed", "deprecated"]),
reason: z.string().describe("昇格/廃止の根拠。検証方法や反証を書く"),
},
},
async ({ name, to, reason }) => {
const files = (await fs.readdir(MEMORY_DIR)).filter((f) => f.includes(name));
if (files.length !== 1) {
return { content: [{ type: "text", text: `対象を特定できません: ${files.join(", ")}` }] };
}
const file = path.join(MEMORY_DIR, files[0]);
let text = await fs.readFile(file, "utf-8");
// ガード: 保存した本人は昇格できない
const owner = text.match(/^owner: (.+)$/m)?.[1];
const me = process.env.AGENT_NAME ?? "unknown";
if (to === "confirmed" && owner === me) {
return {
content: [{ type: "text", text: `自己承認は禁止です(owner=${owner})。別のレビュアーに依頼してください。` }],
};
}
text = text.replace(/^status: .+$/m, `status: ${to}`);
text += `\n> [${to}] by ${me}: ${reason}\n`;
await fs.writeFile(file, text, "utf-8");
return { content: [{ type: "text", text: `${files[0]} → ${to}` }] };
},
);
「AIが自分の書いた記憶を自分で確定できない」——たったこれだけの制約で、思い込みの固定化が目に見えて減りました。人間のコードレビューと同じ構造です。
パターン3: 引き継ぎ台帳(handoff ledger)
3つ目は、知識ではなく行動の記録です。外部に影響する操作(記事公開、デプロイ、API実行)は、通常の記憶とは別の台帳に記録します。
なぜ分けるのか。バックアップ復旧でステートファイルが巻き戻った際、公開済みの記事を「未公開」と判定したスクリプトが同じ記事を再公開しようとする事故が起きたからです。外部操作の記録は「思い出」ではなく「会計記録」であり、追記専用(append-only)で、証跡URL必須にします。
const LEDGER = path.join(MEMORY_DIR, "ledger.jsonl");
server.registerTool(
"ledger_record",
{
title: "外部操作を台帳に記録",
description: "公開・デプロイ・外部API実行の直後に必ず呼ぶ。証跡URL必須。",
inputSchema: {
action: z.string().describe("例: publish_qiita, deploy_prod"),
target: z.string().describe("対象のslugやサービス名"),
evidence: z.string().url().describe("結果を確認できるURL"),
},
},
async ({ action, target, evidence }) => {
const row = JSON.stringify({
action, target, evidence,
actor: process.env.AGENT_NAME ?? "unknown",
at: new Date().toISOString(),
});
await fs.appendFile(LEDGER, row + "\n", "utf-8");
return { content: [{ type: "text", text: `recorded: ${row}` }] };
},
);
server.registerTool(
"ledger_check",
{
title: "実行済みか確認",
description: "外部操作の前に必ず呼ぶ。同じ操作が既に実行済みなら中止する。",
inputSchema: { action: z.string(), target: z.string() },
},
async ({ action, target }) => {
let done = false;
try {
const lines = (await fs.readFile(LEDGER, "utf-8")).trim().split("\n");
done = lines.some((l) => {
const r = JSON.parse(l);
return r.action === action && r.target === target;
});
} catch { /* 台帳なし = 未実行 */ }
return { content: [{ type: "text", text: done ? "EXECUTED: 実行済み。再実行禁止。" : "NOT_EXECUTED" }] };
},
);
運用ルールは「check → 実行 → record を1セット」。recordを忘れると翌日また同じ公開を試みてループします(これも実際にやらかしました。台帳への記録漏れ1件で、cronが3日間同じ記事の公開を再試行し続けました)。
運用してから気づいた細かい罠
同時書き込みの競合。複数エージェントが同時に台帳へ書くと行が混ざることがあります。JSONLの追記は1行が小さければアトミックに近いですが、厳密にやるなら proper-lockfile 等でロックを取ります。うちは「外部操作を実行できるエージェントを1体に限定する」という運用側の解決にしました。技術で解くより役割分担で解くほうが安定するケースもあります。
スコープの混線。全エージェントが全記憶を読めると、ブログ担当の記憶がデプロイ担当の判断を汚染することがあります。frontmatterに scope: を持たせ、recall時にエージェント名でフィルタするのが効きました。
記憶の肥大。インデックスやdescriptionに字数上限(200字)を機械的に課す。「あとで整理する」は来ません。書き込み時に弾くのが唯一機能しました。
まとめ
| パターン | 解決する問題 | 核になる制約 |
|---|---|---|
| 1ファイル1事実 | 肥大化・検索性 | 粒度の統一、description 200字 |
| 状態機械 | 思い込みの固定化 | draft開始、自己承認禁止 |
| 引き継ぎ台帳 | 外部操作の重複実行 | append-only、証跡URL必須、check→実行→record |
3つに共通するのは、「記憶を賢くする」のではなく「記憶の信頼度と副作用を管理する」という方向性です。MCPで切り出しておくと、この規律をツールのインターフェースとして強制できる——descriptionとガードコードで運用ルールを実装に埋め込める——のが最大の利点でした。
コードは全てそのまま動かせます。MEMORY_DIR を共有ストレージに向けて、ぜひ複数エージェントで試してみてください。