diff --git a/packages/agentctx/src/mcp/tools.ts b/packages/agentctx/src/mcp/tools.ts index 91efc27..b8055b0 100644 --- a/packages/agentctx/src/mcp/tools.ts +++ b/packages/agentctx/src/mcp/tools.ts @@ -582,6 +582,11 @@ function optionalString(args: Record, name: string): string | u if (typeof value !== "string") { throw new McpToolError(`${name} must be a string`); } + // Treat empty/whitespace-only strings as absent, matching requireString's + // non-empty rule. This way an optional filter such as `ctx_search file: ""` + // means "no filter" rather than "filter to a file named ''" (which links to + // no entity and silently returns zero results). + if (value.trim().length === 0) return undefined; return value; } diff --git a/packages/agentctx/test/mcp/tools.test.ts b/packages/agentctx/test/mcp/tools.test.ts index 97254a4..9afd91d 100644 --- a/packages/agentctx/test/mcp/tools.test.ts +++ b/packages/agentctx/test/mcp/tools.test.ts @@ -176,6 +176,29 @@ describe("ctx_search", () => { expect(results.map((r) => r.id)).toEqual([linked.id]); }); + it("treats an empty file filter as absent (unfiltered search)", () => { + const linked = seed({ title: "Auth flow decision", body: "JWT in auth module" }); + const unrelated = seed({ title: "Unrelated auth note", body: "JWT elsewhere" }); + const filePath = resolve(cwd, "src/auth.ts"); + linkRecordToEntity(tmp.db, linked.id, upsertNode(tmp.db, PROJECT, "file", filePath)); + + const omitted = call("ctx_search", { query: "JWT auth" }).payload as { + results: Array<{ id: string }>; + }; + const emptyFilter = call("ctx_search", { query: "JWT auth", file: "" }).payload as { + results: Array<{ id: string }>; + }; + const blankFilter = call("ctx_search", { query: "JWT auth", file: " " }).payload as { + results: Array<{ id: string }>; + }; + + const ids = (r: { results: Array<{ id: string }> }) => r.results.map((hit) => hit.id).sort(); + // An empty/whitespace file filter must search unfiltered, not return zero results. + expect(ids(emptyFilter)).toEqual(ids(omitted)); + expect(ids(blankFilter)).toEqual(ids(omitted)); + expect(ids(emptyFilter)).toEqual([linked.id, unrelated.id].sort()); + }); + it("marks degraded like-search when FTS5 is unavailable", () => { seed({ title: "Fallback fact", body: "search should still work" }); tmp.db.exec("DROP TABLE records_fts");