From 1c00bfa0f3a38faee6be46631f95928726e37ab1 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 11 Nov 2026 23:40:27 -0500 Subject: [PATCH 1/3] feat(memory): opt-in write limits on graph size Add MEMORY_MAX_ENTITIES, MEMORY_MAX_RELATIONS and MEMORY_MAX_FILE_BYTES. When set, create_entities, create_relations and add_observations refuse a call that would leave the graph over a limit and larger than it was, with a tool error naming the variable; nothing is saved. Delete tools are never refused, so a graph over a lowered limit can always shrink. An invalid value stops startup. The README documents the limits and that every write rewrites the whole memory file under the lock, so its cost grows linearly with the file. Closes #5525 Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .changeset/memory-write-limits.md | 5 + src/memory/README.md | 86 +++--- src/memory/__tests__/helpers.ts | 11 +- src/memory/__tests__/write-limits.test.ts | 333 ++++++++++++++++++++++ src/memory/index.ts | 162 +++++++++-- 5 files changed, 540 insertions(+), 57 deletions(-) create mode 100644 .changeset/memory-write-limits.md create mode 100644 src/memory/__tests__/write-limits.test.ts diff --git a/.changeset/memory-write-limits.md b/.changeset/memory-write-limits.md new file mode 100644 index 0000000000..1d09bb04ed --- /dev/null +++ b/.changeset/memory-write-limits.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/server-memory": minor +--- + +Add opt-in write limits: `MEMORY_MAX_ENTITIES`, `MEMORY_MAX_RELATIONS` and `MEMORY_MAX_FILE_BYTES` cap how large the write tools may grow the graph, and the README now documents that every write rewrites the whole memory file. diff --git a/src/memory/README.md b/src/memory/README.md index d519f14f3e..abba2e0dfb 100644 --- a/src/memory/README.md +++ b/src/memory/README.md @@ -7,12 +7,15 @@ Published on npm as [`@modelcontextprotocol/server-memory`](https://www.npmjs.co ## Core Concepts ### Entities + Entities are the primary nodes in the knowledge graph. Each entity has: + - A unique name (identifier) - An entity type (e.g., "person", "organization", "event") - A list of observations Example: + ```json { "name": "John_Smith", @@ -22,9 +25,11 @@ Example: ``` ### Relations + Relations define directed connections between entities. They are always stored in active voice and describe how entities interact or relate to each other. Example: + ```json { "from": "John_Smith", @@ -32,7 +37,9 @@ Example: "relationType": "works_at" } ``` + ### Observations + Observations are discrete pieces of information about an entity. They are: - Stored as strings @@ -41,6 +48,7 @@ Observations are discrete pieces of information about an entity. They are: - Should be atomic (one fact per observation) Example: + ```json { "entityName": "John_Smith", @@ -55,6 +63,7 @@ Example: ## API ### Tools + - **create_entities** - Create multiple new entities in the knowledge graph - Input: `entities` (array of objects) @@ -168,22 +177,27 @@ Add this to your claude_desktop_config.json: "mcpServers": { "memory": { "command": "docker", - "args": ["run", "-i", "-v", "claude-memory:/app/dist", "--rm", "mcp/memory"] + "args": [ + "run", + "-i", + "-v", + "claude-memory:/app/dist", + "--rm", + "mcp/memory" + ] } } } ``` #### NPX + ```json { "mcpServers": { "memory": { "command": "npx", - "args": [ - "-y", - "@modelcontextprotocol/server-memory" - ] + "args": ["-y", "@modelcontextprotocol/server-memory"] } } } @@ -196,12 +210,7 @@ On Windows, use `cmd /c` to launch `npx`: "mcpServers": { "memory": { "command": "cmd", - "args": [ - "/c", - "npx", - "-y", - "@modelcontextprotocol/server-memory" - ] + "args": ["/c", "npx", "-y", "@modelcontextprotocol/server-memory"] } } } @@ -216,10 +225,7 @@ The server can be configured using the following environment variables: "mcpServers": { "memory": { "command": "npx", - "args": [ - "-y", - "@modelcontextprotocol/server-memory" - ], + "args": ["-y", "@modelcontextprotocol/server-memory"], "env": { "MEMORY_FILE_PATH": "/path/to/custom/memory.jsonl" } @@ -235,12 +241,7 @@ On Windows, use: "mcpServers": { "memory": { "command": "cmd", - "args": [ - "/c", - "npx", - "-y", - "@modelcontextprotocol/server-memory" - ], + "args": ["/c", "npx", "-y", "@modelcontextprotocol/server-memory"], "env": { "MEMORY_FILE_PATH": "/path/to/custom/memory.jsonl" } @@ -250,6 +251,11 @@ On Windows, use: ``` - `MEMORY_FILE_PATH`: Path to the memory storage JSONL file (default: `memory.jsonl` in the server directory). A leading `~` expands to your home directory; a relative path resolves against the server directory. Set it to an absolute path you own; see [Choosing where the graph is stored](#choosing-where-the-graph-is-stored). +- `MEMORY_MAX_ENTITIES`: The most entities the graph may hold. Unset by default (no limit). +- `MEMORY_MAX_RELATIONS`: The most relations the graph may hold. Unset by default (no limit). +- `MEMORY_MAX_FILE_BYTES`: The most bytes the memory file may hold. Unset by default (no limit). + +The three limits are opt-in, and each is described under [Limiting how large the graph grows](#limiting-how-large-the-graph-grows). A limit that is set must be a positive whole number; any other value stops the server at startup with an error naming the variable. When `MEMORY_FILE_PATH` is not set and the server directory still holds a legacy `memory.json` but no `memory.jsonl`, the server renames `memory.json` to `memory.jsonl` at startup. If that rename fails, the server exits with an error naming both paths and the cause, rather than starting on an empty graph; fix the cause (a permission problem, say) and restart, or set `MEMORY_FILE_PATH` to choose a file explicitly. @@ -271,6 +277,17 @@ Each MCP client starts its own server process, so two clients using the same `ME - A write that cannot get the lock within 60 seconds fails with a tool error rather than overwriting the file. - The lock relies on exclusive file creation, which local filesystems provide; a network filesystem that does not honour it cannot be shared safely this way. +#### Limiting how large the graph grows + +Every write tool reads the whole memory file, changes the graph and writes the whole file back, holding the lock throughout. Its cost, and how long other servers sharing the file wait for the lock, therefore grow linearly with the file, however small the change: adding one entity to a graph of 100,000 entities rewrites the whole file, about 24 MB at around 240 bytes per entity, in roughly 0.4 seconds on a laptop (#5525). The read tools load the whole file too. Size a shared file with that in mind. + +By default nothing caps the graph. Set `MEMORY_MAX_ENTITIES`, `MEMORY_MAX_RELATIONS` or `MEMORY_MAX_FILE_BYTES` to put a ceiling on it: + +- `create_entities`, `create_relations` and `add_observations` refuse a call that would leave the graph over a limit, with a tool error naming the variable. The whole call is refused and nothing is saved, not just the part past the limit. +- A call that adds nothing new (every entity or relation already exists, say) is still answered, even when the graph is at or over a limit. +- The delete tools are never refused, so a graph over a limit (one that grew before the limit was set, or lowered) can always be shrunk. While it is over, calls that would grow it further on that dimension are refused. +- `MEMORY_MAX_FILE_BYTES` counts the bytes of the file as it would be saved, unreadable lines included, so it bounds the cost of each write directly. The counts are easier to reason about but do not bound how long each entity's observations grow. + # VS Code Installation Instructions For quick installation, use one of the one-click installation buttons below: @@ -296,10 +313,7 @@ Alternatively, you can add the configuration to a file called `.vscode/mcp.json` "servers": { "memory": { "command": "npx", - "args": [ - "-y", - "@modelcontextprotocol/server-memory" - ] + "args": ["-y", "@modelcontextprotocol/server-memory"] } } } @@ -312,12 +326,7 @@ On Windows, use: "servers": { "memory": { "command": "cmd", - "args": [ - "/c", - "npx", - "-y", - "@modelcontextprotocol/server-memory" - ] + "args": ["/c", "npx", "-y", "@modelcontextprotocol/server-memory"] } } } @@ -347,7 +356,7 @@ On Windows, use: The prompt for utilizing memory depends on the use case. Changing the prompt will help the model determine the frequency and types of memories created. -Here is an example prompt for chat personalization. You could use this prompt in the "Custom Instructions" field of a [Claude.ai Project](https://www.anthropic.com/news/projects). +Here is an example prompt for chat personalization. You could use this prompt in the "Custom Instructions" field of a [Claude.ai Project](https://www.anthropic.com/news/projects). ``` Follow these steps for each interaction: @@ -380,7 +389,7 @@ Follow these steps for each interaction: Docker: ```sh -docker build -t mcp/memory -f src/memory/Dockerfile . +docker build -t mcp/memory -f src/memory/Dockerfile . ``` ### Upgrading a Docker install @@ -407,7 +416,16 @@ Mounting a volume on a directory of its own and pointing `MEMORY_FILE_PATH` at i "mcpServers": { "memory": { "command": "docker", - "args": ["run", "-i", "-v", "claude-memory-data:/data", "-e", "MEMORY_FILE_PATH=/data/memory.jsonl", "--rm", "mcp/memory"] + "args": [ + "run", + "-i", + "-v", + "claude-memory-data:/data", + "-e", + "MEMORY_FILE_PATH=/data/memory.jsonl", + "--rm", + "mcp/memory" + ] } } } diff --git a/src/memory/__tests__/helpers.ts b/src/memory/__tests__/helpers.ts index 4be893f7c4..008b210c6b 100644 --- a/src/memory/__tests__/helpers.ts +++ b/src/memory/__tests__/helpers.ts @@ -10,6 +10,7 @@ import { InMemoryTransport } from "@modelcontextprotocol/server"; import type { McpServer, CallToolResult } from "@modelcontextprotocol/server"; import { Client } from "@modelcontextprotocol/client"; import { createServer } from "../index.js"; +import type { GraphLimits } from "../index.js"; export interface Connection { client: Client; @@ -18,9 +19,13 @@ export interface Connection { close: () => Promise; } -// Connect a fresh client to a fresh server over the graph file at filePath. -export async function connect(filePath: string): Promise { - const server = createServer(filePath); +// Connect a fresh client to a fresh server over the graph file at filePath, +// with the write limits given (none by default). +export async function connect( + filePath: string, + limits: GraphLimits = {}, +): Promise { + const server = createServer(filePath, "legacy", limits); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const client = new Client({ name: "memory-test-client", version: "0.0.0" }); diff --git a/src/memory/__tests__/write-limits.test.ts b/src/memory/__tests__/write-limits.test.ts new file mode 100644 index 0000000000..ab8f63db4a --- /dev/null +++ b/src/memory/__tests__/write-limits.test.ts @@ -0,0 +1,333 @@ +// Tests for the opt-in write limits (#5525): MEMORY_MAX_ENTITIES, +// MEMORY_MAX_RELATIONS and MEMORY_MAX_FILE_BYTES. graphLimitsFromEnv() is +// tested directly for its parsing; the limits themselves are driven through an +// SDK Client over an in-memory transport, asserting on the tool result and on +// the graph file, which a refused call must leave byte for byte unchanged. +// The last block runs main() to show the environment reaches the server. +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import path from "path"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import type { CallToolResult } from "@modelcontextprotocol/server"; +import { GRAPH_LIMIT_ENV, graphLimitsFromEnv, main } from "../index.js"; +import type { GraphLimits } from "../index.js"; +import { + call, + connect, + makeTempGraph, + readFileText, + textOf, +} from "./helpers.js"; + +const entity = (name: string, observations: string[] = []) => ({ + name, + entityType: "person", + observations, +}); + +describe("graphLimitsFromEnv", () => { + it("leaves every limit off when nothing is set", () => { + expect(graphLimitsFromEnv({})).toEqual({}); + }); + + it("treats an empty or blank variable as unset", () => { + expect( + graphLimitsFromEnv({ + MEMORY_MAX_ENTITIES: "", + MEMORY_MAX_RELATIONS: " ", + }), + ).toEqual({}); + }); + + it("reads each limit from its variable, trimming whitespace", () => { + expect( + graphLimitsFromEnv({ + MEMORY_MAX_ENTITIES: "100", + MEMORY_MAX_RELATIONS: " 200 ", + MEMORY_MAX_FILE_BYTES: "1048576", + }), + ).toEqual({ maxEntities: 100, maxRelations: 200, maxFileBytes: 1048576 }); + }); + + it.each([ + "0", + "-1", + "1.5", + "1e3", + "abc", + "10 MB", + "0x10", + "99999999999999999999", + ])("refuses %j, naming the variable", (value) => { + expect(() => graphLimitsFromEnv({ MEMORY_MAX_FILE_BYTES: value })).toThrow( + `MEMORY_MAX_FILE_BYTES must be a positive whole number, got "${value}"`, + ); + }); + + it("names every variable in GRAPH_LIMIT_ENV", () => { + expect(Object.values(GRAPH_LIMIT_ENV)).toEqual([ + "MEMORY_MAX_ENTITIES", + "MEMORY_MAX_RELATIONS", + "MEMORY_MAX_FILE_BYTES", + ]); + }); +}); + +describe("write limits", () => { + let filePath: string; + let cleanup: () => Promise; + const open: { close: () => Promise }[] = []; + + beforeEach(async () => { + ({ filePath, cleanup } = await makeTempGraph()); + }); + + afterEach(async () => { + for (const conn of open.splice(0)) { + await conn.close(); + } + await cleanup(); + }); + + async function clientWith(limits: GraphLimits): Promise { + const conn = await connect(filePath, limits); + open.push(conn); + return conn.client; + } + + // A refused call: an error result naming the variable, and no write. + async function expectRefused( + result: Promise, + variable: string, + before: string, + ): Promise { + const refused = await result; + expect(refused.isError).toBe(true); + expect(textOf(refused)).toMatch(/^Refused: /); + expect(textOf(refused)).toContain(`set by ${variable}`); + expect(textOf(refused)).toContain("Nothing was saved"); + expect(await readFileText(filePath)).toBe(before); + } + + it("changes nothing when no limit is set", async () => { + const client = await clientWith({}); + const result = await call(client, "create_entities", { + entities: [entity("A"), entity("B"), entity("C")], + }); + expect(result.isError).toBeUndefined(); + }); + + describe("MEMORY_MAX_ENTITIES", () => { + it("allows creating up to the limit and refuses the call that passes it", async () => { + const client = await clientWith({ maxEntities: 2 }); + const ok = await call(client, "create_entities", { + entities: [entity("A"), entity("B")], + }); + expect(ok.isError).toBeUndefined(); + const before = await readFileText(filePath); + + await expectRefused( + call(client, "create_entities", { entities: [entity("C")] }), + "MEMORY_MAX_ENTITIES", + before, + ); + }); + + it("refuses the whole call, not just the entities past the limit", async () => { + const client = await clientWith({ maxEntities: 2 }); + await call(client, "create_entities", { entities: [entity("A")] }); + const before = await readFileText(filePath); + + await expectRefused( + call(client, "create_entities", { + entities: [entity("B"), entity("C")], + }), + "MEMORY_MAX_ENTITIES", + before, + ); + }); + + it("still answers a call that adds nothing once the graph is full", async () => { + const client = await clientWith({ maxEntities: 1 }); + await call(client, "create_entities", { entities: [entity("A")] }); + + const result = await call(client, "create_entities", { + entities: [entity("A")], + }); + expect(result.isError).toBeUndefined(); + expect(result.structuredContent).toEqual({ + entities: [], + skipped: ["A"], + }); + }); + + it("lets a graph already over a lowered limit shrink, and refuses its growth", async () => { + const unlimited = await clientWith({}); + await call(unlimited, "create_entities", { + entities: [entity("A"), entity("B"), entity("C")], + }); + + const client = await clientWith({ maxEntities: 1 }); + const before = await readFileText(filePath); + await expectRefused( + call(client, "create_entities", { entities: [entity("D")] }), + "MEMORY_MAX_ENTITIES", + before, + ); + + // Growth on another dimension is not refused by this one. + const observed = await call(client, "add_observations", { + observations: [{ entityName: "A", contents: ["likes tea"] }], + }); + expect(observed.isError).toBeUndefined(); + + const deleted = await call(client, "delete_entities", { + entityNames: ["B"], + }); + expect(deleted.isError).toBeUndefined(); + expect(await readFileText(filePath)).not.toContain('"name":"B"'); + }); + }); + + describe("MEMORY_MAX_RELATIONS", () => { + it("allows relations up to the limit and refuses the one that passes it", async () => { + const client = await clientWith({ maxRelations: 1 }); + await call(client, "create_entities", { + entities: [entity("A"), entity("B")], + }); + const ok = await call(client, "create_relations", { + relations: [{ from: "A", to: "B", relationType: "knows" }], + }); + expect(ok.isError).toBeUndefined(); + const before = await readFileText(filePath); + + await expectRefused( + call(client, "create_relations", { + relations: [{ from: "B", to: "A", relationType: "knows" }], + }), + "MEMORY_MAX_RELATIONS", + before, + ); + + // A repeat of a stored relation adds nothing, so it is still answered. + const repeat = await call(client, "create_relations", { + relations: [{ from: "A", to: "B", relationType: "knows" }], + }); + expect(repeat.isError).toBeUndefined(); + expect(repeat.structuredContent).toEqual({ relations: [] }); + }); + }); + + describe("MEMORY_MAX_FILE_BYTES", () => { + it("refuses add_observations that would grow the file past the limit", async () => { + const unlimited = await clientWith({}); + await call(unlimited, "create_entities", { entities: [entity("A")] }); + const before = await readFileText(filePath); + const size = Buffer.byteLength(before, "utf-8"); + + // Room for a short observation, not a long one. + const client = await clientWith({ maxFileBytes: size + 20 }); + const ok = await call(client, "add_observations", { + observations: [{ entityName: "A", contents: ["short"] }], + }); + expect(ok.isError).toBeUndefined(); + const afterShort = await readFileText(filePath); + + await expectRefused( + call(client, "add_observations", { + observations: [{ entityName: "A", contents: ["x".repeat(100)] }], + }), + "MEMORY_MAX_FILE_BYTES", + afterShort, + ); + }); + + it("counts bytes, not characters", async () => { + const unlimited = await clientWith({}); + await call(unlimited, "create_entities", { entities: [entity("A")] }); + const size = Buffer.byteLength(await readFileText(filePath), "utf-8"); + + // Ten emoji add 42 bytes to the file (each is 4 bytes in UTF-8, plus + // the quotes) but only 22 UTF-16 code units, so a budget of 30 fits + // them only if the cap counted string length rather than bytes. + const client = await clientWith({ maxFileBytes: size + 30 }); + await expectRefused( + call(client, "add_observations", { + observations: [{ entityName: "A", contents: ["😀".repeat(10)] }], + }), + "MEMORY_MAX_FILE_BYTES", + await readFileText(filePath), + ); + }); + + it("refuses the first write to a new file that would already be too large", async () => { + const client = await clientWith({ maxFileBytes: 10 }); + const refused = await call(client, "create_entities", { + entities: [entity("A")], + }); + expect(refused.isError).toBe(true); + expect(textOf(refused)).toContain("set by MEMORY_MAX_FILE_BYTES"); + await expect(readFileText(filePath)).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + }); +}); + +describe("main reads the limits from the environment", () => { + const saved: Record = {}; + const names = ["MEMORY_FILE_PATH", ...Object.values(GRAPH_LIMIT_ENV)]; + let dir: string; + let cleanup: () => Promise; + + beforeEach(async () => { + for (const name of names) { + saved[name] = process.env[name]; + delete process.env[name]; + } + ({ dir, cleanup } = await makeTempGraph()); + process.env.MEMORY_FILE_PATH = path.join(dir, "memory.jsonl"); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(async () => { + for (const name of names) { + if (saved[name] === undefined) { + delete process.env[name]; + } else { + process.env[name] = saved[name]; + } + } + vi.restoreAllMocks(); + await cleanup(); + }); + + it("enforces MEMORY_MAX_ENTITIES on the server it starts", async () => { + process.env.MEMORY_MAX_ENTITIES = "1"; + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "memory-test-client", version: "0.0.0" }); + const server = await main(serverTransport); + await client.connect(clientTransport); + try { + const ok = await call(client, "create_entities", { + entities: [entity("A")], + }); + expect(ok.isError).toBeUndefined(); + const refused = await call(client, "create_entities", { + entities: [entity("B")], + }); + expect(refused.isError).toBe(true); + expect(textOf(refused)).toContain("set by MEMORY_MAX_ENTITIES"); + } finally { + await client.close(); + await server.close(); + } + }); + + it("refuses to start on an invalid limit", async () => { + process.env.MEMORY_MAX_RELATIONS = "lots"; + await expect(main(InMemoryTransport.createLinkedPair()[1])).rejects.toThrow( + 'MEMORY_MAX_RELATIONS must be a positive whole number, got "lots"', + ); + }); +}); diff --git a/src/memory/index.ts b/src/memory/index.ts index e57bf12ed3..b028740369 100644 --- a/src/memory/index.ts +++ b/src/memory/index.ts @@ -171,6 +171,60 @@ export function skippedEntitiesNotice(skipped: string[]): string { ); } +// Opt-in ceilings on how large the write tools may grow the graph (#5525). +// Every mutation reads the whole graph file and writes it back under the +// cross-process lock, so its cost, and how long it holds the lock, grow +// linearly with the file. These let an operator bound that growth. Each is +// unset by default, which leaves the store unbounded, as it always was. +export interface GraphLimits { + // Most entities the graph may hold. + maxEntities?: number; + // Most relations the graph may hold. + maxRelations?: number; + // Most bytes the saved graph file may hold. + maxFileBytes?: number; +} + +// The environment variable that sets each limit. +export const GRAPH_LIMIT_ENV: Readonly> = { + maxEntities: "MEMORY_MAX_ENTITIES", + maxRelations: "MEMORY_MAX_RELATIONS", + maxFileBytes: "MEMORY_MAX_FILE_BYTES", +}; + +// Read the limits from the environment. An unset or empty variable leaves its +// limit off; anything other than a positive whole number stops startup, since +// a misspelt limit silently ignored would leave the store unbounded while the +// operator believes it capped. +export function graphLimitsFromEnv( + env: NodeJS.ProcessEnv = process.env, +): GraphLimits { + const limits: GraphLimits = {}; + for (const key of Object.keys(GRAPH_LIMIT_ENV) as (keyof GraphLimits)[]) { + const name = GRAPH_LIMIT_ENV[key]; + const raw = env[name]?.trim(); + if (raw === undefined || raw === "") { + continue; + } + const value = Number(raw); + if (!/^\d+$/.test(raw) || !Number.isSafeInteger(value) || value < 1) { + throw new Error( + `${name} must be a positive whole number, got "${env[name]}". ` + + "Unset it to leave that limit off.", + ); + } + limits[key] = value; + } + return limits; +} + +// How large a graph is, on each dimension GraphLimits caps. +interface GraphSize { + entities: number; + relations: number; + bytes: number; +} + // Timings for the cross-process lock around each mutation. Exposed so tests // can shorten them; the server always uses the defaults. export interface FileLockOptions { @@ -389,6 +443,7 @@ export class KnowledgeGraphManager { constructor( private memoryFilePath: string, private lockOptions: FileLockOptions = {}, + private limits: GraphLimits = {}, ) {} // Serializes all read-modify-write graph mutations behind a single queue. @@ -439,9 +494,12 @@ export class KnowledgeGraphManager { // them back to saveGraph so they are written out unchanged. Otherwise the // next unrelated write would silently delete them from disk, together with // every valid observation of an entity that has one bad field. + // size is the graph as loaded, for the write tools' limit check (see + // checkLimits). private async loadGraphWithUnreadable(): Promise<{ graph: KnowledgeGraph; unreadable: string[]; + size: GraphSize; }> { try { const data = await fs.readFile(this.memoryFilePath, "utf-8"); @@ -499,14 +557,23 @@ export class KnowledgeGraphManager { } } - return { graph, unreadable }; + const size = { + entities: graph.entities.length, + relations: graph.relations.length, + bytes: Buffer.byteLength(data, "utf-8"), + }; + return { graph, unreadable, size }; } catch (error) { if ( error instanceof Error && "code" in error && error.code === "ENOENT" ) { - return { graph: { entities: [], relations: [] }, unreadable: [] }; + return { + graph: { entities: [], relations: [] }, + unreadable: [], + size: { entities: 0, relations: 0, bytes: 0 }, + }; } throw error; } @@ -529,9 +596,50 @@ export class KnowledgeGraphManager { } } + // Refuse a write that would leave the graph over a limit and larger than it + // was (`before`), on any one dimension. Comparing against `before` keeps a + // graph already over a lowered limit usable: a call that adds nothing still + // succeeds, and the delete tools, which never pass `before`, can always + // shrink it. The refusal is thrown before anything is written, so the call + // changes nothing. + private checkLimits( + graph: KnowledgeGraph, + content: string, + before: GraphSize, + ): void { + const after: GraphSize = { + entities: graph.entities.length, + relations: graph.relations.length, + bytes: Buffer.byteLength(content, "utf-8"), + }; + const dimensions: [keyof GraphSize, keyof GraphLimits, string][] = [ + ["entities", "maxEntities", "entities"], + ["relations", "maxRelations", "relations"], + ["bytes", "maxFileBytes", "bytes in the memory file"], + ]; + for (const [dimension, key, unit] of dimensions) { + const limit = this.limits[key]; + if ( + limit !== undefined && + after[dimension] > limit && + after[dimension] > before[dimension] + ) { + throw new Error( + `Refused: this change would bring the knowledge graph to ${after[dimension]} ${unit}, ` + + `over the limit of ${limit} set by ${GRAPH_LIMIT_ENV[key]}. Nothing was saved. ` + + "Delete entities, relations or observations that are no longer needed to make room, " + + `or ask the operator to raise ${GRAPH_LIMIT_ENV[key]}.`, + ); + } + } + } + + // before is the graph's size as loaded; the write tools that add to the + // graph pass it, so the save is refused if it breaks a limit (checkLimits). private async saveGraph( graph: KnowledgeGraph, unreadable: string[], + before?: GraphSize, ): Promise { // The modelled fields come first and always hold the current values; a // record's extras (see extraFields) follow unchanged. A record created by @@ -557,6 +665,10 @@ export class KnowledgeGraphManager { ), ...unreadable, ]; + const content = lines.join("\n") + "\n"; + if (before) { + this.checkLimits(graph, content, before); + } // Write to a temporary file in the same directory, then rename it over // the target. fs.writeFile would truncate the memory file before writing, @@ -601,7 +713,7 @@ export class KnowledgeGraphManager { // umask removed. await fs.writeFile( tempFilePath, - lines.join("\n") + "\n", + content, mode === undefined ? undefined : { mode }, ); if (mode !== undefined) { @@ -617,7 +729,7 @@ export class KnowledgeGraphManager { async createEntities(entities: Entity[]): Promise { return this.withLock(async () => { - const { graph, unreadable } = await this.loadGraphWithUnreadable(); + const { graph, unreadable, size } = await this.loadGraphWithUnreadable(); const newEntities = entities.filter( (e, index) => !graph.entities.some( @@ -627,14 +739,14 @@ export class KnowledgeGraphManager { !entities.slice(0, index).some((earlier) => earlier.name === e.name), ); graph.entities.push(...newEntities); - await this.saveGraph(graph, unreadable); + await this.saveGraph(graph, unreadable, size); return newEntities; }); } async createRelations(relations: Relation[]): Promise { return this.withLock(async () => { - const { graph, unreadable } = await this.loadGraphWithUnreadable(); + const { graph, unreadable, size } = await this.loadGraphWithUnreadable(); const entityNames = new Set(graph.entities.map((e) => e.name)); relations.forEach((r) => { @@ -659,7 +771,7 @@ export class KnowledgeGraphManager { .some((earlier) => isSameRelation(earlier, r)), ); graph.relations.push(...newRelations); - await this.saveGraph(graph, unreadable); + await this.saveGraph(graph, unreadable, size); return newRelations; }); } @@ -668,7 +780,7 @@ export class KnowledgeGraphManager { observations: { entityName: string; contents: string[] }[], ): Promise<{ entityName: string; addedObservations: string[] }[]> { return this.withLock(async () => { - const { graph, unreadable } = await this.loadGraphWithUnreadable(); + const { graph, unreadable, size } = await this.loadGraphWithUnreadable(); const results = observations.map((o) => { const entity = graph.entities.find((e) => e.name === o.entityName); if (!entity) { @@ -682,7 +794,7 @@ export class KnowledgeGraphManager { entity.observations.push(...newObservations); return { entityName: o.entityName, addedObservations: newObservations }; }); - await this.saveGraph(graph, unreadable); + await this.saveGraph(graph, unreadable, size); return results; }); } @@ -957,12 +1069,18 @@ const ReadEntitySchema = EntitySchema.extend({ // result on stdio; tests connect it to an in-memory transport, so each call // returns an independent server with its own subscriber set. `era` is the // protocol era the instance will serve, which decides how graph changes are -// announced (see notifyGraphUpdated). +// announced (see notifyGraphUpdated). `limits` caps how large the write tools +// may grow the graph; main() reads it from the environment. export function createServer( memoryFilePath: string, era: "legacy" | "modern" = "legacy", + limits: GraphLimits = {}, ): McpServer { - const knowledgeGraphManager = new KnowledgeGraphManager(memoryFilePath); + const knowledgeGraphManager = new KnowledgeGraphManager( + memoryFilePath, + {}, + limits, + ); const server = new McpServer({ name: "memory-server", @@ -1431,20 +1549,24 @@ export function registerKnowledgeGraphSubscriptions( // including one that stops the transport from starting, is reported on stderr. export async function main(transport?: Transport): Promise { const memoryFilePath = await ensureMemoryFilePath(); + const limits = graphLimitsFromEnv(); const locationWarning = memoryFileLocationWarning(memoryFilePath); if (locationWarning) { console.error(locationWarning); } - const handle = serveStdio(({ era }) => createServer(memoryFilePath, era), { - transport, - // serveStdio reports a transport that fails, including one that cannot - // start, here rather than by rejecting, so record the failure in the - // exit status for whatever supervises the process. - onerror: (error) => { - console.error("Transport error:", error); - process.exitCode = 1; + const handle = serveStdio( + ({ era }) => createServer(memoryFilePath, era, limits), + { + transport, + // serveStdio reports a transport that fails, including one that cannot + // start, here rather than by rejecting, so record the failure in the + // exit status for whatever supervises the process. + onerror: (error) => { + console.error("Transport error:", error); + process.exitCode = 1; + }, }, - }); + ); console.error("Knowledge Graph MCP Server running on stdio"); return handle; } From c4f8da452da3008fb435c0c0404aef7b7348e281 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 11 Nov 2026 23:45:58 -0500 Subject: [PATCH 2/3] fix(memory): hold only additive calls to the limits; refuse blank values A call that adds nothing is no longer checked against the limits, since re-saving can grow a file not written in canonical form. An empty or blank limit variable now stops startup instead of leaving the limit off, as the README says. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/memory/README.md | 2 +- src/memory/__tests__/write-limits.test.ts | 35 ++++++++++++++++++----- src/memory/index.ts | 31 ++++++++++++++------ 3 files changed, 51 insertions(+), 17 deletions(-) diff --git a/src/memory/README.md b/src/memory/README.md index abba2e0dfb..40d18976c1 100644 --- a/src/memory/README.md +++ b/src/memory/README.md @@ -255,7 +255,7 @@ On Windows, use: - `MEMORY_MAX_RELATIONS`: The most relations the graph may hold. Unset by default (no limit). - `MEMORY_MAX_FILE_BYTES`: The most bytes the memory file may hold. Unset by default (no limit). -The three limits are opt-in, and each is described under [Limiting how large the graph grows](#limiting-how-large-the-graph-grows). A limit that is set must be a positive whole number; any other value stops the server at startup with an error naming the variable. +The three limits are opt-in, and each is described under [Limiting how large the graph grows](#limiting-how-large-the-graph-grows). A variable that is set must hold a positive whole number; any other value, an empty one included, stops the server at startup with an error naming the variable. When `MEMORY_FILE_PATH` is not set and the server directory still holds a legacy `memory.json` but no `memory.jsonl`, the server renames `memory.json` to `memory.jsonl` at startup. If that rename fails, the server exits with an error naming both paths and the cause, rather than starting on an empty graph; fix the cause (a permission problem, say) and restart, or set `MEMORY_FILE_PATH` to choose a file explicitly. diff --git a/src/memory/__tests__/write-limits.test.ts b/src/memory/__tests__/write-limits.test.ts index ab8f63db4a..ed1c25af93 100644 --- a/src/memory/__tests__/write-limits.test.ts +++ b/src/memory/__tests__/write-limits.test.ts @@ -5,6 +5,7 @@ // the graph file, which a refused call must leave byte for byte unchanged. // The last block runs main() to show the environment reaches the server. import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { promises as fs } from "fs"; import path from "path"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import type { CallToolResult } from "@modelcontextprotocol/server"; @@ -29,13 +30,13 @@ describe("graphLimitsFromEnv", () => { expect(graphLimitsFromEnv({})).toEqual({}); }); - it("treats an empty or blank variable as unset", () => { - expect( - graphLimitsFromEnv({ - MEMORY_MAX_ENTITIES: "", - MEMORY_MAX_RELATIONS: " ", - }), - ).toEqual({}); + it("refuses an empty or blank variable rather than treating it as unset", () => { + expect(() => graphLimitsFromEnv({ MEMORY_MAX_ENTITIES: "" })).toThrow( + 'MEMORY_MAX_ENTITIES must be a positive whole number, got ""', + ); + expect(() => graphLimitsFromEnv({ MEMORY_MAX_RELATIONS: " " })).toThrow( + 'MEMORY_MAX_RELATIONS must be a positive whole number, got " "', + ); }); it("reads each limit from its variable, trimming whitespace", () => { @@ -259,6 +260,26 @@ describe("write limits", () => { ); }); + it("answers a call that adds nothing even when re-saving grows the file", async () => { + // A record with an extra space and no final newline: the save rewrites + // it in canonical form, a few bytes larger, though nothing was added. + const record = + '{"type":"entity", "name":"A","entityType":"person","observations":["x"]}'; + await fs.writeFile(filePath, record); + const client = await clientWith({ + maxFileBytes: Buffer.byteLength(record, "utf-8"), + }); + + const entities = await call(client, "create_entities", { + entities: [entity("A")], + }); + expect(entities.isError).toBeUndefined(); + const observations = await call(client, "add_observations", { + observations: [{ entityName: "A", contents: ["x"] }], + }); + expect(observations.isError).toBeUndefined(); + }); + it("refuses the first write to a new file that would already be too large", async () => { const client = await clientWith({ maxFileBytes: 10 }); const refused = await call(client, "create_entities", { diff --git a/src/memory/index.ts b/src/memory/index.ts index b028740369..a8c49fe56c 100644 --- a/src/memory/index.ts +++ b/src/memory/index.ts @@ -192,20 +192,20 @@ export const GRAPH_LIMIT_ENV: Readonly> = { maxFileBytes: "MEMORY_MAX_FILE_BYTES", }; -// Read the limits from the environment. An unset or empty variable leaves its -// limit off; anything other than a positive whole number stops startup, since -// a misspelt limit silently ignored would leave the store unbounded while the -// operator believes it capped. +// Read the limits from the environment. Only an unset variable leaves its +// limit off; any other value that is not a positive whole number, an empty +// one included, stops startup, since a limit silently ignored would leave the +// store unbounded while the operator believes it capped. export function graphLimitsFromEnv( env: NodeJS.ProcessEnv = process.env, ): GraphLimits { const limits: GraphLimits = {}; for (const key of Object.keys(GRAPH_LIMIT_ENV) as (keyof GraphLimits)[]) { const name = GRAPH_LIMIT_ENV[key]; - const raw = env[name]?.trim(); - if (raw === undefined || raw === "") { + if (env[name] === undefined) { continue; } + const raw = env[name].trim(); const value = Number(raw); if (!/^\d+$/.test(raw) || !Number.isSafeInteger(value) || value < 1) { throw new Error( @@ -739,7 +739,13 @@ export class KnowledgeGraphManager { !entities.slice(0, index).some((earlier) => earlier.name === e.name), ); graph.entities.push(...newEntities); - await this.saveGraph(graph, unreadable, size); + // Only a call that adds something is held to the limits: re-saving can + // grow an existing file that was not written in this exact form. + await this.saveGraph( + graph, + unreadable, + newEntities.length > 0 ? size : undefined, + ); return newEntities; }); } @@ -771,7 +777,12 @@ export class KnowledgeGraphManager { .some((earlier) => isSameRelation(earlier, r)), ); graph.relations.push(...newRelations); - await this.saveGraph(graph, unreadable, size); + // Held to the limits only when it adds something (see createEntities). + await this.saveGraph( + graph, + unreadable, + newRelations.length > 0 ? size : undefined, + ); return newRelations; }); } @@ -794,7 +805,9 @@ export class KnowledgeGraphManager { entity.observations.push(...newObservations); return { entityName: o.entityName, addedObservations: newObservations }; }); - await this.saveGraph(graph, unreadable, size); + // Held to the limits only when it adds something (see createEntities). + const added = results.some((r) => r.addedObservations.length > 0); + await this.saveGraph(graph, unreadable, added ? size : undefined); return results; }); } From a54816d57e7433b8878cb4709c088b8801bc6433 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 12 Nov 2026 00:01:34 -0500 Subject: [PATCH 3/3] test(memory): make the no-op byte-limit fixture actually grow on save Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/memory/__tests__/write-limits.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/memory/__tests__/write-limits.test.ts b/src/memory/__tests__/write-limits.test.ts index ed1c25af93..9cb816c3c4 100644 --- a/src/memory/__tests__/write-limits.test.ts +++ b/src/memory/__tests__/write-limits.test.ts @@ -261,10 +261,10 @@ describe("write limits", () => { }); it("answers a call that adds nothing even when re-saving grows the file", async () => { - // A record with an extra space and no final newline: the save rewrites - // it in canonical form, a few bytes larger, though nothing was added. + // A record with no final newline: the save writes it back with one, a + // byte larger than the limit, though nothing was added. const record = - '{"type":"entity", "name":"A","entityType":"person","observations":["x"]}'; + '{"type":"entity","name":"A","entityType":"person","observations":["x"]}'; await fs.writeFile(filePath, record); const client = await clientWith({ maxFileBytes: Buffer.byteLength(record, "utf-8"),