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:
- Vector search (OpenSearch k-NN, Bedrock Knowledge Bases) for initial retrieval: "find chunks semantically similar to the user's query."
- Graph traversal (Neptune) for enrichment: "given the entities mentioned in those chunks, what other entities and facts connect to them in the knowledge base?"
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 label | ID format | Key properties | Purpose |
|---|---|---|---|
Document | {tenantId}::doc::{hash} | title, url, source, ingestedAt, contentType | Top-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, confidence | Named entity extracted from documents |
Concept | {tenantId}::concept::{slug} | name, domain, definition | Abstract concept (not a named entity) — used as a hub for grouping related entities |
Fact | {tenantId}::fact::{hash} | claim, confidence, validFrom, validTo, source | Explicit factual claim with provenance and temporal validity |
Core schema: edge labels
| Edge label | From → To | Key edge properties | Meaning |
|---|---|---|---|
CONTAINS | Document → Chunk | chunkIndex, offsetChars | Document is the source of this chunk |
MENTIONS | Chunk → Entity | count, confidence, spanStart, spanEnd | Chunk text mentions this entity |
RELATES_TO | Entity ↔ Entity, Entity → Concept | relationshipType, weight, validFrom, validTo | General relationship; relationshipType carries the semantic (depends_on, is_part_of, authored_by) |
DERIVED_FROM | Entity/Fact → Document | confidence, extractedAt | Entity was extracted from this document |
CONTRADICTS | Fact → Fact | detectedAt, resolvedAt (null if unresolved) | Two facts conflict; used to surface discrepancies in agent responses |
SUPERSEDES | Fact → Fact | supersededAt | Newer 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
| Symptom | Cause | Fix |
|---|---|---|
| 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 edges | Add 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 guessing | ID prefix is based on a predictable hash rather than a secret | Use 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 validTo | Neptune represents null properties by the absence of the property, not by storing null; has('validTo', null) matches vertices where the property is absent | This 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 IDs | Name 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 |