feat(mcp): customer_number on create_customer + Beta tags on webshop surfaces (#1677)

* feat(mcp): accept customer_number on gnubok_create_customer

Parity with gnubok_update_customer: a customer number no longer needs a
create-then-update two-step with two approvals. The staged params carry
the trimmed number, commitCreateCustomer inserts it, and the payload-size
ceiling is bumped 59.7K to 59.75K with a documented entry (the property
has no description; name + maxLength are the whole contract).

Requested by a user on Discord 2026-08-16.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(ui): mark webshop integrations and orders tab as Beta

WooCommerce and Shopify rows on the import page get a quiet Beta chip
next to the title, and the webshop /orders sidebar item sets the
existing betaBadge flag. Chip recipe matches the nav beta badge so
Beta reads identically everywhere.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): enforce customer_number invariants and show it on the approval card

Consolidated resolution pass for PR #1677:
- skeptic (correctness): maxLength 32 was advertisement-only on the create
  path; now enforced with a runtime guard in gnubok_create_customer execute
  (clean errors for non-string and >32) and a 400 guard in
  commitCreateCustomer, matching the web/v1 routes and commitUpdateCustomer.
- skeptic (correctness): CustomerPreview never rendered the staged
  customer_number, leaving the approver blind to the new field; added a
  conditional Kundnr row.
- CodeRabbit: reset the event bus in create-customer.test.ts beforeEach.
- Tests cover both new guards at the tool and executor layers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-18 10:46:34 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 387e1fb7f1
commit cfdddb2d7e
10 changed files with 216 additions and 2 deletions
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { eventBus } from '@/lib/events/bus'
import { tools } from '../server'
const tool = () => tools.find((candidate) => candidate.name === 'gnubok_create_customer')!
describe('gnubok_create_customer: customer_number input', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
it('exposes customer_number in the strict input schema', () => {
const properties = tool().inputSchema.properties as Record<string, Record<string, unknown>>
expect(tool().inputSchema.additionalProperties).toBe(false)
expect(properties.customer_number).toMatchObject({ type: 'string', maxLength: 32 })
})
it('stages the trimmed customer_number in params and preview', async () => {
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-create-customer-1' } })
const result = (await tool().execute(
{
name: 'Kund AB',
customer_type: 'swedish_business',
customer_number: ' K-1001 ',
email: 'faktura@example.test',
},
'company-1',
'user-1',
supabase as never,
)) as { staged: boolean; operation_id?: string; preview: Record<string, unknown> }
expect(result.staged).toBe(true)
expect(result.operation_id).toBe('op-create-customer-1')
expect(result.preview).toMatchObject({
customer_number: 'K-1001',
email: 'faktura@example.test',
})
const inserted = findCall('pending_operations', 'insert')?.[0] as {
params: Record<string, unknown>
}
expect(inserted.params).toMatchObject({ customer_number: 'K-1001' })
})
it('rejects a customer_number longer than 32 characters before staging', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
tool().execute(
{ name: 'Kund AB', customer_type: 'swedish_business', customer_number: 'X'.repeat(33) },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/32/)
expect(supabase.from).not.toHaveBeenCalled()
})
it('rejects a non-string customer_number before staging', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
tool().execute(
{ name: 'Kund AB', customer_type: 'swedish_business', customer_number: 1001 },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/string/i)
expect(supabase.from).not.toHaveBeenCalled()
})
it('stages customer_number as null when omitted', async () => {
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-create-customer-2' } })
const result = (await tool().execute(
{ name: 'Kund AB', customer_type: 'swedish_business' },
'company-1',
'user-1',
supabase as never,
)) as { staged: boolean; preview: Record<string, unknown> }
expect(result.staged).toBe(true)
expect(result.preview).toMatchObject({ customer_number: null })
const inserted = findCall('pending_operations', 'insert')?.[0] as {
params: Record<string, unknown>
}
expect(inserted.params).toMatchObject({ customer_number: null })
})
})
@@ -171,9 +171,14 @@ describe('tools/list payload size guard', () => {
// * 59.5K to 59.7K with account VAT treatments: create_account and
// update_account both expose the 12-value treatment vocabulary. The
// descriptions are minimal; the enum values are the wire contract.
// * 59.7K to 59.75K with customer_number on gnubok_create_customer:
// parity with gnubok_update_customer, so setting a customer number no
// longer needs a second staged update after create. The property has
// no description (name + maxLength are the whole contract); headroom
// before the change was ~11 tokens, so even that minimal form crossed.
// 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(59_700)
expect(approxTokens).toBeLessThan(59_750)
})
})
+12
View File
@@ -4565,6 +4565,7 @@ export const tools: McpTool[] = [
enum: ['individual', 'swedish_business', 'eu_business', 'non_eu_business'],
description: 'Customer type',
},
customer_number: { type: 'string', maxLength: 32 },
email: { type: 'string', description: 'Email address' },
org_number: { type: 'string', description: 'Swedish org number' },
vat_number: { type: 'string', description: 'EU VAT number' },
@@ -4599,9 +4600,20 @@ export const tools: McpTool[] = [
throw new Error('Invalid customer_type. Must be: individual, swedish_business, eu_business, non_eu_business')
}
// Runtime guard (hosts don't always enforce inputSchema maxLength).
const customerNumberArg = args.customer_number
if (customerNumberArg != null && typeof customerNumberArg !== 'string') {
throw new Error('customer_number must be a string.')
}
const customerNumber = typeof customerNumberArg === 'string' ? customerNumberArg.trim() : ''
if (customerNumber.length > 32) {
throw new Error('customer_number must be at most 32 characters.')
}
const params = {
name: name.trim(),
customer_type: customerType,
customer_number: customerNumber || null,
email: (args.email as string) || null,
org_number: (args.org_number as string) || null,
vat_number: (args.vat_number as string) || null,