fix(mcp): promote gnubok_update_customer to the default tools/list catalog (#1986)

* fix(mcp): promote gnubok_update_customer to the default tools/list catalog

The tool existed since #1876 but was catalogVisibility 'search', which
means it appeared only through gnubok_search_tools. Claude.ai (and other
list-driven clients) can only call tools present in tools/list, so on the
primary connector the tool was uncallable and the reporter of #1706 read
it as missing twice.

- server.ts: drop the 'search' flag on gnubok_update_customer; no change
  to schema, executor, scopes, risk tier, or staging path
- update-customer.test.ts: pin the tools/list projection
  (isDefaultCatalogTool) instead of the flag; keep the search test
- payload-size.bench.test.ts: ceiling 63_400 to 64_100 (measured 64 043,
  +761 over main) with a progression bullet
- README.md: note that search-only tools are not callable from Claude.ai
- DECISIONS.md: supersede the 2026-08-25 keep-search-only entry

Closes #1706

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2

* test(mcp): re-measure the tools/list ceiling after #1993 landed on main

#1993 declared line_type and revenue_account on the create item schema,
which moved the accounted projection to 63 761 with gnubok_update_customer
promoted; ceiling 63_800 keeps the same headroom as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2

---------

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-27 22:28:14 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 17caf9d80a
commit 89d0e1b994
5 changed files with 15 additions and 6 deletions
+1
View File
@@ -1298,6 +1298,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-27] New `unlinked_documents` category on the Accounted://attention resource, backed by lib/documents/unlinked-documents.ts. The whole design is the mime ALLOW-LIST, and the naive predicate is a trap: "current version, no journal_entry_id, referenced by none of the eight linking tables" returns 15 806 rows on prod, of which 11 309 are application/json and every single one is named psd2-response_<ts>_pN.json, the archived PSD2 bank-API responses the integration stores as evidence of each fetch. Those are unlinked BY DESIGN; surfacing them would hand an agent 11 309 items of work it must not action, which is worse than showing nothing. Measured 2026-08-27: application/json was 11 309 of 11 309 psd2, and pdf/png/jpeg/heic were 0 of 4 495, so the split is clean. Chose an allow-list of underlag-shaped mime types over excluding known-bad filenames, so a future machine-payload format (XML, CSV, an audit bundle) stays out by default instead of leaking until someone notices. Real remaining surface: 4 497 documents across 210 companies, median 3 per company, 481 in the preceding week, and NOT agent-specific (2 374 upload_source=api vs 1 623 file_upload from the web UI). Two-pass fetch mirroring fetchPurchasesWithoutUnderlag: indexed column filter, then eight reference lookups that run only when candidates exist, so the common case costs one query. Scan cap is 300 and is set by URL LENGTH, not table size: each candidate id is echoed through eight .in(column, ids) lookups at ~38 bytes per UUID, and a cap in the thousands would exceed the gateway limit, fail the lookups, and the "claims nothing" fallback would turn every candidate into a false positive. A failing lookup is deliberately treated as "claims nothing" (can only ADD a row) rather than dropping the category, so one misbehaving table cannot hide real work. UnlinkedDocument is a type alias not an interface: the resource assigns it into samples: Record<string, unknown>[] and an interface has no implicit index signature; vitest does not typecheck so this only fails in npm run build.
[2026-08-27] NOT fixed, and recorded so the next person does not act on an inflated number: the agent-facing readers (resources/attention.ts, resources/recent-activity.ts) still test booked-ness with a raw journal_entry_id null check instead of the canonical isTransactionBooked, which misses the bulk-book (transaction_voucher_links) and multi-allocation (invoice_payments / supplier_invoice_payments) cases. Real scale measured on prod 2026-08-27: 4 transactions, in 1 company, out of 567 column-filtered unbooked, all 4 via transaction_voucher_links and 0 via either payments table. Worth fixing as hygiene, but it is a 4-row problem and doing it properly in attention.ts needs the same two-pass treatment plus a decision about count semantics for a tenant with thousands of unbooked rows, so it does not belong bolted onto this change.
[2026-08-27] Klarmarkera (markPeriodClosedExternally) gets an undo, reopenExternallyClosedPeriod, allowed only while the closed state still comes from klarmarkera (closed_externally set, no closing entry): that close was a person's control decision without a bokslutsverifikat, so reversing it strands nothing, whereas a closePeriod close keeps its closing entry and stays irreversible here. The reopen clears the lock too, because the reason to reopen is to change the period's contents (Forsslund Systems 2026-08-27: five imported years klarmarkerade, then the prior-year SIE turned out wrong; replace refused the closed year, unlock refused the closed state, no way back). Audit_log row plus period.unlocked event; the MCP staged-op surface (lock/unlock) does not get a reopen op yet, follow-up.
[2026-08-27] gnubok_update_customer promoted to the default catalog (tools/list), superseding the 2026-08-25 entry that kept it search-only: that entry assumed gnubok_search_tools reachability was enough, but Claude.ai only calls tools present in tools/list (SilverPark E2E 2026-08-26; same reason as the #1936/#1967 promotions), so the tool was uncallable on the primary connector and the reporter of #1706/#1876 read it as missing twice. Bench ceiling 63.4K to 64.1K (+761 measured). Not chosen: a description hint on gnubok_create_customer pointing at the update tool: it would not make the tool callable, and it made gnubok_create_customer rank for "update customer" in gnubok_search_tools.
[2026-08-27] MCP update_invoice keeps FULL REPLACE item semantics (no preserve-on-omit) but its lines now accept article_id with the same prefill and default-set VAT adoption guard as create_invoice, plus the permitted-set VAT gate at staging; gnubok_get_invoice is the round-trip read surface (search-only: payload-size.bench.test.ts has no headroom and the update tool it serves is search-only too); the staged preview snapshots the lines being replaced and the effective vat_rate/revenue_account per new line so an approver sees a rebooking; the commit executor scope-checks staged article ids like create does (the FK proves existence only, and arg-guard never sees nested keys). Supersedes the 2026-08-17 'create_invoice only' entry (issue #1642). The web PATCH route's missing article scope check is a separate follow-up.
[2026-08-27] MCP invoice round trip closed for non-article lines (skeptic review of #1993): gnubok_get_invoice now returns the ROT property columns (housing_designation/apartment_number/brf_org_number: property identifiers, never the personnummer ciphertext) and gnubok_update_invoice declares line_type/revenue_account/deduction/accrual fields on its item schema, exempts text rows from the quantity gates like CreateInvoiceItemSchema, and gates ROT/RUT completeness (arbetstyp/timmar, fastighetsbeteckning, personnummer availability) at staging so the failure reaches the agent, not the approver. gnubok_create_invoice only gained line_type + revenue_account: commitCreateInvoice drops deduction/accrual fields, so declaring them there would stage silent loss, and the tools/list ceiling (63.4K) has ~60 tokens of headroom left.
[2026-08-27] WhatsApp company question (#1589): the root cause was Meta rejecting the reply-button payload synchronously with HTTP 400 #131009 "Duplicate button title" because the sender belonged to two same-named companies, one archived, not a client that refuses interactive messages; fix = archived-membership filter on every channel membership lookup (mirrors lib/supabase/middleware.ts), unique interactive titles (position suffix), a synchronous numbered-text fallback under the same M6 template id, and a drain of rows parked behind the now-dead question only when the sender resolves as 'single'. The async delivery-status fallback leg was deliberately not built (every observed failure was a synchronous 400), and the drain is not extended to default/pin resolution (those choices are still changeable, so an open question there is not dead).
+1 -1
View File
@@ -21,7 +21,7 @@ For internal Anthropic API usage (the SDK is called from `lib/ai/provider.ts` an
## Payload-size watchdog
`payload-size.bench.test.ts` enforces a `tools/list` JSON payload ceiling. If the test fires, the right answer is rarely "raise the ceiling". Instead, trim descriptions or set specialized wide tools to `catalogVisibility: 'search'`. Those tools remain discoverable with full schemas through `gnubok_search_tools` and callable through `tools/call` without bloating the default catalog.
`payload-size.bench.test.ts` enforces a `tools/list` JSON payload ceiling. If the test fires, the right answer is rarely "raise the ceiling". Instead, trim descriptions or set specialized wide tools to `catalogVisibility: 'search'`. Those tools remain discoverable with full schemas through `gnubok_search_tools` and callable through `tools/call` on the wire without bloating the default catalog. Claude.ai only calls tools present in `tools/list`, so a tool that a user or a skill must call directly stays in the default catalog.
## Where things live
@@ -278,12 +278,20 @@ describe('tools/list payload size guard', () => {
// skatteverket_connection, recommended_tools: agent-briefing.test.ts
// pins their RUNTIME shape, so nothing was left unguarded), against
// +~245 for the new gnubok_call_tool.
// * 63.1K to 63.8K with gnubok_update_customer promoted to the default
// catalog (#1706, #1876). gnubok_call_tool (above) only bridges READ
// tools and refuses writes, so a search-only WRITE tool is still
// uncallable on Claude.ai; the reporter read the tool as missing twice.
// The +761 tokens are the 19-property partial-update wire contract plus
// the staging envelope; the description is already 152 chars.
// Measured 63 761 on the accounted projection after merging #1993
// (line_type and revenue_account declared on the create item schema).
// Long-term answer to growth is no longer a ceiling bump. gnubok_call_tool
// makes `catalogVisibility: 'search'` usable for READ tools on hosts that
// can only invoke what tools/list showed them, which is the constraint that
// forced gnubok_reconcile_match back into the default catalog on
// 2026-08-26. Demote a read to search-only before proposing a bump.
expect(approxTokens).toBeLessThan(63_100)
expect(approxTokens).toBeLessThan(63_800)
})
it('keeps the accounted_* namespace as the measured worst case', () => {
@@ -4,7 +4,7 @@ import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
import { OPERATION_RISK_TIERS } from '@/lib/pending-operations/risk-tiers'
import { hashRequest } from '@/lib/api/idempotency'
import { decryptPersonnummer, encryptPersonnummer } from '@/lib/salary/personnummer'
import { tools } from '../server'
import { tools, isDefaultCatalogTool } from '../server'
const CUSTOMER_ID = '11111111-1111-4111-8111-111111111111'
const tool = () => tools.find((candidate) => candidate.name === 'gnubok_update_customer')!
@@ -38,7 +38,8 @@ describe('gnubok_update_customer: registration', () => {
expect(tool().inputSchema.additionalProperties).toBe(false)
expect(tool().annotations.readOnlyHint).toBe(false)
expect(tool().annotations.idempotentHint).toBe(true)
expect(tool().catalogVisibility).toBe('search')
expect(isDefaultCatalogTool(tool())).toBe(true)
expect(tools.filter(isDefaultCatalogTool).map((t) => t.name)).toContain('gnubok_update_customer')
expect(TOOL_SCOPE_MAP.gnubok_update_customer).toBe('customers:write')
expect(OPERATION_RISK_TIERS.update_customer).toBe('low')
})
@@ -48,7 +49,7 @@ describe('gnubok_update_customer: registration', () => {
expect(properties.personal_number).toMatchObject({ type: ['string', 'null'] })
})
it('keeps the wide write schema discoverable through tool search', async () => {
it('is also discoverable through tool search', async () => {
const search = tools.find((candidate) => candidate.name === 'gnubok_search_tools')!
const result = (await search.execute(
{
-1
View File
@@ -5776,7 +5776,6 @@ export const tools: McpTool[] = [
idempotentHint: true,
openWorldHint: false,
},
catalogVisibility: 'search',
async execute(args, companyId, userId, supabase, actor) {
const changes: Record<string, unknown> = {}
for (const key of [