fix(mcp): unblock query_journal text search across line + entry descriptions (#605)

* fix(mcp): unblock query_journal text search across line+entry descriptions

`gnubok_query_journal` issued a single PostgREST `.or()` filter mixing a
base-table column (line_description on journal_entry_lines) with a column
on the embedded inner-joined resource (journal_entries.description).
PostgREST's flat comma OR syntax cannot span base and embedded resources
and returned 400 "failed to parse logic tree" on every free-text search,
so the agent could not look up history (e.g. "har vi bokfört Google
innan?") via the MCP tool.

Refactor: pull the common filter chain into a `buildBaseQuery()` helper
and issue two parallel `.ilike()` queries — one on `line_description`,
one on `journal_entries.description` — then merge by line id, re-sort,
and slice to `limit`. Same pattern as
`lib/invoices/duplicate-payment-candidates.ts`, with the same rationale
(LIKE-DSL injection risk + cross-embed unsupported).

Drops the obsolete comma-stripping pre-processing on the search term
(only needed because the value was being injected into PostgREST's OR
DSL); LIKE wildcard escaping (`%` and `_`) is preserved.

Adds four tests covering: merge across both legs, dedup of cross-leg
overlap, correct columns + escaped pattern sent to `.ilike`, and
LIKE-wildcard escape end-to-end. Existing amount-filter / accounts-cap /
truncation tests unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(mcp): query_journal PR review — honest truncation, wider leg, error sanitisation

Address PR #605 review (Greptile + compliance swarm).

- truncated math: previously dbMatched = byLine.count + byEntry.count
  inflated the counter every time a row matched both legs (e.g. an entry
  whose line description AND header description both contain "Google").
  total_lines was off by up to 2× and truncated was forced true even when
  every distinct match was already returned, sending the agent into
  unnecessary narrow-and-retry loops. dbMatched is now merged.size — the
  honest distinct-row count among what we fetched. A new legCapHit signal
  drives truncated when either leg's fetch window filled, so we never lie
  the other direction either.

- per-leg fetch widened to 2 × limit. Previously each leg was capped at
  `limit` independently, so when one column was much more selective than
  the other (e.g. 150 line matches vs 5 entry matches), the merge could
  drop globally-ranked rows from the chronologically newer tail. The
  final slice still caps at `limit`; the wider per-leg window just gives
  the merge a better tail to choose from.

- text input length cap (200 chars). Defence-in-depth against pathological
  inputs even though .ilike() parameterises the value (compliance A.8.28).

- error sanitisation in the text-search path. Raw PostgREST messages can
  surface internal schema details (table names, constraint names) to the
  caller. We now log details server-side via the existing module logger
  and throw a generic message (compliance V16).

- comma-stripping rationale documented inline so the next reviewer
  doesn't re-add it. The previous defence was needed because the value
  was injected into PostgREST's OR DSL where `,` is the separator; the
  parameterised .ilike() path treats `,` as a literal and stripping it
  would mangle real-comma searches (compliance V1.2.5).

Adds 4 regression tests: overlap-doesn't-falsely-truncate, leg-cap-hit
flags truncated, text>200 rejected, raw schema name never leaked. All
14 query_journal tests pass; 266/266 MCP suite green; build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(mcp): query_journal PR review round 2 — schema guards, leg cap, audit log

Address compliance swarm findings on commit 3dfab46c.

- text input: add `maxLength: 200` to the JSON Schema so the limit is
  enforced at the boundary by the MCP runtime, not only inside execute().
  Inline guard kept as belt-and-suspenders (ASVS V2.2, ISO 27001 A.8.28).
- limit input: add `minimum: 1, maximum: 500` to the JSON Schema (matches
  the existing Math.min cap inside execute()) so the bound is visible to
  agents and enforced declaratively.
- legLimit hard cap: `Math.min(limit * 2, 500)`. Previously legLimit
  scaled with limit unbounded, so a maxed-out limit triggered 2×1000-row
  fetches. Cap is independent of the caller-supplied limit (ASVS V2.3).
- audit log: include `userId` alongside `companyId` in both log.warn
  payloads so failed-query events can be correlated to the actor for
  incident investigation (ASVS V16, SOC 2 CC7.2).
- new test: assert both parallel ilike legs in the text-search path issue
  `.eq('journal_entries.company_id', companyId)` — defence-in-depth
  against a future refactor accidentally dropping tenant scoping from
  one leg (SOC 2 CC6.1).

Not addressed (with rationale):

- ASVS V1.2.5 / SOC 2 CC7.2 "log.warn body contains raw PostgREST error
  text". The swedish-accounting-compliance reviewer explicitly cleared
  this on the same commit ("the schema-leak is plugged in both paths").
  The thrown error is generic; raw detail lives only in internal
  structured logs (Sentry, etc.) where it is needed for incident triage.
  Same pattern is used by 20+ other tools in this file; scrubbing only
  this one is inconsistent. Defer to a project-wide logger sanitiser.

- Swedish reviewer's "reversed mixed with posted in status=all": false
  positive — `status` IS surfaced per line in the response shape (see
  the lines.map projection in server.ts).

- Swedish reviewer's "debit XOR credit not enforced": insert-time
  concern, not a read-tool concern. Out of scope.

Tests: 15 query_journal cases (4 new this round); 267 MCP suite; build
clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-05-29 14:21:46 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 7eb8715417
commit 6629964780
2 changed files with 469 additions and 63 deletions
@@ -55,6 +55,97 @@ function makeChainMock(lines: unknown[], count: number) {
} as never
}
/**
* Richer mock for the text-search path: returns queued results across
* successive .from() calls and records every .ilike(column, pattern) call so
* tests can assert what was actually sent to PostgREST.
*
* The text branch issues TWO parallel .from('journal_entry_lines') queries —
* one filtered by line_description, one by journal_entries.description. The
* first .from() call gets `results[0]`, the second gets `results[1]`.
*/
function makeQueueMock(results: Array<{ data: unknown[]; count: number }>) {
const ilikeCalls: Array<{ column: string; pattern: string }> = []
// Each entry is one leg's recorded .eq calls. Index lines up with
// .from() invocation order, so tests can assert per-leg tenant scoping.
const eqCallsByLeg: Array<Array<{ column: string; value: unknown }>> = []
let callIndex = 0
const buildChain = (
result: { data: unknown[]; error: null; count: number },
legEqCalls: Array<{ column: string; value: unknown }>,
): unknown => {
return new Proxy(
{},
{
get(_t, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => resolve(result)
}
if (prop === 'ilike') {
return (column: string, pattern: string) => {
ilikeCalls.push({ column, pattern })
return buildChain(result, legEqCalls)
}
}
if (prop === 'eq') {
return (column: string, value: unknown) => {
legEqCalls.push({ column, value })
return buildChain(result, legEqCalls)
}
}
return () => buildChain(result, legEqCalls)
},
},
)
}
const supabase = {
from: vi.fn().mockImplementation(() => {
const next = results[callIndex] ?? { data: [], count: 0 }
callIndex += 1
const legEqCalls: Array<{ column: string; value: unknown }> = []
eqCallsByLeg.push(legEqCalls)
return buildChain({ data: next.data, error: null, count: next.count }, legEqCalls)
}),
} as never
return { supabase, ilikeCalls, eqCallsByLeg, callCount: () => callIndex }
}
/** Build a LineRow fixture inline — keeps the per-test data dense and readable. */
function makeLineRow(opts: {
id: string
account_number?: string
debit_amount?: number
credit_amount?: number
line_description?: string | null
entry_description?: string
voucher_number?: number
entry_date?: string
}) {
return {
id: opts.id,
account_number: opts.account_number ?? '4010',
debit_amount: opts.debit_amount ?? 1000,
credit_amount: opts.credit_amount ?? 0,
currency: 'SEK',
line_description: opts.line_description ?? null,
project: null,
cost_center: null,
sort_order: 0,
journal_entries: {
id: `e-${opts.id}`,
voucher_number: opts.voucher_number ?? 1,
voucher_series: 'A',
entry_date: opts.entry_date ?? '2026-03-15',
description: opts.entry_description ?? '',
source_type: 'bank_transaction',
status: 'posted',
},
}
}
describe('gnubok_query_journal — execute', () => {
it('applies amount_min filter and computes totals on the filtered set', async () => {
const tool = tools.find((t) => t.name === 'gnubok_query_journal')!
@@ -144,3 +235,233 @@ describe('gnubok_query_journal — execute', () => {
expect(result.returned_lines).toBe(1)
})
})
describe('gnubok_query_journal — free-text search', () => {
it('merges non-overlapping results from line_description and journal_entries.description', async () => {
const tool = tools.find((t) => t.name === 'gnubok_query_journal')!
const byLineHit = makeLineRow({
id: 'L1',
line_description: 'GOOGLE*CLOUD EMEA',
entry_description: 'Bank kostnad',
entry_date: '2026-05-10',
voucher_number: 42,
})
const byEntryHit = makeLineRow({
id: 'L2',
line_description: null,
entry_description: 'Google Workspace månadsavgift',
entry_date: '2026-05-12',
voucher_number: 43,
})
const { supabase, callCount } = makeQueueMock([
{ data: [byLineHit], count: 1 },
{ data: [byEntryHit], count: 1 },
])
const result = (await tool.execute(
{ text: 'Google', limit: 50 },
'company-1',
'user-1',
supabase,
)) as { lines: Array<{ line_id: string }>; returned_lines: number }
expect(callCount()).toBe(2)
expect(result.returned_lines).toBe(2)
const ids = result.lines.map((l) => l.line_id).sort()
expect(ids).toEqual(['L1', 'L2'])
})
it('deduplicates rows returned by both query legs', async () => {
const tool = tools.find((t) => t.name === 'gnubok_query_journal')!
const dupHit = makeLineRow({
id: 'LDUP',
line_description: 'Google Cloud',
entry_description: 'Google Cloud invoice',
entry_date: '2026-05-15',
voucher_number: 100,
})
const { supabase } = makeQueueMock([
{ data: [dupHit], count: 1 },
{ data: [dupHit], count: 1 },
])
const result = (await tool.execute(
{ text: 'Google', limit: 50 },
'company-1',
'user-1',
supabase,
)) as { lines: Array<{ line_id: string }>; returned_lines: number }
expect(result.returned_lines).toBe(1)
expect(result.lines[0].line_id).toBe('LDUP')
})
it('issues .ilike against both line_description and journal_entries.description with escaped pattern', async () => {
const tool = tools.find((t) => t.name === 'gnubok_query_journal')!
const { supabase, ilikeCalls } = makeQueueMock([
{ data: [], count: 0 },
{ data: [], count: 0 },
])
await tool.execute(
{ text: 'Google', limit: 50 },
'company-1',
'user-1',
supabase,
)
const columns = ilikeCalls.map((c) => c.column).sort()
expect(columns).toEqual(['journal_entries.description', 'line_description'])
expect(ilikeCalls.every((c) => c.pattern === '%Google%')).toBe(true)
})
it('escapes LIKE wildcards (% and _) in the search pattern', async () => {
const tool = tools.find((t) => t.name === 'gnubok_query_journal')!
const { supabase, ilikeCalls } = makeQueueMock([
{ data: [], count: 0 },
{ data: [], count: 0 },
])
await tool.execute(
{ text: '2_441%foo', limit: 50 },
'company-1',
'user-1',
supabase,
)
// Both legs see the same escaped pattern.
expect(new Set(ilikeCalls.map((c) => c.pattern)).size).toBe(1)
expect(ilikeCalls[0].pattern).toBe('%2\\_441\\%foo%')
})
it('does NOT flag truncated when an overlap row is hit by both legs and merged set fits limit', async () => {
// Greptile / Compliance V2.3 regression: previously, dbMatched = sum of
// leg counts and a row matching both legs would inflate the count and
// force truncated=true even though every distinct match was returned.
const tool = tools.find((t) => t.name === 'gnubok_query_journal')!
const dupHit = makeLineRow({
id: 'LDUP',
line_description: 'Google Cloud',
entry_description: 'Google Cloud invoice',
})
const { supabase } = makeQueueMock([
{ data: [dupHit], count: 1 },
{ data: [dupHit], count: 1 },
])
const result = (await tool.execute(
{ text: 'Google', limit: 50 },
'company-1',
'user-1',
supabase,
)) as { lines: unknown[]; truncated: boolean; total_lines: number; returned_lines: number }
expect(result.returned_lines).toBe(1)
expect(result.total_lines).toBe(1)
expect(result.truncated).toBe(false)
})
it('flags truncated when a leg fills its per-leg fetch window', async () => {
// Per-leg cap is limit*2. With limit=2 → legLimit=4. Returning 4 rows on
// one leg signals "this leg's window filled, more may exist DB-side".
const tool = tools.find((t) => t.name === 'gnubok_query_journal')!
const fullLeg = [
makeLineRow({ id: 'L1', entry_date: '2026-05-10', voucher_number: 4 }),
makeLineRow({ id: 'L2', entry_date: '2026-05-09', voucher_number: 3 }),
makeLineRow({ id: 'L3', entry_date: '2026-05-08', voucher_number: 2 }),
makeLineRow({ id: 'L4', entry_date: '2026-05-07', voucher_number: 1 }),
]
const { supabase } = makeQueueMock([
{ data: fullLeg, count: 4 },
{ data: [], count: 0 },
])
const result = (await tool.execute(
{ text: 'Google', limit: 2 },
'company-1',
'user-1',
supabase,
)) as { returned_lines: number; truncated: boolean }
expect(result.returned_lines).toBe(2)
expect(result.truncated).toBe(true)
})
it('scopes BOTH parallel legs to the caller company_id (tenant isolation)', async () => {
// Defence-in-depth against a future refactor that splits the legs and
// accidentally drops .eq('journal_entries.company_id', companyId) from
// one of them. RLS would still block cross-tenant reads, but losing the
// app-level filter would mean a wider scan than intended.
const tool = tools.find((t) => t.name === 'gnubok_query_journal')!
const { supabase, eqCallsByLeg, callCount } = makeQueueMock([
{ data: [], count: 0 },
{ data: [], count: 0 },
])
await tool.execute(
{ text: 'Google', limit: 50 },
'company-xyz',
'user-1',
supabase,
)
expect(callCount()).toBe(2)
for (const legEqs of eqCallsByLeg) {
const scoped = legEqs.some(
(c) => c.column === 'journal_entries.company_id' && c.value === 'company-xyz',
)
expect(scoped).toBe(true)
}
})
it('rejects text longer than 200 characters', async () => {
const tool = tools.find((t) => t.name === 'gnubok_query_journal')!
const { supabase } = makeQueueMock([])
const oversized = 'x'.repeat(201)
await expect(
tool.execute({ text: oversized, limit: 50 }, 'company-1', 'user-1', supabase),
).rejects.toThrow(/200 characters or shorter/)
})
it('does not surface raw PostgREST error text on text-search failure', async () => {
const tool = tools.find((t) => t.name === 'gnubok_query_journal')!
// Custom mock that returns an error from the first leg.
const supabase = {
from: vi.fn().mockImplementation(() => {
const result = {
data: null,
error: { message: 'relation "journal_entries" does not exist in schema "private_internal"' },
count: null,
}
const buildChain = (): unknown =>
new Proxy(
{},
{
get(_t, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => resolve(result)
}
return () => buildChain()
},
},
)
return buildChain()
}),
} as never
await expect(
tool.execute({ text: 'Google', limit: 50 }, 'company-1', 'user-1', supabase),
).rejects.toThrow(/Database error while running text search/)
// And the schema-leak text never reaches the caller.
await expect(
tool.execute({ text: 'Google', limit: 50 }, 'company-1', 'user-1', supabase),
).rejects.not.toThrow(/private_internal/)
})
})
+148 -63
View File
@@ -3974,7 +3974,7 @@ export const tools: McpTool[] = [
date_to: { type: 'string', description: 'Latest entry date (YYYY-MM-DD, inclusive)' },
amount_min: { type: 'number', description: 'Minimum line amount (absolute value of debit OR credit)' },
amount_max: { type: 'number', description: 'Maximum line amount (absolute value)' },
text: { type: 'string', description: 'Free-text search in entry description and line description' },
text: { type: 'string', maxLength: 200, description: 'Free-text search in entry description and line description (max 200 chars)' },
voucher_series: { type: 'string', description: 'Filter by voucher series (e.g. "A")' },
voucher_number_from: { type: 'number', description: 'Lowest voucher number (inclusive)' },
voucher_number_to: { type: 'number', description: 'Highest voucher number (inclusive)' },
@@ -3982,7 +3982,7 @@ export const tools: McpTool[] = [
status: { type: 'string', enum: ['posted', 'reversed', 'all'], description: 'Default: posted' },
project: { type: 'string', description: 'Filter by project code' },
cost_center: { type: 'string', description: 'Filter by cost center' },
limit: { type: 'number', description: 'Max lines returned 1–500 (default 100). Aggregate totals are computed over the full match set even when truncated.' },
limit: { type: 'number', minimum: 1, maximum: 500, description: 'Max lines returned 1–500 (default 100). Aggregate totals are computed over the full match set even when truncated.' },
},
},
outputSchema: {
@@ -4013,7 +4013,7 @@ export const tools: McpTool[] = [
idempotentHint: true,
openWorldHint: false,
},
async execute(args, companyId, _userId, supabase) {
async execute(args, companyId, userId, supabase) {
const limit = Math.min(Math.max(1, Number(args.limit) || 100), 500)
const status = (args.status as string) || 'posted'
const accounts = args.accounts as string[] | undefined
@@ -4024,71 +4024,61 @@ export const tools: McpTool[] = [
throw new Error('accounts list capped at 50 — use account_from/account_to for ranges')
}
// Build the line-level query with a forced inner join on journal_entries
// so we can filter by the parent's company_id, status, date range, etc.
let query = supabase
.from('journal_entry_lines')
.select(
'id, account_number, debit_amount, credit_amount, currency, line_description, project, cost_center, sort_order, journal_entries!inner(id, voucher_number, voucher_series, entry_date, description, source_type, status, company_id)',
{ count: 'exact' }
)
.eq('journal_entries.company_id', companyId)
if (status === 'all') {
query = query.in('journal_entries.status', ['posted', 'reversed'])
} else {
query = query.eq('journal_entries.status', status)
}
if (accounts && accounts.length > 0) {
query = query.in('account_number', accounts)
} else {
if (accountFrom) query = query.gte('account_number', accountFrom)
if (accountTo) query = query.lte('account_number', accountTo)
}
const dateFrom = args.date_from as string | undefined
const dateTo = args.date_to as string | undefined
if (dateFrom) query = query.gte('journal_entries.entry_date', dateFrom)
if (dateTo) query = query.lte('journal_entries.entry_date', dateTo)
const voucherSeries = args.voucher_series as string | undefined
if (voucherSeries) query = query.eq('journal_entries.voucher_series', voucherSeries)
const vnFrom = args.voucher_number_from as number | undefined
const vnTo = args.voucher_number_to as number | undefined
if (typeof vnFrom === 'number') query = query.gte('journal_entries.voucher_number', vnFrom)
if (typeof vnTo === 'number') query = query.lte('journal_entries.voucher_number', vnTo)
const sourceType = args.source_type as string | undefined
if (sourceType) query = query.eq('journal_entries.source_type', sourceType)
const project = args.project as string | undefined
if (project) query = query.eq('project', project)
const costCenter = args.cost_center as string | undefined
if (costCenter) query = query.eq('cost_center', costCenter)
// Free-text search across both line description and entry description.
// PostgREST `or` filter applies at the joined level when fully qualified.
const text = (args.text as string | undefined)?.trim()
if (text) {
// Escape both LIKE wildcards (`%` and `_`) so a search for "2_441"
// matches the literal string instead of "2X441". Replace `,` with a
// space because PostgREST treats it as the `or` separator.
const escaped = text.replace(/[%]/g, '\\%').replace(/_/g, '\\_').replace(/,/g, ' ')
query = query.or(
`line_description.ilike.%${escaped}%,journal_entries.description.ilike.%${escaped}%`
)
// Each text-search leg needs its own builder instance — PostgREST
// query builders are not reusable across awaits. The factory closes
// over the resolved filter values above.
const buildBaseQuery = () => {
let q = supabase
.from('journal_entry_lines')
.select(
'id, account_number, debit_amount, credit_amount, currency, line_description, project, cost_center, sort_order, journal_entries!inner(id, voucher_number, voucher_series, entry_date, description, source_type, status, company_id)',
{ count: 'exact' }
)
.eq('journal_entries.company_id', companyId)
if (status === 'all') {
q = q.in('journal_entries.status', ['posted', 'reversed'])
} else {
q = q.eq('journal_entries.status', status)
}
if (accounts && accounts.length > 0) {
q = q.in('account_number', accounts)
} else {
if (accountFrom) q = q.gte('account_number', accountFrom)
if (accountTo) q = q.lte('account_number', accountTo)
}
if (dateFrom) q = q.gte('journal_entries.entry_date', dateFrom)
if (dateTo) q = q.lte('journal_entries.entry_date', dateTo)
if (voucherSeries) q = q.eq('journal_entries.voucher_series', voucherSeries)
if (typeof vnFrom === 'number') q = q.gte('journal_entries.voucher_number', vnFrom)
if (typeof vnTo === 'number') q = q.lte('journal_entries.voucher_number', vnTo)
if (sourceType) q = q.eq('journal_entries.source_type', sourceType)
if (project) q = q.eq('project', project)
if (costCenter) q = q.eq('cost_center', costCenter)
return q
}
// Order by date desc then voucher_number desc — most recent first
query = query
.order('entry_date', { foreignTable: 'journal_entries', ascending: false })
.order('voucher_number', { foreignTable: 'journal_entries', ascending: false })
.order('sort_order', { ascending: true })
.limit(limit)
const { data, error, count } = await query
if (error) throw new Error(`Database error: ${error.message}`)
const applyOrderAndLimit = <T extends ReturnType<typeof buildBaseQuery>>(q: T): T =>
q
.order('entry_date', { foreignTable: 'journal_entries', ascending: false })
.order('voucher_number', { foreignTable: 'journal_entries', ascending: false })
.order('sort_order', { ascending: true })
.limit(limit) as T
type LineRow = {
id: string
@@ -4111,19 +4101,115 @@ export const tools: McpTool[] = [
}
}
// Free-text search runs as two parallel .ilike() queries — one against
// line_description (base table) and one against journal_entries.description
// (embedded resource). PostgREST's flat .or() filter cannot span a base
// column and an embedded-resource column ("failed to parse logic tree"),
// so we issue two queries and merge by line id. Same pattern as
// lib/invoices/duplicate-payment-candidates.ts.
const text = (args.text as string | undefined)?.trim()
let data: LineRow[] = []
let dbMatched = 0
// True when at least one text-search leg filled its per-leg fetch
// window — i.e. more matches probably exist on the DB side that didn't
// make it into the merge. Drives the `truncated` signal honestly even
// when the merged distinct set fits inside `limit`.
let legCapHit = false
if (text) {
// Length guard — defence in depth against pathological inputs even
// though .ilike() parameterises the value (compliance A.8.28).
if (text.length > 200) {
throw new Error('text filter must be 200 characters or shorter')
}
// LIKE wildcards `%` and `_` are escaped so a search for "2_441"
// matches the literal string. Comma stripping is intentionally NOT
// applied here: the previous implementation needed it because the
// value was interpolated into PostgREST's OR DSL where `,` is the
// separator. The .ilike() path passes the pattern as a parameterised
// filter operand where `,` is a literal — stripping would mangle
// searches for real commas in line descriptions.
const escaped = text.replace(/[%]/g, '\\%').replace(/_/g, '\\_')
const pattern = `%${escaped}%`
// Fetch up to 2× limit per leg to reduce global-ordering loss when
// one leg is much more selective than the other (e.g. 150 line
// matches vs 5 entry matches with limit=100). Hard-capped at 500
// rows per leg so a caller-supplied `limit` near its own ceiling
// can't fan out to 2× very large queries. The final post-merge
// slice still caps at `limit`; the wider per-leg window just gives
// the merge a better tail to choose from.
const legLimit = Math.min(limit * 2, 500)
const buildLeg = (column: 'line_description' | 'journal_entries.description') =>
buildBaseQuery()
.ilike(column, pattern)
.order('entry_date', { foreignTable: 'journal_entries', ascending: false })
.order('voucher_number', { foreignTable: 'journal_entries', ascending: false })
.order('sort_order', { ascending: true })
.limit(legLimit)
const [byLine, byEntry] = await Promise.all([
buildLeg('line_description'),
buildLeg('journal_entries.description'),
])
if (byLine.error || byEntry.error) {
log.warn('query_journal text-search failed', {
companyId,
userId,
byLine: byLine.error?.message ?? null,
byEntry: byEntry.error?.message ?? null,
})
throw new Error('Database error while running text search')
}
const merged = new Map<string, LineRow>()
for (const row of (byLine.data ?? []) as unknown as LineRow[]) merged.set(row.id, row)
for (const row of (byEntry.data ?? []) as unknown as LineRow[]) {
if (!merged.has(row.id)) merged.set(row.id, row)
}
data = Array.from(merged.values())
.sort((a, b) => {
const ad = a.journal_entries.entry_date
const bd = b.journal_entries.entry_date
if (ad !== bd) return ad < bd ? 1 : -1
const av = a.journal_entries.voucher_number
const bv = b.journal_entries.voucher_number
if (av !== bv) return bv - av
return a.sort_order - b.sort_order
})
.slice(0, limit)
// Honest distinct-row count among what we fetched. If a leg hit its
// window cap, more distinct matches may exist; `legCapHit` carries
// that signal downstream so `truncated` isn't faked false.
dbMatched = merged.size
legCapHit =
(byLine.data?.length ?? 0) >= legLimit ||
(byEntry.data?.length ?? 0) >= legLimit
} else {
const res = await applyOrderAndLimit(buildBaseQuery())
if (res.error) {
log.warn('query_journal failed', { companyId, userId, error: res.error.message })
throw new Error('Database error while running journal query')
}
data = (res.data ?? []) as unknown as LineRow[]
dbMatched = res.count ?? data.length
}
// Apply amount filter post-fetch — PostgREST can't OR an abs(debit) >= n
// with abs(credit) >= n cleanly. Lines are debit XOR credit, so checking
// max(debit, credit) works.
const amountMin = args.amount_min as number | undefined
const amountMax = args.amount_max as number | undefined
const amountFilterApplied = typeof amountMin === 'number' || typeof amountMax === 'number'
const filtered = (data ?? []).filter((row) => {
const r = row as unknown as LineRow
const filtered = data.filter((r) => {
const lineAmount = Math.max(Number(r.debit_amount) || 0, Number(r.credit_amount) || 0)
if (typeof amountMin === 'number' && lineAmount < amountMin) return false
if (typeof amountMax === 'number' && lineAmount > amountMax) return false
return true
}) as unknown as LineRow[]
})
// Compute totals on the fetched-and-filtered set. Note: when truncated,
// these are totals of the returned slice, not the full match. The
@@ -4161,11 +4247,10 @@ export const tools: McpTool[] = [
// result, and surface the pre-filter count + a flag separately so an
// agent can still tell the DB matched more (it just didn't pass the
// amount predicate).
const dbMatched = count ?? (data ?? []).length
const total_lines = amountFilterApplied ? lines.length : dbMatched
const truncated = amountFilterApplied
? (data ?? []).length >= limit && lines.length === limit
: dbMatched > lines.length
? data.length >= limit && lines.length === limit
: dbMatched > lines.length || legCapHit
return {
lines,
truncated,