From 3f872ca6b6d367859adb0d33f4d87b647b7331aa Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Thu, 11 Jun 2026 00:54:37 +0000 Subject: [PATCH 1/3] fix(mcp): treat missing memory/sessions tables as empty memory On a fresh org no session has run yet, so the memory/sessions tables don't exist (provisioning lives in the per-agent SessionStart hooks). The MCP tools hivemind_search / hivemind_read / hivemind_index surfaced the raw backend 400 ("relation \"memory\" does not exist") instead of a clean empty result. Classify the caught error with isMissingTableError and return the same friendly empty-memory message the zero-rows path produces, plus a hint that tables are created when the first agent session starts. No DDL from the MCP server: it is a read-only path and a READ-role member could not CREATE TABLE anyway. Fixes #252 --- src/mcp/server.ts | 14 ++++++++ tests/claude-code/mcp-server.test.ts | 52 ++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index ccdb7f84..8a7f034f 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -19,6 +19,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import { loadCredentials } from "../commands/auth.js"; import { loadConfig } from "../config.js"; import { DeeplakeApi } from "../deeplake-api.js"; +import { isMissingTableError } from "../deeplake-schema.js"; import { sqlStr, sqlLike } from "../utils/sql.js"; import { searchDeeplakeTables, buildGrepSearchOptions, normalizeContent, type GrepMatchParams } from "../shell/grep-core.js"; import { getVersion } from "../cli/version.js"; @@ -46,6 +47,16 @@ function errorResult(text: string): { content: Array<{ type: "text"; text: strin return { content: [{ type: "text", text }] }; } +/** + * On a fresh org no session has run yet, so the memory/sessions tables + * don't exist — provisioning happens in the per-agent SessionStart hooks, + * not here (the MCP server is read-only; a READ-role member couldn't + * CREATE TABLE anyway). Treat the backend's missing-table 400 as "memory + * is empty" instead of surfacing the raw error (issue #252). + */ +const FRESH_ORG_HINT = + "Hivemind memory is empty — tables are created when the first agent session starts, and entries appear after it ends."; + const server = new McpServer({ name: "hivemind", version: getVersion(), @@ -87,6 +98,7 @@ server.registerTool( return { content: [{ type: "text", text: lines.join("\n\n---\n\n") }] }; } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); + if (isMissingTableError(msg)) return errorResult(`No matches for "${query}". ${FRESH_ORG_HINT}`); return errorResult(`Search failed: ${msg}`); } }, @@ -120,6 +132,7 @@ server.registerTool( return { content: [{ type: "text", text }] }; } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); + if (isMissingTableError(msg)) return errorResult(`No content found at ${path}. ${FRESH_ORG_HINT}`); return errorResult(`Read failed: ${msg}`); } }, @@ -160,6 +173,7 @@ server.registerTool( return { content: [{ type: "text", text: `path\tlast_updated\tproject\tdescription\n${lines.join("\n")}` }] }; } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); + if (isMissingTableError(msg)) return errorResult(`No summaries found. ${FRESH_ORG_HINT}`); return errorResult(`Index failed: ${msg}`); } }, diff --git a/tests/claude-code/mcp-server.test.ts b/tests/claude-code/mcp-server.test.ts index 3b785c07..075a6a34 100644 --- a/tests/claude-code/mcp-server.test.ts +++ b/tests/claude-code/mcp-server.test.ts @@ -264,6 +264,58 @@ describe("hivemind_index", () => { }); }); +describe("fresh org — missing memory/sessions tables (issue #252)", () => { + // Exact error shape captured from a live repro against api.deeplake.ai + // (MCP server pointed at a nonexistent table). The backend 400 must be + // classified as "memory is empty", not surfaced raw. + const missingTableErr = new Error( + 'Query failed: 400: {"error":"Table does not exist: relation \\"memory\\" does not exist","code":"INVALID_REQUEST","request_id":"fb0c2da8-d02c-4670-8ecd-c232d59b59da"}', + ); + + it("hivemind_index: missing table → 'No summaries found.' + fresh-org hint, no raw 400", async () => { + queryMock.mockRejectedValue(missingTableErr); + await importServer(); + const out = await registeredTools.get("hivemind_index")!.handler({}) as { content: { text: string }[] }; + expect(out.content[0].text).toContain("No summaries found."); + expect(out.content[0].text).toContain("first agent session"); + expect(out.content[0].text).not.toContain("Index failed"); + expect(out.content[0].text).not.toContain("400"); + }); + + it("hivemind_search: missing table → 'No matches' + fresh-org hint, no raw 400", async () => { + searchDeeplakeTablesMock.mockRejectedValue(missingTableErr); + await importServer(); + const out = await registeredTools.get("hivemind_search")!.handler({ query: "needle" }) as { content: { text: string }[] }; + expect(out.content[0].text).toContain('No matches for "needle".'); + expect(out.content[0].text).toContain("first agent session"); + expect(out.content[0].text).not.toContain("Search failed"); + }); + + it("hivemind_read: missing table → 'No content found' + fresh-org hint, no raw 400", async () => { + queryMock.mockRejectedValue(missingTableErr); + await importServer(); + const out = await registeredTools.get("hivemind_read")!.handler({ path: "/summaries/alice/a.md" }) as { content: { text: string }[] }; + expect(out.content[0].text).toContain("No content found at /summaries/alice/a.md"); + expect(out.content[0].text).toContain("first agent session"); + expect(out.content[0].text).not.toContain("Read failed"); + }); + + it("bare postgres wording (relation ... does not exist) is also classified", async () => { + queryMock.mockRejectedValue(new Error('relation "sessions" does not exist')); + await importServer(); + const out = await registeredTools.get("hivemind_index")!.handler({}) as { content: { text: string }[] }; + expect(out.content[0].text).toContain("No summaries found."); + }); + + it("missing COLUMN is NOT treated as fresh org — raw error still surfaces", async () => { + queryMock.mockRejectedValue(new Error('column "description" of relation "memory" does not exist')); + await importServer(); + const out = await registeredTools.get("hivemind_index")!.handler({}) as { content: { text: string }[] }; + expect(out.content[0].text).toContain("Index failed:"); + expect(out.content[0].text).not.toContain("No summaries found."); + }); +}); + describe("error-message coercion (non-Error rejections)", () => { // Source uses `err instanceof Error ? err.message : String(err)` — exercise // the String(err) branch by rejecting with a non-Error value. From 7a3092475172ba4f6e1a646b04c71706cee28e2c Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Thu, 11 Jun 2026 01:23:33 +0000 Subject: [PATCH 2/3] test(mcp): assert exact fresh-org messages instead of substrings CodeRabbit review on #255: the repo test guidelines prefer asserting specific values over generic substrings. Replace the toContain checks in the fresh-org block with exact toBe assertions on the full message (shared freshOrgHint constant). --- tests/claude-code/mcp-server.test.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/tests/claude-code/mcp-server.test.ts b/tests/claude-code/mcp-server.test.ts index 075a6a34..26353c91 100644 --- a/tests/claude-code/mcp-server.test.ts +++ b/tests/claude-code/mcp-server.test.ts @@ -271,40 +271,35 @@ describe("fresh org — missing memory/sessions tables (issue #252)", () => { const missingTableErr = new Error( 'Query failed: 400: {"error":"Table does not exist: relation \\"memory\\" does not exist","code":"INVALID_REQUEST","request_id":"fb0c2da8-d02c-4670-8ecd-c232d59b59da"}', ); + const freshOrgHint = + "Hivemind memory is empty — tables are created when the first agent session starts, and entries appear after it ends."; it("hivemind_index: missing table → 'No summaries found.' + fresh-org hint, no raw 400", async () => { queryMock.mockRejectedValue(missingTableErr); await importServer(); const out = await registeredTools.get("hivemind_index")!.handler({}) as { content: { text: string }[] }; - expect(out.content[0].text).toContain("No summaries found."); - expect(out.content[0].text).toContain("first agent session"); - expect(out.content[0].text).not.toContain("Index failed"); - expect(out.content[0].text).not.toContain("400"); + expect(out.content[0].text).toBe(`No summaries found. ${freshOrgHint}`); }); it("hivemind_search: missing table → 'No matches' + fresh-org hint, no raw 400", async () => { searchDeeplakeTablesMock.mockRejectedValue(missingTableErr); await importServer(); const out = await registeredTools.get("hivemind_search")!.handler({ query: "needle" }) as { content: { text: string }[] }; - expect(out.content[0].text).toContain('No matches for "needle".'); - expect(out.content[0].text).toContain("first agent session"); - expect(out.content[0].text).not.toContain("Search failed"); + expect(out.content[0].text).toBe(`No matches for "needle". ${freshOrgHint}`); }); it("hivemind_read: missing table → 'No content found' + fresh-org hint, no raw 400", async () => { queryMock.mockRejectedValue(missingTableErr); await importServer(); const out = await registeredTools.get("hivemind_read")!.handler({ path: "/summaries/alice/a.md" }) as { content: { text: string }[] }; - expect(out.content[0].text).toContain("No content found at /summaries/alice/a.md"); - expect(out.content[0].text).toContain("first agent session"); - expect(out.content[0].text).not.toContain("Read failed"); + expect(out.content[0].text).toBe(`No content found at /summaries/alice/a.md. ${freshOrgHint}`); }); it("bare postgres wording (relation ... does not exist) is also classified", async () => { queryMock.mockRejectedValue(new Error('relation "sessions" does not exist')); await importServer(); const out = await registeredTools.get("hivemind_index")!.handler({}) as { content: { text: string }[] }; - expect(out.content[0].text).toContain("No summaries found."); + expect(out.content[0].text).toBe(`No summaries found. ${freshOrgHint}`); }); it("missing COLUMN is NOT treated as fresh org — raw error still surfaces", async () => { From 5c0917472452b41447083915900c735f7e9fd745 Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Thu, 11 Jun 2026 01:35:39 +0000 Subject: [PATCH 3/3] test(mcp): guard row rendering against NULL/missing fields Index and read map backend rows straight into the text the agent consumes; without the ?? fallbacks a NULL description or message::text would render the literal strings 'null'/'undefined' into the recall context. Cover both paths with exact-output assertions (branches 84.6% -> 94.2% on src/mcp/server.ts). --- tests/claude-code/mcp-server.test.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/claude-code/mcp-server.test.ts b/tests/claude-code/mcp-server.test.ts index 26353c91..848eaa42 100644 --- a/tests/claude-code/mcp-server.test.ts +++ b/tests/claude-code/mcp-server.test.ts @@ -200,6 +200,15 @@ describe("hivemind_read", () => { expect(JSON.stringify(out)).toContain("Read failed: conn refused"); }); + it("a row with SQL NULL content renders as empty, not as the string 'null'", async () => { + // message is a nullable JSONB column, so message::text can be NULL on + // real session rows. String(null) would hand the agent a literal "null". + queryMock.mockResolvedValue([{ path: "/sessions/alice/s.jsonl", content: null }]); + await importServer(); + const out = await registeredTools.get("hivemind_read")!.handler({ path: "/sessions/alice/s.jsonl" }) as { content: { text: string }[] }; + expect(out.content[0].text).toBe(""); + }); + it("not authenticated → auth-error short-circuits before any query", async () => { loadCredentialsMock.mockReturnValue(null); await importServer(); @@ -262,6 +271,18 @@ describe("hivemind_index", () => { const out = await registeredTools.get("hivemind_index")!.handler({}) as { content: { text: string }[] }; expect(out.content[0].text).toContain("/summaries/alice/a.md\t2026-04-01\tml\tAlice's first session"); }); + + it("incomplete legacy rows render placeholders, never the strings 'null'/'undefined'", async () => { + // Rows from orgs predating a schema-heal can come back with missing + // keys or SQL NULLs. The agent reads this output verbatim — feeding it + // "undefined\tnull\t..." would poison the recall context. + queryMock.mockResolvedValue([ + { description: null, project: null, last_update_date: null }, + ]); + await importServer(); + const out = await registeredTools.get("hivemind_index")!.handler({}) as { content: { text: string }[] }; + expect(out.content[0].text).toBe("path\tlast_updated\tproject\tdescription\n?\t\t\t"); + }); }); describe("fresh org — missing memory/sessions tables (issue #252)", () => {