From 190bb8dd56b337fe3f873a4782b26dffb28f50a9 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Sat, 13 Jun 2026 14:17:14 -0700 Subject: [PATCH 1/2] create_node: attach to a graph via graph_id (orphan nodes were invisible) Node-level twin of the edge parity fix (#47). The web lists nodes per-graph (workItems where graph.id = currentGraph), but MCP create_node had no graph_id parameter and never created a BELONGS_TO relationship, so every node an AI created was orphaned and shown in NO graph view. This also undermined the edge fix: an AI's nodes never appeared, so neither did the edges between them. Add an optional graph_id to the create_node tool + CreateNodeArgs; when present, MATCH the graph and MERGE (n)-[:BELONGS_TO]->(g). A graph_id that matches no graph returns a clean error instead of silently creating an unattached node. Omitting graph_id stays backward-compatible. Real-Neo4j contract test covers all three: attached (BELONGS_TO + shows in get_graph_context), and unknown graph_id errors. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/mcp-server/src/index.ts | 4 +++ .../mcp-server/src/services/graph-service.ts | 25 +++++++++++++-- .../mcp-server/tests/neo4j-contract.test.ts | 32 +++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index bf91054f..1a49e03a 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -116,6 +116,10 @@ const tools: Tool[] = [ items: { type: 'string' }, description: 'Array of contributor IDs' }, + graph_id: { + type: 'string', + description: 'ID of the graph/project this node belongs to. Strongly recommended: the web UI lists nodes per-graph, so a node created without a graph_id is not shown in any graph view. Use list_graphs / create_graph to obtain one.' + }, metadata: { type: 'object', description: 'Additional metadata for the node' diff --git a/packages/mcp-server/src/services/graph-service.ts b/packages/mcp-server/src/services/graph-service.ts index 69667f9e..fea66f91 100644 --- a/packages/mcp-server/src/services/graph-service.ts +++ b/packages/mcp-server/src/services/graph-service.ts @@ -63,6 +63,7 @@ export interface CreateNodeArgs { status?: NodeStatus; contributor_ids?: string[]; metadata?: NodeMetadata; + graph_id?: string; } export interface UpdateNodeArgs { @@ -465,11 +466,17 @@ export class GraphService { // Generate truly unique ID to prevent race conditions const id = generateUniqueNodeId(); const now = new Date().toISOString(); - + const graphId = args.graph_id ? sanitizeNodeId(args.graph_id) : null; + // Use write consistency to prevent read-after-write issues return await withWriteConsistency(id, 'CREATE', async () => { + // Attach the node to its graph via BELONGS_TO when a graph_id is given. + // Without this a node is orphaned — the web UI lists nodes per-graph + // (workItems where graph.id = currentGraph), so an unattached node an AI + // creates is invisible to humans. const query = ` + ${graphId ? 'MATCH (g:Graph {id: $graphId})' : ''} CREATE (n:WorkItem { id: $id, title: $title, @@ -487,10 +494,11 @@ export class GraphService { sphericalPhi: 0, metadata: $metadata }) + ${graphId ? 'MERGE (n)-[:BELONGS_TO]->(g)' : ''} RETURN n `; - const params = { + const params: Record = { id, title, description, @@ -499,8 +507,21 @@ export class GraphService { now, metadata: JSON.stringify(metadata) }; + if (graphId) params.graphId = graphId; const result = await session.run(query, params); + + // A graph_id that matches no graph yields zero rows (the MATCH fails) — + // surface that instead of silently creating nothing. + if (graphId && result.records.length === 0) { + return { + content: [{ + type: 'text', + text: JSON.stringify({ error: `Graph with id '${graphId}' not found`, graph_id: graphId }, null, 2) + }], + isError: true + }; + } const rawNode = result.records[0].get('n').properties; // Parse metadata back from JSON string for data integrity diff --git a/packages/mcp-server/tests/neo4j-contract.test.ts b/packages/mcp-server/tests/neo4j-contract.test.ts index 53d2f5c9..f8c05617 100644 --- a/packages/mcp-server/tests/neo4j-contract.test.ts +++ b/packages/mcp-server/tests/neo4j-contract.test.ts @@ -226,6 +226,38 @@ describe.skipIf(!RUN)('MCP GraphService — real Neo4j contract', () => { } }); + it('createNode with graph_id attaches via BELONGS_TO (otherwise the node is invisible per-graph)', async () => { + // The web lists nodes per-graph (workItems where graph.id = currentGraph), + // so a node created without a graph link is invisible to humans. createNode + // must attach to the given graph. + const g = parse(await svc.createGraph({ name: `Contract Attach ${Date.now()}`, type: 'PROJECT' } as any)); + const graphId = g.graph.id; + createdGraphs.push(graphId); + + const created = parse(await svc.createNode({ title: `Attached ${Date.now()}`, type: 'TASK', graph_id: graphId } as any)); + const id = created.node.id; + createdNodes.push(id); + + const session = driver.session(); + try { + const r = await session.run( + 'MATCH (g:Graph {id: $gid})<-[:BELONGS_TO]-(w:WorkItem {id: $id}) RETURN count(*) AS c', + { gid: graphId, id } + ); + expect(r.records[0].get('c').toNumber(), 'node is linked to its graph via BELONGS_TO').toBe(1); + } finally { + await session.close(); + } + + // get_graph_context should now count this node + const ctx = parse(await svc.getGraphContext({ graphId } as any)).context; + expect(ctx.counts.nodes, 'attached node shows in the graph context').toBeGreaterThanOrEqual(1); + + // A non-existent graph_id is a clean error, not a silent orphan + const bad = await svc.createNode({ title: 'Bad Graph', type: 'TASK', graph_id: 'no-such-graph' } as any); + expect((bad as { isError?: boolean }).isError, 'unknown graph_id errors').toBe(true); + }); + 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; From 4ea44930b781f0fa1be05fafb7394428dea556d4 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Sat, 13 Jun 2026 14:21:18 -0700 Subject: [PATCH 2/2] create_node: write canonical WorkItem fields the web reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP node creation also used non-canonical property names the GraphQL server/web schema don't read: priorityComputed (vs priorityComp), sphericalRadius/Theta/Phi (vs radius/theta/phi), and it never set positionX/Y/Z or priority at all. So AI-created nodes showed priority 0 at the origin in the web, and MCP was internally inconsistent (createNode + browseGraph used priorityComputed while updateNode and the priority/bottleneck analytics used priorityComp — updates were invisible to browse-by-priority and vice versa). Standardize createNode and executeBulkCreateNode on the canonical schema fields (positionX/Y/Z, radius, theta, phi, priority, priorityComp) while keeping the MCP-internal priorityExec/Indiv/Comm decomposition, and fix browseGraph by_priority to read priorityComp. Contract test now also asserts the canonical fields are set and no legacy priorityComputed remains. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../mcp-server/src/services/graph-service.ts | 42 +++++++++++-------- .../mcp-server/tests/neo4j-contract.test.ts | 14 +++++++ 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/packages/mcp-server/src/services/graph-service.ts b/packages/mcp-server/src/services/graph-service.ts index fea66f91..12bfcd33 100644 --- a/packages/mcp-server/src/services/graph-service.ts +++ b/packages/mcp-server/src/services/graph-service.ts @@ -331,12 +331,12 @@ export class GraphService { case 'by_priority': const minPriority = filters.min_priority || 0; - countQuery = `MATCH (n:WorkItem) WHERE n.priorityComputed >= $min_priority RETURN count(n) as total`; + countQuery = `MATCH (n:WorkItem) WHERE n.priorityComp >= $min_priority RETURN count(n) as total`; query = ` MATCH (n:WorkItem) - WHERE n.priorityComputed >= $min_priority + WHERE n.priorityComp >= $min_priority RETURN n - ORDER BY n.priorityComputed DESC + ORDER BY n.priorityComp DESC SKIP $offset LIMIT $limit `; @@ -485,13 +485,17 @@ export class GraphService { status: $status, createdAt: $now, updatedAt: $now, - priorityExecutive: 0, - priorityIndividual: 0, - priorityCommunity: 0, - priorityComputed: 0, - sphericalRadius: 1.0, - sphericalTheta: 0, - sphericalPhi: 0, + positionX: 0, + positionY: 0, + positionZ: 0, + radius: 1.0, + theta: 0, + phi: 0, + priority: 0, + priorityComp: 0, + priorityExec: 0, + priorityIndiv: 0, + priorityComm: 0, metadata: $metadata }) ${graphId ? 'MERGE (n)-[:BELONGS_TO]->(g)' : ''} @@ -2198,13 +2202,17 @@ export class GraphService { status: $status, createdAt: $now, updatedAt: $now, - priorityExecutive: 0, - priorityIndividual: 0, - priorityCommunity: 0, - priorityComputed: 0, - sphericalRadius: 1.0, - sphericalTheta: 0, - sphericalPhi: 0, + positionX: 0, + positionY: 0, + positionZ: 0, + radius: 1.0, + theta: 0, + phi: 0, + priority: 0, + priorityComp: 0, + priorityExec: 0, + priorityIndiv: 0, + priorityComm: 0, metadata: $metadata }) RETURN n.id as id diff --git a/packages/mcp-server/tests/neo4j-contract.test.ts b/packages/mcp-server/tests/neo4j-contract.test.ts index f8c05617..4b377726 100644 --- a/packages/mcp-server/tests/neo4j-contract.test.ts +++ b/packages/mcp-server/tests/neo4j-contract.test.ts @@ -245,6 +245,20 @@ describe.skipIf(!RUN)('MCP GraphService — real Neo4j contract', () => { { gid: graphId, id } ); expect(r.records[0].get('c').toNumber(), 'node is linked to its graph via BELONGS_TO').toBe(1); + + // The node must carry the CANONICAL schema fields the web reads + // (positionX/Y/Z, radius, theta, phi, priority, priorityComp) — not the + // old MCP-only names (priorityComputed / sphericalRadius) the web ignores. + const fields = await session.run( + 'MATCH (w:WorkItem {id: $id}) RETURN w.positionX AS px, w.radius AS radius, w.priority AS priority, w.priorityComp AS pc, w.priorityComputed AS legacy', + { id } + ); + const rec = fields.records[0]; + expect(rec.get('px'), 'positionX is set (web reads it)').not.toBeNull(); + expect(rec.get('radius'), 'radius is set').not.toBeNull(); + expect(rec.get('priority'), 'priority is set').not.toBeNull(); + expect(rec.get('pc'), 'priorityComp is set (canonical)').not.toBeNull(); + expect(rec.get('legacy'), 'no legacy priorityComputed property').toBeNull(); } finally { await session.close(); }