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:
co-authored by
Claude Fable 5
parent
001de3c844
commit
250cc7c450
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user