Guide · Gremlin · TinkerPop 3 · Amazon Neptune · Knowledge Graph
Gremlin Traversals for MCP Server Knowledge Graphs — Neptune TinkerPop Guide
Gremlin is the query language of Apache TinkerPop, the graph processing framework that Amazon Neptune supports natively. For MCP server developers, Gremlin is the most powerful way to build and traverse a property graph — storing entities, relationships, and properties from document ingestion, then retrieving multi-hop context chains for LLM tool responses. This guide covers every Gremlin pattern you need for a production MCP knowledge graph: vertex/edge creation, complex path traversals, upsert with coalesce(), paginated queries, and bulk insert batching.
TL;DR
Add vertices with g.addV('Label').property(id, 'your-id').property('name', 'value'). Add edges with g.addE('EDGE_LABEL').from_(v1).to_(v2). Upsert a vertex with g.V('id').fold().coalesce(__.unfold(), __.addV('Label').property(id, 'id')). Traverse neighbors with g.V('id').out('EDGE_LABEL').valueMap(true). Use .range(offset, offset+limit) for pagination. Never use .toList() on unbounded traversals — always add .limit(N) first.
Property graph model overview
Neptune's property graph model (used by both Gremlin and openCypher) has four first-class concepts:
- Vertex: a node with a label (type) and zero or more key-value properties. Example:
(:Entity { id: 'entity-001', name: 'AWS Lambda', category: 'compute' }). - Edge: a directed relationship between two vertices, with a label and optional properties. Edges are always directed; to model bidirectional relationships, traverse with
both()rather than creating two edges. - Property: a key-value pair on a vertex or edge. Neptune supports multi-valued properties (a key can have multiple values) using set cardinality.
- ID: every vertex and edge has a string ID. Neptune auto-generates IDs if you omit
property(id, '...'), but providing stable, meaningful IDs (e.g.,entity::{hash}) enables idempotent upserts and makes traversals more readable.
Neptune does NOT support multi-label vertices (a vertex can have exactly one label). Unlike Neo4j, you cannot do :Entity:Person. Model multiple types with a hierarchy: use a base label (Entity) with a subtype property ('Person'), or use separate vertex labels if traversal performance on type filtering is critical.
Basic vertex and edge CRUD
import gremlin from 'gremlin';
const { process: { P, T, cardinality } } = gremlin;
const __ = gremlin.process.statics;
// Add a vertex with a stable ID
await g.addV('Entity')
.property(T.id, 'entity::aws-lambda')
.property('name', 'AWS Lambda')
.property('category', 'compute')
.property('createdAt', Date.now())
.next();
// Add another vertex
await g.addV('Concept')
.property(T.id, 'concept::serverless')
.property('name', 'serverless computing')
.next();
// Add a directed edge
await g.addE('RELATES_TO')
.from_(g.V('entity::aws-lambda'))
.to_(g.V('concept::serverless'))
.property('weight', 0.9)
.property('source', 'ingestion-pipeline')
.next();
// Read a vertex with all properties (valueMap(true) includes T.id and T.label)
const vertex = await g.V('entity::aws-lambda').valueMap(true).next();
// vertex.value = Map { T.id: 'entity::aws-lambda', T.label: 'Entity', name: ['AWS Lambda'], ... }
// Update a property on an existing vertex
await g.V('entity::aws-lambda')
.property(cardinality.single, 'category', 'compute-serverless')
.next();
// cardinality.single overwrites existing value; default cardinality.list appends
// Delete a vertex (also removes all incident edges)
await g.V('entity::aws-lambda').drop().next();
The T.id import (gremlin.process.T.id) is the TinkerPop token for the special id property. Using .property('id', value) (string key) is a user property named "id", NOT the vertex ID. Always use T.id when setting the graph ID.
Traversal patterns for MCP tool calls
// Find neighbors — all vertices connected to an entity by outgoing RELATES_TO edges
const neighbors = await g.V('entity::aws-lambda')
.out('RELATES_TO')
.valueMap(true)
.toList();
// Find incoming edges — what other entities mention this concept?
const mentioners = await g.V('concept::serverless')
.in_('RELATES_TO') // underscore avoids conflict with JS 'in' keyword
.hasLabel('Entity')
.values('name')
.toList();
// Two-hop traversal — entities related to entities related to this one
const twoHops = await g.V('entity::aws-lambda')
.out('RELATES_TO')
.out('RELATES_TO')
.dedup() // remove duplicate vertices reached by multiple paths
.limit(50)
.valueMap(true)
.toList();
// Path traversal — return the full path not just the endpoint
const paths = await g.V('entity::aws-lambda')
.repeat(__.out('RELATES_TO').simplePath()) // simplePath() avoids cycles
.times(3) // exactly 3 hops
.path()
.by(__.values('name')) // map each step to the 'name' property
.limit(10)
.toList();
// Find shortest path between two vertices
const shortestPath = await g.V('entity::aws-lambda')
.repeat(__.both().simplePath())
.until(__.hasId('concept::kubernetes'))
.path()
.by(__.values('name'))
.limit(1)
.next();
Always add .limit(N) before .toList() on traversals that may return large result sets. Neptune executes traversals eagerly — without a limit, a traversal over 100 M vertices returns all 100 M records to the driver before your code sees any results.
Upsert pattern with coalesce()
The coalesce() step is Neptune's idiomatic upsert: "if the vertex exists, return it; otherwise create it." This pattern is essential for idempotent MCP tool calls that ingest entities from external systems.
// Upsert a vertex: create if not exists, return existing if already present
async function upsertEntity(id: string, name: string, category: string) {
return g.V(id)
.fold() // collect into a list (empty if not found)
.coalesce(
__.unfold(), // if list non-empty, unfold it (return existing vertex)
__.addV('Entity') // if list empty, create new vertex
.property(T.id, id)
.property('name', name)
.property('category', category)
.property('createdAt', Date.now()),
)
.next();
}
// Upsert an edge: check if edge already exists before creating
async function upsertEdge(fromId: string, toId: string, label: string, weight: number) {
return g.V(fromId).as('from')
.V(toId).as('to')
.select('from')
.outE(label).where(__.inV().as('to')) // find existing edge between these two
.fold()
.coalesce(
__.unfold(), // return existing edge
__.addE(label) // create new edge
.from_(__.V(fromId))
.to_(__.V(toId))
.property('weight', weight),
)
.next();
}
// Upsert with property update — merge properties if vertex already exists
async function upsertEntityWithUpdate(id: string, props: Record<string, unknown>) {
const existing = await g.V(id).fold().coalesce(
__.unfold(),
__.addV('Entity').property(T.id, id),
).next();
// Update properties on the found/created vertex
let traversal = g.V(id);
for (const [key, value] of Object.entries(props)) {
traversal = traversal.property(cardinality.single, key, value);
}
return traversal.next();
}
coalesce() evaluates its arguments left-to-right and returns the first non-empty traversal. The fold() before it converts the stream of zero-or-one vertices into a list, making the subsequent unfold() safe — without fold(), coalesce() would see an empty traverser (not a list) and would not evaluate the second argument correctly.
Pagination for MCP tool responses
MCP tools return structured data to the LLM; returning thousands of entities at once wastes tokens and confuses the model. Use range() for cursor-based pagination.
interface PaginatedResult<T> {
items: T[];
nextOffset: number | null;
}
async function listEntities(
category: string,
offset: number = 0,
pageSize: number = 20,
): Promise<PaginatedResult<{ id: string; name: string }>> {
const items = await g.V()
.hasLabel('Entity')
.has('category', category)
.order().by('name', gremlin.process.order.asc)
.range(offset, offset + pageSize + 1) // fetch one extra to detect next page
.valueMap(true)
.toList();
const hasMore = items.length > pageSize;
const page = hasMore ? items.slice(0, pageSize) : items;
return {
items: page.map(v => ({
id: v[T.id] as string,
name: (v['name'] as string[])[0],
})),
nextOffset: hasMore ? offset + pageSize : null,
};
}
// Keyset pagination by ID (more stable than offset for concurrent writes)
async function listEntitiesAfterCursor(
category: string,
afterId: string | null,
pageSize: number = 20,
) {
let traversal = g.V().hasLabel('Entity').has('category', category).order().by(T.id);
if (afterId) {
traversal = traversal.has(T.id, P.gt(afterId));
}
return traversal.limit(pageSize).valueMap(true).toList();
}
Bulk insert for knowledge graph ingestion
When an MCP tool call ingests a document and must insert many vertices and edges, batch the operations in a single traversal rather than making a network round-trip per entity.
interface EntityRecord {
id: string;
name: string;
label: string;
properties: Record<string, string | number>;
}
interface EdgeRecord {
fromId: string;
toId: string;
label: string;
weight: number;
}
async function bulkUpsertGraph(entities: EntityRecord[], edges: EdgeRecord[]) {
// Neptune's TinkerPop driver submits the full traversal as a single request
// Chain all addV/coalesce steps in one traversal for a single round-trip
// Limit: a single traversal string cannot exceed ~65 KB (Neptune HTTP limit for Gremlin scripts)
// For large batches, chunk into groups of 100-200 entities
const CHUNK_SIZE = 100;
for (let i = 0; i < entities.length; i += CHUNK_SIZE) {
const chunk = entities.slice(i, i + CHUNK_SIZE);
let traversal = g;
for (const entity of chunk) {
// Each addV returns the new vertex or existing; use inject to chain
traversal = traversal.V(entity.id)
.fold()
.coalesce(
__.unfold(),
__.addV(entity.label)
.property(T.id, entity.id)
.property('name', entity.name),
)
.property(cardinality.single, 'updatedAt', Date.now()) as any;
for (const [k, v] of Object.entries(entity.properties)) {
(traversal as any) = (traversal as any).property(cardinality.single, k, v);
}
}
await (traversal as any).iterate();
}
// Insert edges after vertices (avoid foreign-key-equivalent errors)
for (let i = 0; i < edges.length; i += CHUNK_SIZE) {
const chunk = edges.slice(i, i + CHUNK_SIZE);
for (const edge of chunk) {
await upsertEdge(edge.fromId, edge.toId, edge.label, edge.weight);
}
}
}
For ingestion at scale (millions of edges), use Neptune's bulk loader API with S3 CSV files instead of Gremlin traversals — see the Neptune bulk load guide. Gremlin-based insertion is best for incremental updates of hundreds to low thousands of vertices per MCP tool call.
Common failures
| Symptom | Cause | Fix |
|---|---|---|
SchemaViolationException: Property already exists with key 'name' | Using default cardinality.list — Neptune allows multi-valued properties and created a second value instead of updating | Use property(cardinality.single, 'name', value) for single-valued properties throughout the codebase |
coalesce() always creates a new vertex even when ID exists | Missing fold() before coalesce() — traversal reaches coalesce() with an empty traverser rather than an empty list | Pattern is always g.V(id).fold().coalesce(__.unfold(), __.addV(...)) — the fold() is mandatory |
| Path traversal never returns | Unbounded repeat(out(...)).until(condition) on a dense graph — traversal explores billions of paths | Add .times(N) as a bound: repeat(...).until(...).times(5) limits depth to 5 hops; always combine until() with times() |
| Vertex ID comes back as a typed object, not a string | valueMap() without true argument omits T.id and T.label; reading .id() returns the driver's t.id token | Always use .valueMap(true) and read result.get ? result.get(T.id) : result[T.id] |
| Traversal serialization error for large batches | Gremlin Groovy script size limit (~65 KB) — chaining hundreds of addV steps in one traversal string exceeds the limit | Chunk to <100 entities per traversal; for large initial loads use Neptune bulk loader (S3 CSV import) |