Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cli-idempotency-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@bloque/cli': patch
---

Add idempotency-key protection to all CLI write operations (transfers, card creation/funding, spending-category funding, and swap order creation: PSE, bank transfer, BRE-B). Each tool now accepts an optional `idempotencyKey` input; when omitted, a deterministic key is derived from the operation name and its parameters within a 5-minute window, so accidental retries (e.g. a transport-level retry) can no longer produce duplicate settled transactions.
40 changes: 40 additions & 0 deletions packages/cli/src/mcp/idempotency.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, test } from 'bun:test';
import { deterministicIdempotencyKey } from './idempotency.ts';

describe('deterministicIdempotencyKey', () => {
test('is stable for identical params within the same time window', () => {
const params = { sourceUrn: 'a', destinationUrn: 'b', amount: '5000', asset: 'COPM/2' };
const key1 = deterministicIdempotencyKey('transfer', params);
const key2 = deterministicIdempotencyKey('transfer', params);
expect(key1).toBe(key2);
});

test('differs when any param differs', () => {
const base = { sourceUrn: 'a', destinationUrn: 'b', amount: '5000', asset: 'COPM/2' };
const changedAmount = { ...base, amount: '5001' };
expect(deterministicIdempotencyKey('transfer', base)).not.toBe(
deterministicIdempotencyKey('transfer', changedAmount),
);
});

test('differs by operation name for otherwise-identical params', () => {
const params = { sourceUrn: 'a', destinationUrn: 'b', amount: '5000' };
expect(deterministicIdempotencyKey('transfer', params)).not.toBe(
deterministicIdempotencyKey('fund_card', params),
);
});

test('differs across time windows', () => {
const params = { sourceUrn: 'a', destinationUrn: 'b', amount: '5000' };
const key1 = deterministicIdempotencyKey('transfer', params, 5);
// A 0-minute window buckets by the current millisecond, guaranteeing a
// different bucket than a 5-minute window almost always would.
const key2 = deterministicIdempotencyKey('transfer', params, 1 / 60_000);
expect(key1).not.toBe(key2);
});

test('produces a 64-char hex sha256 digest', () => {
const key = deterministicIdempotencyKey('transfer', { a: 1 });
expect(key).toMatch(/^[0-9a-f]{64}$/);
});
});
29 changes: 29 additions & 0 deletions packages/cli/src/mcp/idempotency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { createHash } from 'node:crypto';

/**
* Retries a duplicate money-moving MCP tool call (transport-level retry,
* client cold-start timeout, etc.) can silently execute the same operation
* twice, since the SDK's per-request auto-generated Idempotency-Key is
* random and doesn't survive across independent tool invocations.
*
* This derives a stable key from the operation's own parameters instead, so
* a retry with identical params reuses the same key by construction — the
* backend rejects the duplicate instead of executing it. Bucketing by time
* window still lets a deliberately-repeated identical operation (e.g. "send
* another $50" a few minutes later) through with a fresh key, while
* absorbing the retry storm a single logical call can produce (HTTP client
* retries up to ~3 times with exponential backoff capped at 30s).
*
* Pass an explicit idempotencyKey from the tool's input schema when the
* caller wants to control deduplication themselves; this is only the
* fallback for when they don't.
*/
export function deterministicIdempotencyKey(
operation: string,
params: Record<string, unknown>,
windowMinutes = 5,
): string {
const bucket = Math.floor(Date.now() / (windowMinutes * 60_000));
const payload = JSON.stringify({ operation, params, bucket });
return createHash('sha256').update(payload).digest('hex');
}
132 changes: 90 additions & 42 deletions packages/cli/src/mcp/tools/primitives/swap.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { SupportedBank } from '@bloque/sdk';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod/v4';
import { deterministicIdempotencyKey } from '../../idempotency.ts';
import type { BloqueClients } from '../../types.ts';

