feat(mcp): briefing returns a recommended tool loadout per workflow (#1098) (#1109)

With deferred tool loading on the client side (Claude Code ToolSearch,
claude.ai connector search), an agent starting a bookkeeping session
burns 4-6 round-trips discovering tools cluster by cluster. The
briefing now returns recommended_tools: five per-workflow loadouts
(categorize_month, close_period, invoice_run, vat_declaration,
payroll_month), each with a stable key, one-line description, the
gnubok_load_skill slug for the full playbook, and the exact registry
tool names ordered by typical call sequence, so a batch-selection
harness loads a whole workflow cluster in ONE ToolSearch select call.

Drift protection: assertRecommendedLoadoutsValid() runs at module init
in server.ts right after the tools array is built, failing module load
(and every test importing the server) if a loadout names a tool absent
from the registry or a skill slug absent from workflowSkills; the
agent-briefing test suite pins the same checks.

The list is static per company: the briefing does not query workflow
state today, so gating inclusion would add reads to the bootstrap hot
path. tools/list payload ceiling bumped 57K to 57.5K with a documented
progression entry (schema prose trimmed to the floor first; headroom
before the change was ~15 tokens).

Fixes #1098

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-22 18:31:39 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 25a7261eda
commit 27ef398623
5 changed files with 273 additions and 3 deletions
+1
View File
@@ -274,3 +274,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-22] Issue #313 fix limited to the meals warning; left "Representationsgåvor max 180 kr" on the gåvor line untouched: scope rule (only the inverted-VAT claim and repealed ML 8:9 reference), even though the swedish-vat skill lists 300 SEK as the representationsgåvor base; flagged as follow-up in the PR.
[2026-07-22] LEGACY_DISCOVERY_HOSTS drift guard (#1093) is an exported validateLegacyDiscoveryHosts() returning a violations list, exercised only by a unit test that pins the registered prod config (app.accounted.se canonical + app.gnubok.se SKV pin), not a startup assertion: CI does not set the prod env vars, so a runtime assertion would either no-op in CI or crash self-hosted deploys with different domains; the test-pinned constants make any allowlist or pin change a deliberate, reviewed edit.
[2026-07-22] Stuck-committing recovery sweep (#843) rejects rows without positive evidence instead of reverting to pending, and only three op types (categorize_transaction, link_transaction_journal_entry, match_transaction_invoice) can recover to committed: no generic side-effect -> pending_op linkage exists yet (that is #842's posted-ids work), so evidence is limited to types whose params identify a target row with an unambiguous posted state; reverting to pending risks re-executing side-effects that posted without a trace (duplicate entries/emails).
[2026-07-22] MCP briefing recommended_tools (#1098) ships as a STATIC per-workflow loadout list, not state-gated: the briefing does not query workflow state (unbooked counts, open periods) today, so gating would add reads to the session-bootstrap hot path for marginal honesty; drift protection is a module-init assert against the tool registry + workflow-skill slugs, pinned by tests.
@@ -4,6 +4,8 @@
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { tools } from '../server'
import { RECOMMENDED_WORKFLOW_LOADOUTS, assertRecommendedLoadoutsValid } from '../recommended-tools'
import { workflowSkills } from '../skills'
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
@@ -452,3 +454,84 @@ describe('gnubok_get_agent_briefing tool', () => {
expect(result.dimensions?.enabled).toBe(false)
})
})
describe('recommended_tools workflow loadouts (issue #1098)', () => {
it('every tool name in every loadout exists in the actual tool registry (drift guard)', () => {
const known = new Set(tools.map((t) => t.name))
for (const loadout of RECOMMENDED_WORKFLOW_LOADOUTS) {
const unknown = loadout.tools.filter((name) => !known.has(name))
expect(unknown, `workflow "${loadout.workflow}" references unknown tools`).toEqual([])
}
})
it('every loadout skill slug exists in the workflow-skill registry', () => {
const slugs = new Set(workflowSkills.map((s) => s.slug))
for (const loadout of RECOMMENDED_WORKFLOW_LOADOUTS) {
expect(slugs.has(loadout.skill), `workflow "${loadout.workflow}" skill "${loadout.skill}"`).toBe(true)
}
})
it('workflow keys are unique, stable snake_case, and loadouts are non-empty with a description', () => {
const keys = RECOMMENDED_WORKFLOW_LOADOUTS.map((w) => w.workflow)
expect(new Set(keys).size).toBe(keys.length)
for (const loadout of RECOMMENDED_WORKFLOW_LOADOUTS) {
expect(loadout.workflow).toMatch(/^[a-z][a-z0-9_]*$/)
expect(loadout.description.length).toBeGreaterThan(0)
expect(loadout.tools.length).toBeGreaterThan(0)
// No duplicate tool names within a single loadout.
expect(new Set(loadout.tools).size).toBe(loadout.tools.length)
}
})
it('assertRecommendedLoadoutsValid throws on a tool name missing from the registry', () => {
const known = new Set(tools.map((t) => t.name))
known.delete('gnubok_categorize_transaction')
expect(() => assertRecommendedLoadoutsValid(known)).toThrow(
/categorize_month.*unknown tool "gnubok_categorize_transaction"/
)
})
it('assertRecommendedLoadoutsValid passes against the real registry (mirrors the module-init guard)', () => {
expect(() => assertRecommendedLoadoutsValid(new Set(tools.map((t) => t.name)))).not.toThrow()
})
it('the briefing returns recommended_tools with the expected shape, even for an empty company', 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 {
recommended_tools: Array<{ workflow: string; description: string; skill: string; tools: string[] }>
}
expect(Array.isArray(result.recommended_tools)).toBe(true)
expect(result.recommended_tools.length).toBe(RECOMMENDED_WORKFLOW_LOADOUTS.length)
for (const entry of result.recommended_tools) {
expect(typeof entry.workflow).toBe('string')
expect(typeof entry.description).toBe('string')
expect(typeof entry.skill).toBe('string')
expect(Array.isArray(entry.tools)).toBe(true)
expect(entry.tools.every((n) => typeof n === 'string')).toBe(true)
}
// The static list is returned verbatim, in registry order.
expect(result.recommended_tools.map((w) => w.workflow)).toEqual(
RECOMMENDED_WORKFLOW_LOADOUTS.map((w) => w.workflow)
)
})
it('the briefing outputSchema declares recommended_tools as required', () => {
const tool = tools.find((t) => t.name === 'gnubok_get_agent_briefing')!
const output = tool.outputSchema as { properties: Record<string, unknown>; required: string[] }
expect(output.properties.recommended_tools).toBeDefined()
expect(output.required).toContain('recommended_tools')
})
it('the briefing description advertises the loadout and the one-call batch-load pattern', () => {
const tool = tools.find((t) => t.name === 'gnubok_get_agent_briefing')!
expect(tool.description).toContain('recommended_tools')
expect(tool.description).toContain('ToolSearch')
})
})
@@ -129,9 +129,16 @@ describe('tools/list payload size guard', () => {
// gnubok_delete_absence (inverse of register_absence), both inlining
// STAGED_OPERATION_SCHEMA + _meta + company_id routing. Descriptions
// trimmed to the floor first; the remainder is wire contract.
// * 57K → 57.5K with recommended_tools on gnubok_get_agent_briefing
// (#1098): the per-workflow tool-loadout array in the outputSchema
// (~175 tokens) lets deferred-loading harnesses batch-load a whole
// workflow cluster in one ToolSearch select call instead of 4-6
// discovery round-trips. Schema prose trimmed to the floor first;
// headroom before the change was ~15 tokens, and the remainder is
// the wire contract agents read the loadout through.
// 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(57_000)
expect(approxTokens).toBeLessThan(57_500)
})
})
@@ -0,0 +1,141 @@
/**
* Recommended tool loadouts per workflow, surfaced by gnubok_get_agent_briefing
* as `recommended_tools`.
*
* Why: client harnesses with deferred tool loading (Claude Code ToolSearch,
* claude.ai connector search) otherwise burn 4-6 round-trips discovering tools
* cluster by cluster before any work happens. Each loadout names the exact
* registry tools a workflow needs, ordered by typical call sequence, so a
* harness that supports batch selection (ToolSearch select:a,b,c) loads the
* whole cluster in ONE call, and connector agents stop guessing search
* keywords.
*
* Drift protection (same spirit as deriveToolMeta): the loadouts are validated
* against the real tool registry and the workflow-skill registry via
* assertRecommendedLoadoutsValid(), called at module init in server.ts right
* after the tools array is defined. A loadout naming a tool or skill that does
* not exist fails module load, and therefore every test that imports the
* server. __tests__/agent-briefing.test.ts additionally pins the check.
*
* The list is static per issue #1098: the briefing does not currently query
* workflow state (unbooked counts, open periods), so gating inclusion on state
* would mean new reads in the hot bootstrap path. Every loadout is returned
* for every company; applicability is the agent's judgment call.
*/
import { workflowSkills } from './skills'
export interface WorkflowLoadout {
/** Stable snake_case workflow key (e.g. "categorize_month"). */
workflow: string
/** One-line English description of what the workflow accomplishes. */
description: string
/** Workflow-skill slug: pass to gnubok_load_skill for the full playbook. */
skill: string
/** Exact registry tool names, ordered by typical call sequence. */
tools: readonly string[]
}
export const RECOMMENDED_WORKFLOW_LOADOUTS: readonly WorkflowLoadout[] = [
{
workflow: 'categorize_month',
description: 'Categorize and book a month of bank transactions.',
skill: 'bank-reconciliation',
tools: [
'gnubok_list_uncategorized_transactions',
'gnubok_suggest_categories',
'gnubok_categorize_transaction',
'gnubok_match_transaction_to_invoice',
'gnubok_load_skill',
'gnubok_approve_pending_operation',
],
},
{
workflow: 'close_period',
description: 'Reconcile, document voucher gaps, and lock a fiscal period.',
skill: 'month-end-close',
tools: [
'gnubok_list_fiscal_periods',
'gnubok_list_uncategorized_transactions',
'gnubok_get_reconciliation_status',
'gnubok_list_voucher_gaps',
'gnubok_explain_voucher_gap',
'gnubok_lock_period',
'gnubok_approve_pending_operation',
],
},
{
workflow: 'invoice_run',
description: 'Create and send customer invoices.',
skill: 'invoicing-rules',
tools: [
'gnubok_list_customers',
'gnubok_create_customer',
'gnubok_list_articles',
'gnubok_create_invoice',
'gnubok_send_invoice',
'gnubok_mark_invoice_as_sent',
'gnubok_approve_pending_operation',
],
},
{
workflow: 'vat_declaration',
description: 'Compute, review, and file the VAT declaration.',
skill: 'quarterly-vat-review',
tools: [
'gnubok_get_vat_report',
'gnubok_vat_close_check',
'gnubok_get_general_ledger',
'gnubok_vat_declaration_validate',
'gnubok_vat_declaration_submit',
'gnubok_vat_declaration_status',
'gnubok_approve_pending_operation',
],
},
{
workflow: 'payroll_month',
description: 'Run monthly payroll and generate the AGI.',
skill: 'payroll-monthly',
tools: [
'gnubok_list_employees',
'gnubok_create_salary_run',
'gnubok_calculate_salary_run',
'gnubok_get_salary_run',
'gnubok_book_salary_run',
'gnubok_generate_agi',
'gnubok_approve_pending_operation',
],
},
]
/**
* Fails fast when a loadout references a tool or workflow skill that does not
* exist. Called at module init in server.ts (after the tools array is built)
* so any rename/removal in the registry breaks the build and the test suite
* immediately instead of shipping a briefing that recommends phantom tools.
*/
export function assertRecommendedLoadoutsValid(knownToolNames: ReadonlySet<string>): void {
const knownSkillSlugs = new Set(workflowSkills.map((s) => s.slug))
const seenWorkflows = new Set<string>()
for (const loadout of RECOMMENDED_WORKFLOW_LOADOUTS) {
if (seenWorkflows.has(loadout.workflow)) {
throw new Error(
`recommended_tools: duplicate workflow key "${loadout.workflow}" in RECOMMENDED_WORKFLOW_LOADOUTS.`
)
}
seenWorkflows.add(loadout.workflow)
if (!knownSkillSlugs.has(loadout.skill)) {
throw new Error(
`recommended_tools: workflow "${loadout.workflow}" references unknown skill slug "${loadout.skill}". ` +
'Update RECOMMENDED_WORKFLOW_LOADOUTS in recommended-tools.ts.'
)
}
for (const toolName of loadout.tools) {
if (!knownToolNames.has(toolName)) {
throw new Error(
`recommended_tools: workflow "${loadout.workflow}" references unknown tool "${toolName}". ` +
'Update RECOMMENDED_WORKFLOW_LOADOUTS in recommended-tools.ts when renaming or removing tools.'
)
}
}
}
}
+40 -2
View File
@@ -36,6 +36,7 @@ import { buildLedgerContext } from '@/lib/agent-context/ledger-context'
import { prompts, findPrompt } from './prompts'
import { findSkill, loadAllSkills, toSummary, SKILL_MIME_TYPE, SKILL_URI_PREFIX, skillUri, skillSlugFromUri } from './skills'
import type { SkillTier } from './skills'
import { RECOMMENDED_WORKFLOW_LOADOUTS, assertRecommendedLoadoutsValid } from './recommended-tools'
import { getRiskLevel } from '@/lib/pending-operations/risk-tiers'
import { normalizeVatRateToDecimal } from '@/lib/vat/supplier-invoice-line-checks'
import { CreateSupplierParamsSchema } from '@/lib/pending-operations/schemas/create-supplier'
@@ -2534,7 +2535,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_get_agent_briefing',
title: 'Get Agent Briefing',
description: 'Bootstrap this company\'s accountant context in one call: user_name, profile_summary, loaded atoms (metadata only, gnubok_load_skill for bodies), top-30 active memories, dimensions snapshot (when registered). Call once at session start.',
description: 'Bootstrap this company\'s accountant context in one call: user_name, profile_summary, atoms (gnubok_load_skill for bodies), top-30 memories, dimensions, and recommended_tools: per-workflow loadouts to batch-load in one ToolSearch select:a,b,c call. Call once at session start.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -2708,8 +2709,28 @@ export const tools: McpTool[] = [
},
required: ['resource_uri', 'window_from', 'posted_entries_window', 'top_counterparty_patterns', 'top_supplier_patterns'],
},
recommended_tools: {
type: 'array',
description:
'Per-workflow tool loadouts, ordered by call sequence. Deferred-loading harnesses batch-load a whole cluster in one call (ToolSearch select:a,b,c). Static; validated against the registry.',
items: {
type: 'object',
additionalProperties: false,
properties: {
workflow: { type: 'string', description: 'Stable workflow key.' },
description: { type: 'string' },
skill: { type: 'string', description: 'Slug for gnubok_load_skill (full playbook).' },
tools: {
type: 'array',
items: { type: 'string' },
description: 'Exact tool names, ordered.',
},
},
required: ['workflow', 'description', 'skill', 'tools'],
},
},
},
required: ['company', 'user_name', 'profile_summary', 'atoms', 'memory'],
required: ['company', 'user_name', 'profile_summary', 'atoms', 'memory', 'recommended_tools'],
},
annotations: {
readOnlyHint: true,
@@ -2979,6 +3000,15 @@ export const tools: McpTool[] = [
})),
...(dimensionsBlock ? { dimensions: dimensionsBlock } : {}),
...(ledgerDigest ? { ledger_context: ledgerDigest } : {}),
// Static per-workflow loadouts (issue #1098): lets a deferred-loading
// harness batch-load a whole workflow cluster in one call. Validated
// against the tool registry at module init (assertRecommendedLoadoutsValid).
recommended_tools: RECOMMENDED_WORKFLOW_LOADOUTS.map((w) => ({
workflow: w.workflow,
description: w.description,
skill: w.skill,
tools: [...w.tools],
})),
}
},
},
@@ -13571,6 +13601,13 @@ export const tools: McpTool[] = [
},
]
// Drift guard for the gnubok_get_agent_briefing recommended_tools loadouts:
// every referenced tool must exist in the registry above and every referenced
// skill must be a real workflow skill. Runs at module init so a rename or
// removal fails the build (and every test importing this module) instead of
// shipping a briefing that recommends phantom tools.
assertRecommendedLoadoutsValid(new Set(tools.map((t) => t.name)))
// ── MCP Protocol Handler ─────────────────────────────────────
const SERVER_INFO = {
@@ -13954,6 +13991,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
'',
'Discovery:',
'• tools/list returns common tool schemas. Call gnubok_search_tools(query="…") for specialized tools: it ranks all capabilities; pass detail="name"|"summary"|"full" to control payload size.',
'• gnubok_get_agent_briefing returns recommended_tools: ordered per-workflow tool loadouts (categorize_month, close_period, invoice_run, vat_declaration, payroll_month). If your harness defers tool loading, batch-load a whole workflow in one call (e.g. Claude Code ToolSearch select:a,b,c) instead of searching cluster by cluster.',
`• This connection can work with every non-archived company the API-key user belongs to. Call gnubok_list_companies to discover company_id values. Omit company_id to use the API key default (${companyId}); when selecting another company, repeat company_id on every company-data call, including approval.`,
'• MCP resources use the API key default company. For a selected non-default company, call gnubok_get_agent_briefing with company_id instead of relying on Accounted://company/current or other company-data resources.',
'• When the user asks "how do I do X" or you\'re unsure of the correct sequence (month-end close, VAT review, year-end, invoicing, payroll), call gnubok_list_skills first: domain workflows are documented as loadable skills with tool references.',