diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index bf91054..1a49e03 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 69667f9..12bfcd3 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 { @@ -330,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 `; @@ -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, @@ -478,19 +485,24 @@ 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)' : ''} RETURN n `; - const params = { + const params: Record = { id, title, description, @@ -499,8 +511,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 @@ -2177,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 53d2f5c..4b37772 100644 --- a/packages/mcp-server/tests/neo4j-contract.test.ts +++ b/packages/mcp-server/tests/neo4j-contract.test.ts @@ -226,6 +226,52 @@ 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); + + // 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(); + } + + // 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;