Graph Unmerge
graph_unmergeCorrect entity resolution errors by splitting a falsely merged entity into two. Move specified edges to the new entity while logging the reason.
Instructions
Split a falsely merged entity back into two separate entities, redistributing specified edges. Use when entity resolution made a mistake (e.g. merged 'Anna' and 'Anne'). The original entity keeps every edge not listed in edges_to_move; the new entity gets the listed edges plus a fresh embedding stub (re-derive with graph_reembed). Logged to the audit trail with reason. Returns the IDs of both entities.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| entity_id | Yes | The merged entity ID to split | |
| new_entity_name | Yes | Name for the split-off entity | |
| new_entity_type | Yes | Type label for the split-off entity | |
| edges_to_move | Yes | Edges to move to the new entity | |
| reason | Yes | Why splitting (logged in audit) |
Implementation Reference
- src/mcp-server/index.ts:602-652 (registration)Registration of the 'graph_unmerge' tool with server.registerTool(...). Defines inputSchema, description, and annotations. Handler calls client.unmerge(...) then logs to audit file.
// ─── Tool: graph_unmerge ─── server.registerTool("graph_unmerge", { title: "Graph Unmerge", description: "Split a falsely merged entity back into two separate entities, redistributing specified edges. Use when entity resolution made a mistake (e.g. merged 'Anna' and 'Anne'). The original entity keeps every edge not listed in `edges_to_move`; the new entity gets the listed edges plus a fresh embedding stub (re-derive with graph_reembed). Logged to the audit trail with `reason`. Returns the IDs of both entities.", inputSchema: { entity_id: z.string().describe("The merged entity ID to split"), new_entity_name: z.string().describe("Name for the split-off entity"), new_entity_type: z.string().describe("Type label for the split-off entity"), edges_to_move: z.array(z.object({ other_entity_id: z.string().describe("Entity on the other end of the edge"), relation_type: z.string().describe("Relationship type (e.g. WORKS_ON)"), direction: z.enum(["in", "out"]).describe("Direction relative to the entity being split"), })).describe("Edges to move to the new entity"), reason: z.string().describe("Why splitting (logged in audit)"), }, annotations: { destructiveHint: true }, }, async (args) => { try { const result = await client.unmerge( currentTenant(), args.entity_id, args.new_entity_name, args.new_entity_type as EntityType, args.edges_to_move.map((e) => ({ ...e, relation_type: e.relation_type as RelationshipType, })), args.reason, ); // Log to merge audit try { const auditDir = join(GRAPH_MEMORY_HOME, "logs"); mkdirSync(auditDir, { recursive: true }); const auditPath = join(auditDir, "merge-audit.jsonl"); const entry = JSON.stringify({ action: "unmerge", timestamp: new Date().toISOString(), ...result, reason: args.reason, }); writeFileSync(auditPath, entry + "\n", { flag: "a" }); } catch { /* audit logging is best-effort */ } return toolResult({ ...result, audit_logged: true }); } catch (err) { return toolError(`graph_unmerge failed: ${err instanceof Error ? err.message : String(err)}`); } }); - src/mcp-server/index.ts:620-652 (handler)Handler function for graph_unmerge. Calls client.unmerge(...) with currentTenant(), entity_id, new_entity_name, new_entity_type, edges_to_move, and reason. Writes an audit entry to logs/merge-audit.jsonl on success.
}, async (args) => { try { const result = await client.unmerge( currentTenant(), args.entity_id, args.new_entity_name, args.new_entity_type as EntityType, args.edges_to_move.map((e) => ({ ...e, relation_type: e.relation_type as RelationshipType, })), args.reason, ); // Log to merge audit try { const auditDir = join(GRAPH_MEMORY_HOME, "logs"); mkdirSync(auditDir, { recursive: true }); const auditPath = join(auditDir, "merge-audit.jsonl"); const entry = JSON.stringify({ action: "unmerge", timestamp: new Date().toISOString(), ...result, reason: args.reason, }); writeFileSync(auditPath, entry + "\n", { flag: "a" }); } catch { /* audit logging is best-effort */ } return toolResult({ ...result, audit_logged: true }); } catch (err) { return toolError(`graph_unmerge failed: ${err instanceof Error ? err.message : String(err)}`); } }); - src/mcp-server/index.ts:608-618 (schema)Input schema for graph_unmerge: entity_id (string), new_entity_name (string), new_entity_type (string), edges_to_move (array of {other_entity_id, relation_type, direction: 'in'|'out'}), reason (string).
inputSchema: { entity_id: z.string().describe("The merged entity ID to split"), new_entity_name: z.string().describe("Name for the split-off entity"), new_entity_type: z.string().describe("Type label for the split-off entity"), edges_to_move: z.array(z.object({ other_entity_id: z.string().describe("Entity on the other end of the edge"), relation_type: z.string().describe("Relationship type (e.g. WORKS_ON)"), direction: z.enum(["in", "out"]).describe("Direction relative to the entity being split"), })).describe("Edges to move to the new entity"), reason: z.string().describe("Why splitting (logged in audit)"), }, - src/shared/neo4j-client.ts:1288-1363 (helper)Neo4jClient.unmerge() method: creates a new entity node in the same tenant, moves specified edges (outgoing and incoming) from original to new entity by re-creating then deleting the old ones, and returns the original entity's remaining edge count and the new entity's details.
async unmerge( tenantId: string, entityId: string, newEntityName: string, newEntityType: EntityType, edgesToMove: Array<{ other_entity_id: string; relation_type: RelationshipType; direction: "in" | "out" }>, reason: string, ): Promise<{ original: { id: string; remaining_edges: number }; new_entity: { id: string; name: string; moved_edges: number }; }> { const newId = newEntityName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); const now = new Date().toISOString(); // Create the new entity in the same tenant await this.run( ` CREATE (n:Entity:\`${newEntityType}\` { tenant_id: $tenantId, id: $newId, name: $newEntityName, confidence: 0.5, times_mentioned: 1, first_seen: datetime($now), last_seen: datetime($now) }) `, { tenantId, newId, newEntityName, now }, ); // Move specified edges (all participants must be in the same tenant) let movedCount = 0; for (const edge of edgesToMove) { if (edge.direction === "out") { const rows = await this.run( ` MATCH (original:Entity {tenant_id: $tenantId, id: $entityId})-[r:\`${edge.relation_type}\`]->(other:Entity {tenant_id: $tenantId, id: $otherId}) MATCH (newNode:Entity {tenant_id: $tenantId, id: $newId}) WITH r, newNode, other, properties(r) AS props CREATE (newNode)-[newR:\`${edge.relation_type}\`]->(other) SET newR = props DELETE r RETURN count(newR) AS moved `, { tenantId, entityId, otherId: edge.other_entity_id, newId }, ); movedCount += Number(rows[0]?.["moved"] ?? 0); } else { const rows = await this.run( ` MATCH (other:Entity {tenant_id: $tenantId, id: $otherId})-[r:\`${edge.relation_type}\`]->(original:Entity {tenant_id: $tenantId, id: $entityId}) MATCH (newNode:Entity {tenant_id: $tenantId, id: $newId}) WITH r, newNode, other, properties(r) AS props CREATE (other)-[newR:\`${edge.relation_type}\`]->(newNode) SET newR = props DELETE r RETURN count(newR) AS moved `, { tenantId, entityId, otherId: edge.other_entity_id, newId }, ); movedCount += Number(rows[0]?.["moved"] ?? 0); } } // Count remaining edges on original (tenant-scoped) const remainingRows = await this.run( `MATCH (n:Entity {tenant_id: $tenantId, id: $entityId})-[r]-() RETURN count(r) AS remaining`, { tenantId, entityId }, ); const remainingEdges = Number(remainingRows[0]?.["remaining"] ?? 0); return { original: { id: entityId, remaining_edges: remainingEdges }, new_entity: { id: newId, name: newEntityName, moved_edges: movedCount }, }; }