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:

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

SymptomCauseFix
SchemaViolationException: Property already exists with key 'name'Using default cardinality.list — Neptune allows multi-valued properties and created a second value instead of updatingUse property(cardinality.single, 'name', value) for single-valued properties throughout the codebase
coalesce() always creates a new vertex even when ID existsMissing fold() before coalesce() — traversal reaches coalesce() with an empty traverser rather than an empty listPattern is always g.V(id).fold().coalesce(__.unfold(), __.addV(...)) — the fold() is mandatory
Path traversal never returnsUnbounded repeat(out(...)).until(condition) on a dense graph — traversal explores billions of pathsAdd .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 stringvalueMap() without true argument omits T.id and T.label; reading .id() returns the driver's t.id tokenAlways use .valueMap(true) and read result.get ? result.get(T.id) : result[T.id]
Traversal serialization error for large batchesGremlin Groovy script size limit (~65 KB) — chaining hundreds of addV steps in one traversal string exceeds the limitChunk to <100 entities per traversal; for large initial loads use Neptune bulk loader (S3 CSV import)