From 92a1643ec218166dd28d396962bfbcb7f0cff088 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Sat, 13 Jun 2026 10:27:42 -0700 Subject: [PATCH 1/3] Fix get_graph_context reporting an empty graph as "not found" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The type/status tally used `CALL { WITH items UNWIND items as i RETURN i.type as type, count(i) as cnt }`. UNWIND of an empty list yields ZERO rows, and a correlated CALL subquery returning zero rows eliminates the outer row — so for a brand-new empty graph the whole query returned no records and getGraphContext threw "Graph with ID X not found", even though the Graph node plainly existed. Return the raw type/status lists from Cypher and tally them in JS instead. The blockers/recent subqueries were already safe because they end in `collect(...)` (aggregation always yields one row). Adds two real-Neo4j contract cases: an empty graph returns zero counts (not an error), and a populated graph tallies byType/byStatus correctly. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../mcp-server/src/services/graph-service.ts | 34 ++++++------- .../mcp-server/tests/neo4j-contract.test.ts | 49 +++++++++++++++++++ 2 files changed, 64 insertions(+), 19 deletions(-) diff --git a/packages/mcp-server/src/services/graph-service.ts b/packages/mcp-server/src/services/graph-service.ts index 8cb08116..c9e74071 100644 --- a/packages/mcp-server/src/services/graph-service.ts +++ b/packages/mcp-server/src/services/graph-service.ts @@ -3291,18 +3291,14 @@ export class GraphService { OPTIONAL MATCH (g)<-[:BELONGS_TO]-(w:WorkItem) OPTIONAL MATCH (w)-[e:DEPENDS_ON|BLOCKS|RELATES_TO|CONTAINS|PART_OF]-(:WorkItem) WITH g, collect(DISTINCT w) as items, count(DISTINCT e) as edgeCount - CALL { - WITH items - UNWIND items as i - RETURN i.type as type, count(i) as cnt - } - WITH g, items, edgeCount, collect({type: type, count: cnt}) as typeCounts - CALL { - WITH items - UNWIND items as i - RETURN i.status as status, count(i) as cnt - } - WITH g, items, edgeCount, typeCounts, collect({status: status, count: cnt}) as statusCounts + // Return the raw type/status lists and tally them in JS. A + // CALL { UNWIND items ... } subquery returns ZERO rows for an empty + // graph (UNWIND of an empty list), which dropped the whole result and + // made get_graph_context wrongly report a brand-new empty graph as + // "not found". + WITH g, items, edgeCount, + [x IN items | x.type] as types, + [x IN items | x.status] as statuses CALL { WITH g OPTIONAL MATCH (g)<-[:BELONGS_TO]-(b:WorkItem)-[r:BLOCKS]->(:WorkItem) @@ -3318,7 +3314,7 @@ export class GraphService { ORDER BY rw.updatedAt DESC LIMIT 5 RETURN collect({id: rw.id, title: rw.title, status: rw.status, type: rw.type, updatedAt: rw.updatedAt}) as recent } - RETURN g, size(items) as nodeCount, edgeCount, typeCounts, statusCounts, blockers, recent + RETURN g, size(items) as nodeCount, edgeCount, types, statuses, blockers, recent `; const result = await session.run(query, { graphId: args.graphId }); @@ -3334,8 +3330,8 @@ export class GraphService { const record = result.records[0]; const g = record.get('g').properties; - const typeCounts = (record.get('typeCounts') || []) as Array<{ type: string; count: unknown }>; - const statusCounts = (record.get('statusCounts') || []) as Array<{ status: string; count: unknown }>; + const types = (record.get('types') || []) as Array; + const statuses = (record.get('statuses') || []) as Array; const blockers = (record.get('blockers') || []) as Array<{ id: string; title: string; blocksCount: unknown }>; const recent = (record.get('recent') || []) as Array<{ id: string; @@ -3346,12 +3342,12 @@ export class GraphService { }>; const byType: Record = {}; - for (const t of typeCounts) { - if (t.type) byType[t.type] = toNum(t.count); + for (const t of types) { + if (t) byType[t] = (byType[t] ?? 0) + 1; } const byStatus: Record = {}; - for (const s of statusCounts) { - if (s.status) byStatus[s.status] = toNum(s.count); + for (const s of statuses) { + if (s) byStatus[s] = (byStatus[s] ?? 0) + 1; } return { diff --git a/packages/mcp-server/tests/neo4j-contract.test.ts b/packages/mcp-server/tests/neo4j-contract.test.ts index 570d906a..f4db34a5 100644 --- a/packages/mcp-server/tests/neo4j-contract.test.ts +++ b/packages/mcp-server/tests/neo4j-contract.test.ts @@ -29,6 +29,7 @@ describe.skipIf(!RUN)('MCP GraphService — real Neo4j contract', () => { let driver: Driver; let svc: GraphService; const createdNodes: string[] = []; + const createdGraphs: string[] = []; beforeAll(async () => { driver = neo4j.driver(URI, neo4j.auth.basic(USER, PASS)); @@ -42,6 +43,9 @@ describe.skipIf(!RUN)('MCP GraphService — real Neo4j contract', () => { if (createdNodes.length) { await session?.run('MATCH (n:WorkItem) WHERE n.id IN $ids DETACH DELETE n', { ids: createdNodes }); } + if (createdGraphs.length) { + await session?.run('MATCH (g:Graph) WHERE g.id IN $ids DETACH DELETE g', { ids: createdGraphs }); + } } catch { /* ignore */ } await session?.close(); await driver?.close(); @@ -81,6 +85,51 @@ describe.skipIf(!RUN)('MCP GraphService — real Neo4j contract', () => { expect(delEdge).toMatch(/delet|success|true|removed/); }); + it('getGraphContext on a brand-new EMPTY graph returns zero counts, not "not found"', async () => { + // Regression: the type/status tally used `CALL { UNWIND items ... }`, and + // UNWIND of an empty list yields ZERO rows, which dropped the whole result + // row — so an existing empty graph was reported as "not found". + const created = parse(await svc.createGraph({ name: `Contract Empty ${Date.now()}`, type: 'PROJECT' } as any)); + const graphId = created.graph.id; + expect(graphId, 'createGraph persists and returns an id').toBeTruthy(); + createdGraphs.push(graphId); + + const ctx = parse(await svc.getGraphContext({ graphId } as any)).context; + expect(ctx.graph.id, 'the empty graph is found by id').toBe(graphId); + expect(ctx.counts.nodes, 'empty graph has zero nodes').toBe(0); + expect(ctx.counts.edges, 'empty graph has zero edges').toBe(0); + expect(ctx.counts.byType, 'no type tallies on an empty graph').toEqual({}); + expect(ctx.counts.byStatus, 'no status tallies on an empty graph').toEqual({}); + expect(Array.isArray(ctx.topBlockers) && ctx.topBlockers.length, 'no blockers').toBe(0); + expect(Array.isArray(ctx.recentActivity) && ctx.recentActivity.length, 'no recent activity').toBe(0); + }); + + it('getGraphContext tallies type/status once a graph has items', async () => { + const g = parse(await svc.createGraph({ name: `Contract Populated ${Date.now()}`, type: 'PROJECT' } as any)); + const graphId = g.graph.id; + createdGraphs.push(graphId); + + // Attach two TASK/IN_PROGRESS items to this graph + const session = driver.session(); + try { + for (const i of [1, 2]) { + const id = parse(await svc.createNode({ title: `Pop ${i} ${Date.now()}`, type: 'TASK', status: 'IN_PROGRESS' } 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: graphId } + ); + } + } finally { + await session.close(); + } + + const ctx = parse(await svc.getGraphContext({ graphId } as any)).context; + expect(ctx.counts.nodes, 'two items counted').toBe(2); + expect(ctx.counts.byType.TASK, 'both items tallied under TASK').toBe(2); + expect(ctx.counts.byStatus.IN_PROGRESS, 'both items tallied under IN_PROGRESS').toBe(2); + }); + 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 371b47af7af38372e62cfc63f3ebb98be22074d9 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Sat, 13 Jun 2026 10:30:50 -0700 Subject: [PATCH 2/3] Add smoke guard: a brand-new empty graph shows its empty-state, not an error UI counterpart to the get_graph_context empty-graph fix. Verifies that creating a graph with zero work items and opening it renders the "Create Your First Work Item" invitation with no error chrome and no uncaught JS errors. Passes against the live dev stack. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/e2e/user-smoke.spec.ts | 57 ++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/tests/e2e/user-smoke.spec.ts b/tests/e2e/user-smoke.spec.ts index 87b410bd..798f7ec0 100644 --- a/tests/e2e/user-smoke.spec.ts +++ b/tests/e2e/user-smoke.spec.ts @@ -155,6 +155,63 @@ test.describe('user smoke: the app works from a user point of view @smoke', () = }, name); }); + // A brand-new EMPTY graph (the very first thing a user sees after "Create + // Graph") must render its empty-state invitation, NOT crash or show error + // chrome. UI counterpart to the get_graph_context "empty graph reported as + // not found" bug — the empty case is a first-class state. + test('a brand-new empty graph shows the empty-state, not an error @smoke', async ({ page }) => { + const pageErrors: string[] = []; + page.on('pageerror', (e) => pageErrors.push(e.message)); + + await login(page, TEST_USERS.ADMIN); + await page.waitForTimeout(2000); + + const graphId = await page.evaluate(async () => { + const token = localStorage.getItem('authToken') ?? ''; + const post = (query: string, variables?: unknown) => + fetch('/api/graphql', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ query, variables }), + }).then((r) => r.json()); + const me = await post('{ me { id } }'); + const userId = me.data.me.id; + const g = await post( + `mutation($input: [GraphCreateInput!]!) { createGraphs(input: $input) { graphs { id } } }`, + { input: [{ name: `Empty Smoke ${Date.now()}`, type: 'PROJECT', status: 'ACTIVE', createdBy: userId, isShared: true }] } + ); + return g.data.createGraphs.graphs[0].id as string; + }); + expect(graphId, 'empty graph created').toBeTruthy(); + + try { + await page.evaluate((gid) => localStorage.setItem('currentGraphId', gid), graphId); + await page.reload(); + await page.waitForTimeout(6000); + + await expect( + page.locator('text=/Create Your First Work Item|Transform Your Vision/i').first(), + 'empty graph shows its create-first-item invitation' + ).toBeVisible({ timeout: 10000 }); + + const errorBadges = await page + .locator('.graph-container') + .locator('text=/^Error$|not found|failed to load|connection lost/i') + .count(); + expect(errorBadges, 'no error chrome on an empty graph').toBe(0); + expect(pageErrors, `uncaught page errors on empty graph: ${pageErrors[0] ?? ''}`).toEqual([]); + } finally { + await page.evaluate(async (gid) => { + const token = localStorage.getItem('authToken') ?? ''; + await fetch('/api/graphql', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ query: `mutation($id: ID!) { deleteGraphs(where: { id: $id }) { nodesDeleted } }`, variables: { id: gid } }), + }); + }, graphId); + } + }); + test('data integrity: no orphan edges in the database @smoke', async ({ page }) => { await login(page, TEST_USERS.ADMIN); const orphans = await page.evaluate(async () => { From de0ea2eeeae917e8b5c47d02f7bea32469cb9d6b Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Sat, 13 Jun 2026 10:33:02 -0700 Subject: [PATCH 3/3] Update mock-neo4j for the new get_graph_context query shape getGraphContext now returns raw types/statuses lists (tallied in JS) instead of pre-aggregated typeCounts/statusCounts, so the mock driver's canned record must match. Match on the new query (size(items) as nodeCount / as statuses) and return raw label arrays. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/mcp-server/tests/mock-neo4j.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/mcp-server/tests/mock-neo4j.ts b/packages/mcp-server/tests/mock-neo4j.ts index df11cf97..5579a1af 100644 --- a/packages/mcp-server/tests/mock-neo4j.ts +++ b/packages/mcp-server/tests/mock-neo4j.ts @@ -226,7 +226,7 @@ export function createMockDriver(): Driver { } // Handle compact graph context query (get_graph_context, AI-6) - if (query.includes('typeCounts') && query.includes('statusCounts')) { + if (query.includes('size(items) as nodeCount') && query.includes('as statuses')) { if (params?.graphId === 'missing-graph-id') { return { records: [] }; } @@ -241,14 +241,8 @@ export function createMockDriver(): Driver { }, nodeCount: { toNumber: () => 12 }, edgeCount: { toNumber: () => 7 }, - typeCounts: [ - { type: 'TASK', count: { toNumber: () => 8 } }, - { type: 'BUG', count: { toNumber: () => 4 } } - ], - statusCounts: [ - { status: 'IN_PROGRESS', count: { toNumber: () => 5 } }, - { status: 'BLOCKED', count: { toNumber: () => 2 } } - ], + types: ['TASK', 'TASK', 'TASK', 'TASK', 'TASK', 'TASK', 'TASK', 'TASK', 'BUG', 'BUG', 'BUG', 'BUG'], + statuses: ['IN_PROGRESS', 'IN_PROGRESS', 'IN_PROGRESS', 'IN_PROGRESS', 'IN_PROGRESS', 'BLOCKED', 'BLOCKED'], blockers: [ { id: 'node-1', title: 'Fix auth', blocksCount: { toNumber: () => 3 } } ],