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
34 changes: 15 additions & 19 deletions packages/mcp-server/src/services/graph-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 });
Expand All @@ -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<string | null>;
const statuses = (record.get('statuses') || []) as Array<string | null>;
const blockers = (record.get('blockers') || []) as Array<{ id: string; title: string; blocksCount: unknown }>;
const recent = (record.get('recent') || []) as Array<{
id: string;
Expand All @@ -3346,12 +3342,12 @@ export class GraphService {
}>;

const byType: Record<string, number> = {};
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<string, number> = {};
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 {
Expand Down
12 changes: 3 additions & 9 deletions packages/mcp-server/tests/mock-neo4j.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] };
}
Expand All @@ -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 } }
],
Expand Down
49 changes: 49 additions & 0 deletions packages/mcp-server/tests/neo4j-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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();
Expand Down Expand Up @@ -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;
Expand Down
57 changes: 57 additions & 0 deletions tests/e2e/user-smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading