@venlyfinance/settlement-mcp 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,18 +3,7 @@
3
3
  */
4
4
  import { z } from "zod";
5
5
  import { reconcileByReferenceCode } from "../reconcile.js";
6
- /** Serialize a result as a text-content tool response. */
7
- function jsonResult(data) {
8
- return {
9
- content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
10
- };
11
- }
12
- function errorResult(message) {
13
- return {
14
- content: [{ type: "text", text: `Error: ${message}` }],
15
- isError: true,
16
- };
17
- }
6
+ import { errorResult, jsonResult } from "../results.js";
18
7
  const READ_ONLY = {
19
8
  readOnlyHint: true,
20
9
  destructiveHint: false,
@@ -71,6 +60,23 @@ export function registerReadTools(server, client) {
71
60
  return errorResult(e.message);
72
61
  }
73
62
  });
63
+ server.registerTool("list_accounts", {
64
+ title: "List accounts",
65
+ description: "List Venly Finance accounts before creating duplicates (finance GET /accounts). Read-only.",
66
+ inputSchema: {
67
+ page: z.number().int().min(1).optional(),
68
+ size: z.number().int().min(1).max(200).optional(),
69
+ },
70
+ annotations: READ_ONLY,
71
+ }, async (params) => {
72
+ try {
73
+ const result = await client.listAccounts(params);
74
+ return jsonResult({ count: result.length, accounts: result });
75
+ }
76
+ catch (e) {
77
+ return errorResult(e.message);
78
+ }
79
+ });
74
80
  server.registerTool("get_account", {
75
81
  title: "Get account",
76
82
  description: "Fetch a settlement account (finance GET /accounts/{accountId}). Read-only.",
@@ -84,6 +90,24 @@ export function registerReadTools(server, client) {
84
90
  return errorResult(e.message);
85
91
  }
86
92
  });
93
+ server.registerTool("list_wallets", {
94
+ title: "List account wallets",
95
+ description: "List wallets auto-provisioned for a Finance account (finance GET /accounts/{accountId}/wallets). Read-only.",
96
+ inputSchema: {
97
+ accountId: z.string().describe("Account UUID"),
98
+ page: z.number().int().min(1).optional(),
99
+ size: z.number().int().min(1).max(200).optional(),
100
+ },
101
+ annotations: READ_ONLY,
102
+ }, async ({ accountId, page, size }) => {
103
+ try {
104
+ const result = await client.listWallets(accountId, { page, size });
105
+ return jsonResult({ count: result.length, wallets: result });
106
+ }
107
+ catch (e) {
108
+ return errorResult(e.message);
109
+ }
110
+ });
87
111
  server.registerTool("list_virtual_bank_accounts", {
88
112
  title: "List virtual bank accounts",
89
113
  description: "List the EUR vIBANs on an account, each with its reconciliation " +
@@ -99,6 +123,22 @@ export function registerReadTools(server, client) {
99
123
  return errorResult(e.message);
100
124
  }
101
125
  });
126
+ server.registerTool("get_virtual_bank_account", {
127
+ title: "Get virtual bank account",
128
+ description: "Fetch receiving-account details including status, IBAN/BIC and referenceCode. Read-only.",
129
+ inputSchema: {
130
+ accountId: z.string().describe("Account UUID"),
131
+ virtualBankAccountId: z.string().describe("Virtual bank account UUID"),
132
+ },
133
+ annotations: READ_ONLY,
134
+ }, async ({ accountId, virtualBankAccountId }) => {
135
+ try {
136
+ return jsonResult(await client.getVirtualBankAccount(accountId, virtualBankAccountId));
137
+ }
138
+ catch (e) {
139
+ return errorResult(e.message);
140
+ }
141
+ });
102
142
  server.registerTool("reconcile_by_reference_code", {
103
143
  title: "Reconcile by referenceCode",
104
144
  description: "Match observed incoming bank transactions on an account's EUR vIBANs to " +
@@ -132,6 +172,24 @@ export function registerReadTools(server, client) {
132
172
  return errorResult(e.message);
133
173
  }
134
174
  });
