/** * Pure helpers for the shim-side read-warning probe. * * The upstream `mcp-neo4j-cypher@0.6.0` server runs the cypher against Neo4j * itself and returns rendered text; it drops `result.summary.notifications` * before serialising the JSON-RPC response. That is the dropped-envelope leg * of failure mode `676504f1`: the agent ran `RETURN h.hostname` against a * node whose property is `hostnameValue`, Neo4j emitted `gql_status=01N52 * "property does not exist"`, the upstream surfaced it to its stderr but * NOT to the tool result, and the agent saw `[]` with no actionable signal. * * The shim now runs the same cypher in a second, lightweight pass against * its own Neo4j driver — only to read `summary.notifications` and stitch * codes matching `^0[12]N5\d$` into a warnings prefix block on the * upstream's response. The probe is sequential (after the upstream has * returned) so concurrent ordering and error-handling stay simple. The * upstream's rendered row text is left byte-for-byte untouched as * `content[1]`, so existing callers that read the rows do not see a format * change. * * Extracted from index.ts so the filter/stitch logic is testable as pure * functions without booting the shim's stdin/stdout pipe. */ /** GQL status codes Neo4j 5.x emits for missing / unrecognised schema tokens. * Covers 01N5x ("property does not exist", "type does not exist", etc.) and * 02N5x (label / type missing in pattern). These are the codes whose loss * most often manifests as an empty-rows result the agent cannot diagnose. */ const ENVELOPE_GQL_RE = /^0[12]N5\d$/; /** Shape of a notification as returned by neo4j-driver 5.x's `summary.notifications`. */ export interface DriverNotification { gqlStatus: string; title: string; description: string; position: { offset: number; line: number; column: number } | null; } /** Stitched envelope warning shape — what the agent sees in the tool result. */ export interface EnvelopeWarning { gql_status: string; description: string; position: { offset: number; line: number; column: number } | null; } /** Keep only notifications whose gql_status matches the envelope-passthrough set. */ export function filterEnvelopeNotifications( notifications: DriverNotification[], ): EnvelopeWarning[] { const out: EnvelopeWarning[] = []; for (const n of notifications) { if (typeof n.gqlStatus === "string" && ENVELOPE_GQL_RE.test(n.gqlStatus)) { out.push({ gql_status: n.gqlStatus, description: n.description, position: n.position ?? null, }); } } return out; } /** JSON-RPC response shape — just enough to clone + prepend a content block. */ export interface ResponseEnvelope { jsonrpc?: string; id?: string | number; result?: { content?: Array<{ type?: string; text?: string }>; isError?: boolean; }; } /** * Prepend a warnings content block to a JSON-RPC read response. * Returns the rewritten JSON string, or `null` if there are no warnings to * surface (caller forwards the original line unchanged in that case). */ export function stitchWarningsIntoResponse( msg: ResponseEnvelope, warnings: EnvelopeWarning[], ): string | null { if (warnings.length === 0) return null; const lines = warnings.map( (w) => ` - gql_status=${w.gql_status} ${w.description}` + (w.position ? ` (line ${w.position.line}, column ${w.position.column})` : ""), ); const text = "Neo4j envelope warnings — these were emitted by the driver but " + "dropped by the upstream server. Treat them as schema feedback on " + "the cypher you just ran:\n" + lines.join("\n") + "\n\n--- results below ---"; const original = msg.result?.content ?? []; const wrapped: ResponseEnvelope = { ...msg, result: { ...(msg.result ?? {}), content: [{ type: "text", text }, ...original], }, }; return JSON.stringify(wrapped); } /** Minimal session / driver interfaces for the probe — defined here so the * probe is testable without importing `neo4j-driver` types at test time. */ export interface ProbeSession { run( cypher: string, params?: Record, ): Promise<{ summary: { notifications?: DriverNotification[] } }>; close(): Promise; } export interface ProbeDriver { session(): ProbeSession; } /** * Run cypher through the shim's driver purely to capture * `result.summary.notifications`. The probe is best-effort: any error closes * the session and rethrows so the caller can decide to forward the * upstream's response unchanged. */ export async function runReadProbe( driver: ProbeDriver, cypher: string, params: Record, ): Promise { const session = driver.session(); try { const result = await session.run(cypher, params); return result.summary.notifications ?? []; } finally { await session.close().catch(() => { /* session already closed on error path — swallow */ }); } }