@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/dist/safety.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
import { LIVE_FLAG } from "./constants.js";
|
|
13
|
+
export function credentialsPresent(env) {
|
|
14
|
+
return Boolean(env.VENLY_CLIENT_ID && env.VENLY_CLIENT_SECRET);
|
|
15
|
+
}
|
|
16
|
+
export function evaluateWriteGate(confirm, env) {
|
|
17
|
+
const liveFlagArmed = env[LIVE_FLAG] === "1";
|
|
18
|
+
const creds = credentialsPresent(env);
|
|
19
|
+
const blockedReasons = [];
|
|
20
|
+
if (!confirm)
|
|
21
|
+
blockedReasons.push("confirm arg is not true");
|
|
22
|
+
if (!liveFlagArmed)
|
|
23
|
+
blockedReasons.push(`${LIVE_FLAG} is not set to "1"`);
|
|
24
|
+
if (!creds)
|
|
25
|
+
blockedReasons.push("credentials are not present in env");
|
|
26
|
+
return {
|
|
27
|
+
armed: confirm && liveFlagArmed && creds,
|
|
28
|
+
confirm,
|
|
29
|
+
liveFlagArmed,
|
|
30
|
+
credentialsPresent: creds,
|
|
31
|
+
blockedReasons,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function buildDryRun(tool, method, api, path, body, gate) {
|
|
35
|
+
return {
|
|
36
|
+
mode: "dry-run",
|
|
37
|
+
tool,
|
|
38
|
+
method,
|
|
39
|
+
api,
|
|
40
|
+
path,
|
|
41
|
+
body,
|
|
42
|
+
gate,
|
|
43
|
+
note: "No live call was made. To execute, set confirm:true AND VENLY_MCP_LIVE=1 " +
|
|
44
|
+
"AND provide VENLY_CLIENT_ID / VENLY_CLIENT_SECRET.",
|
|
45
|
+
};
|
|
46
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the MCP server: registers all three tool tiers over an injected
|
|
3
|
+
* VenlyClient. Kept transport-agnostic so tests can construct the server with a
|
|
4
|
+
* mock client and no network.
|
|
5
|
+
*/
|
|
6
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
|
+
import type { VenlyClient } from "./types.js";
|
|
8
|
+
import type { EnvLike } from "./safety.js";
|
|
9
|
+
export interface CreateServerOptions {
|
|
10
|
+
client: VenlyClient;
|
|
11
|
+
/** Env used for the write gate. Defaults to process.env. */
|
|
12
|
+
env?: EnvLike;
|
|
13
|
+
}
|
|
14
|
+
export declare function createServer(options: CreateServerOptions): McpServer;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the MCP server: registers all three tool tiers over an injected
|
|
3
|
+
* VenlyClient. Kept transport-agnostic so tests can construct the server with a
|
|
4
|
+
* mock client and no network.
|
|
5
|
+
*/
|
|
6
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
|
+
import { SERVER_NAME, SERVER_VERSION } from "./constants.js";
|
|
8
|
+
import { registerReadTools } from "./tools/read-tools.js";
|
|
9
|
+
import { registerWriteTools } from "./tools/write-tools.js";
|
|
10
|
+
import { registerX402Tools } from "./tools/x402-tools.js";
|
|
11
|
+
export function createServer(options) {
|
|
12
|
+
const env = options.env ?? process.env;
|
|
13
|
+
const server = new McpServer({
|
|
14
|
+
name: SERVER_NAME,
|
|
15
|
+
version: SERVER_VERSION,
|
|
16
|
+
});
|
|
17
|
+
registerReadTools(server, options.client);
|
|
18
|
+
registerWriteTools(server, options.client, env);
|
|
19
|
+
registerX402Tools(server);
|
|
20
|
+
return server;
|
|
21
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier 1: READ tools. Always on. Call SDK/transport GETs only. No mutation.
|
|
3
|
+
*/
|
|
4
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import type { VenlyClient } from "../types.js";
|
|
6
|
+
export declare function registerReadTools(server: McpServer, client: VenlyClient): void;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier 1: READ tools. Always on. Call SDK/transport GETs only. No mutation.
|
|
3
|
+
*/
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { reconcileByReferenceCode } from "../reconcile.js";
|
|
6
|
+
/** Serialize a result as a text-content tool response. */
|
|
7
|
+
function jsonResult(data) {
|
|
8
|
+
return {
|
|
9
|
+
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
function errorResult(message) {
|
|
13
|
+
return {
|
|
14
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
15
|
+
isError: true,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
const READ_ONLY = {
|
|
19
|
+
readOnlyHint: true,
|
|
20
|
+
destructiveHint: false,
|
|
21
|
+
idempotentHint: true,
|
|
22
|
+
openWorldHint: true,
|
|
23
|
+
};
|
|
24
|
+
export function registerReadTools(server, client) {
|
|
25
|
+
server.registerTool("list_ramp_requests", {
|
|
26
|
+
title: "List ramp requests",
|
|
27
|
+
description: "List on-ramp / off-ramp requests (fundflow GET /v1/ramp-requests). " +
|
|
28
|
+
"Filter by rampType, status, date range, or paymentReference. Read-only.",
|
|
29
|
+
inputSchema: {
|
|
30
|
+
rampType: z.enum(["ON_RAMP", "OFF_RAMP"]).optional(),
|
|
31
|
+
status: z
|
|
32
|
+
.enum([
|
|
33
|
+
"AWAITING_APPROVAL",
|
|
34
|
+
"AWAITING_FUNDS",
|
|
35
|
+
"PROCESSING",
|
|
36
|
+
"SUCCEEDED",
|
|
37
|
+
"FAILED",
|
|
38
|
+
"BLOCKED",
|
|
39
|
+
"DENIED",
|
|
40
|
+
"REJECTED",
|
|
41
|
+
"CANCELLED",
|
|
42
|
+
])
|
|
43
|
+
.optional(),
|
|
44
|
+
fromDate: z.string().optional().describe("YYYY-MM-DD inclusive"),
|
|
45
|
+
toDate: z.string().optional().describe("YYYY-MM-DD inclusive"),
|
|
46
|
+
paymentReference: z.string().optional(),
|
|
47
|
+
page: z.number().int().min(1).optional(),
|
|
48
|
+
size: z.number().int().min(1).max(200).optional(),
|
|
49
|
+
},
|
|
50
|
+
annotations: READ_ONLY,
|
|
51
|
+
}, async (params) => {
|
|
52
|
+
try {
|
|
53
|
+
const result = await client.listRampRequests(params);
|
|
54
|
+
return jsonResult({ count: result.length, rampRequests: result });
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
return errorResult(e.message);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
server.registerTool("get_ramp_request", {
|
|
61
|
+
title: "Get ramp request",
|
|
62
|
+
description: "Fetch a single ramp request with full detail incl. status and four-eyes " +
|
|
63
|
+
"version (fundflow GET /v1/ramp-requests/{id}). Read-only.",
|
|
64
|
+
inputSchema: { id: z.string().describe("Ramp request UUID") },
|
|
65
|
+
annotations: READ_ONLY,
|
|
66
|
+
}, async ({ id }) => {
|
|
67
|
+
try {
|
|
68
|
+
return jsonResult(await client.getRampRequest(id));
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
return errorResult(e.message);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
server.registerTool("get_account", {
|
|
75
|
+
title: "Get account",
|
|
76
|
+
description: "Fetch a settlement account (finance GET /accounts/{accountId}). Read-only.",
|
|
77
|
+
inputSchema: { accountId: z.string().describe("Account UUID") },
|
|
78
|
+
annotations: READ_ONLY,
|
|
79
|
+
}, async ({ accountId }) => {
|
|
80
|
+
try {
|
|
81
|
+
return jsonResult(await client.getAccount(accountId));
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
return errorResult(e.message);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
server.registerTool("list_virtual_bank_accounts", {
|
|
88
|
+
title: "List virtual bank accounts",
|
|
89
|
+
description: "List the EUR vIBANs on an account, each with its reconciliation " +
|
|
90
|
+
"referenceCode (finance GET /accounts/{accountId}/virtual-bank-accounts). Read-only.",
|
|
91
|
+
inputSchema: { accountId: z.string().describe("Account UUID") },
|
|
92
|
+
annotations: READ_ONLY,
|
|
93
|
+
}, async ({ accountId }) => {
|
|
94
|
+
try {
|
|
95
|
+
const result = await client.listVirtualBankAccounts(accountId);
|
|
96
|
+
return jsonResult({ count: result.length, virtualBankAccounts: result });
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
return errorResult(e.message);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
server.registerTool("reconcile_by_reference_code", {
|
|
103
|
+
title: "Reconcile by referenceCode",
|
|
104
|
+
description: "Match observed incoming bank transactions on an account's EUR vIBANs to " +
|
|
105
|
+
"the vIBAN whose referenceCode they carry. Fetches the account's vIBANs " +
|
|
106
|
+
"(finance GET .../virtual-bank-accounts) and matches against the supplied " +
|
|
107
|
+
"transactions. Read-only, no mutation. Returns the matched vIBAN, matched " +
|
|
108
|
+
"transactions, and total amount.",
|
|
109
|
+
inputSchema: {
|
|
110
|
+
accountId: z.string().describe("Account UUID whose vIBANs to reconcile against"),
|
|
111
|
+
referenceCode: z.string().describe("The reference code to reconcile"),
|
|
112
|
+
transactions: z
|
|
113
|
+
.array(z.object({
|
|
114
|
+
referenceCode: z.string(),
|
|
115
|
+
amount: z.number(),
|
|
116
|
+
currency: z.string(),
|
|
117
|
+
remitterName: z.string().optional(),
|
|
118
|
+
valueDate: z.string().optional(),
|
|
119
|
+
bankTransactionId: z.string().optional(),
|
|
120
|
+
}))
|
|
121
|
+
.default([])
|
|
122
|
+
.describe("Observed incoming bank transactions (operator- or feed-supplied)"),
|
|
123
|
+
},
|
|
124
|
+
annotations: READ_ONLY,
|
|
125
|
+
}, async ({ accountId, referenceCode, transactions }) => {
|
|
126
|
+
try {
|
|
127
|
+
const vbans = await client.listVirtualBankAccounts(accountId);
|
|
128
|
+
const result = reconcileByReferenceCode(referenceCode, vbans, transactions);
|
|
129
|
+
return jsonResult(result);
|
|
130
|
+
}
|
|
131
|
+
catch (e) {
|
|
132
|
+
return errorResult(e.message);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
server.registerTool("get_transfer", {
|
|
136
|
+
title: "Get transfer",
|
|
137
|
+
description: "Fetch a transfer by id (finance GET /accounts/{accountId}/transfers/{transferId}). Read-only.",
|
|
138
|
+
inputSchema: {
|
|
139
|
+
accountId: z.string().describe("Account UUID"),
|
|
140
|
+
transferId: z.string().describe("Transfer UUID"),
|
|
141
|
+
},
|
|
142
|
+
annotations: READ_ONLY,
|
|
143
|
+
}, async ({ accountId, transferId }) => {
|
|
144
|
+
try {
|
|
145
|
+
return jsonResult(await client.getTransfer(accountId, transferId));
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
return errorResult(e.message);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
server.registerTool("list_parties", {
|
|
152
|
+
title: "List parties",
|
|
153
|
+
description: "List parties (individuals and organisations) (finance GET /parties). Read-only.",
|
|
154
|
+
inputSchema: {
|
|
155
|
+
page: z.number().int().min(1).optional(),
|
|
156
|
+
size: z.number().int().min(1).max(200).optional(),
|
|
157
|
+
},
|
|
158
|
+
annotations: READ_ONLY,
|
|
159
|
+
}, async (params) => {
|
|
160
|
+
try {
|
|
161
|
+
const result = await client.listParties(params);
|
|
162
|
+
return jsonResult({ count: result.length, parties: result });
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
return errorResult(e.message);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
server.registerTool("get_reference_data", {
|
|
169
|
+
title: "Get reference data",
|
|
170
|
+
description: "Fetch settlement reference data: supported chains, fiat currencies, " +
|
|
171
|
+
"cryptocurrencies, and company fees (fundflow GETs). Read-only. Choose one " +
|
|
172
|
+
"dataset or 'all'.",
|
|
173
|
+
inputSchema: {
|
|
174
|
+
dataset: z
|
|
175
|
+
.enum(["chains", "fiat_currencies", "cryptocurrencies", "fees", "all"])
|
|
176
|
+
.default("all"),
|
|
177
|
+
},
|
|
178
|
+
annotations: READ_ONLY,
|
|
179
|
+
}, async ({ dataset }) => {
|
|
180
|
+
try {
|
|
181
|
+
const out = {};
|
|
182
|
+
if (dataset === "chains" || dataset === "all")
|
|
183
|
+
out.chains = await client.getSupportedChains();
|
|
184
|
+
if (dataset === "fiat_currencies" || dataset === "all")
|
|
185
|
+
out.fiatCurrencies = await client.getFiatCurrencies();
|
|
186
|
+
if (dataset === "cryptocurrencies" || dataset === "all")
|
|
187
|
+
out.cryptocurrencies = await client.getCryptocurrencies();
|
|
188
|
+
if (dataset === "fees" || dataset === "all")
|
|
189
|
+
out.fees = await client.getCompanyFees();
|
|
190
|
+
return jsonResult(out);
|
|
191
|
+
}
|
|
192
|
+
catch (e) {
|
|
193
|
+
return errorResult(e.message);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier 2: WRITE tools. Present but DISARMED by default.
|
|
3
|
+
*
|
|
4
|
+
* Every write tool is dry-run UNLESS all three hold: confirm===true AND
|
|
5
|
+
* VENLY_MCP_LIVE==="1" AND credentials present (see safety.ts). When not armed
|
|
6
|
+
* it returns the exact request it WOULD send and never touches the transport.
|
|
7
|
+
* This is the core safety property, proven by test/write-tools.test.ts.
|
|
8
|
+
*/
|
|
9
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
|
+
import type { VenlyClient } from "../types.js";
|
|
11
|
+
import { type EnvLike } from "../safety.js";
|
|
12
|
+
export declare function registerWriteTools(server: McpServer, client: VenlyClient, env: EnvLike): void;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier 2: WRITE tools. Present but DISARMED by default.
|
|
3
|
+
*
|
|
4
|
+
* Every write tool is dry-run UNLESS all three hold: confirm===true AND
|
|
5
|
+
* VENLY_MCP_LIVE==="1" AND credentials present (see safety.ts). When not armed
|
|
6
|
+
* it returns the exact request it WOULD send and never touches the transport.
|
|
7
|
+
* This is the core safety property, proven by test/write-tools.test.ts.
|
|
8
|
+
*/
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { buildDryRun, evaluateWriteGate } from "../safety.js";
|
|
11
|
+
function jsonResult(data) {
|
|
12
|
+
return {
|
|
13
|
+
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function errorResult(message) {
|
|
17
|
+
return {
|
|
18
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
19
|
+
isError: true,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
const WRITE_ANNOTATIONS = {
|
|
23
|
+
readOnlyHint: false,
|
|
24
|
+
destructiveHint: false,
|
|
25
|
+
idempotentHint: false,
|
|
26
|
+
openWorldHint: true,
|
|
27
|
+
};
|
|
28
|
+
const confirmField = z
|
|
29
|
+
.boolean()
|
|
30
|
+
.default(false)
|
|
31
|
+
.describe("Must be true to attempt a live call. Even then, VENLY_MCP_LIVE=1 and " +
|
|
32
|
+
"credentials are also required, otherwise the tool dry-runs.");
|
|
33
|
+
export function registerWriteTools(server, client, env) {
|
|
34
|
+
server.registerTool("stage_transfer", {
|
|
35
|
+
title: "Stage a fiat transfer (dry-run by default)",
|
|
36
|
+
description: "Stage a fiat-to-crypto transfer (finance POST /accounts/{senderAccountId}/transfers/fiat). " +
|
|
37
|
+
"DISARMED by default: returns the exact request it would send unless " +
|
|
38
|
+
"confirm:true AND VENLY_MCP_LIVE=1 AND credentials are present.",
|
|
39
|
+
inputSchema: {
|
|
40
|
+
senderAccountId: z.string().describe("Account initiating the transfer"),
|
|
41
|
+
receiverAccountId: z.string(),
|
|
42
|
+
fiatAmount: z.string().describe("Decimal string, e.g. \"1000.00\""),
|
|
43
|
+
fiatCurrency: z.string().describe("e.g. EUR"),
|
|
44
|
+
cryptocurrency: z.string().optional(),
|
|
45
|
+
description: z.string().optional(),
|
|
46
|
+
merchantReference: z.string().optional(),
|
|
47
|
+
confirm: confirmField,
|
|
48
|
+
},
|
|
49
|
+
annotations: WRITE_ANNOTATIONS,
|
|
50
|
+
}, async ({ senderAccountId, confirm, ...rest }) => {
|
|
51
|
+
const gate = evaluateWriteGate(confirm, env);
|
|
52
|
+
const body = {
|
|
53
|
+
receiverAccountId: rest.receiverAccountId,
|
|
54
|
+
fiatAmount: rest.fiatAmount,
|
|
55
|
+
fiatCurrency: rest.fiatCurrency,
|
|
56
|
+
cryptocurrency: rest.cryptocurrency,
|
|
57
|
+
description: rest.description,
|
|
58
|
+
merchantReference: rest.merchantReference,
|
|
59
|
+
};
|
|
60
|
+
if (!gate.armed) {
|
|
61
|
+
return jsonResult(buildDryRun("stage_transfer", "POST", "finance", `/accounts/${senderAccountId}/transfers/fiat`, body, gate));
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
const result = await client.createFiatTransfer(senderAccountId, body);
|
|
65
|
+
return jsonResult({ mode: "live", result });
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
return errorResult(e.message);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
server.registerTool("approve_ramp_request", {
|
|
72
|
+
title: "Approve a ramp request (dry-run by default)",
|
|
73
|
+
description: "Approve a ramp request through four-eyes (fundflow POST /v1/ramp-requests/{id}/approve). " +
|
|
74
|
+
"Requires the current optimistic-locking version. The API enforces that an " +
|
|
75
|
+
"identity cannot approve a request it created; this tool surfaces that state, " +
|
|
76
|
+
"it does not bypass it. DISARMED by default.",
|
|
77
|
+
inputSchema: {
|
|
78
|
+
id: z.string().describe("Ramp request UUID"),
|
|
79
|
+
version: z
|
|
80
|
+
.number()
|
|
81
|
+
.int()
|
|
82
|
+
.describe("Current version (from get_ramp_request) for optimistic locking"),
|
|
83
|
+
confirm: confirmField,
|
|
84
|
+
},
|
|
85
|
+
annotations: WRITE_ANNOTATIONS,
|
|
86
|
+
}, async ({ id, version, confirm }) => {
|
|
87
|
+
const gate = evaluateWriteGate(confirm, env);
|
|
88
|
+
const body = { version };
|
|
89
|
+
if (!gate.armed) {
|
|
90
|
+
return jsonResult(buildDryRun("approve_ramp_request", "POST", "fundflow", `/v1/ramp-requests/${id}/approve`, body, gate));
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const result = await client.approveRampRequest(id, body);
|
|
94
|
+
return jsonResult({ mode: "live", result });
|
|
95
|
+
}
|
|
96
|
+
catch (e) {
|
|
97
|
+
return errorResult(e.message);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
server.registerTool("reject_ramp_request", {
|
|
101
|
+
title: "Reject a ramp request (dry-run by default)",
|
|
102
|
+
description: "Reject a ramp request (fundflow POST /v1/ramp-requests/{id}/reject). " +
|
|
103
|
+
"Requires the current optimistic-locking version. DISARMED by default.",
|
|
104
|
+
inputSchema: {
|
|
105
|
+
id: z.string().describe("Ramp request UUID"),
|
|
106
|
+
version: z
|
|
107
|
+
.number()
|
|
108
|
+
.int()
|
|
109
|
+
.describe("Current version (from get_ramp_request) for optimistic locking"),
|
|
110
|
+
confirm: confirmField,
|
|
111
|
+
},
|
|
112
|
+
annotations: WRITE_ANNOTATIONS,
|
|
113
|
+
}, async ({ id, version, confirm }) => {
|
|
114
|
+
const gate = evaluateWriteGate(confirm, env);
|
|
115
|
+
const body = { version };
|
|
116
|
+
if (!gate.armed) {
|
|
117
|
+
return jsonResult(buildDryRun("reject_ramp_request", "POST", "fundflow", `/v1/ramp-requests/${id}/reject`, body, gate));
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
const result = await client.rejectRampRequest(id, body);
|
|
121
|
+
return jsonResult({ mode: "live", result });
|
|
122
|
+
}
|
|
123
|
+
catch (e) {
|
|
124
|
+
return errorResult(e.message);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
server.registerTool("create_payment_link", {
|
|
128
|
+
title: "Create a fiat-to-crypto payment link (dry-run by default)",
|
|
129
|
+
description: "Create a pay-in link (finance POST /accounts/{accountId}/fiat-to-crypto/payment-links). " +
|
|
130
|
+
"DISARMED by default.",
|
|
131
|
+
inputSchema: {
|
|
132
|
+
accountId: z.string(),
|
|
133
|
+
inAmount: z.string().describe("Decimal string, e.g. \"250.00\""),
|
|
134
|
+
inCurrency: z.string().describe("e.g. EUR"),
|
|
135
|
+
outCryptocurrency: z.string().optional().describe("e.g. USDC"),
|
|
136
|
+
redirectUrl: z.string().optional(),
|
|
137
|
+
externalRef: z.string().optional(),
|
|
138
|
+
confirm: confirmField,
|
|
139
|
+
},
|
|
140
|
+
annotations: WRITE_ANNOTATIONS,
|
|
141
|
+
}, async ({ accountId, confirm, ...rest }) => {
|
|
142
|
+
const gate = evaluateWriteGate(confirm, env);
|
|
143
|
+
const body = {
|
|
144
|
+
inAmount: rest.inAmount,
|
|
145
|
+
inCurrency: rest.inCurrency,
|
|
146
|
+
outCryptocurrency: rest.outCryptocurrency,
|
|
147
|
+
redirectUrl: rest.redirectUrl,
|
|
148
|
+
externalRef: rest.externalRef,
|
|
149
|
+
};
|
|
150
|
+
if (!gate.armed) {
|
|
151
|
+
return jsonResult(buildDryRun("create_payment_link", "POST", "finance", `/accounts/${accountId}/fiat-to-crypto/payment-links`, body, gate));
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
const result = await client.createPaymentLink(accountId, body);
|
|
155
|
+
return jsonResult({ mode: "live", result });
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
return errorResult(e.message);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier 3: x402 tool. Position + stub, no execution.
|
|
3
|
+
*
|
|
4
|
+
* The machine-to-machine agent-payments rail is consolidating on x402
|
|
5
|
+
* (Cloudflare + Coinbase x402 Foundation; MCP tools return HTTP 402). This tool
|
|
6
|
+
* returns an HTTP-402-shaped quote for a settlement action, documenting the rail
|
|
7
|
+
* without executing it. It NEVER moves funds and NEVER calls a facilitator.
|
|
8
|
+
*
|
|
9
|
+
* Shape follows the x402 `PaymentRequirements` model: a 402 response carrying an
|
|
10
|
+
* `accepts` array of payment options (scheme, network, asset, payTo, amount).
|
|
11
|
+
*/
|
|
12
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
13
|
+
export declare function registerX402Tools(server: McpServer): void;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier 3: x402 tool. Position + stub, no execution.
|
|
3
|
+
*
|
|
4
|
+
* The machine-to-machine agent-payments rail is consolidating on x402
|
|
5
|
+
* (Cloudflare + Coinbase x402 Foundation; MCP tools return HTTP 402). This tool
|
|
6
|
+
* returns an HTTP-402-shaped quote for a settlement action, documenting the rail
|
|
7
|
+
* without executing it. It NEVER moves funds and NEVER calls a facilitator.
|
|
8
|
+
*
|
|
9
|
+
* Shape follows the x402 `PaymentRequirements` model: a 402 response carrying an
|
|
10
|
+
* `accepts` array of payment options (scheme, network, asset, payTo, amount).
|
|
11
|
+
*/
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
function jsonResult(data) {
|
|
14
|
+
return {
|
|
15
|
+
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/** Minimal chain -> x402 network + default USDC asset address map (stub data). */
|
|
19
|
+
const CHAIN_META = {
|
|
20
|
+
base: {
|
|
21
|
+
network: "base",
|
|
22
|
+
usdc: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
23
|
+
},
|
|
24
|
+
"base-sepolia": {
|
|
25
|
+
network: "base-sepolia",
|
|
26
|
+
usdc: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
|
|
27
|
+
},
|
|
28
|
+
polygon: {
|
|
29
|
+
network: "polygon",
|
|
30
|
+
usdc: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359",
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
export function registerX402Tools(server) {
|
|
34
|
+
server.registerTool("quote_x402_payment", {
|
|
35
|
+
title: "Quote an x402 machine payment (stub)",
|
|
36
|
+
description: "Return an HTTP-402-shaped quote (price, asset, payTo, chain) for a " +
|
|
37
|
+
"settlement action on the x402 machine-to-machine rail. STUB: documents " +
|
|
38
|
+
"the rail and returns a well-formed 402 PaymentRequirements object. It does " +
|
|
39
|
+
"NOT execute a payment, call a facilitator, or move funds. Production x402 " +
|
|
40
|
+
"settlement needs a facilitator decision and live rails.",
|
|
41
|
+
inputSchema: {
|
|
42
|
+
action: z
|
|
43
|
+
.string()
|
|
44
|
+
.describe("The settlement action being priced, e.g. 'stage_transfer' or 'reconcile'"),
|
|
45
|
+
amount: z.string().describe("Price as a decimal string, e.g. \"1.50\""),
|
|
46
|
+
asset: z.string().default("USDC").describe("Settlement asset symbol"),
|
|
47
|
+
chain: z
|
|
48
|
+
.enum(["base", "base-sepolia", "polygon"])
|
|
49
|
+
.default("base")
|
|
50
|
+
.describe("Settlement chain"),
|
|
51
|
+
payTo: z
|
|
52
|
+
.string()
|
|
53
|
+
.describe("Recipient address that would receive the machine payment"),
|
|
54
|
+
resource: z
|
|
55
|
+
.string()
|
|
56
|
+
.optional()
|
|
57
|
+
.describe("Optional resource/endpoint the payment unlocks"),
|
|
58
|
+
description: z.string().optional(),
|
|
59
|
+
},
|
|
60
|
+
annotations: {
|
|
61
|
+
readOnlyHint: true,
|
|
62
|
+
destructiveHint: false,
|
|
63
|
+
idempotentHint: true,
|
|
64
|
+
openWorldHint: false,
|
|
65
|
+
},
|
|
66
|
+
}, async ({ action, amount, asset, chain, payTo, resource, description }) => {
|
|
67
|
+
const meta = CHAIN_META[chain] ?? CHAIN_META.base;
|
|
68
|
+
const quote = {
|
|
69
|
+
mode: "stub",
|
|
70
|
+
httpStatus: 402,
|
|
71
|
+
error: "payment_required",
|
|
72
|
+
x402Version: 1,
|
|
73
|
+
action,
|
|
74
|
+
accepts: [
|
|
75
|
+
{
|
|
76
|
+
scheme: "exact",
|
|
77
|
+
network: meta.network,
|
|
78
|
+
asset,
|
|
79
|
+
// For USDC the canonical on-chain asset address for the network.
|
|
80
|
+
assetAddress: asset.toUpperCase() === "USDC" ? meta.usdc : undefined,
|
|
81
|
+
maxAmountRequired: amount,
|
|
82
|
+
payTo,
|
|
83
|
+
resource: resource ?? `venly-settlement:${action}`,
|
|
84
|
+
description: description ?? `x402 quote for settlement action '${action}' (stub, not executable).`,
|
|
85
|
+
mimeType: "application/json",
|
|
86
|
+
maxTimeoutSeconds: 60,
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
note: "This is a position stub. No payment is executed and no facilitator is " +
|
|
90
|
+
"called. Venly's stance: the MCP is the human-gated operator surface; " +
|
|
91
|
+
"x402 is the machine-to-machine rail.",
|
|
92
|
+
};
|
|
93
|
+
return jsonResult(quote);
|
|
94
|
+
});
|
|
95
|
+
}
|