Back to Landing

Graph Query + MCP API

Use direct AQL for explicit graph operations, or use MCP tools for agent-first natural-language querying with guarded query generation and execution.

Quick cURL

curl -X POST https://api.lunarchain.net/api/v1/graph/aql-query \
  -H "Content-Type: application/json" \
  -d '{
    "query": "FOR r IN nodes_vertex_collection FILTER r.type == \"report\" AND r._is_latest == true SORT r.modified DESC LIMIT 25 RETURN { id: r._id, name: r.name, modified: r.modified }"
  }'

Base Configuration

Base URL

https://api.lunarchain.net/api/v1

Primary Endpoint

POST /graph/aql-query

Authentication

No auth header is currently required for /graph/aql-query.

Payload Format

JSON body with one field: query.

Required Request Fields

MethodPOST
URLhttps://api.lunarchain.net/api/v1/graph/aql-query
Required Headers
Content-Type: application/json
Required Body{ "query": "<AQL string>" }
Optional Body
bind_vars: { ... }options: { ... }

Endpoint Focus

Primary Endpoint

POST /api/v1/graph/aql-query executes custom AQL directly against the intelligence graph.

Agent Ready

AI agents can convert user intent into AQL, execute, then summarize graph-backed intelligence results.

Bounded By Design

Use LIMITs, date filters, and constrained traversals to keep response time predictable.

Schema-Aware

Queries target nodes_vertex_collection and graph traversals over lunargraph_graph.

MCP Server Docs

MCP Endpoint

https://api.lunarchain.net/api/v1/graph/mcp

Transport

Streamable HTTP over the /api/v1/graph/mcp route.

Session Header

Use mcp-session-id from initialize response headers for subsequent calls.

Primary Tool

Use query_intel first for natural-language intelligence requests.

query_intel

Use: Default tool for agentic graph querying from natural language.

Why: Prevents debugger-style workflows for normal intel questions and enforces bounded safe AQL generation.

build_query_from_intent

Use: Generate/inspect AQL + bind vars from natural language without execution.

Why: Enables transparent review and deterministic retries before runtime execution.

run_read_query

Use: Execute explicit custom read-only AQL with strict runtime/row caps.

Why: Provides full flexibility for advanced operators and agent-authored custom AQL.

query_examples

Use: Retrieve known-good AQL templates by use case.

Why: Gives agents high-signal patterns to adapt instead of inventing fragile traversals.

Install

# Python MCP client (example)
pip install "mcp>=1.18.0,<2.0.0"

# Or use your preferred MCP-compatible client/runtime

Initialize Session

# Initialize MCP session (capture mcp-session-id from response headers)
curl -i -X POST https://api.lunarchain.net/api/v1/graph/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{
    "jsonrpc":"2.0",
    "id":"init-1",
    "method":"initialize",
    "params":{
      "protocolVersion":"2025-03-26",
      "capabilities":{},
      "clientInfo":{"name":"local-test","version":"1.0.0"}
    }
  }'

List Tools (Test)

# List available MCP tools (use mcp-session-id from initialize response)
curl -i -X POST https://api.lunarchain.net/api/v1/graph/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "mcp-session-id: <SESSION_ID>" \
  -d '{
    "jsonrpc":"2.0",
    "id":"tools-1",
    "method":"tools/list",
    "params":{}
  }'

NL Intent -> AQL Preview

# Convert natural-language request into guarded AQL (preview only)
curl -i -X POST https://api.lunarchain.net/api/v1/graph/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "mcp-session-id: <SESSION_ID>" \
  -d '{
    "jsonrpc":"2.0",
    "id":"intent-1",
    "method":"tools/call",
    "params":{
      "name":"build_query_from_intent",
      "arguments":{
        "question":"latest intel in iran about ford in last 7 days",
        "result_limit":40
      }
    }
  }'

NL Query Execution

# Primary tool: ask in natural language and execute
curl -i -X POST https://api.lunarchain.net/api/v1/graph/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "mcp-session-id: <SESSION_ID>" \
  -d '{
    "jsonrpc":"2.0",
    "id":"intel-1",
    "method":"tools/call",
    "params":{
      "name":"query_intel",
      "arguments":{
        "question":"what is the latest intel in iran",
        "result_limit":30
      }
    }
  }'

