AIエージェントに永続記憶を持たせたい。しかもモデルを乗り換えても記憶は残したい。これを実現する具体策が、記憶をMCP(Model Context Protocol)サーバーとして外に出すことです。記憶がモデルから独立するので、MCP対応クライアントなら別のモデルからでも同じ記憶を参照できます。
この記事は手を動かす側に寄せて、記憶MCPサーバーの最小実装と、運用でハマる落とし穴のチェックリストをコード中心でまとめます。
設計思想(なぜ動詞でツールを切るか、なぜrecallで全件返さないか)は別記事に分けています。ここは実装です。
全体像
公開するツールは3つに絞ります。
-
recall_memory… 関連する記憶だけを取り出す(全件ダンプしない) -
save_memory… 1記憶=1事実で記録する(既定は仮) -
list_memories… scopeを指定して棚卸しする
記憶は確度で3層に分けます:tentative(仮) / confirmed(確定) / working(実働)。判断に使ってよいのは確定と実働だけ。新規保存は仮から始めます。
Step 1. スキーマを決める
1記憶=1行。scope(用途境界)と tier(確度)を必ず持たせます。SQLite なら最小これだけです。
CREATE TABLE memory (
id TEXT PRIMARY KEY,
fact TEXT NOT NULL, -- 1件の前提・決定・好み
scope TEXT NOT NULL, -- 用途境界(混ざらないための鍵)
tier TEXT NOT NULL DEFAULT 'tentative',
source TEXT,
updated_at TEXT NOT NULL,
deleted INTEGER NOT NULL DEFAULT 0 -- 論理削除(撤回できる設計)
);
CREATE INDEX idx_memory_scope ON memory(scope, tier, deleted);
deleted を最初から入れておくのが地味に効きます。古くなった前提を物理削除せず降ろせるので、撤回・復元ができます。
Step 2. MCPサーバーの骨格
TypeScript(@modelcontextprotocol/sdk)での最小骨格です。stdioトランスポートでクライアントに繋ぎます。
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
ListToolsRequestSchema,
CallToolRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
const server = new Server(
{ name: "memory-server", version: "0.1.0" },
{ capabilities: { tools: {} } }
);
Step 3. ツールを定義する(入力スキーマで縛る)
ここが設計の本体です。recall は絞り込みを必須にして全件取得を禁止し、save は scope を必須にして越境を防ぎます。
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "recall_memory",
description: "現在のタスクに関連する記憶だけを取り出す。全件取得はしない。",
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "いま必要な文脈を表す検索語" },
scope: { type: "string", description: "用途境界(例: project-a / personal)" },
tier: { type: "string", enum: ["confirmed", "working"] },
limit: { type: "integer", default: 8, maximum: 20 },
},
required: ["query", "scope"],
},
},
{
name: "save_memory",
description:
"次の会話でも必要になる固有の前提・決定・好みだけを保存する。" +
"既に一般的に知られている事実は保存しない。複数の事実を混ぜない。",
inputSchema: {
type: "object",
properties: {
fact: { type: "string" },
scope: { type: "string" },
tier: { type: "string", enum: ["tentative", "confirmed"], default: "tentative" },
source: { type: "string" },
},
required: ["fact", "scope"],
},
},
{
name: "list_memories",
description: "指定した scope の記憶を棚卸しする。",
inputSchema: {
type: "object",
properties: { scope: { type: "string" } },
required: ["scope"],
},
},
],
}));
description はモデルへの運用指示として効きます。保存基準(一般的事実は保存しない・1事実だけ)はここに書いておくと、書き込みの暴走が目に見えて減ります。
Step 4. ハンドラを実装する
肝は recall。返す前に必ず絞り込み、上限を頭打ちにします。
server.setRequestHandler(CallToolRequestSchema, async (req) => {
const { name, arguments: args } = req.params;
if (name === "recall_memory") {
const limit = Math.min(args.limit ?? 8, 20); // サーバー側で頭打ち
const tiers = args.tier ? [args.tier] : ["confirmed", "working"]; // 既定で仮を除外
const rows = db.prepare(
`SELECT id, fact, tier, updated_at FROM memory
WHERE scope = ? AND deleted = 0 AND tier IN (${tiers.map(() => "?").join(",")})
AND fact LIKE ?
ORDER BY updated_at DESC LIMIT ?`
).all(args.scope, ...tiers, `%${args.query}%`, limit);
// tier と updated_at を載せて返す=確度と鮮度をモデルに見せる
return { content: [{ type: "text", text: JSON.stringify(rows) }] };
}
if (name === "save_memory") {
if (!args.scope) throw new Error("scope は必須です"); // 越境をインターフェースで防ぐ
// 重複ガード:同じ scope に似た fact があれば新規作成しない
const dup = db.prepare(
`SELECT id FROM memory WHERE scope = ? AND deleted = 0 AND fact LIKE ? LIMIT 1`
).get(args.scope, `%${args.fact.slice(0, 20)}%`);
if (dup) return { content: [{ type: "text", text: `既存に類似: ${dup.id}` }] };
const id = crypto.randomUUID();
db.prepare(
`INSERT INTO memory (id, fact, scope, tier, source, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`
).run(id, args.fact, args.scope, args.tier ?? "tentative", args.source ?? null,
new Date().toISOString());
return { content: [{ type: "text", text: `saved ${id}` }] };
}
if (name === "list_memories") {
const rows = db.prepare(
`SELECT id, fact, tier FROM memory WHERE scope = ? AND deleted = 0`
).all(args.scope);
return { content: [{ type: "text", text: JSON.stringify(rows) }] };
}
throw new Error(`unknown tool: ${name}`);
});
const transport = new StdioServerTransport();
await server.connect(transport);
これで「関連する確定記憶だけを、上限付きで、scope境界を守って取り出す」最小の記憶MCPサーバーが動きます。
落とし穴チェックリスト
実装が一巡したら、ここを確認してください。素朴に作ると全部踏みます。
-
recall が全件返していないか …
query/scopeを必須にし、limitをサーバー側で頭打ち。 - 仮(tentative)が判断に混ざっていないか … recall既定は confirmed / working のみ。
- scope を任意にしていないか … 任意だとモデルが付けず越境する。必須+デフォルトscopeを持たない。
- save が暴走していないか … 重複ガード+descriptionに保存基準を明記。
-
返り値に確度・鮮度を載せたか …
tierとupdated_atを返し、全部同じ重みに見せない。 - 撤回・降格ができるか … 論理削除カラムを最初から。確定への昇格は低頻度処理に分離。
- 1記憶1事実になっているか … 複数事実をまとめて保存すると後から一部更新できない。
まとめ
記憶MCPサーバーの実装で効くのは、ストレージの選択よりツールの入力スキーマで縛ることです。recallで全件返さない、saveでscopeを必須にする、返り値に確度を載せる。この3つを入れるだけで、記憶は「混ざらず・荒れず・古い地図で暴走しない」状態に近づきます。
設計の考え方(なぜ動詞でツールを切るか、recallを検索に寄せる理由)は MCPでAIに永続記憶を持たせる:記憶レイヤーのインターフェース設計と落とし穴 に書いています。
記憶をモデルの外に置き、どのAIに挿しても同じ文脈が戻る仕組みを、実録とともに育てています:エージェントメモリーズ
