@venlyfinance/settlement-mcp 0.1.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/LICENSE +20 -0
- package/README.md +226 -0
- package/dist/client/http-client.d.ts +58 -0
- package/dist/client/http-client.js +163 -0
- package/dist/constants.d.ts +13 -0
- package/dist/constants.js +13 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +25 -0
- package/dist/reconcile.d.ts +23 -0
- package/dist/reconcile.js +38 -0
- package/dist/safety.d.ts +35 -0
- package/dist/safety.js +46 -0
- package/dist/server.d.ts +14 -0
- package/dist/server.js +21 -0
- package/dist/tools/read-tools.d.ts +6 -0
- package/dist/tools/read-tools.js +196 -0
- package/dist/tools/write-tools.d.ts +12 -0
- package/dist/tools/write-tools.js +161 -0
- package/dist/tools/x402-tools.d.ts +13 -0
- package/dist/tools/x402-tools.js +95 -0
- package/dist/types.d.ts +177 -0
- package/dist/types.js +17 -0
- package/package.json +55 -0
- package/skills/four-eyes-approval.md +39 -0
- package/skills/payment-link-lifecycle.md +44 -0
- package/skills/reconcile-by-reference-code.md +40 -0
- package/skills/stage-and-confirm-transfer.md +39 -0
- package/skills/x402-quote-walkthrough.md +39 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Venly NV
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OF THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# Venly Settlement MCP server
|
|
2
|
+
|
|
3
|
+
A human-gated operator surface for Venly settlement, exposed as a Model Context
|
|
4
|
+
Protocol (MCP) server over stdio. An operator, or a supervised agent, can check a
|
|
5
|
+
ramp's four-eyes approval state, reconcile a EUR vIBAN by referenceCode, or stage
|
|
6
|
+
a transfer, without hand-writing API calls. It pairs that with a position on
|
|
7
|
+
x402, the machine-to-machine agent-payments rail.
|
|
8
|
+
|
|
9
|
+
The differentiator: the MCP is the human-gated operator surface; x402 is the
|
|
10
|
+
machine-to-machine rail. Read-only by default, write tools fail closed.
|
|
11
|
+
|
|
12
|
+
Status: v0.1.0. Read tools work against staging out of the box once credentials
|
|
13
|
+
are set; write tools are shipped disarmed and fail closed (see the safety model).
|
|
14
|
+
The x402 tool is a stub that states a position, it moves no funds.
|
|
15
|
+
|
|
16
|
+
## What it is
|
|
17
|
+
|
|
18
|
+
- Built on the official MCP TypeScript SDK (`@modelcontextprotocol/sdk`), Node
|
|
19
|
+
>= 18, stdio transport.
|
|
20
|
+
- A thin layer over the Venly Finance API (`finance.yaml`, servers
|
|
21
|
+
`https://api.venlyfinance.com/api/v1`) and the Fundflow API (`fundflow.yaml`,
|
|
22
|
+
servers `https://api-fundflow.venly.io`). Endpoint truth is the published API
|
|
23
|
+
reference at [docs.venlyfinance.com](https://docs.venlyfinance.com/api-reference).
|
|
24
|
+
- Transport is injected through the `VenlyClient` interface (`src/types.ts`).
|
|
25
|
+
`HttpVenlyClient` is a minimal fetch-based implementation; tests inject a mock.
|
|
26
|
+
|
|
27
|
+
### Transport note
|
|
28
|
+
|
|
29
|
+
The built-in HTTP transport is minimal by design (OAuth2 client credentials,
|
|
30
|
+
lazy token fetch, no logging of secrets) and is fully covered by the test
|
|
31
|
+
suite. A future release adopts [`@venlyfinance/sdk`](../) as the transport
|
|
32
|
+
layer, with no change to the tool interface.
|
|
33
|
+
|
|
34
|
+
## The three tool tiers
|
|
35
|
+
|
|
36
|
+
### 1. Read tools (always on)
|
|
37
|
+
|
|
38
|
+
Call GETs only. No mutation, safe by default.
|
|
39
|
+
|
|
40
|
+
| Tool | Maps to |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `list_ramp_requests` | fundflow `GET /v1/ramp-requests` |
|
|
43
|
+
| `get_ramp_request` | fundflow `GET /v1/ramp-requests/{id}` |
|
|
44
|
+
| `get_account` | finance `GET /accounts/{accountId}` |
|
|
45
|
+
| `list_virtual_bank_accounts` | finance `GET /accounts/{accountId}/virtual-bank-accounts` |
|
|
46
|
+
| `reconcile_by_reference_code` | composite: lists vIBANs, matches supplied transactions by referenceCode |
|
|
47
|
+
| `get_transfer` | finance `GET /accounts/{accountId}/transfers/{transferId}` |
|
|
48
|
+
| `list_parties` | finance `GET /parties` |
|
|
49
|
+
| `get_reference_data` | fundflow chains / fiat-currencies / crypto-currencies / fees |
|
|
50
|
+
|
|
51
|
+
`reconcile_by_reference_code` is the EUR vIBAN reconciliation: a customer sends
|
|
52
|
+
EUR to a vIBAN including a reference code in the payment; this tool fetches the
|
|
53
|
+
account's vIBANs and matches the observed incoming bank transactions (operator-
|
|
54
|
+
or feed-supplied) to the vIBAN carrying that referenceCode. It returns the
|
|
55
|
+
matched vIBAN, the matched transactions, and the total amount.
|
|
56
|
+
|
|
57
|
+
### 2. Write tools (present, DISARMED by default)
|
|
58
|
+
|
|
59
|
+
`stage_transfer`, `approve_ramp_request`, `reject_ramp_request`,
|
|
60
|
+
`create_payment_link`.
|
|
61
|
+
|
|
62
|
+
Each is dry-run by default and returns the exact request it would send. See the
|
|
63
|
+
safety model below.
|
|
64
|
+
|
|
65
|
+
### 3. x402 tool (position + stub)
|
|
66
|
+
|
|
67
|
+
`quote_x402_payment` returns an HTTP-402-shaped quote (price, asset, payTo,
|
|
68
|
+
chain) for a settlement action, following the x402 `PaymentRequirements` model.
|
|
69
|
+
It documents the machine-to-machine rail. It never executes a payment, never
|
|
70
|
+
calls a facilitator, and never moves funds. Production x402 settlement needs a
|
|
71
|
+
facilitator decision and live rails.
|
|
72
|
+
|
|
73
|
+
## Safety model (fail closed)
|
|
74
|
+
|
|
75
|
+
Read-only is the default posture. A write tool executes a live call ONLY when
|
|
76
|
+
ALL THREE hold:
|
|
77
|
+
|
|
78
|
+
1. the tool argument `confirm === true`, and
|
|
79
|
+
2. the environment flag `VENLY_MCP_LIVE === "1"`, and
|
|
80
|
+
3. credentials are present (`VENLY_CLIENT_ID` and `VENLY_CLIENT_SECRET`).
|
|
81
|
+
|
|
82
|
+
If any leg is missing, the tool returns a dry-run object describing the request
|
|
83
|
+
it would have sent and never touches the transport. This is proven by
|
|
84
|
+
`test/write-tools.test.ts`, including the critical case: `confirm:true` with
|
|
85
|
+
`VENLY_MCP_LIVE` unset still dry-runs and does not call the live client.
|
|
86
|
+
|
|
87
|
+
Other invariants:
|
|
88
|
+
|
|
89
|
+
- Credentials are read from env, never logged, never returned in tool output.
|
|
90
|
+
- No tool broadens an allowlist, changes credentials, or deletes production data.
|
|
91
|
+
- Four-eyes approval semantics are preserved. The Fundflow API enforces that an
|
|
92
|
+
identity cannot approve a request it created; this server surfaces that state,
|
|
93
|
+
it does not bypass it. `approve_ramp_request` / `reject_ramp_request` carry the
|
|
94
|
+
optimistic-locking `version` (read it from `get_ramp_request`).
|
|
95
|
+
|
|
96
|
+
## Install, build, test
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
npm install
|
|
100
|
+
npm run build # tsc -> dist/, entry dist/index.js
|
|
101
|
+
npm test # node:test via tsx, mocked client, no network
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Run
|
|
105
|
+
|
|
106
|
+
The fastest path once published to npm - one line in any MCP client config:
|
|
107
|
+
|
|
108
|
+
```json
|
|
109
|
+
{
|
|
110
|
+
"mcpServers": {
|
|
111
|
+
"venly-settlement": {
|
|
112
|
+
"command": "npx",
|
|
113
|
+
"args": ["-y", "@venlyfinance/settlement-mcp"]
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Or from a clone:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
node dist/index.js
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The server speaks MCP over stdio. On start it logs to stderr whether writes are
|
|
126
|
+
armed or disarmed (stdout is the MCP channel, no credentials are logged).
|
|
127
|
+
|
|
128
|
+
MCP client config example:
|
|
129
|
+
|
|
130
|
+
```json
|
|
131
|
+
{
|
|
132
|
+
"mcpServers": {
|
|
133
|
+
"venly-settlement": {
|
|
134
|
+
"command": "node",
|
|
135
|
+
"args": ["/absolute/path/to/settlement-mcp/dist/index.js"],
|
|
136
|
+
"env": {
|
|
137
|
+
"VENLY_FINANCE_BASE_URL": "https://api-staging.venlyfinance.com/api/v1",
|
|
138
|
+
"VENLY_FUNDFLOW_BASE_URL": "https://api-fundflow-staging.venly.io"
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Point it at sandbox
|
|
146
|
+
|
|
147
|
+
Defaults already point at STAGING so an accidental run never touches production.
|
|
148
|
+
Override via env:
|
|
149
|
+
|
|
150
|
+
| Env var | Default (staging) |
|
|
151
|
+
|---|---|
|
|
152
|
+
| `VENLY_FINANCE_BASE_URL` | `https://api-staging.venlyfinance.com/api/v1` |
|
|
153
|
+
| `VENLY_FUNDFLOW_BASE_URL` | `https://api-fundflow-staging.venly.io` |
|
|
154
|
+
| `VENLY_TOKEN_URL` | `https://login.venly.io/auth/realms/VenlyFinance/protocol/openid-connect/token` |
|
|
155
|
+
| `VENLY_CLIENT_ID` | unset (read-only until set) |
|
|
156
|
+
| `VENLY_CLIENT_SECRET` | unset |
|
|
157
|
+
| `VENLY_MCP_LIVE` | unset (writes disarmed) |
|
|
158
|
+
|
|
159
|
+
Fundflow also exposes a QA sandbox (`https://api-fundflow-qa.venly.io`). If your
|
|
160
|
+
tenant uses different endpoints, override them via the env vars above.
|
|
161
|
+
|
|
162
|
+
## Enabling live writes (a deliberate operator decision)
|
|
163
|
+
|
|
164
|
+
Live writes are OFF. To arm them, the operator must:
|
|
165
|
+
|
|
166
|
+
1. set `VENLY_MCP_LIVE=1`, and
|
|
167
|
+
2. provide sandbox credentials `VENLY_CLIENT_ID` and `VENLY_CLIENT_SECRET`, and
|
|
168
|
+
3. pass `confirm: true` on the specific write tool call.
|
|
169
|
+
|
|
170
|
+
Until all three are set, every write tool dry-runs. Arming the flag and
|
|
171
|
+
provisioning credentials is a deliberate, human decision, not a default.
|
|
172
|
+
|
|
173
|
+
## x402 position
|
|
174
|
+
|
|
175
|
+
The machine-to-machine agent-payments rail is consolidating on x402 (Cloudflare
|
|
176
|
+
plus the Coinbase x402 Foundation; MCP tools return HTTP 402). Venly's stance:
|
|
177
|
+
the MCP is the human-gated operator surface; x402 is the machine-to-machine rail.
|
|
178
|
+
This ship states the position and ships a well-formed 402 quote stub. It does not
|
|
179
|
+
ship a production x402 settlement engine, that needs a facilitator decision and
|
|
180
|
+
live rails.
|
|
181
|
+
|
|
182
|
+
## Skills pack
|
|
183
|
+
|
|
184
|
+
MCP-consumable workflow docs under `skills/`:
|
|
185
|
+
|
|
186
|
+
- `reconcile-by-reference-code.md`
|
|
187
|
+
- `four-eyes-approval.md`
|
|
188
|
+
- `stage-and-confirm-transfer.md`
|
|
189
|
+
- `payment-link-lifecycle.md`
|
|
190
|
+
- `x402-quote-walkthrough.md`
|
|
191
|
+
|
|
192
|
+
## Layout
|
|
193
|
+
|
|
194
|
+
```
|
|
195
|
+
settlement-mcp/
|
|
196
|
+
package.json
|
|
197
|
+
tsconfig.json
|
|
198
|
+
README.md
|
|
199
|
+
src/
|
|
200
|
+
index.ts entry, stdio transport
|
|
201
|
+
server.ts createServer(client, env), registers all tiers
|
|
202
|
+
constants.ts
|
|
203
|
+
types.ts VenlyClient interface + domain types
|
|
204
|
+
safety.ts the write-gate (confirm + env + creds)
|
|
205
|
+
reconcile.ts pure reconciliation logic
|
|
206
|
+
client/
|
|
207
|
+
http-client.ts HttpVenlyClient (fetch, OAuth, vendored transport)
|
|
208
|
+
tools/
|
|
209
|
+
read-tools.ts tier 1
|
|
210
|
+
write-tools.ts tier 2 (fail closed)
|
|
211
|
+
x402-tools.ts tier 3 (stub)
|
|
212
|
+
skills/
|
|
213
|
+
reconcile-by-reference-code.md
|
|
214
|
+
four-eyes-approval.md
|
|
215
|
+
stage-and-confirm-transfer.md
|
|
216
|
+
payment-link-lifecycle.md
|
|
217
|
+
x402-quote-walkthrough.md
|
|
218
|
+
four-eyes-approval.md
|
|
219
|
+
stage-and-confirm-transfer.md
|
|
220
|
+
test/
|
|
221
|
+
helpers.ts in-memory MCP harness + mock client
|
|
222
|
+
read-tools.test.ts
|
|
223
|
+
write-tools.test.ts includes the critical fail-closed test
|
|
224
|
+
reconcile.test.ts
|
|
225
|
+
x402.test.ts
|
|
226
|
+
```
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HttpVenlyClient: a minimal fetch-based transport implementing VenlyClient.
|
|
3
|
+
*
|
|
4
|
+
* TRANSPORT NOTE
|
|
5
|
+
* --------------
|
|
6
|
+
* Minimal by design: OAuth2 client credentials, lazy token fetch, staging
|
|
7
|
+
* defaults. A future release replaces this with a thin adapter over
|
|
8
|
+
* `@venlyfinance/sdk` (single-flight token refresh, automatic idempotency
|
|
9
|
+
* keys, retry/backoff, richer errors) with no change to the tool interface.
|
|
10
|
+
*
|
|
11
|
+
* Safety invariants honored here:
|
|
12
|
+
* - credentials are read from env ONLY, never logged, never returned in output.
|
|
13
|
+
* - no request is issued at construction time; tokens are fetched lazily.
|
|
14
|
+
* - this class does not know about the write gate. It only issues a live call
|
|
15
|
+
* when a write method is invoked, and write methods are invoked only after
|
|
16
|
+
* the gate in safety.ts is armed. Read-only by default is enforced upstream.
|
|
17
|
+
*/
|
|
18
|
+
import type { Account, CreateFiatTransferInput, CreatePaymentLinkRequest, ListRampRequestsParams, OptimisticLockingBody, Party, PaymentLink, RampRequestDto, RampRequestListItem, Transfer, VenlyClient, VirtualBankAccount } from "../types.js";
|
|
19
|
+
export interface HttpVenlyClientConfig {
|
|
20
|
+
financeBaseUrl?: string;
|
|
21
|
+
fundflowBaseUrl?: string;
|
|
22
|
+
tokenUrl?: string;
|
|
23
|
+
clientId?: string;
|
|
24
|
+
clientSecret?: string;
|
|
25
|
+
/** Injectable for tests; defaults to global fetch. */
|
|
26
|
+
fetchImpl?: typeof fetch;
|
|
27
|
+
}
|
|
28
|
+
export declare class HttpVenlyClient implements VenlyClient {
|
|
29
|
+
private readonly financeBaseUrl;
|
|
30
|
+
private readonly fundflowBaseUrl;
|
|
31
|
+
private readonly tokenUrl;
|
|
32
|
+
private readonly clientId?;
|
|
33
|
+
private readonly clientSecret?;
|
|
34
|
+
private readonly fetchImpl;
|
|
35
|
+
private token;
|
|
36
|
+
constructor(config?: HttpVenlyClientConfig);
|
|
37
|
+
/** Build a client from environment variables. Never logs credentials. */
|
|
38
|
+
static fromEnv(env?: NodeJS.ProcessEnv): HttpVenlyClient;
|
|
39
|
+
private getAccessToken;
|
|
40
|
+
private request;
|
|
41
|
+
getAccount(accountId: string): Promise<Account>;
|
|
42
|
+
listVirtualBankAccounts(accountId: string): Promise<VirtualBankAccount[]>;
|
|
43
|
+
getTransfer(accountId: string, transferId: string): Promise<Transfer>;
|
|
44
|
+
listParties(params?: {
|
|
45
|
+
page?: number;
|
|
46
|
+
size?: number;
|
|
47
|
+
}): Promise<Party[]>;
|
|
48
|
+
listRampRequests(params?: ListRampRequestsParams): Promise<RampRequestListItem[]>;
|
|
49
|
+
getRampRequest(id: string): Promise<RampRequestDto>;
|
|
50
|
+
getSupportedChains(): Promise<unknown[]>;
|
|
51
|
+
getFiatCurrencies(): Promise<unknown[]>;
|
|
52
|
+
getCryptocurrencies(): Promise<unknown[]>;
|
|
53
|
+
getCompanyFees(): Promise<unknown>;
|
|
54
|
+
createFiatTransfer(senderAccountId: string, body: CreateFiatTransferInput): Promise<Transfer>;
|
|
55
|
+
approveRampRequest(id: string, body: OptimisticLockingBody): Promise<RampRequestDto>;
|
|
56
|
+
rejectRampRequest(id: string, body: OptimisticLockingBody): Promise<RampRequestDto>;
|
|
57
|
+
createPaymentLink(accountId: string, body: CreatePaymentLinkRequest): Promise<PaymentLink>;
|
|
58
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HttpVenlyClient: a minimal fetch-based transport implementing VenlyClient.
|
|
3
|
+
*
|
|
4
|
+
* TRANSPORT NOTE
|
|
5
|
+
* --------------
|
|
6
|
+
* Minimal by design: OAuth2 client credentials, lazy token fetch, staging
|
|
7
|
+
* defaults. A future release replaces this with a thin adapter over
|
|
8
|
+
* `@venlyfinance/sdk` (single-flight token refresh, automatic idempotency
|
|
9
|
+
* keys, retry/backoff, richer errors) with no change to the tool interface.
|
|
10
|
+
*
|
|
11
|
+
* Safety invariants honored here:
|
|
12
|
+
* - credentials are read from env ONLY, never logged, never returned in output.
|
|
13
|
+
* - no request is issued at construction time; tokens are fetched lazily.
|
|
14
|
+
* - this class does not know about the write gate. It only issues a live call
|
|
15
|
+
* when a write method is invoked, and write methods are invoked only after
|
|
16
|
+
* the gate in safety.ts is armed. Read-only by default is enforced upstream.
|
|
17
|
+
*/
|
|
18
|
+
import { DEFAULT_FINANCE_BASE_URL, DEFAULT_FUNDFLOW_BASE_URL, DEFAULT_TOKEN_URL, } from "../constants.js";
|
|
19
|
+
/** Unwrap the Venly `{ success, result, pagination }` envelope. */
|
|
20
|
+
function unwrap(payload) {
|
|
21
|
+
if (payload && typeof payload === "object" && "result" in payload) {
|
|
22
|
+
return payload.result;
|
|
23
|
+
}
|
|
24
|
+
return payload;
|
|
25
|
+
}
|
|
26
|
+
export class HttpVenlyClient {
|
|
27
|
+
financeBaseUrl;
|
|
28
|
+
fundflowBaseUrl;
|
|
29
|
+
tokenUrl;
|
|
30
|
+
clientId;
|
|
31
|
+
clientSecret;
|
|
32
|
+
fetchImpl;
|
|
33
|
+
token = null;
|
|
34
|
+
constructor(config = {}) {
|
|
35
|
+
this.financeBaseUrl = config.financeBaseUrl ?? DEFAULT_FINANCE_BASE_URL;
|
|
36
|
+
this.fundflowBaseUrl = config.fundflowBaseUrl ?? DEFAULT_FUNDFLOW_BASE_URL;
|
|
37
|
+
this.tokenUrl = config.tokenUrl ?? DEFAULT_TOKEN_URL;
|
|
38
|
+
this.clientId = config.clientId;
|
|
39
|
+
this.clientSecret = config.clientSecret;
|
|
40
|
+
this.fetchImpl = config.fetchImpl ?? fetch;
|
|
41
|
+
}
|
|
42
|
+
/** Build a client from environment variables. Never logs credentials. */
|
|
43
|
+
static fromEnv(env = process.env) {
|
|
44
|
+
return new HttpVenlyClient({
|
|
45
|
+
financeBaseUrl: env.VENLY_FINANCE_BASE_URL,
|
|
46
|
+
fundflowBaseUrl: env.VENLY_FUNDFLOW_BASE_URL,
|
|
47
|
+
tokenUrl: env.VENLY_TOKEN_URL,
|
|
48
|
+
clientId: env.VENLY_CLIENT_ID,
|
|
49
|
+
clientSecret: env.VENLY_CLIENT_SECRET,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
async getAccessToken() {
|
|
53
|
+
const now = Date.now();
|
|
54
|
+
if (this.token && this.token.expiresAt > now + 5_000) {
|
|
55
|
+
return this.token.accessToken;
|
|
56
|
+
}
|
|
57
|
+
if (!this.clientId || !this.clientSecret) {
|
|
58
|
+
// Do not include any credential material in the error.
|
|
59
|
+
throw new Error("Missing Venly credentials. Set VENLY_CLIENT_ID and VENLY_CLIENT_SECRET.");
|
|
60
|
+
}
|
|
61
|
+
const body = new URLSearchParams({
|
|
62
|
+
grant_type: "client_credentials",
|
|
63
|
+
client_id: this.clientId,
|
|
64
|
+
client_secret: this.clientSecret,
|
|
65
|
+
});
|
|
66
|
+
const res = await this.fetchImpl(this.tokenUrl, {
|
|
67
|
+
method: "POST",
|
|
68
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
69
|
+
body,
|
|
70
|
+
});
|
|
71
|
+
if (!res.ok) {
|
|
72
|
+
// Never echo the request body (contains the secret).
|
|
73
|
+
throw new Error(`Token request failed with status ${res.status}`);
|
|
74
|
+
}
|
|
75
|
+
const json = (await res.json());
|
|
76
|
+
const expiresInMs = (json.expires_in ?? 300) * 1000;
|
|
77
|
+
this.token = {
|
|
78
|
+
accessToken: json.access_token,
|
|
79
|
+
expiresAt: now + expiresInMs,
|
|
80
|
+
};
|
|
81
|
+
return json.access_token;
|
|
82
|
+
}
|
|
83
|
+
async request(base, method, path, opts = {}) {
|
|
84
|
+
const token = await this.getAccessToken();
|
|
85
|
+
const url = new URL(path.replace(/^\//, ""), base.endsWith("/") ? base : base + "/");
|
|
86
|
+
if (opts.query) {
|
|
87
|
+
for (const [k, v] of Object.entries(opts.query)) {
|
|
88
|
+
if (v !== undefined && v !== null)
|
|
89
|
+
url.searchParams.set(k, String(v));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const headers = {
|
|
93
|
+
Authorization: `Bearer ${token}`,
|
|
94
|
+
Accept: "application/json",
|
|
95
|
+
};
|
|
96
|
+
if (opts.body !== undefined) {
|
|
97
|
+
headers["Content-Type"] = "application/json";
|
|
98
|
+
// Idempotency for writes. Production SDK does this automatically.
|
|
99
|
+
headers["Idempotency-Key"] = crypto.randomUUID();
|
|
100
|
+
}
|
|
101
|
+
const res = await this.fetchImpl(url.toString(), {
|
|
102
|
+
method,
|
|
103
|
+
headers,
|
|
104
|
+
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
|
105
|
+
});
|
|
106
|
+
if (!res.ok) {
|
|
107
|
+
throw new Error(`Venly API ${method} ${path} failed with status ${res.status}`);
|
|
108
|
+
}
|
|
109
|
+
const json = await res.json();
|
|
110
|
+
return unwrap(json);
|
|
111
|
+
}
|
|
112
|
+
// ----- READ (finance) -----
|
|
113
|
+
getAccount(accountId) {
|
|
114
|
+
return this.request(this.financeBaseUrl, "GET", `/accounts/${accountId}`);
|
|
115
|
+
}
|
|
116
|
+
listVirtualBankAccounts(accountId) {
|
|
117
|
+
return this.request(this.financeBaseUrl, "GET", `/accounts/${accountId}/virtual-bank-accounts`);
|
|
118
|
+
}
|
|
119
|
+
getTransfer(accountId, transferId) {
|
|
120
|
+
return this.request(this.financeBaseUrl, "GET", `/accounts/${accountId}/transfers/${transferId}`);
|
|
121
|
+
}
|
|
122
|
+
listParties(params = {}) {
|
|
123
|
+
return this.request(this.financeBaseUrl, "GET", "/parties", { query: params });
|
|
124
|
+
}
|
|
125
|
+
// ----- READ (fundflow) -----
|
|
126
|
+
listRampRequests(params = {}) {
|
|
127
|
+
return this.request(this.fundflowBaseUrl, "GET", "/v1/ramp-requests", {
|
|
128
|
+
query: params,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
getRampRequest(id) {
|
|
132
|
+
return this.request(this.fundflowBaseUrl, "GET", `/v1/ramp-requests/${id}`);
|
|
133
|
+
}
|
|
134
|
+
getSupportedChains() {
|
|
135
|
+
return this.request(this.fundflowBaseUrl, "GET", "/v1/chains");
|
|
136
|
+
}
|
|
137
|
+
getFiatCurrencies() {
|
|
138
|
+
return this.request(this.fundflowBaseUrl, "GET", "/v1/fiat-currencies");
|
|
139
|
+
}
|
|
140
|
+
getCryptocurrencies() {
|
|
141
|
+
return this.request(this.fundflowBaseUrl, "GET", "/v1/crypto-currencies");
|
|
142
|
+
}
|
|
143
|
+
getCompanyFees() {
|
|
144
|
+
return this.request(this.fundflowBaseUrl, "GET", "/v1/fees");
|
|
145
|
+
}
|
|
146
|
+
// ----- WRITE -----
|
|
147
|
+
createFiatTransfer(senderAccountId, body) {
|
|
148
|
+
return this.request(this.financeBaseUrl, "POST", `/accounts/${senderAccountId}/transfers/fiat`, { body });
|
|
149
|
+
}
|
|
150
|
+
approveRampRequest(id, body) {
|
|
151
|
+
return this.request(this.fundflowBaseUrl, "POST", `/v1/ramp-requests/${id}/approve`, {
|
|
152
|
+
body,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
rejectRampRequest(id, body) {
|
|
156
|
+
return this.request(this.fundflowBaseUrl, "POST", `/v1/ramp-requests/${id}/reject`, {
|
|
157
|
+
body,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
createPaymentLink(accountId, body) {
|
|
161
|
+
return this.request(this.financeBaseUrl, "POST", `/accounts/${accountId}/fiat-to-crypto/payment-links`, { body });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +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. */
|
|
3
|
+
export declare const SERVER_NAME = "venly-settlement-mcp-server";
|
|
4
|
+
export declare const SERVER_VERSION = "0.1.0";
|
|
5
|
+
/** The env flag that must equal "1" for any write tool to execute live. */
|
|
6
|
+
export declare const LIVE_FLAG = "VENLY_MCP_LIVE";
|
|
7
|
+
/** Default base URLs (STAGING). Production values live in the vendored specs:
|
|
8
|
+
* finance https://api.venlyfinance.com/api/v1, fundflow https://api-fundflow.venly.io */
|
|
9
|
+
export declare const DEFAULT_FINANCE_BASE_URL = "https://api-staging.venlyfinance.com/api/v1";
|
|
10
|
+
export declare const DEFAULT_FUNDFLOW_BASE_URL = "https://api-fundflow-staging.venly.io";
|
|
11
|
+
export declare const DEFAULT_TOKEN_URL = "https://login.venly.io/auth/realms/VenlyFinance/protocol/openid-connect/token";
|
|
12
|
+
/** Cap on serialized tool output length to keep responses readable. */
|
|
13
|
+
export declare const CHARACTER_LIMIT = 30000;
|
|
@@ -0,0 +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. */
|
|
3
|
+
export const SERVER_NAME = "venly-settlement-mcp-server";
|
|
4
|
+
export const SERVER_VERSION = "0.1.0";
|
|
5
|
+
/** The env flag that must equal "1" for any write tool to execute live. */
|
|
6
|
+
export const LIVE_FLAG = "VENLY_MCP_LIVE";
|
|
7
|
+
/** Default base URLs (STAGING). Production values live in the vendored specs:
|
|
8
|
+
* finance https://api.venlyfinance.com/api/v1, fundflow https://api-fundflow.venly.io */
|
|
9
|
+
export const DEFAULT_FINANCE_BASE_URL = "https://api-staging.venlyfinance.com/api/v1";
|
|
10
|
+
export const DEFAULT_FUNDFLOW_BASE_URL = "https://api-fundflow-staging.venly.io";
|
|
11
|
+
export const DEFAULT_TOKEN_URL = "https://login.venly.io/auth/realms/VenlyFinance/protocol/openid-connect/token";
|
|
12
|
+
/** Cap on serialized tool output length to keep responses readable. */
|
|
13
|
+
export const CHARACTER_LIMIT = 30_000;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Entry point. Builds the server over a real HttpVenlyClient (from env) and
|
|
4
|
+
* connects it over stdio. Read-only by default; write tools stay disarmed
|
|
5
|
+
* unless VENLY_MCP_LIVE=1 and credentials are set.
|
|
6
|
+
*
|
|
7
|
+
* Credentials are read from env inside HttpVenlyClient and never logged.
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Entry point. Builds the server over a real HttpVenlyClient (from env) and
|
|
4
|
+
* connects it over stdio. Read-only by default; write tools stay disarmed
|
|
5
|
+
* unless VENLY_MCP_LIVE=1 and credentials are set.
|
|
6
|
+
*
|
|
7
|
+
* Credentials are read from env inside HttpVenlyClient and never logged.
|
|
8
|
+
*/
|
|
9
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
10
|
+
import { createServer } from "./server.js";
|
|
11
|
+
import { HttpVenlyClient } from "./client/http-client.js";
|
|
12
|
+
import { LIVE_FLAG } from "./constants.js";
|
|
13
|
+
async function main() {
|
|
14
|
+
const client = HttpVenlyClient.fromEnv();
|
|
15
|
+
const server = createServer({ client, env: process.env });
|
|
16
|
+
const transport = new StdioServerTransport();
|
|
17
|
+
await server.connect(transport);
|
|
18
|
+
// Log to stderr only (stdout is the MCP channel). No credentials here.
|
|
19
|
+
const armed = process.env[LIVE_FLAG] === "1";
|
|
20
|
+
process.stderr.write(`venly-settlement-mcp started. write tools ${armed ? "ARMED (VENLY_MCP_LIVE=1)" : "DISARMED (read-only default)"}.\n`);
|
|
21
|
+
}
|
|
22
|
+
main().catch((err) => {
|
|
23
|
+
process.stderr.write(`Fatal: ${err.message}\n`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reconciliation logic: match observed incoming bank transactions on a
|
|
3
|
+
* EUR vIBAN to the vIBAN whose `referenceCode` they carry.
|
|
4
|
+
*
|
|
5
|
+
* Kept pure (no I/O) so it is trivially testable. The read tool fetches the
|
|
6
|
+
* vIBANs via the VenlyClient and passes them in alongside the operator- or
|
|
7
|
+
* bank-feed-supplied transactions.
|
|
8
|
+
*/
|
|
9
|
+
import type { ObservedBankTransaction, VirtualBankAccount } from "./types.js";
|
|
10
|
+
export interface ReconcileResult {
|
|
11
|
+
referenceCode: string;
|
|
12
|
+
matched: boolean;
|
|
13
|
+
/** The vIBAN whose referenceCode equals the target, if any. */
|
|
14
|
+
virtualBankAccount: VirtualBankAccount | null;
|
|
15
|
+
/** Transactions carrying the target referenceCode. */
|
|
16
|
+
matchedTransactions: ObservedBankTransaction[];
|
|
17
|
+
/** Sum of matched transaction amounts. */
|
|
18
|
+
totalAmount: number;
|
|
19
|
+
/** Currency of the matched vIBAN (or first matched transaction). */
|
|
20
|
+
currency: string | null;
|
|
21
|
+
note: string;
|
|
22
|
+
}
|
|
23
|
+
export declare function reconcileByReferenceCode(referenceCode: string, virtualBankAccounts: VirtualBankAccount[], transactions: ObservedBankTransaction[]): ReconcileResult;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reconciliation logic: match observed incoming bank transactions on a
|
|
3
|
+
* EUR vIBAN to the vIBAN whose `referenceCode` they carry.
|
|
4
|
+
*
|
|
5
|
+
* Kept pure (no I/O) so it is trivially testable. The read tool fetches the
|
|
6
|
+
* vIBANs via the VenlyClient and passes them in alongside the operator- or
|
|
7
|
+
* bank-feed-supplied transactions.
|
|
8
|
+
*/
|
|
9
|
+
export function reconcileByReferenceCode(referenceCode, virtualBankAccounts, transactions) {
|
|
10
|
+
const target = referenceCode.trim();
|
|
11
|
+
const vban = virtualBankAccounts.find((v) => (v.referenceCode ?? "").trim() === target) ?? null;
|
|
12
|
+
const matchedTransactions = transactions.filter((t) => (t.referenceCode ?? "").trim() === target);
|
|
13
|
+
const totalAmount = matchedTransactions.reduce((sum, t) => sum + (Number.isFinite(t.amount) ? t.amount : 0), 0);
|
|
14
|
+
const currency = vban?.currency ?? matchedTransactions[0]?.currency ?? null;
|
|
15
|
+
const matched = vban !== null && matchedTransactions.length > 0;
|
|
16
|
+
let note;
|
|
17
|
+
if (matched) {
|
|
18
|
+
note = `Matched ${matchedTransactions.length} transaction(s) totalling ${totalAmount} ${currency ?? ""} to vIBAN ${vban?.id}.`;
|
|
19
|
+
}
|
|
20
|
+
else if (vban && matchedTransactions.length === 0) {
|
|
21
|
+
note = `vIBAN ${vban.id} carries referenceCode "${target}" but no supplied transaction references it. Awaiting funds.`;
|
|
22
|
+
}
|
|
23
|
+
else if (!vban && matchedTransactions.length > 0) {
|
|
24
|
+
note = `Transaction(s) reference "${target}" but no vIBAN on this account carries that referenceCode. Possible misdirected payment.`;
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
note = `No vIBAN and no transaction match referenceCode "${target}".`;
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
referenceCode: target,
|
|
31
|
+
matched,
|
|
32
|
+
virtualBankAccount: vban,
|
|
33
|
+
matchedTransactions,
|
|
34
|
+
totalAmount,
|
|
35
|
+
currency,
|
|
36
|
+
note,
|
|
37
|
+
};
|
|
38
|
+
}
|
package/dist/safety.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The write-tool safety gate. This is the core safety property of the server.
|
|
3
|
+
*
|
|
4
|
+
* A write tool executes a live call ONLY when ALL THREE hold:
|
|
5
|
+
* 1. the tool arg `confirm === true`
|
|
6
|
+
* 2. the env flag VENLY_MCP_LIVE === "1"
|
|
7
|
+
* 3. credentials are present (client id + secret in env)
|
|
8
|
+
*
|
|
9
|
+
* If ANY is missing the tool returns a dry-run object describing the exact
|
|
10
|
+
* request it WOULD have sent, and never calls the transport. Fail closed.
|
|
11
|
+
*/
|
|
12
|
+
export type EnvLike = Record<string, string | undefined>;
|
|
13
|
+
export interface GateDecision {
|
|
14
|
+
/** true only when confirm + armed env + creds all hold. */
|
|
15
|
+
armed: boolean;
|
|
16
|
+
confirm: boolean;
|
|
17
|
+
liveFlagArmed: boolean;
|
|
18
|
+
credentialsPresent: boolean;
|
|
19
|
+
/** Human-readable reasons a live call is blocked (empty when armed). */
|
|
20
|
+
blockedReasons: string[];
|
|
21
|
+
}
|
|
22
|
+
export declare function credentialsPresent(env: EnvLike): boolean;
|
|
23
|
+
export declare function evaluateWriteGate(confirm: boolean, env: EnvLike): GateDecision;
|
|
24
|
+
export interface DryRunRequest {
|
|
25
|
+
mode: "dry-run";
|
|
26
|
+
tool: string;
|
|
27
|
+
method: "GET" | "POST" | "PUT" | "DELETE";
|
|
28
|
+
/** Which API this maps to. */
|
|
29
|
+
api: "finance" | "fundflow";
|
|
30
|
+
path: string;
|
|
31
|
+
body?: unknown;
|
|
32
|
+
gate: GateDecision;
|
|
33
|
+
note: string;
|
|
34
|
+
}
|
|
35
|
+
export declare function buildDryRun(tool: string, method: DryRunRequest["method"], api: DryRunRequest["api"], path: string, body: unknown, gate: GateDecision): DryRunRequest;
|