@venlyfinance/settlement-mcp 0.1.0 → 0.2.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.
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerBuilderPrompts(server: McpServer): void;
@@ -0,0 +1,43 @@
1
+ import { z } from "zod";
2
+ export function registerBuilderPrompts(server) {
3
+ server.registerPrompt("build_international_account", {
4
+ title: "Build an international account experience",
5
+ description: "Guide a coding agent to build a mock-first international account product over Venly Finance.",
6
+ argsSchema: {
7
+ productName: z.string().optional().describe("Working product name"),
8
+ customerType: z
9
+ .enum(["individual", "organisation", "both"])
10
+ .default("organisation"),
11
+ targetGeography: z
12
+ .string()
13
+ .optional()
14
+ .describe("Target geography to validate; never treated as supported by assumption"),
15
+ },
16
+ }, async ({ productName, customerType, targetGeography }) => ({
17
+ description: "Mock-first Venly Finance application-building brief",
18
+ messages: [
19
+ {
20
+ role: "user",
21
+ content: {
22
+ type: "text",
23
+ text: `Build an international account reference experience${productName ? ` called ${productName}` : ""} for ${customerType} customers${targetGeography ? ` targeting ${targetGeography}` : ""}.
24
+
25
+ Use this operating brief:
26
+
27
+ 1. Read venly://capabilities, venly://safety and venly://workflows/international-account before writing code.
28
+ 2. Start in explicit mock mode with VENLY_ENV=mock. Keep all simulated states visibly labelled Mock.
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.
37
+
38
+ 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
+ },
41
+ ],
42
+ }));
43
+ }
package/dist/reconcile.js CHANGED
@@ -8,7 +8,13 @@
8
8
  */
9
9
  export function reconcileByReferenceCode(referenceCode, virtualBankAccounts, transactions) {
10
10
  const target = referenceCode.trim();
11
+ if (!target) {
12
+ throw new Error("referenceCode must not be blank");
13
+ }
11
14
  const vban = virtualBankAccounts.find((v) => (v.referenceCode ?? "").trim() === target) ?? null;
15
+ if (vban && !(vban.id ?? "").trim()) {
16
+ throw new Error("matching vIBAN is missing an id");
17
+ }
12
18
  const matchedTransactions = transactions.filter((t) => (t.referenceCode ?? "").trim() === target);
13
19
  const totalAmount = matchedTransactions.reduce((sum, t) => sum + (Number.isFinite(t.amount) ? t.amount : 0), 0);
14
20
  const currency = vban?.currency ?? matchedTransactions[0]?.currency ?? null;
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerBuilderResources(server: McpServer): void;
@@ -0,0 +1,106 @@
1
+ const RESOURCES = [
2
+ {
3
+ name: "venly-finance-capabilities",
4
+ uri: "venly://capabilities",
5
+ title: "Venly Finance capabilities",
6
+ description: "Contract-backed builder capabilities and explicit product boundaries.",
7
+ text: `# Venly Finance capabilities
8
+
9
+ Venly Finance provides financial infrastructure through several regulated partners. The public Finance contract exposes:
10
+
11
+ - individual and organisation party records with KYC/KYB state;
12
+ - accounts with an auto-provisioned wallet;
13
+ - wallet balances split into total, available and reserved token amounts;
14
+ - EUR SEPA virtual bank accounts with IBAN, BIC and reconciliation reference code;
15
+ - fiat-to-crypto payment sessions;
16
+ - account-to-account fiat-denominated and crypto-denominated transfers;
17
+ - payment-request authorization, settlement and reversal primitives;
18
+ - Fundflow on/off-ramp workflows with four-eyes approval.
19
+
20
+ Current boundaries:
21
+
22
+ - EUR is the currently documented virtual-bank-account currency. Do not infer global bank-account coverage.
23
+ - Creating a party does not complete KYC/KYB. Live virtual-bank-account provisioning requires a VERIFIED account.
24
+ - Card issuing is not exposed by the current Finance OpenAPI contract.
25
+ - A bank charter, deposit insurance and external-bank payout coverage are not supplied or implied by this MCP.
26
+ - Production x402 settlement is not implemented; the x402 tool is a quote-only stub.
27
+ `,
28
+ },
29
+ {
30
+ name: "venly-finance-safety",
31
+ uri: "venly://safety",
32
+ title: "Venly Finance safety model",
33
+ description: "Environment, write, compliance and secret-handling rules.",
34
+ text: `# Venly Finance MCP safety
35
+
36
+ - Set VENLY_ENV explicitly to mock, staging or production. An absent value remains staging for 0.x compatibility.
37
+ - Mock mode uses synthetic SDK fixtures, no credentials and no network. Every mutation result is labelled mode=mock.
38
+ - Staging writes require confirm=true, VENLY_MCP_LIVE=1 and VENLY_CLIENT_ID/VENLY_CLIENT_SECRET.
39
+ - Production requires every staging gate plus VENLY_MCP_PRODUCTION=1.
40
+ - There is no implicit fallback from staging or production to mock.
41
+ - Mutations use idempotency keys where supported. Preserve a caller-supplied key across retries.
42
+ - Fundflow approvals retain four-eyes and optimistic-locking rules.
43
+ - Creating a party does not mean KYC/KYB has passed. A live virtual bank account requires KYC status VERIFIED.
44
+ - Keep Venly credentials and access tokens server-side. Never place them in browser code, tool output or logs.
45
+ - Never arm writes or move from mock to staging/production without an explicit user decision.
46
+ `,
47
+ },
48
+ {
49
+ name: "international-account-workflow",
50
+ uri: "venly://workflows/international-account",
51
+ title: "International account golden workflow",
52
+ description: "Atomic tool order for an international-account reference experience.",
53
+ text: `# International account workflow
54
+
55
+ Start with VENLY_ENV=mock.
56
+
57
+ 1. Call get_reference_data to inspect supported chains and assets.
58
+ 2. Call create_party for an INDIVIDUAL or ORGANISATION. Treat returned KYC/KYB state as a state, not an approval.
59
+ 3. Call create_account with the party ID and selected chain. The Finance API auto-provisions the account wallet.
60
+ 4. Call list_wallets and display returned total, available and reserved token balances.
61
+ 5. Call create_virtual_bank_account for EUR -> USDC. In live environments this requires a VERIFIED account.
62
+ 6. Call get_virtual_bank_account to display IBAN/BIC/referenceCode where returned.
63
+ 7. Call create_fiat_transfer or create_crypto_transfer using one stable idempotency key.
64
+ 8. Call list_transfers/get_transfer to display status.
65
+ 9. Use reconcile_by_reference_code when observed incoming bank transactions are available.
66
+
67
+ Do not collapse this workflow into an autonomous mega-tool. Each mutation remains visible, inspectable and separately confirmed outside mock mode.
68
+ `,
69
+ },
70
+ {
71
+ name: "mock-to-staging-workflow",
72
+ uri: "venly://workflows/mock-to-staging",
73
+ title: "Mock-to-staging transition",
74
+ description: "Configuration and compliance checklist for leaving simulation.",
75
+ text: `# Mock to staging
76
+
77
+ Application business logic should continue to use @venlyfinance/sdk on the server side.
78
+
79
+ 1. Keep the mock experience working and visibly labelled.
80
+ 2. Set VENLY_ENV=staging and provide the VENLY_CLIENT_ID/VENLY_CLIENT_SECRET credentials through server-side secret storage.
81
+ 3. Confirm the tenant's custody model, enabled chains/assets and regulated-partner coverage.
82
+ 4. Use a documented VERIFIED test party/account before provisioning a live EUR virtual bank account.
83
+ 5. Run read-only smoke checks first. Do not set VENLY_MCP_LIVE until those checks pass.
84
+ 6. Dry-run every intended write, review its normalized request, then explicitly confirm it.
85
+
86
+ There is no implicit fallback to mock. Authentication or capability failures must remain visible rather than returning synthetic data.
87
+ `,
88
+ },
89
+ ];
90
+ export function registerBuilderResources(server) {
91
+ for (const resource of RESOURCES) {
92
+ server.registerResource(resource.name, resource.uri, {
93
+ title: resource.title,
94
+ description: resource.description,
95
+ mimeType: "text/markdown",
96
+ }, async () => ({
97
+ contents: [
98
+ {
99
+ uri: resource.uri,
100
+ mimeType: "text/markdown",
101
+ text: resource.text,
102
+ },
103
+ ],
104
+ }));
105
+ }
106
+ }
@@ -0,0 +1,18 @@
1
+ export declare function jsonResult(data: unknown): {
2
+ content: {
3
+ type: "text";
4
+ text: string;
5
+ }[];
6
+ structuredContent: Record<string, unknown>;
7
+ };
8
+ export declare function sanitizeErrorMessage(message: string): string;
9
+ export declare function errorResult(message: string): {
10
+ content: {
11
+ type: "text";
12
+ text: string;
13
+ }[];
14
+ structuredContent: {
15
+ error: string;
16
+ };
17
+ isError: boolean;
18
+ };
@@ -0,0 +1,25 @@
1
+ function toStructured(data) {
2
+ if (data !== null && typeof data === "object" && !Array.isArray(data)) {
3
+ return data;
4
+ }
5
+ return { result: data };
6
+ }
7
+ export function jsonResult(data) {
8
+ return {
9
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
10
+ structuredContent: toStructured(data),
11
+ };
12
+ }
13
+ export function sanitizeErrorMessage(message) {
14
+ return message
15
+ .replace(/(Authorization\s*:\s*Bearer\s+)[^\s,;]+/gi, "$1[REDACTED]")
16
+ .replace(/(client_secret\s*[=:]\s*)[^\s,;]+/gi, "$1[REDACTED]");
17
+ }
18
+ export function errorResult(message) {
19
+ const safeMessage = sanitizeErrorMessage(message);
20
+ return {
21
+ content: [{ type: "text", text: `Error: ${safeMessage}` }],
22
+ structuredContent: { error: safeMessage },
23
+ isError: true,
24
+ };
25
+ }
package/dist/safety.d.ts CHANGED
@@ -9,12 +9,15 @@
9
9
  * If ANY is missing the tool returns a dry-run object describing the exact
10
10
  * request it WOULD have sent, and never calls the transport. Fail closed.
11
11
  */
12
+ import { type VenlyEnvironment } from "./constants.js";
12
13
  export type EnvLike = Record<string, string | undefined>;
13
14
  export interface GateDecision {
14
- /** true only when confirm + armed env + creds all hold. */
15
+ /** True in explicit mock mode, or when every live environment gate holds. */
15
16
  armed: boolean;
17
+ environment: VenlyEnvironment;
16
18
  confirm: boolean;
17
19
  liveFlagArmed: boolean;
20
+ productionFlagArmed: boolean;
18
21
  credentialsPresent: boolean;
19
22
  /** Human-readable reasons a live call is blocked (empty when armed). */
20
23
  blockedReasons: string[];
@@ -23,6 +26,7 @@ export declare function credentialsPresent(env: EnvLike): boolean;
23
26
  export declare function evaluateWriteGate(confirm: boolean, env: EnvLike): GateDecision;
24
27
  export interface DryRunRequest {
25
28
  mode: "dry-run";
29
+ environment: VenlyEnvironment;
26
30
  tool: string;
27
31
  method: "GET" | "POST" | "PUT" | "DELETE";
28
32
  /** Which API this maps to. */
package/dist/safety.js CHANGED
@@ -9,13 +9,26 @@
9
9
  * If ANY is missing the tool returns a dry-run object describing the exact
10
10
  * request it WOULD have sent, and never calls the transport. Fail closed.
11
11
  */
12
- import { LIVE_FLAG } from "./constants.js";
12
+ import { LIVE_FLAG, PRODUCTION_FLAG, resolveVenlyEnvironment, } from "./constants.js";
13
13
  export function credentialsPresent(env) {
14
14
  return Boolean(env.VENLY_CLIENT_ID && env.VENLY_CLIENT_SECRET);
15
15
  }
16
16
  export function evaluateWriteGate(confirm, env) {
17
+ const environment = resolveVenlyEnvironment(env);
17
18
  const liveFlagArmed = env[LIVE_FLAG] === "1";
19
+ const productionFlagArmed = env[PRODUCTION_FLAG] === "1";
18
20
  const creds = credentialsPresent(env);
21
+ if (environment === "mock") {
22
+ return {
23
+ armed: true,
24
+ environment,
25
+ confirm,
26
+ liveFlagArmed,
27
+ productionFlagArmed,
28
+ credentialsPresent: creds,
29
+ blockedReasons: [],
30
+ };
31
+ }
19
32
  const blockedReasons = [];
20
33
  if (!confirm)
21
34
  blockedReasons.push("confirm arg is not true");
@@ -23,10 +36,18 @@ export function evaluateWriteGate(confirm, env) {
23
36
  blockedReasons.push(`${LIVE_FLAG} is not set to "1"`);
24
37
  if (!creds)
25
38
  blockedReasons.push("credentials are not present in env");
39
+ if (environment === "production" && !productionFlagArmed) {
40
+ blockedReasons.push(`${PRODUCTION_FLAG} is not set to "1"`);
41
+ }
26
42
  return {
27
- armed: confirm && liveFlagArmed && creds,
43
+ armed: confirm &&
44
+ liveFlagArmed &&
45
+ creds &&
46
+ (environment !== "production" || productionFlagArmed),
47
+ environment,
28
48
  confirm,
29
49
  liveFlagArmed,
50
+ productionFlagArmed,
30
51
  credentialsPresent: creds,
31
52
  blockedReasons,
32
53
  };
@@ -34,13 +55,15 @@ export function evaluateWriteGate(confirm, env) {
34
55
  export function buildDryRun(tool, method, api, path, body, gate) {
35
56
  return {
36
57
  mode: "dry-run",
58
+ environment: gate.environment,
37
59
  tool,
38
60
  method,
39
61
  api,
40
62
  path,
41
63
  body,
42
64
  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.",
65
+ note: "No live call was made. To execute outside mock mode, set confirm:true AND " +
66
+ "VENLY_MCP_LIVE=1 AND provide VENLY_CLIENT_ID / VENLY_CLIENT_SECRET. " +
67
+ "Production additionally requires VENLY_MCP_PRODUCTION=1.",
45
68
  };
46
69
  }
package/dist/server.js CHANGED
@@ -4,12 +4,24 @@
4
4
  * mock client and no network.
5
5
  */
6
6
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
- import { SERVER_NAME, SERVER_VERSION } from "./constants.js";
7
+ import { ENVIRONMENT_FLAG, 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";
11
+ import { registerBuilderResources } from "./resources.js";
12
+ import { registerBuilderPrompts } from "./prompts.js";
11
13
  export function createServer(options) {
12
14
  const env = options.env ?? process.env;
15
+ // The write gate auto-arms every mutation in mock mode on the assumption
16
+ // that the injected client is also mock. Refuse to start when a client that
17
+ // declares its environment disagrees with the env the gate will read –
18
+ // otherwise a mock env over a live client would execute un-gated writes.
19
+ const gateEnvironment = resolveVenlyEnvironment(env);
20
+ if (options.client.environment !== undefined &&
21
+ options.client.environment !== gateEnvironment) {
22
+ throw new Error(`client targets "${options.client.environment}" but ${ENVIRONMENT_FLAG} resolves to ` +
23
+ `"${gateEnvironment}"; the write gate and client must agree on the environment`);
24
+ }
13
25
  const server = new McpServer({
14
26
  name: SERVER_NAME,
15
27
  version: SERVER_VERSION,
@@ -17,5 +29,7 @@ export function createServer(options) {
17
29
  registerReadTools(server, options.client);
18
30
  registerWriteTools(server, options.client, env);
19
31
  registerX402Tools(server);
32
+ registerBuilderResources(server);
33
+ registerBuilderPrompts(server);
20
34
  return server;
21
35
  }
@@ -0,0 +1,17 @@
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"];
3
+ export declare const EXPECTED_PROMPTS: readonly ["build_international_account"];
4
+ export interface DiscoveryNames {
5
+ tools: string[];
6
+ resources: string[];
7
+ prompts: string[];
8
+ }
9
+ export declare function assertExpectedDiscovery(actual: DiscoveryNames): void;
10
+ export declare function assertDryRunResult(result: unknown): void;
11
+ export interface StagingSmokeOptions {
12
+ env?: NodeJS.ProcessEnv;
13
+ serverEntry?: string;
14
+ log?: (line: string) => void;
15
+ }
16
+ export declare function buildStagingChildEnv(env: NodeJS.ProcessEnv): Record<string, string>;
17
+ export declare function runStagingSmoke(options?: StagingSmokeOptions): Promise<void>;
@@ -0,0 +1,199 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StdioClientTransport, getDefaultEnvironment, } from "@modelcontextprotocol/sdk/client/stdio.js";
3
+ import { fileURLToPath } from "node:url";
4
+ import { SERVER_VERSION } from "./constants.js";
5
+ import { sanitizeErrorMessage } from "./results.js";
6
+ export const EXPECTED_TOOLS = [
7
+ "list_ramp_requests",
8
+ "get_ramp_request",
9
+ "list_accounts",
10
+ "get_account",
11
+ "list_wallets",
12
+ "list_virtual_bank_accounts",
13
+ "get_virtual_bank_account",
14
+ "reconcile_by_reference_code",
15
+ "list_transfers",
16
+ "get_transfer",
17
+ "list_parties",
18
+ "get_party",
19
+ "get_reference_data",
20
+ "create_party",
21
+ "create_account",
22
+ "create_virtual_bank_account",
23
+ "create_fiat_transfer",
24
+ "create_crypto_transfer",
25
+ "stage_transfer",
26
+ "approve_ramp_request",
27
+ "reject_ramp_request",
28
+ "create_payment_session",
29
+ "quote_x402_payment",
30
+ ];
31
+ export const EXPECTED_RESOURCE_URIS = [
32
+ "venly://capabilities",
33
+ "venly://safety",
34
+ "venly://workflows/international-account",
35
+ "venly://workflows/mock-to-staging",
36
+ ];
37
+ export const EXPECTED_PROMPTS = ["build_international_account"];
38
+ function assertExactMembers(label, expected, actual) {
39
+ const actualSet = new Set(actual);
40
+ const expectedSet = new Set(expected);
41
+ for (const name of expected) {
42
+ if (!actualSet.has(name))
43
+ throw new Error(`missing ${label}: ${name}`);
44
+ }
45
+ for (const name of actual) {
46
+ if (!expectedSet.has(name))
47
+ throw new Error(`unexpected ${label}: ${name}`);
48
+ }
49
+ }
50
+ export function assertExpectedDiscovery(actual) {
51
+ assertExactMembers("tool", EXPECTED_TOOLS, actual.tools);
52
+ assertExactMembers("resource", EXPECTED_RESOURCE_URIS, actual.resources);
53
+ assertExactMembers("prompt", EXPECTED_PROMPTS, actual.prompts);
54
+ }
55
+ function asRecord(value, label) {
56
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
57
+ throw new Error(`${label} must be an object`);
58
+ }
59
+ return value;
60
+ }
61
+ export function assertDryRunResult(result) {
62
+ const record = asRecord(result, "write result");
63
+ const gate = asRecord(record.gate, "write gate");
64
+ if (record.mode !== "dry-run" ||
65
+ record.environment !== "staging" ||
66
+ gate.armed !== false ||
67
+ gate.liveFlagArmed !== false) {
68
+ throw new Error("expected a staging dry-run with both write gates disarmed");
69
+ }
70
+ }
71
+ function requireCredentials(env) {
72
+ const clientId = env.VENLY_CLIENT_ID;
73
+ const clientSecret = env.VENLY_CLIENT_SECRET;
74
+ if (!clientId || !clientSecret) {
75
+ throw new Error("Set VENLY_CLIENT_ID and VENLY_CLIENT_SECRET for the staging tenant.");
76
+ }
77
+ return { clientId, clientSecret };
78
+ }
79
+ function optionalEnvironment(env) {
80
+ const keys = [
81
+ "VENLY_FINANCE_BASE_URL",
82
+ "VENLY_FUNDFLOW_BASE_URL",
83
+ "VENLY_TOKEN_URL",
84
+ ];
85
+ const out = {};
86
+ for (const key of keys) {
87
+ if (env[key])
88
+ out[key] = env[key];
89
+ }
90
+ return out;
91
+ }
92
+ function structuredContent(result, label) {
93
+ const record = asRecord(result, label);
94
+ if (record.isError === true) {
95
+ const detail = Array.isArray(record.content)
96
+ ? record.content
97
+ .map((item) => item && typeof item === "object" && "text" in item
98
+ ? String(item.text)
99
+ : "")
100
+ .filter(Boolean)
101
+ .join("; ")
102
+ : "";
103
+ throw new Error(`${label} returned an MCP error${detail ? `: ${sanitizeErrorMessage(detail)}` : ""}`);
104
+ }
105
+ return asRecord(record.structuredContent, `${label} structuredContent`);
106
+ }
107
+ function countFrom(result, label) {
108
+ const content = structuredContent(result, label);
109
+ if (typeof content.count !== "number") {
110
+ throw new Error(`${label} did not return a numeric count`);
111
+ }
112
+ return content.count;
113
+ }
114
+ function referenceCounts(result) {
115
+ const content = structuredContent(result, "get_reference_data");
116
+ const counts = [];
117
+ for (const key of ["chains", "fiatCurrencies", "cryptocurrencies", "fees"]) {
118
+ const value = content[key];
119
+ if (!Array.isArray(value))
120
+ throw new Error(`get_reference_data missing ${key} array`);
121
+ counts.push(`${key}=${value.length}`);
122
+ }
123
+ return counts.join(", ");
124
+ }
125
+ export function buildStagingChildEnv(env) {
126
+ const { clientId, clientSecret } = requireCredentials(env);
127
+ return {
128
+ ...getDefaultEnvironment(),
129
+ ...optionalEnvironment(env),
130
+ VENLY_ENV: "staging",
131
+ VENLY_CLIENT_ID: clientId,
132
+ VENLY_CLIENT_SECRET: clientSecret,
133
+ };
134
+ }
135
+ export async function runStagingSmoke(options = {}) {
136
+ const env = options.env ?? process.env;
137
+ const log = options.log ?? console.log;
138
+ const serverEntry = options.serverEntry ?? fileURLToPath(new URL("./index.js", import.meta.url));
139
+ // The builder allowlists inherited variables and never copies live-write flags.
140
+ const childEnv = buildStagingChildEnv(env);
141
+ const transport = new StdioClientTransport({
142
+ command: process.execPath,
143
+ args: [serverEntry],
144
+ env: childEnv,
145
+ stderr: "inherit",
146
+ });
147
+ const client = new Client({
148
+ name: "venly-staging-smoke",
149
+ version: SERVER_VERSION,
150
+ });
151
+ try {
152
+ await client.connect(transport);
153
+ const [toolResult, resourceResult, promptResult] = await Promise.all([
154
+ client.listTools(),
155
+ client.listResources(),
156
+ client.listPrompts(),
157
+ ]);
158
+ assertExpectedDiscovery({
159
+ tools: toolResult.tools.map((tool) => tool.name),
160
+ resources: resourceResult.resources.map((resource) => resource.uri),
161
+ prompts: promptResult.prompts.map((prompt) => prompt.name),
162
+ });
163
+ log(`OK discovery - tools=${toolResult.tools.length}, resources=${resourceResult.resources.length}, prompts=${promptResult.prompts.length}`);
164
+ const parties = await client.callTool({ name: "list_parties", arguments: { size: 1 } });
165
+ log(`OK list_parties - count=${countFrom(parties, "list_parties")}`);
166
+ const accounts = await client.callTool({ name: "list_accounts", arguments: { size: 1 } });
167
+ log(`OK list_accounts - count=${countFrom(accounts, "list_accounts")}`);
168
+ try {
169
+ const referenceData = await client.callTool({
170
+ name: "get_reference_data",
171
+ arguments: { dataset: "all" },
172
+ });
173
+ log(`OK get_reference_data - ${referenceCounts(referenceData)}`);
174
+ }
175
+ catch (error) {
176
+ const message = error instanceof Error ? error.message : String(error);
177
+ const isScopeError = /\b(401|403)\b/.test(message);
178
+ if (isScopeError && env.VENLY_SMOKE_ALLOW_FUNDFLOW_SKIP === "1") {
179
+ log("SKIP get_reference_data - credential lacks Fundflow scope (tolerated via VENLY_SMOKE_ALLOW_FUNDFLOW_SKIP=1); fundflow validated by spec-diff only");
180
+ }
181
+ else {
182
+ throw error;
183
+ }
184
+ }
185
+ const dryRun = await client.callTool({
186
+ name: "create_party",
187
+ arguments: {
188
+ partyType: "ORGANISATION",
189
+ name: "Venly staging smoke dry-run",
190
+ confirm: true,
191
+ },
192
+ });
193
+ assertDryRunResult(structuredContent(dryRun, "create_party"));
194
+ log("OK create_party - confirmed request remained dry-run; zero mutation");
195
+ }
196
+ finally {
197
+ await client.close();
198
+ }
199
+ }