Payload and Response

Request Body

{
  "query": "LET startDate = DATE_ISO8601(DATE_SUBTRACT(DATE_NOW(), 30, \"days\"))\nFOR r IN nodes_vertex_collection\n  FILTER r.type == \"report\" AND r._is_latest == true\n  FILTER r.created >= startDate\n  SORT r.modified DESC\n  LIMIT 50\n  RETURN { id: r._id, name: r.name, modified: r.modified }"
}

Success

{
  "ok": true,
  "data": [
    {
      "id": "nodes_vertex_collection/report--...",
      "name": "Article: ...",
      "modified": "2026-03-17T20:10:11Z"
    }
  ],
  "meta": { "requestId": "..." }
}

Error

{
  "ok": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid query payload"
  }
}

AQL Query Playbook

Ordered from simple to advanced. Each example is written against nodes_vertex_collection and lunargraph_graph.

Example 1Simple

Latest Reports

Get the most recent reports in the graph.

FOR reportDoc IN nodes_vertex_collection
  FILTER reportDoc.type == "report" AND reportDoc._is_latest == true
  SORT reportDoc.modified DESC
  LIMIT 25
  RETURN {
    id: reportDoc._id,
    name: reportDoc.name,
    modified: reportDoc.modified
  }
Example 2Simple

Latest Reports In A Date Window

Get reports in the last 14 days.

LET startDate = DATE_ISO8601(DATE_SUBTRACT(DATE_NOW(), 14, "days"))

FOR reportDoc IN nodes_vertex_collection
  FILTER reportDoc.type == "report" AND reportDoc._is_latest == true
  FILTER reportDoc.created >= startDate
  SORT reportDoc.modified DESC
  LIMIT 50
  RETURN {
    id: reportDoc._id,
    name: reportDoc.name,
    created: reportDoc.created,
    modified: reportDoc.modified
  }
Example 3Simple

Reports Linked To A Country

Pivot from location to reports (example: US).

LET countryCode = "US"
LET locationIds = (
  FOR loc IN nodes_vertex_collection
    FILTER loc.type == "location"
    FILTER UPPER(TRIM(TO_STRING(loc.country))) == countryCode
    RETURN loc._id
)

FOR locId IN locationIds
  FOR reportDoc, edge, path IN 1..2 ANY locId GRAPH "lunargraph_graph"
    PRUNE LENGTH(path.vertices) == 2
      AND LOWER(TO_STRING(reportDoc.type)) IN ["marking-definition", "identity"]
    FILTER reportDoc.type == "report" AND reportDoc._is_latest == true
    COLLECT reportId = reportDoc._id, reportName = reportDoc.name, modified = reportDoc.modified
    SORT modified DESC
    LIMIT 50
    RETURN { reportId, reportName, modified }
Example 4Intermediate

Indicator To Reports Pivot

Find reports connected to a specific indicator.

LET indicatorName = "email address: [email protected]"

FOR indicator IN nodes_vertex_collection
  FILTER indicator.type == "indicator"
  FILTER LOWER(TRIM(TO_STRING(indicator.name))) == LOWER(indicatorName)
  FOR reportDoc, edge, path IN 1..2 ANY indicator._id GRAPH "lunargraph_graph"
    PRUNE LENGTH(path.vertices) == 2
      AND LOWER(TO_STRING(reportDoc.type)) IN ["marking-definition", "identity"]
    FILTER reportDoc.type == "report" AND reportDoc._is_latest == true
    COLLECT reportId = reportDoc._id, reportName = reportDoc.name, modified = reportDoc.modified
    SORT modified DESC
    LIMIT 40
    RETURN { reportId, reportName, modified }
Example 5Intermediate

Entity Bundle Per Recent Report

Pull high-value linked entities for each recent report.

