Graph Query
graph_queryRetrieve memory graph nodes and edges for given entity names. Returns connected subgraph with edge weights and source provenance.
Instructions
Query the memory graph by canonical entity name. Use when you know the entity name or close-to-canonical form (e.g. "Steve", "graph-memory"); for natural-language phrasing or synonyms (e.g. "the knowledge graph project") prefer graph_search. Returns up to limit matching nodes plus the edges that connect them within max_hops, with per-edge weight and source provenance.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| entities | Yes | Entity names to search for | |
| entity_types | No | Filter results to these entity types | |
| max_hops | No | Max traversal depth (default: 2) | |
| min_weight | No | Min edge weight to traverse (default: 0.3) | |
| limit | No | Max results (default: 20) | |
| project_context | No | Project directory or name for affinity scoring | |
| context_level | No | Response detail level (default: full) | full |
| current_only | No | Only current facts, exclude superseded (default: true) |
Implementation Reference
- src/mcp-server/index.ts:165-213 (registration)Registration of the graph_query tool with the MCP server, including inputSchema (Zod-based) definition and the async handler that delegates to client.query().
server.registerTool("graph_query", { title: "Graph Query", description: "Query the memory graph by canonical entity name. Use when you know the entity name or close-to-canonical form (e.g. \"Steve\", \"graph-memory\"); for natural-language phrasing or synonyms (e.g. \"the knowledge graph project\") prefer graph_search. Returns up to `limit` matching nodes plus the edges that connect them within `max_hops`, with per-edge weight and source provenance.", inputSchema: { entities: z.array(z.string()).describe("Entity names to search for"), entity_types: z.array(z.string()).optional().describe("Filter results to these entity types"), max_hops: z.number().optional().default(2).describe("Max traversal depth (default: 2)"), min_weight: z.number().optional().default(0.3).describe("Min edge weight to traverse (default: 0.3)"), limit: z.number().optional().default(20).describe("Max results (default: 20)"), project_context: z.string().optional().describe("Project directory or name for affinity scoring"), context_level: z.enum(["minimal", "full", "relations-only"]).optional().default("full").describe("Response detail level (default: full)"), current_only: z.boolean().optional().default(true).describe("Only current facts, exclude superseded (default: true)"), }, annotations: { readOnlyHint: true }, }, async (args) => { try { const result = await client.query(currentTenant(), args.entities, { entity_types: args.entity_types as EntityType[] | undefined, max_hops: args.max_hops, min_weight: args.min_weight, limit: args.limit, project_context: args.project_context, current_only: args.current_only, }); if (args.context_level === "minimal") { return toolResult({ nodes: result.nodes.map((n) => ({ id: n.id, type: n.type, name: n.name, confidence: n.confidence, })), edge_count: result.edges.length, }); } if (args.context_level === "relations-only") { return toolResult({ nodes: result.nodes.map((n) => ({ id: n.id, type: n.type, name: n.name, })), edges: result.edges, }); } return toolResult(result); } catch (err) { return toolError(`graph_query failed: ${err instanceof Error ? err.message : String(err)}`); } }); - src/mcp-server/index.ts:169-179 (schema)Input schema for graph_query tool: entities (required array), entity_types, max_hops, min_weight, limit, project_context, context_level, current_only — all with Zod validation and defaults.
inputSchema: { entities: z.array(z.string()).describe("Entity names to search for"), entity_types: z.array(z.string()).optional().describe("Filter results to these entity types"), max_hops: z.number().optional().default(2).describe("Max traversal depth (default: 2)"), min_weight: z.number().optional().default(0.3).describe("Min edge weight to traverse (default: 0.3)"), limit: z.number().optional().default(20).describe("Max results (default: 20)"), project_context: z.string().optional().describe("Project directory or name for affinity scoring"), context_level: z.enum(["minimal", "full", "relations-only"]).optional().default("full").describe("Response detail level (default: full)"), current_only: z.boolean().optional().default(true).describe("Only current facts, exclude superseded (default: true)"), }, annotations: { readOnlyHint: true }, - src/mcp-server/index.ts:180-213 (handler)Handler function for graph_query — calls client.query() with tenant ID and args, then formats response based on context_level (minimal, relations-only, or full).
}, async (args) => { try { const result = await client.query(currentTenant(), args.entities, { entity_types: args.entity_types as EntityType[] | undefined, max_hops: args.max_hops, min_weight: args.min_weight, limit: args.limit, project_context: args.project_context, current_only: args.current_only, }); if (args.context_level === "minimal") { return toolResult({ nodes: result.nodes.map((n) => ({ id: n.id, type: n.type, name: n.name, confidence: n.confidence, })), edge_count: result.edges.length, }); } if (args.context_level === "relations-only") { return toolResult({ nodes: result.nodes.map((n) => ({ id: n.id, type: n.type, name: n.name, })), edges: result.edges, }); } return toolResult(result); } catch (err) { return toolError(`graph_query failed: ${err instanceof Error ? err.message : String(err)}`); } }); - src/shared/neo4j-client.ts:598-738 (helper)Neo4jClient.query() — the core traversal logic: resolves entity names to IDs via fulltext search, then traverses the graph up to max_hops, applying tenant isolation, weight/type filters, current_only validity filter, and optional project-context affinity scoring.
async query( tenantId: string, entities: string[], options: { entity_types?: EntityType[]; max_hops?: number; min_weight?: number; limit?: number; project_context?: string; current_only?: boolean; } = {}, ): Promise<QueryResult> { const config = getConfig(); const maxHops = options.max_hops ?? config.query.default_max_hops; const minWeight = options.min_weight ?? config.query.default_min_weight; const limit = options.limit ?? config.query.default_limit; const currentOnly = options.current_only ?? true; // First resolve entity names to IDs via full-text search, scoped to tenant. // The fulltext index is global, so we filter results to the tenant's nodes. const entityIds: string[] = []; for (const name of entities) { const searchRows = await this.run( ` CALL db.index.fulltext.queryNodes('entity_names', $name) YIELD node, score WHERE node.tenant_id = $tenantId RETURN node.id AS id, score ORDER BY score DESC LIMIT 1 `, { tenantId, name }, ); if (searchRows.length > 0) { entityIds.push(String(searchRows[0]["id"])); } } if (entityIds.length === 0) { return { nodes: [], edges: [], source_files: [] }; } // Traverse from resolved entities — restrict the entire path to nodes in // this tenant so we cannot cross into another tenant's subgraph even via // a shared edge id collision. const validityFilter = currentOnly ? "AND rel.invalid_at IS NULL" : ""; const typeFilter = options.entity_types && options.entity_types.length > 0 ? `AND ANY(label IN labels(m) WHERE label IN $entityTypes)` : ""; const rows = await this.run( ` UNWIND $entityIds AS startId MATCH (start:Entity {tenant_id: $tenantId, id: startId}) // *0..N includes the seed itself (zero-length path). Without this the // entity you actually asked about never appears in its own query result. MATCH path = (start)-[*0..${maxHops}]-(m:Entity) WHERE m.tenant_id = $tenantId AND ALL(node IN nodes(path) WHERE node.tenant_id = $tenantId) AND ALL(rel IN relationships(path) WHERE rel.weight >= $minWeight ${validityFilter}) ${typeFilter} WITH DISTINCT m, relationships(path) AS rels, start RETURN m, labels(m) AS labels, [rel IN rels | { props: properties(rel), type: type(rel), fromId: startNode(rel).id, toId: endNode(rel).id }] AS edgeData LIMIT $limit `, { tenantId, entityIds, minWeight, limit, ...(options.entity_types ? { entityTypes: options.entity_types } : {}), }, ); const nodeMap = new Map<string, EntityNode>(); const edgeMap = new Map<string, RelationshipEdge>(); const sourceFiles = new Set<string>(); for (const row of rows) { const nodeObj = row["m"] as { labels: string[]; properties: Record<string, unknown> }; const node = recordToEntity(nodeObj.properties, nodeObj.labels); nodeMap.set(node.id, node); if (node.source_file) sourceFiles.add(node.source_file); const edgeData = row["edgeData"] as Array<{ props: Record<string, unknown>; type: string; fromId: string; toId: string; }>; for (const ed of edgeData) { const key = `${ed.fromId}-${ed.type}-${ed.toId}`; if (!edgeMap.has(key)) { const edge = recordToEdge(ed.props, ed.type, ed.fromId, ed.toId); if (options.project_context) { edge.effective_weight = edge.weight; } edgeMap.set(key, edge); } } } // Apply project-context affinity (scoped to tenant) if (options.project_context) { const affinityRows = await this.run( ` MATCH (proj:Project {tenant_id: $tenantId}) WHERE proj.directory CONTAINS $projectContext OR proj.name = $projectContext MATCH (proj)-[*1..2]-(related:Entity) WHERE related.tenant_id = $tenantId RETURN DISTINCT related.id AS id `, { tenantId, projectContext: options.project_context }, ); const projectRelatedIds = new Set(affinityRows.map((r) => String(r["id"]))); for (const edge of edgeMap.values()) { const fromRelated = projectRelatedIds.has(edge.from); const toRelated = projectRelatedIds.has(edge.to); if (fromRelated || toRelated) { edge.effective_weight = edge.weight * config.affinity.hop_1_multiplier; } else { edge.effective_weight = edge.weight; } } } return { nodes: Array.from(nodeMap.values()), edges: Array.from(edgeMap.values()).sort( (a, b) => (b.effective_weight ?? b.weight) - (a.effective_weight ?? a.weight), ), source_files: Array.from(sourceFiles), }; }