@blanketbyte/wise-mcp 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tools.js ADDED
@@ -0,0 +1,358 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { appendFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { z } from "zod";
5
+ import { config } from "./config.js";
6
+ import { ACTIVITY_TYPES } from "./wise/service.js";
7
+ import { WiseAccessRevokedError } from "./wise/client.js";
8
+ import { storePending, takePending, discardPending } from "./pending.js";
9
+ const ok = (data) => ({
10
+ content: [{ type: "text", text: typeof data === "string" ? data : JSON.stringify(data, null, 2) }],
11
+ });
12
+ const fail = (message) => ({ content: [{ type: "text", text: message }], isError: true });
13
+ const date = z
14
+ .string()
15
+ .regex(/^\d{4}-\d{2}-\d{2}(T[\d:.]+(Z|[+-]\d{2}:?\d{2})?)?$/, "Use YYYY-MM-DD or an ISO 8601 timestamp");
16
+ const profileArg = z
17
+ .union([z.string(), z.number().int()])
18
+ .optional()
19
+ .describe('Profile id from list_profiles, "personal", "business" or part of the profile name');
20
+ const READ = { readOnlyHint: true, openWorldHint: false };
21
+ const currency = z.string().regex(/^[A-Za-z]{3}$/, "3-letter currency code").transform((c) => c.toUpperCase());
22
+ const money = (n, c) => (n === undefined ? "?" : `${n.toFixed(2)} ${c ?? ""}`.trim());
23
+ async function audit(event, details) {
24
+ const line = JSON.stringify({ at: new Date().toISOString(), event, ...details }) + "\n";
25
+ await appendFile(path.join(config.DATA_DIR, "audit.log"), line, { mode: 0o600 }).catch(() => { });
26
+ }
27
+ /**
28
+ * `sessionKey` scopes confirmation codes (a code only works in the session that prepared it);
29
+ * `connectionId` identifies the Wise token in the audit log.
30
+ */
31
+ export function registerTools(server, wise, { sessionKey, connectionId, onRevoked }) {
32
+ async function safe(fn) {
33
+ try {
34
+ return await fn();
35
+ }
36
+ catch (err) {
37
+ if (err instanceof WiseAccessRevokedError)
38
+ await onRevoked();
39
+ return fail(err instanceof Error ? err.message : String(err));
40
+ }
41
+ }
42
+ server.registerTool("list_profiles", {
43
+ title: "List Wise profiles",
44
+ description: "List the Wise profiles (personal and business) this token can access, with id, type and name.",
45
+ inputSchema: {},
46
+ annotations: READ,
47
+ }, async () => safe(async () => ok(await wise.listProfiles())));
48
+ server.registerTool("list_balances", {
49
+ title: "List Wise balances",
50
+ description: "Current balances in every currency plus Jars (savings), with amounts. Default: all profiles. " +
51
+ "Empty balances are hidden unless include_empty is true.",
52
+ inputSchema: {
53
+ profile: profileArg.describe('Profile id, "personal", "business", part of the name, or "all" (default)'),
54
+ include_empty: z.boolean().optional().describe("Also list balances with 0"),
55
+ },
56
+ annotations: READ,
57
+ }, async ({ profile, include_empty }) => safe(async () => ok(await wise.listBalances(await wise.resolveProfiles(profile), include_empty ?? false))));
58
+ server.registerTool("get_transactions", {
59
+ title: "Get Wise transactions",
60
+ description: "List activity on a Wise profile, newest first: card payments, transfers, conversions, cashback, direct debits. " +
61
+ "Amounts are positive numbers; `direction` is in/out/neutral (neutral = between your own balances). " +
62
+ "`title` is the merchant or counterparty. Filter with `type`, `since`/`until` (YYYY-MM-DD, UTC) and `search`. " +
63
+ "If `next_cursor` is returned, pass it as `cursor` (with the same filters) for older results. " +
64
+ "Use `resource.id` of TRANSFER items with get_transfer for details. Default profile: personal.",
65
+ inputSchema: {
66
+ profile: profileArg,
67
+ type: z
68
+ .string()
69
+ .optional()
70
+ .describe(`One of ${Object.keys(ACTIVITY_TYPES).join(", ")}, or a raw Wise monetaryResourceType (e.g. CARD_TRANSACTION)`),
71
+ status: z.enum(["COMPLETED", "IN_PROGRESS", "UPCOMING", "REQUIRES_ATTENTION", "CANCELLED"]).optional(),
72
+ since: date.optional().describe("Only on/after this date (YYYY-MM-DD or ISO timestamp)"),
73
+ until: date.optional().describe("Only on/before this date (YYYY-MM-DD or ISO timestamp)"),
74
+ search: z
75
+ .string()
76
+ .optional()
77
+ .describe("Case-insensitive text match on merchant/title/description (scans up to 2000 items; may return slightly more than limit)"),
78
+ limit: z.number().int().min(1).max(500).optional().describe("Max items (default 50)"),
79
+ cursor: z.string().optional().describe("next_cursor from a previous call"),
80
+ },
81
+ annotations: READ,
82
+ }, async (args) => safe(async () => {
83
+ const profile = await wise.resolveProfile(args.profile);
84
+ const resourceType = args.type ? (ACTIVITY_TYPES[args.type.toLowerCase()] ?? args.type.toUpperCase()) : undefined;
85
+ const { activities, nextCursor } = await wise.listActivities({
86
+ profile,
87
+ limit: args.limit ?? 50,
88
+ since: args.since,
89
+ until: args.until,
90
+ resourceType,
91
+ status: args.status,
92
+ search: args.search,
93
+ cursor: args.cursor,
94
+ });
95
+ return ok({ profile, count: activities.length, transactions: activities, next_cursor: nextCursor });
96
+ }));
97
+ server.registerTool("list_transfers", {
98
+ title: "List Wise transfers",
99
+ description: "Transfers (money sent or converted) on a profile, newest first, with both currencies, reference, status and recipient name. " +
100
+ "Filter by date (YYYY-MM-DD), status (e.g. outgoing_payment_sent, processing, cancelled) or search (reference/recipient). Default profile: personal.",
101
+ inputSchema: {
102
+ profile: profileArg,
103
+ since: date.optional(),
104
+ until: date.optional(),
105
+ status: z.string().optional().describe("Wise transfer status, e.g. outgoing_payment_sent"),
106
+ search: z.string().optional().describe("Case-insensitive match on reference or recipient name"),
107
+ limit: z.number().int().min(1).max(200).optional().describe("Max transfers (default 25)"),
108
+ },
109
+ annotations: READ,
110
+ }, async (args) => safe(async () => {
111
+ const profile = await wise.resolveProfile(args.profile);
112
+ const res = await wise.listTransfers({ profile, limit: args.limit ?? 25, since: args.since, until: args.until, status: args.status, search: args.search });
113
+ return ok({ profile, ...res });
114
+ }));
115
+ server.registerTool("get_transfer", {
116
+ title: "Get a Wise transfer",
117
+ description: "Details of one transfer: amounts in both currencies, rate, reference, status, created date and recipient.",
118
+ inputSchema: { id: z.coerce.number().int().describe("Transfer id (from list_transfers or a TRANSFER activity's resource.id)") },
119
+ annotations: READ,
120
+ }, async ({ id }) => safe(async () => ok(await wise.getTransfer(id))));
121
+ server.registerTool("list_recipients", {
122
+ title: "List Wise recipients",
123
+ description: "Saved recipients (name, currency, account type, account number/IBAN). Default: all profiles.",
124
+ inputSchema: {
125
+ profile: profileArg.describe('Profile id, "personal", "business", part of the name, or "all" (default)'),
126
+ currency: currency.optional().describe("Only recipients in this currency, e.g. EUR"),
127
+ search: z.string().optional().describe("Case-insensitive match on name or account"),
128
+ },
129
+ annotations: READ,
130
+ }, async ({ profile, currency, search }) => safe(async () => {
131
+ const s = search?.trim().toLowerCase();
132
+ const all = await wise.listRecipients(await wise.resolveProfiles(profile), currency);
133
+ const recipients = s ? all.filter((r) => [r.name, r.account, r.details].some((v) => v?.toLowerCase().includes(s))) : all;
134
+ return ok({ count: recipients.length, recipients });
135
+ }));
136
+ server.registerTool("get_exchange_rate", {
137
+ title: "Get Wise exchange rate",
138
+ description: "Wise's mid-market rate between two currencies, now or at a past date/time.",
139
+ inputSchema: {
140
+ source: currency.describe("Source currency, e.g. EUR"),
141
+ target: currency.describe("Target currency, e.g. RON"),
142
+ at: date.optional().describe("Historic rate at this date/time (default: now)"),
143
+ },
144
+ annotations: READ,
145
+ }, async ({ source, target, at }) => safe(async () => ok(await wise.getRate(source, target, at))));
146
+ if (config.WRITES_ENABLED)
147
+ registerWriteTools(server, wise, { sessionKey, connectionId }, safe);
148
+ }
149
+ // -----------------------------------------------------------------------------
150
+ // Write tools: every action is prepare → user approves in chat → confirm_action.
151
+ // -----------------------------------------------------------------------------
152
+ const TWO_STEP = "Nothing is written to Wise yet: this returns a summary and a one-time confirmation_code. Show the summary to the user and " +
153
+ "call confirm_action ONLY if the user explicitly approves it in their next message.";
154
+ function registerWriteTools(server, wise, { sessionKey, connectionId }, safe) {
155
+ const log = (event, details) => audit(event, { connection: connectionId, ...details });
156
+ const limitText = config.MAX_TRANSFER_AMOUNT ? ` Max ${config.MAX_TRANSFER_AMOUNT} (source currency) per transfer.` : "";
157
+ server.registerTool("get_quote", {
158
+ title: "Get a Wise quote",
159
+ description: "Price a transfer or conversion: rate, fee, amount received and estimated delivery. Creates nothing the user sees and moves no money. " +
160
+ "Give either send_amount (in source_currency) or receive_amount (in target_currency).",
161
+ inputSchema: {
162
+ source_currency: currency,
163
+ target_currency: currency,
164
+ send_amount: z.number().positive().optional(),
165
+ receive_amount: z.number().positive().optional(),
166
+ recipient_id: z.number().int().optional().describe("Saved recipient, for an exact price and delivery time"),
167
+ profile: profileArg,
168
+ },
169
+ annotations: { readOnlyHint: true, openWorldHint: true },
170
+ }, async (a) => safe(async () => {
171
+ if (!!a.send_amount === !!a.receive_amount)
172
+ return fail("Give exactly one of send_amount or receive_amount.");
173
+ const profile = await wise.resolveProfile(a.profile);
174
+ return ok(await wise.createQuote(profile, {
175
+ sourceCurrency: a.source_currency,
176
+ targetCurrency: a.target_currency,
177
+ sourceAmount: a.send_amount,
178
+ targetAmount: a.receive_amount,
179
+ targetAccount: a.recipient_id,
180
+ }));
181
+ }));
182
+ server.registerTool("get_recipient_requirements", {
183
+ title: "Get Wise recipient fields",
184
+ description: "Which bank details Wise needs to add a recipient in a currency (e.g. IBAN for EUR, sort code + account number for GBP). " +
185
+ "Each option has a `type` and `fields` (key, required, example, allowed values). Use before prepare_recipient.",
186
+ inputSchema: {
187
+ currency: currency.describe("Recipient's currency"),
188
+ source_currency: currency.optional().describe("Currency you'll send from (default: same as currency)"),
189
+ amount: z.number().positive().optional().describe("Typical amount (default 100); some routes depend on it"),
190
+ profile: profileArg,
191
+ },
192
+ annotations: { readOnlyHint: true, openWorldHint: true },
193
+ }, async (a) => safe(async () => {
194
+ const profile = await wise.resolveProfile(a.profile);
195
+ return ok(await wise.recipientRequirements(profile, a.source_currency ?? a.currency, a.currency, a.amount ?? 100));
196
+ }));
197
+ server.registerTool("prepare_recipient", {
198
+ title: "Prepare a new Wise recipient",
199
+ description: `Step 1 of 2 to save a new recipient. Use get_recipient_requirements first for the right \`type\` and \`details\` keys. ${TWO_STEP}`,
200
+ inputSchema: {
201
+ currency,
202
+ type: z.string().min(1).describe("Requirement type, e.g. iban, sort_code, aba"),
203
+ account_holder_name: z.string().min(2).describe("Recipient's full name or business name"),
204
+ details: z
205
+ .record(z.string(), z.unknown())
206
+ .describe('Fields from get_recipient_requirements, e.g. {"legalType":"PRIVATE","iban":"DE89..."}; nested keys like address.city as objects'),
207
+ owned_by_me: z.boolean().optional().describe("True if this is the user's own account elsewhere"),
208
+ profile: profileArg,
209
+ },
210
+ annotations: { readOnlyHint: true, openWorldHint: false },
211
+ }, async (a) => safe(async () => {
212
+ const profile = await wise.resolveProfile(a.profile);
213
+ const name = a.account_holder_name.trim();
214
+ const shown = Object.entries(a.details)
215
+ .map(([k, v]) => `${k}=${typeof v === "object" ? JSON.stringify(v) : String(v)}`)
216
+ .join(", ");
217
+ const summary = `Add ${a.currency} recipient "${name}" (${a.type}: ${shown}) to ${profile.type.toLowerCase()} profile ${profile.id}`;
218
+ const { code, expiresAt } = storePending(sessionKey, { kind: "recipient", profileId: profile.id, currency: a.currency, type: a.type, accountHolderName: name, ownedByCustomer: a.owned_by_me, details: a.details }, summary);
219
+ await log("recipient_prepared", { code, summary });
220
+ return ok({ status: "awaiting_user_confirmation", summary, confirmation_code: code, expires_at: expiresAt.toISOString() });
221
+ }));
222
+ server.registerTool("prepare_transfer", {
223
+ title: "Prepare a Wise transfer",
224
+ description: "Step 1 of 2 to send money to a saved recipient (list_recipients / prepare_recipient). Gets a live quote and checks Wise's requirements. " +
225
+ `${TWO_STEP} Confirming creates the transfer in Wise UNPAID: the user then pays it in the Wise app, so money only moves after that.${limitText}`,
226
+ inputSchema: {
227
+ recipient_id: z.number().int().describe("Saved recipient id"),
228
+ send_amount: z.number().positive().optional().describe("Amount to send, in source_currency"),
229
+ receive_amount: z.number().positive().optional().describe("Amount the recipient should get, in their currency"),
230
+ source_currency: currency.optional().describe("Currency to send from (default: the recipient's currency)"),
231
+ reference: z.string().max(140).optional().describe("Reference the recipient sees (max length depends on the route)"),
232
+ extra_details: z
233
+ .record(z.string(), z.unknown())
234
+ .optional()
235
+ .describe("Extra fields Wise requires on some routes, e.g. transferPurpose, sourceOfFunds (a previous attempt lists them)"),
236
+ profile: profileArg.describe("Default: the profile the recipient belongs to"),
237
+ },
238
+ annotations: { readOnlyHint: true, openWorldHint: true },
239
+ }, async (a) => safe(async () => {
240
+ if (!!a.send_amount === !!a.receive_amount)
241
+ return fail("Give exactly one of send_amount or receive_amount.");
242
+ const recipient = await wise.getRecipient(a.recipient_id);
243
+ if (recipient.active === false)
244
+ return fail(`Recipient ${a.recipient_id} is inactive.`);
245
+ const profile = await wise.resolveProfile(a.profile ?? recipient.profile_id);
246
+ const sourceCurrency = a.source_currency ?? recipient.currency;
247
+ if (a.send_amount && config.MAX_TRANSFER_AMOUNT && a.send_amount > config.MAX_TRANSFER_AMOUNT) {
248
+ return fail(`Amount exceeds the configured limit of ${config.MAX_TRANSFER_AMOUNT} ${sourceCurrency} per transfer.`);
249
+ }
250
+ const quote = await wise.createQuote(profile, {
251
+ sourceCurrency,
252
+ targetCurrency: recipient.currency,
253
+ sourceAmount: a.send_amount,
254
+ targetAmount: a.receive_amount,
255
+ targetAccount: recipient.id,
256
+ });
257
+ if (config.MAX_TRANSFER_AMOUNT && quote.source.amount > config.MAX_TRANSFER_AMOUNT) {
258
+ return fail(`This transfer costs ${money(quote.source.amount, sourceCurrency)}, over the configured limit of ${config.MAX_TRANSFER_AMOUNT}.`);
259
+ }
260
+ const details = { ...(a.extra_details ?? {}) };
261
+ if (a.reference?.trim())
262
+ details.reference = a.reference.trim();
263
+ const missing = await wise.missingTransferFields(quote.id, recipient.id, details);
264
+ if (missing.length) {
265
+ return fail("Wise needs more details for this transfer. Ask the user, then call prepare_transfer again with extra_details: " +
266
+ JSON.stringify(missing.map((f) => ({ key: f.key, name: f.name, allowed: f.allowed?.map((v) => v.key) }))));
267
+ }
268
+ const balances = await wise.listBalances([profile], true);
269
+ const balance = balances.find((b) => b.kind === "balance" && b.currency === sourceCurrency);
270
+ const available = balance ? Number(balance.amount) : 0;
271
+ const summary = `Send ${money(quote.source.amount, sourceCurrency)} from your ${profile.type.toLowerCase()} profile → ` +
272
+ `${recipient.name} (${recipient.account ?? recipient.type}) receives ${money(quote.target.amount, recipient.currency)}` +
273
+ (quote.fee ? `, fee ${money(quote.fee.amount, sourceCurrency)}` : "") +
274
+ (sourceCurrency !== recipient.currency ? `, rate ${quote.rate}` : "") +
275
+ (details.reference ? `, reference "${details.reference}"` : "") +
276
+ (quote.delivery ? `, ${stripHtml(quote.delivery)}` : "");
277
+ const { code, expiresAt } = storePending(sessionKey, { kind: "transfer", profileId: profile.id, quoteId: quote.id, targetAccount: recipient.id, customerTransactionId: randomUUID(), details }, summary);
278
+ await log("transfer_prepared", { code, summary });
279
+ return ok({
280
+ status: "awaiting_user_confirmation",
281
+ summary,
282
+ quote,
283
+ balance_check: available >= quote.source.amount
284
+ ? `OK: ${money(available, sourceCurrency)} available`
285
+ : `Not enough ${sourceCurrency} balance (${money(available, sourceCurrency)}); the user can still pay another way in the Wise app`,
286
+ confirmation_code: code,
287
+ expires_at: expiresAt.toISOString(),
288
+ next_step: "Show the summary. Only call confirm_action if the user explicitly approves. Afterwards they pay it in the Wise app.",
289
+ });
290
+ }));
291
+ server.registerTool("prepare_cancel_transfer", {
292
+ title: "Prepare cancelling a Wise transfer",
293
+ description: `Step 1 of 2 to cancel a transfer that hasn't been paid yet (status incoming_payment_waiting). ${TWO_STEP}`,
294
+ inputSchema: { transfer_id: z.coerce.number().int() },
295
+ annotations: { readOnlyHint: true, openWorldHint: false },
296
+ }, async ({ transfer_id }) => safe(async () => {
297
+ const t = await wise.getTransfer(transfer_id);
298
+ if (t.status !== "incoming_payment_waiting") {
299
+ return fail(`Transfer ${transfer_id} is "${t.status}"; Wise only allows cancelling unpaid transfers (incoming_payment_waiting) via API.`);
300
+ }
301
+ const summary = `Cancel transfer ${t.id}: ${money(t.source.amount, t.source.currency)} → ${t.recipient?.name ?? `recipient ${t.recipient_id}`} ` +
302
+ `(${money(t.target.amount, t.target.currency)})${t.reference ? `, reference "${t.reference}"` : ""}, created ${t.created}`;
303
+ const { code, expiresAt } = storePending(sessionKey, { kind: "cancel_transfer", transferId: t.id }, summary);
304
+ await log("cancel_prepared", { code, summary });
305
+ return ok({ status: "awaiting_user_confirmation", summary, confirmation_code: code, expires_at: expiresAt.toISOString() });
306
+ }));
307
+ server.registerTool("confirm_action", {
308
+ title: "Confirm a prepared Wise action",
309
+ description: "Step 2 of 2: performs a transfer, new recipient or cancellation prepared with a prepare_* tool. ONLY call this after the user has " +
310
+ "explicitly approved that exact summary in their latest message. Codes are single-use and expire after 10 minutes. " +
311
+ "Transfers are created unpaid: the user pays them in the Wise app.",
312
+ inputSchema: { confirmation_code: z.string().describe("Code from a prepare_* tool, e.g. TX-1A2B3C4D") },
313
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
314
+ }, async ({ confirmation_code }) => safe(async () => {
315
+ const p = takePending(sessionKey, confirmation_code);
316
+ if (!p)
317
+ return fail("Unknown or expired confirmation code. Prepare the action again.");
318
+ const a = p.action;
319
+ try {
320
+ if (a.kind === "transfer") {
321
+ const t = await wise.createTransfer({ targetAccount: a.targetAccount, quoteId: a.quoteId, customerTransactionId: a.customerTransactionId, details: a.details });
322
+ await log("transfer_created", { code: confirmation_code, summary: p.summary, transfer_id: t.id, status: t.status });
323
+ return ok({
324
+ status: "created_awaiting_payment",
325
+ transfer_id: t.id,
326
+ wise_status: t.status,
327
+ summary: p.summary,
328
+ next_step: "Open the Wise app (or wise.com): the transfer is waiting for you to pay. No money has moved yet.",
329
+ });
330
+ }
331
+ if (a.kind === "recipient") {
332
+ const profile = await wise.resolveProfile(a.profileId);
333
+ const r = await wise.createRecipient(profile, a);
334
+ await log("recipient_created", { code: confirmation_code, summary: p.summary, recipient_id: r.id });
335
+ return ok({ status: "recipient_created", recipient: r, summary: p.summary });
336
+ }
337
+ const t = await wise.cancelTransfer(a.transferId);
338
+ await log("transfer_cancelled", { code: confirmation_code, summary: p.summary, status: t.status });
339
+ return ok({ status: "cancelled", transfer_id: a.transferId, wise_status: t.status, summary: p.summary });
340
+ }
341
+ catch (err) {
342
+ await log(`${a.kind}_failed`, { code: confirmation_code, summary: p.summary, error: String(err) });
343
+ throw err;
344
+ }
345
+ }));
346
+ server.registerTool("discard_action", {
347
+ title: "Discard a prepared Wise action",
348
+ description: "Throw away an action prepared with a prepare_* tool, before it's confirmed.",
349
+ inputSchema: { confirmation_code: z.string() },
350
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
351
+ }, async ({ confirmation_code }) => {
352
+ const removed = discardPending(sessionKey, confirmation_code);
353
+ if (removed)
354
+ await log("action_discarded", { code: confirmation_code });
355
+ return ok(removed ? "Discarded. Nothing was sent to Wise." : "No pending action with that code.");
356
+ });
357
+ }
358
+ const stripHtml = (s) => s.replace(/<[^>]*>/g, "").trim();
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Minimal Wise API client for a personal API token (https://docs.wise.com/guides/developer/auth-and-security/personal-api-token).
3
+ */
4
+ export class WiseApiError extends Error {
5
+ status;
6
+ constructor(status, message) {
7
+ super(message);
8
+ this.status = status;
9
+ this.name = "WiseApiError";
10
+ }
11
+ }
12
+ /** Wise no longer accepts the token (revoked or expired in Wise → Settings → API tokens). */
13
+ export class WiseAccessRevokedError extends Error {
14
+ constructor() {
15
+ super("The Wise API token was revoked or has expired. Create a new token in Wise and reconnect (or update WISE_API_TOKEN when running locally).");
16
+ this.name = "WiseAccessRevokedError";
17
+ }
18
+ }
19
+ const TIMEOUT_MS = 20_000;
20
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
21
+ export class WiseClient {
22
+ token;
23
+ baseUrl;
24
+ constructor(token, baseUrl) {
25
+ this.token = token;
26
+ this.baseUrl = baseUrl;
27
+ }
28
+ get(path, query = {}, headers) {
29
+ return this.request("GET", path, { query, headers, idempotent: true });
30
+ }
31
+ post(path, body, opts = {}) {
32
+ return this.request("POST", path, { ...opts, body });
33
+ }
34
+ put(path, body) {
35
+ return this.request("PUT", path, { body, idempotent: true });
36
+ }
37
+ me() {
38
+ return this.get("/v1/me");
39
+ }
40
+ async request(method, path, opts = {}) {
41
+ const url = new URL(this.baseUrl + path);
42
+ for (const [k, v] of Object.entries(opts.query ?? {}))
43
+ if (v !== undefined && v !== "")
44
+ url.searchParams.set(k, String(v));
45
+ for (let attempt = 0;; attempt++) {
46
+ let res;
47
+ try {
48
+ res = await fetch(url, {
49
+ method,
50
+ headers: {
51
+ Authorization: `Bearer ${this.token}`,
52
+ Accept: "application/json",
53
+ "User-Agent": "wise-mcp",
54
+ ...(opts.body !== undefined ? { "Content-Type": "application/json" } : {}),
55
+ ...opts.headers,
56
+ },
57
+ body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
58
+ signal: AbortSignal.timeout(TIMEOUT_MS),
59
+ });
60
+ }
61
+ catch (err) {
62
+ if (opts.idempotent && attempt < 1)
63
+ continue;
64
+ throw err;
65
+ }
66
+ // 429 means the request wasn't processed, so it's always safe to retry.
67
+ if (res.status === 429 && attempt < 2) {
68
+ await sleep(Math.min(10, Number(res.headers.get("retry-after")) || 2) * 1000);
69
+ continue;
70
+ }
71
+ if (res.status >= 500 && opts.idempotent && attempt < 1) {
72
+ await sleep(1000);
73
+ continue;
74
+ }
75
+ const text = await res.text();
76
+ if (res.ok)
77
+ return (text ? JSON.parse(text) : undefined);
78
+ if (res.status === 401)
79
+ throw new WiseAccessRevokedError();
80
+ if (res.status === 403 && path !== "/v1/me" && (await this.isRevoked()))
81
+ throw new WiseAccessRevokedError();
82
+ let message = errorMessage(text, res.status);
83
+ if (res.status === 403) {
84
+ if (res.headers.get("x-2fa-approval")) {
85
+ message += " — Wise requires strong customer authentication for this, which a personal token can't complete here. Do it in the Wise app.";
86
+ }
87
+ else if (method !== "GET") {
88
+ message += " — if your token is Read only, create a Full access token in Wise and reconnect.";
89
+ }
90
+ }
91
+ throw new WiseApiError(res.status, `Wise API ${res.status} on ${method} ${path}: ${message}`);
92
+ }
93
+ }
94
+ /** A 403 can mean "not allowed for this token" or "token revoked": ask /v1/me. */
95
+ async isRevoked() {
96
+ try {
97
+ await this.me();
98
+ return false;
99
+ }
100
+ catch (err) {
101
+ return err instanceof WiseAccessRevokedError || (err instanceof WiseApiError && err.status === 403);
102
+ }
103
+ }
104
+ }
105
+ function errorMessage(text, status) {
106
+ try {
107
+ const j = JSON.parse(text);
108
+ if (Array.isArray(j.errors) && j.errors.length) {
109
+ return j.errors.map((e) => [e.path || e.field, e.message ?? e.code].filter(Boolean).join(": ")).join("; ");
110
+ }
111
+ return j.message ?? j.error_description ?? j.error ?? `HTTP ${status}`;
112
+ }
113
+ catch {
114
+ return status === 403 ? "forbidden (not available for this token)" : `HTTP ${status}`;
115
+ }
116
+ }
@@ -0,0 +1,76 @@
1
+ import { readFileSync, mkdirSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { config } from "../config.js";
4
+ import { sha256 } from "../crypto.js";
5
+ import { writeJsonAtomic } from "../fsutil.js";
6
+ import { WiseClient } from "./client.js";
7
+ import { WiseService } from "./service.js";
8
+ export const wiseClient = (token) => new WiseClient(token, config.WISE_API_URL);
9
+ /** Wise connections (one per pasted API token), stored encrypted in DATA_DIR. */
10
+ export class ConnectionStore {
11
+ box;
12
+ file;
13
+ data = {};
14
+ services = new Map();
15
+ constructor(dataDir, box) {
16
+ this.box = box;
17
+ const dir = path.join(dataDir, "wise");
18
+ mkdirSync(dir, { recursive: true });
19
+ this.file = path.join(dir, "connections.json");
20
+ try {
21
+ this.data = JSON.parse(readFileSync(this.file, "utf8"));
22
+ }
23
+ catch {
24
+ /* first run */
25
+ }
26
+ }
27
+ static idFor(token) {
28
+ return sha256(token).slice(0, 24);
29
+ }
30
+ save(token, me) {
31
+ const id = ConnectionStore.idFor(token);
32
+ this.data[id] = { token: this.box.seal(token), wiseUserId: me.id, wiseUserLabel: me.name ?? me.email, createdAt: Date.now() };
33
+ this.services.delete(id);
34
+ this.flush();
35
+ return id;
36
+ }
37
+ get(id) {
38
+ const c = this.data[id];
39
+ return c && { id, wiseUserId: c.wiseUserId, wiseUserLabel: c.wiseUserLabel, createdAt: c.createdAt };
40
+ }
41
+ service(id) {
42
+ const c = this.data[id];
43
+ if (!c)
44
+ return undefined;
45
+ let svc = this.services.get(id);
46
+ if (!svc) {
47
+ let token;
48
+ try {
49
+ token = this.box.open(c.token);
50
+ }
51
+ catch {
52
+ console.warn(`Wise connection ${id} can't be decrypted (TOKEN_ENCRYPTION_KEY changed?); dropping it`);
53
+ this.remove(id);
54
+ return undefined;
55
+ }
56
+ svc = new WiseService(wiseClient(token));
57
+ this.services.set(id, svc);
58
+ }
59
+ return svc;
60
+ }
61
+ remove(id) {
62
+ this.services.delete(id);
63
+ if (delete this.data[id])
64
+ this.flush();
65
+ }
66
+ /** Drops tokens no Claude session uses any more, so they aren't kept longer than needed. */
67
+ prune(inUse) {
68
+ const unused = Object.keys(this.data).filter((id) => !inUse.has(id));
69
+ for (const id of unused)
70
+ this.remove(id);
71
+ return unused.length;
72
+ }
73
+ flush() {
74
+ writeJsonAtomic(this.file, this.data);
75
+ }
76
+ }