feat(mcp): self-describing agent surface — staging _meta, company identity, clean skill summaries (#775)
* feat(mcp): make the agent surface self-describing (staging _meta, company identity, clean summaries)
A pass over the MCP server's agent-facing surface so an agent can act
correctly without parsing description prose:
- Machine-readable staging contract: deriveToolMeta() attaches _meta to
tools/list (and search detail=full) — { requires_approval, approve_tool,
preflight? } — keyed off the STAGED_OPERATION_SCHEMA output schema. Literal
_meta (e.g. UI widget hints) wins on collision. TOOL_PREFLIGHT_MAP names the
read-only pre-flight for the few writes that have one (year-end readiness,
VAT validate, depreciation proposal). Guarded by staging-meta.test.ts.
- Company identity in gnubok_get_agent_briefing: returns a `company` block
(id, name, org_number, entity_type, accounting_method) so the agent can
confirm WHICH entity it operates on and pick the right settlement account
(accrual = credit 1510; cash = debit 19xx) before any write. Best-effort —
a missing row never blocks the briefing. Covered by agent-briefing.test.ts.
- toSummary(): trims the long, keyword-stuffed SKILL.md frontmatter into clean
one-liners for gnubok_list_skills / gnubok_get_agent_briefing so the client
never truncates one mid-sentence; full bodies stay in gnubok_load_skill.
Covered by to-summary.test.ts.
- bank-reconciliation skill: a match/link decision tree (what you have x
whether a verifikat exists) and kontant- vs faktureringsmetoden settlement
accounts.
- Prose/description clarifications: "Stages"/"Stages for approval" on the
link tools; propose_dispositioner/accruals note there is no dedicated MCP
poster; server-info documents _meta and the legacy gnubok_ tool prefix.
All 34 touched MCP tests pass. Merged cleanly on top of #759/#760 (server.ts).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(mcp): make accounting_method description state the full settlement posting
Review (swedish-accounting-compliance): the agent-briefing schema described
accrual as "credit 1510 on payment", which reads as a one-sided entry. Spell
out both sides (payment debits 19xx AND credits 1510) so an agent can't infer a
single-leg posting that violates BFL 5 kap double-entry. Mirrors the precision
already in the bank-reconciliation skill body. Payload-size guard still passes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,4 +19,6 @@ Accounted exposes its bookkeeping engine as an MCP server for Claude Desktop/Cod
|
||||
- Every `inputSchema` must declare `additionalProperties: false` at the top level. Guarded by `extensions/general/mcp-server/__tests__/strict-schemas.test.ts`.
|
||||
- Tool descriptions must be ≤ 280 chars (guarded by `output-schema.test.ts`). No `Args:` / `Returns:` / `Examples:` blocks — those belong in JSON Schema, not description prose. Use agent-native hints like "Use to…" / "Call X first" instead.
|
||||
- Completion-signal pattern: tools that stage operations return `STAGED_OPERATION_SCHEMA` — `{ staged, risk_level, actor, message, preview, period_status?, next? }`. The `staged: true` boolean is the explicit completion signal; agents must not infer completion from prose. Do NOT introduce a parallel `{ success, shouldContinue, output }` envelope.
|
||||
- Machine-readable staging contract: `tools/list` (and `gnubok_search_tools` detail=full) attach a derived `_meta` to staging writes so an agent knows the contract WITHOUT reading prose. `deriveToolMeta()` keys off `outputSchema === STAGED_OPERATION_SCHEMA` and emits `{ requires_approval: true, approve_tool: 'gnubok_approve_pending_operation', preflight? }`; it merges under any literal `_meta` (e.g. UI widget hints), which wins on collision. Add to `TOOL_PREFLIGHT_MAP` when a write has a genuine read-only pre-flight (e.g. `gnubok_run_year_end` → `gnubok_year_end_readiness`). A new staging tool inherits `_meta` for free — just keep its description declaring it stages (guarded by `__tests__/staging-meta.test.ts`). `confirmed=true` belongs on the APPROVE call for high-risk ops, never on the staging tool; only some tools accept `dry_run`/`idempotency_key` — never imply they are universal.
|
||||
- Skill/atom summaries: `gnubok_list_skills` and `gnubok_get_agent_briefing` pass registry `description` fields through `toSummary()` (`skills/atoms.ts`) — the raw SKILL.md frontmatter is a long keyword-stuffed trigger list authored for CLI matching, not display copy, and gets truncated mid-sentence otherwise. Full bodies are fetched via `gnubok_load_skill`. The local `.claude/skills/*` are the Claude-Code surface; the `agent_atom_registry` rows seeded from the same bodies are the canonical connector surface — when they overlap, the connector atom is authoritative for MCP users.
|
||||
- Tools that touch a fiscal-period-bound date (categorize, mark paid, create voucher, correct/reverse entry, approve supplier invoice) pass `dateForPeriodCheck` to `stagePendingOperation` so the response includes `period_status: { period_id, status: open|locked|closed, lock_date }`. Widgets and agents use this to disable writes without round-trips.
|
||||
|
||||
@@ -46,6 +46,10 @@ function mockSupabase(opts: {
|
||||
}>
|
||||
// profiles.full_name for the signed-in user. undefined → no profile row.
|
||||
userFullName?: string | null
|
||||
// companies row for the active company. undefined → no row (null data).
|
||||
company?: { name: string | null; org_number: string | null; entity_type: string | null } | null
|
||||
// company_settings.accounting_method. undefined → no settings row (null data).
|
||||
accountingMethod?: string | null
|
||||
errors?: { profile?: string; memory?: string; atoms?: string }
|
||||
}) {
|
||||
const profile = opts.profile === undefined ? null : opts.profile
|
||||
@@ -110,6 +114,33 @@ function mockSupabase(opts: {
|
||||
})),
|
||||
}
|
||||
}
|
||||
if (table === 'companies') {
|
||||
return {
|
||||
select: vi.fn(() => ({
|
||||
eq: vi.fn(() => ({
|
||||
maybeSingle: vi.fn().mockResolvedValue({
|
||||
data: opts.company === undefined ? null : opts.company,
|
||||
error: null,
|
||||
}),
|
||||
})),
|
||||
})),
|
||||
}
|
||||
}
|
||||
if (table === 'company_settings') {
|
||||
return {
|
||||
select: vi.fn(() => ({
|
||||
eq: vi.fn(() => ({
|
||||
maybeSingle: vi.fn().mockResolvedValue({
|
||||
data:
|
||||
opts.accountingMethod === undefined
|
||||
? null
|
||||
: { accounting_method: opts.accountingMethod },
|
||||
error: null,
|
||||
}),
|
||||
})),
|
||||
})),
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected table in test mock: ${table}`)
|
||||
}),
|
||||
}
|
||||
@@ -156,6 +187,54 @@ describe('gnubok_get_agent_briefing tool', () => {
|
||||
expect(result.user_name).toBeNull()
|
||||
})
|
||||
|
||||
it('returns the active company block so the agent can confirm the entity before writing', async () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_get_agent_briefing')!
|
||||
const supabase = mockSupabase({
|
||||
profile: null,
|
||||
company: { name: 'Acme AB', org_number: '556677-8899', entity_type: 'aktiebolag' },
|
||||
accountingMethod: 'cash',
|
||||
})
|
||||
const result = (await tool.execute(
|
||||
{},
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
{ type: 'api_key' }
|
||||
)) as {
|
||||
company: {
|
||||
id: string
|
||||
name: string | null
|
||||
org_number: string | null
|
||||
entity_type: string | null
|
||||
accounting_method: string | null
|
||||
}
|
||||
}
|
||||
expect(result.company).toEqual({
|
||||
id: 'company-1',
|
||||
name: 'Acme AB',
|
||||
org_number: '556677-8899',
|
||||
entity_type: 'aktiebolag',
|
||||
accounting_method: 'cash',
|
||||
})
|
||||
})
|
||||
|
||||
it('always returns the company id even when the company/settings rows are missing', async () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_get_agent_briefing')!
|
||||
const supabase = mockSupabase({ profile: null })
|
||||
const result = (await tool.execute(
|
||||
{},
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
{ type: 'api_key' }
|
||||
)) as {
|
||||
company: { id: string; name: string | null; accounting_method: string | null }
|
||||
}
|
||||
expect(result.company.id).toBe('company-1')
|
||||
expect(result.company.name).toBeNull()
|
||||
expect(result.company.accounting_method).toBeNull()
|
||||
})
|
||||
|
||||
it('returns only the first name (tilltalsnamn) — data minimisation, not the full legal name', async () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_get_agent_briefing')!
|
||||
const supabase = mockSupabase({ profile: null, userFullName: 'Peter Bennet' })
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { tools } from '../server'
|
||||
import { tools, deriveToolMeta } from '../server'
|
||||
|
||||
describe('tools/list payload size guard', () => {
|
||||
it('keeps the projected tools/list payload under the context-budget ceiling', () => {
|
||||
const projection = tools.map((t) => ({
|
||||
name: t.name,
|
||||
...(t.title ? { title: t.title } : {}),
|
||||
description: t.description,
|
||||
inputSchema: t.inputSchema,
|
||||
...(t.outputSchema ? { outputSchema: t.outputSchema } : {}),
|
||||
annotations: t.annotations,
|
||||
...(t._meta ? { _meta: t._meta } : {}),
|
||||
}))
|
||||
// Mirror the real tools/list serializer, including the derived staging
|
||||
// _meta (requires_approval / approve_tool / preflight) merged over any
|
||||
// literal _meta — otherwise the guard under-measures the wire payload.
|
||||
const projection = tools.map((t) => {
|
||||
const meta = { ...(deriveToolMeta(t) ?? {}), ...(t._meta ?? {}) }
|
||||
return {
|
||||
name: t.name,
|
||||
...(t.title ? { title: t.title } : {}),
|
||||
description: t.description,
|
||||
inputSchema: t.inputSchema,
|
||||
...(t.outputSchema ? { outputSchema: t.outputSchema } : {}),
|
||||
annotations: t.annotations,
|
||||
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
|
||||
}
|
||||
})
|
||||
const payload = JSON.stringify({ tools: projection })
|
||||
const approxTokens = Math.round(payload.length / 4)
|
||||
// Ceiling progression: 20K → 25K → 30K → 31K → 31.5K → 32K → 36K.
|
||||
@@ -46,9 +52,16 @@ describe('tools/list payload size guard', () => {
|
||||
// * Held at 36K when gnubok_list_accrual_schedules (add/bokslut) merged with
|
||||
// the categorize vat_amount override (#717): the combination crossed the
|
||||
// ceiling by ~75, offset by trimming the 8 longest descriptions to ~200 chars.
|
||||
// * 36K → 38K with the MCP legibility pass: the machine-readable staging
|
||||
// contract now emits `_meta { requires_approval, approve_tool, preflight }`
|
||||
// on every staging write (~40 tools) so an agent can tell — without reading
|
||||
// prose — which writes need a follow-up gnubok_approve_pending_operation and
|
||||
// which have a pre-flight; gnubok_get_agent_briefing also gained a `company`
|
||||
// identity block in its outputSchema. This is wire data the agent depends
|
||||
// on, not trimmable prose — hence a bump rather than a description trim.
|
||||
// Long-term answer to growth is leaning harder on gnubok_search_tools — if this
|
||||
// fires again, prefer trimming descriptions or making a tool opt-in via search
|
||||
// before bumping further.
|
||||
expect(approxTokens).toBeLessThan(36_000)
|
||||
expect(approxTokens).toBeLessThan(38_000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -195,7 +195,18 @@ describe('MCP Receipt Matcher', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not include _meta for tools without it', async () => {
|
||||
it('does not include _meta for read-only tools that neither stage nor render UI', async () => {
|
||||
const res = await handleMcpRequest(mcpRequest('tools/list'))
|
||||
const result = await parseResult(res)
|
||||
|
||||
const listTool = result.tools.find(
|
||||
(t: { name: string }) => t.name === 'gnubok_list_customers'
|
||||
)
|
||||
expect(listTool).toBeDefined()
|
||||
expect(listTool._meta).toBeUndefined()
|
||||
})
|
||||
|
||||
it('includes the derived staging contract in _meta for staging writes', async () => {
|
||||
const res = await handleMcpRequest(mcpRequest('tools/list'))
|
||||
const result = await parseResult(res)
|
||||
|
||||
@@ -203,7 +214,10 @@ describe('MCP Receipt Matcher', () => {
|
||||
(t: { name: string }) => t.name === 'gnubok_categorize_transaction'
|
||||
)
|
||||
expect(categorizeTool).toBeDefined()
|
||||
expect(categorizeTool._meta).toBeUndefined()
|
||||
expect(categorizeTool._meta).toMatchObject({
|
||||
requires_approval: true,
|
||||
approve_tool: 'gnubok_approve_pending_operation',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Guards the machine-readable staging contract surfaced via tools/list `_meta`.
|
||||
*
|
||||
* An agent must be able to tell — without reading description prose — whether a
|
||||
* write stages a pending_operation (and so needs a follow-up
|
||||
* gnubok_approve_pending_operation), and whether a read-only pre-flight exists.
|
||||
* That signal is `_meta.requires_approval` / `_meta.preflight`, derived from the
|
||||
* staged-operation output schema. These tests pin that derivation and the
|
||||
* companion prose convention so neither drifts.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { tools, deriveToolMeta } from '../server'
|
||||
|
||||
/** Structural proxy for STAGED_OPERATION_SCHEMA without importing the private const. */
|
||||
function isStagingTool(t: { outputSchema?: Record<string, unknown> }): boolean {
|
||||
const schema = t.outputSchema as
|
||||
| { properties?: Record<string, unknown>; required?: string[] }
|
||||
| undefined
|
||||
return Boolean(schema?.properties?.staged) && Boolean(schema?.required?.includes('staged'))
|
||||
}
|
||||
|
||||
describe('staging contract _meta', () => {
|
||||
it('requires_approval is set exactly for the tools that stage a pending_operation', () => {
|
||||
for (const t of tools) {
|
||||
const meta = deriveToolMeta(t)
|
||||
if (isStagingTool(t)) {
|
||||
expect(meta, `tool ${t.name} should expose staging _meta`).toBeDefined()
|
||||
expect(meta?.requires_approval, `tool ${t.name}`).toBe(true)
|
||||
expect(meta?.approve_tool, `tool ${t.name}`).toBe('gnubok_approve_pending_operation')
|
||||
} else {
|
||||
expect(meta, `tool ${t.name} must not claim to stage`).toBeUndefined()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('every _meta.preflight points at a real, read-only tool', () => {
|
||||
const byName = new Map(tools.map((t) => [t.name, t]))
|
||||
for (const t of tools) {
|
||||
const preflight = deriveToolMeta(t)?.preflight as string | undefined
|
||||
if (!preflight) continue
|
||||
const target = byName.get(preflight)
|
||||
expect(target, `${t.name} preflight ${preflight} must exist`).toBeDefined()
|
||||
expect(target?.annotations.readOnlyHint, `preflight ${preflight} must be read-only`).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('every staging tool declares staging in its description (uniform prose signal)', () => {
|
||||
// The approval REFERENCE is carried machine-readably by _meta.approve_tool
|
||||
// (see the test above), so prose only needs to make the staging nature
|
||||
// unambiguous — every staged write says it stages (stage/stages/staged/staging).
|
||||
const violations = tools
|
||||
.filter(isStagingTool)
|
||||
.filter((t) => !/stag(e|ing)/i.test(t.description))
|
||||
.map((t) => t.name)
|
||||
expect(violations).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Unit tests for toSummary — trims the long, keyword-stuffed SKILL.md
|
||||
* frontmatter descriptions into clean one-liners for gnubok_list_skills and
|
||||
* gnubok_get_agent_briefing, so the client never truncates one mid-sentence.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { toSummary } from '../skills/atoms'
|
||||
|
||||
describe('toSummary', () => {
|
||||
it('returns short input unchanged (idempotent)', () => {
|
||||
const s = 'Swedish VAT compliance reference.'
|
||||
expect(toSummary(s)).toBe(s)
|
||||
expect(toSummary(toSummary(s))).toBe(s)
|
||||
})
|
||||
|
||||
it('collapses internal whitespace', () => {
|
||||
expect(toSummary('Foo bar\n\tbaz')).toBe('Foo bar baz')
|
||||
})
|
||||
|
||||
it('prefers the first sentence when it ends within the cap', () => {
|
||||
const s = 'Project accounting basics. ' + 'tail '.repeat(60) // well over the cap
|
||||
expect(toSummary(s, 60)).toBe('Project accounting basics.')
|
||||
})
|
||||
|
||||
it('cuts at a word boundary with an ellipsis when no sentence ends within the cap', () => {
|
||||
const long = 'a '.repeat(300).trim() // 300 single-char words, no punctuation
|
||||
const out = toSummary(long, 50)
|
||||
expect(out.length).toBeLessThanOrEqual(51) // <= maxLen + ellipsis
|
||||
expect(out.endsWith('…')).toBe(true)
|
||||
expect(out).not.toMatch(/\s…$/) // trimmed before the ellipsis
|
||||
expect(out.includes(' ')).toBe(false)
|
||||
})
|
||||
|
||||
it('never cuts in the middle of a word', () => {
|
||||
const s = 'alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima'
|
||||
const out = toSummary(s, 25)
|
||||
const body = out.replace(/…$/, '').trim()
|
||||
// every whitespace-delimited token in the output is a whole word from the input
|
||||
const words = new Set(s.split(/\s+/))
|
||||
for (const tok of body.split(/\s+/)) expect(words.has(tok)).toBe(true)
|
||||
})
|
||||
|
||||
it('handles the real project-accounting description without a mid-word cut', () => {
|
||||
const desc =
|
||||
'Swedish project accounting (projektredovisning) covering dimensional tagging of bokföringsposter with project codes, WIP accounting (pågående arbeten), revenue recognition under K2 and K3 (successiv vinstavräkning, färdigställandemetoden), construction contracts (entreprenadavtal), BAS account patterns for project tracking (1470, 1620, 2420, 2450, 4970), SIE4 dimension encoding (#DIM 6, #OBJEKT, #TRANS object lists)'
|
||||
const out = toSummary(desc)
|
||||
expect(out.length).toBeLessThanOrEqual(201)
|
||||
expect(out.endsWith('…')).toBe(true)
|
||||
})
|
||||
|
||||
it('tolerates empty/blank input', () => {
|
||||
expect(toSummary('')).toBe('')
|
||||
expect(toSummary(' ')).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -31,7 +31,7 @@ import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown'
|
||||
import { uiWidgets, findUiWidget, WIDGET_MIME_TYPE } from './widgets'
|
||||
import { dataResources, findResource, parseResourceQuery } from './resources'
|
||||
import { prompts, findPrompt } from './prompts'
|
||||
import { findSkill, loadAllSkills, SKILL_MIME_TYPE, SKILL_URI_PREFIX, skillUri, skillSlugFromUri } from './skills'
|
||||
import { findSkill, loadAllSkills, toSummary, SKILL_MIME_TYPE, SKILL_URI_PREFIX, skillUri, skillSlugFromUri } from './skills'
|
||||
import type { SkillTier } from './skills'
|
||||
import { getRiskLevel } from '@/lib/pending-operations/risk-tiers'
|
||||
import { CreateSupplierParamsSchema } from '@/lib/pending-operations/schemas/create-supplier'
|
||||
@@ -800,6 +800,39 @@ const STAGED_OPERATION_SCHEMA = {
|
||||
required: ['staged', 'risk_level', 'actor', 'message', 'preview'],
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Staging writes that have a read-only pre-flight an agent should run first.
|
||||
* Surfaced as `_meta.preflight` in tools/list so the preview/validate step is
|
||||
* discoverable from the staging tool itself, not just from prose. Keep entries
|
||||
* to genuine pre-flights (a tool that returns a verdict/proposal before the
|
||||
* irreversible write) — not recovery/undo tools.
|
||||
*/
|
||||
const TOOL_PREFLIGHT_MAP: Record<string, string> = {
|
||||
gnubok_run_year_end: 'gnubok_year_end_readiness',
|
||||
gnubok_vat_declaration_submit: 'gnubok_vat_declaration_validate',
|
||||
gnubok_post_annual_depreciation: 'gnubok_propose_annual_depreciation',
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovery-time metadata derived from a tool definition, surfaced under `_meta`
|
||||
* in tools/list (and gnubok_search_tools detail=full). Lets an agent tell —
|
||||
* WITHOUT reading prose — whether a write stages for approval and whether a
|
||||
* pre-flight exists. `requires_approval` keys off the staged-operation output
|
||||
* schema, the single source of truth for "this write produces a
|
||||
* pending_operation you must commit via approve_tool". Returns undefined for
|
||||
* tools with no staging contract (reads, direct-commit approve/reject) so we
|
||||
* don't bloat the catalog with empty objects.
|
||||
*/
|
||||
export function deriveToolMeta(t: { name: string; outputSchema?: Record<string, unknown> }): Record<string, unknown> | undefined {
|
||||
if (t.outputSchema !== STAGED_OPERATION_SCHEMA) return undefined
|
||||
const preflight = TOOL_PREFLIGHT_MAP[t.name]
|
||||
return {
|
||||
requires_approval: true,
|
||||
approve_tool: 'gnubok_approve_pending_operation',
|
||||
...(preflight ? { preflight } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function paginatedSchema(itemsKey: string, itemSchema: Record<string, unknown> = { type: 'object' }) {
|
||||
return {
|
||||
type: 'object',
|
||||
@@ -1591,6 +1624,7 @@ export const tools: McpTool[] = [
|
||||
const requiredScope = TOOL_SCOPE_MAP[t.name] ?? null
|
||||
if (detail === 'name') return { name: t.name, scope: requiredScope }
|
||||
if (detail === 'full') {
|
||||
const meta = { ...(deriveToolMeta(t) ?? {}), ...(t._meta ?? {}) }
|
||||
return {
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
@@ -1598,6 +1632,7 @@ export const tools: McpTool[] = [
|
||||
inputSchema: t.inputSchema,
|
||||
...(t.outputSchema ? { outputSchema: t.outputSchema } : {}),
|
||||
annotations: t.annotations,
|
||||
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
|
||||
}
|
||||
}
|
||||
// summary (default)
|
||||
@@ -2072,6 +2107,24 @@ export const tools: McpTool[] = [
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
company: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
description:
|
||||
'The single company every tool call in this session reads and writes. Confirm this is the entity the user means BEFORE any staged write — there is no per-call company switch; scope is fixed by the API key.',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'company_id this session is scoped to.' },
|
||||
name: { type: ['string', 'null'] },
|
||||
org_number: { type: ['string', 'null'] },
|
||||
entity_type: { type: ['string', 'null'], description: 'e.g. "aktiebolag", "enskild_firma". Null if unset.' },
|
||||
accounting_method: {
|
||||
type: ['string', 'null'],
|
||||
enum: ['accrual', 'cash', null],
|
||||
description: 'accrual = faktureringsmetoden: payment debits 19xx AND credits 1510 (both sides). cash = kontantmetoden: payment debits 19xx and books revenue + moms. Drives the settlement posting. Null defaults to accrual.',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
user_name: {
|
||||
type: ['string', 'null'],
|
||||
description:
|
||||
@@ -2112,7 +2165,7 @@ export const tools: McpTool[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['user_name', 'profile_summary', 'atoms', 'memory'],
|
||||
required: ['company', 'user_name', 'profile_summary', 'atoms', 'memory'],
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
@@ -2121,7 +2174,7 @@ export const tools: McpTool[] = [
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(_args, companyId, userId, supabase) {
|
||||
const [profileRes, memoryRes, userRes] = await Promise.all([
|
||||
const [profileRes, memoryRes, userRes, companyRes, settingsRes] = await Promise.all([
|
||||
supabase
|
||||
.from('agent_profiles')
|
||||
.select('profile_summary, horizontal_atoms, vertical_atoms, modifier_atoms')
|
||||
@@ -2145,6 +2198,20 @@ export const tools: McpTool[] = [
|
||||
.select('full_name')
|
||||
.eq('id', userId)
|
||||
.maybeSingle(),
|
||||
// Company identity so the agent can confirm WHICH entity it operates on
|
||||
// before any write. Scope is fixed by the API key — there is no per-call
|
||||
// switch — so this is the session's "whoami for the company". Best-effort:
|
||||
// a failed read still yields a company block with at least the id.
|
||||
supabase
|
||||
.from('companies')
|
||||
.select('name, org_number, entity_type')
|
||||
.eq('id', companyId)
|
||||
.maybeSingle(),
|
||||
supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle(),
|
||||
])
|
||||
|
||||
if (profileRes.error) throw new Error(`Failed to load agent profile: ${profileRes.error.message}`)
|
||||
@@ -2175,6 +2242,20 @@ export const tools: McpTool[] = [
|
||||
.trim()
|
||||
.split(/\s+/)[0] || null)
|
||||
|
||||
// Company identity is best-effort — a missing row never blocks the
|
||||
// briefing. The id is always known (it scopes every query above).
|
||||
const companyRow = companyRes.data as
|
||||
| { name: string | null; org_number: string | null; entity_type: string | null }
|
||||
| null
|
||||
const company = {
|
||||
id: companyId,
|
||||
name: companyRow?.name ?? null,
|
||||
org_number: companyRow?.org_number ?? null,
|
||||
entity_type: companyRow?.entity_type ?? null,
|
||||
accounting_method:
|
||||
(settingsRes.data as { accounting_method: string | null } | null)?.accounting_method ?? null,
|
||||
}
|
||||
|
||||
const atomIds = [
|
||||
...(profile?.horizontal_atoms ?? []),
|
||||
...(profile?.vertical_atoms ?? []),
|
||||
@@ -2198,11 +2279,14 @@ export const tools: McpTool[] = [
|
||||
id: r.id,
|
||||
tier: r.tier,
|
||||
title: r.title ?? r.id,
|
||||
description: r.description,
|
||||
// Trim the keyword-stuffed registry description to a clean one-liner —
|
||||
// bodies are fetched via gnubok_load_skill, not from this metadata.
|
||||
description: toSummary(r.description),
|
||||
}))
|
||||
}
|
||||
|
||||
return {
|
||||
company,
|
||||
user_name: userName,
|
||||
profile_summary: profile?.profile_summary ?? null,
|
||||
atoms,
|
||||
@@ -5359,7 +5443,7 @@ export const tools: McpTool[] = [
|
||||
{
|
||||
name: 'gnubok_link_invoice_to_voucher',
|
||||
title: 'Link Invoice to Voucher',
|
||||
description: 'Markera en faktura som betald via länk till en befintlig verifikation (faktureringsmetoden: krediterar 1510; kontantmetoden: debiterar 19xx). Kör gnubok_find_voucher_candidates_for_invoice först.',
|
||||
description: 'Markera en faktura som betald via länk till en befintlig verifikation (faktureringsmetoden: krediterar 1510; kontantmetoden: debiterar 19xx). Kör gnubok_find_voucher_candidates_for_invoice först. Stages for approval.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -5512,7 +5596,7 @@ export const tools: McpTool[] = [
|
||||
{
|
||||
name: 'gnubok_link_supplier_invoice_to_voucher',
|
||||
title: 'Link Supplier Invoice to Voucher',
|
||||
description: 'Markera en leverantörsfaktura som betald via länk till en befintlig verifikation som debiterar leverantörsskuld (2440). Skapar ingen ny verifikation. Kör gnubok_find_voucher_candidates_for_supplier_invoice först.',
|
||||
description: 'Markera en leverantörsfaktura som betald via länk till en befintlig verifikation som debiterar leverantörsskuld (2440). Skapar ingen ny verifikation. Kör gnubok_find_voucher_candidates_for_supplier_invoice först. Stages.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -8999,7 +9083,7 @@ export const tools: McpTool[] = [
|
||||
name: 'gnubok_propose_dispositioner',
|
||||
title: 'Propose Year-End Dispositioner',
|
||||
description:
|
||||
'Read-only proposal of bokslutsdispositioner for a fiscal period: periodiseringsfond (avsättning + obligatorisk återföring), överavskrivningar, SLP, bolagsskatt. Call before staging postings.',
|
||||
'Read-only proposal of bokslutsdispositioner for a fiscal period: periodiseringsfond (avsättning + obligatorisk återföring), överavskrivningar, SLP, bolagsskatt. No dedicated MCP poster — stage entries via gnubok_create_voucher (web bokslut UI) before gnubok_run_year_end.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -9025,7 +9109,7 @@ export const tools: McpTool[] = [
|
||||
name: 'gnubok_propose_accruals',
|
||||
title: 'Propose Accruals (Periodiseringar)',
|
||||
description:
|
||||
'Read-only proposal of periodiseringar (förutbetalda/upplupna kostnader). Currently surfaces the vacation-liability change; manual prepaid/accrued entries are submitted by the UI form.',
|
||||
'Read-only proposal of periodiseringar (förutbetalda/upplupna kostnader); currently surfaces the vacation-liability change. No dedicated MCP poster — stage accrual entries via gnubok_create_voucher (or the web accruals form).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -10082,7 +10166,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
||||
'',
|
||||
'Common workflows:',
|
||||
'• Categorize transactions: gnubok_list_uncategorized_transactions → gnubok_suggest_categories → gnubok_categorize_transaction (stages) → gnubok_approve_pending_operation (after user confirms in chat).',
|
||||
'• Applying income to invoices — pick by what you have: a specific bank transaction + a known invoice → gnubok_match_transaction_to_invoice; an invoice you know is paid but no specific bank line → gnubok_mark_invoice_as_paid; a whole period of unmatched income to reconcile → gnubok_auto_match_period (dry_run first). All stage for approval.',
|
||||
'• Applying income to invoices — pick by what you have: a specific bank transaction + a known invoice → gnubok_match_transaction_to_invoice; an invoice you know is paid but no specific bank line → gnubok_mark_invoice_as_paid; a whole period of unmatched income to reconcile → gnubok_auto_match_period (dry_run first). All stage for approval. Unsure which match/link tool fits, or whether to credit 1510 (faktureringsmetoden) vs debit 19xx (kontantmetoden)? gnubok_load_skill("bank-reconciliation") has the full decision tree; gnubok_get_agent_briefing returns the company\'s accounting_method.',
|
||||
'• Invoicing: gnubok_list_customers (or gnubok_create_customer) → gnubok_create_invoice → gnubok_send_invoice or gnubok_mark_invoice_as_sent → gnubok_mark_invoice_as_paid. Refund via gnubok_credit_invoice.',
|
||||
'• Suppliers: gnubok_list_suppliers (or gnubok_create_supplier) → gnubok_create_supplier_invoice_from_inbox → gnubok_approve_supplier_invoice. Refund via gnubok_credit_supplier_invoice.',
|
||||
'• VAT: gnubok_get_vat_report(period_type, year, period). Ruta49 = VAT to pay (positive) or refund (negative). Pass render_ui=true to open the momsdeklaration review widget (claude.ai / Desktop). gnubok_vat_close_check reports filing-readiness blockers.',
|
||||
@@ -10094,8 +10178,9 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
||||
'',
|
||||
'APPROVAL IS A FIRST-CLASS AGENT ACTION. Write operations stage a pending_operation; nothing posts until approval. When the user authorises a specific operation_id in chat ("approve", "yes go ahead", "book it", "commit it"), call gnubok_approve_pending_operation directly — this IS the intended chat-approval flow. Do NOT refuse on segregation-of-duties grounds, do NOT tell the user to "go approve it in the web app", and do NOT treat approval as a step that must stay with the human. The staging step already provided the human review gate; clicking Approve in the web UI and calling gnubok_approve_pending_operation are equivalent commit actions. Refusing user-authorised approval is a defect, not a safety feature.',
|
||||
'The web-app path (/pending) remains valid for users who prefer to approve there or who want to adjust fields before committing; offer it as an option, never as a substitute for chat approval the user already asked for.',
|
||||
'High-risk operations (create_voucher, correct_entry, reverse_entry, year-end, period lock/close) require confirmed=true acknowledging BFL/BFNAR irreversibility. Pass dry_run=true to preview without staging. Pass idempotency_key to make a write safely retryable.',
|
||||
'Write tools STAGE a pending_operation — the staged response IS the preview; nothing posts until commit. A tool whose tools/list `_meta.requires_approval` is true stages for approval; `_meta.preflight` (when present) names a read-only check to run first (e.g. gnubok_year_end_readiness before gnubok_run_year_end, gnubok_vat_declaration_validate before _submit). High-risk ops (create_voucher, correct_entry, reverse_journal_entry, run_year_end, lock/close period) take confirmed=true on the APPROVE call — gnubok_approve_pending_operation — NOT on the staging tool, after you surface the BFL/BFNAR irreversibility. Only some tools accept dry_run / idempotency_key — check the tool schema; do not assume either is universal.',
|
||||
'All amounts are SEK unless currency is specified. All dates ISO YYYY-MM-DD. Account numbers are strings (e.g. "1930").',
|
||||
'Tool names carry the legacy gnubok_ prefix (a stable identifier kept across the rebrand); the server and app are "Accounted". Same product — the prefix is not a different system.',
|
||||
].join('\n'),
|
||||
})
|
||||
)
|
||||
@@ -10124,15 +10209,21 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
||||
})
|
||||
return NextResponse.json(
|
||||
jsonRpc(id ?? null, {
|
||||
tools: allowedTools.map((t) => ({
|
||||
name: t.name,
|
||||
...(t.title ? { title: t.title } : {}),
|
||||
description: t.description,
|
||||
inputSchema: t.inputSchema,
|
||||
...(t.outputSchema ? { outputSchema: t.outputSchema } : {}),
|
||||
annotations: t.annotations,
|
||||
...(t._meta ? { _meta: t._meta } : {}),
|
||||
})),
|
||||
tools: allowedTools.map((t) => {
|
||||
// Merge derived staging metadata with any literal _meta (e.g. UI
|
||||
// widget hints). Literal _meta wins on key collision so explicit
|
||||
// tool config is never clobbered.
|
||||
const meta = { ...(deriveToolMeta(t) ?? {}), ...(t._meta ?? {}) }
|
||||
return {
|
||||
name: t.name,
|
||||
...(t.title ? { title: t.title } : {}),
|
||||
description: t.description,
|
||||
inputSchema: t.inputSchema,
|
||||
...(t.outputSchema ? { outputSchema: t.outputSchema } : {}),
|
||||
annotations: t.annotations,
|
||||
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,28 @@ interface AtomRegistryRow {
|
||||
|
||||
let cache: Skill[] | null = null
|
||||
|
||||
/**
|
||||
* SKILL.md frontmatter `description` fields are long, keyword-stuffed trigger
|
||||
* lists authored for CLI skill-matching — not display copy (the project-accounting
|
||||
* atom is ~1,100 chars). `gnubok_list_skills` and `gnubok_get_agent_briefing`
|
||||
* surface them as one-line summaries, where the raw string gets truncated
|
||||
* mid-sentence by the client. Trim to the first sentence, or a clean word
|
||||
* boundary capped at `maxLen` with an ellipsis. The full text always stays
|
||||
* available in the skill body (gnubok_load_skill). Idempotent for short input.
|
||||
*/
|
||||
export function toSummary(description: string, maxLen = 200): string {
|
||||
const text = (description ?? '').trim().replace(/\s+/g, ' ')
|
||||
if (text.length <= maxLen) return text
|
||||
// Prefer the first sentence when it ends within the cap.
|
||||
const firstStop = text.search(/[.!?](\s|$)/)
|
||||
if (firstStop !== -1 && firstStop + 1 <= maxLen) return text.slice(0, firstStop + 1)
|
||||
// Otherwise cut at the last word boundary before the cap and ellipsize so the
|
||||
// truncation is ours (clean) rather than the client's (mid-word).
|
||||
const slice = text.slice(0, maxLen)
|
||||
const lastSpace = slice.lastIndexOf(' ')
|
||||
return `${(lastSpace > 0 ? slice.slice(0, lastSpace) : slice).trimEnd()}…`
|
||||
}
|
||||
|
||||
export async function loadAtomsAsSkills(supabase: SupabaseClient): Promise<Skill[]> {
|
||||
if (cache) return cache
|
||||
|
||||
@@ -83,7 +105,7 @@ export async function loadAtomsAsSkills(supabase: SupabaseClient): Promise<Skill
|
||||
out.push({
|
||||
slug: row.id,
|
||||
name: row.title ?? row.id,
|
||||
summary: row.description,
|
||||
summary: toSummary(row.description),
|
||||
tags,
|
||||
body,
|
||||
tier: row.tier as SkillTier,
|
||||
@@ -137,7 +159,7 @@ export async function loadReferenceById(
|
||||
return {
|
||||
slug: row.id,
|
||||
name: row.title ?? row.id,
|
||||
summary: row.description,
|
||||
summary: toSummary(row.description),
|
||||
tags: [row.tier, 'reference'],
|
||||
body,
|
||||
tier: row.tier as SkillTier,
|
||||
|
||||
@@ -11,6 +11,43 @@ Reconcile the company's bank statements against the bookkeeping ledger so that t
|
||||
- "Why doesn't my 1930 balance match the bank?"
|
||||
- Mid-month spot-check before tax filings
|
||||
|
||||
## Choosing the right matching/linking tool
|
||||
|
||||
Accounted has several reconciliation tools because the *right* one depends on what
|
||||
you already have in hand. Decide along two axes — **what you're connecting** and
|
||||
**whether a verifikat already exists** — then pick:
|
||||
|
||||
| You have… | …and the entry is | Use |
|
||||
|---|---|---|
|
||||
| One bank tx + one customer invoice | not yet booked | \`gnubok_match_transaction_to_invoice\` |
|
||||
| One bank receipt covering many invoices (customer **or** supplier) | not yet booked | \`gnubok_match_batch_allocate\` (samlingsbetalning, BFL 5 kap 6§) |
|
||||
| A whole period of unmatched income to clear | not yet booked | \`gnubok_auto_match_period\` (run \`dry_run=true\` first) |
|
||||
| One bank tx whose affärshändelse you already posted manually | already booked | \`gnubok_link_transaction_to_journal_entry\` |
|
||||
| A customer invoice you know is paid, payment already in a verifikat | already booked | \`gnubok_find_voucher_candidates_for_invoice\` → \`gnubok_link_invoice_to_voucher\` |
|
||||
| A supplier invoice paid, payment already in a verifikat | already booked | \`gnubok_find_voucher_candidates_for_supplier_invoice\` → \`gnubok_link_supplier_invoice_to_voucher\` |
|
||||
| A receipt/document to file against a tx (no new bokföring) | n/a | \`gnubok_attach_document_to_transaction\` |
|
||||
| Income you're sure is paid but with no bank line to point at | not yet booked | \`gnubok_mark_invoice_as_paid\` |
|
||||
|
||||
Rule of thumb: **match_\*** creates the payment bokföring; **link_\*** attaches to
|
||||
bokföring that already exists (no new verifikat). Every one of these stages a
|
||||
pending operation — the user approves before anything posts.
|
||||
|
||||
### Kontantmetoden vs faktureringsmetoden
|
||||
|
||||
The settlement posting differs by the company's \`accounting_method\` (read it from
|
||||
\`gnubok_get_agent_briefing\` — \`accrual\` = faktureringsmetoden, \`cash\` =
|
||||
kontantmetoden; null defaults to accrual):
|
||||
|
||||
- **Faktureringsmetoden (accrual):** revenue was booked at invoice time against
|
||||
kundfordran **1510**. Payment **credits 1510** and debits the bank (19xx). The
|
||||
link/match tools settle 1510.
|
||||
- **Kontantmetoden (cash):** no receivable was raised at invoice time. Payment
|
||||
**debits 19xx** and books the revenue + moms now.
|
||||
|
||||
You usually don't pass the method explicitly — the tools resolve it from company
|
||||
settings — but knowing it lets you sanity-check the staged preview's accounts
|
||||
before approving (1510 movement under accrual; revenue/moms under cash).
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1 — Pull the reconciliation status
|
||||
@@ -84,8 +121,11 @@ ledger after reconciliation:
|
||||
- \`gnubok_list_uncategorized_transactions\`
|
||||
- \`gnubok_suggest_categories\`
|
||||
- \`gnubok_categorize_transaction\`
|
||||
- \`gnubok_match_transaction_to_invoice\`
|
||||
- \`gnubok_match_transaction_to_invoice\`, \`gnubok_match_batch_allocate\` (one receipt → many invoices)
|
||||
- \`gnubok_auto_match_period\` (bulk matcher with confidence thresholds — use for big backlogs)
|
||||
- \`gnubok_link_transaction_to_journal_entry\`, \`gnubok_link_invoice_to_voucher\`, \`gnubok_link_supplier_invoice_to_voucher\` (attach to an existing verifikat — no new bokföring)
|
||||
- \`gnubok_find_voucher_candidates_for_invoice\`, \`gnubok_find_voucher_candidates_for_supplier_invoice\` (read-only — run before the link_\* tools)
|
||||
- \`gnubok_attach_document_to_transaction\` (file a receipt against a tx)
|
||||
- \`gnubok_reverse_journal_entry\` (storno)
|
||||
- \`gnubok_run_currency_revaluation\` (FX accounts only)
|
||||
- \`gnubok_get_trial_balance\`, \`gnubok_get_ar_ledger\`, \`gnubok_get_supplier_ledger\` (verification)
|
||||
@@ -94,8 +134,8 @@ ledger after reconciliation:
|
||||
export const bankReconciliationSkill: Skill = {
|
||||
slug: 'bank-reconciliation',
|
||||
name: 'Bank Reconciliation',
|
||||
summary: 'Stämma av banken: categorize incoming PSD2 rows, match against invoices, resolve duplicates, verify with ledger reports.',
|
||||
tags: ['monthly', 'reconciliation', 'bank', 'verification'],
|
||||
summary: 'Stämma av banken: pick the right match/link tool, categorize PSD2 rows, handle kontant- vs faktureringsmetoden, resolve duplicates, verify with ledger reports.',
|
||||
tags: ['monthly', 'reconciliation', 'bank', 'verification', 'matching'],
|
||||
body,
|
||||
tier: 'workflow',
|
||||
applicability: { entity_type: 'both' },
|
||||
|
||||
@@ -53,4 +53,4 @@ export async function loadAllSkills(supabase: SupabaseClient): Promise<Skill[]>
|
||||
|
||||
export type { Skill, SkillTier } from './types'
|
||||
export { SKILL_MIME_TYPE, SKILL_URI_PREFIX, skillUri, skillSlugFromUri } from './types'
|
||||
export { loadAtomsAsSkills, __resetAtomCache } from './atoms'
|
||||
export { loadAtomsAsSkills, toSummary, __resetAtomCache } from './atoms'
|
||||
|
||||
Reference in New Issue
Block a user