export function registerSwapTools(server: McpServer, clients: BloqueClients) {
Expand Down Expand Up @@ -56,29 +57,46 @@ export function registerSwapTools(server: McpServer, clients: BloqueClients) {
fullName: z.string(),
phoneNumber: z.string().optional(),
webhookUrl: z.string().optional(),
idempotencyKey: z.string().optional(),
},
},
async ({
rateSig, toMedium, amountSrc, amountDst, depositUrn,
bankCode, userType, customerEmail, userLegalIdType,
userLegalId, fullName, phoneNumber, webhookUrl,
userLegalId, fullName, phoneNumber, webhookUrl, idempotencyKey,
}) => {
const result = await clients.swap.pse.create({
rateSig,
toMedium,
webhookUrl,
amountSrc,
amountDst,
depositInformation: { urn: depositUrn },
args: {
bankCode,
userType,
customerEmail,
userLegalIdType,
userLegalId,
customerData: { fullName, phoneNumber: phoneNumber ?? '' },
const result = await clients.swap.pse.create(
{
rateSig,
toMedium,
webhookUrl,
amountSrc,
amountDst,
depositInformation: { urn: depositUrn },
args: {
bankCode,
userType,
customerEmail,
userLegalIdType,
userLegalId,
customerData: { fullName, phoneNumber: phoneNumber ?? '' },
},
},
});
{
idempotencyKey:
idempotencyKey ??
deterministicIdempotencyKey('create_pse_order', {
rateSig,
toMedium,
amountSrc,
amountDst,
depositUrn,
bankCode,
userType,
customerEmail,
}),
},
);
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
};
Expand All @@ -101,29 +119,44 @@ export function registerSwapTools(server: McpServer, clients: BloqueClients) {
bankAccountHolderIdentificationType: z.enum(['CC', 'CE', 'NIT', 'PP']),
bankAccountHolderIdentificationValue: z.string(),
webhookUrl: z.string().optional(),
idempotencyKey: z.string().optional(),
},
},
async ({
rateSig, toMedium, amountSrc, amountDst, sourceAccountUrn,
bankAccountType, bankAccountNumber, bankAccountHolderName,
bankAccountHolderIdentificationType, bankAccountHolderIdentificationValue,
webhookUrl,
webhookUrl, idempotencyKey,
}) => {
const result = await clients.swap.bankTransfer.create({
rateSig,
toMedium: toMedium as SupportedBank,
webhookUrl,
amountSrc,
amountDst,
depositInformation: {
bankAccountType,
bankAccountNumber,
bankAccountHolderName,
bankAccountHolderIdentificationType,
bankAccountHolderIdentificationValue,
const result = await clients.swap.bankTransfer.create(
{
rateSig,
toMedium: toMedium as SupportedBank,
webhookUrl,
amountSrc,
amountDst,
depositInformation: {
bankAccountType,
bankAccountNumber,
bankAccountHolderName,
bankAccountHolderIdentificationType,
bankAccountHolderIdentificationValue,
},
args: { sourceAccountUrn },
},
{
idempotencyKey:
idempotencyKey ??
deterministicIdempotencyKey('create_bank_transfer_order', {
rateSig,
toMedium,
amountSrc,
amountDst,
sourceAccountUrn,
bankAccountNumber,
}),
},
args: { sourceAccountUrn },
});
);
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
};
Expand All @@ -144,6 +177,7 @@ export function registerSwapTools(server: McpServer, clients: BloqueClients) {
webhookUrl: z.string().optional(),
nodeId: z.string().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
idempotencyKey: z.string().optional(),
},
},
async ({
Expand All @@ -156,18 +190,32 @@ export function registerSwapTools(server: McpServer, clients: BloqueClients) {
webhookUrl,
nodeId,
metadata,
idempotencyKey,
}) => {
const result = await clients.swap.breb.create({
rateSig,
amountSrc,
amountDst,
type,
webhookUrl,
nodeId,
metadata,
depositInformation: { resolutionId },
args: { sourceAccountUrn },
});
const result = await clients.swap.breb.create(
{
rateSig,
amountSrc,
amountDst,
type,
webhookUrl,
nodeId,
metadata,
depositInformation: { resolutionId },
args: { sourceAccountUrn },
},
{
idempotencyKey:
idempotencyKey ??
deterministicIdempotencyKey('create_breb_order', {
rateSig,
amountSrc,
amountDst,
resolutionId,
sourceAccountUrn,
}),
},
);
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
};
Expand Down
52 changes: 37 additions & 15 deletions packages/cli/src/mcp/tools/primitives/transfers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { SupportedAsset } from '@bloque/sdk';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod/v4';
import { toHuman, toRaw } from '../../currency.ts';
import { deterministicIdempotencyKey } from '../../idempotency.ts';
import type { BloqueClients } from '../../types.ts';

export function registerTransferTools(server: McpServer, clients: BloqueClients) {
Expand All @@ -15,17 +16,30 @@ export function registerTransferTools(server: McpServer, clients: BloqueClients)
amount: z.string(),
currency: z.string().default('USD'),
metadata: z.record(z.string(), z.unknown()).optional(),
idempotencyKey: z.string().optional(),
},
},
async ({ sourceUrn, destinationUrn, amount, currency, metadata }) => {
async ({ sourceUrn, destinationUrn, amount, currency, metadata, idempotencyKey }) => {
const { amount: rawAmount, asset } = toRaw(amount, currency);
const result = await clients.accounts.transfer({
sourceUrn,
destinationUrn,
amount: rawAmount,
asset: asset as SupportedAsset,
metadata,
});
const result = await clients.accounts.transfer(
{
sourceUrn,
destinationUrn,
amount: rawAmount,
asset: asset as SupportedAsset,
metadata,
},
{
idempotencyKey:
idempotencyKey ??
deterministicIdempotencyKey('transfer', {
sourceUrn,
destinationUrn,
amount: rawAmount,
asset,
}),
},
);
const humanized = {
...result,
amount: toHuman(rawAmount, asset).amount,
Expand Down Expand Up @@ -55,9 +69,10 @@ export function registerTransferTools(server: McpServer, clients: BloqueClients)
),
metadata: z.record(z.string(), z.unknown()).optional(),
webhookUrl: z.string().optional(),
idempotencyKey: z.string().optional(),
},
},
async ({ reference, operations, metadata, webhookUrl }) => {
async ({ reference, operations, metadata, webhookUrl, idempotencyKey }) => {
const mappedOps = operations.map((op) => {
const { amount, asset } = toRaw(op.amount, op.currency);
return {
Expand All @@ -69,12 +84,19 @@ export function registerTransferTools(server: McpServer, clients: BloqueClients)
metadata: op.metadata,
};
});
const result = await clients.accounts.batchTransfer({
reference,
operations: mappedOps,
metadata,
webhookUrl,
});
const result = await clients.accounts.batchTransfer(
{
reference,
operations: mappedOps,
metadata,
webhookUrl,
},
{
idempotencyKey:
idempotencyKey ??
deterministicIdempotencyKey('batch_transfer', { reference, operations: mappedOps }),
},
);
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
};
Expand Down
28 changes: 21 additions & 7 deletions packages/cli/src/mcp/tools/workflows/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { SupportedAsset } from '@bloque/sdk';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod/v4';
import { toRaw } from '../../currency.ts';
import { deterministicIdempotencyKey } from '../../idempotency.ts';
import type { BloqueClients } from '../../types.ts';

export function registerAccountWorkflows(server: McpServer, clients: BloqueClients) {
Expand All @@ -15,21 +16,34 @@ export function registerAccountWorkflows(server: McpServer, clients: BloqueClien
fundFromUrn: z.string().optional(),
fundAmount: z.string().optional(),
currency: z.string().optional().default('USD'),
idempotencyKey: z.string().optional(),
},
},
async ({ name, fundFromUrn, fundAmount, currency }) => {
async ({ name, fundFromUrn, fundAmount, currency, idempotencyKey }) => {
const pocket = await clients.accounts.virtual.create({ name });
const polygon = await clients.accounts.polygon.create({ ledgerId: pocket.ledgerId, name });

let transferResult;
if (fundFromUrn && fundAmount) {
const { amount: rawAmount, asset } = toRaw(fundAmount, currency);
transferResult = await clients.accounts.transfer({
sourceUrn: fundFromUrn,
destinationUrn: pocket.urn,
amount: rawAmount,
asset: asset as SupportedAsset,
});
transferResult = await clients.accounts.transfer(
{
sourceUrn: fundFromUrn,
destinationUrn: pocket.urn,
amount: rawAmount,
asset: asset as SupportedAsset,
},
{
idempotencyKey:
idempotencyKey ??
deterministicIdempotencyKey('create_account.fund', {
sourceUrn: fundFromUrn,
destinationUrn: pocket.urn,
amount: rawAmount,
asset,
}),
},
);
}

const result = {
Expand Down
Loading