From 6ce8ef7d145e024dedd803ca7761fe69e54d955b Mon Sep 17 00:00:00 2001 From: Levalicious <24482560+Levalicious@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:38:58 -0500 Subject: [PATCH] fix(open_nodes): direction 'any' returns in-edges, each edge once The 'any' branch kept an in-edge only when its source was also in the opened set: the inverse of the intended filter. 'any' therefore returned out-edges only (in-edges from outside the opened set were dropped), and an edge between two opened nodes was listed twice, once as the source's out-edge and once as the target's in-edge. Skip an in-edge under 'any' exactly when its source is opened (it was already emitted as that source's out-edge). That keeps every other in-edge and lists shared edges and self-loops once. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B7UQfn7Vue83jAPFaWfmAZ --- server.ts | 5 ++- tests/open-nodes-direction.test.ts | 72 ++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 tests/open-nodes-direction.test.ts diff --git a/server.ts b/server.ts index 11ea88c..0960e78 100644 --- a/server.ts +++ b/server.ts @@ -1032,7 +1032,10 @@ export class KnowledgeGraphManager { filteredRelations.push(r); } else { if (direction === 'forward') continue; - if (direction === 'any' && !filteredEntityNames.has(targetName)) continue; + // 'any' = out-edges + in-edges. An in-edge whose source is also + // being opened was already emitted as that source's out-edge; + // skip it so it appears exactly once. Every other in-edge stays. + if (direction === 'any' && filteredEntityNames.has(targetName)) continue; const r: Relation = { from: targetName, to: name, relationType }; if (mtime > 0) r.mtime = mtime; filteredRelations.push(r); diff --git a/tests/open-nodes-direction.test.ts b/tests/open-nodes-direction.test.ts new file mode 100644 index 0000000..b458397 --- /dev/null +++ b/tests/open-nodes-direction.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import { createServer, type Relation } from '../server.js'; +import { createTestClient, callTool, type PaginatedGraph } from './test-utils.js'; + +// Regression: open_nodes direction 'any' kept an in-edge only when its source +// was ALSO being opened — the inverse of the right filter. Result: 'any' +// returned out-edges only (in-edges from outside the opened set vanished), and +// an edge between two opened nodes was listed twice (once as the source's +// out-edge, once as the target's in-edge). +describe("open_nodes direction semantics", () => { + let testDir: string; + let client: Awaited>['client']; + let cleanup: () => Promise; + + beforeEach(async () => { + testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-memory-opendir-')); + const server = createServer(path.join(testDir, 'test-memory.json')); + const result = await createTestClient(server); + client = result.client; + cleanup = result.cleanup; + + // A -> B, C -> A, B -> C, D -> B, A -> A (self-loop) + await callTool(client, 'create_entities', { + entities: ['A', 'B', 'C', 'D'].map(name => ({ name, entityType: 'Test', observations: [] })), + }); + await callTool(client, 'create_relations', { + relations: [ + { from: 'A', to: 'B', relationType: 'R' }, + { from: 'C', to: 'A', relationType: 'R' }, + { from: 'B', to: 'C', relationType: 'R' }, + { from: 'D', to: 'B', relationType: 'R' }, + { from: 'A', to: 'A', relationType: 'SELF' }, + ], + }); + }); + + afterEach(async () => { + await cleanup(); + try { + await fs.rm(testDir, { recursive: true }); + } catch { + // Ignore cleanup errors + } + }); + + async function open(names: string[], direction: string): Promise { + const page = await callTool(client, 'open_nodes', { names, direction }) as PaginatedGraph; + expect(page.relations.nextCursor).toBeNull(); + return page.relations.items.map((r: Relation) => `${r.from}-${r.relationType}->${r.to}`).sort(); + } + + it('single node: any = forward ∪ backward, self-loop once', async () => { + expect(await open(['A'], 'forward')).toEqual(['A-R->B', 'A-SELF->A']); + expect(await open(['A'], 'backward')).toEqual(['A-SELF->A', 'C-R->A']); + expect(await open(['A'], 'any')).toEqual(['A-R->B', 'A-SELF->A', 'C-R->A']); + }); + + it('includes in-edges from nodes outside the opened set', async () => { + // D -> B: D is not opened. Before the fix 'any' dropped it. + expect(await open(['B'], 'any')).toEqual(['A-R->B', 'B-R->C', 'D-R->B']); + }); + + it('lists an edge between two opened nodes exactly once', async () => { + // A -> B has both endpoints opened. Before the fix it appeared twice. + expect(await open(['A', 'B'], 'any')).toEqual([ + 'A-R->B', 'A-SELF->A', 'B-R->C', 'C-R->A', 'D-R->B', + ]); + }); +});