175
+ server.registerTool("list_transfers", {
176
+ title: "List transfers",
177
+ description: "List transfer history for an account (finance GET /accounts/{accountId}/transfers). Read-only.",
178
+ inputSchema: {
179
+ accountId: z.string().describe("Account UUID"),
180
+ page: z.number().int().min(1).optional(),
181
+ size: z.number().int().min(1).max(200).optional(),
182
+ },
183
+ annotations: READ_ONLY,
184
+ }, async ({ accountId, page, size }) => {
185
+ try {
186
+ const result = await client.listTransfers(accountId, { page, size });
187
+ return jsonResult({ count: result.length, transfers: result });
188
+ }
189
+ catch (e) {
190
+ return errorResult(e.message);
191
+ }
192
+ });
135
193
  server.registerTool("get_transfer", {
136
194
  title: "Get transfer",
137
195
  description: "Fetch a transfer by id (finance GET /accounts/{accountId}/transfers/{transferId}). Read-only.",
@@ -165,6 +223,19 @@ export function registerReadTools(server, client) {
165
223
  return errorResult(e.message);
166
224
  }
167
225
  });
226
+ server.registerTool("get_party", {
227
+ title: "Get party",
228
+ description: "Fetch an individual or organisation party, including KYC/KYB state when present. Read-only.",
229
+ inputSchema: { partyId: z.string().describe("Party UUID") },
230
+ annotations: READ_ONLY,
231
+ }, async ({ partyId }) => {
232
+ try {
233
+ return jsonResult(await client.getParty(partyId));
234
+ }
235
+ catch (e) {
236
+ return errorResult(e.message);
237
+ }
238
+ });
168
239
  server.registerTool("get_reference_data", {
169
240
  title: "Get reference data",
170
241
  description: "Fetch settlement reference data: supported chains, fiat currencies, " +
@@ -8,16 +8,14 @@
8
8
  */
9
9
  import { z } from "zod";
10
10
  import { buildDryRun, evaluateWriteGate } from "../safety.js";
11
- function jsonResult(data) {
12
- return {
13
- content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
14
- };
15
- }
16
- function errorResult(message) {
17
- return {
18
- content: [{ type: "text", text: `Error: ${message}` }],
19
- isError: true,
20
- };
11
+ import { errorResult, jsonResult } from "../results.js";
12
+ import { normalizeLegacyFiatTransfer } from "../client/sdk-client.js";
13
+ function executionResult(gate, result) {
14
+ return jsonResult({
15
+ mode: gate.environment === "mock" ? "mock" : "live",
16
+ environment: gate.environment,
17
+ result,
18
+ });
21
19
  }
22
20
  const WRITE_ANNOTATIONS = {
23
21
  readOnlyHint: false,
@@ -30,18 +28,215 @@ const confirmField = z
30
28
  .default(false)
31
29
  .describe("Must be true to attempt a live call. Even then, VENLY_MCP_LIVE=1 and " +
32
30
  "credentials are also required, otherwise the tool dry-runs.");
31
+ const addressSchema = z
32
+ .object({
33
+ addressLine1: z.string().optional(),
34
+ addressLine2: z.string().optional(),
35
+ city: z.string().optional(),
36
+ state: z.string().optional(),
37
+ postalCode: z.string().optional(),
38
+ country: z.string().regex(/^[A-Z]{2}$/).optional(),
39
+ })
40
+ .optional();
41
+ const partySchema = z.object({
42
+ partyType: z.enum(["INDIVIDUAL", "ORGANISATION"]),
43
+ externalId: z.string().optional(),
44
+ firstName: z.string().optional(),
45
+ lastName: z.string().optional(),
46
+ name: z.string().optional(),
47
+ vatNumber: z.string().optional(),
48
+ address: addressSchema,
49
+ });
50
+ function partyValidationError(party) {
51
+ if (party.partyType === "INDIVIDUAL" && (!party.firstName || !party.lastName)) {
52
+ return "INDIVIDUAL parties require firstName and lastName";
53
+ }
54
+ if (party.partyType === "ORGANISATION" && !party.name) {
55
+ return "ORGANISATION parties require name";
56
+ }
57
+ return undefined;
58
+ }
33
59
  export function registerWriteTools(server, client, env) {
60
+ server.registerTool("create_party", {
61
+ title: "Create a customer or organisation party",
62
+ description: "Create a Finance party. This creates the party record; it does not complete KYC/KYB. Dry-run by default outside explicit mock mode.",
63
+ inputSchema: {
64
+ partyType: z.enum(["INDIVIDUAL", "ORGANISATION"]),
65
+ externalId: z.string().optional(),
66
+ firstName: z.string().optional(),
67
+ lastName: z.string().optional(),
68
+ name: z.string().optional(),
69
+ vatNumber: z.string().optional(),
70
+ address: addressSchema,
71
+ confirm: confirmField,
72
+ },
73
+ annotations: WRITE_ANNOTATIONS,
74
+ }, async ({ confirm, ...party }) => {
75
+ const validationError = partyValidationError(party);
76
+ if (validationError)
77
+ return errorResult(validationError);
78
+ const gate = evaluateWriteGate(confirm, env);
79
+ if (!gate.armed) {
80
+ return jsonResult(buildDryRun("create_party", "POST", "finance", "/parties", party, gate));
81
+ }
82
+ try {
83
+ return executionResult(gate, await client.createParty(party));
84
+ }
85
+ catch (e) {
86
+ return errorResult(e.message);
87
+ }
88
+ });
89
+ server.registerTool("create_account", {
90
+ title: "Create an account and provision its wallet",
91
+ description: "Create a Finance account. Venly auto-provisions its wallet on the selected chain. Supply partyId or an inline party. Dry-run by default.",
92
+ inputSchema: {
93
+ externalId: z.string().min(1),
94
+ name: z.string().optional(),
95
+ chain: z.enum(["AVALANCHE", "BASE", "POLYGON"]),
96
+ address: z.string().optional().describe("Required for SELF_CUSTODY tenants"),
97
+ partyId: z.string().optional(),
98
+ party: partySchema.optional(),
99
+ confirm: confirmField,
100
+ },
101
+ annotations: WRITE_ANNOTATIONS,
102
+ }, async ({ confirm, ...body }) => {
103
+ if (!body.partyId && !body.party) {
104
+ return errorResult("create_account requires partyId or an inline party");
105
+ }
106
+ if (body.party) {
107
+ const validationError = partyValidationError(body.party);
108
+ if (validationError)
109
+ return errorResult(validationError);
110
+ }
111
+ const gate = evaluateWriteGate(confirm, env);
112
+ if (!gate.armed) {
113
+ return jsonResult(buildDryRun("create_account", "POST", "finance", "/accounts", body, gate));
114
+ }
115
+ try {
116
+ return executionResult(gate, await client.createAccount(body));
117
+ }
118
+ catch (e) {
119
+ return errorResult(e.message);
120
+ }
121
+ });
122
+ server.registerTool("create_virtual_bank_account", {
123
+ title: "Create a EUR receiving account",
124
+ description: "Provision a EUR SEPA virtual bank account and conversion target. Outside mock mode the Finance account must have KYC status VERIFIED. Dry-run by default.",
125
+ inputSchema: {
126
+ accountId: z.string(),
127
+ name: z.string().min(1),
128
+ inCurrency: z.literal("EUR"),
129
+ targetCryptocurrency: z.enum(["USDC", "EURC", "USDT", "USDS"]),
130
+ idempotencyKey: z.string().min(1).optional(),
131
+ confirm: confirmField,
132
+ },
133
+ annotations: WRITE_ANNOTATIONS,
134
+ }, async ({ accountId, confirm, ...input }) => {
135
+ const body = {
136
+ ...input,
137
+ idempotencyKey: input.idempotencyKey ?? crypto.randomUUID(),
138
+ };
139
+ const gate = evaluateWriteGate(confirm, env);
140
+ if (!gate.armed) {
141
+ const dryRun = buildDryRun("create_virtual_bank_account", "POST", "finance", `/accounts/${accountId}/virtual-bank-accounts`, body, gate);
142
+ dryRun.note += " The account must have KYC status VERIFIED before live provisioning.";
143
+ return jsonResult(dryRun);
144
+ }
145
+ try {
146
+ return executionResult(gate, await client.createVirtualBankAccount(accountId, body));
147
+ }
148
+ catch (e) {
149
+ return errorResult(e.message);
150
+ }
151
+ });
152
+ server.registerTool("create_fiat_transfer", {
153
+ title: "Create a fiat-denominated internal transfer",
154
+ description: "Create an account-to-account transfer using the current Finance OpenAPI fields. Dry-run by default outside explicit mock mode.",
155
+ inputSchema: {
156
+ senderAccountId: z.string(),
157
+ receiverAccountId: z.string().optional(),
158
+ receiverExternalId: z.string().optional(),
159
+ currency: z.enum(["EUR", "GBP", "USD"]),
160
+ amount: z.number(),
161
+ description: z.string().optional(),
162
+ merchantReference: z.string().optional(),
163
+ idempotencyKey: z.string().min(1).optional(),
164
+ confirm: confirmField,
165
+ },
166
+ annotations: WRITE_ANNOTATIONS,
167
+ }, async ({ senderAccountId, confirm, ...input }) => {
168
+ if (!input.receiverAccountId && !input.receiverExternalId) {
169
+ return errorResult("A receiverAccountId or receiverExternalId is required");
170
+ }
171
+ const body = {
172
+ ...input,
173
+ idempotencyKey: input.idempotencyKey ?? crypto.randomUUID(),
174
+ };
175
+ const gate = evaluateWriteGate(confirm, env);
176
+ if (!gate.armed) {
177
+ return jsonResult(buildDryRun("create_fiat_transfer", "POST", "finance", `/accounts/${senderAccountId}/transfers/fiat`, body, gate));
178
+ }
179
+ try {
180
+ return executionResult(gate, await client.createCurrentFiatTransfer(senderAccountId, body));
181
+ }
182
+ catch (e) {
183
+ return errorResult(e.message);
184
+ }
185
+ });
186
+ server.registerTool("create_crypto_transfer", {
187
+ title: "Create a crypto-denominated internal transfer",
188
+ description: "Create an account-to-account asset transfer using the current Finance OpenAPI fields. Dry-run by default outside explicit mock mode.",
189
+ inputSchema: {
190
+ senderAccountId: z.string(),
191
+ receiverAccountId: z.string().optional(),
192
+ receiverExternalId: z.string().optional(),
193
+ chain: z.enum(["AVALANCHE", "BASE", "POLYGON"]),
194
+ asset: z.string().min(1),
195
+ amount: z.number(),
196
+ description: z.string().optional(),
197
+ merchantReference: z.string().optional(),
198
+ idempotencyKey: z.string().min(1).optional(),
199
+ confirm: confirmField,
200
+ },
201
+ annotations: WRITE_ANNOTATIONS,
202
+ }, async ({ senderAccountId, confirm, ...input }) => {
203
+ if (!input.receiverAccountId && !input.receiverExternalId) {
204
+ return errorResult("A receiverAccountId or receiverExternalId is required");
205
+ }
206
+ const body = {
207
+ ...input,
208
+ idempotencyKey: input.idempotencyKey ?? crypto.randomUUID(),
209
+ };
210
+ const gate = evaluateWriteGate(confirm, env);
211
+ if (!gate.armed) {
212
+ return jsonResult(buildDryRun("create_crypto_transfer", "POST", "finance", `/accounts/${senderAccountId}/transfers/crypto`, body, gate));
213
+ }
214
+ try {
215
+ return executionResult(gate, await client.createCryptoTransfer(senderAccountId, body));
216
+ }
217
+ catch (e) {
218
+ return errorResult(e.message);
219
+ }
220
+ });
34
221
  server.registerTool("stage_transfer", {
35
222
  title: "Stage a fiat transfer (dry-run by default)",
36
223
  description: "Stage a fiat-to-crypto transfer (finance POST /accounts/{senderAccountId}/transfers/fiat). " +
37
- "DISARMED by default: returns the exact request it would send unless " +
38
- "confirm:true AND VENLY_MCP_LIVE=1 AND credentials are present.",
224
+ "Legacy fiatAmount/fiatCurrency inputs are normalized to the current OpenAPI fields; " +
225
+ "the dry-run shows the exact normalized request. DISARMED by default: returns that " +
226
+ "request without sending unless confirm:true AND VENLY_MCP_LIVE=1 AND credentials are present.",
39
227
  inputSchema: {
40
228
  senderAccountId: z.string().describe("Account initiating the transfer"),
41
229
  receiverAccountId: z.string(),
42
- fiatAmount: z.string().describe("Decimal string, e.g. \"1000.00\""),
230
+ fiatAmount: z
231
+ .string()
232
+ .refine((value) => value.trim() !== "" && Number.isFinite(Number(value)), "fiatAmount must be a numeric decimal string")
233
+ .describe("Decimal string, e.g. \"1000.00\""),
43
234
  fiatCurrency: z.string().describe("e.g. EUR"),
44
- cryptocurrency: z.string().optional(),
235
+ cryptocurrency: z
236
+ .string()
237
+ .optional()
238
+ .describe("Retired: rejected with guidance. The current contract resolves the fiat " +
239
+ "amount to the account's settlement asset; use create_crypto_transfer instead."),
45
240
  description: z.string().optional(),
46
241
  merchantReference: z.string().optional(),
47
242
  confirm: confirmField,
@@ -49,20 +244,31 @@ export function registerWriteTools(server, client, env) {
49
244
  annotations: WRITE_ANNOTATIONS,
50
245
  }, async ({ senderAccountId, confirm, ...rest }) => {
51
246
  const gate = evaluateWriteGate(confirm, env);
52
- const body = {
247
+ const legacyInput = {
53
248
  receiverAccountId: rest.receiverAccountId,
54
249
  fiatAmount: rest.fiatAmount,
55
250
  fiatCurrency: rest.fiatCurrency,
56
251
  cryptocurrency: rest.cryptocurrency,
57
252
  description: rest.description,
58
253
  merchantReference: rest.merchantReference,
254
+ idempotencyKey: crypto.randomUUID(),
59
255
  };
256
+ // Normalize BEFORE the gate branch so the dry-run preview is byte-for-byte
257
+ // the request a live call would send (and the retired cryptocurrency field
258
+ // is rejected instead of silently dropped).
259
+ let body;
260
+ try {
261
+ body = normalizeLegacyFiatTransfer(legacyInput);
262
+ }
263
+ catch (e) {
264
+ return errorResult(e.message);
265
+ }
60
266
  if (!gate.armed) {
61
267
  return jsonResult(buildDryRun("stage_transfer", "POST", "finance", `/accounts/${senderAccountId}/transfers/fiat`, body, gate));
62
268
  }
63
269
  try {
64
- const result = await client.createFiatTransfer(senderAccountId, body);
65
- return jsonResult({ mode: "live", result });
270
+ const result = await client.createFiatTransfer(senderAccountId, legacyInput);
271
+ return executionResult(gate, result);
66
272
  }
67
273
  catch (e) {
68
274
  return errorResult(e.message);
@@ -91,7 +297,7 @@ export function registerWriteTools(server, client, env) {
91
297
  }
92
298
  try {
93
299
  const result = await client.approveRampRequest(id, body);
94
- return jsonResult({ mode: "live", result });
300
+ return executionResult(gate, result);
95
301
  }
96
302
  catch (e) {
97
303
  return errorResult(e.message);
@@ -118,23 +324,30 @@ export function registerWriteTools(server, client, env) {
118
324
  }
119
325
  try {
120
326
  const result = await client.rejectRampRequest(id, body);
121
- return jsonResult({ mode: "live", result });
327
+ return executionResult(gate, result);
122
328
  }
123
329
  catch (e) {
124
330
  return errorResult(e.message);
125
331
  }
126
332
  });
127
- server.registerTool("create_payment_link", {
128
- title: "Create a fiat-to-crypto payment link (dry-run by default)",
129
- description: "Create a pay-in link (finance POST /accounts/{accountId}/fiat-to-crypto/payment-links). " +
130
- "DISARMED by default.",
333
+ server.registerTool("create_payment_session", {
334
+ title: "Create a fiat-to-crypto payment session (dry-run by default)",
335
+ description: "Create a hosted pay-in session (finance POST " +
336
+ "/accounts/{accountId}/fiat-to-crypto/payment-sessions); redirect the " +
337
+ "payer to the returned paymentUrl. DISARMED by default.",
131
338
  inputSchema: {
132
339
  accountId: z.string(),
133
340
  inAmount: z.string().describe("Decimal string, e.g. \"250.00\""),
134
341
  inCurrency: z.string().describe("e.g. EUR"),
135
- outCryptocurrency: z.string().optional().describe("e.g. USDC"),
136
- redirectUrl: z.string().optional(),
342
+ outCryptocurrency: z.string().describe("e.g. USDC"),
343
+ callbackUrl: z.string().describe("Webhook URL notified on completion"),
344
+ successRedirectUrl: z.string().optional(),
345
+ failureRedirectUrl: z.string().optional(),
137
346
  externalRef: z.string().optional(),
347
+ idempotencyKey: z
348
+ .string()
349
+ .optional()
350
+ .describe("UUID; generated when omitted"),
138
351
  confirm: confirmField,
139
352
  },
140
353
  annotations: WRITE_ANNOTATIONS,
@@ -144,15 +357,18 @@ export function registerWriteTools(server, client, env) {
144
357
  inAmount: rest.inAmount,
145
358
  inCurrency: rest.inCurrency,
146
359
  outCryptocurrency: rest.outCryptocurrency,
147
- redirectUrl: rest.redirectUrl,
360
+ callbackUrl: rest.callbackUrl,
361
+ successRedirectUrl: rest.successRedirectUrl,
362
+ failureRedirectUrl: rest.failureRedirectUrl,
148
363
  externalRef: rest.externalRef,
364
+ idempotencyKey: rest.idempotencyKey ?? crypto.randomUUID(),
149
365
  };
150
366
  if (!gate.armed) {
151
- return jsonResult(buildDryRun("create_payment_link", "POST", "finance", `/accounts/${accountId}/fiat-to-crypto/payment-links`, body, gate));
367
+ return jsonResult(buildDryRun("create_payment_session", "POST", "finance", `/accounts/${accountId}/fiat-to-crypto/payment-sessions`, body, gate));
152
368
  }
153
369
  try {
154
- const result = await client.createPaymentLink(accountId, body);
155
- return jsonResult({ mode: "live", result });
370
+ const result = await client.createPayInSession(accountId, body);
371
+ return executionResult(gate, result);
156
372
  }
157
373
  catch (e) {
158
374
  return errorResult(e.message);