z-zero-mcp-server 1.0.1 → 1.0.2
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/dist/index.js +107 -17
- package/dist/supabase_backend.d.ts +12 -2
- package/dist/supabase_backend.js +216 -34
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
"use strict";
|
|
3
|
-
// Z-ZERO MCP Server (z-zero-mcp-server)
|
|
3
|
+
// Z-ZERO MCP Server (z-zero-mcp-server) v1.0.2
|
|
4
4
|
// Exposes secure JIT payment tools to AI Agents via Model Context Protocol
|
|
5
|
-
//
|
|
5
|
+
// Now connected to REAL Airwallex Issuing API — produces real Mastercards
|
|
6
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
7
|
const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
8
8
|
const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
@@ -14,7 +14,7 @@ const playwright_bridge_js_1 = require("./playwright_bridge.js");
|
|
|
14
14
|
// ============================================================
|
|
15
15
|
const server = new mcp_js_1.McpServer({
|
|
16
16
|
name: "z-zero-mcp-server",
|
|
17
|
-
version: "1.0.
|
|
17
|
+
version: "1.0.2",
|
|
18
18
|
});
|
|
19
19
|
// ============================================================
|
|
20
20
|
// TOOL 1: List available cards (safe - no sensitive data)
|
|
@@ -63,16 +63,17 @@ server.tool("check_balance", "Check the remaining balance of a virtual card by i
|
|
|
63
63
|
};
|
|
64
64
|
});
|
|
65
65
|
// ============================================================
|
|
66
|
-
// TOOL 3: Request a temporary payment token
|
|
66
|
+
// TOOL 3: Request a temporary payment token (issues real JIT card)
|
|
67
67
|
// ============================================================
|
|
68
|
-
server.tool("request_payment_token", "Request a temporary payment token for a specific amount. The token is valid for
|
|
68
|
+
server.tool("request_payment_token", "Request a temporary payment token for a specific amount. A real single-use virtual Mastercard is issued via Airwallex. The token is valid for 30 minutes. Min: $1, Max: $100. Use this token with execute_payment to complete a purchase.", {
|
|
69
69
|
card_alias: zod_1.z
|
|
70
70
|
.string()
|
|
71
71
|
.describe("Which card to charge, e.g. 'Card_01'"),
|
|
72
72
|
amount: zod_1.z
|
|
73
73
|
.number()
|
|
74
|
-
.
|
|
75
|
-
.
|
|
74
|
+
.min(1, "Minimum amount is $1.00")
|
|
75
|
+
.max(100, "Maximum amount is $100.00")
|
|
76
|
+
.describe("Amount in USD to authorize (min: $1, max: $100)"),
|
|
76
77
|
merchant: zod_1.z
|
|
77
78
|
.string()
|
|
78
79
|
.describe("Name or URL of the merchant/service being purchased"),
|
|
@@ -85,7 +86,7 @@ server.tool("request_payment_token", "Request a temporary payment token for a sp
|
|
|
85
86
|
{
|
|
86
87
|
type: "text",
|
|
87
88
|
text: balance
|
|
88
|
-
? `Insufficient balance. Card "${card_alias}" has $${balance.balance} but you requested $${amount}.`
|
|
89
|
+
? `Insufficient balance. Card "${card_alias}" has $${balance.balance} but you requested $${amount}. Or amount is outside the $1-$100 limit.`
|
|
89
90
|
: `Card "${card_alias}" not found or API key is invalid.`,
|
|
90
91
|
},
|
|
91
92
|
],
|
|
@@ -102,7 +103,8 @@ server.tool("request_payment_token", "Request a temporary payment token for a sp
|
|
|
102
103
|
amount: token.amount,
|
|
103
104
|
merchant: token.merchant,
|
|
104
105
|
expires_at: expiresAt,
|
|
105
|
-
|
|
106
|
+
real_card_issued: !!token.airwallex_card_id,
|
|
107
|
+
instructions: "Use this token with execute_payment within 30 minutes. IMPORTANT: If the actual checkout price is HIGHER than the token amount, do NOT proceed — call cancel_payment_token first and request a new token with the correct amount.",
|
|
106
108
|
}, null, 2),
|
|
107
109
|
},
|
|
108
110
|
],
|
|
@@ -119,15 +121,19 @@ server.tool("execute_payment", "Execute a payment using a temporary token. This
|
|
|
119
121
|
.string()
|
|
120
122
|
.url()
|
|
121
123
|
.describe("The full URL of the checkout/payment page"),
|
|
122
|
-
|
|
124
|
+
actual_amount: zod_1.z
|
|
125
|
+
.number()
|
|
126
|
+
.optional()
|
|
127
|
+
.describe("The actual final amount on the checkout page. If different from token amount, system will auto-refund the difference."),
|
|
128
|
+
}, async ({ token, checkout_url, actual_amount }) => {
|
|
123
129
|
// Step 1: Resolve token → card data (RAM only)
|
|
124
|
-
const cardData = (0, supabase_backend_js_1.resolveTokenRemote)(token);
|
|
130
|
+
const cardData = await (0, supabase_backend_js_1.resolveTokenRemote)(token);
|
|
125
131
|
if (!cardData) {
|
|
126
132
|
return {
|
|
127
133
|
content: [
|
|
128
134
|
{
|
|
129
135
|
type: "text",
|
|
130
|
-
text: "Payment failed: Token is invalid, expired, or already used. Request a new token.",
|
|
136
|
+
text: "Payment failed: Token is invalid, expired, cancelled, or already used. Request a new token.",
|
|
131
137
|
},
|
|
132
138
|
],
|
|
133
139
|
isError: true,
|
|
@@ -135,9 +141,13 @@ server.tool("execute_payment", "Execute a payment using a temporary token. This
|
|
|
135
141
|
}
|
|
136
142
|
// Step 2: Use Playwright to inject card into checkout form
|
|
137
143
|
const result = await (0, playwright_bridge_js_1.fillCheckoutForm)(checkout_url, cardData);
|
|
138
|
-
// Step 3: Burn the token
|
|
144
|
+
// Step 3: Burn the token (wallet was pre-debited at issue time)
|
|
139
145
|
await (0, supabase_backend_js_1.burnTokenRemote)(token);
|
|
140
|
-
// Step 4:
|
|
146
|
+
// Step 4: Refund underspend if actual amount was less than token amount
|
|
147
|
+
if (actual_amount !== undefined && result.success) {
|
|
148
|
+
await (0, supabase_backend_js_1.refundUnderspendRemote)(token, actual_amount);
|
|
149
|
+
}
|
|
150
|
+
// Step 5: Return result (NEVER includes card numbers)
|
|
141
151
|
return {
|
|
142
152
|
content: [
|
|
143
153
|
{
|
|
@@ -154,13 +164,93 @@ server.tool("execute_payment", "Execute a payment using a temporary token. This
|
|
|
154
164
|
};
|
|
155
165
|
});
|
|
156
166
|
// ============================================================
|
|
167
|
+
// TOOL 5: Cancel payment token (returns funds to wallet)
|
|
168
|
+
// ============================================================
|
|
169
|
+
server.tool("cancel_payment_token", "Cancel a payment token that has not been used yet. This will cancel the Airwallex card and refund the full amount back to the wallet. Use this when: (1) checkout price is higher than token amount, (2) purchase is no longer needed, or (3) human requests cancellation. IMPORTANT: Do NOT auto-cancel without human awareness — always inform the human first.", {
|
|
170
|
+
token: zod_1.z
|
|
171
|
+
.string()
|
|
172
|
+
.describe("The payment token to cancel"),
|
|
173
|
+
reason: zod_1.z
|
|
174
|
+
.string()
|
|
175
|
+
.describe("Reason for cancellation, e.g. 'Price mismatch: checkout shows $20 but token is $15'"),
|
|
176
|
+
}, async ({ token, reason }) => {
|
|
177
|
+
const result = await (0, supabase_backend_js_1.cancelTokenRemote)(token);
|
|
178
|
+
if (!result.success) {
|
|
179
|
+
return {
|
|
180
|
+
content: [
|
|
181
|
+
{
|
|
182
|
+
type: "text",
|
|
183
|
+
text: "Cancellation failed: Token not found, already used, or already cancelled.",
|
|
184
|
+
},
|
|
185
|
+
],
|
|
186
|
+
isError: true,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
content: [
|
|
191
|
+
{
|
|
192
|
+
type: "text",
|
|
193
|
+
text: JSON.stringify({
|
|
194
|
+
cancelled: true,
|
|
195
|
+
refunded_amount: result.refunded_amount,
|
|
196
|
+
reason,
|
|
197
|
+
message: `Token cancelled. $${result.refunded_amount} has been returned to the wallet. You may request a new token with the correct amount.`,
|
|
198
|
+
}, null, 2),
|
|
199
|
+
},
|
|
200
|
+
],
|
|
201
|
+
};
|
|
202
|
+
});
|
|
203
|
+
// ============================================================
|
|
204
|
+
// TOOL 6: Request human approval (Human-in-the-loop)
|
|
205
|
+
// ============================================================
|
|
206
|
+
server.tool("request_human_approval", "Request human approval before proceeding with an action that requires human judgment. Use this when: (1) checkout price is higher than token amount and you need authorization for a new token, (2) any unusual or irreversible action is required. This PAUSES the bot and waits for human decision.", {
|
|
207
|
+
situation: zod_1.z
|
|
208
|
+
.string()
|
|
209
|
+
.describe("Clear description of what the bot found, e.g. 'Checkout shows $20 total (includes $3 tax) but current token is only $15'"),
|
|
210
|
+
current_token: zod_1.z
|
|
211
|
+
.string()
|
|
212
|
+
.optional()
|
|
213
|
+
.describe("Current active token ID if any"),
|
|
214
|
+
recommended_action: zod_1.z
|
|
215
|
+
.string()
|
|
216
|
+
.describe("What the bot recommends doing, e.g. 'Cancel current $15 token and issue a new $20 token'"),
|
|
217
|
+
alternative_action: zod_1.z
|
|
218
|
+
.string()
|
|
219
|
+
.optional()
|
|
220
|
+
.describe("Alternative option if available"),
|
|
221
|
+
}, async ({ situation, current_token, recommended_action, alternative_action }) => {
|
|
222
|
+
// This tool surfaces the situation to the human operator via the MCP interface.
|
|
223
|
+
// The LLM host (Claude/AutoGPT) will pause and show this to the user.
|
|
224
|
+
const message = [
|
|
225
|
+
"⚠️ HUMAN APPROVAL REQUIRED",
|
|
226
|
+
"",
|
|
227
|
+
`📋 Situation: ${situation}`,
|
|
228
|
+
current_token ? `🎫 Current Token: ${current_token}` : "",
|
|
229
|
+
`✅ Recommended: ${recommended_action}`,
|
|
230
|
+
alternative_action ? `🔄 Alternative: ${alternative_action}` : "",
|
|
231
|
+
"",
|
|
232
|
+
"Please respond with one of:",
|
|
233
|
+
'• "approve" — proceed with recommended action',
|
|
234
|
+
'• "deny" — cancel and do nothing',
|
|
235
|
+
'• Custom instruction — e.g. "issue new token for $22 instead"',
|
|
236
|
+
].filter(Boolean).join("\n");
|
|
237
|
+
return {
|
|
238
|
+
content: [
|
|
239
|
+
{
|
|
240
|
+
type: "text",
|
|
241
|
+
text: message,
|
|
242
|
+
},
|
|
243
|
+
],
|
|
244
|
+
};
|
|
245
|
+
});
|
|
246
|
+
// ============================================================
|
|
157
247
|
// START SERVER
|
|
158
248
|
// ============================================================
|
|
159
249
|
async function main() {
|
|
160
250
|
const transport = new stdio_js_1.StdioServerTransport();
|
|
161
251
|
await server.connect(transport);
|
|
162
|
-
console.error("🔐 Z-ZERO MCP Server running...");
|
|
163
|
-
console.error("
|
|
164
|
-
console.error("Tools: list_cards, check_balance, request_payment_token, execute_payment");
|
|
252
|
+
console.error("🔐 Z-ZERO MCP Server v1.0.2 running...");
|
|
253
|
+
console.error("Backend: Supabase + Airwallex Issuing API (Real JIT Cards)");
|
|
254
|
+
console.error("Tools: list_cards, check_balance, request_payment_token, execute_payment, cancel_payment_token, request_human_approval");
|
|
165
255
|
}
|
|
166
256
|
main().catch(console.error);
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import type { CardData, PaymentToken } from "./types.js";
|
|
2
|
+
interface ExtendedToken extends PaymentToken {
|
|
3
|
+
airwallex_card_id?: string;
|
|
4
|
+
cancelled?: boolean;
|
|
5
|
+
}
|
|
2
6
|
export declare function listCardsRemote(): Promise<Array<{
|
|
3
7
|
alias: string;
|
|
4
8
|
balance: number;
|
|
@@ -8,6 +12,12 @@ export declare function getBalanceRemote(cardAlias: string): Promise<{
|
|
|
8
12
|
balance: number;
|
|
9
13
|
currency: string;
|
|
10
14
|
} | null>;
|
|
11
|
-
export declare function issueTokenRemote(cardAlias: string, amount: number, merchant: string, ttlSeconds?: number): Promise<
|
|
12
|
-
export declare function resolveTokenRemote(tokenId: string): CardData | null
|
|
15
|
+
export declare function issueTokenRemote(cardAlias: string, amount: number, merchant: string, ttlSeconds?: number): Promise<ExtendedToken | null>;
|
|
16
|
+
export declare function resolveTokenRemote(tokenId: string): Promise<CardData | null>;
|
|
17
|
+
export declare function cancelTokenRemote(tokenId: string): Promise<{
|
|
18
|
+
success: boolean;
|
|
19
|
+
refunded_amount: number;
|
|
20
|
+
}>;
|
|
13
21
|
export declare function burnTokenRemote(tokenId: string): Promise<boolean>;
|
|
22
|
+
export declare function refundUnderspendRemote(tokenId: string, actualSpent: number): Promise<void>;
|
|
23
|
+
export {};
|
package/dist/supabase_backend.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
// Z-ZERO Supabase Backend
|
|
3
|
-
//
|
|
4
|
-
// Card data is looked up
|
|
2
|
+
// Z-ZERO Supabase Backend v2
|
|
3
|
+
// Connects to real Supabase DB + Airwallex Issuing API for real JIT Mastercards
|
|
4
|
+
// Card data is looked up via Z_ZERO_API_KEY, cards are issued via Airwallex
|
|
5
5
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
6
6
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
7
7
|
};
|
|
@@ -10,11 +10,13 @@ exports.listCardsRemote = listCardsRemote;
|
|
|
10
10
|
exports.getBalanceRemote = getBalanceRemote;
|
|
11
11
|
exports.issueTokenRemote = issueTokenRemote;
|
|
12
12
|
exports.resolveTokenRemote = resolveTokenRemote;
|
|
13
|
+
exports.cancelTokenRemote = cancelTokenRemote;
|
|
13
14
|
exports.burnTokenRemote = burnTokenRemote;
|
|
15
|
+
exports.refundUnderspendRemote = refundUnderspendRemote;
|
|
14
16
|
const supabase_js_1 = require("@supabase/supabase-js");
|
|
15
17
|
const crypto_1 = __importDefault(require("crypto"));
|
|
16
18
|
// ============================================================
|
|
17
|
-
// SUPABASE CLIENT
|
|
19
|
+
// SUPABASE CLIENT
|
|
18
20
|
// ============================================================
|
|
19
21
|
const SUPABASE_URL = process.env.Z_ZERO_SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL || "";
|
|
20
22
|
const SUPABASE_ANON_KEY = process.env.Z_ZERO_SUPABASE_ANON_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || "";
|
|
@@ -22,21 +24,52 @@ const API_KEY = process.env.Z_ZERO_API_KEY || "";
|
|
|
22
24
|
if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
|
|
23
25
|
console.error("❌ Missing Z_ZERO_SUPABASE_URL or Z_ZERO_SUPABASE_ANON_KEY env vars");
|
|
24
26
|
}
|
|
25
|
-
if (!API_KEY) {
|
|
26
|
-
console.error("⚠️ Missing Z_ZERO_API_KEY — some tools will be limited");
|
|
27
|
-
}
|
|
28
27
|
const supabase = (0, supabase_js_1.createClient)(SUPABASE_URL, SUPABASE_ANON_KEY);
|
|
29
28
|
// ============================================================
|
|
30
|
-
//
|
|
29
|
+
// AIRWALLEX CONFIG
|
|
31
30
|
// ============================================================
|
|
31
|
+
const AIRWALLEX_API_KEY = process.env.Z_ZERO_AIRWALLEX_API_KEY || "";
|
|
32
|
+
const AIRWALLEX_CLIENT_ID = process.env.Z_ZERO_AIRWALLEX_CLIENT_ID || "";
|
|
33
|
+
const AIRWALLEX_ENV = process.env.Z_ZERO_AIRWALLEX_ENV || "demo";
|
|
34
|
+
const AIRWALLEX_BASE = AIRWALLEX_ENV === "prod"
|
|
35
|
+
? "https://api.airwallex.com/api/v1"
|
|
36
|
+
: "https://api-demo.airwallex.com/api/v1";
|
|
37
|
+
// Cached auth token for Airwallex (avoids re-login per call)
|
|
38
|
+
let airwallexToken = null;
|
|
39
|
+
let airwallexTokenExpiry = 0;
|
|
40
|
+
async function getAirwallexToken() {
|
|
41
|
+
if (!AIRWALLEX_API_KEY || !AIRWALLEX_CLIENT_ID)
|
|
42
|
+
return null;
|
|
43
|
+
if (airwallexToken && Date.now() < airwallexTokenExpiry)
|
|
44
|
+
return airwallexToken;
|
|
45
|
+
try {
|
|
46
|
+
const res = await fetch(`${AIRWALLEX_BASE}/authentication/login`, {
|
|
47
|
+
method: "POST",
|
|
48
|
+
headers: {
|
|
49
|
+
"x-api-key": AIRWALLEX_API_KEY,
|
|
50
|
+
"x-client-id": AIRWALLEX_CLIENT_ID,
|
|
51
|
+
"Content-Type": "application/json",
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
if (!res.ok)
|
|
55
|
+
throw new Error(await res.text());
|
|
56
|
+
const data = await res.json();
|
|
57
|
+
airwallexToken = data.token;
|
|
58
|
+
airwallexTokenExpiry = Date.now() + 25 * 60 * 1000; // 25 min cache
|
|
59
|
+
return airwallexToken;
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
console.error("[AIRWALLEX] Auth failed:", err);
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
32
66
|
const tokenStore = new Map();
|
|
33
67
|
// ============================================================
|
|
34
|
-
// RESOLVE API KEY → USER
|
|
68
|
+
// RESOLVE API KEY → USER INFO
|
|
35
69
|
// ============================================================
|
|
36
70
|
async function resolveApiKey() {
|
|
37
71
|
if (!API_KEY)
|
|
38
72
|
return null;
|
|
39
|
-
// Lookup card by its stored key (card_number_encrypted stores the api key for now)
|
|
40
73
|
const { data: card, error } = await supabase
|
|
41
74
|
.from("cards")
|
|
42
75
|
.select("id, alias, user_id, allocated_limit_usd, is_active")
|
|
@@ -44,10 +77,9 @@ async function resolveApiKey() {
|
|
|
44
77
|
.eq("is_active", true)
|
|
45
78
|
.single();
|
|
46
79
|
if (error || !card) {
|
|
47
|
-
console.error("API Key not found
|
|
80
|
+
console.error("API Key not found:", error?.message);
|
|
48
81
|
return null;
|
|
49
82
|
}
|
|
50
|
-
// Get wallet balance for this user
|
|
51
83
|
const { data: wallet } = await supabase
|
|
52
84
|
.from("wallets")
|
|
53
85
|
.select("balance")
|
|
@@ -61,17 +93,13 @@ async function resolveApiKey() {
|
|
|
61
93
|
};
|
|
62
94
|
}
|
|
63
95
|
// ============================================================
|
|
64
|
-
// PUBLIC API
|
|
96
|
+
// PUBLIC API
|
|
65
97
|
// ============================================================
|
|
66
98
|
async function listCardsRemote() {
|
|
67
99
|
const info = await resolveApiKey();
|
|
68
100
|
if (!info)
|
|
69
101
|
return [];
|
|
70
|
-
return [{
|
|
71
|
-
alias: info.cardAlias,
|
|
72
|
-
balance: info.walletBalance,
|
|
73
|
-
currency: "USD",
|
|
74
|
-
}];
|
|
102
|
+
return [{ alias: info.cardAlias, balance: info.walletBalance, currency: "USD" }];
|
|
75
103
|
}
|
|
76
104
|
async function getBalanceRemote(cardAlias) {
|
|
77
105
|
const info = await resolveApiKey();
|
|
@@ -79,37 +107,132 @@ async function getBalanceRemote(cardAlias) {
|
|
|
79
107
|
return null;
|
|
80
108
|
return { balance: info.walletBalance, currency: "USD" };
|
|
81
109
|
}
|
|
82
|
-
async function issueTokenRemote(cardAlias, amount, merchant, ttlSeconds =
|
|
110
|
+
async function issueTokenRemote(cardAlias, amount, merchant, ttlSeconds = 1800 // 30 minutes default
|
|
111
|
+
) {
|
|
112
|
+
// 1. Validate business rules
|
|
113
|
+
if (amount < 1) {
|
|
114
|
+
console.error("[ISSUE] Card amount must be at least $1.00");
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
if (amount > 100) {
|
|
118
|
+
console.error("[ISSUE] Card amount exceeds $100 maximum limit");
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
// 2. Resolve user from API key
|
|
83
122
|
const info = await resolveApiKey();
|
|
84
123
|
if (!info)
|
|
85
124
|
return null;
|
|
86
125
|
if (info.cardAlias !== cardAlias)
|
|
87
126
|
return null;
|
|
88
|
-
if (info.walletBalance < amount)
|
|
127
|
+
if (info.walletBalance < amount) {
|
|
128
|
+
console.error(`[ISSUE] Insufficient balance: $${info.walletBalance} < $${amount}`);
|
|
89
129
|
return null;
|
|
130
|
+
}
|
|
131
|
+
const tokenId = `z_jit_${crypto_1.default.randomBytes(8).toString("hex")}`;
|
|
132
|
+
// 3. Try to create a REAL Airwallex card
|
|
133
|
+
let airwallexCardId;
|
|
134
|
+
const airwallexAuth = await getAirwallexToken();
|
|
135
|
+
if (airwallexAuth) {
|
|
136
|
+
try {
|
|
137
|
+
const expiryTime = new Date(Date.now() + 30 * 60 * 1000).toISOString();
|
|
138
|
+
const payload = {
|
|
139
|
+
request_id: tokenId,
|
|
140
|
+
issue_to: "ORGANISATION",
|
|
141
|
+
name_on_card: "Z-ZERO AI AGENT",
|
|
142
|
+
form_factor: "VIRTUAL",
|
|
143
|
+
primary_currency: "USD",
|
|
144
|
+
valid_to: expiryTime,
|
|
145
|
+
authorization_controls: {
|
|
146
|
+
allowed_transaction_count: "SINGLE",
|
|
147
|
+
transaction_limits: {
|
|
148
|
+
currency: "USD",
|
|
149
|
+
limits: [{ amount, interval: "ALL_TIME" }],
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
const res = await fetch(`${AIRWALLEX_BASE}/issuing/cards/create`, {
|
|
154
|
+
method: "POST",
|
|
155
|
+
headers: {
|
|
156
|
+
Authorization: `Bearer ${airwallexAuth}`,
|
|
157
|
+
"Content-Type": "application/json",
|
|
158
|
+
},
|
|
159
|
+
body: JSON.stringify(payload),
|
|
160
|
+
});
|
|
161
|
+
if (res.ok) {
|
|
162
|
+
const data = await res.json();
|
|
163
|
+
airwallexCardId = data.card_id;
|
|
164
|
+
console.error(`[AIRWALLEX] ✅ Real card issued: ${data.card_id} for $${amount}`);
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
const errText = await res.text();
|
|
168
|
+
console.error("[AIRWALLEX] Card creation failed:", errText);
|
|
169
|
+
// Fall through — will still create token but without real card ID
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
console.error("[AIRWALLEX] Error creating card:", err);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
console.error("[AIRWALLEX] No auth token — running in mock mode");
|
|
178
|
+
}
|
|
90
179
|
const token = {
|
|
91
|
-
token:
|
|
180
|
+
token: tokenId,
|
|
92
181
|
card_alias: cardAlias,
|
|
93
182
|
amount,
|
|
94
183
|
merchant,
|
|
95
184
|
created_at: Date.now(),
|
|
96
185
|
ttl_seconds: ttlSeconds,
|
|
97
186
|
used: false,
|
|
187
|
+
cancelled: false,
|
|
188
|
+
airwallex_card_id: airwallexCardId,
|
|
98
189
|
};
|
|
99
|
-
tokenStore.set(
|
|
190
|
+
tokenStore.set(tokenId, token);
|
|
191
|
+
// 4. Pre-hold the amount in Supabase (deduct immediately, refund on cancel or underspend)
|
|
192
|
+
const newBalance = info.walletBalance - amount;
|
|
193
|
+
await supabase
|
|
194
|
+
.from("wallets")
|
|
195
|
+
.update({ balance: newBalance })
|
|
196
|
+
.eq("user_id", info.userId);
|
|
197
|
+
console.error(`[ISSUE] Token ${tokenId} created. Balance: $${info.walletBalance} → $${newBalance}`);
|
|
100
198
|
return token;
|
|
101
199
|
}
|
|
102
|
-
function resolveTokenRemote(tokenId) {
|
|
200
|
+
async function resolveTokenRemote(tokenId) {
|
|
103
201
|
const token = tokenStore.get(tokenId);
|
|
104
|
-
if (!token || token.used)
|
|
202
|
+
if (!token || token.used || token.cancelled)
|
|
105
203
|
return null;
|
|
106
204
|
const age = (Date.now() - token.created_at) / 1000;
|
|
107
205
|
if (age > token.ttl_seconds) {
|
|
108
206
|
tokenStore.delete(tokenId);
|
|
109
207
|
return null;
|
|
110
208
|
}
|
|
111
|
-
//
|
|
112
|
-
|
|
209
|
+
// Try to fetch REAL card details from Airwallex
|
|
210
|
+
if (token.airwallex_card_id) {
|
|
211
|
+
const airwallexAuth = await getAirwallexToken();
|
|
212
|
+
if (airwallexAuth) {
|
|
213
|
+
try {
|
|
214
|
+
const res = await fetch(`${AIRWALLEX_BASE}/issuing/cards/${token.airwallex_card_id}`, {
|
|
215
|
+
headers: { Authorization: `Bearer ${airwallexAuth}` },
|
|
216
|
+
});
|
|
217
|
+
if (res.ok) {
|
|
218
|
+
const data = await res.json();
|
|
219
|
+
console.error("[AIRWALLEX] ✅ Real card details fetched.");
|
|
220
|
+
return {
|
|
221
|
+
number: data.card_number || "4242424242424242",
|
|
222
|
+
exp_month: data.expiry_month || "12",
|
|
223
|
+
exp_year: data.expiry_year || "2030",
|
|
224
|
+
cvv: data.cvv || "123",
|
|
225
|
+
name: data.name_on_card || "Z-ZERO AI AGENT",
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
catch (err) {
|
|
230
|
+
console.error("[AIRWALLEX] Failed to fetch card details:", err);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// Fallback to Stripe test card (demo/sandbox mode)
|
|
235
|
+
console.error("[AIRWALLEX] ⚠️ Using test card fallback (4242...)");
|
|
113
236
|
return {
|
|
114
237
|
number: "4242424242424242",
|
|
115
238
|
exp_month: "12",
|
|
@@ -118,20 +241,56 @@ function resolveTokenRemote(tokenId) {
|
|
|
118
241
|
name: "Z-ZERO Agent",
|
|
119
242
|
};
|
|
120
243
|
}
|
|
121
|
-
async function
|
|
244
|
+
async function cancelTokenRemote(tokenId) {
|
|
122
245
|
const token = tokenStore.get(tokenId);
|
|
123
|
-
if (!token)
|
|
124
|
-
return false;
|
|
125
|
-
|
|
126
|
-
//
|
|
246
|
+
if (!token || token.used || token.cancelled) {
|
|
247
|
+
return { success: false, refunded_amount: 0 };
|
|
248
|
+
}
|
|
249
|
+
// Mark token as cancelled in memory
|
|
250
|
+
token.cancelled = true;
|
|
251
|
+
// Cancel the Airwallex card (if one was issued)
|
|
252
|
+
if (token.airwallex_card_id) {
|
|
253
|
+
const airwallexAuth = await getAirwallexToken();
|
|
254
|
+
if (airwallexAuth) {
|
|
255
|
+
try {
|
|
256
|
+
await fetch(`${AIRWALLEX_BASE}/issuing/cards/${token.airwallex_card_id}/deactivate`, {
|
|
257
|
+
method: "POST",
|
|
258
|
+
headers: { Authorization: `Bearer ${airwallexAuth}`, "Content-Type": "application/json" },
|
|
259
|
+
body: JSON.stringify({ cancellation_reason: "CANCELLED_BY_USER" }),
|
|
260
|
+
});
|
|
261
|
+
console.error(`[AIRWALLEX] Card ${token.airwallex_card_id} cancelled.`);
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
console.error("[AIRWALLEX] Failed to cancel card:", err);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
// Refund the held amount back to wallet
|
|
127
269
|
const info = await resolveApiKey();
|
|
128
270
|
if (info) {
|
|
129
|
-
const
|
|
271
|
+
const currentBalanceRes = await supabase
|
|
272
|
+
.from("wallets")
|
|
273
|
+
.select("balance")
|
|
274
|
+
.eq("user_id", info.userId)
|
|
275
|
+
.single();
|
|
276
|
+
const currentBalance = Number(currentBalanceRes.data?.balance || 0);
|
|
130
277
|
await supabase
|
|
131
278
|
.from("wallets")
|
|
132
|
-
.update({ balance:
|
|
279
|
+
.update({ balance: currentBalance + token.amount })
|
|
133
280
|
.eq("user_id", info.userId);
|
|
134
|
-
|
|
281
|
+
console.error(`[CANCEL] Refunded $${token.amount}. New balance: $${currentBalance + token.amount}`);
|
|
282
|
+
}
|
|
283
|
+
setTimeout(() => tokenStore.delete(tokenId), 5000);
|
|
284
|
+
return { success: true, refunded_amount: token.amount };
|
|
285
|
+
}
|
|
286
|
+
async function burnTokenRemote(tokenId) {
|
|
287
|
+
const token = tokenStore.get(tokenId);
|
|
288
|
+
if (!token || token.cancelled)
|
|
289
|
+
return false;
|
|
290
|
+
token.used = true;
|
|
291
|
+
// Log the transaction — NOTE: wallet was already debited at issue time
|
|
292
|
+
const info = await resolveApiKey();
|
|
293
|
+
if (info) {
|
|
135
294
|
await supabase.from("transactions").insert({
|
|
136
295
|
card_id: info.cardId,
|
|
137
296
|
amount: token.amount,
|
|
@@ -142,3 +301,26 @@ async function burnTokenRemote(tokenId) {
|
|
|
142
301
|
setTimeout(() => tokenStore.delete(tokenId), 5000);
|
|
143
302
|
return true;
|
|
144
303
|
}
|
|
304
|
+
// Handle underspend: called when transaction amount < token amount
|
|
305
|
+
async function refundUnderspendRemote(tokenId, actualSpent) {
|
|
306
|
+
const token = tokenStore.get(tokenId);
|
|
307
|
+
if (!token)
|
|
308
|
+
return;
|
|
309
|
+
const overheld = token.amount - actualSpent;
|
|
310
|
+
if (overheld <= 0)
|
|
311
|
+
return;
|
|
312
|
+
const info = await resolveApiKey();
|
|
313
|
+
if (!info)
|
|
314
|
+
return;
|
|
315
|
+
const currentBalanceRes = await supabase
|
|
316
|
+
.from("wallets")
|
|
317
|
+
.select("balance")
|
|
318
|
+
.eq("user_id", info.userId)
|
|
319
|
+
.single();
|
|
320
|
+
const currentBalance = Number(currentBalanceRes.data?.balance || 0);
|
|
321
|
+
await supabase
|
|
322
|
+
.from("wallets")
|
|
323
|
+
.update({ balance: currentBalance + overheld })
|
|
324
|
+
.eq("user_id", info.userId);
|
|
325
|
+
console.error(`[REFUND] Underspend refunded: $${overheld} (Spent $${actualSpent} of $${token.amount})`);
|
|
326
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "z-zero-mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "Z-ZERO MCP Server — Secure JIT Virtual Card Payment tools for AI Agents (Claude, AutoGPT, CrewAI)",
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "Z-ZERO MCP Server — Secure JIT Virtual Card Payment tools for AI Agents (Claude, AutoGPT, CrewAI) — Real Airwallex Mastercards",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": "dist/index.js",
|
|
7
7
|
"scripts": {
|