@venlyfinance/settlement-mcp 0.1.1 → 0.3.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,19 +123,41 @@ 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 " +
105
145
  "the vIBAN whose referenceCode they carry. Fetches the account's vIBANs " +
106
146
  "(finance GET .../virtual-bank-accounts) and matches against the supplied " +
107
- "transactions. Read-only, no mutation. Returns the matched vIBAN, matched " +
147
+ "transactions. Matching is remittance-text tolerant: case- and " +
148
+ "separator-insensitive, and a transaction matches when its normalized " +
149
+ "reference CONTAINS the normalized code (real payers type 'invoice ref " +
150
+ "abc 123 ty'). Codes under 4 alphanumeric characters are refused. " +
151
+ "Read-only, no mutation. Returns the matched vIBAN, matched " +
108
152
  "transactions, and total amount.",
109
153
  inputSchema: {
110
154
  accountId: z.string().describe("Account UUID whose vIBANs to reconcile against"),
111
155
  referenceCode: z.string().describe("The reference code to reconcile"),
112
156
  transactions: z
113
157
  .array(z.object({
114
- referenceCode: z.string(),
158
+ referenceCode: z
159
+ .string()
160
+ .describe("Remittance text as received - free-form is fine; matching normalizes it"),
115
161
  amount: z.number(),
116
162
  currency: z.string(),
117
163
  remitterName: z.string().optional(),
@@ -132,6 +178,24 @@ export function registerReadTools(server, client) {
132
178
  return errorResult(e.message);
133
179
  }
134
180
  });
181
+ server.registerTool("list_transfers", {
182
+ title: "List transfers",
183
+ description: "List transfer history for an account (finance GET /accounts/{accountId}/transfers). Read-only.",
184
+ inputSchema: {
185
+ accountId: z.string().describe("Account UUID"),
186
+ page: z.number().int().min(1).optional(),
187
+ size: z.number().int().min(1).max(200).optional(),
188
+ },
189
+ annotations: READ_ONLY,
190
+ }, async ({ accountId, page, size }) => {
191
+ try {
192
+ const result = await client.listTransfers(accountId, { page, size });
193
+ return jsonResult({ count: result.length, transfers: result });
194
+ }
195
+ catch (e) {
196
+ return errorResult(e.message);
197
+ }
198
+ });
135
199
  server.registerTool("get_transfer", {
136
200
  title: "Get transfer",
137
201
  description: "Fetch a transfer by id (finance GET /accounts/{accountId}/transfers/{transferId}). Read-only.",
@@ -165,6 +229,19 @@ export function registerReadTools(server, client) {
165
229
  return errorResult(e.message);
166
230
  }
167
231
  });
232
+ server.registerTool("get_party", {
233
+ title: "Get party",
234
+ description: "Fetch an individual or organisation party, including KYC/KYB state when present. Read-only.",
235
+ inputSchema: { partyId: z.string().describe("Party UUID") },
236
+ annotations: READ_ONLY,
237
+ }, async ({ partyId }) => {
238
+ try {
239
+ return jsonResult(await client.getParty(partyId));
240
+ }
241
+ catch (e) {
242
+ return errorResult(e.message);
243
+ }
244
+ });
168
245
  server.registerTool("get_reference_data", {
169
246
  title: "Get reference data",
170
247
  description: "Fetch settlement reference data: supported chains, fiat currencies, " +
@@ -8,16 +8,17 @@
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
+ // Explicit on every mutation result: this call DID execute (against local
17
+ // fixtures in mock, against the real API when armed).
18
+ dryRun: false,
19
+ environment: gate.environment,
20
+ result,
21
+ });
21
22
  }
22
23
  const WRITE_ANNOTATIONS = {
23
24
  readOnlyHint: false,
@@ -30,18 +31,230 @@ const confirmField = z
30
31
  .default(false)
31
32
  .describe("Must be true to attempt a live call. Even then, VENLY_MCP_LIVE=1 and " +
32
33
  "credentials are also required, otherwise the tool dry-runs.");
34
+ const addressSchema = z
35
+ .object({
36
+ addressLine1: z.string().optional(),
37
+ addressLine2: z.string().optional(),
38
+ city: z.string().optional(),
39
+ state: z.string().optional(),
40
+ postalCode: z.string().optional(),
41
+ country: z.string().regex(/^[A-Z]{2}$/).optional(),
42
+ })
43
+ .optional();
44
+ const partySchema = z.object({
45
+ partyType: z.enum(["INDIVIDUAL", "ORGANISATION"]),
46
+ externalId: z.string().optional(),
47
+ firstName: z.string().optional(),
48
+ lastName: z.string().optional(),
49
+ name: z.string().optional(),
50
+ vatNumber: z.string().optional(),
51
+ address: addressSchema,
52
+ });
53
+ function partyValidationError(party) {
54
+ if (party.partyType === "INDIVIDUAL" && (!party.firstName || !party.lastName)) {
55
+ return "INDIVIDUAL parties require firstName and lastName";
56
+ }
57
+ if (party.partyType === "ORGANISATION" && !party.name) {
58
+ return "ORGANISATION parties require name";
59
+ }
60
+ return undefined;
61
+ }
33
62
  export function registerWriteTools(server, client, env) {
63
+ server.registerTool("create_party", {
64
+ title: "Create a customer or organisation party",
65
+ description: "Create a Finance party. This creates the party record; it does not complete KYC/KYB. Dry-run by default outside explicit mock mode.",
66
+ inputSchema: {
67
+ partyType: z.enum(["INDIVIDUAL", "ORGANISATION"]),
68
+ externalId: z.string().optional(),
69
+ firstName: z.string().optional(),
70
+ lastName: z.string().optional(),
71
+ name: z.string().optional(),
72
+ vatNumber: z.string().optional(),
73
+ address: addressSchema,
74
+ confirm: confirmField,
75
+ },
76
+ annotations: WRITE_ANNOTATIONS,
77
+ }, async ({ confirm, ...party }) => {
78
+ const validationError = partyValidationError(party);
79
+ if (validationError)
80
+ return errorResult(validationError);
81
+ const gate = evaluateWriteGate(confirm, env);
82
+ if (!gate.armed) {
83
+ return jsonResult(buildDryRun("create_party", "POST", "finance", "/parties", party, gate));
84
+ }
85
+ try {
86
+ return executionResult(gate, await client.createParty(party));
87
+ }
88
+ catch (e) {
89
+ return errorResult(e.message);
90
+ }
91
+ });
92
+ server.registerTool("create_account", {
93
+ title: "Create an account and provision its wallet",
94
+ description: "Create a Finance account. Venly auto-provisions its wallet on the selected chain. Supply partyId or an inline party. Dry-run by default.",
95
+ inputSchema: {
96
+ externalId: z.string().min(1),
97
+ name: z.string().optional(),
98
+ chain: z.enum(["AVALANCHE", "BASE", "POLYGON"]),
99
+ address: z.string().optional().describe("Required for SELF_CUSTODY tenants"),
100
+ partyId: z.string().optional(),
101
+ party: partySchema.optional(),
102
+ confirm: confirmField,
103
+ },
104
+ annotations: WRITE_ANNOTATIONS,
105
+ }, async ({ confirm, ...body }) => {
106
+ if (!body.partyId && !body.party) {
107
+ return errorResult("create_account requires partyId or an inline party");
108
+ }
109
+ if (body.party) {
110
+ const validationError = partyValidationError(body.party);
111
+ if (validationError)
112
+ return errorResult(validationError);
113
+ }
114
+ const gate = evaluateWriteGate(confirm, env);
115
+ if (!gate.armed) {
116
+ return jsonResult(buildDryRun("create_account", "POST", "finance", "/accounts", body, gate));
117
+ }
118
+ try {
119
+ return executionResult(gate, await client.createAccount(body));
120
+ }
121
+ catch (e) {
122
+ return errorResult(e.message);
123
+ }
124
+ });
125
+ server.registerTool("create_virtual_bank_account", {
126
+ title: "Create a EUR receiving account",
127
+ 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.",
128
+ inputSchema: {
129
+ accountId: z.string(),
130
+ name: z.string().min(1),
131
+ inCurrency: z.literal("EUR"),
132
+ targetCryptocurrency: z.enum(["USDC", "EURC", "USDT", "USDS"]),
133
+ idempotencyKey: z.string().min(1).optional(),
134
+ confirm: confirmField,
135
+ },
136
+ annotations: WRITE_ANNOTATIONS,
137
+ }, async ({ accountId, confirm, ...input }) => {
138
+ const body = {
139
+ ...input,
140
+ idempotencyKey: input.idempotencyKey ?? crypto.randomUUID(),
141
+ };
142
+ const gate = evaluateWriteGate(confirm, env);
143
+ if (!gate.armed) {
144
+ const dryRun = buildDryRun("create_virtual_bank_account", "POST", "finance", `/accounts/${accountId}/virtual-bank-accounts`, body, gate);
145
+ dryRun.note += " The account must have KYC status VERIFIED before live provisioning.";
146
+ return jsonResult(dryRun);
147
+ }
148
+ try {
149
+ return executionResult(gate, await client.createVirtualBankAccount(accountId, body));
150
+ }
151
+ catch (e) {
152
+ return errorResult(e.message);
153
+ }
154
+ });
155
+ server.registerTool("create_fiat_transfer", {
156
+ title: "Create a fiat-denominated internal transfer",
157
+ description: "Create an account-to-account transfer using the current Finance OpenAPI fields. Dry-run by default outside explicit mock mode.",
158
+ inputSchema: {
159
+ senderAccountId: z.string(),
160
+ receiverAccountId: z
161
+ .string()
162
+ .optional()
163
+ .describe("Receiver's Venly account id. Exactly one of receiverAccountId / receiverExternalId is required."),
164
+ receiverExternalId: z
165
+ .string()
166
+ .optional()
167
+ .describe("Receiver's integrator-assigned externalId. Exactly one of receiverAccountId / receiverExternalId is required."),
168
+ currency: z.enum(["EUR", "GBP", "USD"]),
169
+ amount: z.number(),
170
+ description: z.string().optional(),
171
+ merchantReference: z.string().optional(),
172
+ idempotencyKey: z.string().min(1).optional(),
173
+ confirm: confirmField,
174
+ },
175
+ annotations: WRITE_ANNOTATIONS,
176
+ }, async ({ senderAccountId, confirm, ...input }) => {
177
+ if (!input.receiverAccountId === !input.receiverExternalId) {
178
+ return errorResult("Provide exactly one of receiverAccountId or receiverExternalId - a transfer needs one receiver, addressed one way.");
179
+ }
180
+ const body = {
181
+ ...input,
182
+ idempotencyKey: input.idempotencyKey ?? crypto.randomUUID(),
183
+ };
184
+ const gate = evaluateWriteGate(confirm, env);
185
+ if (!gate.armed) {
186
+ return jsonResult(buildDryRun("create_fiat_transfer", "POST", "finance", `/accounts/${senderAccountId}/transfers/fiat`, body, gate));
187
+ }
188
+ try {
189
+ return executionResult(gate, await client.createCurrentFiatTransfer(senderAccountId, body));
190
+ }
191
+ catch (e) {
192
+ return errorResult(e.message);
193
+ }
194
+ });
195
+ server.registerTool("create_crypto_transfer", {
196
+ title: "Create a crypto-denominated internal transfer",
197
+ description: "Create an account-to-account asset transfer using the current Finance OpenAPI fields. Dry-run by default outside explicit mock mode.",
198
+ inputSchema: {
199
+ senderAccountId: z.string(),
200
+ receiverAccountId: z
201
+ .string()
202
+ .optional()
203
+ .describe("Receiver's Venly account id. Exactly one of receiverAccountId / receiverExternalId is required."),
204
+ receiverExternalId: z
205
+ .string()
206
+ .optional()
207
+ .describe("Receiver's integrator-assigned externalId. Exactly one of receiverAccountId / receiverExternalId is required."),
208
+ chain: z.enum(["AVALANCHE", "BASE", "POLYGON"]),
209
+ asset: z.string().min(1),
210
+ amount: z.number(),
211
+ description: z.string().optional(),
212
+ merchantReference: z.string().optional(),
213
+ idempotencyKey: z.string().min(1).optional(),
214
+ confirm: confirmField,
215
+ },
216
+ annotations: WRITE_ANNOTATIONS,
217
+ }, async ({ senderAccountId, confirm, ...input }) => {
218
+ if (!input.receiverAccountId === !input.receiverExternalId) {
219
+ return errorResult("Provide exactly one of receiverAccountId or receiverExternalId - a transfer needs one receiver, addressed one way.");
220
+ }
221
+ const body = {
222
+ ...input,
223
+ idempotencyKey: input.idempotencyKey ?? crypto.randomUUID(),
224
+ };
225
+ const gate = evaluateWriteGate(confirm, env);
226
+ if (!gate.armed) {
227
+ return jsonResult(buildDryRun("create_crypto_transfer", "POST", "finance", `/accounts/${senderAccountId}/transfers/crypto`, body, gate));
228
+ }
229
+ try {
230
+ return executionResult(gate, await client.createCryptoTransfer(senderAccountId, body));
231
+ }
232
+ catch (e) {
233
+ return errorResult(e.message);
234
+ }
235
+ });
34
236
  server.registerTool("stage_transfer", {
35
- title: "Stage a fiat transfer (dry-run by default)",
36
- 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.",
237
+ title: "DEPRECATED - use create_fiat_transfer",
238
+ description: "DEPRECATED: legacy alias of create_fiat_transfer kept for 0.1.x compatibility; " +
239
+ "it will be removed in 0.4.0. Prefer create_fiat_transfer, whose inputs match the " +
240
+ "current OpenAPI contract directly. " +
241
+ "Stages a fiat-to-crypto transfer (finance POST /accounts/{senderAccountId}/transfers/fiat). " +
242
+ "Legacy fiatAmount/fiatCurrency inputs are normalized to the current OpenAPI fields; " +
243
+ "the dry-run shows the exact normalized request. DISARMED by default: returns that " +
244
+ "request without sending unless confirm:true AND VENLY_MCP_LIVE=1 AND credentials are present.",
39
245
  inputSchema: {
40
246
  senderAccountId: z.string().describe("Account initiating the transfer"),
41
247
  receiverAccountId: z.string(),
42
- fiatAmount: z.string().describe("Decimal string, e.g. \"1000.00\""),
248
+ fiatAmount: z
249
+ .string()
250
+ .refine((value) => value.trim() !== "" && Number.isFinite(Number(value)), "fiatAmount must be a numeric decimal string")
251
+ .describe("Decimal string, e.g. \"1000.00\""),
43
252
  fiatCurrency: z.string().describe("e.g. EUR"),
44
- cryptocurrency: z.string().optional(),
253
+ cryptocurrency: z
254
+ .string()
255
+ .optional()
256
+ .describe("Retired: rejected with guidance. The current contract resolves the fiat " +
257
+ "amount to the account's settlement asset; use create_crypto_transfer instead."),
45
258
  description: z.string().optional(),
46
259
  merchantReference: z.string().optional(),
47
260
  confirm: confirmField,
@@ -49,20 +262,31 @@ export function registerWriteTools(server, client, env) {
49
262
  annotations: WRITE_ANNOTATIONS,
50
263
  }, async ({ senderAccountId, confirm, ...rest }) => {
51
264
  const gate = evaluateWriteGate(confirm, env);
52
- const body = {
265
+ const legacyInput = {
53
266
  receiverAccountId: rest.receiverAccountId,
54
267
  fiatAmount: rest.fiatAmount,
55
268
  fiatCurrency: rest.fiatCurrency,
56
269
  cryptocurrency: rest.cryptocurrency,
57
270
  description: rest.description,
58
271
  merchantReference: rest.merchantReference,
272
+ idempotencyKey: crypto.randomUUID(),
59
273
  };
274
+ // Normalize BEFORE the gate branch so the dry-run preview is byte-for-byte
275
+ // the request a live call would send (and the retired cryptocurrency field
276
+ // is rejected instead of silently dropped).
277
+ let body;
278
+ try {
279
+ body = normalizeLegacyFiatTransfer(legacyInput);
280
+ }
281
+ catch (e) {
282
+ return errorResult(e.message);
283
+ }
60
284
  if (!gate.armed) {
61
285
  return jsonResult(buildDryRun("stage_transfer", "POST", "finance", `/accounts/${senderAccountId}/transfers/fiat`, body, gate));
62
286
  }
63
287
  try {
64
- const result = await client.createFiatTransfer(senderAccountId, body);
65
- return jsonResult({ mode: "live", result });
288
+ const result = await client.createFiatTransfer(senderAccountId, legacyInput);
289
+ return executionResult(gate, result);
66
290
  }
67
291
  catch (e) {
68
292
  return errorResult(e.message);
@@ -91,7 +315,7 @@ export function registerWriteTools(server, client, env) {
91
315
  }
92
316
  try {
93
317
  const result = await client.approveRampRequest(id, body);
94
- return jsonResult({ mode: "live", result });
318
+ return executionResult(gate, result);
95
319
  }
96
320
  catch (e) {
97
321
  return errorResult(e.message);
@@ -118,7 +342,7 @@ export function registerWriteTools(server, client, env) {
118
342
  }
119
343
  try {
120
344
  const result = await client.rejectRampRequest(id, body);
121
- return jsonResult({ mode: "live", result });
345
+ return executionResult(gate, result);
122
346
  }
123
347
  catch (e) {
124
348
  return errorResult(e.message);
@@ -162,7 +386,7 @@ export function registerWriteTools(server, client, env) {
162
386
  }
163
387
  try {
164
388
  const result = await client.createPayInSession(accountId, body);
165
- return jsonResult({ mode: "live", result });
389
+ return executionResult(gate, result);
166
390
  }
167
391
  catch (e) {
168
392
  return errorResult(e.message);