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
4 changes: 4 additions & 0 deletions packages/mcp-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
67 changes: 48 additions & 19 deletions packages/mcp-server/src/services/graph-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export interface CreateNodeArgs {
status?: NodeStatus;
contributor_ids?: string[];
metadata?: NodeMetadata;
graph_id?: string;
}

export interface UpdateNodeArgs {
Expand Down Expand Up @@ -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
`;
Expand Down Expand Up @@ -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,
Expand All @@ -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<string, unknown> = {
id,
title,
description,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions packages/mcp-server/tests/neo4j-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading