@blanketbyte/wise-mcp 1.0.8 → 1.1.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.
@@ -0,0 +1,146 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Output schemas: the shape of each tool's structuredContent. They're loose on purpose (extra keys allowed, values
4
+ * that come from Wise may be missing), so a new field in Wise's API can never make a tool call fail validation.
5
+ */
6
+ const id = z.union([z.number(), z.string()]);
7
+ const num = z.number().nullish();
8
+ const str = z.string().nullish();
9
+ const money = z.looseObject({ amount: num, currency: str });
10
+ const profileRef = z.looseObject({ id, type: z.string() });
11
+ const profile = z.looseObject({ id, type: z.string(), name: str });
12
+ const recipient = z.looseObject({
13
+ id,
14
+ name: str,
15
+ currency: str,
16
+ type: str,
17
+ account: str.describe("Account number or IBAN, as Wise shows it"),
18
+ details: str,
19
+ owned_by_me: z.boolean().nullish(),
20
+ profile_id: id.nullish(),
21
+ active: z.boolean().nullish(),
22
+ profile: profileRef.optional(),
23
+ });
24
+ const transferShape = {
25
+ id,
26
+ created: str,
27
+ status: str.describe("Wise transfer status, e.g. incoming_payment_waiting, outgoing_payment_sent, cancelled"),
28
+ reference: str,
29
+ source: money.describe("Amount converted, in the source currency (excludes the fee)"),
30
+ target: money.describe("Amount the recipient gets"),
31
+ recipient_id: id.nullish(),
32
+ };
33
+ const quote = z.looseObject({
34
+ id: z.string(),
35
+ source: money.describe("What you pay"),
36
+ target: money.describe("What the recipient gets, when paid from the Wise balance"),
37
+ rate: num,
38
+ rate_type: str,
39
+ rate_expires: str,
40
+ fee: money.optional(),
41
+ pay_in: str,
42
+ pay_out: str,
43
+ estimated_delivery: str,
44
+ delivery: str,
45
+ notices: z.array(z.string()).optional(),
46
+ pay_in_options: z
47
+ .array(z.looseObject({ pay_in: z.string(), fee: num, target_amount: num }))
48
+ .optional()
49
+ .describe("Price per payment method, cheapest first (methods with the same price merged)"),
50
+ });
51
+ const pending = {
52
+ status: z.string().describe("awaiting_user_confirmation"),
53
+ summary: z.string().describe("Show this to the user before confirming"),
54
+ confirmation_code: z.string(),
55
+ expires_at: z.string(),
56
+ };
57
+ export const outputs = {
58
+ list_profiles: { profiles: z.array(profile) },
59
+ list_balances: {
60
+ balances: z.array(z.looseObject({
61
+ id,
62
+ profile: profileRef,
63
+ kind: z.string().describe("balance or jar"),
64
+ name: str,
65
+ currency: str,
66
+ amount: num.describe("Available to spend or send now; reserved money is already taken out of it"),
67
+ reserved: num.describe("Held for pending transactions (mostly card payments not settled yet). Already deducted from amount: not extra money"),
68
+ total_worth: num.describe("Only when it differs from amount, e.g. invested balances"),
69
+ note: str,
70
+ })),
71
+ },
72
+ get_transactions: {
73
+ profile,
74
+ count: z.number(),
75
+ transactions: z.array(z.looseObject({
76
+ id,
77
+ date: str,
78
+ type: str,
79
+ status: str,
80
+ title: str.describe("Merchant or counterparty"),
81
+ description: str,
82
+ direction: str.describe("in, out or neutral (between your own balances)"),
83
+ amount: num,
84
+ currency: str,
85
+ amount_text: str,
86
+ secondary_amount: money.optional(),
87
+ secondary_text: str,
88
+ resource: z.looseObject({ type: str, id: id.nullish() }).optional(),
89
+ })),
90
+ next_cursor: str,
91
+ },
92
+ list_transfers: {
93
+ profile,
94
+ total: z.number(),
95
+ transfers: z.array(z.looseObject({ ...transferShape, recipient_name: str })),
96
+ truncated: z.boolean().optional(),
97
+ },
98
+ get_transfer: {
99
+ ...transferShape,
100
+ recipient: recipient.optional(),
101
+ rate: num,
102
+ quote_id: str,
103
+ has_active_issues: z.boolean().nullish(),
104
+ raw_status: str,
105
+ note: str,
106
+ },
107
+ list_recipients: { count: z.number(), recipients: z.array(recipient) },
108
+ get_exchange_rate: { source: str, target: str, rate: num, time: str },
109
+ get_quote: quote.shape,
110
+ get_recipient_requirements: {
111
+ options: z.array(z.looseObject({
112
+ type: z.string().describe("Pass as `type` to prepare_recipient"),
113
+ title: str,
114
+ usage: str,
115
+ fields: z.array(z.looseObject({
116
+ key: z.string(),
117
+ name: str,
118
+ required: z.boolean(),
119
+ type: str,
120
+ example: str,
121
+ pattern: str,
122
+ allowed: z.array(z.looseObject({ key: id, name: str })).optional(),
123
+ refresh_on_change: z.boolean().optional(),
124
+ })),
125
+ })),
126
+ },
127
+ prepare_recipient: pending,
128
+ prepare_transfer: {
129
+ ...pending,
130
+ quote,
131
+ balance_check: z.string(),
132
+ next_step: z.string(),
133
+ },
134
+ prepare_cancel_transfer: pending,
135
+ confirm_action: {
136
+ status: z.string().describe("created_awaiting_payment, recipient_created or cancelled"),
137
+ summary: z.string(),
138
+ transfer_id: id.optional(),
139
+ wise_status: str,
140
+ recipient: recipient.optional(),
141
+ wise_shows_until_paid: z.string().optional(),
142
+ note: z.string().optional(),
143
+ next_step: z.string().optional(),
144
+ },
145
+ discard_action: { status: z.string().describe("discarded or not_found"), message: z.string() },
146
+ };
package/dist/tools.js CHANGED
@@ -6,8 +6,11 @@ import { config } from "./config.js";
6
6
  import { ACTIVITY_TYPES, UNPAID_PRICE_NOTE } from "./wise/service.js";
7
7
  import { WiseAccessRevokedError } from "./wise/client.js";
8
8
  import { storePending, takePending, discardPending } from "./pending.js";
9
+ import { outputs } from "./schemas.js";
10
+ /** Every tool returns an object: as text (for older clients) and as structuredContent matching its output schema. */
9
11
  const ok = (data) => ({
10
- content: [{ type: "text", text: typeof data === "string" ? data : JSON.stringify(data, null, 2) }],
12
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
13
+ structuredContent: data,
11
14
  });
12
15
  const fail = (message) => ({ content: [{ type: "text", text: message }], isError: true });
13
16
  const date = z
@@ -43,18 +46,20 @@ export function registerTools(server, wise, { sessionKey, connectionId, onRevoke
43
46
  title: "List Wise profiles",
44
47
  description: "List the Wise profiles (personal and business) this token can access, with id, type and name.",
45
48
  inputSchema: {},
49
+ outputSchema: outputs.list_profiles,
46
50
  annotations: READ,
47
- }, async () => safe(async () => ok(await wise.listProfiles())));
51
+ }, async () => safe(async () => ok({ profiles: await wise.listProfiles() })));
48
52
  server.registerTool("list_balances", {
49
53
  title: "List Wise balances",
50
- description: "Current balances in every currency plus Jars (savings), with amounts. Default: all profiles. " +
54
+ description: "Current balances in every currency plus Jars (savings). `amount` is what's available now; `reserved` (pending card payments) is already deducted from it, not extra money. Default: all profiles. " +
51
55
  "Empty balances are hidden unless include_empty is true.",
52
56
  inputSchema: {
53
57
  profile: profileArg.describe('Profile id, "personal", "business", part of the name, or "all" (default)'),
54
58
  include_empty: z.boolean().optional().describe("Also list balances with 0"),
55
59
  },
60
+ outputSchema: outputs.list_balances,
56
61
  annotations: READ,
57
- }, async ({ profile, include_empty }) => safe(async () => ok(await wise.listBalances(await wise.resolveProfiles(profile), include_empty ?? false))));
62
+ }, async ({ profile, include_empty }) => safe(async () => ok({ balances: await wise.listBalances(await wise.resolveProfiles(profile), include_empty ?? false) })));
58
63
  server.registerTool("get_transactions", {
59
64
  title: "Get Wise transactions",
60
65
  description: "List activity on a Wise profile, newest first: card payments, transfers, conversions, cashback, direct debits. " +
@@ -68,7 +73,10 @@ export function registerTools(server, wise, { sessionKey, connectionId, onRevoke
68
73
  .string()
69
74
  .optional()
70
75
  .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(),
76
+ status: z
77
+ .enum(["COMPLETED", "IN_PROGRESS", "UPCOMING", "REQUIRES_ATTENTION", "CANCELLED"])
78
+ .optional()
79
+ .describe("Only activity in this state, e.g. UPCOMING for pending card payments"),
72
80
  since: date.optional().describe("Only on/after this date (YYYY-MM-DD or ISO timestamp)"),
73
81
  until: date.optional().describe("Only on/before this date (YYYY-MM-DD or ISO timestamp)"),
74
82
  search: z
@@ -78,6 +86,7 @@ export function registerTools(server, wise, { sessionKey, connectionId, onRevoke
78
86
  limit: z.number().int().min(1).max(500).optional().describe("Max items (default 50)"),
79
87
  cursor: z.string().optional().describe("next_cursor from a previous call"),
80
88
  },
89
+ outputSchema: outputs.get_transactions,
81
90
  annotations: READ,
82
91
  }, async (args) => safe(async () => {
83
92
  const profile = await wise.resolveProfile(args.profile);
@@ -100,12 +109,13 @@ export function registerTools(server, wise, { sessionKey, connectionId, onRevoke
100
109
  "Filter by date (YYYY-MM-DD), status (e.g. outgoing_payment_sent, processing, cancelled) or search (reference/recipient). Default profile: personal.",
101
110
  inputSchema: {
102
111
  profile: profileArg,
103
- since: date.optional(),
104
- until: date.optional(),
112
+ since: date.optional().describe("Only transfers created on/after this date (YYYY-MM-DD or ISO timestamp)"),
113
+ until: date.optional().describe("Only transfers created on/before this date (YYYY-MM-DD or ISO timestamp)"),
105
114
  status: z.string().optional().describe("Wise transfer status, e.g. outgoing_payment_sent"),
106
115
  search: z.string().optional().describe("Case-insensitive match on reference or recipient name"),
107
116
  limit: z.number().int().min(1).max(200).optional().describe("Max transfers (default 25)"),
108
117
  },
118
+ outputSchema: outputs.list_transfers,
109
119
  annotations: READ,
110
120
  }, async (args) => safe(async () => {
111
121
  const profile = await wise.resolveProfile(args.profile);
@@ -116,6 +126,7 @@ export function registerTools(server, wise, { sessionKey, connectionId, onRevoke
116
126
  title: "Get a Wise transfer",
117
127
  description: "Details of one transfer: amounts in both currencies, rate, reference, status, created date and recipient.",
118
128
  inputSchema: { id: z.coerce.number().int().describe("Transfer id (from list_transfers or a TRANSFER activity's resource.id)") },
129
+ outputSchema: outputs.get_transfer,
119
130
  annotations: READ,
120
131
  }, async ({ id }) => safe(async () => ok(await wise.getTransfer(id))));
121
132
  server.registerTool("list_recipients", {
@@ -126,6 +137,7 @@ export function registerTools(server, wise, { sessionKey, connectionId, onRevoke
126
137
  currency: currency.optional().describe("Only recipients in this currency, e.g. EUR"),
127
138
  search: z.string().optional().describe("Case-insensitive match on name or account"),
128
139
  },
140
+ outputSchema: outputs.list_recipients,
129
141
  annotations: READ,
130
142
  }, async ({ profile, currency, search }) => safe(async () => {
131
143
  const s = search?.trim().toLowerCase();
@@ -141,6 +153,7 @@ export function registerTools(server, wise, { sessionKey, connectionId, onRevoke
141
153
  target: currency.describe("Target currency, e.g. RON"),
142
154
  at: date.optional().describe("Historic rate at this date/time (default: now)"),
143
155
  },
156
+ outputSchema: outputs.get_exchange_rate,
144
157
  annotations: READ,
145
158
  }, async ({ source, target, at }) => safe(async () => ok(await wise.getRate(source, target, at))));
146
159
  if (config.WRITES_ENABLED)
@@ -159,13 +172,14 @@ function registerWriteTools(server, wise, { sessionKey, connectionId }, safe) {
159
172
  description: "Price a transfer or conversion: rate, fee, amount received and estimated delivery. Creates nothing the user sees and moves no money. " +
160
173
  "Give either send_amount (in source_currency) or receive_amount (in target_currency).",
161
174
  inputSchema: {
162
- source_currency: currency,
163
- target_currency: currency,
164
- send_amount: z.number().positive().optional(),
165
- receive_amount: z.number().positive().optional(),
175
+ source_currency: currency.describe("Currency you send, e.g. EUR"),
176
+ target_currency: currency.describe("Currency the recipient gets, e.g. RON (same as source_currency for a same-currency transfer)"),
177
+ send_amount: z.number().positive().optional().describe("Amount to send, in source_currency (give this or receive_amount)"),
178
+ receive_amount: z.number().positive().optional().describe("Amount the recipient should get, in target_currency (give this or send_amount)"),
166
179
  recipient_id: z.number().int().optional().describe("Saved recipient, for an exact price and delivery time"),
167
180
  profile: profileArg,
168
181
  },
182
+ outputSchema: outputs.get_quote,
169
183
  annotations: { readOnlyHint: true, openWorldHint: true },
170
184
  }, async (a) => safe(async () => {
171
185
  if (!!a.send_amount === !!a.receive_amount)
@@ -189,16 +203,17 @@ function registerWriteTools(server, wise, { sessionKey, connectionId }, safe) {
189
203
  amount: z.number().positive().optional().describe("Typical amount (default 100); some routes depend on it"),
190
204
  profile: profileArg,
191
205
  },
206
+ outputSchema: outputs.get_recipient_requirements,
192
207
  annotations: { readOnlyHint: true, openWorldHint: true },
193
208
  }, async (a) => safe(async () => {
194
209
  const profile = await wise.resolveProfile(a.profile);
195
- return ok(await wise.recipientRequirements(profile, a.source_currency ?? a.currency, a.currency, a.amount ?? 100));
210
+ return ok({ options: await wise.recipientRequirements(profile, a.source_currency ?? a.currency, a.currency, a.amount ?? 100) });
196
211
  }));
197
212
  server.registerTool("prepare_recipient", {
198
213
  title: "Prepare a new Wise recipient",
199
214
  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
215
  inputSchema: {
201
- currency,
216
+ currency: currency.describe("Currency of the recipient's account, e.g. EUR"),
202
217
  type: z.string().min(1).describe("Requirement type, e.g. iban, sort_code, aba"),
203
218
  account_holder_name: z.string().min(2).describe("Recipient's full name or business name"),
204
219
  details: z
@@ -207,6 +222,7 @@ function registerWriteTools(server, wise, { sessionKey, connectionId }, safe) {
207
222
  owned_by_me: z.boolean().optional().describe("True if this is the user's own account elsewhere"),
208
223
  profile: profileArg,
209
224
  },
225
+ outputSchema: outputs.prepare_recipient,
210
226
  annotations: { readOnlyHint: true, openWorldHint: false },
211
227
  }, async (a) => safe(async () => {
212
228
  const profile = await wise.resolveProfile(a.profile);
@@ -235,6 +251,7 @@ function registerWriteTools(server, wise, { sessionKey, connectionId }, safe) {
235
251
  .describe("Extra fields Wise requires on some routes, e.g. transferPurpose, sourceOfFunds (a previous attempt lists them)"),
236
252
  profile: profileArg.describe("Default: the profile the recipient belongs to"),
237
253
  },
254
+ outputSchema: outputs.prepare_transfer,
238
255
  annotations: { readOnlyHint: true, openWorldHint: true },
239
256
  }, async (a) => safe(async () => {
240
257
  if (!!a.send_amount === !!a.receive_amount)
@@ -301,7 +318,8 @@ function registerWriteTools(server, wise, { sessionKey, connectionId }, safe) {
301
318
  server.registerTool("prepare_cancel_transfer", {
302
319
  title: "Prepare cancelling a Wise transfer",
303
320
  description: `Step 1 of 2 to cancel a transfer that hasn't been paid yet (status incoming_payment_waiting). ${TWO_STEP}`,
304
- inputSchema: { transfer_id: z.coerce.number().int() },
321
+ inputSchema: { transfer_id: z.coerce.number().int().describe("Id of the unpaid transfer (from list_transfers or prepare_transfer's confirmation)") },
322
+ outputSchema: outputs.prepare_cancel_transfer,
305
323
  annotations: { readOnlyHint: true, openWorldHint: false },
306
324
  }, async ({ transfer_id }) => safe(async () => {
307
325
  const t = await wise.getTransfer(transfer_id);
@@ -309,7 +327,7 @@ function registerWriteTools(server, wise, { sessionKey, connectionId }, safe) {
309
327
  return fail(`Transfer ${transfer_id} is "${t.status}"; Wise only allows cancelling unpaid transfers (incoming_payment_waiting) via API.`);
310
328
  }
311
329
  const summary = `Cancel transfer ${t.id}: ${money(t.source.amount, t.source.currency)} → ${t.recipient?.name ?? `recipient ${t.recipient_id}`} ` +
312
- `(${money(t.target.amount, t.target.currency)})${t.reference ? `, reference "${t.reference}"` : ""}, created ${t.created}`;
330
+ `(${money(t.target.amount, t.target.currency)}, Wise's price until paid)${t.reference ? `, reference "${t.reference}"` : ""}, created ${t.created}`;
313
331
  const { code, expiresAt } = storePending(sessionKey, { kind: "cancel_transfer", transferId: t.id }, summary);
314
332
  await log("cancel_prepared", { code, summary });
315
333
  return ok({ status: "awaiting_user_confirmation", summary, confirmation_code: code, expires_at: expiresAt.toISOString() });
@@ -320,6 +338,7 @@ function registerWriteTools(server, wise, { sessionKey, connectionId }, safe) {
320
338
  "explicitly approved that exact summary in their latest message. Codes are single-use and expire after 10 minutes. " +
321
339
  "Transfers are created unpaid: the user pays them in the Wise app.",
322
340
  inputSchema: { confirmation_code: z.string().describe("Code from a prepare_* tool, e.g. TX-1A2B3C4D") },
341
+ outputSchema: outputs.confirm_action,
323
342
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
324
343
  }, async ({ confirmation_code }) => safe(async () => {
325
344
  const p = takePending(sessionKey, confirmation_code);
@@ -385,13 +404,16 @@ function registerWriteTools(server, wise, { sessionKey, connectionId }, safe) {
385
404
  server.registerTool("discard_action", {
386
405
  title: "Discard a prepared Wise action",
387
406
  description: "Throw away an action prepared with a prepare_* tool, before it's confirmed.",
388
- inputSchema: { confirmation_code: z.string() },
407
+ inputSchema: { confirmation_code: z.string().describe("Code from a prepare_* tool to throw away, e.g. TX-1A2B3C4D") },
408
+ outputSchema: outputs.discard_action,
389
409
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
390
410
  }, async ({ confirmation_code }) => {
391
411
  const removed = discardPending(sessionKey, confirmation_code);
392
412
  if (removed)
393
413
  await log("action_discarded", { code: confirmation_code });
394
- return ok(removed ? "Discarded. Nothing was sent to Wise." : "No pending action with that code.");
414
+ return ok(removed
415
+ ? { status: "discarded", message: "Discarded. Nothing was sent to Wise." }
416
+ : { status: "not_found", message: "No pending action with that code." });
395
417
  });
396
418
  }
397
419
  const stripHtml = (s) => s.replace(/<[^>]*>/g, "").trim();
@@ -122,6 +122,11 @@ export class WiseService {
122
122
  amount: b.amount?.value,
123
123
  reserved: b.reservedAmount?.value || undefined,
124
124
  total_worth: b.totalWorth?.value !== b.amount?.value ? b.totalWorth?.value : undefined,
125
+ // Stated in the data itself: assistants read values more reliably than schema descriptions.
126
+ note: b.reservedAmount?.value
127
+ ? `${b.reservedAmount.value} ${b.currency} is held for pending payments and already deducted from amount: ` +
128
+ `${b.amount?.value} ${b.currency} is available now. Don't add the reserved amount to the total.`
129
+ : undefined,
125
130
  }));
126
131
  }));
127
132
  return perProfile.flat().filter((b) => includeEmpty || Number(b.amount) !== 0 || Number(b.reserved ?? 0) !== 0);
@@ -346,10 +351,21 @@ function mapQuote(q) {
346
351
  estimated_delivery: o?.estimatedDelivery,
347
352
  delivery: o?.formattedEstimatedDelivery,
348
353
  notices: (q.notices ?? []).map((n) => stripTags(n.text)).filter(Boolean),
349
- // Price per pay-in method: an unpaid transfer shows the BANK_TRANSFER one until it's paid.
350
- pay_in_options: options.map((x) => ({ pay_in: x.payIn, fee: x.fee?.total, target_amount: x.targetAmount })),
354
+ // Every way to pay in the app, cheapest first; methods with the same price are merged (~20 cards and wallets
355
+ // collapse to a few lines). An unpaid transfer shows the BANK_TRANSFER price until it's paid.
356
+ pay_in_options: groupPayIns(options),
351
357
  };
352
358
  }
359
+ function groupPayIns(options) {
360
+ const groups = new Map();
361
+ for (const x of options) {
362
+ const key = `${x.fee?.total}|${x.targetAmount}`;
363
+ const g = groups.get(key) ?? { pay_in: [], fee: x.fee?.total, target_amount: x.targetAmount };
364
+ g.pay_in.push(x.payIn);
365
+ groups.set(key, g);
366
+ }
367
+ return [...groups.values()].sort((a, b) => a.fee - b.fee).map((g) => ({ ...g, pay_in: g.pay_in.join(", ") }));
368
+ }
353
369
  function mapRequirementField(g) {
354
370
  return {
355
371
  key: String(g.key),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blanketbyte/wise-mcp",
3
- "version": "1.0.8",
3
+ "version": "1.1.1",
4
4
  "description": "MCP server for Wise: read balances, card payments, transfers and recipients, and prepare transfers you approve twice. Runs locally (npx, for Claude, Codex, Cursor…) or as a remote connector for Claude and ChatGPT.",
5
5
  "mcpName": "io.github.BlanketByte/wise-mcp",
6
6
  "keywords": [