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
14 changes: 14 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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}`);
}
},
Expand Down Expand Up @@ -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}`);
}
},
Expand Down Expand Up @@ -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}`);
}
},
Expand Down
68 changes: 68 additions & 0 deletions tests/claude-code/mcp-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -262,6 +271,65 @@ 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)", () => {
// 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"}',
);
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).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).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).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).toBe(`No summaries found. ${freshOrgHint}`);
});

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)", () => {
Expand Down
Loading