@venlyfinance/settlement-mcp 0.2.0 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 – 2026-08-04
4
+
5
+ Wording-is-the-safety-surface release. An outside integrator audit (Report 1,
6
+ 2026-08-04) found the server's words disagreeing with its behavior in three
7
+ places; all fixed, plus the SDK under the mock now teaches the documented
8
+ lifecycle (see @venlyfinance/sdk 0.2.0).
9
+
10
+ ### Changed
11
+
12
+ - **Default environment is `mock`** (was `staging`). A mock-first product must
13
+ not point at real infrastructure when unconfigured. Set `VENLY_ENV=staging`
14
+ or `production` explicitly for real calls; the safety resource and README
15
+ say so.
16
+ - **State-accurate startup banner.** Mock: "writes execute against local
17
+ fixtures - no network, no credentials, nothing real". Staging/production
18
+ disarmed: "mutations return dry-run previews". Armed: "confirmed writes hit
19
+ the live API". No more "DISARMED" next to a write that visibly executes.
20
+ - **Explicit `dryRun: true|false` on every mutation result** - agents no longer
21
+ infer persistence from `mode`.
22
+ - **`reconcile_by_reference_code` reads real remittance text**: matching is
23
+ case- and separator-insensitive, transactions match by containment
24
+ ("invoice ref abc 123 ty" finds REF-ABC-123), and codes under 4 alphanumeric
25
+ characters are refused.
26
+ - **Receiver XOR enforced**: `create_fiat_transfer` / `create_crypto_transfer`
27
+ reject a transfer with zero or two receivers (exactly one of
28
+ `receiverAccountId` / `receiverExternalId`), stated in the tool schema.
29
+ - Requires `@venlyfinance/sdk` ^0.2.0: in mock mode, created parties/accounts
30
+ start verification-pending (`mock.advanceVerification`), transfers start
31
+ `PENDING` (`mock.advanceTransfer`), and request bodies are spec-validated.
32
+
33
+ ### Deprecated
34
+
35
+ - **`stage_transfer`** is now plainly marked deprecated (legacy alias of
36
+ `create_fiat_transfer`); it will be removed in 0.4.0.
37
+
3
38
  ## 0.2.0 – 2026-08-03
4
39
 
5
40
  The Settlement MCP becomes the SDK-backed **Venly Finance MCP** builder while retaining
package/README.md CHANGED
@@ -190,7 +190,7 @@ Override via env:
190
190
 
191
191
  | Env var | Default (staging) |
192
192
  |---|---|
193
- | `VENLY_ENV` | `staging` for 0.x compatibility; set `mock` explicitly for fixtures |
193
+ | `VENLY_ENV` | Defaults to `mock` (since 0.3.0): an unconfigured server never points at real infrastructure. Set `staging` or `production` explicitly |
194
194
  | `VENLY_FINANCE_BASE_URL` | `https://api-staging.venlyfinance.com/v1` |
195
195
  | `VENLY_FUNDFLOW_BASE_URL` | `https://api-fundflow-staging.venly.io` |
196
196
  | `VENLY_TOKEN_URL` | `https://login-staging.venly.io/auth/realms/VenlyFinance/protocol/openid-connect/token` (staging; use `login.venly.io` for production) |
@@ -1,7 +1,7 @@
1
- /** Shared constants. Defaults point at STAGING so an accidental run never
2
- * touches production. Override via env for a real sandbox test. */
1
+ /** Shared constants. The default environment is MOCK so an unconfigured run
2
+ * never touches real infrastructure; staging/production are explicit. */
3
3
  export declare const SERVER_NAME = "venly-finance-mcp-server";
4
- export declare const SERVER_VERSION = "0.2.0";
4
+ export declare const SERVER_VERSION = "0.3.0";
5
5
  export declare const ENVIRONMENT_FLAG = "VENLY_ENV";
6
6
  export type VenlyEnvironment = "mock" | "staging" | "production";
7
7
  export declare function resolveVenlyEnvironment(env: Record<string, string | undefined>): VenlyEnvironment;
