@venlyfinance/settlement-mcp 0.2.0 → 0.4.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
@@ -52,3 +87,16 @@ its package name and compatibility binary throughout the 0.x line.
52
87
  ### Security
53
88
 
54
89
  - Runtime dependency audits report zero findings after the MCP SDK/Hono upgrade.
90
+
91
+ ## 0.4.0 – 2026-08-07
92
+
93
+ Frontend toolset: the judgment layer for interface assembly.
94
+
95
+ - `get_journey_blueprint` – screen inventory, required states, registry items and binding hooks for eight money-product journeys.
96
+ - `review_screen` – deterministic design audit (raw colours, hyphen-minus amounts, success styling on cancelled steps, masked review values, zebra striping, off-token shadows, gradients, colour-only state). Findings, not a score.
97
+ - `venly://frontend/agents` resource – composition rules plus the @venlyfinance shadcn-registry wiring (delivery of UI source rides the registry standard; these tools carry what a registry cannot).
98
+ - `build_international_account` prompt now assembles the interface from the registry and gates every screen on `review_screen`.
99
+
100
+ ### Removed
101
+
102
+ - **`stage_transfer`**, as promised in 0.3.0's deprecation: use `create_fiat_transfer`, whose inputs match the OpenAPI contract directly. The skills pack is updated accordingly.
package/README.md CHANGED
@@ -92,7 +92,7 @@ Builder writes: `create_party`, `create_account`,
92
92
  `create_crypto_transfer`, `create_payment_session`.
93
93
 
94
94
  Operator writes: `approve_ramp_request`, `reject_ramp_request`. The legacy
95
- `stage_transfer` name remains as a compatibility tool; new builds use
95
+ `stage_transfer` alias was removed in 0.4.0 as deprecated in 0.3.0; use
96
96
  `create_fiat_transfer` and its current OpenAPI field names.
97
97
 
98
98
  Each is dry-run by default and returns the exact request it would send. See the
@@ -106,6 +106,23 @@ It documents the machine-to-machine rail. It never executes a payment, never
106
106
  calls a facilitator, and never moves funds. Production x402 settlement needs a
107
107
  facilitator decision and live rails.
108
108
 
109
+ ## Frontend toolset (interface assembly)
110
+
111
+ Delivery of UI source rides the shadcn registry standard – add
112
+ `{ "registries": { "@venlyfinance": "https://raw.githubusercontent.com/Venly/venly-settlement-sdk/main/ui/r/{name}.json" } }`
113
+ to `components.json`, then `npx shadcn@latest add @venlyfinance/receive`. The MCP carries
114
+ what a registry cannot:
115
+
116
+ - `get_journey_blueprint` – screen inventory, required states, registry items and binding
117
+ hooks for eight money-product journeys.
118
+ - `review_screen` – deterministic design audit of a screen's source (raw colours,
119
+ hyphen-minus amounts, success styling on cancelled steps, masked review values, zebra
120
+ striping, off-token shadows, colour-only state). Findings, not a score.
121
+ - `venly://frontend/agents` – the composition rules an agent should read before building.
122
+
123
+ The `build_international_account` prompt assembles the interface from the registry and
124
+ gates every finished screen on `review_screen`. See [`ui/`](../ui/README.md) for the kit itself.
125
+
109
126
  ## Safety model (fail closed)
110
127
 
111
128
  Outside explicit mock mode, read-only/dry-run is the default posture. A staging
@@ -190,7 +207,7 @@ Override via env:
190
207
 
191
208
  | Env var | Default (staging) |
192
209
  |---|---|
193
- | `VENLY_ENV` | `staging` for 0.x compatibility; set `mock` explicitly for fixtures |
210
+ | `VENLY_ENV` | Defaults to `mock` (since 0.3.0): an unconfigured server never points at real infrastructure. Set `staging` or `production` explicitly |
194
211
  | `VENLY_FINANCE_BASE_URL` | `https://api-staging.venlyfinance.com/v1` |