FOR reportDoc IN nodes_vertex_collection
  FILTER reportDoc.type == "report" AND reportDoc._is_latest == true
  SORT reportDoc.modified DESC
  LIMIT 15
  LET entities = (
    FOR ent, edge IN 1..1 OUTBOUND reportDoc._id GRAPH "lunargraph_graph"
      LET relType = LOWER(TO_STRING(edge.relationship_type ? edge.relationship_type : edge.type))
      FILTER relType IN ["object", "references", "related-to", "uses", "targets", "attributed-to", "indicates", "located-at"]
      FILTER ent.type IN ["threat-actor", "campaign", "malware", "tool", "location", "intrusion-set", "attack-pattern", "vulnerability"]
      RETURN DISTINCT {
        type: ent.type,
        name: ent.name
      }
  )
  FILTER LENGTH(entities) > 0
  RETURN {
    report: reportDoc.name,
    modified: reportDoc.modified,
    entities: SLICE(entities, 0, 25)
  }
Example 6Intermediate

Top Threat Actors By Report Coverage

Rank threat actors by number of distinct linked reports.

FOR actor IN nodes_vertex_collection
  FILTER actor.type == "threat-actor"
  LET reportIds = UNIQUE(
    FOR reportDoc, edge, path IN 1..2 ANY actor._id GRAPH "lunargraph_graph"
      PRUNE LENGTH(path.vertices) == 2
        AND LOWER(TO_STRING(reportDoc.type)) IN ["marking-definition", "identity"]
      FILTER reportDoc.type == "report" AND reportDoc._is_latest == true
      RETURN reportDoc._id
  )
  LET reportCount = LENGTH(reportIds)
  FILTER reportCount > 0
  SORT reportCount DESC
  LIMIT 20
  RETURN {
    threatActor: actor.name,
    reportCount
  }
Example 7Intermediate

Campaign Neighborhood (2-Hop)

Explore key entities around a campaign node.

LET campaignName = "operation epic fury"

FOR campaign IN nodes_vertex_collection
  FILTER campaign.type == "campaign"
  FILTER LOWER(TRIM(TO_STRING(campaign.name))) == LOWER(campaignName)
  FOR node, edge, path IN 1..2 ANY campaign._id GRAPH "lunargraph_graph"
    PRUNE LENGTH(path.vertices) == 2
      AND LOWER(TO_STRING(node.type)) IN ["marking-definition", "identity"]
    FILTER node.type IN ["report", "threat-actor", "intrusion-set", "tool", "malware", "location", "attack-pattern"]
    FILTER node.type != "report" OR (node._is_latest == true)
    RETURN DISTINCT {
      type: node.type,
      id: node._id,
      name: node.name
    }
Example 8Advanced

Shared Threat Actors Between Two Countries

Measure overlap between country-level threat-actor exposure.

LET countryA = "US"
LET countryB = "IR"

LET countryAActors = UNIQUE(
  FOR loc IN nodes_vertex_collection
    FILTER loc.type == "location"
    FILTER UPPER(TRIM(TO_STRING(loc.country))) == countryA
    FOR actor, edge, path IN 1..2 ANY loc._id GRAPH "lunargraph_graph"
      PRUNE LENGTH(path.vertices) == 2
        AND LOWER(TO_STRING(actor.type)) IN ["marking-definition", "identity"]
      FILTER actor.type == "threat-actor"
      RETURN LOWER(TRIM(TO_STRING(actor.name)))
)

LET countryBActors = UNIQUE(
  FOR loc IN nodes_vertex_collection
    FILTER loc.type == "location"
    FILTER UPPER(TRIM(TO_STRING(loc.country))) == countryB
    FOR actor, edge, path IN 1..2 ANY loc._id GRAPH "lunargraph_graph"
      PRUNE LENGTH(path.vertices) == 2
        AND LOWER(TO_STRING(actor.type)) IN ["marking-definition", "identity"]
      FILTER actor.type == "threat-actor"
      RETURN LOWER(TRIM(TO_STRING(actor.name)))
)

LET sharedActors = INTERSECTION(countryAActors, countryBActors)

RETURN {
  countryA,
  countryB,
  sharedCount: LENGTH(sharedActors),
  sharedThreatActors: SLICE(sharedActors, 0, 25)
}
Example 9Advanced

CVE To Reports Lookup

Pivot from vulnerabilities to linked latest reports and source links.

