@curless/mcp-server 0.1.10
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/README.md +244 -0
- package/dist/client.d.ts +37 -0
- package/dist/client.js +89 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +2007 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2007 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Curless MCP Server — lets any MCP-aware client (Claude Desktop, Cursor,
|
|
4
|
+
* Cline, custom LangChain agent, …) drive a Curless wallet + VCC to order
|
|
5
|
+
* coffee, book hotels, and buy office supplies through the Curless backend.
|
|
6
|
+
*
|
|
7
|
+
* v0.1 = single-user demo mode. All installs share the same demo wallets
|
|
8
|
+
* (wal-001..wal-004). v0.2 will introduce per-user API keys.
|
|
9
|
+
*/
|
|
10
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
11
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
12
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
13
|
+
import { CurlessClient, HttpError, unwrap } from './client.js';
|
|
14
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
15
|
+
// Config
|
|
16
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
17
|
+
const DEFAULT_WALLET_ID = process.env.CURLESS_WALLET_ID || 'wal-002';
|
|
18
|
+
const DEFAULT_AGENT_ID = process.env.CURLESS_AGENT_ID || 'agent-002';
|
|
19
|
+
// Records persisted by Curless are stamped with an `mcp-` prefix so they
|
|
20
|
+
// can be distinguished from web-UI traffic in the merchant dashboard.
|
|
21
|
+
// Per-tool overrides where applicable so coffee orders land under
|
|
22
|
+
// ProcureAI, hotel bookings under TravelBot, etc.
|
|
23
|
+
const STAMP_PREFIX = process.env.CURLESS_STAMP_PREFIX || 'mcp';
|
|
24
|
+
const state = {
|
|
25
|
+
lastVccId: null,
|
|
26
|
+
lastClubmedProperty: null,
|
|
27
|
+
walletId: null,
|
|
28
|
+
};
|
|
29
|
+
/** Resolve the wallet id for tools: runtime switch_wallet override > env. */
|
|
30
|
+
function currentWalletId() {
|
|
31
|
+
return state.walletId ?? DEFAULT_WALLET_ID;
|
|
32
|
+
}
|
|
33
|
+
const client = new CurlessClient();
|
|
34
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
35
|
+
// Helpers
|
|
36
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
37
|
+
function stampAgent(scope) {
|
|
38
|
+
const agent = scope === 'coffee' ? 'agent-002' :
|
|
39
|
+
scope === 'hotel' ? 'agent-001' :
|
|
40
|
+
scope === 'supply' ? 'agent-004' :
|
|
41
|
+
DEFAULT_AGENT_ID;
|
|
42
|
+
// SUPPLY shares per-agent state with the web UI (inventory + reorder
|
|
43
|
+
// rules + order list ALL filter by agent_id). The web UI's LLM tools
|
|
44
|
+
// write bare `agent-004` — if MCP prefixes, we end up in a separate
|
|
45
|
+
// shadow inventory and `check_inventory` returns different rows
|
|
46
|
+
// depending on whether the user asked via Claude Desktop or the web
|
|
47
|
+
// chat. Bare canonical agent for supply inventory scope.
|
|
48
|
+
if (scope === 'supply')
|
|
49
|
+
return agent;
|
|
50
|
+
return `${STAMP_PREFIX}-${agent}`;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Audit identity of this MCP install for payment records — distinct
|
|
54
|
+
* from inventory scope. Always carries the stamp prefix so charge
|
|
55
|
+
* records correctly attribute to the originating MCP caller, even
|
|
56
|
+
* when the inventory scope is a shared bare agent_id (e.g. supplies).
|
|
57
|
+
* Spark bug #1 (2026-05-12): supply payments were dropping the MCP
|
|
58
|
+
* caller and showing bare 'agent-004' on incoming_payments.
|
|
59
|
+
*/
|
|
60
|
+
function callerAgentId() {
|
|
61
|
+
return `${STAMP_PREFIX}-${DEFAULT_AGENT_ID}`;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Resolve a default VCC when the caller didn't supply one. Spark MCP
|
|
65
|
+
* audit bug A (2026-05-12): `state.lastVccId` is null at the start of
|
|
66
|
+
* every new MCP session — so the documented "defaults to the most
|
|
67
|
+
* recently created VCC" semantics actually fail after a server
|
|
68
|
+
* restart. Fall back to /v1/cards filtered by this install's stamp
|
|
69
|
+
* prefix, picking the most recently-created active card that satisfies
|
|
70
|
+
* the merchant lock + estimated charge.
|
|
71
|
+
*
|
|
72
|
+
* Returns null if no suitable card exists — the caller surfaces a
|
|
73
|
+
* helpful error instead of silently picking nothing.
|
|
74
|
+
*/
|
|
75
|
+
/**
|
|
76
|
+
* Display-name mapping for merchant_lock values. The internal enum
|
|
77
|
+
* value stays as `cotti` / `clubmed` / `procurement` so existing
|
|
78
|
+
* card rows, charge routing, and approval gates don't have to be
|
|
79
|
+
* migrated, but every surface that shows a lock to a human or LLM
|
|
80
|
+
* gets the friendly brand name through this helper.
|
|
81
|
+
*
|
|
82
|
+
* 'cotti' → 'Cotti Coffee' (coffee merchant rebrand, May 2026)
|
|
83
|
+
* 'clubmed' → 'Club Med'
|
|
84
|
+
* 'procurement' → 'ProcureAI'
|
|
85
|
+
* 'any' / '' → 'Any merchant'
|
|
86
|
+
*/
|
|
87
|
+
function merchantLockLabel(lock) {
|
|
88
|
+
const v = String(lock || '').toLowerCase();
|
|
89
|
+
// 'cotti' is the canonical post-rebrand value; 'cotti' is kept
|
|
90
|
+
// as a legacy alias so any pre-migration card row still labels
|
|
91
|
+
// correctly during the rollout.
|
|
92
|
+
if (v === 'cotti' || v === 'cotti')
|
|
93
|
+
return 'Cotti Coffee';
|
|
94
|
+
if (v === 'clubmed')
|
|
95
|
+
return 'Club Med';
|
|
96
|
+
if (v === 'procurement')
|
|
97
|
+
return 'ProcureAI';
|
|
98
|
+
if (v === 'any' || v === '')
|
|
99
|
+
return 'Any merchant';
|
|
100
|
+
return v;
|
|
101
|
+
}
|
|
102
|
+
async function resolveDefaultVcc(opts) {
|
|
103
|
+
if (state.lastVccId)
|
|
104
|
+
return state.lastVccId;
|
|
105
|
+
try {
|
|
106
|
+
const data = unwrap(await client.get('/v1/cards'));
|
|
107
|
+
const all = Array.isArray(data) ? data : [];
|
|
108
|
+
const ownPrefix = `${STAMP_PREFIX}-`;
|
|
109
|
+
const minBalance = Number(opts.minBalance ?? 0);
|
|
110
|
+
const matching = all
|
|
111
|
+
.filter((c) => {
|
|
112
|
+
if (!String(c.agent_id ?? '').startsWith(ownPrefix))
|
|
113
|
+
return false;
|
|
114
|
+
if (c.status !== 'active')
|
|
115
|
+
return false;
|
|
116
|
+
// Alias 'cotti' → 'cotti' so legacy card rows match the new
|
|
117
|
+
// canonical lock value even before they're migrated.
|
|
118
|
+
let lock = String(c.merchant_lock ?? '').toLowerCase();
|
|
119
|
+
if (lock === 'cotti')
|
|
120
|
+
lock = 'cotti';
|
|
121
|
+
const lockOk = !opts.merchantLock
|
|
122
|
+
|| lock === ''
|
|
123
|
+
|| lock === 'any'
|
|
124
|
+
|| lock === opts.merchantLock;
|
|
125
|
+
if (!lockOk)
|
|
126
|
+
return false;
|
|
127
|
+
if (minBalance > 0 && Number(c.balance ?? 0) < minBalance)
|
|
128
|
+
return false;
|
|
129
|
+
return true;
|
|
130
|
+
})
|
|
131
|
+
.sort((a, b) => String(b.created_at ?? '').localeCompare(String(a.created_at ?? '')));
|
|
132
|
+
if (matching.length > 0) {
|
|
133
|
+
state.lastVccId = matching[0].id; // cache for subsequent calls in this session
|
|
134
|
+
return matching[0].id;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
/* fall through to null */
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
/** Strip `_iframe_navigate` from a tool result — the local client doesn't
|
|
143
|
+
* have an iframe to drive. Everything else passes through. */
|
|
144
|
+
function stripIframe(obj) {
|
|
145
|
+
if (!obj || typeof obj !== 'object')
|
|
146
|
+
return obj;
|
|
147
|
+
const { _iframe_navigate, ...rest } = obj;
|
|
148
|
+
return rest;
|
|
149
|
+
}
|
|
150
|
+
/** Standardised error envelope so the LLM client gets the same shape on
|
|
151
|
+
* HTTP failures as on tool-level errors. */
|
|
152
|
+
function toErrorResult(e, hint) {
|
|
153
|
+
if (e instanceof HttpError) {
|
|
154
|
+
const body = e.body;
|
|
155
|
+
if (typeof body === 'object' && body) {
|
|
156
|
+
return { error: body.error ?? 'http_error', status: e.status, hint, ...body };
|
|
157
|
+
}
|
|
158
|
+
return { error: 'http_error', status: e.status, detail: String(body), hint };
|
|
159
|
+
}
|
|
160
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
161
|
+
return { error: 'unknown_error', detail: message, hint };
|
|
162
|
+
}
|
|
163
|
+
const TOOLS = [
|
|
164
|
+
// ─── VCC / WALLET CORE ────────────────────────────────────────────────
|
|
165
|
+
{
|
|
166
|
+
name: 'create_vcc',
|
|
167
|
+
description: '[curless-mcp-server v0.1.9] Mint a virtual credit card backed by the Curless wallet. Returns the VCC id (remembered as the default for subsequent tool calls). The card\'s merchant_lock is ENFORCED at charge time — if you lock to one merchant, the card cannot be used at other merchants. Default lock is "any" (unrestricted). VERSION STAMP NOTE: if you observe this description changing without the version suffix advancing, that\'s a governance bug — report it. Description content is part of the API contract.',
|
|
168
|
+
inputSchema: {
|
|
169
|
+
type: 'object',
|
|
170
|
+
properties: {
|
|
171
|
+
amount: {
|
|
172
|
+
type: 'number',
|
|
173
|
+
minimum: 1,
|
|
174
|
+
maximum: 100000,
|
|
175
|
+
description: 'Spending limit in USD. Default 5000.',
|
|
176
|
+
},
|
|
177
|
+
merchant_lock: {
|
|
178
|
+
type: 'string',
|
|
179
|
+
enum: ['any', 'clubmed', 'cotti', 'procurement'],
|
|
180
|
+
description: 'Restrict which merchant this card can pay. ENFORCED at charge time. The enum values are INTERNAL codes — when you describe the card to the user, say "Club Med" for clubmed, "Cotti Coffee" for cotti (the coffee merchant), "ProcureAI" for procurement, "Any merchant" for any. The display name comes back on every VCC response as `merchant_lock`; the raw enum lives under `merchant_lock_id`. Use "any" (the default) for a single card that pays every demo merchant.',
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
handler: async (args) => {
|
|
185
|
+
const amount = typeof args.amount === 'number' ? args.amount : 5000;
|
|
186
|
+
// Default 'any' — gives the user a single card that works at every
|
|
187
|
+
// demo merchant. The backend risk-rule whitelist (rule-005) accepts
|
|
188
|
+
// 'any', and merchantChargeService treats 'any' as bypass for the
|
|
189
|
+
// per-merchant lock check. Locked values still work and ARE
|
|
190
|
+
// enforced at charge time.
|
|
191
|
+
const merchant_lock = typeof args.merchant_lock === 'string' ? args.merchant_lock : 'any';
|
|
192
|
+
const data = unwrap(await client.post('/v1/hotels/quick-vcc', {
|
|
193
|
+
amount,
|
|
194
|
+
merchant_lock,
|
|
195
|
+
agent_id: `${STAMP_PREFIX}-${DEFAULT_AGENT_ID}`,
|
|
196
|
+
}));
|
|
197
|
+
state.lastVccId = data.id;
|
|
198
|
+
return {
|
|
199
|
+
vcc_id: data.id,
|
|
200
|
+
last_four: data.last_four,
|
|
201
|
+
expiry: data.expiry,
|
|
202
|
+
limit_usd: data.max_amount,
|
|
203
|
+
balance_usd: 0,
|
|
204
|
+
balance_usdc: 0,
|
|
205
|
+
// merchant_lock is the DISPLAY string ('Cotti Coffee' / 'Club Med' /
|
|
206
|
+
// 'ProcureAI' / 'Any merchant'). The raw enum lives under
|
|
207
|
+
// merchant_lock_id for callers that route on it programmatically.
|
|
208
|
+
// We don't return the raw enum under `merchant_lock` because the
|
|
209
|
+
// LLM picks it up verbatim and surfaces "cotti" to users.
|
|
210
|
+
merchant_lock: merchantLockLabel(data.merchant_lock),
|
|
211
|
+
merchant_lock_id: data.merchant_lock,
|
|
212
|
+
hint: 'Card is empty — call top_up_vcc to load USD from the wallet.',
|
|
213
|
+
};
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
name: 'top_up_vcc',
|
|
218
|
+
description: 'Move funds from a wallet bucket onto a VCC card. The card balance goes up; the named wallet bucket goes down by the same amount. Uses the most-recently-created VCC by default. Two-step under the hood: withdraw from wallet bucket, then credit the card. Wallets keep INDEPENDENT per-currency balances (USD ≠ USDC) — pass `currency` to choose which bucket to draw from (defaults to USDC since that\'s the stablecoin path). If the chosen bucket has insufficient cash, the tool returns insufficient_funds even when other buckets have money.',
|
|
219
|
+
inputSchema: {
|
|
220
|
+
type: 'object',
|
|
221
|
+
required: ['amount_usdc'],
|
|
222
|
+
properties: {
|
|
223
|
+
amount_usdc: { type: 'number', minimum: 0.01, description: 'Amount to load onto the card.' },
|
|
224
|
+
vcc_id: { type: 'string', description: 'Defaults to the most recently created VCC.' },
|
|
225
|
+
wallet_id: { type: 'string', description: 'Defaults to CURLESS_WALLET_ID.' },
|
|
226
|
+
currency: {
|
|
227
|
+
type: 'string',
|
|
228
|
+
enum: ['USDC', 'USD', 'USDT', 'EURC', 'EUR'],
|
|
229
|
+
description: 'Wallet bucket to draw from. Defaults to USDC.',
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
handler: async (args) => {
|
|
234
|
+
const amount = Number(args.amount_usdc);
|
|
235
|
+
const vccId = (typeof args.vcc_id === 'string' && args.vcc_id) || state.lastVccId;
|
|
236
|
+
const walletId = (typeof args.wallet_id === 'string' && args.wallet_id) || currentWalletId();
|
|
237
|
+
const currency = typeof args.currency === 'string' && args.currency
|
|
238
|
+
? String(args.currency).toUpperCase()
|
|
239
|
+
: 'USDC';
|
|
240
|
+
if (!vccId)
|
|
241
|
+
return { error: 'no_vcc', hint: 'Call create_vcc first or pass vcc_id explicitly.' };
|
|
242
|
+
// Step 1: debit the requested wallet bucket. Backend's
|
|
243
|
+
// per-currency check is strict — a USDC withdraw won't fall
|
|
244
|
+
// back to USD if USDC is empty.
|
|
245
|
+
let walletRes;
|
|
246
|
+
try {
|
|
247
|
+
walletRes = unwrap(await client.post(`/v1/wallets/${encodeURIComponent(walletId)}/withdraw`, {
|
|
248
|
+
amount,
|
|
249
|
+
currency,
|
|
250
|
+
destination: 'vcc',
|
|
251
|
+
}));
|
|
252
|
+
}
|
|
253
|
+
catch (e) {
|
|
254
|
+
return toErrorResult(e, `Wallet withdraw failed — the wallet's ${currency} bucket may not have enough cash. Check balances with get_wallet_info; other buckets won't be drawn from automatically.`);
|
|
255
|
+
}
|
|
256
|
+
// Step 2: credit card. Use pin_funding_wallet_id (not
|
|
257
|
+
// source_wallet_id) — the wallet was ALREADY debited in
|
|
258
|
+
// step 1, and source_wallet_id would trigger fundCard's
|
|
259
|
+
// atomic-debit path, draining the wallet a SECOND time
|
|
260
|
+
// (= 2026-05-13 $2,160 orphan: wallet was already $0 from
|
|
261
|
+
// step 1 so the second debit threw insufficient_funds,
|
|
262
|
+
// card credit got rolled back, but step 1's debit was
|
|
263
|
+
// already durable in PG). pin_funding_wallet_id only
|
|
264
|
+
// stamps funding_wallet_id without touching wallet balance.
|
|
265
|
+
let cardRes;
|
|
266
|
+
try {
|
|
267
|
+
cardRes = unwrap(await client.post(`/v1/cards/${encodeURIComponent(vccId)}/fund`, {
|
|
268
|
+
amount,
|
|
269
|
+
currency,
|
|
270
|
+
pin_funding_wallet_id: walletId,
|
|
271
|
+
}));
|
|
272
|
+
}
|
|
273
|
+
catch (cardErr) {
|
|
274
|
+
// Defense-in-depth rollback: step 1 already moved money,
|
|
275
|
+
// but step 2 just failed. Auto-refund the SAME bucket so
|
|
276
|
+
// we never leave funds stranded. If the rollback itself
|
|
277
|
+
// fails, surface a critical message so ops can reconcile.
|
|
278
|
+
let rollbackOk = false;
|
|
279
|
+
let rollbackMsg = '';
|
|
280
|
+
try {
|
|
281
|
+
await client.post(`/v1/wallets/${encodeURIComponent(walletId)}/fund`, {
|
|
282
|
+
amount,
|
|
283
|
+
currency,
|
|
284
|
+
});
|
|
285
|
+
rollbackOk = true;
|
|
286
|
+
}
|
|
287
|
+
catch (rbErr) {
|
|
288
|
+
rollbackMsg = (rbErr instanceof Error ? rbErr.message : String(rbErr));
|
|
289
|
+
}
|
|
290
|
+
return toErrorResult(cardErr, rollbackOk
|
|
291
|
+
? `Card credit failed; wallet has been auto-refunded ${amount} ${currency}. Safe to retry top_up_vcc.`
|
|
292
|
+
: `CRITICAL: card credit failed AND auto-refund failed (${rollbackMsg}). Wallet may be missing ${amount} ${currency}. Contact support with wallet_id=${walletId}, vcc_id=${vccId}.`);
|
|
293
|
+
}
|
|
294
|
+
let parsed = {};
|
|
295
|
+
try {
|
|
296
|
+
parsed = typeof walletRes?.balances === 'string'
|
|
297
|
+
? JSON.parse(walletRes.balances)
|
|
298
|
+
: (walletRes?.balances || {});
|
|
299
|
+
}
|
|
300
|
+
catch { /* ignore */ }
|
|
301
|
+
const cardBal = Number(cardRes?.balance ?? 0);
|
|
302
|
+
return {
|
|
303
|
+
vcc_id: vccId,
|
|
304
|
+
wallet_id: walletId,
|
|
305
|
+
amount_loaded: amount,
|
|
306
|
+
loaded_currency: currency,
|
|
307
|
+
new_card_balance_usd: cardBal,
|
|
308
|
+
// Two INDEPENDENT figures — distinct buckets, not mirrors.
|
|
309
|
+
new_wallet_balance_usd: Number(parsed.USD ?? 0),
|
|
310
|
+
new_wallet_balance_usdc: Number(parsed.USDC ?? 0),
|
|
311
|
+
balances: parsed,
|
|
312
|
+
status: 'loaded',
|
|
313
|
+
};
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
name: 'unload_vcc',
|
|
318
|
+
description: '[curless-mcp-server v0.1.9] Inverse of top_up_vcc. Move funds FROM a VCC BACK INTO a wallet bucket — useful for unloading an unused card, recovering an over-topped-up balance, or consolidating funds before closing a card. The card balance goes DOWN by `amount_usdc`; the named wallet bucket goes UP by the same. Specify `currency` to choose which destination bucket gets credited (defaults to USDC, matching the default top_up direction). Wallets keep INDEPENDENT per-currency balances. Refuses if (a) the card lacks balance — returns insufficient_funds, (b) the destination wallet is frozen or closed. Atomic at the server: if the wallet credit fails, the card decrement is rolled back so the user never loses money.',
|
|
319
|
+
inputSchema: {
|
|
320
|
+
type: 'object',
|
|
321
|
+
required: ['amount_usdc'],
|
|
322
|
+
properties: {
|
|
323
|
+
amount_usdc: { type: 'number', minimum: 0.01, description: 'Amount to move off the card.' },
|
|
324
|
+
vcc_id: { type: 'string', description: 'Defaults to the most recently created VCC in this session.' },
|
|
325
|
+
wallet_id: { type: 'string', description: 'Destination wallet. Defaults to the active wallet (CURLESS_WALLET_ID env or last switch_wallet).' },
|
|
326
|
+
currency: {
|
|
327
|
+
type: 'string',
|
|
328
|
+
enum: ['USDC', 'USD', 'USDT', 'EURC', 'EUR'],
|
|
329
|
+
description: 'Destination bucket on the wallet. Defaults to USDC.',
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
handler: async (args) => {
|
|
334
|
+
const amount = Number(args.amount_usdc);
|
|
335
|
+
const vccId = (typeof args.vcc_id === 'string' && args.vcc_id) || state.lastVccId;
|
|
336
|
+
const walletId = (typeof args.wallet_id === 'string' && args.wallet_id) || currentWalletId();
|
|
337
|
+
// Choose which destination bucket to credit on the wallet.
|
|
338
|
+
// Defaults to USDC — that's the inverse of the typical
|
|
339
|
+
// stablecoin top_up. Pass currency='USD' to land in the fiat
|
|
340
|
+
// bucket instead.
|
|
341
|
+
const currency = typeof args.currency === 'string' && args.currency
|
|
342
|
+
? String(args.currency).toUpperCase()
|
|
343
|
+
: 'USDC';
|
|
344
|
+
if (!vccId)
|
|
345
|
+
return { error: 'no_vcc', hint: 'No VCC to unload from. Pass vcc_id or create_vcc first.' };
|
|
346
|
+
if (!Number.isFinite(amount) || amount <= 0)
|
|
347
|
+
return { error: 'bad_amount', hint: 'amount_usdc must be a positive number.' };
|
|
348
|
+
try {
|
|
349
|
+
const res = unwrap(await client.post(`/v1/cards/${encodeURIComponent(vccId)}/unload`, {
|
|
350
|
+
wallet_id: walletId,
|
|
351
|
+
amount,
|
|
352
|
+
currency,
|
|
353
|
+
}));
|
|
354
|
+
const cardBal = Number(res?.card_balance_after ?? 0);
|
|
355
|
+
// Pull the post-unload wallet snapshot. The legacy
|
|
356
|
+
// `wallet_usdc_after` field may not split per-currency,
|
|
357
|
+
// so prefer the balances JSON when present.
|
|
358
|
+
let parsedWallet = {};
|
|
359
|
+
try {
|
|
360
|
+
const b = res?.balances ?? res?.wallet_balances;
|
|
361
|
+
parsedWallet = typeof b === 'string' ? JSON.parse(b) : (b || {});
|
|
362
|
+
}
|
|
363
|
+
catch { /* ignore */ }
|
|
364
|
+
return {
|
|
365
|
+
vcc_id: vccId,
|
|
366
|
+
wallet_id: walletId,
|
|
367
|
+
amount_unloaded: amount,
|
|
368
|
+
unloaded_currency: currency,
|
|
369
|
+
new_card_balance_usd: cardBal,
|
|
370
|
+
// Two INDEPENDENT figures (USD bucket vs USDC bucket).
|
|
371
|
+
new_wallet_balance_usd: Number(parsedWallet.USD ?? 0),
|
|
372
|
+
new_wallet_balance_usdc: Number(parsedWallet.USDC ?? res?.wallet_usdc_after ?? 0),
|
|
373
|
+
balances: parsedWallet,
|
|
374
|
+
status: 'unloaded',
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
catch (e) {
|
|
378
|
+
return toErrorResult(e, 'Unload failed — check that the card has enough balance and the wallet is active.');
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
name: 'update_vcc_limit',
|
|
384
|
+
description: 'Raise or lower the spending limit of a VCC.',
|
|
385
|
+
inputSchema: {
|
|
386
|
+
type: 'object',
|
|
387
|
+
required: ['new_limit_usd'],
|
|
388
|
+
properties: {
|
|
389
|
+
new_limit_usd: { type: 'number', minimum: 1, maximum: 100000 },
|
|
390
|
+
vcc_id: { type: 'string', description: 'Defaults to the most recently created VCC.' },
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
handler: async (args) => {
|
|
394
|
+
const vccId = (typeof args.vcc_id === 'string' && args.vcc_id) || state.lastVccId;
|
|
395
|
+
if (!vccId)
|
|
396
|
+
return { error: 'no_vcc', hint: 'Call create_vcc first or pass vcc_id explicitly.' };
|
|
397
|
+
const newLimit = Number(args.new_limit_usd);
|
|
398
|
+
try {
|
|
399
|
+
const data = unwrap(await client.put(`/v1/vcc/${encodeURIComponent(vccId)}/limit`, { max_amount: newLimit }));
|
|
400
|
+
return {
|
|
401
|
+
vcc_id: vccId,
|
|
402
|
+
new_limit_usd: data?.max_amount ?? newLimit,
|
|
403
|
+
status: 'updated',
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
catch (e) {
|
|
407
|
+
return toErrorResult(e);
|
|
408
|
+
}
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
name: 'get_vcc_info',
|
|
413
|
+
description: 'Look up details of a VCC: last four, expiry, spending limit, current balance, merchant lock, status. Returns archived=true for cards that have been closed (residual balance was zeroed). Backend now PG-fallbacks for cross-lambda visibility, so newly-minted cards no longer 404.',
|
|
414
|
+
inputSchema: {
|
|
415
|
+
type: 'object',
|
|
416
|
+
properties: {
|
|
417
|
+
vcc_id: { type: 'string', description: 'Defaults to the most recently created VCC.' },
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
handler: async (args) => {
|
|
421
|
+
const vccId = (typeof args.vcc_id === 'string' && args.vcc_id) || state.lastVccId;
|
|
422
|
+
if (!vccId)
|
|
423
|
+
return { error: 'no_vcc' };
|
|
424
|
+
try {
|
|
425
|
+
const data = unwrap(await client.get(`/v1/vcc/${encodeURIComponent(vccId)}`));
|
|
426
|
+
// Prefer the new balance_usd field from the hydrated /v1/vcc/:id
|
|
427
|
+
// endpoint; fall back to legacy `balance` if the backend hasn't
|
|
428
|
+
// deployed yet (graceful during rollout).
|
|
429
|
+
const bal = Number(data?.balance_usd ?? data?.balance ?? 0);
|
|
430
|
+
return {
|
|
431
|
+
vcc_id: vccId,
|
|
432
|
+
last_four: data?.last_four,
|
|
433
|
+
expiry: data?.expiry,
|
|
434
|
+
limit_usd: data?.max_amount,
|
|
435
|
+
balance_usd: bal,
|
|
436
|
+
balance_usdc: bal,
|
|
437
|
+
// Display string only — raw enum lives under merchant_lock_id.
|
|
438
|
+
merchant_lock: merchantLockLabel(data?.merchant_lock),
|
|
439
|
+
merchant_lock_id: data?.merchant_lock,
|
|
440
|
+
status: data?.status,
|
|
441
|
+
archived: Boolean(data?.archived),
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
catch (e) {
|
|
445
|
+
return toErrorResult(e);
|
|
446
|
+
}
|
|
447
|
+
},
|
|
448
|
+
},
|
|
449
|
+
{
|
|
450
|
+
name: 'get_wallet_info',
|
|
451
|
+
description: '[curless-mcp-server v0.1.9] Return the active wallet (id, per-currency balances, on-chain deposit address, network, status). Wallets keep INDEPENDENT per-currency buckets: USD (fiat) and USDC (stablecoin) are tracked SEPARATELY — `balance_usd` and `balance_usdc` are two different numbers, NOT the same balance under two names. `balances` map exposes every currency held. Active wallet defaults to CURLESS_WALLET_ID env, or whatever switch_wallet last set. The `status` field tells you whether the wallet is `active`, `frozen`, or `closed` — only `active` wallets can receive deposits or pay charges.',
|
|
452
|
+
inputSchema: { type: 'object', properties: {} },
|
|
453
|
+
handler: async () => {
|
|
454
|
+
try {
|
|
455
|
+
const walletId = currentWalletId();
|
|
456
|
+
const wallets = unwrap(await client.get('/v1/wallets'));
|
|
457
|
+
const w = (Array.isArray(wallets) ? wallets : []).find((x) => x.id === walletId);
|
|
458
|
+
if (!w)
|
|
459
|
+
return { error: 'wallet_not_found', wallet_id: walletId };
|
|
460
|
+
// Independent per-currency buckets. USD (fiat) ≠ USDC
|
|
461
|
+
// (stablecoin) — they're separate balances the user can
|
|
462
|
+
// hold and operate on independently.
|
|
463
|
+
let parsed = {};
|
|
464
|
+
try {
|
|
465
|
+
parsed = typeof w.balances === 'string' ? JSON.parse(w.balances) : (w.balances || {});
|
|
466
|
+
}
|
|
467
|
+
catch { /* ignore */ }
|
|
468
|
+
const balance_usd = Number(parsed.USD ?? 0);
|
|
469
|
+
const balance_usdc = Number(parsed.USDC ?? 0);
|
|
470
|
+
return {
|
|
471
|
+
wallet_id: w.id,
|
|
472
|
+
name: w.name,
|
|
473
|
+
// Surface status so the LLM knows whether this wallet can
|
|
474
|
+
// receive deposits or pay charges without an extra round-trip.
|
|
475
|
+
status: w.status ?? 'unknown',
|
|
476
|
+
// Two INDEPENDENT numbers — USD bucket vs USDC bucket.
|
|
477
|
+
balance_usd,
|
|
478
|
+
balance_usdc,
|
|
479
|
+
// Full per-currency map for callers that hold USDT, EURC, EUR too.
|
|
480
|
+
balances: parsed,
|
|
481
|
+
// Wallet's declared primary stablecoin (used as a display hint).
|
|
482
|
+
primary_currency: (w.stablecoin || 'USD').toUpperCase(),
|
|
483
|
+
deposit_address: w.wallet_address ?? w.deposit_address ?? null,
|
|
484
|
+
network: w.network ?? 'base',
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
catch (e) {
|
|
488
|
+
return toErrorResult(e);
|
|
489
|
+
}
|
|
490
|
+
},
|
|
491
|
+
},
|
|
492
|
+
{
|
|
493
|
+
name: 'fund_wallet',
|
|
494
|
+
description: '[curless-mcp-server v0.1.9] Show how to send money INTO the active Curless wallet. Two rails, picked by `currency`: currency="USDC" (default) returns the on-chain deposit address + QR code — the user scans/copies it and sends USDC on the indicated network, balance updates after on-chain confirmation. currency="USD" returns FIAT BANK-TRANSFER details (account name, account number, bank, SWIFT, bank address) — the user wires real USD to that account; show every field to the user and tell them to put the transfer_reference in the wire memo. Server-side `assertFundable` gate refuses frozen / closed wallets BEFORE returning anything. NOTE: this is the REAL funding path; for demo/test scenarios that need instant credit, call simulate_wallet_funding instead.',
|
|
495
|
+
inputSchema: {
|
|
496
|
+
type: 'object',
|
|
497
|
+
properties: {
|
|
498
|
+
currency: {
|
|
499
|
+
type: 'string',
|
|
500
|
+
enum: ['USDC', 'USD'],
|
|
501
|
+
description: 'Funding rail. "USDC" (default) → on-chain deposit address + QR. "USD" → fiat bank-transfer details. Pick "USD" when the user wants to fund in 美金 / fiat / by bank transfer.',
|
|
502
|
+
},
|
|
503
|
+
amount_usdc: {
|
|
504
|
+
type: 'number',
|
|
505
|
+
minimum: 0.01,
|
|
506
|
+
description: 'Optional. Just a display hint shown next to the QR / bank details — no enforcement (the user can send any amount).',
|
|
507
|
+
},
|
|
508
|
+
},
|
|
509
|
+
},
|
|
510
|
+
handler: async (args) => {
|
|
511
|
+
const walletId = currentWalletId();
|
|
512
|
+
const currency = args.currency === 'USD' ? 'USD' : 'USDC';
|
|
513
|
+
const hintAmount = typeof args.amount_usdc === 'number' && args.amount_usdc > 0
|
|
514
|
+
? args.amount_usdc
|
|
515
|
+
: null;
|
|
516
|
+
try {
|
|
517
|
+
// Hit the centralized deposit-info endpoint — runs the shared
|
|
518
|
+
// assertFundable gate server-side. Frozen / closed wallets
|
|
519
|
+
// come back as HTTP 400 with a precise error, propagated to
|
|
520
|
+
// the LLM as a `wallet_not_fundable` result instead of a
|
|
521
|
+
// bogus QR / bank block.
|
|
522
|
+
const info = unwrap(await client.get(`/v1/wallets/${encodeURIComponent(walletId)}/deposit-info?currency=${currency}`));
|
|
523
|
+
// ── Fiat USD rail — bank transfer ──────────────────────────
|
|
524
|
+
if (currency === 'USD') {
|
|
525
|
+
const bank = info?.bank_transfer;
|
|
526
|
+
if (!bank) {
|
|
527
|
+
return { error: 'no_bank_info', wallet_id: walletId, hint: 'Bank-transfer details unavailable for this wallet.' };
|
|
528
|
+
}
|
|
529
|
+
return {
|
|
530
|
+
wallet_id: walletId,
|
|
531
|
+
status: info.status,
|
|
532
|
+
funding_method: 'bank_transfer',
|
|
533
|
+
currency: 'USD',
|
|
534
|
+
suggested_amount_usd: hintAmount,
|
|
535
|
+
bank_transfer: bank,
|
|
536
|
+
transfer_reference: info.transfer_reference ?? walletId,
|
|
537
|
+
instructions: `Wire ${hintAmount ? `$${hintAmount} ` : ''}USD to:\nAccount Name: ${bank.account_name}\nAccount Number: ${bank.account_number}\nBank Name: ${bank.bank_name}\nSwift Code: ${bank.swift_code}\nBank Address: ${bank.bank_address}\n\nPut "${info.transfer_reference ?? walletId}" in the transfer reference / memo. Balance updates after the transfer settles. Show ALL fields to the user verbatim.`,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
// ── Crypto USDC rail — on-chain deposit address ────────────
|
|
541
|
+
const address = info?.deposit_address;
|
|
542
|
+
if (!address) {
|
|
543
|
+
return { error: 'no_deposit_address', wallet_id: walletId, hint: 'Wallet has no on-chain address configured.' };
|
|
544
|
+
}
|
|
545
|
+
const network = info.network ?? 'base';
|
|
546
|
+
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=${encodeURIComponent(address)}`;
|
|
547
|
+
return {
|
|
548
|
+
wallet_id: walletId,
|
|
549
|
+
status: info.status,
|
|
550
|
+
funding_method: 'crypto',
|
|
551
|
+
deposit_address: address,
|
|
552
|
+
network,
|
|
553
|
+
stablecoin: info.stablecoin ?? 'USDC',
|
|
554
|
+
suggested_amount_usdc: hintAmount,
|
|
555
|
+
qr_url: qrUrl,
|
|
556
|
+
instructions: `Open your wallet app, scan the QR (or copy the address), and send ${hintAmount ?? 'any amount of'} USDC on the ${network} network. Balance updates after on-chain confirmation.`,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
catch (e) {
|
|
560
|
+
// assertFundable failures come through HttpError(400/404) —
|
|
561
|
+
// toErrorResult passes the body through so the LLM sees e.g.
|
|
562
|
+
// { error: 'Wallet is frozen — only active wallets can receive funds', status: 400 }.
|
|
563
|
+
return toErrorResult(e, `Wallet ${walletId} cannot receive funds right now. Check status with get_wallet_info or switch_wallet to a different wallet.`);
|
|
564
|
+
}
|
|
565
|
+
},
|
|
566
|
+
},
|
|
567
|
+
{
|
|
568
|
+
name: 'simulate_wallet_funding',
|
|
569
|
+
description: '[DEMO ONLY] Instantly credit a wallet bucket without an on-chain transfer. Skips the QR/scan flow — useful for tests, walkthroughs, and CI. Specify `currency` to choose which bucket gets credited (USDC, USD, USDT, EURC, EUR). Defaults to USDC — that\'s the on-chain stablecoin path this tool simulates. Wallets keep INDEPENDENT per-currency balances; this only credits the bucket you name. Production usage should call fund_wallet instead.',
|
|
570
|
+
inputSchema: {
|
|
571
|
+
type: 'object',
|
|
572
|
+
required: ['amount_usdc'],
|
|
573
|
+
properties: {
|
|
574
|
+
amount_usdc: { type: 'number', minimum: 0.01 },
|
|
575
|
+
currency: {
|
|
576
|
+
type: 'string',
|
|
577
|
+
enum: ['USDC', 'USD', 'USDT', 'EURC', 'EUR'],
|
|
578
|
+
description: 'Wallet bucket to credit. Defaults to USDC.',
|
|
579
|
+
},
|
|
580
|
+
},
|
|
581
|
+
},
|
|
582
|
+
handler: async (args) => {
|
|
583
|
+
const amount = Number(args.amount_usdc);
|
|
584
|
+
const currency = typeof args.currency === 'string' && args.currency
|
|
585
|
+
? String(args.currency).toUpperCase()
|
|
586
|
+
: 'USDC';
|
|
587
|
+
try {
|
|
588
|
+
const walletId = currentWalletId();
|
|
589
|
+
const data = unwrap(await client.post(`/v1/wallets/${encodeURIComponent(walletId)}/fund`, {
|
|
590
|
+
amount,
|
|
591
|
+
currency,
|
|
592
|
+
}));
|
|
593
|
+
let parsed = {};
|
|
594
|
+
try {
|
|
595
|
+
parsed = typeof data?.balances === 'string' ? JSON.parse(data.balances) : (data?.balances || {});
|
|
596
|
+
}
|
|
597
|
+
catch { /* ignore */ }
|
|
598
|
+
return {
|
|
599
|
+
wallet_id: walletId,
|
|
600
|
+
deposited_amount: amount,
|
|
601
|
+
deposited_currency: currency,
|
|
602
|
+
// Two INDEPENDENT figures — USD bucket vs USDC bucket.
|
|
603
|
+
new_balance_usd: Number(parsed.USD ?? 0),
|
|
604
|
+
new_balance_usdc: Number(parsed.USDC ?? 0),
|
|
605
|
+
balances: parsed,
|
|
606
|
+
status: 'funded',
|
|
607
|
+
mode: 'simulated',
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
catch (e) {
|
|
611
|
+
return toErrorResult(e);
|
|
612
|
+
}
|
|
613
|
+
},
|
|
614
|
+
},
|
|
615
|
+
{
|
|
616
|
+
name: 'list_wallets',
|
|
617
|
+
description: '[curless-mcp-server v0.1.9] List every wallet available to this Curless install (id, name, status, USD balance, network, deposit address). Use this before switch_wallet to discover what wallets you can target. The `status` field (active / frozen / closed) tells you which wallets can currently receive deposits or pay charges — frozen or closed wallets reject fund_wallet / simulate_wallet_funding / charge attempts with HTTP 400. Default-active wallet is whichever currentWalletId() returns; this list is the catalog.',
|
|
618
|
+
inputSchema: { type: 'object', properties: {} },
|
|
619
|
+
handler: async () => {
|
|
620
|
+
try {
|
|
621
|
+
const wallets = unwrap(await client.get('/v1/wallets'));
|
|
622
|
+
const list = Array.isArray(wallets) ? wallets : [];
|
|
623
|
+
const items = list.map((w) => {
|
|
624
|
+
let parsed = {};
|
|
625
|
+
try {
|
|
626
|
+
parsed = typeof w.balances === 'string' ? JSON.parse(w.balances) : (w.balances || {});
|
|
627
|
+
}
|
|
628
|
+
catch { /* ignore */ }
|
|
629
|
+
return {
|
|
630
|
+
wallet_id: w.id,
|
|
631
|
+
name: w.name,
|
|
632
|
+
// Surface status so callers can filter for active wallets
|
|
633
|
+
// before routing money. Spark 2026-05-12.
|
|
634
|
+
status: w.status ?? 'unknown',
|
|
635
|
+
// Independent per-currency buckets. USD (fiat) and USDC
|
|
636
|
+
// (stablecoin) are SEPARATE balances — two different numbers.
|
|
637
|
+
balance_usd: Number(parsed.USD ?? 0),
|
|
638
|
+
balance_usdc: Number(parsed.USDC ?? 0),
|
|
639
|
+
balances: parsed,
|
|
640
|
+
primary_currency: (w.stablecoin || 'USD').toUpperCase(),
|
|
641
|
+
network: w.network ?? 'base',
|
|
642
|
+
deposit_address: w.wallet_address ?? w.deposit_address ?? null,
|
|
643
|
+
is_active: w.id === currentWalletId(),
|
|
644
|
+
};
|
|
645
|
+
});
|
|
646
|
+
return { items, active_wallet_id: currentWalletId() };
|
|
647
|
+
}
|
|
648
|
+
catch (e) {
|
|
649
|
+
return toErrorResult(e);
|
|
650
|
+
}
|
|
651
|
+
},
|
|
652
|
+
},
|
|
653
|
+
{
|
|
654
|
+
name: 'switch_wallet',
|
|
655
|
+
description: 'Change the active wallet for subsequent tool calls in this MCP session. Affects fund_wallet, get_wallet_info, top_up_vcc, simulate_wallet_funding, and any tool that uses the active wallet by default. Reverts to CURLESS_WALLET_ID env when the MCP server restarts. Use list_wallets first to find a valid wallet_id.',
|
|
656
|
+
inputSchema: {
|
|
657
|
+
type: 'object',
|
|
658
|
+
required: ['wallet_id'],
|
|
659
|
+
properties: {
|
|
660
|
+
wallet_id: { type: 'string', description: 'Target wallet id (e.g. "wal-002").' },
|
|
661
|
+
},
|
|
662
|
+
},
|
|
663
|
+
handler: async (args) => {
|
|
664
|
+
const requested = String(args.wallet_id ?? '').trim();
|
|
665
|
+
if (!requested)
|
|
666
|
+
return { error: 'no_wallet_id' };
|
|
667
|
+
// Validate the wallet exists before flipping
|
|
668
|
+
try {
|
|
669
|
+
const wallets = unwrap(await client.get('/v1/wallets'));
|
|
670
|
+
const list = Array.isArray(wallets) ? wallets : [];
|
|
671
|
+
const w = list.find((x) => x.id === requested);
|
|
672
|
+
if (!w) {
|
|
673
|
+
return {
|
|
674
|
+
error: 'wallet_not_found',
|
|
675
|
+
wallet_id: requested,
|
|
676
|
+
available: list.map((x) => x.id),
|
|
677
|
+
hint: 'Call list_wallets for the canonical set.',
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
const previous = currentWalletId();
|
|
681
|
+
state.walletId = requested;
|
|
682
|
+
return {
|
|
683
|
+
previous_wallet_id: previous,
|
|
684
|
+
active_wallet_id: requested,
|
|
685
|
+
name: w.name,
|
|
686
|
+
status: 'switched',
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
catch (e) {
|
|
690
|
+
return toErrorResult(e);
|
|
691
|
+
}
|
|
692
|
+
},
|
|
693
|
+
},
|
|
694
|
+
{
|
|
695
|
+
name: 'list_vccs',
|
|
696
|
+
description: '[curless-mcp-server v0.1.9] List virtual credit cards. Returns id, last four, current loaded balance, spending limit, merchant_lock (display string), merchant_lock_id (raw enum), status. Use this to recover the list of cards available for charges, especially after restarting the MCP server (which clears the lastVccId memory). CONTRACT: by default `scope="install"` filters to cards minted by THIS MCP install (agent_id prefix = "${STAMP_PREFIX}-"). Pass `scope="all"` to see every card in the Curless account, including those minted via the web UI or other MCP installs. Status defaults to "active" — pass status="all" to include archived/frozen. The `merchant_lock` field is the DISPLAY name ("Club Med" / "Cotti Coffee" / "ProcureAI" / "Any merchant"); `merchant_lock_id` is the raw enum (any/clubmed/cotti/procurement; "cotti" appears in pre-rebrand rows as an alias for "cotti"). Use `merchant_lock` when describing cards to users.',
|
|
697
|
+
inputSchema: {
|
|
698
|
+
type: 'object',
|
|
699
|
+
properties: {
|
|
700
|
+
status: {
|
|
701
|
+
type: 'string',
|
|
702
|
+
enum: ['active', 'archived', 'frozen', 'all'],
|
|
703
|
+
description: 'Filter by card status. Default "active".',
|
|
704
|
+
},
|
|
705
|
+
scope: {
|
|
706
|
+
type: 'string',
|
|
707
|
+
enum: ['install', 'all'],
|
|
708
|
+
description: 'Ownership scope. "install" (default) returns cards minted by this MCP install (agent_id starts with the configured stamp prefix). "all" returns every card in the account.',
|
|
709
|
+
},
|
|
710
|
+
limit: { type: 'integer', minimum: 1, maximum: 100, default: 50 },
|
|
711
|
+
},
|
|
712
|
+
},
|
|
713
|
+
handler: async (args) => {
|
|
714
|
+
const statusFilter = typeof args.status === 'string' ? args.status : 'active';
|
|
715
|
+
const scopeFilter = typeof args.scope === 'string' ? args.scope : 'install';
|
|
716
|
+
const limit = Math.max(1, Math.min(100, Number(args.limit ?? 50)));
|
|
717
|
+
try {
|
|
718
|
+
// /v1/cards returns ALL cards by default. We filter client-side
|
|
719
|
+
// since the backend doesn't accept agent_id as a query param.
|
|
720
|
+
const data = unwrap(await client.get('/v1/cards'));
|
|
721
|
+
const all = Array.isArray(data) ? data : [];
|
|
722
|
+
const ownAgentPrefix = `${STAMP_PREFIX}-`;
|
|
723
|
+
const filtered = all
|
|
724
|
+
.filter((c) => {
|
|
725
|
+
const inScope = scopeFilter === 'all' ||
|
|
726
|
+
String(c.agent_id ?? '').startsWith(ownAgentPrefix);
|
|
727
|
+
const statusOk = statusFilter === 'all' || c.status === statusFilter;
|
|
728
|
+
return inScope && statusOk;
|
|
729
|
+
})
|
|
730
|
+
.slice(0, limit)
|
|
731
|
+
.map((c) => {
|
|
732
|
+
const bal = Number(c.balance ?? 0);
|
|
733
|
+
return {
|
|
734
|
+
vcc_id: c.id,
|
|
735
|
+
last_four: c.last_four,
|
|
736
|
+
balance_usd: bal,
|
|
737
|
+
balance_usdc: bal,
|
|
738
|
+
spending_limit_usd: c.spending_limit,
|
|
739
|
+
// Display string only — raw enum lives under merchant_lock_id.
|
|
740
|
+
merchant_lock: merchantLockLabel(c.merchant_lock),
|
|
741
|
+
merchant_lock_id: c.merchant_lock ?? null,
|
|
742
|
+
agent_id: c.agent_id,
|
|
743
|
+
status: c.status,
|
|
744
|
+
expiry: c.expiry,
|
|
745
|
+
created_at: c.created_at,
|
|
746
|
+
};
|
|
747
|
+
});
|
|
748
|
+
return {
|
|
749
|
+
items: filtered,
|
|
750
|
+
count: filtered.length,
|
|
751
|
+
filter_status: statusFilter,
|
|
752
|
+
filter_scope: scopeFilter,
|
|
753
|
+
stamp_prefix: STAMP_PREFIX,
|
|
754
|
+
last_vcc_id_in_session: state.lastVccId,
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
catch (e) {
|
|
758
|
+
return toErrorResult(e);
|
|
759
|
+
}
|
|
760
|
+
},
|
|
761
|
+
},
|
|
762
|
+
// ─── COFFEE (Cotti) ───────────────────────────────────────────────────
|
|
763
|
+
{
|
|
764
|
+
name: 'list_coffees',
|
|
765
|
+
description: 'Return the Cotti Coffee menu (8 drinks), the self-pickup store directory, AND the drink customization options. Menu `price` is in CNY (¥); the VCC settles in USD — the server converts at checkout. `pickup_stores` = curated Shanghai Cotti locations (pass a store id to order_coffee for self_pickup). `drink_options` = valid cup sizes (with CNY price deltas — small −¥3, large +¥6), sugar levels, temperatures, milk toggle, plus `currency` carrying the cny_per_usd rate — source of truth for order_coffee args. Quote ¥ prices to the user; mention the ≈ USD charge when relevant. ⚠️ VERBATIM CHINESE: when you show a drink name, store name, or option label to the user, COPY the exact `name` / `name_zh` / `label_zh` string from THIS response character-for-character. NEVER re-type Chinese from memory or translate it back — doing so corrupts the characters (椰漾拿铁 → 淇漾拿铁). The strings in this JSON are authoritative.',
|
|
766
|
+
inputSchema: { type: 'object', properties: {} },
|
|
767
|
+
handler: async () => {
|
|
768
|
+
try {
|
|
769
|
+
const data = unwrap(await client.get('/api/cotti/products'));
|
|
770
|
+
let pickup_stores = [];
|
|
771
|
+
try {
|
|
772
|
+
const storeData = unwrap(await client.get('/api/cotti/pickup-stores'));
|
|
773
|
+
pickup_stores = storeData?.stores ?? [];
|
|
774
|
+
}
|
|
775
|
+
catch { /* non-fatal */ }
|
|
776
|
+
let drink_options = null;
|
|
777
|
+
try {
|
|
778
|
+
drink_options = unwrap(await client.get('/api/cotti/options'));
|
|
779
|
+
}
|
|
780
|
+
catch { /* non-fatal */ }
|
|
781
|
+
return {
|
|
782
|
+
items: data?.products ?? data,
|
|
783
|
+
menu_currency: 'CNY',
|
|
784
|
+
pickup_stores,
|
|
785
|
+
drink_options,
|
|
786
|
+
fulfillment_hint: 'Menu is priced in CNY (¥); the VCC charges the USD conversion. Default fulfillment is delivery — for self-pickup, offer pickup_stores then call order_coffee with fulfillment_type="self_pickup" + pickup_store_id. Cup size / sugar / milk / temperature default to medium / standard / with-milk / hot — only pass what the user specifies.',
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
catch (e) {
|
|
790
|
+
return toErrorResult(e);
|
|
791
|
+
}
|
|
792
|
+
},
|
|
793
|
+
},
|
|
794
|
+
{
|
|
795
|
+
name: 'order_coffee',
|
|
796
|
+
description: '[curless-mcp-server v0.1.9] Order a coffee from Cotti Coffee. Charges the active VCC. `product` accepts the canonical id (e.g. "cotti-yeyan"), Chinese name ("椰漾拿铁"), or English name — resolved automatically. (cotti-* slugs are INTERNAL ids — render the merchant as "Cotti Coffee" / "Cotti咖啡".) ⚠️ ORDER THE STEPS — fulfillment → drink → spec → order: STEP 1 ask delivery or self-pickup (配送还是自提); if self-pickup, call list_coffees, show pickup_stores, get the chosen store. STEP 2 settle WHICH drink and acknowledge it. STEP 3 — only after the drink is settled — ask the spec, picking within THAT drink\'s spec.options from list_coffees (e.g. Cold Brew is iced-only): cup_size (杯型), sugar_level (糖度), with_milk (要不要奶), temperature (冷/热). All four are REQUIRED. Calling without them returns error="spec_required"; calling with a value outside the drink\'s options returns error="spec_not_available". Do NOT silently default the spec, and do NOT ask spec before the drink is chosen. If the user says "默认/随便", pass the drink\'s spec.defaults explicitly. EXCEPTION: if the user names the drink AND all four spec fields inline ("大杯冰美式不要糖"), STEP 2+3 are covered at once. CURRENCY: the menu is priced in CNY (¥); the VCC settles in USD. The server converts at checkout — the result returns total_amount_cny (quote this) AND total_amount_usd (what was charged). Tell the user both, e.g. "¥56 (≈ $7.78 charged)". cup_size affects the price: small −¥3 / medium base / large +¥6. FULFILLMENT: defaults to delivery (delivery_address auto-fills). For self-pickup / 自提 / 到店取, pass fulfillment_type="self_pickup" AND pickup_store_id (call list_coffees for the store list). APPROVAL GATE: orders whose USD total ≥ approval threshold (default $100) return `{status: "pending_approval", approval_id, approval_url}` — surface the URL, then RETRY with `approval_id`. VCC RESOLUTION: if `vcc_id` omitted, falls back to the session\'s last-created VCC, then the most recent install-stamped active VCC whose merchant_lock allows Cotti Coffee and covers the charge. Pass `inline_screenshot` to embed a rendered page screenshot. ⚠️ VERBATIM CHINESE: when you echo a drink name, store name, or option label to the user, COPY the exact string from the list_coffees / order result JSON character-for-character. NEVER re-type Chinese from memory — it corrupts the characters (椰漾拿铁 → 淇漾拿铁, 温度 → 熨毆度).',
|
|
797
|
+
inputSchema: {
|
|
798
|
+
type: 'object',
|
|
799
|
+
properties: {
|
|
800
|
+
product: { type: 'string' },
|
|
801
|
+
quantity: { type: 'integer', minimum: 1, maximum: 200, default: 1 },
|
|
802
|
+
cup_size: { type: 'string', enum: ['small', 'medium', 'large'], description: '小杯 / 中杯 / 大杯. Default "medium". small −¥3, large +¥6 per cup (CNY).' },
|
|
803
|
+
sugar_level: { type: 'string', enum: ['none', 'less', 'standard'], description: '无糖 / 少糖 / 标准糖. Default "standard".' },
|
|
804
|
+
with_milk: { type: 'boolean', description: '要奶 / 不要奶. Default true.' },
|
|
805
|
+
temperature: { type: 'string', enum: ['hot', 'iced'], description: '热 / 冷. Default "hot".' },
|
|
806
|
+
fulfillment_type: { type: 'string', enum: ['delivery', 'self_pickup'], description: 'Default "delivery". Use "self_pickup" for 自提 / 到店取.' },
|
|
807
|
+
pickup_store_id: { type: 'string', description: 'Required when fulfillment_type="self_pickup". A store id from list_coffees → pickup_stores; a store name or district also resolves.' },
|
|
808
|
+
delivery_address: { type: 'string', description: 'Defaults to the saved demo address. Ignored for self_pickup.' },
|
|
809
|
+
customer_name: { type: 'string', description: 'Defaults to "Spark".' },
|
|
810
|
+
vcc_id: { type: 'string', description: 'Optional. Defaults to (1) session\'s last-created VCC, then (2) most recent install-stamped active VCC with cotti-compatible merchant_lock and sufficient balance.' },
|
|
811
|
+
approval_id: { type: 'string', description: 'Retry a previously gated order. Server replays the originally-approved payload — other args are ignored on replay.' },
|
|
812
|
+
inline_screenshot: { type: 'boolean', description: 'Embed an inline screenshot of the order confirmation page in the reply. DEFAULTS TO TRUE — the user wants to see the order page. Pass false to skip it (faster, no ~3-5s screenshot fetch).' },
|
|
813
|
+
// auto_open removed 2026-05-13: storefront page no longer
|
|
814
|
+
// auto-launches in the OS browser. Click the markdown link in
|
|
815
|
+
// the response instead.
|
|
816
|
+
},
|
|
817
|
+
},
|
|
818
|
+
handler: async (args) => {
|
|
819
|
+
// ── Replay branch ───────────────────────────────────────────
|
|
820
|
+
const approvalId = typeof args.approval_id === 'string' ? args.approval_id.trim() : '';
|
|
821
|
+
if (approvalId) {
|
|
822
|
+
try {
|
|
823
|
+
const result = unwrap(await client.post('/api/cotti/checkout', { approval_id: approvalId }));
|
|
824
|
+
if (result?.status === 'pending_approval') {
|
|
825
|
+
return {
|
|
826
|
+
status: 'pending_approval',
|
|
827
|
+
approval_id: result.approval_id,
|
|
828
|
+
approval_url: result.approval_url,
|
|
829
|
+
amount_usd: result.amount_usd,
|
|
830
|
+
expires_at: result.expires_at,
|
|
831
|
+
hint: result.hint ?? 'Approval still pending. Open the URL and decide before retrying.',
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
return stripIframe({
|
|
835
|
+
order_id: result.id ?? result.order_id,
|
|
836
|
+
product_id: result.product_id,
|
|
837
|
+
product_name: result.product_name,
|
|
838
|
+
quantity: result.quantity,
|
|
839
|
+
// Menu is CNY; VCC charged the USD conversion. Surface both.
|
|
840
|
+
total_amount_cny: result.total_amount_cny,
|
|
841
|
+
total_amount_usd: result.total_amount_usd,
|
|
842
|
+
exchange_rate: result.exchange_rate,
|
|
843
|
+
menu_currency: 'CNY',
|
|
844
|
+
charge_currency: 'USD',
|
|
845
|
+
status: result.status,
|
|
846
|
+
payment_id: result.payment_id,
|
|
847
|
+
cup_size: result.cup_size ?? 'medium',
|
|
848
|
+
sugar_level: result.sugar_level ?? 'standard',
|
|
849
|
+
with_milk: result.with_milk ?? true,
|
|
850
|
+
temperature: result.temperature ?? 'hot',
|
|
851
|
+
fulfillment_type: result.fulfillment_type ?? 'delivery',
|
|
852
|
+
pickup_store_id: result.pickup_store_id ?? null,
|
|
853
|
+
pickup_store_name: result.pickup_store_name ?? null,
|
|
854
|
+
pickup_store_address: result.pickup_store_address ?? null,
|
|
855
|
+
delivery_address: result.delivery_address,
|
|
856
|
+
vcc_last_four: result.vcc_last_four,
|
|
857
|
+
approval_id: approvalId,
|
|
858
|
+
view_url: (result.id ?? result.order_id) ? `https://cotti.curless.ai/order/${result.id ?? result.order_id}` : undefined,
|
|
859
|
+
_inline_screenshot: args.inline_screenshot !== false,
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
catch (e) {
|
|
863
|
+
return toErrorResult(e);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
// ── Fresh branch ────────────────────────────────────────────
|
|
867
|
+
const product = String(args.product || '').trim();
|
|
868
|
+
if (!product)
|
|
869
|
+
return { error: 'no_product' };
|
|
870
|
+
const quantity = Math.max(1, Math.min(200, Number(args.quantity ?? 1)));
|
|
871
|
+
// Resolve product against catalog first so we know the price.
|
|
872
|
+
let products = [];
|
|
873
|
+
try {
|
|
874
|
+
const cat = unwrap(await client.get('/api/cotti/products'));
|
|
875
|
+
products = cat?.products ?? cat ?? [];
|
|
876
|
+
}
|
|
877
|
+
catch (e) {
|
|
878
|
+
return toErrorResult(e);
|
|
879
|
+
}
|
|
880
|
+
const lower = product.toLowerCase();
|
|
881
|
+
let resolved = products.find((p) => p.id === product) ||
|
|
882
|
+
products.find((p) => p.name === product || (p.name_en || '').toLowerCase() === lower) ||
|
|
883
|
+
products.find((p) => p.name.includes(product) || (p.name_en || '').toLowerCase().includes(lower));
|
|
884
|
+
if (!resolved) {
|
|
885
|
+
return {
|
|
886
|
+
error: 'product_not_found',
|
|
887
|
+
hint: `Use one of: ${products.map((p) => p.id).join(', ')}`,
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
// ── Spec gate ─────────────────────────────────────────────
|
|
891
|
+
// The agent MUST ask the user for the full drink spec before
|
|
892
|
+
// ordering — no silently-defaulted orders. Enforced at the
|
|
893
|
+
// tool boundary: reject with spec_required if any of the four
|
|
894
|
+
// spec fields is missing. The model re-calls once it has the
|
|
895
|
+
// answers (or the user said "默认/随便" → pass defaults).
|
|
896
|
+
const specHasCup = ['small', 'medium', 'large'].includes(args.cup_size);
|
|
897
|
+
const specHasSugar = ['none', 'less', 'standard'].includes(args.sugar_level);
|
|
898
|
+
const specHasTemp = args.temperature === 'hot' || args.temperature === 'iced';
|
|
899
|
+
const specHasMilk = typeof args.with_milk === 'boolean';
|
|
900
|
+
if (!specHasCup || !specHasSugar || !specHasTemp || !specHasMilk) {
|
|
901
|
+
const missing = [];
|
|
902
|
+
if (!specHasCup)
|
|
903
|
+
missing.push('cup_size (杯型: 小杯/中杯/大杯)');
|
|
904
|
+
if (!specHasSugar)
|
|
905
|
+
missing.push('sugar_level (糖度: 无糖/少糖/标准糖)');
|
|
906
|
+
if (!specHasMilk)
|
|
907
|
+
missing.push('with_milk (要奶/不要奶)');
|
|
908
|
+
if (!specHasTemp)
|
|
909
|
+
missing.push('temperature (冷热: 热/冰)');
|
|
910
|
+
return {
|
|
911
|
+
error: 'spec_required',
|
|
912
|
+
product_id: resolved.id,
|
|
913
|
+
product_name: resolved.name,
|
|
914
|
+
quantity,
|
|
915
|
+
missing,
|
|
916
|
+
hint: `MUST ask the user for the drink spec before ordering — do NOT order with silent defaults. In ONE short message ask for: 杯型 (小杯 −¥3 / 中杯 / 大杯 +¥6)、糖度 (无糖/少糖/标准糖)、要不要加奶、冷的还是热的. Once the user answers — or says "默认/随便" — call order_coffee AGAIN for ${quantity}× ${resolved.name} with cup_size, sugar_level, with_milk, temperature ALL set. Still missing: ${missing.join(', ')}.`,
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
// Catalog prices are CNY. The VCC settles in USD, so derive a
|
|
920
|
+
// rough USD estimate for the VCC-balance heuristic below. Fetch
|
|
921
|
+
// the live rate from /options; fall back to 7.2 if unavailable.
|
|
922
|
+
let cnyPerUsd = 7.2;
|
|
923
|
+
try {
|
|
924
|
+
const opts = unwrap(await client.get('/api/cotti/options'));
|
|
925
|
+
const r = Number(opts?.currency?.cny_per_usd);
|
|
926
|
+
if (Number.isFinite(r) && r > 0)
|
|
927
|
+
cnyPerUsd = r;
|
|
928
|
+
}
|
|
929
|
+
catch { /* keep fallback */ }
|
|
930
|
+
const estimatedTotalCny = +(resolved.price * quantity).toFixed(2);
|
|
931
|
+
const estimatedTotalUsd = +(estimatedTotalCny / cnyPerUsd).toFixed(2);
|
|
932
|
+
// Fulfillment. Default delivery; self_pickup needs a store id.
|
|
933
|
+
const fulfillmentType = args.fulfillment_type === 'self_pickup' ? 'self_pickup' : 'delivery';
|
|
934
|
+
const pickupStoreId = typeof args.pickup_store_id === 'string' && args.pickup_store_id.trim()
|
|
935
|
+
? args.pickup_store_id.trim()
|
|
936
|
+
: undefined;
|
|
937
|
+
if (fulfillmentType === 'self_pickup' && !pickupStoreId) {
|
|
938
|
+
return {
|
|
939
|
+
error: 'pickup_store_required',
|
|
940
|
+
hint: 'fulfillment_type="self_pickup" needs pickup_store_id. Call list_coffees, show the user the pickup_stores list, and pass the chosen store id.',
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
// Drink customization — backend applies defaults + size pricing.
|
|
944
|
+
const cupSize = ['small', 'medium', 'large'].includes(args.cup_size)
|
|
945
|
+
? args.cup_size : 'medium';
|
|
946
|
+
const sugarLevel = ['none', 'less', 'standard'].includes(args.sugar_level)
|
|
947
|
+
? args.sugar_level : 'standard';
|
|
948
|
+
const temperature = args.temperature === 'iced' ? 'iced' : 'hot';
|
|
949
|
+
const withMilk = args.with_milk !== false;
|
|
950
|
+
// Resolve VCC with merchant_lock + balance constraints (Spark bug A).
|
|
951
|
+
// VCC balances are USD — use the USD estimate, not the CNY total.
|
|
952
|
+
const vccId = (typeof args.vcc_id === 'string' && args.vcc_id) ||
|
|
953
|
+
(await resolveDefaultVcc({ merchantLock: 'cotti', minBalance: estimatedTotalUsd }));
|
|
954
|
+
if (!vccId) {
|
|
955
|
+
return {
|
|
956
|
+
error: 'no_vcc',
|
|
957
|
+
hint: `No suitable install-stamped VCC found with merchant_lock allowing Cotti Coffee and balance ≥ $${estimatedTotalUsd} (¥${estimatedTotalCny} converted). Run create_vcc(merchant_lock="any") + top_up_vcc(${estimatedTotalUsd}).`,
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
try {
|
|
961
|
+
const result = unwrap(await client.post('/api/cotti/checkout', {
|
|
962
|
+
product_id: resolved.id,
|
|
963
|
+
quantity,
|
|
964
|
+
delivery_address: (typeof args.delivery_address === 'string' && args.delivery_address.trim()) ||
|
|
965
|
+
'上海市黄浦区南京东路1号 · 国金中心商场 35F · Spark',
|
|
966
|
+
customer_name: (typeof args.customer_name === 'string' && args.customer_name.trim()) ||
|
|
967
|
+
'Spark',
|
|
968
|
+
vcc_id: vccId,
|
|
969
|
+
agent_id: stampAgent('coffee'),
|
|
970
|
+
caller_agent_id: callerAgentId(),
|
|
971
|
+
fulfillment_type: fulfillmentType,
|
|
972
|
+
...(pickupStoreId ? { pickup_store_id: pickupStoreId } : {}),
|
|
973
|
+
cup_size: cupSize,
|
|
974
|
+
sugar_level: sugarLevel,
|
|
975
|
+
with_milk: withMilk,
|
|
976
|
+
temperature: temperature,
|
|
977
|
+
}));
|
|
978
|
+
// Approval gate fired — surface the URL to the LLM.
|
|
979
|
+
if (result?.status === 'pending_approval') {
|
|
980
|
+
return {
|
|
981
|
+
status: 'pending_approval',
|
|
982
|
+
approval_id: result.approval_id,
|
|
983
|
+
approval_url: result.approval_url,
|
|
984
|
+
amount_cny: result.amount_cny,
|
|
985
|
+
amount_usd: result.amount_usd,
|
|
986
|
+
exchange_rate: result.exchange_rate,
|
|
987
|
+
threshold_usd: result.threshold_usd,
|
|
988
|
+
expires_at: result.expires_at,
|
|
989
|
+
hint: result.hint ?? `Coffee order requires human approval. Open ${result.approval_url}, decide, then re-run order_coffee with approval_id="${result.approval_id}".`,
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
const out = stripIframe({
|
|
993
|
+
order_id: result.id,
|
|
994
|
+
product_id: resolved.id,
|
|
995
|
+
product_name: resolved.name,
|
|
996
|
+
quantity,
|
|
997
|
+
// Menu is CNY; VCC charged the USD conversion. Surface both.
|
|
998
|
+
total_amount_cny: result.total_amount_cny ?? estimatedTotalCny,
|
|
999
|
+
total_amount_usd: result.total_amount_usd ?? estimatedTotalUsd,
|
|
1000
|
+
exchange_rate: result.exchange_rate ?? cnyPerUsd,
|
|
1001
|
+
menu_currency: 'CNY',
|
|
1002
|
+
charge_currency: 'USD',
|
|
1003
|
+
status: result.status,
|
|
1004
|
+
payment_id: result.payment_id,
|
|
1005
|
+
cup_size: result.cup_size ?? cupSize,
|
|
1006
|
+
sugar_level: result.sugar_level ?? sugarLevel,
|
|
1007
|
+
with_milk: result.with_milk ?? withMilk,
|
|
1008
|
+
temperature: result.temperature ?? temperature,
|
|
1009
|
+
fulfillment_type: result.fulfillment_type ?? fulfillmentType,
|
|
1010
|
+
pickup_store_id: result.pickup_store_id ?? null,
|
|
1011
|
+
pickup_store_name: result.pickup_store_name ?? null,
|
|
1012
|
+
pickup_store_address: result.pickup_store_address ?? null,
|
|
1013
|
+
delivery_address: result.delivery_address,
|
|
1014
|
+
vcc_id: vccId,
|
|
1015
|
+
vcc_last_four: result.vcc_last_four,
|
|
1016
|
+
view_url: result.id ? `https://cotti.curless.ai/order/${result.id}` : undefined,
|
|
1017
|
+
// order_coffee embeds the order-page screenshot by default —
|
|
1018
|
+
// pass inline_screenshot=false to opt out.
|
|
1019
|
+
_inline_screenshot: args.inline_screenshot !== false,
|
|
1020
|
+
});
|
|
1021
|
+
return out;
|
|
1022
|
+
}
|
|
1023
|
+
catch (e) {
|
|
1024
|
+
return toErrorResult(e);
|
|
1025
|
+
}
|
|
1026
|
+
},
|
|
1027
|
+
},
|
|
1028
|
+
// ─── HOTEL (Club Med) ─────────────────────────────────────────────────
|
|
1029
|
+
{
|
|
1030
|
+
name: 'list_properties',
|
|
1031
|
+
description: 'Return all Club Med properties available for booking (10 worldwide).',
|
|
1032
|
+
inputSchema: { type: 'object', properties: {} },
|
|
1033
|
+
handler: async () => {
|
|
1034
|
+
try {
|
|
1035
|
+
const data = unwrap(await client.get('/api/clubmed/properties'));
|
|
1036
|
+
const props = Array.isArray(data) ? data : (data?.properties ?? data?.items ?? []);
|
|
1037
|
+
return { items: props };
|
|
1038
|
+
}
|
|
1039
|
+
catch (e) {
|
|
1040
|
+
return toErrorResult(e);
|
|
1041
|
+
}
|
|
1042
|
+
},
|
|
1043
|
+
},
|
|
1044
|
+
{
|
|
1045
|
+
name: 'list_rooms',
|
|
1046
|
+
description: 'List room types available at a specific Club Med property.',
|
|
1047
|
+
inputSchema: {
|
|
1048
|
+
type: 'object',
|
|
1049
|
+
required: ['property'],
|
|
1050
|
+
properties: {
|
|
1051
|
+
property: {
|
|
1052
|
+
type: 'string',
|
|
1053
|
+
description: 'Property id ("bali", "phuket", "maldives", "cancun", "punta-cana", "mauritius", "seychelles", "val-thorens", "sahoro", "turkoise") or full/Chinese name.',
|
|
1054
|
+
},
|
|
1055
|
+
},
|
|
1056
|
+
},
|
|
1057
|
+
handler: async (args) => {
|
|
1058
|
+
const property = String(args.property || '').trim();
|
|
1059
|
+
if (!property)
|
|
1060
|
+
return { error: 'no_property' };
|
|
1061
|
+
try {
|
|
1062
|
+
// Backend response shape is { property: {...}, room_types: [...] }
|
|
1063
|
+
// — earlier code read flat fields (data.id, data.rooms) which is why
|
|
1064
|
+
// every property looked empty regardless of how many rooms it had.
|
|
1065
|
+
// Bug from 2026-05-11 report: list_rooms(mauritius) → rooms: [].
|
|
1066
|
+
const data = unwrap(await client.get(`/api/clubmed/properties/${encodeURIComponent(property)}`));
|
|
1067
|
+
const prop = data?.property ?? {};
|
|
1068
|
+
const rooms = (data?.room_types ?? []);
|
|
1069
|
+
state.lastClubmedProperty = prop.id ?? property;
|
|
1070
|
+
return {
|
|
1071
|
+
property_id: prop.id ?? property,
|
|
1072
|
+
property_name: prop.name,
|
|
1073
|
+
city: prop.city,
|
|
1074
|
+
country: prop.country,
|
|
1075
|
+
rooms: rooms.map((r) => ({
|
|
1076
|
+
room_id: r.id,
|
|
1077
|
+
name: r.name,
|
|
1078
|
+
description: r.description,
|
|
1079
|
+
max_guests: r.max_guests,
|
|
1080
|
+
price_per_night_usd: r.price_per_night,
|
|
1081
|
+
features: r.features,
|
|
1082
|
+
})),
|
|
1083
|
+
// Fallback convention so agents have a predictable id even
|
|
1084
|
+
// when this list is empty (which it shouldn't be after this
|
|
1085
|
+
// fix, but defense-in-depth):
|
|
1086
|
+
fallback_standard_room_id: `${prop.id ?? property}-standard`,
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
catch (e) {
|
|
1090
|
+
return toErrorResult(e);
|
|
1091
|
+
}
|
|
1092
|
+
},
|
|
1093
|
+
},
|
|
1094
|
+
{
|
|
1095
|
+
name: 'book_room',
|
|
1096
|
+
description: '[curless-mcp-server v0.1.9] Book a Club Med room. If room_id is omitted, picks the property\'s standard room. Charges the active VCC. VCC RESOLUTION: if `vcc_id` is omitted, the server tries the session\'s last-created VCC first; if none (fresh MCP session), it falls back to the most recent install-stamped active VCC whose merchant_lock allows Club Med. APPROVAL GATE: bookings whose total ≥ approval threshold (default $3000) DO NOT charge immediately. Instead the tool returns `{status: "pending_approval", approval_id, approval_url}`. Surface the URL to the user, wait for them to approve via the page, then RETRY this exact tool call with `approval_id` set — the server replays the originally-approved booking. SCREENSHOT: book_room embeds an inline screenshot of the booking confirmation page BY DEFAULT — the user wants to see the booking page. Pass inline_screenshot=false only if they ask to skip it.',
|
|
1097
|
+
inputSchema: {
|
|
1098
|
+
type: 'object',
|
|
1099
|
+
required: ['property', 'check_in', 'check_out'],
|
|
1100
|
+
properties: {
|
|
1101
|
+
property: { type: 'string', description: 'Property id, full name, or Chinese name.' },
|
|
1102
|
+
check_in: { type: 'string', description: 'ISO date YYYY-MM-DD.' },
|
|
1103
|
+
check_out: { type: 'string', description: 'ISO date YYYY-MM-DD.' },
|
|
1104
|
+
room_id: { type: 'string', description: 'Optional — defaults to property standard room.' },
|
|
1105
|
+
guests: { type: 'integer', minimum: 1, maximum: 4, default: 2 },
|
|
1106
|
+
guest_name: { type: 'string', description: 'Defaults to "Spark".' },
|
|
1107
|
+
vcc_id: { type: 'string', description: 'Optional. Defaults to (1) session\'s last-created VCC, then (2) most recent install-stamped active VCC with clubmed-compatible merchant_lock.' },
|
|
1108
|
+
approval_id: { type: 'string', description: 'When retrying a booking that was previously gated for human approval, pass the approval_id returned by the first call. The server replays the originally-approved booking — other args are ignored on replay to prevent tampering.' },
|
|
1109
|
+
inline_screenshot: { type: 'boolean', description: 'Embed an inline screenshot of the booking confirmation page in the reply. DEFAULTS TO TRUE for book_room — the user wants to see the booking page. Pass false to skip it (faster, no ~3-5s screenshot fetch).' },
|
|
1110
|
+
// auto_open removed 2026-05-13: booking page no longer
|
|
1111
|
+
// auto-launches in the OS browser. Click the markdown link
|
|
1112
|
+
// in the response instead.
|
|
1113
|
+
},
|
|
1114
|
+
},
|
|
1115
|
+
handler: async (args) => {
|
|
1116
|
+
// ── Replay branch: caller is retrying with approval_id ──────
|
|
1117
|
+
// Don't re-resolve VCC / room / etc — the server has the
|
|
1118
|
+
// originally-approved payload. We only need to forward the
|
|
1119
|
+
// approval_id. Server replays the stored booking and returns
|
|
1120
|
+
// the same shape as a fresh successful booking, OR an
|
|
1121
|
+
// approval-state error (pending / rejected / expired).
|
|
1122
|
+
const approvalId = typeof args.approval_id === 'string' ? args.approval_id.trim() : '';
|
|
1123
|
+
if (approvalId) {
|
|
1124
|
+
try {
|
|
1125
|
+
const result = unwrap(await client.post('/api/clubmed/checkout', { approval_id: approvalId }));
|
|
1126
|
+
// If the result still says pending_approval, surface it
|
|
1127
|
+
// verbatim so the LLM tells the user "still waiting".
|
|
1128
|
+
if (result?.status === 'pending_approval') {
|
|
1129
|
+
return {
|
|
1130
|
+
status: 'pending_approval',
|
|
1131
|
+
approval_id: result.approval_id,
|
|
1132
|
+
approval_url: result.approval_url,
|
|
1133
|
+
amount_usd: result.amount_usd,
|
|
1134
|
+
expires_at: result.expires_at,
|
|
1135
|
+
hint: result.hint ?? 'Approval still pending. Open the URL and decide before retrying.',
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
return stripIframe({
|
|
1139
|
+
booking_id: result.booking_id ?? result.id,
|
|
1140
|
+
confirmation_code: result.confirmation_code,
|
|
1141
|
+
property_id: result.property_id,
|
|
1142
|
+
room_type_id: result.room_type_id,
|
|
1143
|
+
check_in: result.check_in,
|
|
1144
|
+
check_out: result.check_out,
|
|
1145
|
+
total_amount_usd: result.total_amount_usd,
|
|
1146
|
+
currency: 'USD',
|
|
1147
|
+
status: result.status,
|
|
1148
|
+
payment_id: result.payment_id,
|
|
1149
|
+
approval_id: approvalId,
|
|
1150
|
+
view_url: (result.booking_id ?? result.id) ? `https://clubmed.curless.ai/booking/${result.booking_id ?? result.id}` : undefined,
|
|
1151
|
+
// book_room embeds the booking-page screenshot by default —
|
|
1152
|
+
// pass inline_screenshot=false to opt out.
|
|
1153
|
+
_inline_screenshot: args.inline_screenshot !== false,
|
|
1154
|
+
_auto_open: args.auto_open === true,
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
catch (e) {
|
|
1158
|
+
return toErrorResult(e);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
// ── Fresh booking branch ────────────────────────────────────
|
|
1162
|
+
const property = String(args.property || '').trim();
|
|
1163
|
+
const checkIn = String(args.check_in || '').trim();
|
|
1164
|
+
const checkOut = String(args.check_out || '').trim();
|
|
1165
|
+
if (!property || !checkIn || !checkOut)
|
|
1166
|
+
return { error: 'missing_fields' };
|
|
1167
|
+
// Resolve VCC (Spark bug A). Hotels are large charges — we don't
|
|
1168
|
+
// know the exact total here without an extra round-trip, so the
|
|
1169
|
+
// balance check is omitted and we trust merchant-side balance
|
|
1170
|
+
// validation. merchant_lock filter still applies.
|
|
1171
|
+
const vccId = (typeof args.vcc_id === 'string' && args.vcc_id) ||
|
|
1172
|
+
(await resolveDefaultVcc({ merchantLock: 'clubmed' }));
|
|
1173
|
+
if (!vccId) {
|
|
1174
|
+
return {
|
|
1175
|
+
error: 'no_vcc',
|
|
1176
|
+
hint: 'No suitable install-stamped VCC found with merchant_lock allowing clubmed. Run create_vcc(merchant_lock="any") + top_up_vcc.',
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
try {
|
|
1180
|
+
const result = unwrap(await client.post('/api/clubmed/checkout', {
|
|
1181
|
+
property_id: property,
|
|
1182
|
+
room_id: typeof args.room_id === 'string' ? args.room_id : undefined,
|
|
1183
|
+
check_in: checkIn,
|
|
1184
|
+
check_out: checkOut,
|
|
1185
|
+
guests: Number(args.guests ?? 2),
|
|
1186
|
+
guest_name: (typeof args.guest_name === 'string' && args.guest_name.trim()) ||
|
|
1187
|
+
'Spark',
|
|
1188
|
+
vcc_id: vccId,
|
|
1189
|
+
agent_id: stampAgent('hotel'),
|
|
1190
|
+
caller_agent_id: callerAgentId(),
|
|
1191
|
+
}));
|
|
1192
|
+
// Approval gate fired — return the pending state to the LLM
|
|
1193
|
+
// so it can show the URL to the human and stop.
|
|
1194
|
+
if (result?.status === 'pending_approval') {
|
|
1195
|
+
return {
|
|
1196
|
+
status: 'pending_approval',
|
|
1197
|
+
approval_id: result.approval_id,
|
|
1198
|
+
approval_url: result.approval_url,
|
|
1199
|
+
amount_usd: result.amount_usd,
|
|
1200
|
+
threshold_usd: result.threshold_usd,
|
|
1201
|
+
expires_at: result.expires_at,
|
|
1202
|
+
hint: result.hint ?? `Booking requires human approval. Open ${result.approval_url}, decide, then re-run book_room with approval_id="${result.approval_id}" (other args are ignored on replay).`,
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
return stripIframe({
|
|
1206
|
+
booking_id: result.booking_id ?? result.id,
|
|
1207
|
+
confirmation_code: result.confirmation_code,
|
|
1208
|
+
property_id: result.property_id,
|
|
1209
|
+
room_type_id: result.room_type_id,
|
|
1210
|
+
check_in: result.check_in,
|
|
1211
|
+
check_out: result.check_out,
|
|
1212
|
+
total_amount_usd: result.total_amount_usd,
|
|
1213
|
+
currency: 'USD',
|
|
1214
|
+
status: result.status,
|
|
1215
|
+
payment_id: result.payment_id,
|
|
1216
|
+
vcc_id: vccId,
|
|
1217
|
+
// Desktop client equivalent of the iframe nav — clickable URL
|
|
1218
|
+
// to the Club Med booking confirmation page.
|
|
1219
|
+
view_url: (result.booking_id ?? result.id) ? `https://clubmed.curless.ai/booking/${result.booking_id ?? result.id}` : undefined,
|
|
1220
|
+
// book_room embeds the booking-page screenshot by default —
|
|
1221
|
+
// pass inline_screenshot=false to opt out.
|
|
1222
|
+
_inline_screenshot: args.inline_screenshot !== false,
|
|
1223
|
+
_auto_open: args.auto_open === true,
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
catch (e) {
|
|
1227
|
+
return toErrorResult(e);
|
|
1228
|
+
}
|
|
1229
|
+
},
|
|
1230
|
+
},
|
|
1231
|
+
// ─── OFFICE SUPPLIES (Procurement) ────────────────────────────────────
|
|
1232
|
+
{
|
|
1233
|
+
name: 'list_supplies',
|
|
1234
|
+
description: 'Return the office-supplies catalog (A4 paper, toner cartridges, pens, …).',
|
|
1235
|
+
inputSchema: { type: 'object', properties: {} },
|
|
1236
|
+
handler: async () => {
|
|
1237
|
+
try {
|
|
1238
|
+
const data = unwrap(await client.get('/api/procurement/products'));
|
|
1239
|
+
return { items: data?.products ?? data };
|
|
1240
|
+
}
|
|
1241
|
+
catch (e) {
|
|
1242
|
+
return toErrorResult(e);
|
|
1243
|
+
}
|
|
1244
|
+
},
|
|
1245
|
+
},
|
|
1246
|
+
{
|
|
1247
|
+
name: 'check_inventory',
|
|
1248
|
+
description: 'Show current on-hand quantity for office supplies, with low-stock flags. Suggests items that would benefit from a reorder.',
|
|
1249
|
+
inputSchema: {
|
|
1250
|
+
type: 'object',
|
|
1251
|
+
properties: {
|
|
1252
|
+
item: { type: 'string', description: 'Optional — restrict to one item by id or name.' },
|
|
1253
|
+
},
|
|
1254
|
+
},
|
|
1255
|
+
handler: async (args) => {
|
|
1256
|
+
try {
|
|
1257
|
+
const agentId = stampAgent('supply');
|
|
1258
|
+
const [catalog, inv, rules] = await Promise.all([
|
|
1259
|
+
client.get('/api/procurement/products'),
|
|
1260
|
+
client.get('/api/procurement/inventory', { agent_id: agentId }),
|
|
1261
|
+
client.get('/api/procurement/reorder-rules', { agent_id: agentId }),
|
|
1262
|
+
]);
|
|
1263
|
+
const products = (unwrap(catalog)?.products ?? catalog ?? []);
|
|
1264
|
+
const inventory = (unwrap(inv)?.inventory ?? inv?.inventory ?? []);
|
|
1265
|
+
const ruleList = (unwrap(rules)?.rules ?? rules?.rules ?? []);
|
|
1266
|
+
const itemFilter = typeof args.item === 'string' ? args.item.trim().toLowerCase() : '';
|
|
1267
|
+
const merged = products
|
|
1268
|
+
.filter((p) => !itemFilter
|
|
1269
|
+
|| p.id.includes(itemFilter)
|
|
1270
|
+
|| (p.name || '').toLowerCase().includes(itemFilter)
|
|
1271
|
+
|| (p.name_en || '').toLowerCase().includes(itemFilter))
|
|
1272
|
+
.map((p) => {
|
|
1273
|
+
const inv = inventory.find((i) => i.item_id === p.id);
|
|
1274
|
+
const rule = ruleList.find((r) => r.item_id === p.id);
|
|
1275
|
+
const qty = Number(inv?.current_quantity ?? 0);
|
|
1276
|
+
const threshold = rule ? Number(rule.threshold) : null;
|
|
1277
|
+
const low = threshold != null && qty < threshold;
|
|
1278
|
+
return {
|
|
1279
|
+
item_id: p.id,
|
|
1280
|
+
name: p.name,
|
|
1281
|
+
name_en: p.name_en,
|
|
1282
|
+
current_quantity: qty,
|
|
1283
|
+
reorder_threshold: threshold,
|
|
1284
|
+
reorder_quantity: rule ? Number(rule.reorder_quantity) : null,
|
|
1285
|
+
low_stock: low,
|
|
1286
|
+
unit_price_usd: p.price,
|
|
1287
|
+
};
|
|
1288
|
+
});
|
|
1289
|
+
return {
|
|
1290
|
+
items: merged,
|
|
1291
|
+
low_stock_count: merged.filter((m) => m.low_stock).length,
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
catch (e) {
|
|
1295
|
+
return toErrorResult(e);
|
|
1296
|
+
}
|
|
1297
|
+
},
|
|
1298
|
+
},
|
|
1299
|
+
{
|
|
1300
|
+
name: 'order_supplies',
|
|
1301
|
+
description: '[curless-mcp-server v0.1.9] Place an office-supplies order. `item` accepts id, Chinese, or English name. If `scheduled_for` (ISO date) is given, the order is queued for later; otherwise it charges immediately and bumps inventory. APPROVAL GATE: orders ≥ approval threshold (default $200) return `{status: "pending_approval", approval_id, approval_url}` instead of charging. Surface the URL to the user; once they approve, retry with `approval_id` — server replays the stored payload. VCC RESOLUTION: if `vcc_id` is omitted, the server tries the session\'s last-created VCC first; if there isn\'t one (e.g. fresh MCP session), it falls back to the most recently-created active install-stamped VCC whose merchant_lock allows procurement AND whose balance covers the charge.',
|
|
1302
|
+
inputSchema: {
|
|
1303
|
+
type: 'object',
|
|
1304
|
+
properties: {
|
|
1305
|
+
item: { type: 'string' },
|
|
1306
|
+
quantity: { type: 'integer', minimum: 1, maximum: 500, default: 1 },
|
|
1307
|
+
scheduled_for: { type: 'string', description: 'Optional ISO date YYYY-MM-DD.' },
|
|
1308
|
+
vcc_id: { type: 'string', description: 'Optional. Defaults to (1) this session\'s last-created VCC, then (2) the most recent install-stamped active VCC whose merchant_lock allows procurement and balance ≥ estimated charge.' },
|
|
1309
|
+
approval_id: { type: 'string', description: 'Retry a previously gated supply order. Server replays the originally-approved payload — other args are ignored on replay.' },
|
|
1310
|
+
inline_screenshot: { type: 'boolean', description: 'Embed an inline screenshot of the order confirmation page in the reply. DEFAULTS TO TRUE — the user wants to see the order page. Pass false to skip it (faster, no ~3-5s screenshot fetch).' },
|
|
1311
|
+
// auto_open removed 2026-05-13: storefront page no longer
|
|
1312
|
+
// auto-launches in the OS browser. Click the markdown link in
|
|
1313
|
+
// the response instead.
|
|
1314
|
+
},
|
|
1315
|
+
},
|
|
1316
|
+
handler: async (args) => {
|
|
1317
|
+
// ── Replay branch ───────────────────────────────────────────
|
|
1318
|
+
const approvalId = typeof args.approval_id === 'string' ? args.approval_id.trim() : '';
|
|
1319
|
+
if (approvalId) {
|
|
1320
|
+
try {
|
|
1321
|
+
const result = unwrap(await client.post('/api/procurement/checkout', { approval_id: approvalId }));
|
|
1322
|
+
if (result?.status === 'pending_approval') {
|
|
1323
|
+
return {
|
|
1324
|
+
status: 'pending_approval',
|
|
1325
|
+
approval_id: result.approval_id,
|
|
1326
|
+
approval_url: result.approval_url,
|
|
1327
|
+
amount_usd: result.amount_usd,
|
|
1328
|
+
expires_at: result.expires_at,
|
|
1329
|
+
hint: result.hint ?? 'Approval still pending. Open the URL and decide before retrying.',
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
return stripIframe({
|
|
1333
|
+
order_id: result.id ?? result.order_id,
|
|
1334
|
+
item_id: result.item_id,
|
|
1335
|
+
item_name: result.item_name,
|
|
1336
|
+
quantity: result.quantity,
|
|
1337
|
+
unit_price_usd: result.unit_price_usd,
|
|
1338
|
+
total_amount_usd: result.total_amount_usd,
|
|
1339
|
+
currency: 'USD',
|
|
1340
|
+
status: result.status,
|
|
1341
|
+
payment_id: result.payment_id,
|
|
1342
|
+
vcc_last_four: result.vcc_last_four,
|
|
1343
|
+
approval_id: approvalId,
|
|
1344
|
+
view_url: (result.id ?? result.order_id) ? `https://procurement.curless.ai/order/${result.id ?? result.order_id}` : undefined,
|
|
1345
|
+
_inline_screenshot: args.inline_screenshot !== false,
|
|
1346
|
+
_auto_open: args.auto_open === true,
|
|
1347
|
+
});
|
|
1348
|
+
}
|
|
1349
|
+
catch (e) {
|
|
1350
|
+
return toErrorResult(e);
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
// ── Fresh branch ────────────────────────────────────────────
|
|
1354
|
+
const item = String(args.item || '').trim();
|
|
1355
|
+
if (!item)
|
|
1356
|
+
return { error: 'no_item' };
|
|
1357
|
+
const quantity = Math.max(1, Math.min(500, Number(args.quantity ?? 1)));
|
|
1358
|
+
const scheduledFor = typeof args.scheduled_for === 'string' && args.scheduled_for.trim()
|
|
1359
|
+
? args.scheduled_for.trim()
|
|
1360
|
+
: undefined;
|
|
1361
|
+
// Resolve product first so we know the price → can resolve VCC
|
|
1362
|
+
// by balance sufficiency (Spark bug A).
|
|
1363
|
+
let products = [];
|
|
1364
|
+
try {
|
|
1365
|
+
const cat = unwrap(await client.get('/api/procurement/products'));
|
|
1366
|
+
products = cat?.products ?? cat ?? [];
|
|
1367
|
+
}
|
|
1368
|
+
catch (e) {
|
|
1369
|
+
return toErrorResult(e);
|
|
1370
|
+
}
|
|
1371
|
+
const lower = item.toLowerCase();
|
|
1372
|
+
let resolved = products.find((p) => p.id === item) ||
|
|
1373
|
+
products.find((p) => p.name === item || (p.name_en || '').toLowerCase() === lower) ||
|
|
1374
|
+
products.find((p) => p.name.includes(item) || (p.name_en || '').toLowerCase().includes(lower));
|
|
1375
|
+
if (!resolved) {
|
|
1376
|
+
return {
|
|
1377
|
+
error: 'product_not_found',
|
|
1378
|
+
hint: `Use one of: ${products.map((p) => p.id).join(', ')}`,
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
const estimatedTotal = +(resolved.price * quantity).toFixed(2);
|
|
1382
|
+
// Resolve VCC: explicit → session memory → smart fallback.
|
|
1383
|
+
let vccId = (typeof args.vcc_id === 'string' && args.vcc_id) ||
|
|
1384
|
+
(scheduledFor ? null : await resolveDefaultVcc({ merchantLock: 'procurement', minBalance: estimatedTotal }));
|
|
1385
|
+
if (!vccId && !scheduledFor) {
|
|
1386
|
+
return {
|
|
1387
|
+
error: 'no_vcc',
|
|
1388
|
+
hint: `No suitable install-stamped VCC found with merchant_lock allowing procurement and balance ≥ $${estimatedTotal}. Run create_vcc(merchant_lock="any") + top_up_vcc(${estimatedTotal}), or pass scheduled_for for a deferred order.`,
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
try {
|
|
1392
|
+
const result = unwrap(await client.post('/api/procurement/checkout', {
|
|
1393
|
+
item_id: resolved.id,
|
|
1394
|
+
quantity,
|
|
1395
|
+
vcc_id: vccId,
|
|
1396
|
+
// Inventory scope stays as the shared 'agent-004' bucket so
|
|
1397
|
+
// MCP and web UI see the same on-hand counts.
|
|
1398
|
+
agent_id: stampAgent('supply'),
|
|
1399
|
+
// Audit identity is the actual MCP caller — Spark bug #1.
|
|
1400
|
+
caller_agent_id: callerAgentId(),
|
|
1401
|
+
scheduled_for: scheduledFor,
|
|
1402
|
+
}));
|
|
1403
|
+
// Approval gate fired — surface the URL to the LLM.
|
|
1404
|
+
if (result?.status === 'pending_approval') {
|
|
1405
|
+
return {
|
|
1406
|
+
status: 'pending_approval',
|
|
1407
|
+
approval_id: result.approval_id,
|
|
1408
|
+
approval_url: result.approval_url,
|
|
1409
|
+
amount_usd: result.amount_usd,
|
|
1410
|
+
threshold_usd: result.threshold_usd,
|
|
1411
|
+
expires_at: result.expires_at,
|
|
1412
|
+
hint: result.hint ?? `Supply order requires human approval. Open ${result.approval_url}, decide, then re-run order_supplies with approval_id="${result.approval_id}".`,
|
|
1413
|
+
};
|
|
1414
|
+
}
|
|
1415
|
+
return stripIframe({
|
|
1416
|
+
order_id: result.id,
|
|
1417
|
+
item_id: resolved.id,
|
|
1418
|
+
item_name: resolved.name,
|
|
1419
|
+
quantity,
|
|
1420
|
+
unit_price_usd: resolved.price,
|
|
1421
|
+
total_amount_usd: result.total_amount_usd ?? estimatedTotal,
|
|
1422
|
+
currency: 'USD',
|
|
1423
|
+
status: result.status,
|
|
1424
|
+
scheduled_for: result.scheduled_for,
|
|
1425
|
+
payment_id: result.payment_id,
|
|
1426
|
+
vcc_id: vccId,
|
|
1427
|
+
view_url: result.id ? `https://procurement.curless.ai/order/${result.id}` : undefined,
|
|
1428
|
+
// order_supplies embeds the order-page screenshot by default —
|
|
1429
|
+
// pass inline_screenshot=false to opt out.
|
|
1430
|
+
_inline_screenshot: args.inline_screenshot !== false,
|
|
1431
|
+
_auto_open: args.auto_open === true,
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
catch (e) {
|
|
1435
|
+
return toErrorResult(e);
|
|
1436
|
+
}
|
|
1437
|
+
},
|
|
1438
|
+
},
|
|
1439
|
+
{
|
|
1440
|
+
name: 'set_reorder_rule',
|
|
1441
|
+
description: 'Configure auto-reorder for an item: when on-hand drops below `threshold`, automatically place an order of `reorder_quantity` units. The rule runs server-side on every inventory change.',
|
|
1442
|
+
inputSchema: {
|
|
1443
|
+
type: 'object',
|
|
1444
|
+
required: ['item', 'threshold', 'reorder_quantity'],
|
|
1445
|
+
properties: {
|
|
1446
|
+
item: { type: 'string' },
|
|
1447
|
+
threshold: { type: 'integer', minimum: 0, maximum: 100000 },
|
|
1448
|
+
reorder_quantity: { type: 'integer', minimum: 1, maximum: 500 },
|
|
1449
|
+
},
|
|
1450
|
+
},
|
|
1451
|
+
handler: async (args) => {
|
|
1452
|
+
const item = String(args.item || '').trim();
|
|
1453
|
+
const threshold = Math.max(0, Math.min(100000, Number(args.threshold)));
|
|
1454
|
+
const reorderQty = Math.max(1, Math.min(500, Number(args.reorder_quantity)));
|
|
1455
|
+
if (!item)
|
|
1456
|
+
return { error: 'no_item' };
|
|
1457
|
+
// Resolve product
|
|
1458
|
+
let products = [];
|
|
1459
|
+
try {
|
|
1460
|
+
const cat = unwrap(await client.get('/api/procurement/products'));
|
|
1461
|
+
products = cat?.products ?? cat ?? [];
|
|
1462
|
+
}
|
|
1463
|
+
catch (e) {
|
|
1464
|
+
return toErrorResult(e);
|
|
1465
|
+
}
|
|
1466
|
+
const lower = item.toLowerCase();
|
|
1467
|
+
let resolved = products.find((p) => p.id === item) ||
|
|
1468
|
+
products.find((p) => p.name === item || (p.name_en || '').toLowerCase() === lower) ||
|
|
1469
|
+
products.find((p) => p.name.includes(item) || (p.name_en || '').toLowerCase().includes(lower));
|
|
1470
|
+
if (!resolved) {
|
|
1471
|
+
return { error: 'product_not_found', hint: `Use one of: ${products.map((p) => p.id).join(', ')}` };
|
|
1472
|
+
}
|
|
1473
|
+
try {
|
|
1474
|
+
const rule = unwrap(await client.post('/api/procurement/reorder-rules', {
|
|
1475
|
+
item_id: resolved.id,
|
|
1476
|
+
agent_id: stampAgent('supply'),
|
|
1477
|
+
threshold,
|
|
1478
|
+
reorder_quantity: reorderQty,
|
|
1479
|
+
}));
|
|
1480
|
+
return {
|
|
1481
|
+
rule_id: rule?.id,
|
|
1482
|
+
item_id: resolved.id,
|
|
1483
|
+
item_name: resolved.name,
|
|
1484
|
+
threshold,
|
|
1485
|
+
reorder_quantity: reorderQty,
|
|
1486
|
+
status: 'active',
|
|
1487
|
+
};
|
|
1488
|
+
}
|
|
1489
|
+
catch (e) {
|
|
1490
|
+
return toErrorResult(e);
|
|
1491
|
+
}
|
|
1492
|
+
},
|
|
1493
|
+
},
|
|
1494
|
+
{
|
|
1495
|
+
name: 'consume_supplies',
|
|
1496
|
+
description: 'Consume / use up office-supplies inventory — decrements on-hand stock by `quantity`. Use when the user reports USING supplies ("用掉了 20 包 A4 纸", "消耗了 3 支硒鼓", "deduct 5 folders"). Inverse of order_supplies. AUTO-REORDER IS ATOMIC: if the consumption drops on-hand below an item\'s reorder-rule threshold (set via set_reorder_rule), the backend resolves a VCC + charges it IMMEDIATELY + replenishes the shelf ONLY on payment success. reorder_triggered=true means a real charge landed (reorder_payment.payment_id non-null, order status=paid). reorder_triggered=false with reorder_skip_reason names the precise blocker — \'no_rule\' (suggest set_reorder_rule), \'rule_disabled\', \'above_threshold\' (working as intended), \'no_charger_available\' (config bug), \'auto_reorder_charge_failed\' (consume succeeded but reorder charge failed — reorder_charge_error has the underlying code: VCC_INSUFFICIENT → top up, NO_VCC_FOR_REORDER → create / top-up a procurement-locked card, VCC_MERCHANT_LOCKED → wrong card lock). `item` accepts id / Chinese / English / fuzzy keyword. `vcc_id` is optional — when omitted the backend auto-picks an active procurement-compatible VCC with enough balance.',
|
|
1497
|
+
inputSchema: {
|
|
1498
|
+
type: 'object',
|
|
1499
|
+
required: ['item', 'quantity'],
|
|
1500
|
+
properties: {
|
|
1501
|
+
item: { type: 'string', description: 'Item id, Chinese name, English name, or keyword.' },
|
|
1502
|
+
quantity: { type: 'integer', minimum: 1, maximum: 100000, description: 'How many units were consumed.' },
|
|
1503
|
+
vcc_id: { type: 'string', description: 'Optional. VCC to charge if a reorder fires. Defaults to an auto-picked procurement-compatible VCC with enough balance.' },
|
|
1504
|
+
},
|
|
1505
|
+
},
|
|
1506
|
+
handler: async (args) => {
|
|
1507
|
+
const item = String(args.item || '').trim();
|
|
1508
|
+
const quantity = Math.max(1, Math.min(100000, Math.floor(Number(args.quantity ?? 0))));
|
|
1509
|
+
const vccId = typeof args.vcc_id === 'string' && args.vcc_id.trim() ? args.vcc_id.trim() : undefined;
|
|
1510
|
+
if (!item)
|
|
1511
|
+
return { error: 'no_item' };
|
|
1512
|
+
if (!Number.isFinite(quantity) || quantity < 1) {
|
|
1513
|
+
return { error: 'bad_quantity', hint: 'quantity must be a positive integer.' };
|
|
1514
|
+
}
|
|
1515
|
+
try {
|
|
1516
|
+
const result = unwrap(await client.post('/api/procurement/consume', {
|
|
1517
|
+
item,
|
|
1518
|
+
quantity,
|
|
1519
|
+
agent_id: stampAgent('supply'),
|
|
1520
|
+
caller_agent_id: callerAgentId(),
|
|
1521
|
+
...(vccId ? { vcc_id: vccId } : {}),
|
|
1522
|
+
}));
|
|
1523
|
+
return {
|
|
1524
|
+
item_id: result.item_id,
|
|
1525
|
+
item_name: result.item_name,
|
|
1526
|
+
unit: result.unit,
|
|
1527
|
+
consumed: result.consumed,
|
|
1528
|
+
previous_quantity: result.previous_quantity,
|
|
1529
|
+
quantity_after_consume: result.quantity_after_consume,
|
|
1530
|
+
current_quantity: result.current_quantity,
|
|
1531
|
+
// Auto-reorder outcome — ATOMIC. reorder_triggered=true ONLY
|
|
1532
|
+
// when a real charge landed (reorder_payment populated, order
|
|
1533
|
+
// status='paid', shelf replenished). Otherwise
|
|
1534
|
+
// reorder_skip_reason + reorder_charge_error name the
|
|
1535
|
+
// precise blocker so the agent can guide recovery.
|
|
1536
|
+
reorder_triggered: result.reorder_triggered,
|
|
1537
|
+
reorder_rule: result.reorder_rule,
|
|
1538
|
+
reorder_skip_reason: result.reorder_skip_reason,
|
|
1539
|
+
reorder_order: result.reorder_order,
|
|
1540
|
+
reorder_payment: result.reorder_payment,
|
|
1541
|
+
reorder_charge_error: result.reorder_charge_error,
|
|
1542
|
+
};
|
|
1543
|
+
}
|
|
1544
|
+
catch (e) {
|
|
1545
|
+
return toErrorResult(e, 'Consume failed — call list_supplies to confirm the item, or check_inventory for current stock.');
|
|
1546
|
+
}
|
|
1547
|
+
},
|
|
1548
|
+
},
|
|
1549
|
+
{
|
|
1550
|
+
name: 'list_supply_orders',
|
|
1551
|
+
description: 'Return recent office-supplies orders (paid, scheduled, failed).',
|
|
1552
|
+
inputSchema: {
|
|
1553
|
+
type: 'object',
|
|
1554
|
+
properties: {
|
|
1555
|
+
limit: { type: 'integer', minimum: 1, maximum: 50, default: 10 },
|
|
1556
|
+
},
|
|
1557
|
+
},
|
|
1558
|
+
handler: async (args) => {
|
|
1559
|
+
const limit = Math.max(1, Math.min(50, Number(args.limit ?? 10)));
|
|
1560
|
+
try {
|
|
1561
|
+
const data = unwrap(await client.get('/api/procurement/orders', {
|
|
1562
|
+
agent_id: stampAgent('supply'),
|
|
1563
|
+
limit,
|
|
1564
|
+
}));
|
|
1565
|
+
return { orders: data?.orders ?? data };
|
|
1566
|
+
}
|
|
1567
|
+
catch (e) {
|
|
1568
|
+
return toErrorResult(e);
|
|
1569
|
+
}
|
|
1570
|
+
},
|
|
1571
|
+
},
|
|
1572
|
+
// ─── TRANSACTIONS ─────────────────────────────────────────────────────
|
|
1573
|
+
{
|
|
1574
|
+
name: 'list_transactions',
|
|
1575
|
+
description: 'Recent merchant payments (across all merchants). Useful for receipts / debugging.',
|
|
1576
|
+
inputSchema: {
|
|
1577
|
+
type: 'object',
|
|
1578
|
+
properties: {
|
|
1579
|
+
limit: { type: 'integer', minimum: 1, maximum: 50, default: 10 },
|
|
1580
|
+
},
|
|
1581
|
+
},
|
|
1582
|
+
handler: async (args) => {
|
|
1583
|
+
const limit = Math.max(1, Math.min(50, Number(args.limit ?? 10)));
|
|
1584
|
+
try {
|
|
1585
|
+
const data = unwrap(await client.get('/v1/merchant/payments', { limit, fresh: '1' }));
|
|
1586
|
+
return { items: data?.items ?? data };
|
|
1587
|
+
}
|
|
1588
|
+
catch (e) {
|
|
1589
|
+
return toErrorResult(e);
|
|
1590
|
+
}
|
|
1591
|
+
},
|
|
1592
|
+
},
|
|
1593
|
+
// ─── CAROUSELL (二手商品 / APass / ATOKEN) ────────────────────────────
|
|
1594
|
+
//
|
|
1595
|
+
// New scenario — wallet-identified (chain + address) rather than
|
|
1596
|
+
// agent-id gated. Buyer + seller wallets are passed explicitly; if
|
|
1597
|
+
// omitted we fall back to CAROUSELL_DEFAULT_CHAIN + CAROUSELL_WALLET
|
|
1598
|
+
// env vars on the MCP host.
|
|
1599
|
+
{
|
|
1600
|
+
name: 'list_carousell_listings',
|
|
1601
|
+
description: '[curless-mcp-server v0.1.10] Browse second-hand goods on carousell.curless.ai. All prices in USDC. Filter by query (matches title + description), category (electronics / fashion / home / sports / other), condition (new / like_new / good / fair), max_price_usdc, min_price_usdc. Always call this BEFORE buy_carousell_listing — the user almost always wants to compare options.',
|
|
1602
|
+
inputSchema: {
|
|
1603
|
+
type: 'object',
|
|
1604
|
+
properties: {
|
|
1605
|
+
query: { type: 'string' },
|
|
1606
|
+
category: { type: 'string' },
|
|
1607
|
+
condition: { type: 'string', enum: ['new', 'like_new', 'good', 'fair'] },
|
|
1608
|
+
min_price_usdc: { type: 'number', minimum: 0 },
|
|
1609
|
+
max_price_usdc: { type: 'number', minimum: 0 },
|
|
1610
|
+
limit: { type: 'integer', minimum: 1, maximum: 50, default: 12 },
|
|
1611
|
+
},
|
|
1612
|
+
},
|
|
1613
|
+
handler: async (args) => {
|
|
1614
|
+
const params = {};
|
|
1615
|
+
for (const k of ['query', 'category', 'condition']) {
|
|
1616
|
+
const v = args[k];
|
|
1617
|
+
if (typeof v === 'string' && v.trim())
|
|
1618
|
+
params[k] = v.trim();
|
|
1619
|
+
}
|
|
1620
|
+
for (const k of ['min_price_usdc', 'max_price_usdc']) {
|
|
1621
|
+
const v = args[k];
|
|
1622
|
+
if (typeof v === 'number' && Number.isFinite(v))
|
|
1623
|
+
params[k] = v;
|
|
1624
|
+
}
|
|
1625
|
+
params.limit = Math.max(1, Math.min(50, Number(args.limit ?? 12)));
|
|
1626
|
+
try {
|
|
1627
|
+
const data = unwrap(await client.get('/api/carousell/listings', params));
|
|
1628
|
+
return {
|
|
1629
|
+
listings: (data.listings ?? []).map((l) => ({
|
|
1630
|
+
id: l.id, title: l.title, price_usdc: l.price_usdc, category: l.category,
|
|
1631
|
+
condition: l.condition, image_url: l.image_url, location: l.location,
|
|
1632
|
+
seller_chain: l.seller_chain, seller_address: l.seller_address,
|
|
1633
|
+
view_url: `https://carousell.curless.ai/listing/${l.id}`,
|
|
1634
|
+
})),
|
|
1635
|
+
};
|
|
1636
|
+
}
|
|
1637
|
+
catch (e) {
|
|
1638
|
+
return toErrorResult(e);
|
|
1639
|
+
}
|
|
1640
|
+
},
|
|
1641
|
+
},
|
|
1642
|
+
{
|
|
1643
|
+
name: 'get_carousell_listing',
|
|
1644
|
+
description: '[curless-mcp-server v0.1.10] Get a single Carousell listing\'s full detail. Use after list_carousell_listings when the user picks one and you need title/description/price before calling buy.',
|
|
1645
|
+
inputSchema: {
|
|
1646
|
+
type: 'object',
|
|
1647
|
+
required: ['listing_id'],
|
|
1648
|
+
properties: { listing_id: { type: 'string', description: 'CL-... id' } },
|
|
1649
|
+
},
|
|
1650
|
+
handler: async (args) => {
|
|
1651
|
+
const id = String(args.listing_id || '').trim();
|
|
1652
|
+
if (!id)
|
|
1653
|
+
return { error: 'missing_listing_id' };
|
|
1654
|
+
try {
|
|
1655
|
+
const data = unwrap(await client.get(`/api/carousell/listings/${encodeURIComponent(id)}`));
|
|
1656
|
+
return { ...data, view_url: `https://carousell.curless.ai/listing/${data.id}` };
|
|
1657
|
+
}
|
|
1658
|
+
catch (e) {
|
|
1659
|
+
return toErrorResult(e);
|
|
1660
|
+
}
|
|
1661
|
+
},
|
|
1662
|
+
},
|
|
1663
|
+
{
|
|
1664
|
+
name: 'post_carousell_listing',
|
|
1665
|
+
description: '[curless-mcp-server v0.1.10] Publish a second-hand item for sale on carousell.curless.ai. Price in USDC. Requires the seller wallet to have a Cleanverse APass — if missing returns `{status: "needs_apass", magiclink_url}` (NOT an error: surface URL, ask user to register, then retry). image_url is REQUIRED (suggest unsplash.com to users who have no photo). On success returns the listing + view_url.',
|
|
1666
|
+
inputSchema: {
|
|
1667
|
+
type: 'object',
|
|
1668
|
+
required: ['title', 'description', 'price_usdc', 'category', 'condition', 'image_url'],
|
|
1669
|
+
properties: {
|
|
1670
|
+
title: { type: 'string', maxLength: 200 },
|
|
1671
|
+
description: { type: 'string', maxLength: 4000 },
|
|
1672
|
+
price_usdc: { type: 'number', minimum: 0.01, maximum: 100000 },
|
|
1673
|
+
category: { type: 'string', description: 'electronics | fashion | home | sports | other' },
|
|
1674
|
+
condition: { type: 'string', enum: ['new', 'like_new', 'good', 'fair'] },
|
|
1675
|
+
image_url: { type: 'string', description: 'Public image URL.' },
|
|
1676
|
+
location: { type: 'string' },
|
|
1677
|
+
chain: { type: 'string', description: `Seller wallet chain. Defaults to env CAROUSELL_DEFAULT_CHAIN (${process.env.CAROUSELL_DEFAULT_CHAIN || 'base'}).` },
|
|
1678
|
+
address: { type: 'string', description: 'Seller wallet address. Defaults to env CAROUSELL_WALLET_ADDRESS.' },
|
|
1679
|
+
},
|
|
1680
|
+
},
|
|
1681
|
+
handler: async (args) => {
|
|
1682
|
+
const chain = (typeof args.chain === 'string' && args.chain.trim()) || process.env.CAROUSELL_DEFAULT_CHAIN || 'base';
|
|
1683
|
+
const address = (typeof args.address === 'string' && args.address.trim()) || process.env.CAROUSELL_WALLET_ADDRESS;
|
|
1684
|
+
if (!address) {
|
|
1685
|
+
return {
|
|
1686
|
+
error: 'wallet_required',
|
|
1687
|
+
hint: 'Ask the user for their on-chain wallet address (e.g. "What\'s your Base/Solana wallet address?"), or set CAROUSELL_WALLET_ADDRESS in this MCP install\'s env.',
|
|
1688
|
+
};
|
|
1689
|
+
}
|
|
1690
|
+
try {
|
|
1691
|
+
const data = unwrap(await client.post('/api/carousell/listings', {
|
|
1692
|
+
seller_chain: chain, seller_address: address,
|
|
1693
|
+
title: args.title, description: args.description,
|
|
1694
|
+
price_usdc: Number(args.price_usdc),
|
|
1695
|
+
category: String(args.category || '').toLowerCase().trim(),
|
|
1696
|
+
condition: args.condition, image_url: args.image_url,
|
|
1697
|
+
location: args.location, caller_agent_id: callerAgentId(),
|
|
1698
|
+
}));
|
|
1699
|
+
if (data?.status === 'needs_apass') {
|
|
1700
|
+
return {
|
|
1701
|
+
status: 'needs_apass', magiclink_url: data.magiclink_url, expires_at: data.expires_at,
|
|
1702
|
+
hint: 'Seller wallet needs a Cleanverse APass first. Surface magiclink_url, ask user to register, then retry this tool with the same args.',
|
|
1703
|
+
};
|
|
1704
|
+
}
|
|
1705
|
+
return {
|
|
1706
|
+
listing_id: data.id, title: data.title, price_usdc: data.price_usdc, status: data.status,
|
|
1707
|
+
view_url: `https://carousell.curless.ai/listing/${data.id}`,
|
|
1708
|
+
};
|
|
1709
|
+
}
|
|
1710
|
+
catch (e) {
|
|
1711
|
+
return toErrorResult(e);
|
|
1712
|
+
}
|
|
1713
|
+
},
|
|
1714
|
+
},
|
|
1715
|
+
{
|
|
1716
|
+
name: 'buy_carousell_listing',
|
|
1717
|
+
description: '[curless-mcp-server v0.1.10] Buy a Carousell listing with atoken (1:1 USDC settlement). THREE pre-settlement gates: (1) needs_apass — surface magiclink_url, register, retry. (2) pending_approval (≥ $200) — surface approval_url, ask user to approve, retry with same approval_id. (3) insufficient_atoken_balance — top up buyer wallet. On success returns settled order + atoken_tx_hash + view_url. Listing auto-marks sold; duplicate buy attempts return error="listing_unavailable".',
|
|
1718
|
+
inputSchema: {
|
|
1719
|
+
type: 'object',
|
|
1720
|
+
required: ['listing_id'],
|
|
1721
|
+
properties: {
|
|
1722
|
+
listing_id: { type: 'string' },
|
|
1723
|
+
chain: { type: 'string', description: 'Buyer wallet chain (defaults to env CAROUSELL_DEFAULT_CHAIN).' },
|
|
1724
|
+
address: { type: 'string', description: 'Buyer wallet address (defaults to env CAROUSELL_WALLET_ADDRESS).' },
|
|
1725
|
+
approval_id: { type: 'string', description: 'Pass to retry a previously-gated buy after human approval.' },
|
|
1726
|
+
},
|
|
1727
|
+
},
|
|
1728
|
+
handler: async (args) => {
|
|
1729
|
+
const id = String(args.listing_id || '').trim();
|
|
1730
|
+
if (!id)
|
|
1731
|
+
return { error: 'missing_listing_id' };
|
|
1732
|
+
const chain = (typeof args.chain === 'string' && args.chain.trim()) || process.env.CAROUSELL_DEFAULT_CHAIN || 'base';
|
|
1733
|
+
const address = (typeof args.address === 'string' && args.address.trim()) || process.env.CAROUSELL_WALLET_ADDRESS;
|
|
1734
|
+
if (!address) {
|
|
1735
|
+
return { error: 'wallet_required', hint: 'Set CAROUSELL_WALLET_ADDRESS env or pass `address` arg.' };
|
|
1736
|
+
}
|
|
1737
|
+
try {
|
|
1738
|
+
const data = unwrap(await client.post('/api/carousell/buy', {
|
|
1739
|
+
listing_id: id, buyer_chain: chain, buyer_address: address,
|
|
1740
|
+
caller_agent_id: callerAgentId(),
|
|
1741
|
+
...(args.approval_id ? { approval_id: String(args.approval_id) } : {}),
|
|
1742
|
+
}));
|
|
1743
|
+
if (data?.status === 'needs_apass') {
|
|
1744
|
+
return { status: 'needs_apass', magiclink_url: data.magiclink_url, expires_at: data.expires_at };
|
|
1745
|
+
}
|
|
1746
|
+
if (data?.status === 'pending_approval') {
|
|
1747
|
+
return {
|
|
1748
|
+
status: 'pending_approval', approval_id: data.approval_id,
|
|
1749
|
+
approval_url: data.approval_url, amount_usd: data.amount_usd,
|
|
1750
|
+
threshold_usd: data.threshold_usd, hint: data.hint,
|
|
1751
|
+
};
|
|
1752
|
+
}
|
|
1753
|
+
return {
|
|
1754
|
+
order_id: data.id, listing_id: data.listing_id, price_usdc: data.price_usdc,
|
|
1755
|
+
atoken_tx_hash: data.atoken_tx_hash, status: data.status,
|
|
1756
|
+
view_url: `https://carousell.curless.ai/order/${data.id}`,
|
|
1757
|
+
};
|
|
1758
|
+
}
|
|
1759
|
+
catch (e) {
|
|
1760
|
+
return toErrorResult(e);
|
|
1761
|
+
}
|
|
1762
|
+
},
|
|
1763
|
+
},
|
|
1764
|
+
{
|
|
1765
|
+
name: 'list_my_carousell_orders',
|
|
1766
|
+
description: '[curless-mcp-server v0.1.10] My Carousell orders. role="buyer" (default) for purchases, role="seller" for sales.',
|
|
1767
|
+
inputSchema: {
|
|
1768
|
+
type: 'object',
|
|
1769
|
+
properties: {
|
|
1770
|
+
role: { type: 'string', enum: ['buyer', 'seller'], default: 'buyer' },
|
|
1771
|
+
chain: { type: 'string' },
|
|
1772
|
+
address: { type: 'string' },
|
|
1773
|
+
status: { type: 'string', enum: ['pending_atoken', 'settled', 'failed', 'refunded', 'any'] },
|
|
1774
|
+
limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
|
|
1775
|
+
},
|
|
1776
|
+
},
|
|
1777
|
+
handler: async (args) => {
|
|
1778
|
+
const chain = (typeof args.chain === 'string' && args.chain.trim()) || process.env.CAROUSELL_DEFAULT_CHAIN || 'base';
|
|
1779
|
+
const address = (typeof args.address === 'string' && args.address.trim()) || process.env.CAROUSELL_WALLET_ADDRESS;
|
|
1780
|
+
if (!address)
|
|
1781
|
+
return { error: 'wallet_required', hint: 'Set CAROUSELL_WALLET_ADDRESS env or pass address.' };
|
|
1782
|
+
const role = (args.role === 'seller') ? 'seller' : 'buyer';
|
|
1783
|
+
const params = role === 'buyer'
|
|
1784
|
+
? { buyer_chain: chain, buyer_address: address }
|
|
1785
|
+
: { seller_chain: chain, seller_address: address };
|
|
1786
|
+
if (typeof args.status === 'string')
|
|
1787
|
+
params.status = args.status;
|
|
1788
|
+
params.limit = Math.max(1, Math.min(100, Number(args.limit ?? 20)));
|
|
1789
|
+
try {
|
|
1790
|
+
const data = unwrap(await client.get('/api/carousell/orders', params));
|
|
1791
|
+
return { role, orders: data.orders ?? [] };
|
|
1792
|
+
}
|
|
1793
|
+
catch (e) {
|
|
1794
|
+
return toErrorResult(e);
|
|
1795
|
+
}
|
|
1796
|
+
},
|
|
1797
|
+
},
|
|
1798
|
+
{
|
|
1799
|
+
name: 'get_apass_status',
|
|
1800
|
+
description: '[curless-mcp-server v0.1.10] Check whether a wallet has an active Cleanverse APass. Returns `{registered, reason?, apass_address?, expiration_time?}`. Use BEFORE calling register_apass to avoid double-registering.',
|
|
1801
|
+
inputSchema: {
|
|
1802
|
+
type: 'object',
|
|
1803
|
+
properties: {
|
|
1804
|
+
chain: { type: 'string' },
|
|
1805
|
+
address: { type: 'string' },
|
|
1806
|
+
},
|
|
1807
|
+
},
|
|
1808
|
+
handler: async (args) => {
|
|
1809
|
+
const chain = (typeof args.chain === 'string' && args.chain.trim()) || process.env.CAROUSELL_DEFAULT_CHAIN || 'base';
|
|
1810
|
+
const address = (typeof args.address === 'string' && args.address.trim()) || process.env.CAROUSELL_WALLET_ADDRESS;
|
|
1811
|
+
if (!address)
|
|
1812
|
+
return { error: 'wallet_required', hint: 'Set CAROUSELL_WALLET_ADDRESS env or pass address.' };
|
|
1813
|
+
try {
|
|
1814
|
+
const data = unwrap(await client.get('/api/apass/status', { chain, address }));
|
|
1815
|
+
return { chain, address, ...data };
|
|
1816
|
+
}
|
|
1817
|
+
catch (e) {
|
|
1818
|
+
return toErrorResult(e);
|
|
1819
|
+
}
|
|
1820
|
+
},
|
|
1821
|
+
},
|
|
1822
|
+
{
|
|
1823
|
+
name: 'register_apass',
|
|
1824
|
+
description: '[curless-mcp-server v0.1.10] Start a Cleanverse APass registration for a wallet. Returns `{magiclink_url, state_nonce, expires_at}`. Share the magiclink_url with the user — they click it to register. After they confirm, retry whichever Carousell action triggered the apass gate.',
|
|
1825
|
+
inputSchema: {
|
|
1826
|
+
type: 'object',
|
|
1827
|
+
properties: {
|
|
1828
|
+
chain: { type: 'string' },
|
|
1829
|
+
address: { type: 'string' },
|
|
1830
|
+
purpose: { type: 'string', enum: ['list', 'buy', 'direct'], default: 'direct' },
|
|
1831
|
+
},
|
|
1832
|
+
},
|
|
1833
|
+
handler: async (args) => {
|
|
1834
|
+
const chain = (typeof args.chain === 'string' && args.chain.trim()) || process.env.CAROUSELL_DEFAULT_CHAIN || 'base';
|
|
1835
|
+
const address = (typeof args.address === 'string' && args.address.trim()) || process.env.CAROUSELL_WALLET_ADDRESS;
|
|
1836
|
+
if (!address)
|
|
1837
|
+
return { error: 'wallet_required', hint: 'Set CAROUSELL_WALLET_ADDRESS env or pass address.' };
|
|
1838
|
+
try {
|
|
1839
|
+
const data = unwrap(await client.post('/api/apass/start', {
|
|
1840
|
+
chain, address,
|
|
1841
|
+
purpose: ['list', 'buy', 'direct'].includes(String(args.purpose)) ? args.purpose : 'direct',
|
|
1842
|
+
}));
|
|
1843
|
+
return {
|
|
1844
|
+
chain, address,
|
|
1845
|
+
state_nonce: data.state_nonce, magiclink_url: data.magiclink_url, expires_at: data.expires_at,
|
|
1846
|
+
};
|
|
1847
|
+
}
|
|
1848
|
+
catch (e) {
|
|
1849
|
+
return toErrorResult(e);
|
|
1850
|
+
}
|
|
1851
|
+
},
|
|
1852
|
+
},
|
|
1853
|
+
];
|
|
1854
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1855
|
+
// MCP Server wiring
|
|
1856
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
1857
|
+
const server = new Server({ name: 'curless-mcp-server', version: '0.1.10' }, { capabilities: { tools: {} } });
|
|
1858
|
+
// Auto-open of the storefront confirmation page was disabled by
|
|
1859
|
+
// product decision (2026-05-13). Users found the surprise browser
|
|
1860
|
+
// jump after every successful order_coffee / book_room / order_supplies
|
|
1861
|
+
// disruptive — especially on macOS where it steals focus from Claude
|
|
1862
|
+
// Desktop. The view_url is still surfaced as a clickable markdown
|
|
1863
|
+
// link in the chat; users who want the page open can click it.
|
|
1864
|
+
// The env var CURLESS_AUTO_OPEN_URLS is now ignored.
|
|
1865
|
+
const AUTO_OPEN_URLS = false;
|
|
1866
|
+
// Opt-in: render an inline screenshot of the storefront confirmation
|
|
1867
|
+
// page right in the chat. Closest thing to "open the iframe in Claude
|
|
1868
|
+
// Desktop" — Claude Desktop has no embedded webview, but it DOES render
|
|
1869
|
+
// inline images (same path as the QR code). Free public service does
|
|
1870
|
+
// the screenshot, no API key needed. Adds ~3-5s to every order; keep
|
|
1871
|
+
// it off for low-latency demos.
|
|
1872
|
+
const INLINE_PAGE_SCREENSHOT = (process.env.CURLESS_INLINE_PAGE_SCREENSHOT || '').toLowerCase() === 'true' ||
|
|
1873
|
+
process.env.CURLESS_INLINE_PAGE_SCREENSHOT === '1';
|
|
1874
|
+
/**
|
|
1875
|
+
* Fetch a rendered screenshot of an arbitrary URL via a free public
|
|
1876
|
+
* screenshot service and return it as an MCP image content block.
|
|
1877
|
+
* The page is rendered with a real headless browser server-side so
|
|
1878
|
+
* Vue SPAs (cotti/clubmed/procurement order pages) hydrate before the
|
|
1879
|
+
* snapshot is taken. Returns null on failure — caller falls back to
|
|
1880
|
+
* the markdown link.
|
|
1881
|
+
*/
|
|
1882
|
+
async function screenshotContentFromUrl(url) {
|
|
1883
|
+
// image.thum.io: free, no auth, returns PNG. Use the 'wait' param so
|
|
1884
|
+
// the SPA has time to hydrate and fetch its order detail.
|
|
1885
|
+
const shotUrl = `https://image.thum.io/get/width/900/crop/1200/wait/3/png/${url}`;
|
|
1886
|
+
return imageContentFromUrl(shotUrl);
|
|
1887
|
+
}
|
|
1888
|
+
async function openInBrowser(url) {
|
|
1889
|
+
// Best-effort cross-platform launcher. Never throws into the caller;
|
|
1890
|
+
// failure here just means the URL didn't open, which is fine — the
|
|
1891
|
+
// user still has it as clickable text in the chat.
|
|
1892
|
+
try {
|
|
1893
|
+
const { exec } = await import('node:child_process');
|
|
1894
|
+
const cmd = process.platform === 'darwin' ? `open "${url}"` :
|
|
1895
|
+
process.platform === 'win32' ? `start "" "${url}"` :
|
|
1896
|
+
`xdg-open "${url}"`;
|
|
1897
|
+
exec(cmd, () => { });
|
|
1898
|
+
}
|
|
1899
|
+
catch {
|
|
1900
|
+
/* swallow */
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
/**
|
|
1904
|
+
* Fetch an external image (e.g. a generated QR code) and return it as a
|
|
1905
|
+
* base64 string + mime, ready to embed in an MCP `image` content block.
|
|
1906
|
+
* MCP clients that support inline images (Claude Desktop, Cursor) will
|
|
1907
|
+
* render this directly in chat — no browser hop needed for QR scans.
|
|
1908
|
+
* Returns null on any failure so the caller can fall back to a text URL.
|
|
1909
|
+
*/
|
|
1910
|
+
async function imageContentFromUrl(url) {
|
|
1911
|
+
try {
|
|
1912
|
+
const res = await fetch(url);
|
|
1913
|
+
if (!res.ok)
|
|
1914
|
+
return null;
|
|
1915
|
+
const mime = res.headers.get('content-type') || 'image/png';
|
|
1916
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
1917
|
+
return { type: 'image', data: buf.toString('base64'), mimeType: mime };
|
|
1918
|
+
}
|
|
1919
|
+
catch {
|
|
1920
|
+
return null;
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
1924
|
+
tools: TOOLS.map((t) => ({
|
|
1925
|
+
name: t.name,
|
|
1926
|
+
description: t.description,
|
|
1927
|
+
inputSchema: t.inputSchema,
|
|
1928
|
+
})),
|
|
1929
|
+
}));
|
|
1930
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
1931
|
+
const tool = TOOLS.find((t) => t.name === req.params.name);
|
|
1932
|
+
if (!tool) {
|
|
1933
|
+
return {
|
|
1934
|
+
isError: true,
|
|
1935
|
+
content: [{ type: 'text', text: `Unknown tool: ${req.params.name}` }],
|
|
1936
|
+
};
|
|
1937
|
+
}
|
|
1938
|
+
const args = (req.params.arguments ?? {});
|
|
1939
|
+
try {
|
|
1940
|
+
const result = await tool.handler(args);
|
|
1941
|
+
const isError = Boolean(result && typeof result === 'object' && result.error);
|
|
1942
|
+
const resultObj = (result && typeof result === 'object'
|
|
1943
|
+
? result
|
|
1944
|
+
: {});
|
|
1945
|
+
// Pull per-call viewer overrides BEFORE serializing — these are
|
|
1946
|
+
// internal control flags, not user-facing data. Spark bug D.
|
|
1947
|
+
const inlineOverride = resultObj._inline_screenshot === true;
|
|
1948
|
+
const wantsInline = INLINE_PAGE_SCREENSHOT || inlineOverride;
|
|
1949
|
+
// Auto-open is permanently disabled (see AUTO_OPEN_URLS comment
|
|
1950
|
+
// above). We still strip the legacy `_auto_open` flag from the
|
|
1951
|
+
// payload so it doesn't leak through into the serialized result.
|
|
1952
|
+
delete resultObj._inline_screenshot;
|
|
1953
|
+
delete resultObj._auto_open;
|
|
1954
|
+
const text = JSON.stringify(result, null, 2);
|
|
1955
|
+
const content = [{ type: 'text', text }];
|
|
1956
|
+
// 1. Inline QR image for fund_wallet etc. — bytes embedded so the
|
|
1957
|
+
// user sees the QR right in chat without leaving Claude Desktop.
|
|
1958
|
+
const qrUrl = !isError ? resultObj.qr_url : undefined;
|
|
1959
|
+
if (typeof qrUrl === 'string' && qrUrl) {
|
|
1960
|
+
const img = await imageContentFromUrl(qrUrl);
|
|
1961
|
+
if (img)
|
|
1962
|
+
content.push(img);
|
|
1963
|
+
content.push({
|
|
1964
|
+
type: 'text',
|
|
1965
|
+
text: `\n📥 **Scan with your wallet app** (USDC) → ${resultObj.deposit_address ?? '(see deposit_address above)'}`,
|
|
1966
|
+
});
|
|
1967
|
+
}
|
|
1968
|
+
// 2. Surface view_url as a markdown link (post-order storefront page).
|
|
1969
|
+
// The link is clickable in the chat; we no longer auto-launch
|
|
1970
|
+
// the OS browser (see AUTO_OPEN_URLS comment above) — users
|
|
1971
|
+
// asked for that to stop because it stole focus on every order.
|
|
1972
|
+
const viewUrl = !isError ? resultObj.view_url : undefined;
|
|
1973
|
+
if (typeof viewUrl === 'string' && viewUrl) {
|
|
1974
|
+
if (wantsInline) {
|
|
1975
|
+
const shot = await screenshotContentFromUrl(viewUrl);
|
|
1976
|
+
if (shot)
|
|
1977
|
+
content.push(shot);
|
|
1978
|
+
}
|
|
1979
|
+
const hint = wantsInline
|
|
1980
|
+
? ''
|
|
1981
|
+
: '\n\n(pass `inline_screenshot=true` to embed a rendered page screenshot · or set env CURLESS_INLINE_PAGE_SCREENSHOT=true to default it on)';
|
|
1982
|
+
content.push({
|
|
1983
|
+
type: 'text',
|
|
1984
|
+
text: `\n🔗 **Open in browser**: ${viewUrl}${hint}`,
|
|
1985
|
+
});
|
|
1986
|
+
}
|
|
1987
|
+
return { isError, content };
|
|
1988
|
+
}
|
|
1989
|
+
catch (e) {
|
|
1990
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1991
|
+
return {
|
|
1992
|
+
isError: true,
|
|
1993
|
+
content: [{ type: 'text', text: `Tool ${tool.name} crashed: ${msg}` }],
|
|
1994
|
+
};
|
|
1995
|
+
}
|
|
1996
|
+
});
|
|
1997
|
+
async function main() {
|
|
1998
|
+
const transport = new StdioServerTransport();
|
|
1999
|
+
await server.connect(transport);
|
|
2000
|
+
// stderr is the only safe place to print — stdout is reserved for MCP.
|
|
2001
|
+
console.error(`[curless-mcp-server] Ready. Backend=${process.env.CURLESS_BASE_URL ?? 'https://openclaw.curless.ai'} · wallet=${DEFAULT_WALLET_ID}`);
|
|
2002
|
+
}
|
|
2003
|
+
main().catch((e) => {
|
|
2004
|
+
console.error('[curless-mcp-server] Fatal:', e);
|
|
2005
|
+
process.exit(1);
|
|
2006
|
+
});
|
|
2007
|
+
//# sourceMappingURL=index.js.map
|