fix(mcp): honest tool text and build-derived server version (#1923)

Tool text that lied to agents:
- gnubok_create_voucher pointed at gnubok_reverse_entry, which does not
  exist; the tool is gnubok_reverse_journal_entry. A scan of server.ts,
  skills/, prompts/ and structured-errors.ts found no other phantom names.
- gnubok_reverse_journal_entry said reversal_date defaults to today; the
  executor passes undefined and reverseEntry() uses the original entry
  date (same as the dashboard). Description now states that. No behaviour
  change.
- gnubok_get_vacation_balance promised an estimated semesterloneskuld in
  SEK but returned none. The tool now returns estimated_liability_sek
  using the same BFNAR 2016:10 day valuation as the year-close and the v1
  vacation-balance route (dayValueSek exported from semesterberedning),
  floored at zero for overdrawn balances. Descriptions trimmed so the
  tools/list payload stays under the 60.7K-token ceiling (60,696 after).
- gnubok_create_invoice said the invoice number is assigned at approval;
  it is assigned on send or mark-as-sent (ensureInvoiceNumber).
- gnubok_convert_invoice: "har redan makuleras" -> "har redan makulerats".
- lib/entitlements/keys.ts comment claimed bank_sync has no MCP tool while
  the map right below gates gnubok_connect_bank on it.

Version: MCP serverInfo.version, the extension version and /api/health all
hardcoded '1.0.0', so clients could not tell deploys apart. They now share
currentAppVersion() (commit SHA prefix inlined at build), resolved once at
module load so the definitions layer stays deterministic, with '1.0.0' as
the self-hosted fallback so Docker healthchecks keep a value. serverInfo is
not part of tools/list, so the catalog payload is unaffected by this part.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-26 13:35:15 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 1a27b5bd4a
commit 1185ab4294
10 changed files with 201 additions and 29 deletions
+1
View File
@@ -1247,6 +1247,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-25] Woo bulk revenue template = per-rate account choice, no hardcoded varor/tjanster preset: BAS 2026 has no standard 30xx goods/services subdivision (3040-series is company-specific), so presets would invent accounts; chosen accounts are validated against the company chart instead, and only diffs from the 3001-series default are sent.
[2026-08-26] Support-dialog attachments use the existing email delivery path without storage or schema changes: this keeps the feature scoped to the contact form. The budget is 5 files / 4 MB total under the 4.5 MB hosted request-body ceiling, with client-side image shrinking when needed.
[2026-08-26] RFC 9728 protected-resource metadata is served at THREE locations (root, path-based /.well-known/oauth-protected-resource/<mcp path>, and <mcp url>/.well-known/oauth-protected-resource): Claude.ai's connector setup derives the metadata URL from the server URL and fetches it before any 401, so the root document our WWW-Authenticate header points at was not enough ('Authorization with Accounted failed' with only 404s in the logs). One builder, three routes; the path-based route answers 404 for any path other than the MCP endpoint so no phantom resource is advertised.
[2026-08-26] MCP serverInfo.version, extension version and /api/health version reuse currentAppVersion() (12-char SHA, '1.0.0' fallback) instead of a new 7-char slice: one identifier across behandlingshistorik, health and MCP so a support thread can match a deploy by a single string; gnubok_get_vacation_balance got a real estimated_liability_sek by exporting semesterberedning's dayValueSek rather than dropping the description's promise, with descriptions trimmed to stay under the tools/list ceiling.
[2026-08-26] raw-route-auth guard judges each top-level export segment of a route file, not the whole file: transactions/[id] (wrapped PATCH + hand-rolled DELETE) and transactions (wrapped POST + hand-rolled GET) passed the file-level check for months because one withRouteContext call exempted every sibling handler. Baseline unchanged (mcp-oauth/authorize is the one grandfathered file).
[2026-08-26] No ratchet on direct requireAuth() calls in app/api: requireAuth() is the MFA (AAL2) guard withRouteContext itself calls, and .claude/rules/api-routes.md sanctions it for routes without a company context (onboarding, account, user prefs). The 20 remaining direct callers skip request ids and the canonical envelope, not MFA; migrating them is a consistency campaign, not a security fix, so it was not folded into the bypass PR.
[2026-08-26] defer_invoice_booking (#967) now gates booking on every door, not just the dashboard: MCP send_invoice / mark_invoice_sent / create_supplier_invoice_from_inbox, v1 invoices send / mark-sent and supplier-invoices create, and the inbox convert route all checked accounting_method === 'accrual' and posted a verifikat at issue for deferred companies. All six now call booksInvoicesOnIssue() (lib/bookkeeping/booking-mode.ts), the same helper the dashboard routes use, so the setting has one meaning. No data repair attempted: vouchers already posted for deferred companies through these doors are legitimate entries and stay.
+70
View File
@@ -0,0 +1,70 @@
/**
* /api/health: the version field is the build identifier (commit SHA prefix)
* when one is inlined at build, and the '1.0.0' fallback otherwise, so
* self-hosted Docker healthchecks keep a stable value.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
let selectResult: { error: { code?: string; message?: string } | null } = { error: null }
vi.mock('@/lib/supabase/service-client', () => ({
createServiceRoleClient: () => ({
from: () => ({
select: () => ({
limit: async () => selectResult,
}),
}),
}),
}))
vi.mock('@/lib/logger', () => ({
createLogger: () => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }),
}))
async function loadRoute() {
vi.resetModules()
return import('../route')
}
beforeEach(() => {
selectResult = { error: null }
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', 'https://example.supabase.co')
vi.stubEnv('SUPABASE_SERVICE_ROLE_KEY', 'service-role-key')
vi.stubEnv('VERCEL_GIT_COMMIT_SHA', '')
vi.stubEnv('NEXT_PUBLIC_BUILD_ID', '')
})
afterEach(() => {
vi.unstubAllEnvs()
})
describe('GET /api/health', () => {
it('reports healthy with the 1.0.0 fallback when no build id is inlined', async () => {
const { GET } = await loadRoute()
const res = await GET()
expect(res.status).toBe(200)
const body = await res.json()
expect(body.status).toBe('healthy')
expect(body.version).toBe('1.0.0')
expect(typeof body.timestamp).toBe('string')
})
it('reports the commit SHA prefix as version when VERCEL_GIT_COMMIT_SHA is set', async () => {
vi.stubEnv('VERCEL_GIT_COMMIT_SHA', 'abcdef1234567890abcdef1234567890abcdef12')
const { GET } = await loadRoute()
const body = await (await GET()).json()
expect(body.status).toBe('healthy')
expect(body.version).toBe('abcdef123456')
})
it('keeps the same version on the unhealthy path', async () => {
vi.stubEnv('VERCEL_GIT_COMMIT_SHA', 'abcdef1234567890abcdef1234567890abcdef12')
selectResult = { error: { code: '57P01', message: 'terminating connection' } }
const { GET } = await loadRoute()
const res = await GET()
expect(res.status).toBe(503)
const body = await res.json()
expect(body.status).toBe('unhealthy')
expect(body.version).toBe('abcdef123456')
})
})
+11 -4
View File
@@ -1,9 +1,16 @@
import { createServiceRoleClient } from '@/lib/supabase/service-client'
import { NextResponse } from 'next/server'
import { createLogger } from '@/lib/logger'
import { currentAppVersion } from '@/lib/reports/app-version'
const log = createLogger('health')
// Build identifier (commit SHA prefix on Vercel) so operators can tell which
// deploy answered. Falls back to '1.0.0' for self-hosted builds with no SHA
// inlined, so Docker healthchecks keep a stable value. Same identifier the
// MCP serverInfo and the behandlingshistorik report stamp.
const APP_VERSION = currentAppVersion() ?? '1.0.0'
const CACHE_TTL_MS = 5_000
type HealthBody = {
@@ -73,7 +80,7 @@ async function runHealthCheck(): Promise<CheckResult> {
if (!supabaseUrl || !supabaseServiceKey) {
log.error('Missing Supabase configuration for health check')
return {
body: { status: 'unhealthy', timestamp: new Date().toISOString(), version: '1.0.0' },
body: { status: 'unhealthy', timestamp: new Date().toISOString(), version: APP_VERSION },
status: 503,
}
}
@@ -94,13 +101,13 @@ async function runHealthCheck(): Promise<CheckResult> {
errMessage: error.message ?? null,
})
return {
body: { status: 'unhealthy', timestamp: new Date().toISOString(), version: '1.0.0' },
body: { status: 'unhealthy', timestamp: new Date().toISOString(), version: APP_VERSION },
status: 503,
}
}
return {
body: { status: 'healthy', timestamp: new Date().toISOString(), version: '1.0.0' },
body: { status: 'healthy', timestamp: new Date().toISOString(), version: APP_VERSION },
status: 200,
}
} catch (err) {
@@ -114,7 +121,7 @@ async function runHealthCheck(): Promise<CheckResult> {
errCode: typeof e?.code === 'string' ? e.code : null,
})
return {
body: { status: 'unhealthy', timestamp: new Date().toISOString(), version: '1.0.0' },
body: { status: 'unhealthy', timestamp: new Date().toISOString(), version: APP_VERSION },
status: 503,
}
}
+2
View File
@@ -140,6 +140,8 @@ Verify the deployment:
```bash
curl http://localhost:3000/api/health
# {"status":"healthy","timestamp":"...","version":"1.0.0"}
# `version` is the build commit SHA prefix when VERCEL_GIT_COMMIT_SHA or
# NEXT_PUBLIC_BUILD_ID was set at build time, otherwise "1.0.0".
```
> **Note:** The health check queries the database, so migrations must be applied before it returns healthy.
@@ -83,6 +83,10 @@ describe('MCP namespace compatibility', () => {
)
const serverInfo = initialized.serverInfo as Record<string, unknown>
expect(serverInfo.name).toBe('gnubok')
// Build-derived (commit SHA prefix) or the '1.0.0' self-hosted fallback:
// never empty, so clients can tell deploys apart.
expect(typeof serverInfo.version).toBe('string')
expect((serverInfo.version as string).length).toBeGreaterThan(0)
expect(initialized.instructions).toContain('gnubok_search_tools')
const listed = await readResult(mcpRequest('tools/list'))
@@ -105,6 +109,8 @@ describe('MCP namespace compatibility', () => {
)
const serverInfo = initialized.serverInfo as Record<string, unknown>
expect(serverInfo.name).toBe('accounted')
expect(typeof serverInfo.version).toBe('string')
expect((serverInfo.version as string).length).toBeGreaterThan(0)
expect(initialized.instructions).toContain('accounted_search_tools')
const listed = await readResult(mcpRequest('tools/list', undefined, 'accounted'))
@@ -294,20 +294,31 @@ describe('gnubok_get_payslip', () => {
})
describe('gnubok_get_vacation_balance', () => {
it('returns the open balance with qualified id and remaining days', async () => {
const BALANCE_ROW = {
id: 'vb-1',
employee_id: 'emp-1',
vacation_year_start: '2026-01-01',
entitled_days: 25,
accrued_days: 0,
taken_days: 10,
saved_days: { '2025': 5 },
forced_payout_days: 0,
}
// Procentregeln, 25 days, 35 000 kr/month: day value = 35000*12*0.12/25 = 2016.
const EMPLOYEE_ROW = {
vacation_rule: 'procentregeln',
vacation_days_per_year: 25,
salary_type: 'monthly',
monthly_salary: 35000,
hourly_rate: null,
hours_per_week: null,
workdays_per_week: null,
}
it('returns the open balance with qualified id, remaining days and the SEK estimate', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: {
id: 'vb-1',
employee_id: 'emp-1',
vacation_year_start: '2026-01-01',
entitled_days: 25,
accrued_days: 0,
taken_days: 10,
saved_days: { '2025': 5 },
forced_payout_days: 0,
},
})
enqueue({ data: BALANCE_ROW })
enqueue({ data: EMPLOYEE_ROW })
const result = (await getVacationBalance.execute(
{ employee_id: 'emp-1' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' },
@@ -317,6 +328,34 @@ describe('gnubok_get_vacation_balance', () => {
expect(result.remaining_days).toBe(15)
expect(result.saved_days).toEqual({ '2025': 5 })
expect(result.id).toBeUndefined()
// (15 remaining + 5 saved) * 2016 kr: the same valuation the v1 route and
// the year-close use, so the description's promise is a real field.
expect(result.estimated_liability_sek).toBe(40320)
expect(getVacationBalance.outputSchema?.properties).toHaveProperty('estimated_liability_sek')
})
it('floors the SEK estimate at zero when the balance is overdrawn', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { ...BALANCE_ROW, entitled_days: 5, taken_days: 12, saved_days: {} } })
enqueue({ data: EMPLOYEE_ROW })
const result = (await getVacationBalance.execute(
{ employee_id: 'emp-1' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' },
)) as Record<string, unknown>
expect(result.remaining_days).toBe(-7)
expect(result.estimated_liability_sek).toBe(0)
})
it('throws when the employee row is missing for the company', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: BALANCE_ROW })
enqueue({ data: null })
await expect(
getVacationBalance.execute(
{ employee_id: 'emp-1' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' },
),
).rejects.toThrow(/Employee emp-1 not found/)
})
it('throws before the ledger has seeded', async () => {
+4 -1
View File
@@ -5,6 +5,7 @@ import { handleMcpRequest, tools as mcpTools } from './server'
import { isForbiddenOrigin, forbiddenOriginResponse } from './origin-guard'
import { registerAgentTools } from '@/lib/agent/tools/registry'
import type { AgentTool } from '@/lib/agent/tools/types'
import { currentAppVersion } from '@/lib/reports/app-version'
// Make the same tool set available to the in-app chat agent. The chat loop
// (lib/agent/chat/*) dispatches against the core agentToolRegistry so it can
@@ -15,7 +16,9 @@ registerAgentTools(mcpTools as unknown as AgentTool[])
export const mcpServerExtension: Extension = {
id: 'mcp-server',
name: 'MCP Server',
version: '1.0.0',
// Build-derived so deploys are distinguishable; '1.0.0' when self-hosted
// without an inlined commit SHA. Same identifier as serverInfo.version.
version: currentAppVersion() ?? '1.0.0',
settingsPanel: {
label: 'MCP-server (API)',
+39 -8
View File
@@ -33,6 +33,8 @@ import {
} from './public-tools'
import { createLogger } from '@/lib/logger'
import { roundOre, sumOre } from '@/lib/money'
import { currentAppVersion } from '@/lib/reports/app-version'
import type { DayValueEmployee } from '@/lib/salary/semesterberedning'
import {
getVatDeadlineForPeriod,
type VatDeadlineCalculationSettings,
@@ -5972,7 +5974,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_create_invoice',
title: 'Create Customer Invoice',
description: 'Stage a new invoice. Validates inputs, calculates VAT preview. Items accept dims bags. Stages for user approval: invoice number assigned at approval.',
description: 'Stage a new invoice. Validates inputs, calculates VAT preview. Items accept dims bags. Approval creates a draft; the invoice number is assigned on send or mark-as-sent.',
outputSchema: STAGED_OPERATION_SCHEMA,
inputSchema: {
type: 'object',
@@ -14301,7 +14303,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_get_vacation_balance',
title: 'Get Vacation Balance (Semestersaldo)',
description: 'Get one employee\'s current vacation balance: entitled/taken/remaining days, sparade dagar per origin year (5-year rule), forced payouts, and an estimated semesterlöneskuld in SEK. Ledger seeds on first booking. Use before gnubok_close_vacation_year.',
description: 'Get one employee\'s open vacation balance: entitled/taken/remaining days, sparade dagar per origin year, forced payouts and estimated semesterlöneskuld in SEK. Use before gnubok_close_vacation_year.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -14323,6 +14325,7 @@ export const tools: McpTool[] = [
remaining_days: { type: 'number' },
saved_days: { type: 'object', description: 'Origin year -> days' },
forced_payout_days: { type: 'number' },
estimated_liability_sek: { type: 'number' },
},
required: ['employee_vacation_balance_id', 'employee_id', 'vacation_year_start', 'entitled_days', 'taken_days', 'remaining_days'],
},
@@ -14344,10 +14347,31 @@ export const tools: McpTool[] = [
const { id, ...rest } = balance as { id: string } & Record<string, unknown>
const entitled = (rest.entitled_days as number) ?? 0
const taken = (rest.taken_days as number) ?? 0
const remaining = roundOre(entitled - taken)
// Same simplified BFNAR 2016:10 day valuation the year-close and the v1
// vacation-balance route use, so the three surfaces agree on the SEK
// estimate. remaining + sparade dagar, floored at zero: an overdrawn
// balance is a receivable, not a negative skuld.
const { dayValueSek } = await import('@/lib/salary/semesterberedning')
const { data: employee, error: empErr } = await supabase
.from('employees')
.select('vacation_rule, vacation_days_per_year, salary_type, monthly_salary, hourly_rate, hours_per_week, workdays_per_week')
.eq('id', employeeId)
.eq('company_id', companyId)
.maybeSingle()
if (empErr) throw new Error(`Database error: ${empErr.message}`)
if (!employee) throw new Error(`Employee ${employeeId} not found for this company`)
const savedTotal = Object.values((rest.saved_days as Record<string, number> | null) ?? {})
.reduce((s, d) => s + (Number(d) || 0), 0)
const liabilityDays = Math.max(0, remaining + savedTotal)
const estimatedLiability = roundOre(liabilityDays * dayValueSek(employee as DayValueEmployee))
return {
employee_vacation_balance_id: id,
...rest,
remaining_days: roundOre(entitled - taken),
remaining_days: remaining,
estimated_liability_sek: estimatedLiability,
}
},
},
@@ -15791,7 +15815,7 @@ export const tools: McpTool[] = [
.eq('id', id).eq('company_id', companyId).single()
if (!inv) throw new Error('Invoice not found')
if (inv.document_type !== 'proforma') throw new Error('Endast proformafakturor kan konverteras')
if (inv.status === 'cancelled') throw new Error('Denna proformafaktura har redan makuleras')
if (inv.status === 'cancelled') throw new Error('Denna proformafaktura har redan makulerats')
const customerName = (inv.customer as { name?: string } | null)?.name ?? 'okänd kund'
return stagePendingOperation(supabase, companyId, userId, 'convert_invoice',
@@ -16536,7 +16560,7 @@ export const tools: McpTool[] = [
if (inbox.created_journal_entry_id) {
throw new Error(
`Inbox item is already booked as journal entry ${inbox.created_journal_entry_id}. ` +
'Use gnubok_correct_entry or gnubok_reverse_entry if it needs to be changed.'
'Use gnubok_correct_entry or gnubok_reverse_journal_entry if it needs to be changed.'
)
}
if (inbox.created_supplier_invoice_id) {
@@ -16859,7 +16883,7 @@ export const tools: McpTool[] = [
additionalProperties: false,
properties: {
entry_id: { type: 'string', description: 'Journal entry UUID OR voucher ref like "A-113". Prefer voucher refs: UUIDs reused from earlier tool output are frequently hallucinated by LLM callers.' },
reversal_date: { type: 'string', pattern: '^[0-9]{4}-[0-9]{2}-[0-9]{2}$', description: 'Optional ISO yyyy-MM-dd date for the storno verifikation. Defaults to today (Swedish timezone). Period attribution always follows the original entry, regardless of this date.' },
reversal_date: { type: 'string', pattern: '^[0-9]{4}-[0-9]{2}-[0-9]{2}$', description: 'Optional ISO yyyy-MM-dd date for the storno verifikation. Defaults to the original entry date. Period attribution always follows the original entry, regardless of this date.' },
reason: { type: 'string', maxLength: 500, description: 'Optional human-readable reason: shown in pending_operations review. Not stored on the storno itself. Max 500 chars.' },
allow_deep_chain: {
type: 'boolean',
@@ -18434,17 +18458,24 @@ assertRecommendedLoadoutsValid(new Set(tools.map((t) => t.name)))
// ── MCP Protocol Handler ─────────────────────────────────────
// Build identifier so MCP clients can tell deploys apart in `initialize`
// serverInfo. Resolved once at module load (constant per process, so the
// definitions layer stays deterministic); the same identifier /api/health
// and the behandlingshistorik report stamp. '1.0.0' is the self-hosted
// fallback when no commit SHA is inlined at build.
const SERVER_VERSION = currentAppVersion() ?? '1.0.0'
const SERVER_INFO_BY_NAMESPACE = {
gnubok: {
// Stable legacy identity for every existing connection.
name: 'gnubok',
title: 'Accounted',
version: '1.0.0',
version: SERVER_VERSION,
},
accounted: {
name: 'accounted',
title: 'Accounted',
version: '1.0.0',
version: SERVER_VERSION,
},
} as const
+3 -2
View File
@@ -82,8 +82,9 @@ export const PAID_CAPABILITIES: readonly CapabilityKey[] = [
* The document upload tools invoke AI (Bedrock document OCR via
* extractInvoiceFields), so they are gated on CAPABILITY.ai: the same paywall
* the HTTP inbox upload/attach/retry paths enforce. Without these entries a
* free-tier API key could trigger paid AI extraction. bank_sync has no MCP
* tool (bank sync is cron/HTTP only).
* free-tier API key could trigger paid AI extraction. bank_sync gates only
* gnubok_connect_bank (the onboarding connect link); the sync itself is
* cron/HTTP only.
*/
export const MCP_TOOL_CAPABILITY_MAP: Readonly<Partial<Record<string, CapabilityKey>>> = {
gnubok_send_invoice: CAPABILITY.email_send,
+13 -1
View File
@@ -118,7 +118,19 @@ function lastDayBefore(dateIso: string): string {
return d.toISOString().slice(0, 10)
}
function dayValueSek(emp: EmployeeRosterRow): number {
/** The employee master fields the day valuation reads. */
export type DayValueEmployee = Pick<
EmployeeRosterRow,
'vacation_rule' | 'vacation_days_per_year' | 'salary_type' | 'monthly_salary' | 'hourly_rate' | 'hours_per_week' | 'workdays_per_week'
>
/**
* Simplified BFNAR 2016:10 value of one vacation day in SEK. Shared by the
* year-close and the MCP vacation-balance tool; the v1 vacation-balance
* route carries the same formula inline. All three must agree on the
* semesterlöneskuld estimate.
*/
export function dayValueSek(emp: DayValueEmployee): number {
const rate = emp.vacation_days_per_year >= 30 ? 0.144 : 0.12
if (emp.salary_type === 'hourly') {
const annualBasis = (emp.hourly_rate || 0) * (emp.hours_per_week ?? 40) * 52