Guide · Knowledge Graph · Agent Memory · Neptune · RAG

Knowledge Graph Schema Design for MCP Agent Memory — Neptune Property Graph

A knowledge graph stored in Amazon Neptune gives MCP servers persistent, structured memory that an LLM can query across conversation sessions, documents, and users. Unlike vector stores (which return similar passages) or relational databases (which return matching rows), a property graph returns relationship chains — the path from a user's question to an entity, through the entities that entity relates to, to the source documents and facts that justify a claim. This guide covers how to design a Neptune schema for MCP agent memory: vertex labels, edge labels, temporal fact versioning, multi-tenant namespace isolation, and the traversal patterns that power graph-augmented RAG.

TL;DR

Use four vertex labels: Document, Chunk, Entity, Concept. Use four edge labels: CONTAINS (Document→Chunk), MENTIONS (Chunk→Entity), RELATES_TO (Entity↔Entity/Concept), DERIVED_FROM (Entity→Source). Prefix vertex IDs with tenant ID for multi-tenant isolation: {tenantId}::{entityType}::{hash}. Add validFrom/validTo epoch-ms properties on edges for temporal fact versioning. Retrieve context with a two-hop traversal from the user's extracted entities to their neighbors' source chunks.

Why graph memory over vector memory for MCP servers

Vector stores excel at semantic similarity search — finding the passages most similar to a query embedding. Property graphs excel at relational traversal — following named relationships from a known entity to its connected facts. The two are complementary, not competitive. A well-designed MCP agent uses both:

Graph enrichment adds relational context that vector search misses. If the user asks "what are the dependencies of service X?", a vector search may return the service documentation, but a graph traversal can return the exact dependency chain: X → depends_on → Y → depends_on → Z → hosted_in → us-east-1. This level of relational precision is what makes MCP agent responses feel accurate rather than approximate.

Core schema: vertex labels and properties

Vertex labelID formatKey propertiesPurpose
Document{tenantId}::doc::{hash}title, url, source, ingestedAt, contentTypeTop-level document or conversation turn
Chunk{tenantId}::chunk::{hash}text, chunkIndex, tokenCount, embedding (optional — store in OpenSearch instead)Passage from a document; directly fed to LLM as context
Entity{tenantId}::entity::{type}::{canonicalName}name, entityType (Person/Organization/Service/Concept), description, confidenceNamed entity extracted from documents
Concept{tenantId}::concept::{slug}name, domain, definitionAbstract concept (not a named entity) — used as a hub for grouping related entities
Fact{tenantId}::fact::{hash}claim, confidence, validFrom, validTo, sourceExplicit factual claim with provenance and temporal validity

Core schema: edge labels

Edge labelFrom → ToKey edge propertiesMeaning
CONTAINSDocument → ChunkchunkIndex, offsetCharsDocument is the source of this chunk
MENTIONSChunk → Entitycount, confidence, spanStart, spanEndChunk text mentions this entity
RELATES_TOEntity ↔ Entity, Entity → ConceptrelationshipType, weight, validFrom, validToGeneral relationship; relationshipType carries the semantic (depends_on, is_part_of, authored_by)
DERIVED_FROMEntity/Fact → Documentconfidence, extractedAtEntity was extracted from this document
CONTRADICTSFact → FactdetectedAt, resolvedAt (null if unresolved)Two facts conflict; used to surface discrepancies in agent responses
SUPERSEDESFact → FactsupersededAtNewer fact replaces an older one; older fact's validTo is set to supersededAt

MCP tool definitions for the knowledge graph

Design MCP tools to map directly to graph operations. Keep tool scope narrow — the LLM should compose multiple small tools rather than one monolithic tool that does entity extraction, relationship inference, and storage in a single call.

// Tool: store_entity — upsert an entity after extraction
{
  name: 'store_entity',
  inputSchema: {
    type: 'object',
    properties: {
      id: { type: 'string', description: 'Stable entity ID, e.g. entity::Service::aws-lambda' },
      name: { type: 'string' },
      entityType: { type: 'string', enum: ['Person', 'Organization', 'Service', 'Concept', 'Location'] },
      description: { type: 'string' },
      sourceDocumentId: { type: 'string', description: 'Document vertex ID this entity came from' },
    },
    required: ['id', 'name', 'entityType'],
  },
}

// Tool: add_relationship — connect two entities
{
  name: 'add_relationship',
  inputSchema: {
    type: 'object',
    properties: {
      fromId: { type: 'string' },
      toId: { type: 'string' },
      relationshipType: { type: 'string', description: 'Semantic label: depends_on, is_part_of, authored_by, etc.' },
      weight: { type: 'number', minimum: 0, maximum: 1, description: 'Confidence score 0–1' },
    },
    required: ['fromId', 'toId', 'relationshipType'],
  },
}