FOR vuln IN nodes_vertex_collection
  FILTER vuln.type == "vulnerability"
  FILTER LIKE(UPPER(TO_STRING(vuln.name)), "CVE-%", true)
  FOR reportDoc, edge, path IN 1..2 ANY vuln._id GRAPH "lunargraph_graph"
    PRUNE LENGTH(path.vertices) == 2
      AND LOWER(TO_STRING(reportDoc.type)) IN ["marking-definition", "identity"]
    FILTER reportDoc.type == "report" AND reportDoc._is_latest == true
    LET sourceLink = FIRST(
      FOR ref IN reportDoc.external_references
        FILTER ref.source_name == "source_link"
        RETURN ref.url
    )
    COLLECT cve = vuln.name, reportName = reportDoc.name, modified = reportDoc.modified, sourceLink = sourceLink
    SORT modified DESC
    LIMIT 60
    RETURN {
      cve,
      reportName,
      modified,
      sourceLink
    }
Example 10Advanced

Daily Report Trend For A Country

Build a time-series of report volume for a location.

LET countryCode = "US"
LET reportIds = UNIQUE(
  FOR loc IN nodes_vertex_collection
    FILTER loc.type == "location"
    FILTER UPPER(TRIM(TO_STRING(loc.country))) == countryCode
    FOR reportDoc, edge, path IN 1..2 ANY loc._id GRAPH "lunargraph_graph"
      PRUNE LENGTH(path.vertices) == 2
        AND LOWER(TO_STRING(reportDoc.type)) IN ["marking-definition", "identity"]
      FILTER reportDoc.type == "report" AND reportDoc._is_latest == true
      RETURN reportDoc._id
)

FOR reportId IN reportIds
  LET reportDoc = DOCUMENT(reportId)
  FILTER reportDoc != null
  COLLECT day = DATE_FORMAT(reportDoc.created, "%yyyy-%mm-%dd") WITH COUNT INTO count
  SORT day ASC
  RETURN { day, count }
Example 11Advanced

Top Co-Occurring Entity Pairs

Find frequently co-mentioned entity pairs across recent reports.

LET recentReports = (
  FOR reportDoc IN nodes_vertex_collection
    FILTER reportDoc.type == "report" AND reportDoc._is_latest == true
    SORT reportDoc.modified DESC
    LIMIT 120
    RETURN reportDoc._id
)

LET pairs = (
  FOR reportId IN recentReports
    LET entities = (
      FOR ent, edge IN 1..1 OUTBOUND reportId GRAPH "lunargraph_graph"
        FILTER ent.type IN ["threat-actor", "malware", "tool", "campaign", "intrusion-set"]
        RETURN DISTINCT CONCAT(ent.type, ":", LOWER(TRIM(TO_STRING(ent.name))))
    )
    FOR leftEnt, i IN entities
      FOR rightEnt, j IN entities
        FILTER j > i
        RETURN CONCAT(leftEnt, " <-> ", rightEnt)
)

FOR pair IN pairs
  COLLECT pairLabel = pair WITH COUNT INTO count
  SORT count DESC
  LIMIT 30
  RETURN { pair: pairLabel, count }
Example 12Advanced

Intrusion-Set Multi-Hop Correlation

From one intrusion set, pull related malware, tools, campaigns, patterns, and reports.

LET intrusionSetName = "lazarus group"

FOR intr IN nodes_vertex_collection
  FILTER intr.type == "intrusion-set"
  FILTER LOWER(TRIM(TO_STRING(intr.name))) == LOWER(intrusionSetName)
  LET neighbors = (
    FOR node, edge, path IN 1..2 ANY intr._id GRAPH "lunargraph_graph"
      PRUNE LENGTH(path.vertices) == 2
        AND LOWER(TO_STRING(node.type)) IN ["marking-definition", "identity"]
      FILTER node.type IN ["malware", "tool", "campaign", "attack-pattern", "report", "location"]
      FILTER node.type != "report" OR node._is_latest == true
      RETURN DISTINCT {
        type: node.type,
        id: node._id,
        name: node.name
      }
  )
  RETURN {
    intrusionSet: intr.name,
    neighborCount: LENGTH(neighbors),
    neighbors
  }