195
212
  | `VENLY_FUNDFLOW_BASE_URL` | `https://api-fundflow-staging.venly.io` |
196
213
  | `VENLY_TOKEN_URL` | `https://login-staging.venly.io/auth/realms/VenlyFinance/protocol/openid-connect/token` (staging; use `login.venly.io` for production) |
@@ -211,8 +228,8 @@ reads, and fail-closed write gate without mutating staging:
211
228
  VENLY_CLIENT_ID=... VENLY_CLIENT_SECRET=... npm run smoke:staging
212
229
  ```
213
230
 
214
- The command starts the MCP with `VENLY_ENV=staging`, lists the expected 23 tools,
215
- four resources, and builder prompt, then reads parties, accounts, and reference data.
231
+ The command starts the MCP with `VENLY_ENV=staging`, lists the expected 24 tools,
232
+ five resources, and builder prompt, then reads parties, accounts, and reference data.
216
233
  It deliberately removes `VENLY_MCP_LIVE` and `VENLY_MCP_PRODUCTION` from the child
217
234
  process before submitting one confirmed `create_party` request. Passing requires that
218
235
  request to return `mode: dry-run`, `environment: staging`, and an unarmed gate. Output
@@ -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.4.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.4.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
  }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Frontend toolset: what no generic registry can provide.
3
+ *
4
+ * Delivery of UI source belongs to the shadcn registry standard (the
5
+ * @venlyfinance registry under ui/r/ in this repo); these tools carry the
6
+ * judgment layer instead – journey blueprints (what screens and states a
7
+ * money product needs) and a deterministic design audit that pushes back
8
+ * on the classic agent-built-dashboard failure modes.
9
+ */
10
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11
+ export declare const REGISTRY_URL_TEMPLATE = "https://raw.githubusercontent.com/Venly/venly-settlement-sdk/main/ui/r/{name}.json";
12
+ interface Finding {
13
+ rule: string;
14
+ severity: "error" | "warn";
15
+ evidence: string;
16
+ fix: string;
17
+ }
18
+ /** Deterministic design audit. Text in, findings out - no model, no taste. */
19
+ export declare function reviewScreenSource(source: string): Finding[];
20
+ export declare function registerFrontendTools(server: McpServer): void;
21
+ export {};
@@ -0,0 +1,163 @@
1
+ import { z } from "zod";
2
+ export const REGISTRY_URL_TEMPLATE = "https://raw.githubusercontent.com/Venly/venly-settlement-sdk/main/ui/r/{name}.json";
3
+ const JOURNEYS = {
4
+ "home-balances": `# Home / balances
5
+ Shell: left nav rail + thin top bar; full-width content.
6
+ Registry items: venly-tokens, balance-card, data-table, status-pill.
7
+ Hooks: useAccounts, useVirtualBankAccounts; balances rendered per account/currency.
8
+ States that must exist: loading, zero accounts (first-run guidance), balances with reserved buckets.
9
+ Rules that must hold: available is the emphasised figure and the only one above the rule; reserved is demoted by position and scale, never colour; unspendable buckets carry the padlock; never assume stablecoin parity - render the quoted rate.`,
10
+ receive: `# Receive
11
+ Shell: content column - a warning callout, the field card, an advisory below.
12
+ Registry items: venly-tokens, field-list; block: receive.
13
+ Hooks: useVirtualBankAccounts (first active EUR account).
14
+ States that must exist: no virtual bank account yet (offer creation), details present, reference not yet assigned ("Not assigned yet" + Required pill - never "(not required)").
15
+ Rules that must hold: the payment reference is enforced as mandatory (amber Required pill, warning above the fields); per-field copy names the field it copied and only confirms on a successful write; rows never vanish - render the "(not required)" variant.`,
16
+ send: `# Send
17
+ Shell: full page; form clamped ~600px; review step replaces the form.
18
+ Registry items: venly-tokens, arithmetic-ladder, timeline; block: send.
19
+ Hooks: useStagedTransfer (the machine IS the flow), useFeeQuote when fees apply.
20
+ States that must exist: draft (validation issues listed), staged review, submitting, pending (polling), completed, failed (reason shown, terminal).
21
+ Rules that must hold: money movement is stage-then-confirm - the review renders the exact staged request as an arithmetic ladder (working before the answer, uncertainty attached to the number); the commit button restates the amount and never carries a countdown; values are never masked on review; execution is single-shot on an idempotency key pinned at staging.`,
22
+ activity: `# Activity
23
+ Shell: full-width table + side panel.
24
+ Registry items: venly-tokens, data-table, status-pill, side-panel, timeline; block: activity.
25
+ Hooks: useTransfers (and useRampRequests where ramps are in scope).
26
+ States that must exist: loading, empty ledger, rows with pending/failed pills, open detail panel that stays in sync with refetches.
27
+ Rules that must hold: a row click opens the panel, never navigates; no scrim - the source row stays tinted; settled rows stay quiet (colour is a budget; pills only where action or failure lives); the panel's hero is the amount; the failure reason rides the terminal timeline node.`,
28
+ "onboarding-status": `# Onboarding / verification status
29
+ Shell: full page, form clamped ~600px; a status home once submitted.
30
+ Registry items: venly-tokens, timeline, status-pill, field-list.
31
+ Hooks: useParties, useCreateParty; verification status from the party/account records.
32
+ States that must exist: collecting (per-section progress), submitted/waiting (say who acts next, on which channel, what still works meanwhile), approved, declined (humane copy + what to do next), re-verification on a live account.
33
+ Rules that must hold: never render a fake progress percentage - use real per-item status; a waiting state answers how long / who acts / what still works; a decline explains and offers a next step, not a dead end; creating a party is NOT completed verification - show the honest state.`,
34
+ reconciliation: `# Reconciliation
35
+ Shell: split pane (roughly one-third list, two-thirds evidence) - not a drawer.
36
+ Registry items: venly-tokens, data-table, side-panel, status-pill, field-list.
37
+ Hooks: reconcile_by_reference_code (MCP composite) or useVirtualBankAccounts + useTransfers joined on referenceCode.
38
+ States that must exist: matched, unmatched with candidate expectations, partial/many-to-one with a live shortfall figure, resolved.
39
+ Rules that must hold: show per-signal match rationale (which fields agree), never a bare score; keep zero-counts visible - an empty exception queue is information; keyboard row-stepping for review throughput.`,
40
+ "proof-of-segregation": `# Proof of segregation
41
+ Shell: content column, single card.
42
+ Registry items: venly-tokens, field-list, balance-card.
43
+ Hooks: useWallets, useAccount; on-chain balance beside the ledger figure.
44
+ States that must exist: reconciled (figures agree, timestamped), reconciling, source unavailable (say so - never render a stale figure as current).
45
+ Rules that must hold: the wallet address renders monospace with copy; the on-chain figure and ledger figure sit side by side with their as-of times; discrepancies are stated, not smoothed.`,
46
+ approvals: `# Approvals
47
+ Shell: full-width queue + side panel tailored to the approver.
48
+ Registry items: venly-tokens, data-table, status-pill, side-panel, timeline.
49
+ Hooks: useRampRequests, useFourEyesApproval (capability decides what renders), useRampLifecycle.
50
+ States that must exist: queue with awaiting-approval items, detail with the decision context beside the figures, applied, stale-version (someone acted first - refetch and re-decide), creator-view (cannot approve own request - render the rule, not a disabled mystery button).
51
+ Rules that must hold: the optimistic-locking version travels with every decision; a 409 means re-decide against fresh state, never auto-retry; reject requires a reason; the creator sees why they cannot approve.`,
52
+ };
53
+ const JOURNEY_KEYS = Object.keys(JOURNEYS);
54
+ /** Deterministic design audit. Text in, findings out - no model, no taste. */
55
+ export function reviewScreenSource(source) {
56
+ const findings = [];
57
+ const push = (rule, severity, evidence, fix) => findings.push({ rule, severity, evidence: evidence.slice(0, 120), fix });
58
+ for (const match of source.matchAll(/#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)/g)) {
59
+ push("raw-colour", "error", match[0], "Read colours from the venly-tokens custom properties; a reskin must be tokens.css and nothing else.");
60
+ }
61
+ for (const match of source.matchAll(/-\d[\d,]*\.\d{2}\s*(?:[A-Z]{3}|€|\$|£)/g)) {
62
+ push("hyphen-minus-amount", "error", match[0], "Use the true minus sign − before negative amounts (the Money primitive does this).");
63
+ }
64
+ // Only a RENDERED cancelled state counts (a quoted/JSX label or a state
65
+ // value), never the verb "cancel" in prose or a token file's comment; and
66
+ // only the ✓ glyph counts as the violation, never the mere NAME of a
67
+ // success token nearby.
68
+ for (const match of source.matchAll(/(?:["'>`]|\bstate\s*[:=]\s*["'])\s*cancell?ed\b/gi)) {
69
+ const idx = match.index ?? 0;
70
+ const around = source.slice(Math.max(0, idx - 150), idx + 150);
71
+ if (/✓/.test(around)) {
72
+ push("success-on-cancelled", "error", around.trim().slice(0, 80), "A cancelled or failed terminal step must never carry a success check - grey ↺ or red ✕.");
73
+ break;
74
+ }
75
+ }
76
+ if (/review|confirm/i.test(source) && /[•*]{3,}/.test(source)) {
77
+ push("masked-review-value", "error", source.match(/[•*]{3,}/)[0], "Never mask values on a review screen; its only job is legibility of what is about to happen.");
78
+ }
79
+ if (/nth-child\(\s*(?:even|odd|2n)/.test(source)) {
80
+ push("zebra-striping", "warn", "nth-child(even/odd) background", "No finance reference uses zebra striping - separate rows with hairlines and spacing.");
81
+ }
82
+ if (/box-shadow/.test(source) && !/var\(--shadow-overlay\)/.test(source)) {
83
+ push("shadow-outside-overlay", "warn", source.match(/box-shadow[^;"}]*/)?.[0] ?? "box-shadow", "Elevation is only for overlays, and only via the --shadow-overlay token; the base layer is flat.");
84
+ }
85
+ if (/linear-gradient|radial-gradient/.test(source)) {
86
+ push("gradient-surface", "warn", source.match(/\w+-gradient\([^)]*\)/)?.[0] ?? "gradient", "Gradient balance heroes read as template, not product; surfaces are flat neutrals with one accent.");
87
+ }
88
+ if (/(?:status|state)/i.test(source) && /var\(--state-/.test(source)) {
89
+ if (!/[✓✕↺⚠●○]|aria-hidden/.test(source)) {
90
+ push("colour-only-state", "warn", "state colours present without any glyph", "Pair every state hue with a glyph or word so status survives greyscale.");
91
+ }
92
+ }
93
+ return findings;
94
+ }
95
+ const AGENTS_TEXT = `# Composition rules for coding agents building on the Venly UI registry
96
+
97
+ Delivery: add the registry once to components.json -
98
+ { "registries": { "@venlyfinance": "${REGISTRY_URL_TEMPLATE}" } }
99
+ then install blocks with the shadcn CLI, e.g. \`npx shadcn@latest add @venlyfinance/receive\`.
100
+ Each block auto-installs its components, the venly-tokens file and the
101
+ @venlyfinance/react data layer. Import tokens.css once at the app root.
102
+
103
+ 1. Never hand-roll API calls, auth, retries, or transfer state - every read
104
+ is a hook, every regulated lifecycle is a flow machine from
105
+ @venlyfinance/react. Wrap the tree once in <VenlyProvider environment="mock">.
106
+ 2. Mock mode is the default for any demo or first build: zero credentials,
107
+ zero network. Never place a clientSecret in browser code - the provider
108
+ throws; use proxyClientOptions() against your own backend for production.
109
+ 3. Money movement is stage-then-confirm: render the review, restate the
110
+ amount on the commit button, execute once.
111
+ 4. Approval UIs render the rule, not the error: use the capability object;
112
+ on "stale-version" refetch and let the operator re-decide.
113
+ 5. Theme by editing the installed venly-tokens css file and nothing else.
114
+ 6. Before declaring a screen done, run the review_screen tool on its source
115
+ and fix every error-severity finding. Consult get_journey_blueprint
116
+ before designing a screen the registry has no block for.
117
+ `;
118
+ export function registerFrontendTools(server) {
119
+ server.registerTool("get_journey_blueprint", {
120
+ title: "Get a journey blueprint",
121
+ description: "Screen inventory, required states, registry items and binding hooks for one money-product journey. Consult before designing any screen.",
122
+ inputSchema: {
123
+ journey: z.enum(JOURNEY_KEYS).describe("Which journey to blueprint"),
124
+ },
125
+ }, async ({ journey }) => ({
126
+ content: [{ type: "text", text: JOURNEYS[journey] }],
127
+ }));
128
+ server.registerTool("review_screen", {
129
+ title: "Design-audit a screen",
130
+ description: "Deterministic audit of component/markup source against the kit's design contract: raw colours, hyphen-minus amounts, success styling on cancelled steps, masked review values, zebra striping, off-token shadows, gradients, colour-only state. Returns findings, not a score.",
131
+ inputSchema: {
132
+ source: z.string().min(1).describe("The component/markup/CSS source to audit"),
133
+ },
134
+ }, async ({ source }) => {
135
+ const findings = reviewScreenSource(source);
136
+ return {
137
+ content: [
138
+ {
139
+ type: "text",
140
+ text: JSON.stringify({
141
+ findings,
142
+ summary: findings.length === 0
143
+ ? "No contract violations detected."
144
+ : `${findings.filter((f) => f.severity === "error").length} error(s), ${findings.filter((f) => f.severity === "warn").length} warning(s). Fix every error before declaring the screen done.`,
145
+ }, null, 2),
146
+ },
147
+ ],
148
+ };
149
+ });
150
+ server.registerResource("frontend-agents", "venly://frontend/agents", {
151
+ title: "UI composition rules for coding agents",
152
+ description: "How to assemble a money-product frontend from the @venlyfinance registry and react package.",
153
+ mimeType: "text/markdown",
154
+ }, async () => ({
155
+ contents: [
156
+ {
157
+ uri: "venly://frontend/agents",
158
+ mimeType: "text/markdown",
159
+ text: AGENTS_TEXT,
160
+ },
161
+ ],
162
+ }));
163
+ }
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`);
package/dist/prompts.js CHANGED
@@ -24,16 +24,17 @@ export function registerBuilderPrompts(server) {
24
24
 
25
25
  Use this operating brief:
26
26
 
27
- 1. Read venly://capabilities, venly://safety and venly://workflows/international-account before writing code.
27
+ 1. Read venly://capabilities, venly://safety, venly://workflows/international-account and venly://frontend/agents before writing code.
28
28
  2. Start in explicit mock mode with VENLY_ENV=mock. Keep all simulated states visibly labelled Mock.
29
29
  3. Use @venlyfinance/sdk in server-side code. Never put Venly credentials or access tokens in browser code.
30
- 4. Build the customer experience around atomic Finance capabilities: party, account, auto-provisioned wallet and balances, EUR receiving account, transfer and status/reconciliation.
31
- 5. Do not claim that creating a party completes KYC/KYB. Display verification and pending states honestly.
32
- 6. Venly supplies financial infrastructure through regulated partners. Do not describe the application or its customer as a licensed bank unless separately verified.
33
- 7. EUR/SEPA virtual bank accounts are documented. Validate ${targetGeography ?? "the requested geography"} and any broader currency/coverage requirement instead of inferring support.
34
- 8. Card issuing is not exposed by the current Finance contract; do not invent a card feature.
35
- 9. Require an explicit user decision before switching to staging, adding credentials or arming writes. Dry-run staging mutations before confirmation.
36
- 10. Produce a concise README showing mock setup, the unchanged SDK business logic and the explicit staging transition.
30
+ 4. Assemble the interface instead of inventing it: register the @venlyfinance registry in components.json (URL template in venly://frontend/agents), install the receive/send/activity blocks with the shadcn CLI, wrap the tree in <VenlyProvider environment="mock"> from @venlyfinance/react, and import the installed venly-tokens css once at the app root. Consult get_journey_blueprint before designing any screen the registry has no block for, and run every finished screen through review_screen, fixing all error-severity findings.
31
+ 5. Build the customer experience around atomic Finance capabilities: party, account, auto-provisioned wallet and balances, EUR receiving account, transfer and status/reconciliation.
32
+ 6. Do not claim that creating a party completes KYC/KYB. Display verification and pending states honestly.
33
+ 7. Venly supplies financial infrastructure through regulated partners. Do not describe the application or its customer as a licensed bank unless separately verified.
34
+ 8. EUR/SEPA virtual bank accounts are documented. Validate ${targetGeography ?? "the requested geography"} and any broader currency/coverage requirement instead of inferring support.
35
+ 9. Card issuing is not exposed by the current Finance contract; do not invent a card feature.
36
+ 10. Require an explicit user decision before switching to staging, adding credentials or arming writes. Dry-run staging mutations before confirmation.
37
+ 11. Produce a concise README showing mock setup, the unchanged SDK business logic and the explicit staging transition.
37
38
 
38
39
  Success means a credible money-product experience backed by real Venly contract shapes – not a generic dashboard and not a claim that the MCP itself generated a regulated bank.`,
39
40
  },
@@ -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,
package/dist/server.js CHANGED
@@ -10,6 +10,7 @@ import { registerWriteTools } from "./tools/write-tools.js";
10
10
  import { registerX402Tools } from "./tools/x402-tools.js";
11
11
  import { registerBuilderResources } from "./resources.js";
12
12
  import { registerBuilderPrompts } from "./prompts.js";
13
+ import { registerFrontendTools } from "./frontend.js";
13
14
  export function createServer(options) {
14
15
  const env = options.env ?? process.env;
15
16
  // The write gate auto-arms every mutation in mock mode on the assumption
@@ -29,6 +30,7 @@ export function createServer(options) {
29
30
  registerReadTools(server, options.client);
30
31
  registerWriteTools(server, options.client, env);
31
32
  registerX402Tools(server);
33
+ registerFrontendTools(server);
32
34
  registerBuilderResources(server);
33
35
  registerBuilderPrompts(server);
34
36
  return server;
@@ -1,5 +1,5 @@
1
- export declare const EXPECTED_TOOLS: readonly ["list_ramp_requests", "get_ramp_request", "list_accounts", "get_account", "list_wallets", "list_virtual_bank_accounts", "get_virtual_bank_account", "reconcile_by_reference_code", "list_transfers", "get_transfer", "list_parties", "get_party", "get_reference_data", "create_party", "create_account", "create_virtual_bank_account", "create_fiat_transfer", "create_crypto_transfer", "stage_transfer", "approve_ramp_request", "reject_ramp_request", "create_payment_session", "quote_x402_payment"];
2
- export declare const EXPECTED_RESOURCE_URIS: readonly ["venly://capabilities", "venly://safety", "venly://workflows/international-account", "venly://workflows/mock-to-staging"];
1
+ export declare const EXPECTED_TOOLS: readonly ["list_ramp_requests", "get_ramp_request", "list_accounts", "get_account", "list_wallets", "list_virtual_bank_accounts", "get_virtual_bank_account", "reconcile_by_reference_code", "list_transfers", "get_transfer", "list_parties", "get_party", "get_reference_data", "create_party", "create_account", "create_virtual_bank_account", "create_fiat_transfer", "create_crypto_transfer", "approve_ramp_request", "reject_ramp_request", "create_payment_session", "quote_x402_payment", "get_journey_blueprint", "review_screen"];
2
+ export declare const EXPECTED_RESOURCE_URIS: readonly ["venly://capabilities", "venly://safety", "venly://workflows/international-account", "venly://workflows/mock-to-staging", "venly://frontend/agents"];
3
3
  export declare const EXPECTED_PROMPTS: readonly ["build_international_account"];
4
4
  export interface DiscoveryNames {
5
5
  tools: string[];
@@ -22,17 +22,19 @@ export const EXPECTED_TOOLS = [
22
22
  "create_virtual_bank_account",
23
23
  "create_fiat_transfer",
24
24
  "create_crypto_transfer",
25
- "stage_transfer",
26
25
  "approve_ramp_request",
27
26
  "reject_ramp_request",
28
27
  "create_payment_session",
29
28
  "quote_x402_payment",
29
+ "get_journey_blueprint",
30
+ "review_screen",
30
31
  ];
31
32
  export const EXPECTED_RESOURCE_URIS = [
32
33
  "venly://capabilities",
33
34
  "venly://safety",
34
35
  "venly://workflows/international-account",
35
36
  "venly://workflows/mock-to-staging",
37
+ "venly://frontend/agents",
36
38
  ];
37
39
  export const EXPECTED_PROMPTS = ["build_international_account"];
38
40
  function assertExactMembers(label, expected, actual) {
@@ -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(),
@@ -9,10 +9,12 @@
9
9
  import { z } from "zod";
10
10
  import { buildDryRun, evaluateWriteGate } from "../safety.js";
11
11
  import { errorResult, jsonResult } from "../results.js";
12
- import { normalizeLegacyFiatTransfer } from "../client/sdk-client.js";
13
12
  function executionResult(gate, result) {
14
13
  return jsonResult({
15
14
  mode: gate.environment === "mock" ? "mock" : "live",
15
+ // Explicit on every mutation result: this call DID execute (against local
16
+ // fixtures in mock, against the real API when armed).
17
+ dryRun: false,
16
18
  environment: gate.environment,
17
19
  result,
18
20
  });
@@ -154,8 +156,14 @@ export function registerWriteTools(server, client, env) {
154
156
  description: "Create an account-to-account transfer using the current Finance OpenAPI fields. Dry-run by default outside explicit mock mode.",
155
157
  inputSchema: {
156
158
  senderAccountId: z.string(),
157
- receiverAccountId: z.string().optional(),
158
- receiverExternalId: z.string().optional(),
159
+ receiverAccountId: z
160
+ .string()
161
+ .optional()
162
+ .describe("Receiver's Venly account id. Exactly one of receiverAccountId / receiverExternalId is required."),
163
+ receiverExternalId: z
164
+ .string()
165
+ .optional()
166
+ .describe("Receiver's integrator-assigned externalId. Exactly one of receiverAccountId / receiverExternalId is required."),
159
167
  currency: z.enum(["EUR", "GBP", "USD"]),
160
168
  amount: z.number(),
161
169
  description: z.string().optional(),
@@ -165,8 +173,8 @@ export function registerWriteTools(server, client, env) {
165
173
  },
166
174
  annotations: WRITE_ANNOTATIONS,
167
175
  }, async ({ senderAccountId, confirm, ...input }) => {
168
- if (!input.receiverAccountId && !input.receiverExternalId) {
169
- return errorResult("A receiverAccountId or receiverExternalId is required");
176
+ if (!input.receiverAccountId === !input.receiverExternalId) {
177
+ return errorResult("Provide exactly one of receiverAccountId or receiverExternalId - a transfer needs one receiver, addressed one way.");
170
178
  }
171
179
  const body = {
172
180
  ...input,
@@ -188,8 +196,14 @@ export function registerWriteTools(server, client, env) {
188
196
  description: "Create an account-to-account asset transfer using the current Finance OpenAPI fields. Dry-run by default outside explicit mock mode.",
189
197
  inputSchema: {
190
198
  senderAccountId: z.string(),
191
- receiverAccountId: z.string().optional(),
192
- receiverExternalId: z.string().optional(),
199
+ receiverAccountId: z
200
+ .string()
201
+ .optional()
202
+ .describe("Receiver's Venly account id. Exactly one of receiverAccountId / receiverExternalId is required."),
203
+ receiverExternalId: z
204
+ .string()
205
+ .optional()
206
+ .describe("Receiver's integrator-assigned externalId. Exactly one of receiverAccountId / receiverExternalId is required."),
193
207
  chain: z.enum(["AVALANCHE", "BASE", "POLYGON"]),
194
208
  asset: z.string().min(1),
195
209
  amount: z.number(),
@@ -200,8 +214,8 @@ export function registerWriteTools(server, client, env) {
200
214
  },
201
215
  annotations: WRITE_ANNOTATIONS,
202
216
  }, async ({ senderAccountId, confirm, ...input }) => {
203
- if (!input.receiverAccountId && !input.receiverExternalId) {
204
- return errorResult("A receiverAccountId or receiverExternalId is required");
217
+ if (!input.receiverAccountId === !input.receiverExternalId) {
218
+ return errorResult("Provide exactly one of receiverAccountId or receiverExternalId - a transfer needs one receiver, addressed one way.");
205
219
  }
206
220
  const body = {
207
221
  ...input,
@@ -218,62 +232,6 @@ export function registerWriteTools(server, client, env) {
218
232
  return errorResult(e.message);
219
233
  }
220
234
  });
221
- 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). " +
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.",
227
- inputSchema: {
228
- senderAccountId: z.string().describe("Account initiating the transfer"),
229
- receiverAccountId: z.string(),
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\""),
234
- fiatCurrency: z.string().describe("e.g. EUR"),
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."),
240
- description: z.string().optional(),
241
- merchantReference: z.string().optional(),
242
- confirm: confirmField,
243
- },
244
- annotations: WRITE_ANNOTATIONS,
245
- }, async ({ senderAccountId, confirm, ...rest }) => {
246
- const gate = evaluateWriteGate(confirm, env);
247
- const legacyInput = {
248
- receiverAccountId: rest.receiverAccountId,
249
- fiatAmount: rest.fiatAmount,
250
- fiatCurrency: rest.fiatCurrency,
251
- cryptocurrency: rest.cryptocurrency,
252
- description: rest.description,
253
- merchantReference: rest.merchantReference,
254
- idempotencyKey: crypto.randomUUID(),
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
- }
266
- if (!gate.armed) {
267
- return jsonResult(buildDryRun("stage_transfer", "POST", "finance", `/accounts/${senderAccountId}/transfers/fiat`, body, gate));
268
- }
269
- try {
270
- const result = await client.createFiatTransfer(senderAccountId, legacyInput);
271
- return executionResult(gate, result);
272
- }
273
- catch (e) {
274
- return errorResult(e.message);
275
- }
276
- });
277
235
  server.registerTool("approve_ramp_request", {
278
236
  title: "Approve a ramp request (dry-run by default)",
279
237
  description: "Approve a ramp request through four-eyes (fundflow POST /v1/ramp-requests/{id}/approve). " +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@venlyfinance/settlement-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.4.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
  },
@@ -11,13 +11,13 @@ fiat to crypto.
11
11
  ## Tools
12
12
 
13
13
  - `get_account` (read)
14
- - `stage_transfer` (write, disarmed by default)
14
+ - `create_fiat_transfer` (write, disarmed by default)
15
15
  - `get_transfer` (read)
16
16
 
17
17
  ## Steps
18
18
 
19
19
  1. `get_account` for the `senderAccountId` to confirm it is active.
20
- 2. Stage the transfer: call `stage_transfer` with `senderAccountId`,
20
+ 2. Stage the transfer: call `create_fiat_transfer` with `senderAccountId`,
21
21
  `receiverAccountId`, `fiatAmount` (decimal string), `fiatCurrency`, and
22
22
  optionally `cryptocurrency`, `description`, `merchantReference`. Omit
23
23
  `confirm` (or set it false).