Skip to content

memory: create_relations accepts relations to non-existent entities (dangling relations, silent graph corruption) #4457

Description

@chrisbartoloburlo

Summary

The Memory server's create_relations does not check that the referenced entities exist, so it silently persists relations pointing at entities that were never created (dangling relations). add_observations in the same file does validate entity existence, so the behaviour is inconsistent.

Reproduction

Against @modelcontextprotocol/server-memory (current main), on a fresh graph:

  1. Call create_relations with [{ "from": "Ghost_A", "to": "Ghost_B", "relationType": "knows" }] — no entities exist yet.
    → returns success (isError: false).
  2. Call read_graphentities: [], relations: [{ "from": "Ghost_A", "to": "Ghost_B", ... }] — a dangling relation.

For contrast, add_observations with { "entityName": "Ghost_C", "contents": [...] } correctly fails with Entity with name Ghost_C not found.

Live output:

create_relations(nonexistent) -> isError: False
  returned: [ { "from": "Ghost_A", "to": "Ghost_B", "relationType": "knows" } ]
add_observations(nonexistent) -> isError: True
  returned: Entity with name Ghost_C not found
read_graph -> entities: 0 | relations: 1

A minimal (~30-line) stdio reproduction using the MCP Python client is here: https://github.com/chrisbartoloburlo/llmcontract-mcp-audit/blob/main/servers/memory/probe.py

Root cause

src/memory/index.ts, createRelations filters only for duplicates and then pushes, with no existence check — unlike addObservations, which throws Entity with name ... not found:

async createRelations(relations: Relation[]): Promise<Relation[]> {
  const graph = await this.loadGraph();
  const newRelations = relations.filter(r => !graph.relations.some(er =>
    er.from === r.from && er.to === r.to && er.relationType === r.relationType));
  graph.relations.push(...newRelations);   // no check that from/to exist
  await this.saveGraph(graph);
  return newRelations;
}

Impact

read_graph returns referentially-inconsistent state that persists to MEMORY_FILE_PATH. Downstream consumers that assume referential integrity (graph traversal, visualization, an agent reasoning over the graph) get silently corrupt data — whereas the analogous observation case fails loudly.

Suggested fix

In createRelations, validate that every relation's from/to names an existing entity (mirroring addObservations) and throw, or skip-and-report, otherwise.


Found with llmcontract, a session-type runtime monitor for agent/tool protocols: encoding the documented "entities before relations" workflow as a contract flags these sequences that the server accepts. Full case study (protocol, probe, replay tests): https://github.com/chrisbartoloburlo/llmcontract-mcp-audit/tree/main/servers/memory

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions