@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.
@@ -0,0 +1,298 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { config } from "../config.js";
5
+ import { PUBLIC_DIR, REPO_URL, WEBSITE } from "../meta.js";
6
+ /** Asset URL with a content hash, so browsers never show a stale logo after a deploy. */
7
+ const asset = (file) => {
8
+ try {
9
+ return `/${file}?v=${createHash("sha256").update(readFileSync(path.join(PUBLIC_DIR, file))).digest("hex").slice(0, 10)}`;
10
+ }
11
+ catch {
12
+ return `/${file}`;
13
+ }
14
+ };
15
+ const A = {
16
+ ico: asset("favicon.ico"),
17
+ svg: asset("logo.svg"),
18
+ png: asset("logo.png"),
19
+ touch: asset("apple-touch-icon.png"),
20
+ };
21
+ const esc = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
22
+ export const SESSION_CHOICES = [
23
+ { value: "5", label: "5 days", days: 5 },
24
+ { value: "15", label: "15 days", days: 15 },
25
+ { value: "30", label: "30 days", days: 30 },
26
+ { value: "90", label: "90 days", days: 90 },
27
+ { value: "never", label: "Always", days: null },
28
+ ];
29
+ export const DEFAULT_SESSION_CHOICE = "30";
30
+ /**
31
+ * form-action also covers redirects after a submit: POST /login redirects to Claude's callback,
32
+ * so the login page passes that origin in `formTargets`.
33
+ */
34
+ export function pageCsp(formTargets = []) {
35
+ return ("default-src 'none'; style-src 'unsafe-inline'; img-src 'self'; " +
36
+ `form-action ${["'self'", ...formTargets].join(" ")}; frame-ancestors 'none'; base-uri 'none'`);
37
+ }
38
+ /** Small inline line icons (24×24, stroke = currentColor). */
39
+ const ICONS = {
40
+ eye: '<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/>',
41
+ arrows: '<path d="M4 8h14M14 4l4 4-4 4M20 16H6M10 12l-4 4 4 4"/>',
42
+ hand: '<path d="M12 3v18M17 7.5C17 5.6 14.8 4.5 12 4.5S7 5.6 7 7.5s2 2.7 5 3.5 5 1.6 5 3.5-2.2 3-5 3-5-1.1-5-3"/>',
43
+ shield: '<path d="M12 3 4.5 6v5.5c0 4.6 3.2 8.3 7.5 9.5 4.3-1.2 7.5-4.9 7.5-9.5V6L12 3Z"/><path d="m9 12 2 2 4-4"/>',
44
+ key: '<circle cx="8" cy="15" r="4"/><path d="m10.8 12.2 9.2-9.2M17 6l3 3M14.5 8.5l2 2"/>',
45
+ lock: '<rect x="5" y="11" width="14" height="10" rx="2.5"/><path d="M8 11V7.5a4 4 0 0 1 8 0V11"/>',
46
+ alert: '<circle cx="12" cy="12" r="9"/><path d="M12 7.5v5.5M12 16.5h.01"/>',
47
+ check: '<circle cx="12" cy="12" r="9"/><path d="m8.5 12 2.5 2.5 4.5-5"/>',
48
+ github: '<path d="M9 19c-4.3 1.4 -4.3 -2.5 -6 -3m12 5v-3.5c0 -1 .1 -1.4 -.5 -2c2.8 -.3 5.5 -1.4 5.5 -6a4.6 4.6 0 0 0 -1.3 -3.2a4.2 4.2 0 0 0 -.1 -3.2s-1.1 -.3 -3.5 1.3a12.3 12.3 0 0 0 -6.2 0c-2.4 -1.6 -3.5 -1.3 -3.5 -1.3a4.2 4.2 0 0 0 -.1 3.2a4.6 4.6 0 0 0 -1.3 3.2c0 4.6 2.7 5.7 5.5 6c-.6 .6 -.6 1.2 -.5 2v3.5"/>',
49
+ info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 8h.01"/>',
50
+ logout: '<path d="M14 4h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-4M10 16l-4-4 4-4M6 12h10"/>',
51
+ };
52
+ const icon = (name, cls = "") => `<svg class="ic ${cls}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name]}</svg>`;
53
+ function layout(title, body) {
54
+ return `<!doctype html>
55
+ <html lang="en">
56
+ <head>
57
+ <meta charset="utf-8">
58
+ <meta name="viewport" content="width=device-width, initial-scale=1">
59
+ <meta name="color-scheme" content="light dark">
60
+ <title>${esc(title)}</title>
61
+ <link rel="icon" href="${A.ico}" sizes="any">
62
+ <link rel="icon" type="image/svg+xml" href="${A.svg}">
63
+ <link rel="apple-touch-icon" href="${A.touch}">
64
+ <style>
65
+ :root {
66
+ --bg:#eef4f6; --card:#ffffff; --fg:#0f1d22; --muted:#5d6b71; --faint:#8b989d; --line:#dfe7ea; --soft:#f2f6f7;
67
+ --teal:#1a4d5c; --teal-2:#5ba3b8; --lime:#9fe870; --forest:#163300;
68
+ --btn:#9fe870; --btn-fg:#163300; --btn-hover:#80e142; --focus:#5ba3b8;
69
+ --err:#b42318; --err-bg:#fef3f2; --ok:#067647; --ok-bg:#ecfdf3;
70
+ --glow-1:rgba(91,163,184,.28); --glow-2:rgba(159,232,112,.22);
71
+ --warn:#b54708; --warn-bg:#fff4e5; --warn-line:#fedf89;
72
+ }
73
+ @media (prefers-color-scheme: dark) {
74
+ :root {
75
+ --bg:#091214; --card:#111c20; --fg:#eef4f5; --muted:#9db0b6; --faint:#6f8287; --line:#21323a; --soft:#16252a;
76
+ --btn:#9fe870; --btn-fg:#163300; --btn-hover:#80e142; --focus:#9fe870;
77
+ --err:#ff9b8f; --err-bg:#2a1513; --ok:#7ee2a8; --ok-bg:#0f2a1c;
78
+ --glow-1:rgba(91,163,184,.18); --glow-2:rgba(159,232,112,.10);
79
+ --warn:#fdb022; --warn-bg:#2b1d0b; --warn-line:#5a3a10;
80
+ }
81
+ }
82
+ * { box-sizing:border-box; }
83
+ html { -webkit-text-size-adjust:100%; }
84
+ body {
85
+ margin:0; min-height:100vh; display:grid; place-items:center; padding:24px 16px; color:var(--fg);
86
+ font:15px/1.55 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",sans-serif;
87
+ background:
88
+ radial-gradient(900px 520px at 12% -10%, var(--glow-1), transparent 60%),
89
+ radial-gradient(760px 480px at 105% 110%, var(--glow-2), transparent 60%),
90
+ var(--bg);
91
+ -webkit-font-smoothing:antialiased;
92
+ }
93
+ main {
94
+ width:100%; max-width:440px; background:var(--card); border:1px solid var(--line); border-radius:28px;
95
+ box-shadow:0 1px 2px rgba(15,29,34,.04), 0 24px 64px -28px rgba(15,29,34,.28);
96
+ }
97
+ main.wide { max-width:720px; }
98
+ .inner { padding:32px; }
99
+ @media (max-width:480px) { .inner { padding:24px 20px; } main { border-radius:22px; } }
100
+ .hero { text-align:center; margin-bottom:24px; }
101
+ .hero img { width:76px; height:76px; border-radius:22px; display:block; margin:0 auto 16px;
102
+ box-shadow:0 0 0 6px var(--soft), 0 10px 24px -12px rgba(26,77,92,.5); }
103
+ h1 { font-size:22px; line-height:1.25; letter-spacing:-.01em; margin:0 0 6px; font-weight:650; }
104
+ .sub { color:var(--muted); font-size:13.5px; margin:0; }
105
+ .sub b, .sub a.brand { color:var(--fg); font-weight:600; text-decoration:none; }
106
+ .sub a.brand:hover { text-decoration:underline; }
107
+ .notice { display:inline-flex; align-items:center; gap:6px; margin-top:10px; padding:5px 12px 5px 10px; border-radius:999px;
108
+ background:var(--warn-bg); color:var(--warn); border:1px solid var(--warn-line); font-size:12.5px; font-weight:600; }
109
+ .notice .ic { width:15px; height:15px; }
110
+ p { margin:0 0 16px; }
111
+ .muted { color:var(--muted); }
112
+ .perms { list-style:none; margin:0 0 24px; padding:4px; background:var(--soft); border-radius:18px; }
113
+ .perms li { display:flex; gap:12px; align-items:flex-start; padding:10px 12px; font-size:14px; }
114
+ .perms li + li { border-top:1px solid var(--line); }
115
+ .chip { flex:none; width:30px; height:30px; border-radius:10px; display:grid; place-items:center; background:var(--card); color:var(--teal-2);
116
+ border:1px solid var(--line); }
117
+ .perms strong { display:block; font-weight:600; }
118
+ .perms span { color:var(--muted); font-size:13px; }
119
+ .ic { width:18px; height:18px; flex:none; }
120
+ .alert { display:flex; gap:10px; align-items:flex-start; padding:12px 14px; border-radius:14px; font-size:14px; margin-bottom:18px; }
121
+ .alert.err { background:var(--err-bg); color:var(--err); }
122
+ .alert.ok { background:var(--ok-bg); color:var(--ok); }
123
+ .alert .ic { margin-top:1px; }
124
+ .field { margin-bottom:20px; }
125
+ .lbl { display:block; font-size:13.5px; font-weight:600; margin-bottom:8px; }
126
+ .input { position:relative; }
127
+ .input .ic { position:absolute; left:14px; top:50%; transform:translateY(-50%); color:var(--faint); pointer-events:none; }
128
+ input[type=password] {
129
+ width:100%; height:48px; padding:0 14px 0 42px; border-radius:14px; border:1px solid var(--line); background:var(--card); color:inherit;
130
+ font:15px ui-monospace,SFMono-Regular,Menlo,monospace; letter-spacing:.02em; transition:border-color .15s, box-shadow .15s;
131
+ }
132
+ input[type=password]::placeholder { color:var(--faint); letter-spacing:0; }
133
+ input[type=password]:focus { outline:none; border-color:var(--focus); box-shadow:0 0 0 4px color-mix(in srgb, var(--focus) 22%, transparent); }
134
+ details { margin-top:10px; font-size:13px; color:var(--muted); }
135
+ summary { cursor:pointer; color:var(--teal-2); font-weight:550; list-style:none; display:inline-flex; align-items:center; gap:6px; }
136
+ summary::-webkit-details-marker { display:none; }
137
+ summary::after { content:"›"; font-size:16px; line-height:1; transition:transform .15s; }
138
+ details[open] summary::after { transform:rotate(90deg); }
139
+ details ol { margin:10px 0 0; padding-left:20px; }
140
+ details li { margin:4px 0; }
141
+ .seg { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:4px; padding:4px; background:var(--soft); border-radius:14px; border:1px solid var(--line); }
142
+ .seg input { position:absolute; opacity:0; pointer-events:none; }
143
+ .seg label { display:block; text-align:center; padding:9px 2px; border-radius:10px; font-size:13px; font-weight:550; color:var(--muted); cursor:pointer;
144
+ white-space:nowrap; transition:background .15s, color .15s, box-shadow .15s; }
145
+ .seg label:hover { color:var(--fg); }
146
+ .seg input:checked + label { background:var(--card); color:var(--fg); box-shadow:0 1px 2px rgba(15,29,34,.08), 0 0 0 1px var(--line); }
147
+ .seg input:focus-visible + label { outline:2px solid var(--focus); outline-offset:1px; }
148
+ .hint { font-size:12.5px; color:var(--faint); margin:8px 0 0; }
149
+ .btn {
150
+ display:flex; align-items:center; justify-content:center; gap:10px; width:100%; height:50px; margin-top:24px; border:0; border-radius:14px;
151
+ background:var(--btn); color:var(--btn-fg); font-size:15.5px; font-weight:650; cursor:pointer; text-decoration:none;
152
+ transition:background .15s, transform .05s;
153
+ }
154
+ .btn:hover { background:var(--btn-hover); }
155
+ .btn:active { transform:translateY(1px); }
156
+ .btn:focus-visible { outline:3px solid var(--focus); outline-offset:2px; }
157
+ .btn.secondary { background:transparent; color:var(--fg); border:1px solid var(--line); height:44px; margin-top:16px; font-weight:600; }
158
+ .btn.secondary:hover { background:var(--soft); }
159
+ code { font:13px ui-monospace,SFMono-Regular,Menlo,monospace; padding:2px 7px; border-radius:7px; background:var(--soft); border:1px solid var(--line); word-break:break-all; }
160
+ .copy { display:block; padding:12px 14px; border-radius:14px; font-size:14px; text-align:center; user-select:all; -webkit-user-select:all; cursor:text; }
161
+ .apps { display:grid; grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); gap:10px; margin-top:16px; }
162
+ .app { padding:14px 16px; border-radius:16px; background:var(--soft); border:1px solid var(--line); }
163
+ .app h2 { font-size:14px; margin:0 0 8px; font-weight:650; }
164
+ .app ol { margin:0; padding-left:18px; font-size:13px; color:var(--muted); }
165
+ .app li { margin:3px 0; }
166
+ .app b { color:var(--fg); font-weight:600; }
167
+ a { color:var(--teal-2); text-underline-offset:2px; }
168
+ .sessions { list-style:none; margin:0; padding:0; border:1px solid var(--line); border-radius:18px; overflow:hidden; }
169
+ .sessions li { display:flex; gap:16px; align-items:center; padding:14px 16px; }
170
+ .sessions li + li { border-top:1px solid var(--line); }
171
+ .sessions .meta { flex:1; min-width:0; }
172
+ .sessions .name { font-weight:600; }
173
+ .sessions .dates { color:var(--muted); font-size:12.5px; }
174
+ .sessions form { margin:0; }
175
+ .sessions button { height:34px; padding:0 12px; border-radius:10px; border:1px solid var(--line); background:transparent; color:var(--err); font-weight:600; cursor:pointer; }
176
+ .sessions button:hover { background:var(--err-bg); }
177
+ footer { display:flex; align-items:center; justify-content:center; gap:8px; flex-wrap:wrap; padding:16px 24px; border-top:1px solid var(--line);
178
+ color:var(--faint); font-size:12.5px; text-align:center; }
179
+ footer .ic { width:14px; height:14px; }
180
+ footer a { color:var(--muted); }
181
+ footer a.gh { display:inline-flex; align-items:center; gap:4px; }
182
+ .dot { opacity:.5; }
183
+ </style>
184
+ </head>
185
+ <body>
186
+ ${body}
187
+ </body>
188
+ </html>`;
189
+ }
190
+ function card(inner, opts) {
191
+ return `<main${opts.wide ? ' class="wide"' : ""}>
192
+ <div class="inner">
193
+ <div class="hero">
194
+ <img src="${A.png}" alt="Wise MCP">
195
+ <h1>${esc(opts.title)}</h1>
196
+ <p class="sub">${opts.subtitle ?? `Personal connector by <a class="brand" href="${WEBSITE}" target="_blank" rel="noopener">BlanketByte</a>`}</p>
197
+ <span class="notice">${icon("info")}Not operated by Wise</span>
198
+ </div>
199
+ ${inner}
200
+ </div>
201
+ <footer>${icon("lock")}<span>Token encrypted at rest</span><span class="dot">·</span><a href="/sessions">Manage sessions</a><span class="dot">·</span><a class="gh" href="${REPO_URL}" target="_blank" rel="noopener">${icon("github")}Source</a></footer>
202
+ </main>`;
203
+ }
204
+ const alert = (kind, html) => `<div class="alert ${kind}" role="${kind === "err" ? "alert" : "status"}">${icon(kind === "err" ? "alert" : "check")}<div>${html}</div></div>`;
205
+ const TOKEN_STEPS = `<details>
206
+ <summary>Where do I find my token?</summary>
207
+ <ol>
208
+ <li>Open Wise → Settings → <a href="https://wise.com/settings/api-tokens" target="_blank" rel="noopener">API tokens</a>.</li>
209
+ <li>Add a new token${config.WRITES_ENABLED ? `: <b>Full access</b> to prepare transfers and recipients, or <b>Read only</b> for answers only` : ` with <b>Read only</b> access`}.</li>
210
+ <li>Copy it and paste it here. It's shown only once.</li>
211
+ </ol>
212
+ </details>`;
213
+ function permissions() {
214
+ const rows = [
215
+ ["eye", "Read your accounts", "Balances, Jars, card payments, transfers, recipients"],
216
+ ...(config.WRITES_ENABLED
217
+ ? [
218
+ ["arrows", "Prepare transfers and recipients", "You approve every change in chat first"],
219
+ ["hand", "You stay in control of money", "Transfers are only paid when you pay them in the Wise app"],
220
+ ]
221
+ : [["shield", "Read-only", "This connector can't move money"]]),
222
+ ];
223
+ return `<ul class="perms">${rows
224
+ .map(([ic, title, text]) => `<li><span class="chip">${icon(ic)}</span><div><strong>${title}</strong><span>${text}</span></div></li>`)
225
+ .join("")}</ul>`;
226
+ }
227
+ export function loginPage(opts) {
228
+ const seg = SESSION_CHOICES.map((c) => `<input type="radio" id="ttl-${c.value}" name="ttl" value="${c.value}"${c.value === DEFAULT_SESSION_CHOICE ? " checked" : ""}><label for="ttl-${c.value}">${c.label}</label>`).join("");
229
+ return layout("Connect Wise", card(`${permissions()}
230
+ ${opts.error ? alert("err", esc(opts.error)) : ""}
231
+ <form method="post" action="/login">
232
+ <input type="hidden" name="login_id" value="${esc(opts.loginId)}">
233
+ <div class="field">
234
+ <label class="lbl" for="token">Wise API token</label>
235
+ <div class="input">${icon("key")}<input id="token" name="token" type="password" required autofocus autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"></div>
236
+ ${TOKEN_STEPS}
237
+ </div>
238
+ <div class="field">
239
+ <span class="lbl" id="ttl-label">Stay connected for</span>
240
+ <div class="seg" role="radiogroup" aria-labelledby="ttl-label">${seg}</div>
241
+ <p class="hint">"Always" keeps you connected until you revoke access.</p>
242
+ </div>
243
+ <button class="btn" type="submit">Connect Wise</button>
244
+ </form>`, { title: `Connect Wise to ${opts.clientName ?? "your app"}` }));
245
+ }
246
+ export function sessionsLoginPage(error) {
247
+ return layout("Wise MCP sessions", card(`<p class="muted" style="text-align:center">Paste a Wise API token for your account to see and revoke connected sessions (Claude, ChatGPT, …). It's only used to check who you are and is never stored.</p>
248
+ ${error ? alert("err", esc(error)) : ""}
249
+ <form method="post" action="/sessions/login">
250
+ <div class="field">
251
+ <label class="lbl" for="token">Wise API token</label>
252
+ <div class="input">${icon("key")}<input id="token" name="token" type="password" required autofocus autocomplete="off" autocapitalize="off" spellcheck="false"></div>
253
+ </div>
254
+ <button class="btn" type="submit">Sign in</button>
255
+ </form>`, { title: "Your sessions" }));
256
+ }
257
+ export function messagePage(title, message, kind = "err") {
258
+ return layout(title, card(alert(kind, message), { title }));
259
+ }
260
+ export function sessionsPage(opts) {
261
+ const fmt = (t) => (t === null ? "never" : new Date(t).toISOString().slice(0, 16).replace("T", " "));
262
+ const rows = opts.sessions
263
+ .map((s) => `<li>
264
+ <div class="meta">
265
+ <div class="name">${esc(s.clientName ?? s.clientId.slice(0, 8))}</div>
266
+ <div class="dates">Connected ${fmt(s.createdAt)} · last used ${fmt(s.lastUsedAt)} · expires ${fmt(s.expiresAt)}</div>
267
+ </div>
268
+ <form method="post" action="/sessions/revoke"><input type="hidden" name="id" value="${esc(s.id)}"><button>Revoke</button></form>
269
+ </li>`)
270
+ .join("");
271
+ return layout("Wise MCP sessions", card(`${opts.flash ? alert("ok", esc(opts.flash)) : ""}
272
+ ${opts.sessions.length
273
+ ? `<ul class="sessions">${rows}</ul>
274
+ <form method="post" action="/sessions/revoke"><input type="hidden" name="id" value="*"><button class="btn secondary" type="submit">${icon("logout")}Revoke all sessions</button></form>`
275
+ : `<p class="muted" style="text-align:center">No active sessions.</p>`}`, { title: "Your sessions", subtitle: `Signed in as <b>${esc(opts.userName ?? "Wise user")}</b> · times in UTC`, wide: true }));
276
+ }
277
+ export function homePage(mcpUrl) {
278
+ return layout("Wise MCP", card(`<p class="muted" style="text-align:center;margin-bottom:10px">Your connector address</p>
279
+ <code class="copy">${esc(mcpUrl)}</code>
280
+ <div class="apps">
281
+ <div class="app">
282
+ <h2>Claude</h2>
283
+ <ol>
284
+ <li><b>Settings → Connectors</b></li>
285
+ <li><b>Add custom connector</b></li>
286
+ <li>Paste the address, then sign in with your Wise token</li>
287
+ </ol>
288
+ </div>
289
+ <div class="app">
290
+ <h2>ChatGPT</h2>
291
+ <ol>
292
+ <li><b>Settings → Apps &amp; Connectors</b></li>
293
+ <li>Turn on <b>Developer mode</b> (Advanced), then <b>Create</b></li>
294
+ <li>Paste the address, choose <b>OAuth</b>, then sign in with your Wise token</li>
295
+ </ol>
296
+ </div>
297
+ </div>`, { title: "Wise MCP" }));
298
+ }
@@ -0,0 +1,222 @@
1
+ import { InvalidClientMetadataError, InvalidGrantError, InvalidTargetError, InvalidTokenError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
2
+ import { randomToken } from "../crypto.js";
3
+ import { wiseClient } from "../wise/connections.js";
4
+ import { WiseAccessRevokedError, WiseApiError } from "../wise/client.js";
5
+ import { loginPage, messagePage, pageCsp, SESSION_CHOICES } from "./pages.js";
6
+ const ACCESS_TTL_MS = 60 * 60 * 1000; // 1 hour
7
+ const DAY_MS = 24 * 60 * 60 * 1000;
8
+ const LOGIN_TTL_MS = 15 * 60 * 1000; // the login page must be submitted within 15 minutes
9
+ const CODE_TTL_MS = 2 * 60 * 1000; // our authorization code is valid for 2 minutes
10
+ /** Validates a pasted Wise token with GET /v1/me and checks the allowlist. Nothing is stored. */
11
+ export async function identify(token, allowedWiseUsers) {
12
+ if (!/^[\w-]{16,200}$/.test(token))
13
+ return { ok: false, status: 400, message: "That doesn't look like a Wise API token." };
14
+ let me;
15
+ try {
16
+ me = await wiseClient(token).me();
17
+ }
18
+ catch (err) {
19
+ if (err instanceof WiseAccessRevokedError || (err instanceof WiseApiError && err.status === 403)) {
20
+ return { ok: false, status: 401, message: "Wise rejected this token. Check it's copied fully and not revoked." };
21
+ }
22
+ console.error("Wise /v1/me failed", err instanceof Error ? err.message : err);
23
+ return { ok: false, status: 502, message: "Couldn't reach Wise. Try again in a moment." };
24
+ }
25
+ if (typeof me?.id !== "number")
26
+ return { ok: false, status: 502, message: "Unexpected answer from Wise." };
27
+ if (!allowedWiseUsers.includes(me.id)) {
28
+ console.warn(`Wise login refused for Wise user ${me.id}`);
29
+ return {
30
+ ok: false,
31
+ status: 403,
32
+ message: `This Wise account isn't allowed on this server. If it's yours, add <code>ALLOWED_WISE_USERS=${me.id}</code> to the server's environment and redeploy.`,
33
+ };
34
+ }
35
+ return { ok: true, me };
36
+ }
37
+ /**
38
+ * OAuth 2.1 authorization server for MCP clients (Claude, ChatGPT). Wise has no OAuth for individuals,
39
+ * so login is a pasted Wise personal API token:
40
+ * client → /authorize → login page (token + session length) → POST /login → /v1/me + allowlist
41
+ * → token stored encrypted → code → the client exchanges it for access + rotating refresh tokens.
42
+ */
43
+ export class WiseOAuthProvider {
44
+ store;
45
+ connections;
46
+ resourceUrl;
47
+ allowedRedirectHosts;
48
+ allowedWiseUsers;
49
+ logins = new Map();
50
+ codes = new Map();
51
+ constructor(store, connections, resourceUrl, allowedRedirectHosts, allowedWiseUsers) {
52
+ this.store = store;
53
+ this.connections = connections;
54
+ this.resourceUrl = resourceUrl;
55
+ this.allowedRedirectHosts = allowedRedirectHosts;
56
+ this.allowedWiseUsers = allowedWiseUsers;
57
+ }
58
+ get clientsStore() {
59
+ return {
60
+ getClient: (id) => this.store.getClient(id),
61
+ registerClient: (client) => {
62
+ const full = client;
63
+ for (const uri of full.redirect_uris) {
64
+ const host = new URL(uri).hostname;
65
+ if (!this.allowedRedirectHosts.some((h) => host === h || host.endsWith("." + h))) {
66
+ throw new InvalidClientMetadataError(`redirect_uri host "${host}" is not allowed on this server`);
67
+ }
68
+ }
69
+ this.store.saveClient(full);
70
+ return full;
71
+ },
72
+ };
73
+ }
74
+ // --- Step 1: login page --------------------------------------------------
75
+ async authorize(client, params, res) {
76
+ if (params.resource && !sameResource(params.resource, this.resourceUrl)) {
77
+ throw new InvalidTargetError(`Unknown resource ${params.resource}`);
78
+ }
79
+ this.sweep();
80
+ const loginId = randomToken();
81
+ const pending = { client, params, expiresAt: Date.now() + LOGIN_TTL_MS };
82
+ this.logins.set(loginId, pending);
83
+ this.loginForm(res, loginId, pending);
84
+ }
85
+ // --- Step 2: POST /login (token + session length) ------------------------
86
+ async handleLogin(req, res) {
87
+ const loginId = String(req.body?.login_id ?? "");
88
+ const pending = this.logins.get(loginId);
89
+ if (!pending || pending.expiresAt < Date.now()) {
90
+ this.logins.delete(loginId);
91
+ page(res, 400).send(messagePage("Login expired", "Start connecting again from your app."));
92
+ return;
93
+ }
94
+ const choice = SESSION_CHOICES.find((c) => c.value === String(req.body?.ttl ?? ""));
95
+ if (!choice)
96
+ return this.loginForm(res, loginId, pending, "Pick how long to stay connected.", 400);
97
+ const token = String(req.body?.token ?? "").trim();
98
+ const who = await identify(token, this.allowedWiseUsers);
99
+ if (!who.ok) {
100
+ if (who.status === 403) {
101
+ this.logins.delete(loginId);
102
+ page(res, 403).send(messagePage("Not allowed", who.message));
103
+ return;
104
+ }
105
+ return this.loginForm(res, loginId, pending, who.message, who.status);
106
+ }
107
+ this.logins.delete(loginId); // single use
108
+ const connectionId = this.connections.save(token, who.me);
109
+ const now = Date.now();
110
+ const sessionId = randomToken();
111
+ this.store.createSession({
112
+ id: sessionId,
113
+ clientId: pending.client.client_id,
114
+ clientName: pending.client.client_name,
115
+ connectionId,
116
+ createdAt: now,
117
+ expiresAt: choice.days === null ? null : now + choice.days * DAY_MS,
118
+ lastUsedAt: now,
119
+ });
120
+ console.log(`Wise login: user ${who.me.id}, session ${choice.days ?? "never"} days`);
121
+ const code = randomToken();
122
+ this.codes.set(code, { clientId: pending.client.client_id, params: pending.params, sessionId, expiresAt: now + CODE_TTL_MS });
123
+ const target = new URL(pending.params.redirectUri);
124
+ target.searchParams.set("code", code);
125
+ if (pending.params.state !== undefined)
126
+ target.searchParams.set("state", pending.params.state);
127
+ res.redirect(302, target.toString());
128
+ }
129
+ loginForm(res, loginId, pending, error, status = 200) {
130
+ // The form's POST answers with a redirect to the client's callback: allow that origin in form-action.
131
+ page(res, status, [new URL(pending.params.redirectUri).origin]).send(loginPage({ loginId, clientName: pending.client.client_name, error }));
132
+ }
133
+ // --- Step 3: token endpoint ----------------------------------------------
134
+ async challengeForAuthorizationCode(_client, code) {
135
+ const c = this.codes.get(code);
136
+ if (!c || c.expiresAt < Date.now())
137
+ throw new InvalidGrantError("Invalid or expired authorization code");
138
+ return c.params.codeChallenge;
139
+ }
140
+ async exchangeAuthorizationCode(client, code) {
141
+ const c = this.codes.get(code);
142
+ this.codes.delete(code); // single use
143
+ if (!c || c.expiresAt < Date.now())
144
+ throw new InvalidGrantError("Invalid or expired authorization code");
145
+ if (c.clientId !== client.client_id)
146
+ throw new InvalidGrantError("Code was issued to another client");
147
+ return this.issueTokens(c.sessionId, c.params.scopes ?? []);
148
+ }
149
+ async exchangeRefreshToken(client, refreshToken) {
150
+ const found = this.store.getToken(refreshToken);
151
+ if (!found || found.token.kind !== "refresh" || found.session.clientId !== client.client_id) {
152
+ throw new InvalidGrantError("Invalid refresh token");
153
+ }
154
+ this.store.deleteToken(refreshToken); // rotation
155
+ return this.issueTokens(found.session.id, found.token.scopes);
156
+ }
157
+ async verifyAccessToken(raw) {
158
+ // InvalidTokenError → 401 + WWW-Authenticate, which makes Claude refresh or reconnect.
159
+ const found = this.store.getToken(raw);
160
+ if (!found || found.token.kind !== "access")
161
+ throw new InvalidTokenError("Invalid or expired token");
162
+ // Removing someone from ALLOWED_WISE_USERS (and redeploying) cuts off their existing sessions too.
163
+ const conn = this.connections.get(found.session.connectionId);
164
+ if (!conn || !this.allowedWiseUsers.includes(conn.wiseUserId)) {
165
+ this.store.deleteSession(found.session.id);
166
+ throw new InvalidTokenError("Session no longer allowed on this server");
167
+ }
168
+ this.store.touchSession(found.session.id);
169
+ return {
170
+ token: raw,
171
+ clientId: found.session.clientId,
172
+ scopes: found.token.scopes,
173
+ expiresAt: Math.floor(found.token.expiresAt / 1000),
174
+ resource: new URL(found.token.resource),
175
+ extra: { connectionId: found.session.connectionId, sessionId: found.session.id },
176
+ };
177
+ }
178
+ async revokeToken(_client, request) {
179
+ this.store.deleteToken(request.token);
180
+ }
181
+ issueTokens(sessionId, scopes) {
182
+ const session = this.store.getSession(sessionId);
183
+ if (!session)
184
+ throw new InvalidGrantError("Session expired or revoked");
185
+ const access = randomToken();
186
+ const refresh = randomToken();
187
+ const resource = this.resourceUrl.href;
188
+ // Access tokens never outlive their session; refresh tokens live exactly as long as the session.
189
+ const now = Date.now();
190
+ const accessExpiresAt = Math.min(now + ACCESS_TTL_MS, session.expiresAt ?? Infinity);
191
+ this.store.saveToken(access, { kind: "access", sessionId, scopes, expiresAt: accessExpiresAt, resource });
192
+ this.store.saveToken(refresh, { kind: "refresh", sessionId, scopes, expiresAt: session.expiresAt, resource });
193
+ return {
194
+ access_token: access,
195
+ token_type: "bearer",
196
+ expires_in: Math.max(1, Math.floor((accessExpiresAt - now) / 1000)),
197
+ refresh_token: refresh,
198
+ scope: scopes.join(" "),
199
+ };
200
+ }
201
+ sweep() {
202
+ const now = Date.now();
203
+ for (const [k, v] of this.logins)
204
+ if (v.expiresAt < now)
205
+ this.logins.delete(k);
206
+ for (const [k, v] of this.codes)
207
+ if (v.expiresAt < now)
208
+ this.codes.delete(k);
209
+ }
210
+ }
211
+ export function page(res, status = 200, formTargets = []) {
212
+ res.status(status);
213
+ res.setHeader("Cache-Control", "no-store");
214
+ res.setHeader("Content-Security-Policy", pageCsp(formTargets));
215
+ res.setHeader("X-Frame-Options", "DENY");
216
+ res.setHeader("Referrer-Policy", "no-referrer");
217
+ return res.type("html");
218
+ }
219
+ function sameResource(a, b) {
220
+ const norm = (u) => u.href.replace(/#.*$/, "").replace(/\/$/, "");
221
+ return norm(a) === norm(b);
222
+ }
@@ -0,0 +1,105 @@
1
+ import { readFileSync, mkdirSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { sha256 } from "../crypto.js";
4
+ import { writeJsonAtomic } from "../fsutil.js";
5
+ const expired = (at, now = Date.now()) => at !== null && at < now;
6
+ /** Tiny JSON-file store; survives restarts so clients stay connected. */
7
+ export class AuthStore {
8
+ data = { clients: {}, sessions: {}, tokens: {} };
9
+ file;
10
+ constructor(dataDir) {
11
+ mkdirSync(dataDir, { recursive: true });
12
+ this.file = path.join(dataDir, "oauth-sessions.json");
13
+ try {
14
+ this.data = { ...this.data, ...JSON.parse(readFileSync(this.file, "utf8")) };
15
+ }
16
+ catch {
17
+ /* first run */
18
+ }
19
+ this.sweep();
20
+ }
21
+ getClient(id) {
22
+ return this.data.clients[id];
23
+ }
24
+ saveClient(client) {
25
+ this.data.clients[client.client_id] = client;
26
+ this.flush();
27
+ }
28
+ createSession(session) {
29
+ this.data.sessions[session.id] = session;
30
+ this.flush();
31
+ }
32
+ getSession(id) {
33
+ const s = this.data.sessions[id];
34
+ if (s && expired(s.expiresAt)) {
35
+ this.deleteSession(id);
36
+ return undefined;
37
+ }
38
+ return s;
39
+ }
40
+ listSessions(connectionId) {
41
+ this.sweep();
42
+ return Object.values(this.data.sessions)
43
+ .filter((s) => !connectionId || s.connectionId === connectionId)
44
+ .sort((a, b) => b.createdAt - a.createdAt);
45
+ }
46
+ touchSession(id) {
47
+ const s = this.data.sessions[id];
48
+ // Persist at most once a minute to avoid rewriting the file on every MCP call.
49
+ if (s && Date.now() - s.lastUsedAt > 60_000) {
50
+ s.lastUsedAt = Date.now();
51
+ this.flush();
52
+ }
53
+ }
54
+ /** Revokes a session and every token issued in it. */
55
+ deleteSession(id) {
56
+ delete this.data.sessions[id];
57
+ for (const [k, t] of Object.entries(this.data.tokens))
58
+ if (t.sessionId === id)
59
+ delete this.data.tokens[k];
60
+ this.flush();
61
+ }
62
+ deleteSessionsForConnection(connectionId) {
63
+ for (const s of Object.values(this.data.sessions))
64
+ if (s.connectionId === connectionId)
65
+ this.deleteSession(s.id);
66
+ }
67
+ getToken(raw) {
68
+ const key = sha256(raw);
69
+ const token = this.data.tokens[key];
70
+ if (!token)
71
+ return undefined;
72
+ const session = this.getSession(token.sessionId);
73
+ if (!session || expired(token.expiresAt)) {
74
+ delete this.data.tokens[key];
75
+ this.flush();
76
+ return undefined;
77
+ }
78
+ return { token, session };
79
+ }
80
+ saveToken(raw, token) {
81
+ this.data.tokens[sha256(raw)] = token;
82
+ this.flush();
83
+ }
84
+ deleteToken(raw) {
85
+ if (delete this.data.tokens[sha256(raw)])
86
+ this.flush();
87
+ }
88
+ sweep() {
89
+ const now = Date.now();
90
+ let changed = false;
91
+ for (const [id, s] of Object.entries(this.data.sessions)) {
92
+ if (expired(s.expiresAt, now))
93
+ changed = delete this.data.sessions[id];
94
+ }
95
+ for (const [k, t] of Object.entries(this.data.tokens)) {
96
+ if (expired(t.expiresAt, now) || !this.data.sessions[t.sessionId])
97
+ changed = delete this.data.tokens[k];
98
+ }
99
+ if (changed)
100
+ this.flush();
101
+ }
102
+ flush() {
103
+ writeJsonAtomic(this.file, this.data);
104
+ }
105
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { VERSION } from "./meta.js";
5
+ const HELP = `wise-mcp ${VERSION} — MCP server for Wise
6
+
7
+ Usage:
8
+ wise-mcp Run locally over stdio (Claude Desktop, Claude Code, Codex, Cursor…)
9
+ wise-mcp serve Run the remote HTTP server (OAuth, for Claude and ChatGPT custom connectors)
10
+
11
+ Local mode env:
12
+ WISE_API_TOKEN Wise personal API token (required for tool calls)
13
+ WRITES_ENABLED false = read-only (default true; writes are always two-step)
14
+ MAX_TRANSFER_AMOUNT optional max per transfer, in the source currency
15
+ DATA_DIR where the audit log is written (default ~/.wise-mcp)
16
+
17
+ Docs: https://github.com/BlanketByte/wise-mcp`;
18
+ const cmd = process.argv[2];
19
+ if (cmd === "-h" || cmd === "--help" || cmd === "help") {
20
+ console.log(HELP);
21
+ }
22
+ else if (cmd === "-v" || cmd === "--version") {
23
+ console.log(VERSION);
24
+ }
25
+ else if (cmd === "serve") {
26
+ await import("./index.js");
27
+ }
28
+ else if (cmd === undefined || cmd === "stdio") {
29
+ process.env.DATA_DIR ??= path.join(os.homedir(), ".wise-mcp");
30
+ await import("./stdio.js");
31
+ }
32
+ else {
33
+ console.error(`Unknown command "${cmd}".\n\n${HELP}`);
34
+ process.exit(1);
35
+ }