feat(mcp): always-explicit retryable on structured errors + transient inference (P1-1) (#875)

Agents could not distinguish 'keep retrying' from 'stop, this is
broken' (agent.feedback): retryable was emitted only when a registry
entry declared true — absent otherwise, including for genuinely
transient DB/network failures whose SQLSTATE is lost when tools wrap
them as Error('Database error: ...').

- StructuredError.retryable is now a required boolean. Registry
  declaration wins; otherwise isTransientFailure() infers from Postgres
  SQLSTATEs (40001/40P01/57014/08xxx/53xxx/55P03), upstream HTTP
  statuses (408/429/5xx), and message signatures that survive wrapping
  (deadlock, serialization, statement timeout, fetch/socket failures).
- Unclassified transient failures surface as stable code
  TRANSIENT_ERROR (new registry entry, retryable: true).
- categorize_transaction accepts idempotency_key — the tool agents
  blind-retry after client-side approval-elicitation drops; the key
  makes that retry replay-safe instead of double-staging.
- Contract documented in .claude/rules/mcp-server.md. The planned
  'kind' field was dropped: the code registry already encodes it;
  retryable is the agent-actionable bit.

Every tool error already flows through the single dispatch point
(toToolError -> getStructuredError), so coverage is universal without
per-tool migration. Full unit suite: 6575 tests green.

Part of dev_docs/mcp_optimization_plan.md (P1-1).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-03 10:29:58 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 001de3c844
commit 250cc7c450
6 changed files with 135 additions and 10 deletions
@@ -134,14 +134,20 @@ describe('toToolError: retryable propagation', () => {
expect(result.error.retryable).toBe(true)
})
it('omits retryable on permanent validation/period errors', () => {
it('marks permanent validation/period errors retryable:false — explicitly, never absent', () => {
const periodLocked = toToolError(new Error('locked/closed fiscal period'))
expect(periodLocked.error.code).toBe('PERIOD_LOCKED')
expect(periodLocked.error.retryable).toBeUndefined()
expect(periodLocked.error.retryable).toBe(false)
const tagged = Object.assign(new Error('validation'), { code: 'VALIDATION_ERROR' })
const validation = toToolError(tagged)
expect(validation.error.retryable).toBeUndefined()
expect(validation.error.retryable).toBe(false)
})
it('categorize_transaction accepts idempotency_key so blind retries are replay-safe', () => {
const tool = tools.find((t) => t.name === 'gnubok_categorize_transaction')!
const schema = tool.inputSchema as { properties: Record<string, unknown> }
expect(schema.properties.idempotency_key).toBeDefined()
})
it('PERIOD_LOCKED carries a remediation pointing at gnubok_unlock_period', () => {
+8 -1
View File
@@ -2837,6 +2837,7 @@ export const tools: McpTool[] = [
description: 'Dims bag {sie_dim_no: kod eller namn}, e.g. {"1":"KS01","6":"P001"}. Tags the expense/business lines of the generated voucher — never the bank or VAT lines. Unknown values rejected, never auto-created.',
},
allow_duplicate: { type: 'boolean', description: 'Override the duplicate-booking guard (default false). Set true ONLY after the user confirms this bank line is a genuinely separate event — the guard blocks a second verifikat for an event already booked (e.g. a paid invoice or a salary payout).' },
idempotency_key: { type: 'string', description: 'Optional UUID to dedupe retries — a replayed call returns the already-staged operation instead of staging twice.' },
},
required: ['transaction_id', 'category'],
},
@@ -2953,7 +2954,13 @@ export const tools: McpTool[] = [
description: 'Once approved, the journal entry is posted. Continue with gnubok_list_uncategorized_transactions to keep clearing the backlog, or lock the period once it is empty.',
tool: 'gnubok_list_uncategorized_transactions',
},
tx?.date ? { dateForPeriodCheck: tx.date } : {},
{
...(tx?.date ? { dateForPeriodCheck: tx.date } : {}),
// Categorize is the tool agents blind-retry after ambiguous
// client-side failures (approval elicitation drops) — the key makes
// that retry safe instead of double-staging.
idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined,
},
)
},
},