Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 13 additions & 10 deletions packages/mcp-server/src/services/graph-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3586,10 +3586,14 @@ export class GraphService {
const newGraph = await tx.run(createGraphQuery, {
newName: args.newName,
description: `Cloned from: ${sourceGraph.name}`,
type: sourceGraph.type,
teamId: args.teamId || sourceGraph.teamId,
isShared: sourceGraph.isShared,
settings: sourceGraph.settings,
type: sourceGraph.type ?? 'PROJECT',
// Neo4j does not store null-valued properties, so a graph created
// without a teamId has NO teamId property — reading it back yields
// `undefined`, and the driver rejects an undefined param value with
// ParameterMissing. Coalesce to null so clone works for every graph.
teamId: args.teamId ?? sourceGraph.teamId ?? null,
isShared: sourceGraph.isShared ?? false,
settings: sourceGraph.settings ?? '{}',
sourceGraphId: args.sourceGraphId
});

Expand Down Expand Up @@ -3636,12 +3640,11 @@ export class GraphService {
MATCH (sourceW)-[r:DEPENDS_ON|BLOCKS|RELATES_TO|CONTAINS|PART_OF]->(targetW:WorkItem)-[:BELONGS_TO]->(sourceG)
MATCH (newG)<-[:BELONGS_TO]-(newTargetW:WorkItem)
WHERE newTargetW.originalId = targetW.id
CREATE (newW)-[newR:DEPENDS_ON {
type: r.type,
weight: r.weight,
metadata: r.metadata
}]->(newTargetW)
RETURN count(newR) as edgeCount
// Preserve the real relationship type — the previous code
// hard-coded :DEPENDS_ON, silently rewriting every BLOCKS /
// RELATES_TO / CONTAINS / PART_OF edge into a DEPENDS_ON on clone.
CALL apoc.create.relationship(newW, type(r), { weight: r.weight, metadata: r.metadata }, newTargetW) YIELD rel
RETURN count(rel) as edgeCount
`;

const edgesResult = await tx.run(cloneEdgesQuery, {
Expand Down
50 changes: 50 additions & 0 deletions packages/mcp-server/tests/neo4j-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ describe.skipIf(!RUN)('MCP GraphService — real Neo4j contract', () => {
await session?.run('MATCH (n:WorkItem) WHERE n.id IN $ids DETACH DELETE n', { ids: createdNodes });
}
if (createdGraphs.length) {
// Also remove any WorkItems that belong to these graphs (clone creates
// brand-new node ids we don't otherwise track).
await session?.run('MATCH (g:Graph) WHERE g.id IN $ids OPTIONAL MATCH (g)<-[:BELONGS_TO]-(w:WorkItem) DETACH DELETE w', { ids: createdGraphs });
await session?.run('MATCH (g:Graph) WHERE g.id IN $ids DETACH DELETE g', { ids: createdGraphs });
}
} catch { /* ignore */ }
Expand Down Expand Up @@ -130,6 +133,53 @@ describe.skipIf(!RUN)('MCP GraphService — real Neo4j contract', () => {
expect(ctx.counts.byStatus.IN_PROGRESS, 'both items tallied under IN_PROGRESS').toBe(2);
});

it('cloneGraph copies nodes AND preserves each edge type (not all DEPENDS_ON)', async () => {
// Regressions this guards:
// 1. clone threw ParameterMissing(teamId) because Neo4j never stores a
// null teamId, so reading it back yields undefined.
// 2. clone hard-coded :DEPENDS_ON, silently rewriting BLOCKS/RELATES_TO/…
const src = parse(await svc.createGraph({ name: `Contract Clone Src ${Date.now()}`, type: 'PROJECT' } as any));
const srcId = src.graph.id;
createdGraphs.push(srcId);

const session = driver.session();
const mk = async (title: string) => {
const id = parse(await svc.createNode({ title, type: 'TASK', status: 'PROPOSED' } as any)).node.id;
createdNodes.push(id);
await session.run('MATCH (w:WorkItem {id: $id}), (g:Graph {id: $gid}) MERGE (w)-[:BELONGS_TO]->(g)', { id, gid: srcId });
return id;
};
let a: string, b: string, c: string;
try {
a = await mk(`Clone A ${Date.now()}`);
b = await mk(`Clone B ${Date.now()}`);
c = await mk(`Clone C ${Date.now()}`);
} finally {
await session.close();
}
await svc.createEdge({ source_id: a, target_id: b, type: 'BLOCKS' } as any);
await svc.createEdge({ source_id: b, target_id: c, type: 'RELATES_TO' } as any);

const cloned = parse(await svc.cloneGraph({ sourceGraphId: srcId, newName: `Contract Clone Dst ${Date.now()}` } as any));
const dstId = cloned.newGraph.id;
createdGraphs.push(dstId);
expect(cloned.newGraph.clonedNodes, 'all three nodes cloned').toBe(3);
expect(cloned.newGraph.clonedEdges, 'both edges cloned').toBe(2);

// The cloned edges must keep their real types, not all become DEPENDS_ON
const s2 = driver.session();
try {
const r = await s2.run(
'MATCH (g:Graph {id: $gid})<-[:BELONGS_TO]-(:WorkItem)-[rel]->(:WorkItem) RETURN type(rel) AS t ORDER BY t',
{ gid: dstId }
);
const types = r.records.map((rec) => rec.get('t')).sort();
expect(types, 'cloned relationship types are preserved').toEqual(['BLOCKS', 'RELATES_TO']);
} finally {
await s2.close();
}
});

it('browseGraph returns well-formed data over a real DB', async () => {
const browsed = parse(await svc.browseGraph({ query_type: 'all_nodes', limit: 25 } as any));
const arr = browsed.nodes ?? browsed.results ?? browsed.workItems;
Expand Down
Loading