package/dist/constants.js CHANGED
@@ -1,10 +1,13 @@
1
- /** Shared constants. Defaults point at STAGING so an accidental run never
2
- * touches production. Override via env for a real sandbox test. */
1
+ /** Shared constants. The default environment is MOCK so an unconfigured run
2
+ * never touches real infrastructure; staging/production are explicit. */
3
3
  export const SERVER_NAME = "venly-finance-mcp-server";
4
- export const SERVER_VERSION = "0.2.0";
4
+ export const SERVER_VERSION = "0.3.0";
5
5
  export const ENVIRONMENT_FLAG = "VENLY_ENV";
6
6
  export function resolveVenlyEnvironment(env) {
7
- const value = env[ENVIRONMENT_FLAG] ?? "staging";
7
+ // Default is MOCK (since 0.3.0): the mock-first product must not point at
8
+ // real infrastructure when unconfigured. Set VENLY_ENV explicitly for
9
+ // staging or production.
10
+ const value = env[ENVIRONMENT_FLAG] ?? "mock";
8
11
  if (value === "mock" || value === "staging" || value === "production") {
9
12
  return value;
10
13
  }
package/dist/index.js CHANGED
@@ -17,8 +17,15 @@ async function main() {
17
17
  const transport = new StdioServerTransport();
18
18
  await server.connect(transport);
19
19
  // Log to stderr only (stdout is the MCP channel). No credentials here.
20
+ // The banner states what writes actually do in THIS environment - wording is
21
+ // the agent's safety surface, so it must match observed behavior exactly.
20
22
  const armed = process.env[LIVE_FLAG] === "1";
21
- process.stderr.write(`venly-finance-mcp started in ${client.environment}. write tools ${armed ? "ARMED (VENLY_MCP_LIVE=1)" : "DISARMED (read-only default)"}.\n`);
23
+ const writeState = client.environment === "mock"
24
+ ? "writes execute against local fixtures - no network, no credentials, nothing real"
25
+ : armed
26
+ ? "writes ARMED (VENLY_MCP_LIVE=1): confirmed writes hit the live API"
27
+ : "writes DISARMED: mutations return dry-run previews (arming needs confirm:true + VENLY_MCP_LIVE=1 + credentials)";
28
+ process.stderr.write(`venly-finance-mcp started in ${client.environment}. ${writeState}.\n`);
22
29
  }
23
30
  main().catch((err) => {
24
31
  process.stderr.write(`Fatal: ${err.message}\n`);
@@ -20,4 +20,11 @@ export interface ReconcileResult {
20
20
  currency: string | null;
21
21
  note: string;
22
22
  }
23
+ /**
24
+ * Normalize a payment reference the way bank remittance text must be read:
25
+ * uppercase, alphanumerics only. Payer banks freely re-case, strip or pad
26
+ * separators, so "ref-abc-123", "REF ABC 123" and "invoice REFABC123 thanks"
27
+ * must all find REF-ABC-123.
28
+ */
29
+ export declare function normalizeReference(text: string): string;
23
30
  export declare function reconcileByReferenceCode(referenceCode: string, virtualBankAccounts: VirtualBankAccount[], transactions: ObservedBankTransaction[]): ReconcileResult;
package/dist/reconcile.js CHANGED
@@ -6,16 +6,34 @@
6
6
  * vIBANs via the VenlyClient and passes them in alongside the operator- or
7
7
  * bank-feed-supplied transactions.
8
8
  */
9
+ /**
10
+ * Normalize a payment reference the way bank remittance text must be read:
11
+ * uppercase, alphanumerics only. Payer banks freely re-case, strip or pad
12
+ * separators, so "ref-abc-123", "REF ABC 123" and "invoice REFABC123 thanks"
13
+ * must all find REF-ABC-123.
14
+ */
15
+ export function normalizeReference(text) {
16
+ return text.toUpperCase().replace(/[^A-Z0-9]/g, "");
17
+ }
9
18
  export function reconcileByReferenceCode(referenceCode, virtualBankAccounts, transactions) {
10
19
  const target = referenceCode.trim();
11
20
  if (!target) {
12
21
  throw new Error("referenceCode must not be blank");
13
22
  }
14
- const vban = virtualBankAccounts.find((v) => (v.referenceCode ?? "").trim() === target) ?? null;
23
+ const normalizedTarget = normalizeReference(target);
24
+ if (normalizedTarget.length < 4) {
25
+ throw new Error(`referenceCode "${target}" is too short after normalization ` +
26
+ `("${normalizedTarget}"): at least 4 alphanumeric characters are required ` +
27
+ "to match safely against free-form remittance text.");
28
+ }
29
+ // The vIBAN side is Venly-issued, so it matches exactly (after normalization).
30
+ const vban = virtualBankAccounts.find((v) => normalizeReference(v.referenceCode ?? "") === normalizedTarget) ?? null;
15
31
  if (vban && !(vban.id ?? "").trim()) {
16
32
  throw new Error("matching vIBAN is missing an id");
17
33
  }
18
- const matchedTransactions = transactions.filter((t) => (t.referenceCode ?? "").trim() === target);
34
+ // The transaction side is free-form remittance text typed by a payer, so a
35
+ // containment test on the normalized text is the honest match.
36
+ const matchedTransactions = transactions.filter((t) => normalizeReference(t.referenceCode ?? "").includes(normalizedTarget));
19
37
  const totalAmount = matchedTransactions.reduce((sum, t) => sum + (Number.isFinite(t.amount) ? t.amount : 0), 0);
20
38
  const currency = vban?.currency ?? matchedTransactions[0]?.currency ?? null;
21
39
  const matched = vban !== null && matchedTransactions.length > 0;
package/dist/resources.js CHANGED
@@ -33,7 +33,7 @@ Current boundaries:
33
33
  description: "Environment, write, compliance and secret-handling rules.",
34
34
  text: `# Venly Finance MCP safety
35
35
 
36
- - Set VENLY_ENV explicitly to mock, staging or production. An absent value remains staging for 0.x compatibility.
36
+ - Set VENLY_ENV explicitly to mock, staging or production. An absent value defaults to mock (since 0.3.0), so an unconfigured server never points at real infrastructure.
37
37
  - Mock mode uses synthetic SDK fixtures, no credentials and no network. Every mutation result is labelled mode=mock.
38
38
  - Staging writes require confirm=true, VENLY_MCP_LIVE=1 and VENLY_CLIENT_ID/VENLY_CLIENT_SECRET.
39
39
  - Production requires every staging gate plus VENLY_MCP_PRODUCTION=1.
package/dist/safety.d.ts CHANGED
@@ -26,6 +26,8 @@ export declare function credentialsPresent(env: EnvLike): boolean;
26
26
  export declare function evaluateWriteGate(confirm: boolean, env: EnvLike): GateDecision;
27
27
  export interface DryRunRequest {
28
28
  mode: "dry-run";
29
+ /** Explicit on every mutation result: nothing was persisted. */
30
+ dryRun: true;
29
31
  environment: VenlyEnvironment;
30
32
  tool: string;
31
33
  method: "GET" | "POST" | "PUT" | "DELETE";
package/dist/safety.js CHANGED
@@ -55,6 +55,7 @@ export function evaluateWriteGate(confirm, env) {
55
55
  export function buildDryRun(tool, method, api, path, body, gate) {
56
56
  return {
57
57
  mode: "dry-run",
58
+ dryRun: true,
58
59
  environment: gate.environment,
59
60
  tool,
60
61
  method,
@@ -144,14 +144,20 @@ export function registerReadTools(server, client) {
144
144
  description: "Match observed incoming bank transactions on an account's EUR vIBANs to " +
145
145
  "the vIBAN whose referenceCode they carry. Fetches the account's vIBANs " +
146
146
  "(finance GET .../virtual-bank-accounts) and matches against the supplied " +
147
- "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 " +
148
152
  "transactions, and total amount.",
149
153
  inputSchema: {
150
154
  accountId: z.string().describe("Account UUID whose vIBANs to reconcile against"),
151
155
  referenceCode: z.string().describe("The reference code to reconcile"),
152
156
  transactions: z
153
157
  .array(z.object({
154
- referenceCode: z.string(),
158
+ referenceCode: z
159
+ .string()
160
+ .describe("Remittance text as received - free-form is fine; matching normalizes it"),
155
161
  amount: z.number(),
156
162
  currency: z.string(),
157
163
  remitterName: z.string().optional(),
@@ -13,6 +13,9 @@ import { normalizeLegacyFiatTransfer } from "../client/sdk-client.js";
13
13
  function executionResult(gate, result) {
14
14
  return jsonResult({
15
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,
16
19
  environment: gate.environment,
17
20
  result,
18
21
  });
@@ -154,8 +157,14 @@ export function registerWriteTools(server, client, env) {
154
157
  description: "Create an account-to-account transfer using the current Finance OpenAPI fields. Dry-run by default outside explicit mock mode.",
155
158
  inputSchema: {
156
159
  senderAccountId: z.string(),
157
- receiverAccountId: z.string().optional(),
158
- receiverExternalId: z.string().optional(),
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."),
159
168
  currency: z.enum(["EUR", "GBP", "USD"]),
160
169
  amount: z.number(),
161
170
  description: z.string().optional(),
@@ -165,8 +174,8 @@ export function registerWriteTools(server, client, env) {
165
174
  },
166
175
  annotations: WRITE_ANNOTATIONS,
167
176
  }, async ({ senderAccountId, confirm, ...input }) => {
168
- if (!input.receiverAccountId && !input.receiverExternalId) {
169
- return errorResult("A receiverAccountId or receiverExternalId is required");
177
+ if (!input.receiverAccountId === !input.receiverExternalId) {
178
+ return errorResult("Provide exactly one of receiverAccountId or receiverExternalId - a transfer needs one receiver, addressed one way.");
170
179
  }
171
180
  const body = {
172
181
  ...input,
@@ -188,8 +197,14 @@ export function registerWriteTools(server, client, env) {
188
197
  description: "Create an account-to-account asset transfer using the current Finance OpenAPI fields. Dry-run by default outside explicit mock mode.",
189
198
  inputSchema: {
190
199
  senderAccountId: z.string(),
191
- receiverAccountId: z.string().optional(),
192
- receiverExternalId: z.string().optional(),
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."),
193
208
  chain: z.enum(["AVALANCHE", "BASE", "POLYGON"]),
194
209
  asset: z.string().min(1),
195
210
  amount: z.number(),
@@ -200,8 +215,8 @@ export function registerWriteTools(server, client, env) {
200
215
  },
201
216
  annotations: WRITE_ANNOTATIONS,
202
217
  }, async ({ senderAccountId, confirm, ...input }) => {
203
- if (!input.receiverAccountId && !input.receiverExternalId) {
204
- return errorResult("A receiverAccountId or receiverExternalId is required");
218
+ if (!input.receiverAccountId === !input.receiverExternalId) {
219
+ return errorResult("Provide exactly one of receiverAccountId or receiverExternalId - a transfer needs one receiver, addressed one way.");
205
220
  }
206
221
  const body = {
207
222
  ...input,
@@ -219,8 +234,11 @@ export function registerWriteTools(server, client, env) {
219
234
  }
220
235
  });
221
236
  server.registerTool("stage_transfer", {
222
- title: "Stage a fiat transfer (dry-run by default)",
223
- description: "Stage a fiat-to-crypto transfer (finance POST /accounts/{senderAccountId}/transfers/fiat). " +
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). " +
224
242
  "Legacy fiatAmount/fiatCurrency inputs are normalized to the current OpenAPI fields; " +
225
243
  "the dry-run shows the exact normalized request. DISARMED by default: returns that " +
226
244
  "request without sending unless confirm:true AND VENLY_MCP_LIVE=1 AND credentials are present.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@venlyfinance/settlement-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Venly Finance MCP: SDK-backed tools, resources and prompts for building international money products safely.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,7 +27,7 @@
27
27
  "node": ">=20"
28
28
  },
29
29
  "dependencies": {
30
- "@venlyfinance/sdk": "^0.1.1",
30
+ "@venlyfinance/sdk": "^0.2.0",
31
31
  "@modelcontextprotocol/sdk": "^1.30.0",
32
32
  "zod": "^3.23.8"
33
33
  },