@venlyfinance/settlement-mcp 0.5.0 → 0.7.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/AGENTS.md +35 -0
- package/CHANGELOG.md +53 -2
- package/README.md +63 -7
- package/dist/constants.d.ts +2 -1
- package/dist/constants.js +2 -1
- package/dist/frontend.d.ts +21 -1
- package/dist/frontend.js +677 -25
- package/dist/index.js +32 -4
- package/dist/review-cli.d.ts +4 -0
- package/dist/review-cli.js +131 -0
- package/dist/server.js +3 -1
- package/dist/staging-smoke.d.ts +1 -1
- package/dist/staging-smoke.js +1 -0
- package/dist/verify-cli.d.ts +24 -0
- package/dist/verify-cli.js +371 -0
- package/package.json +6 -4
package/dist/index.js
CHANGED
|
@@ -27,7 +27,35 @@ async function main() {
|
|
|
27
27
|
: "writes DISARMED: mutations return dry-run previews (arming needs confirm:true + VENLY_MCP_LIVE=1 + credentials)";
|
|
28
28
|
process.stderr.write(`venly-finance-mcp started in ${client.environment}. ${writeState}.\n`);
|
|
29
29
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
const argv = process.argv.slice(2);
|
|
31
|
+
if (argv[0] === "review") {
|
|
32
|
+
// Design-audit CLI mode: `... review "src/**/*.tsx"`. Dynamic import so the
|
|
33
|
+
// MCP/SDK path is never touched; MCP hosts launch with zero args, so plain
|
|
34
|
+
// startup is unchanged.
|
|
35
|
+
import("./review-cli.js")
|
|
36
|
+
.then(({ runReviewCli }) => runReviewCli(argv.slice(1)))
|
|
37
|
+
.then((code) => {
|
|
38
|
+
process.exitCode = code;
|
|
39
|
+
})
|
|
40
|
+
.catch((err) => {
|
|
41
|
+
process.stderr.write(`Fatal: ${err.message}\n`);
|
|
42
|
+
process.exit(2);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
else if (argv[0] === "verify") {
|
|
46
|
+
import("./verify-cli.js")
|
|
47
|
+
.then(({ runVerifyCli }) => runVerifyCli(argv.slice(1)))
|
|
48
|
+
.then((code) => {
|
|
49
|
+
process.exitCode = code;
|
|
50
|
+
})
|
|
51
|
+
.catch((err) => {
|
|
52
|
+
process.stderr.write(`Fatal: ${err.message}\n`);
|
|
53
|
+
process.exit(2);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
main().catch((err) => {
|
|
58
|
+
process.stderr.write(`Fatal: ${err.message}\n`);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Expand one glob pattern (posix-style separators) relative to cwd. */
|
|
2
|
+
export declare function expandPattern(pattern: string, cwd: string): string[];
|
|
3
|
+
export declare function expandPatterns(patterns: string[], cwd: string): string[];
|
|
4
|
+
export declare function runReviewCli(args: string[], out?: NodeJS.WritableStream, err?: NodeJS.WritableStream): Promise<0 | 1 | 2>;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// `review` subcommand: the review_screen design audit as a CI gate.
|
|
2
|
+
//
|
|
3
|
+
// npx @venlyfinance/settlement-mcp review "src/**/*.tsx"
|
|
4
|
+
//
|
|
5
|
+
// Exit codes: 0 clean (warnings allowed, printed either way) · 1 at least one
|
|
6
|
+
// error-severity finding · 2 usage error or a pattern that matched nothing
|
|
7
|
+
// (a typo'd path must never pass CI silently).
|
|
8
|
+
//
|
|
9
|
+
// Patterns are self-expanded (**, *, {a,b}) so the quoted form works on any
|
|
10
|
+
// shell; unquoted shell-expanded literal paths work too. No dependencies.
|
|
11
|
+
// (Line comments on purpose: a glob's **/ would terminate a block comment.)
|
|
12
|
+
import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
|
|
13
|
+
import { join, relative } from "node:path";
|
|
14
|
+
import { reviewScreenSource } from "./frontend.js";
|
|
15
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", "dist"]);
|
|
16
|
+
function braceExpand(pattern) {
|
|
17
|
+
const m = /\{([^{}]*)\}/.exec(pattern);
|
|
18
|
+
if (!m)
|
|
19
|
+
return [pattern];
|
|
20
|
+
const before = pattern.slice(0, m.index);
|
|
21
|
+
const after = pattern.slice(m.index + m[0].length);
|
|
22
|
+
const out = [];
|
|
23
|
+
for (const option of m[1].split(",")) {
|
|
24
|
+
out.push(...braceExpand(before + option + after));
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
function patternToRegExp(pattern) {
|
|
29
|
+
// Escape everything regex-special except the glob characters we translate.
|
|
30
|
+
const escaped = pattern.replace(/[.+^$()|[\]\\?]/g, "\\$&");
|
|
31
|
+
const translated = escaped
|
|
32
|
+
.replace(/\*\*\//g, "\u0000") // **/ may match zero segments
|
|
33
|
+
.replace(/\*\*/g, "\u0001") // a bare ** matches anything
|
|
34
|
+
.replace(/\*/g, "[^/]*")
|
|
35
|
+
.replace(/\u0000/g, "(?:.*/)?")
|
|
36
|
+
.replace(/\u0001/g, ".*");
|
|
37
|
+
return new RegExp(`^${translated}$`);
|
|
38
|
+
}
|
|
39
|
+
function walk(dir, into) {
|
|
40
|
+
let entries;
|
|
41
|
+
try {
|
|
42
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
for (const entry of entries) {
|
|
48
|
+
if (entry.isDirectory()) {
|
|
49
|
+
if (!SKIP_DIRS.has(entry.name))
|
|
50
|
+
walk(join(dir, entry.name), into);
|
|
51
|
+
}
|
|
52
|
+
else if (entry.isFile()) {
|
|
53
|
+
into.push(join(dir, entry.name));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Expand one glob pattern (posix-style separators) relative to cwd. */
|
|
58
|
+
export function expandPattern(pattern, cwd) {
|
|
59
|
+
const results = [];
|
|
60
|
+
for (const variant of braceExpand(pattern)) {
|
|
61
|
+
const segments = variant.split("/");
|
|
62
|
+
const firstWild = segments.findIndex((s) => s.includes("*"));
|
|
63
|
+
if (firstWild === -1) {
|
|
64
|
+
if (existsSync(join(cwd, variant)) && statSync(join(cwd, variant)).isFile()) {
|
|
65
|
+
results.push(variant);
|
|
66
|
+
}
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const staticPrefix = segments.slice(0, firstWild).join("/");
|
|
70
|
+
const root = staticPrefix ? join(cwd, staticPrefix) : cwd;
|
|
71
|
+
const files = [];
|
|
72
|
+
walk(root, files);
|
|
73
|
+
const matcher = patternToRegExp(variant);
|
|
74
|
+
for (const file of files) {
|
|
75
|
+
// relative(), not string slicing: a pattern like "../ui/**/*.tsx"
|
|
76
|
+
// walks outside cwd, where prefix slicing produces garbage.
|
|
77
|
+
const rel = relative(cwd, file).split("\\").join("/");
|
|
78
|
+
if (matcher.test(rel))
|
|
79
|
+
results.push(rel);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return [...new Set(results)].sort();
|
|
83
|
+
}
|
|
84
|
+
export function expandPatterns(patterns, cwd) {
|
|
85
|
+
const out = [];
|
|
86
|
+
for (const pattern of patterns) {
|
|
87
|
+
if (/[*{]/.test(pattern)) {
|
|
88
|
+
out.push(...expandPattern(pattern.split("\\").join("/"), cwd));
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
out.push(pattern); // shell-expanded or literal; existence checked by caller
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return [...new Set(out)];
|
|
95
|
+
}
|
|
96
|
+
export async function runReviewCli(args, out = process.stdout, err = process.stderr) {
|
|
97
|
+
const patterns = args.filter((a) => !a.startsWith("-"));
|
|
98
|
+
if (patterns.length === 0) {
|
|
99
|
+
err.write('Usage: review "<glob>" [more globs or files]\n' +
|
|
100
|
+
' e.g. review "src/**/*.tsx"\n' +
|
|
101
|
+
"Exits 1 on any error-severity finding, 2 when nothing matched.\n");
|
|
102
|
+
return 2;
|
|
103
|
+
}
|
|
104
|
+
const cwd = process.cwd();
|
|
105
|
+
const files = expandPatterns(patterns, cwd);
|
|
106
|
+
const missing = files.filter((f) => !existsSync(f));
|
|
107
|
+
if (missing.length > 0) {
|
|
108
|
+
err.write(`No such file: ${missing.join(", ")}\n`);
|
|
109
|
+
return 2;
|
|
110
|
+
}
|
|
111
|
+
if (files.length === 0) {
|
|
112
|
+
err.write(`Nothing matched: ${patterns.join(" ")}\n`);
|
|
113
|
+
return 2;
|
|
114
|
+
}
|
|
115
|
+
let errors = 0;
|
|
116
|
+
let warnings = 0;
|
|
117
|
+
for (const file of files) {
|
|
118
|
+
const findings = reviewScreenSource(readFileSync(file, "utf8"));
|
|
119
|
+
for (const finding of findings) {
|
|
120
|
+
if (finding.severity === "error")
|
|
121
|
+
errors++;
|
|
122
|
+
else
|
|
123
|
+
warnings++;
|
|
124
|
+
const line = finding.line === undefined ? "" : `:${finding.line}`;
|
|
125
|
+
out.write(`${file}${line} ${finding.severity} ${finding.rule} ${finding.evidence}\n`);
|
|
126
|
+
out.write(` fix: ${finding.fix}\n`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
out.write(`${errors} error(s), ${warnings} warning(s) across ${files.length} file(s)\n`);
|
|
130
|
+
return errors > 0 ? 1 : 0;
|
|
131
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* mock client and no network.
|
|
5
5
|
*/
|
|
6
6
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
|
-
import { ENVIRONMENT_FLAG, SERVER_NAME, SERVER_VERSION, resolveVenlyEnvironment, } from "./constants.js";
|
|
7
|
+
import { ENVIRONMENT_FLAG, INSTRUCTIONS, SERVER_NAME, SERVER_VERSION, resolveVenlyEnvironment, } from "./constants.js";
|
|
8
8
|
import { registerReadTools } from "./tools/read-tools.js";
|
|
9
9
|
import { registerWriteTools } from "./tools/write-tools.js";
|
|
10
10
|
import { registerX402Tools } from "./tools/x402-tools.js";
|
|
@@ -26,6 +26,8 @@ export function createServer(options) {
|
|
|
26
26
|
const server = new McpServer({
|
|
27
27
|
name: SERVER_NAME,
|
|
28
28
|
version: SERVER_VERSION,
|
|
29
|
+
}, {
|
|
30
|
+
instructions: INSTRUCTIONS,
|
|
29
31
|
});
|
|
30
32
|
registerReadTools(server, options.client);
|
|
31
33
|
registerWriteTools(server, options.client, env);
|
package/dist/staging-smoke.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
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", "list_payouts", "get_payout", "list_payout_routes", "list_payout_bank_accounts", "register_payout_bank_account", "create_payout_route", "prepare_payout_ownership_proof", "complete_payout_ownership_proof", "request_payout", "quote_x402_payment", "get_journey_blueprint", "review_screen"];
|
|
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", "list_payouts", "get_payout", "list_payout_routes", "list_payout_bank_accounts", "register_payout_bank_account", "create_payout_route", "prepare_payout_ownership_proof", "complete_payout_ownership_proof", "request_payout", "quote_x402_payment", "get_journey_blueprint", "verify_runtime_contract", "review_screen"];
|
|
2
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 {
|
package/dist/staging-smoke.js
CHANGED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type VerifyProfile = "direct-sdk" | "backend-proxy";
|
|
2
|
+
export interface VerifySourceFile {
|
|
3
|
+
path: string;
|
|
4
|
+
source: string;
|
|
5
|
+
}
|
|
6
|
+
export interface VerifyFinding {
|
|
7
|
+
rule: string;
|
|
8
|
+
severity: "error" | "warn";
|
|
9
|
+
path: string;
|
|
10
|
+
evidence: string;
|
|
11
|
+
fix: string;
|
|
12
|
+
line?: number;
|
|
13
|
+
}
|
|
14
|
+
export interface VerifyResult {
|
|
15
|
+
profile: VerifyProfile;
|
|
16
|
+
findings: VerifyFinding[];
|
|
17
|
+
summary: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function verifyRuntimeContract(options: {
|
|
20
|
+
files: VerifySourceFile[];
|
|
21
|
+
packageJson: Record<string, unknown>;
|
|
22
|
+
profile?: VerifyProfile;
|
|
23
|
+
}): VerifyResult;
|
|
24
|
+
export declare function runVerifyCli(args: string[], out?: NodeJS.WritableStream, err?: NodeJS.WritableStream): Promise<0 | 1 | 2>;
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
// `verify` subcommand: deterministic runtime-contract checks for generated apps.
|
|
2
|
+
//
|
|
3
|
+
// Exit codes: 0 clean (warnings allowed) · 1 at least one error · 2 usage or
|
|
4
|
+
// no-match. The three unresolved false-positive boundaries intentionally warn:
|
|
5
|
+
// missing React in direct-sdk, app-owned money routes, and in-memory stores.
|
|
6
|
+
import { existsSync, readFileSync, readdirSync, statSync, } from "node:fs";
|
|
7
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
8
|
+
const BLUEPRINT_HOOKS = new Set([
|
|
9
|
+
"useAccount",
|
|
10
|
+
"useAccounts",
|
|
11
|
+
"useBankAccountConfig",
|
|
12
|
+
"useCompanyBankAccounts",
|
|
13
|
+
"useCreateAccount",
|
|
14
|
+
"useCreateCompanyBankAccount",
|
|
15
|
+
"useCreateParty",
|
|
16
|
+
"useCreateRampRequest",
|
|
17
|
+
"useFeeQuote",
|
|
18
|
+
"useFourEyesApproval",
|
|
19
|
+
"useInitiateRamp",
|
|
20
|
+
"useParty",
|
|
21
|
+
"useRampLifecycle",
|
|
22
|
+
"useRampPairs",
|
|
23
|
+
"useRampRequest",
|
|
24
|
+
"useRampRequests",
|
|
25
|
+
"useReferenceData",
|
|
26
|
+
"useStagedTransfer",
|
|
27
|
+
"useTransfers",
|
|
28
|
+
"useVirtualBankAccounts",
|
|
29
|
+
"useWallets",
|
|
30
|
+
]);
|
|
31
|
+
function lineFor(source, index) {
|
|
32
|
+
return source.slice(0, index).split("\n").length;
|
|
33
|
+
}
|
|
34
|
+
function suppressed(source, rule, line) {
|
|
35
|
+
const token = `venly-allow:${rule}`;
|
|
36
|
+
if (line === undefined)
|
|
37
|
+
return source.includes(token);
|
|
38
|
+
const lines = source.split("\n");
|
|
39
|
+
return Boolean(lines[line - 1]?.includes(token) || lines[line - 2]?.includes(token));
|
|
40
|
+
}
|
|
41
|
+
function dependencies(packageJson) {
|
|
42
|
+
return Object.assign({}, packageJson.dependencies ?? {}, packageJson.devDependencies ?? {}, packageJson.peerDependencies ?? {});
|
|
43
|
+
}
|
|
44
|
+
function importsFrom(source, packageName) {
|
|
45
|
+
const names = [];
|
|
46
|
+
const expression = new RegExp(`import\\s*\\{([^}]*)\\}\\s*from\\s*["']${packageName.replaceAll("/", "\\/")}["']`, "g");
|
|
47
|
+
for (const match of source.matchAll(expression)) {
|
|
48
|
+
for (const item of (match[1] ?? "").split(",")) {
|
|
49
|
+
const name = item.trim().split(/\s+as\s+/)[0]?.trim();
|
|
50
|
+
if (name)
|
|
51
|
+
names.push(name);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return names;
|
|
55
|
+
}
|
|
56
|
+
function isServerFile(file) {
|
|
57
|
+
return (/(?:^|\/)(?:server|backend|api)(?:\/|\.)/i.test(file.path) ||
|
|
58
|
+
/(?:^|\/)route\.[cm]?[jt]sx?$/i.test(file.path) ||
|
|
59
|
+
/[.]server\.[cm]?[jt]sx?$/i.test(file.path) ||
|
|
60
|
+
/^\s*["']use server["'];?/m.test(file.source));
|
|
61
|
+
}
|
|
62
|
+
function isMoneyRoute(file) {
|
|
63
|
+
const routeHandler = /(?:^|\/)route\.[cm]?[jt]sx?$/i.test(file.path) ||
|
|
64
|
+
/export\s+(?:async\s+)?function\s+(?:GET|POST|PUT|PATCH|DELETE)\b/.test(file.source);
|
|
65
|
+
const moneySignal = /(?:transfers?|payouts?|balances?|ramps?|rampRequests|virtual[-_/ ]bank)/i.test(`${file.path}\n${file.source}`);
|
|
66
|
+
return routeHandler && moneySignal;
|
|
67
|
+
}
|
|
68
|
+
function autoDetectProfile(files) {
|
|
69
|
+
return files.some((file) => file.source.includes("proxyClientOptions") || isMoneyRoute(file))
|
|
70
|
+
? "backend-proxy"
|
|
71
|
+
: "direct-sdk";
|
|
72
|
+
}
|
|
73
|
+
export function verifyRuntimeContract(options) {
|
|
74
|
+
const { files, packageJson } = options;
|
|
75
|
+
const profile = options.profile ?? autoDetectProfile(files);
|
|
76
|
+
const deps = dependencies(packageJson);
|
|
77
|
+
const findings = [];
|
|
78
|
+
function addProjectFinding(finding) {
|
|
79
|
+
const source = files.map((file) => file.source).join("\n");
|
|
80
|
+
if (!suppressed(source, finding.rule)) {
|
|
81
|
+
findings.push({ ...finding, path: "package.json" });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function addSourceFinding(file, finding) {
|
|
85
|
+
if (!suppressed(file.source, finding.rule, finding.line)) {
|
|
86
|
+
findings.push({ ...finding, path: file.path });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (!("@venlyfinance/react" in deps) && !("@venlyfinance/sdk" in deps)) {
|
|
90
|
+
addProjectFinding({
|
|
91
|
+
rule: "venly-package-missing",
|
|
92
|
+
severity: "error",
|
|
93
|
+
evidence: "package.json declares zero @venlyfinance runtime packages",
|
|
94
|
+
fix: "Install the registry block for the journey or add the required @venlyfinance package.",
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
if (profile === "direct-sdk") {
|
|
98
|
+
if (!("@venlyfinance/react" in deps)) {
|
|
99
|
+
addProjectFinding({
|
|
100
|
+
rule: "react-package-missing",
|
|
101
|
+
severity: "warn",
|
|
102
|
+
evidence: "direct-sdk profile has no @venlyfinance/react dependency",
|
|
103
|
+
fix: "Install the journey's registry block, or suppress this warning for an intentionally headless integration.",
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
const providerImported = files.some((file) => importsFrom(file.source, "@venlyfinance/react").includes("VenlyProvider"));
|
|
107
|
+
const mockProviderRendered = files.some((file) => /<VenlyProvider\b[^>]*\benvironment\s*=\s*(?:["']mock["']|\{[^}]+\})/s.test(file.source));
|
|
108
|
+
if (!providerImported || !mockProviderRendered) {
|
|
109
|
+
addProjectFinding({
|
|
110
|
+
rule: "provider-missing",
|
|
111
|
+
severity: "error",
|
|
112
|
+
evidence: "no VenlyProvider import and mock/environment expression wrapper were found",
|
|
113
|
+
fix: 'Import VenlyProvider from @venlyfinance/react and wrap the tree with environment="mock".',
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
const importedHooks = files.flatMap((file) => importsFrom(file.source, "@venlyfinance/react").filter((name) => BLUEPRINT_HOOKS.has(name)));
|
|
117
|
+
if (importedHooks.length === 0) {
|
|
118
|
+
addProjectFinding({
|
|
119
|
+
rule: "blueprint-hook-missing",
|
|
120
|
+
severity: "error",
|
|
121
|
+
evidence: "no journey-blueprint hook is imported from @venlyfinance/react",
|
|
122
|
+
fix: "Use the qualified hooks named by get_journey_blueprint instead of rebuilding the data layer.",
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
for (const file of files) {
|
|
126
|
+
if (!/(?:@venlyfinance\/react|["']react["']|["']use client["'])/.test(file.source))
|
|
127
|
+
continue;
|
|
128
|
+
for (const match of file.source.matchAll(/\bclientSecret\b/g)) {
|
|
129
|
+
addSourceFinding(file, {
|
|
130
|
+
rule: "browser-client-secret",
|
|
131
|
+
severity: "error",
|
|
132
|
+
line: lineFor(file.source, match.index ?? 0),
|
|
133
|
+
evidence: "clientSecret appears in browser/React source",
|
|
134
|
+
fix: "Keep secrets server-side; use proxyClientOptions() for browser traffic.",
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
if (!("@venlyfinance/sdk" in deps)) {
|
|
141
|
+
addProjectFinding({
|
|
142
|
+
rule: "sdk-package-missing",
|
|
143
|
+
severity: "error",
|
|
144
|
+
evidence: "backend-proxy profile has no @venlyfinance/sdk dependency",
|
|
145
|
+
fix: "Add @venlyfinance/sdk and make money routes wrap the official client.",
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
for (const file of files) {
|
|
149
|
+
if (isMoneyRoute(file) && !/from\s*["']@venlyfinance\/sdk["']/.test(file.source)) {
|
|
150
|
+
addSourceFinding(file, {
|
|
151
|
+
rule: "money-route-without-sdk",
|
|
152
|
+
severity: "warn",
|
|
153
|
+
line: 1,
|
|
154
|
+
evidence: "money-route heuristic matched but no @venlyfinance/sdk import was found",
|
|
155
|
+
fix: "Wrap this route with @venlyfinance/sdk, or suppress if it is an unrelated consumer-owned ledger.",
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
if (!isServerFile(file)) {
|
|
159
|
+
for (const match of file.source.matchAll(/\bclientSecret\b/g)) {
|
|
160
|
+
addSourceFinding(file, {
|
|
161
|
+
rule: "client-secret-outside-server",
|
|
162
|
+
severity: "error",
|
|
163
|
+
line: lineFor(file.source, match.index ?? 0),
|
|
164
|
+
evidence: "clientSecret appears outside a server-only file",
|
|
165
|
+
fix: "Move the secret into a server route or server-only module.",
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const proxyImported = files.some((file) => importsFrom(file.source, "@venlyfinance/react").includes("proxyClientOptions"));
|
|
171
|
+
if (!proxyImported) {
|
|
172
|
+
addProjectFinding({
|
|
173
|
+
rule: "proxy-client-options-missing",
|
|
174
|
+
severity: "warn",
|
|
175
|
+
evidence: "backend-proxy profile has no browser-side proxyClientOptions import",
|
|
176
|
+
fix: "Use proxyClientOptions() in the browser provider, or suppress for a server-only consumer.",
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
for (const file of files) {
|
|
181
|
+
if (/\buseEffect\b/.test(file.source) &&
|
|
182
|
+
/\bset(?:Interval|Timeout)\b/.test(file.source) &&
|
|
183
|
+
/(?:status|state)/i.test(file.source) &&
|
|
184
|
+
/\b(?:transfer|ramp)/i.test(file.source)) {
|
|
185
|
+
const match = /\buseEffect\b/.exec(file.source);
|
|
186
|
+
addSourceFinding(file, {
|
|
187
|
+
rule: "status-polling",
|
|
188
|
+
severity: "warn",
|
|
189
|
+
line: lineFor(file.source, match?.index ?? 0),
|
|
190
|
+
evidence: "useEffect timer polling appears beside transfer/ramp state",
|
|
191
|
+
fix: "Use useStagedTransfer or useRampLifecycle for lifecycle polling.",
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
const store = /(?:^|\n)\s*export\s+(?:const|let)\s+(transfers|balances|payouts|rampRequests)\s*=\s*(?:\[|new\s+(?:Map|Set)\b)/g;
|
|
195
|
+
for (const match of file.source.matchAll(store)) {
|
|
196
|
+
addSourceFinding(file, {
|
|
197
|
+
rule: "in-memory-money-store",
|
|
198
|
+
severity: "warn",
|
|
199
|
+
line: lineFor(file.source, match.index ?? 0),
|
|
200
|
+
evidence: `module exports mutable in-memory money state named ${match[1]}`,
|
|
201
|
+
fix: "Use @venlyfinance/react hooks/flows or the SDK; suppress only for a deliberate fixture.",
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
findings.sort((a, b) => a.path.localeCompare(b.path) ||
|
|
206
|
+
(a.line ?? 0) - (b.line ?? 0) ||
|
|
207
|
+
a.rule.localeCompare(b.rule));
|
|
208
|
+
const errors = findings.filter((finding) => finding.severity === "error").length;
|
|
209
|
+
const warnings = findings.length - errors;
|
|
210
|
+
return {
|
|
211
|
+
profile,
|
|
212
|
+
findings,
|
|
213
|
+
summary: `${errors} error(s), ${warnings} warning(s) across ${files.length} file(s)`,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", "dist"]);
|
|
217
|
+
function braceExpand(pattern) {
|
|
218
|
+
const match = /\{([^{}]*)\}/.exec(pattern);
|
|
219
|
+
if (!match)
|
|
220
|
+
return [pattern];
|
|
221
|
+
const before = pattern.slice(0, match.index);
|
|
222
|
+
const after = pattern.slice(match.index + match[0].length);
|
|
223
|
+
return (match[1] ?? "").split(",").flatMap((option) => braceExpand(before + option + after));
|
|
224
|
+
}
|
|
225
|
+
function patternToRegExp(pattern) {
|
|
226
|
+
const escaped = pattern.replace(/[.+^$()|[\]\\?]/g, "\\$&");
|
|
227
|
+
const translated = escaped
|
|
228
|
+
.replace(/\*\*\//g, "\u0000")
|
|
229
|
+
.replace(/\*\*/g, "\u0001")
|
|
230
|
+
.replace(/\*/g, "[^/]*")
|
|
231
|
+
.replace(/\u0000/g, "(?:.*/)?")
|
|
232
|
+
.replace(/\u0001/g, ".*");
|
|
233
|
+
return new RegExp(`^${translated}$`);
|
|
234
|
+
}
|
|
235
|
+
function walk(dir, into) {
|
|
236
|
+
let entries;
|
|
237
|
+
try {
|
|
238
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
for (const entry of entries) {
|
|
244
|
+
if (entry.isDirectory()) {
|
|
245
|
+
if (!SKIP_DIRS.has(entry.name))
|
|
246
|
+
walk(join(dir, entry.name), into);
|
|
247
|
+
}
|
|
248
|
+
else if (entry.isFile()) {
|
|
249
|
+
into.push(join(dir, entry.name));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
function expandPattern(pattern, cwd) {
|
|
254
|
+
const results = [];
|
|
255
|
+
for (const variant of braceExpand(pattern)) {
|
|
256
|
+
const segments = variant.split("/");
|
|
257
|
+
const firstWild = segments.findIndex((segment) => segment.includes("*"));
|
|
258
|
+
if (firstWild === -1) {
|
|
259
|
+
if (existsSync(join(cwd, variant)) && statSync(join(cwd, variant)).isFile())
|
|
260
|
+
results.push(variant);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
const staticPrefix = segments.slice(0, firstWild).join("/");
|
|
264
|
+
const root = staticPrefix ? join(cwd, staticPrefix) : cwd;
|
|
265
|
+
const files = [];
|
|
266
|
+
walk(root, files);
|
|
267
|
+
const matcher = patternToRegExp(variant);
|
|
268
|
+
for (const file of files) {
|
|
269
|
+
const rel = relative(cwd, file).split("\\").join("/");
|
|
270
|
+
if (matcher.test(rel))
|
|
271
|
+
results.push(rel);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return [...new Set(results)].sort();
|
|
275
|
+
}
|
|
276
|
+
function expandPatterns(patterns, cwd) {
|
|
277
|
+
return [...new Set(patterns.flatMap((pattern) => expandPattern(pattern, cwd)))];
|
|
278
|
+
}
|
|
279
|
+
function findPackageJson(file, cwd) {
|
|
280
|
+
let dir = dirname(resolve(cwd, file));
|
|
281
|
+
while (true) {
|
|
282
|
+
const candidate = join(dir, "package.json");
|
|
283
|
+
if (existsSync(candidate))
|
|
284
|
+
return candidate;
|
|
285
|
+
const parent = dirname(dir);
|
|
286
|
+
if (parent === dir)
|
|
287
|
+
return undefined;
|
|
288
|
+
dir = parent;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
function parseArgs(args) {
|
|
292
|
+
const patterns = [];
|
|
293
|
+
let profile;
|
|
294
|
+
for (let index = 0; index < args.length; index++) {
|
|
295
|
+
const arg = args[index] ?? "";
|
|
296
|
+
if (arg === "--profile") {
|
|
297
|
+
const value = args[++index];
|
|
298
|
+
if (value !== "direct-sdk" && value !== "backend-proxy") {
|
|
299
|
+
return { patterns, error: "--profile must be direct-sdk or backend-proxy" };
|
|
300
|
+
}
|
|
301
|
+
profile = value;
|
|
302
|
+
}
|
|
303
|
+
else if (arg.startsWith("--profile=")) {
|
|
304
|
+
const value = arg.slice("--profile=".length);
|
|
305
|
+
if (value !== "direct-sdk" && value !== "backend-proxy") {
|
|
306
|
+
return { patterns, error: "--profile must be direct-sdk or backend-proxy" };
|
|
307
|
+
}
|
|
308
|
+
profile = value;
|
|
309
|
+
}
|
|
310
|
+
else if (arg.startsWith("-")) {
|
|
311
|
+
return { patterns, error: `Unknown option: ${arg}` };
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
patterns.push(arg);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return { patterns, profile };
|
|
318
|
+
}
|
|
319
|
+
export async function runVerifyCli(args, out = process.stdout, err = process.stderr) {
|
|
320
|
+
const parsed = parseArgs(args);
|
|
321
|
+
if (parsed.error || parsed.patterns.length === 0) {
|
|
322
|
+
if (parsed.error)
|
|
323
|
+
err.write(`${parsed.error}\n`);
|
|
324
|
+
err.write('Usage: verify [--profile direct-sdk|backend-proxy] "<glob>" [more globs or files]\n' +
|
|
325
|
+
' e.g. verify "src/**/*.{ts,tsx}"\n' +
|
|
326
|
+
"Exits 1 on any error-severity finding, 2 when nothing matched.\n");
|
|
327
|
+
return 2;
|
|
328
|
+
}
|
|
329
|
+
const cwd = process.cwd();
|
|
330
|
+
const files = expandPatterns(parsed.patterns, cwd);
|
|
331
|
+
if (files.length === 0) {
|
|
332
|
+
err.write(`Nothing matched: ${parsed.patterns.join(" ")}\n`);
|
|
333
|
+
return 2;
|
|
334
|
+
}
|
|
335
|
+
const groups = new Map();
|
|
336
|
+
for (const file of files) {
|
|
337
|
+
const packagePath = findPackageJson(file, cwd) ?? join(cwd, "package.json");
|
|
338
|
+
groups.set(packagePath, [...(groups.get(packagePath) ?? []), file]);
|
|
339
|
+
}
|
|
340
|
+
let errors = 0;
|
|
341
|
+
let warnings = 0;
|
|
342
|
+
for (const [packagePath, groupFiles] of [...groups.entries()].sort()) {
|
|
343
|
+
let packageJson = {};
|
|
344
|
+
if (existsSync(packagePath)) {
|
|
345
|
+
try {
|
|
346
|
+
packageJson = JSON.parse(readFileSync(packagePath, "utf8"));
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
err.write(`Invalid package.json at ${relative(cwd, packagePath)}: ${error.message}\n`);
|
|
350
|
+
return 2;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const result = verifyRuntimeContract({
|
|
354
|
+
files: groupFiles.map((path) => ({ path, source: readFileSync(path, "utf8") })),
|
|
355
|
+
packageJson,
|
|
356
|
+
profile: parsed.profile,
|
|
357
|
+
});
|
|
358
|
+
out.write(`profile: ${result.profile}\n`);
|
|
359
|
+
for (const finding of result.findings) {
|
|
360
|
+
if (finding.severity === "error")
|
|
361
|
+
errors++;
|
|
362
|
+
else
|
|
363
|
+
warnings++;
|
|
364
|
+
const line = finding.line === undefined ? "" : `:${finding.line}`;
|
|
365
|
+
out.write(`${finding.path}${line} ${finding.severity} ${finding.rule} ${finding.evidence}\n`);
|
|
366
|
+
out.write(` fix: ${finding.fix}\n`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
out.write(`${errors} error(s), ${warnings} warning(s) across ${files.length} file(s)\n`);
|
|
370
|
+
return errors > 0 ? 1 : 0;
|
|
371
|
+
}
|
package/package.json
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@venlyfinance/settlement-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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": {
|
|
7
7
|
"venly-finance-mcp": "dist/index.js",
|
|
8
|
-
"venly-settlement-mcp": "dist/index.js"
|
|
8
|
+
"venly-settlement-mcp": "dist/index.js",
|
|
9
|
+
"settlement-mcp": "dist/index.js"
|
|
9
10
|
},
|
|
10
11
|
"main": "dist/index.js",
|
|
11
12
|
"files": [
|
|
12
13
|
"dist",
|
|
13
14
|
"scripts",
|
|
14
15
|
"skills",
|
|
16
|
+
"AGENTS.md",
|
|
15
17
|
"README.md",
|
|
16
18
|
"CHANGELOG.md"
|
|
17
19
|
],
|
|
@@ -27,7 +29,7 @@
|
|
|
27
29
|
"node": ">=20"
|
|
28
30
|
},
|
|
29
31
|
"dependencies": {
|
|
30
|
-
"@venlyfinance/sdk": "^0.
|
|
32
|
+
"@venlyfinance/sdk": "^0.5.0",
|
|
31
33
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
32
34
|
"zod": "^3.23.8"
|
|
33
35
|
},
|
|
@@ -60,4 +62,4 @@
|
|
|
60
62
|
"eur",
|
|
61
63
|
"viban"
|
|
62
64
|
]
|
|
63
|
-
}
|
|
65
|
+
}
|