// Tool: get_entity_context — retrieve graph neighborhood for RAG enrichment
{
  name: 'get_entity_context',
  inputSchema: {
    type: 'object',
    properties: {
      entityId: { type: 'string' },
      hops: { type: 'integer', minimum: 1, maximum: 3, default: 2 },
      includeSourceChunks: { type: 'boolean', default: true },
    },
    required: ['entityId'],
  },
}
// Implementation of get_entity_context
async function getEntityContext(entityId: string, hops: number, includeSourceChunks: boolean) {
  const g = await getTraversalSource();

  // Get entity itself
  const entity = await g.V(entityId).valueMap(true).next();
  if (!entity.value) throw new Error(`Entity not found: ${entityId}`);

  // Get N-hop neighborhood
  const neighborhood = await g.V(entityId)
    .repeat(__.both('RELATES_TO').simplePath())
    .times(hops)
    .dedup()
    .limit(30)
    .project('entity', 'distance')
    .by(__.valueMap(true))
    .by(__.loops())   // loops() returns current traversal depth
    .toList();

  // Get source chunks if requested
  let sourceChunks: unknown[] = [];
  if (includeSourceChunks) {
    sourceChunks = await g.V(entityId)
      .in_('MENTIONS')               // Chunk that mentions this entity
      .has('tokenCount', P.lt(512))  // filter to shorter chunks that fit in context
      .limit(5)
      .values('text')
      .toList();
  }

  return {
    entity: entity.value,
    neighbors: neighborhood.map(n => ({ entity: n.entity, hops: n.distance })),
    sourceChunks,
  };
}

Temporal fact versioning

Facts in a knowledge graph change over time. The price of a service changes. An API endpoint is deprecated. An employee leaves a company. Neptune does not have a native time-travel feature — you model temporal validity with validFrom and validTo epoch-ms properties on edges and Fact vertices.

// Add a versioned fact
await g.addV('Fact')
  .property(T.id, `fact::${hash(claim)}`)
  .property('claim', claim)
  .property('confidence', 0.95)
  .property('validFrom', Date.now())
  .property('validTo', null)         // null = currently valid
  .next();

// Supersede an old fact with a new one
async function supersedeFact(oldFactId: string, newClaim: string) {
  const now = Date.now();

  // Mark old fact as expired
  await g.V(oldFactId)
    .property(cardinality.single, 'validTo', now)
    .next();

  // Create new fact
  const newFactId = `fact::${hash(newClaim)}`;
  await g.addV('Fact')
    .property(T.id, newFactId)
    .property('claim', newClaim)
    .property('validFrom', now)
    .property('validTo', null)
    .next();

  // Link them
  await g.addE('SUPERSEDES')
    .from_(g.V(newFactId))
    .to_(g.V(oldFactId))
    .property('supersededAt', now)
    .next();
}

// Query only currently valid facts
const currentFacts = await g.V()
  .hasLabel('Fact')
  .has('validTo', null)        // validTo is null for currently active facts
  .related(entityId, 'ABOUT')  // filter to facts about a specific entity
  .values('claim')
  .toList();

An alternative to null-terminated validity is using a sentinel value like Number.MAX_SAFE_INTEGER. The null approach is more readable but requires Neptune to handle null in has('validTo', null) — Neptune supports this correctly, unlike some graph databases that require a placeholder value.

Multi-tenant namespace isolation

When an MCP server serves multiple tenants (organizations or users), all sharing the same Neptune cluster, vertex IDs must be namespaced to prevent tenants from reading each other's data. Neptune has no row-level security equivalent — isolation is by ID prefix convention enforced at the application layer.

const TENANT_ID = process.env.TENANT_ID!;  // injected at deploy time, not from user input

function tenantId(type: string, name: string): string {
  // Hash the name so it's URL-safe and consistent
  return `${TENANT_ID}::${type}::${createHash('sha256').update(name).digest('hex').slice(0, 16)}`;
}

// All graph operations prefix IDs with tenant
async function storeEntityForTenant(name: string, entityType: string) {
  const id = tenantId('entity', `${entityType}::${name.toLowerCase()}`);
  return upsertEntity(id, name, entityType);
}

// All traversals scope to tenant's vertex ID prefix
async function getEntitiesForTenant(entityType: string, limit: number = 20) {
  return g.V()
    .hasLabel('Entity')
    .has(T.id, P.between(`${TENANT_ID}::entity::`, `${TENANT_ID}::entity::z`))
    .has('entityType', entityType)
    .limit(limit)
    .valueMap(true)
    .toList();
}

Neptune does support predicates on the special T.id property with P.between() and P.within(). This allows prefix-range scans, which are efficient because Neptune indexes vertex IDs. Prefix scanning is not a full-text search — it uses string ordering, so ensure your prefix is a fixed-width, sortable string.

Common failures

SymptomCauseFix
Graph context retrieval is too slow for real-time MCP responses (>2 s)Multi-hop traversal on large graph without index hints; both('RELATES_TO') on a highly-connected entity scans all edgesAdd a maximum hop count ≤ 2 for real-time calls; cache k-hop neighborhoods in DynamoDB with 5-minute TTL; reserve deep traversals for async background jobs
Tenant A can read Tenant B's entities via ID guessingID prefix is based on a predictable hash rather than a secretUse a UUID tenant ID (not organization name) as the prefix; validate the tenant prefix on every read operation before returning data
has('validTo', null) returns no results even when facts have null validToNeptune represents null properties by the absence of the property, not by storing null; has('validTo', null) matches vertices where the property is absentThis actually works correctly in Neptune — a vertex with no validTo property matches has('validTo', null); if no results, check that facts were written without validTo property rather than with an explicit null value
Entity deduplication is inconsistent — same real-world entity stored under different IDsName normalization not applied before hashing (e.g., "AWS Lambda" vs "aws lambda" vs "Lambda")Normalize to lowercase, strip punctuation, and sort tokens alphabetically before hashing; consider using a canonical name lookup (Concept vertex as hub) instead of name-derived IDs