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 edaa21397e..147429f73d 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) @@ -195,22 +204,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"] } } } @@ -223,12 +237,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"] } } } @@ -243,10 +252,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" } @@ -262,12 +268,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" } @@ -277,6 +278,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 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. @@ -299,6 +305,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: @@ -324,10 +341,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"] } } } @@ -340,12 +354,7 @@ On Windows, use: "servers": { "memory": { "command": "cmd", - "args": [ - "/c", - "npx", - "-y", - "@modelcontextprotocol/server-memory" - ] + "args": ["/c", "npx", "-y", "@modelcontextprotocol/server-memory"] } } } @@ -375,7 +384,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: @@ -408,7 +417,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 @@ -435,7 +444,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..0615304ea8 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", undefined, 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..4e81d934a8 --- /dev/null +++ b/src/memory/__tests__/write-limits.test.ts @@ -0,0 +1,392 @@ +// 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 blocks show the limits reach the Streamable HTTP endpoint, and +// that main() reads them from the environment. +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { promises as fs } from "fs"; +import path from "path"; +import { + Client, + InMemoryTransport, + StreamableHTTPClientTransport, +} 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 { startHttpServer } from "../http.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("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", () => { + 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("answers a call that adds nothing even when re-saving grows the file", async () => { + // 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"]}'; + 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", { + 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("write limits over Streamable HTTP", () => { + it("holds the endpoint's write tools to the limits it was given", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + const { filePath, cleanup } = await makeTempGraph(); + const running = await startHttpServer( + filePath, + { host: "127.0.0.1", port: 0 }, + { maxEntities: 1 }, + ); + const client = new Client({ name: "http-test", version: "0.0.0" }); + try { + await client.connect( + new StreamableHTTPClientTransport(new URL(running.url)), + ); + 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 running.close(); + await cleanup(); + vi.restoreAllMocks(); + } + }); +}); + +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/http.ts b/src/memory/http.ts index a00d843139..7d4846d2ae 100644 --- a/src/memory/http.ts +++ b/src/memory/http.ts @@ -28,6 +28,7 @@ import { toNodeHandler, } from "@modelcontextprotocol/node"; import { createServer, RESOURCE_URI } from "./index.js"; +import type { GraphLimits } from "./index.js"; /** The interface bound when `HOST` is unset: loopback only. */ export const DEFAULT_HOST = "127.0.0.1"; @@ -70,19 +71,23 @@ export interface RunningHttpServer { /** * Bind the endpoint on `host`:`port` (port 0 picks a free one) and serve the - * memory server over the graph at `memoryFilePath` on it. Rejects when the - * port cannot be bound. + * memory server over the graph at `memoryFilePath` on it, holding its write + * tools to `limits`. Rejects when the port cannot be bound. */ export async function startHttpServer( memoryFilePath: string, { host, port }: { host: string; port: number }, + limits: GraphLimits = {}, ): Promise { const onerror = (error: Error) => console.error("Error serving MCP request:", error); const handler: McpHttpHandler = createMcpHandler( ({ era }) => - createServer(memoryFilePath, era, () => - handler.notify.resourceUpdated(RESOURCE_URI), + createServer( + memoryFilePath, + era, + () => handler.notify.resourceUpdated(RESOURCE_URI), + limits, ), { onerror }, ); diff --git a/src/memory/index.ts b/src/memory/index.ts index c163769103..225db87524 100644 --- a/src/memory/index.ts +++ b/src/memory/index.ts @@ -173,6 +173,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. 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]; + 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( + `${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 { @@ -471,6 +525,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. @@ -521,9 +576,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"); @@ -581,14 +639,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; } @@ -611,9 +678,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 @@ -639,6 +747,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, @@ -683,7 +795,7 @@ export class KnowledgeGraphManager { // umask removed. await fs.writeFile( tempFilePath, - lines.join("\n") + "\n", + content, mode === undefined ? undefined : { mode }, ); if (mode !== undefined) { @@ -699,7 +811,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( @@ -709,14 +821,20 @@ export class KnowledgeGraphManager { !entities.slice(0, index).some((earlier) => earlier.name === e.name), ); graph.entities.push(...newEntities); - await this.saveGraph(graph, unreadable); + // 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; }); } 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) => { @@ -741,7 +859,12 @@ export class KnowledgeGraphManager { .some((earlier) => isSameRelation(earlier, r)), ); graph.relations.push(...newRelations); - await this.saveGraph(graph, unreadable); + // Held to the limits only when it adds something (see createEntities). + await this.saveGraph( + graph, + unreadable, + newRelations.length > 0 ? size : undefined, + ); return newRelations; }); } @@ -750,7 +873,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) { @@ -764,7 +887,9 @@ export class KnowledgeGraphManager { entity.observations.push(...newObservations); return { entityName: o.entityName, addedObservations: newObservations }; }); - await this.saveGraph(graph, unreadable); + // 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; }); } @@ -1043,13 +1168,19 @@ const ReadEntitySchema = EntitySchema.extend({ // announces every change instead of the instance itself, whatever era wrote // it: over HTTP each request gets a fresh server that holds no listen stream // and no subscriber, so http.ts hands in the handler's subscription bus, which -// every open 2026-07-28 stream listens on. +// every open 2026-07-28 stream listens on. `limits` caps how large the write +// tools may grow the graph; main() and run() read it from the environment. export function createServer( memoryFilePath: string, era: "legacy" | "modern" = "legacy", publishGraphUpdated?: () => void, + limits: GraphLimits = {}, ): McpServer { - const knowledgeGraphManager = new KnowledgeGraphManager(memoryFilePath); + const knowledgeGraphManager = new KnowledgeGraphManager( + memoryFilePath, + {}, + limits, + ); const server = new McpServer({ name: "memory-server", @@ -1520,21 +1651,25 @@ 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); } installLockCleanup(); - 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, undefined, 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; } @@ -1597,12 +1732,13 @@ export async function run( return main(); } const memoryFilePath = await ensureMemoryFilePath(); + const limits = graphLimitsFromEnv(); const locationWarning = memoryFileLocationWarning(memoryFilePath); if (locationWarning) { console.error(locationWarning); } const { httpEndpoint, startHttpServer } = await import("./http.js"); - const running = await startHttpServer(memoryFilePath, httpEndpoint()); + const running = await startHttpServer(memoryFilePath, httpEndpoint(), limits); console.error( `Knowledge Graph MCP Server running on Streamable HTTP at ${running.url}`, );