Graph Unmerge
graph_unmergeSplit a falsely merged entity into two, moving specified edges to the new entity. Use when entity resolution incorrectly merged distinct entities.
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:605-655 (registration)MCP tool registration for graph_unmerge with input schema (entity_id, new_entity_name, new_entity_type, edges_to_move, reason) and handler that delegates to client.unmerge() then logs to merge-audit.jsonl.
// ─── 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/shared/neo4j-client.ts:1344-1419 (handler)Core handler for unmerge: creates a new entity node in the same tenant, moves specified edges (outgoing/incoming) from the original to the new entity by copying properties and deleting the old edge, then returns counts of remaining and moved edges.
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 }, }; } - src/mcp-server/index.ts:611-621 (schema)Input schema for graph_unmerge defining entity_id, new_entity_name, new_entity_type, edges_to_move (array of {other_entity_id, relation_type, direction}), and reason.
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)"), },