@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/config.js ADDED
@@ -0,0 +1,55 @@
1
+ import { z } from "zod";
2
+ /** Settings shared by both modes: local (stdio) and remote server. */
3
+ const baseSchema = z.object({
4
+ // Write tools (transfers you pay in the Wise app, recipients, cancellations), always two-step. false = read-only.
5
+ WRITES_ENABLED: z
6
+ .enum(["true", "false"])
7
+ .default("true")
8
+ .transform((v) => v === "true"),
9
+ // Optional max amount per transfer, in the source currency. Empty = no limit.
10
+ MAX_TRANSFER_AMOUNT: z
11
+ .string()
12
+ .optional()
13
+ .transform((v) => (v && v.trim() ? Number(v) : undefined))
14
+ .refine((v) => v === undefined || (Number.isFinite(v) && v > 0), "MAX_TRANSFER_AMOUNT must be a positive number"),
15
+ // Where state lives; the audit log is written here in both modes.
16
+ DATA_DIR: z.string().default("./data"),
17
+ // Overridable only for the local e2e tests against a mock Wise.
18
+ WISE_API_URL: z.string().url().default("https://api.wise.com"),
19
+ });
20
+ /** Settings only the remote (HTTP) server needs. */
21
+ const serverSchema = z.object({
22
+ // Public HTTPS URL of this server, e.g. https://wise.example.com (no trailing slash)
23
+ PUBLIC_URL: z.string().url(),
24
+ PORT: z.coerce.number().int().default(3000),
25
+ // Comma-separated Wise user ids allowed to log in. Empty = nobody (the login error shows your id).
26
+ ALLOWED_WISE_USERS: z.string().default(""),
27
+ // Encrypts stored Wise API tokens in DATA_DIR. openssl rand -base64 32
28
+ TOKEN_ENCRYPTION_KEY: z.string().min(32, "TOKEN_ENCRYPTION_KEY must be at least 32 characters"),
29
+ // Redirect hosts allowed during dynamic client registration
30
+ ALLOWED_REDIRECT_HOSTS: z.string().default("claude.ai,claude.com,chatgpt.com,localhost,127.0.0.1"),
31
+ });
32
+ function parse(schema) {
33
+ const parsed = schema.safeParse(process.env);
34
+ if (!parsed.success) {
35
+ console.error("❌ Invalid configuration:");
36
+ for (const issue of parsed.error.issues) {
37
+ console.error(` - ${issue.path.join(".")}: ${issue.message}`);
38
+ }
39
+ process.exit(1);
40
+ }
41
+ return parsed.data;
42
+ }
43
+ const list = (s) => s.split(",").map((x) => x.trim()).filter(Boolean);
44
+ const base = parse(baseSchema);
45
+ export const config = { ...base, WISE_API_URL: base.WISE_API_URL.replace(/\/+$/, "") };
46
+ export function loadServerConfig() {
47
+ const server = parse(serverSchema);
48
+ return {
49
+ ...config,
50
+ ...server,
51
+ PUBLIC_URL: server.PUBLIC_URL.replace(/\/+$/, ""),
52
+ allowedWiseUsers: list(server.ALLOWED_WISE_USERS).map(Number).filter(Number.isFinite),
53
+ allowedRedirectHosts: list(server.ALLOWED_REDIRECT_HOSTS),
54
+ };
55
+ }
package/dist/crypto.js ADDED
@@ -0,0 +1,37 @@
1
+ import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
+ /** AES-256-GCM for secrets at rest (Wise API tokens). */
3
+ export class SecretBox {
4
+ key;
5
+ constructor(secret) {
6
+ this.key = createHash("sha256").update(secret).digest();
7
+ }
8
+ seal(plain) {
9
+ const iv = randomBytes(12);
10
+ const cipher = createCipheriv("aes-256-gcm", this.key, iv);
11
+ const data = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
12
+ return ["v1", iv.toString("base64"), cipher.getAuthTag().toString("base64"), data.toString("base64")].join(".");
13
+ }
14
+ open(sealed) {
15
+ const [version, iv, tag, data] = sealed.split(".");
16
+ if (version !== "v1" || !iv || !tag || !data)
17
+ throw new Error("Unsupported sealed value");
18
+ const decipher = createDecipheriv("aes-256-gcm", this.key, Buffer.from(iv, "base64"));
19
+ decipher.setAuthTag(Buffer.from(tag, "base64"));
20
+ return Buffer.concat([decipher.update(Buffer.from(data, "base64")), decipher.final()]).toString("utf8");
21
+ }
22
+ /** HMAC signature for short-lived browser cookies. */
23
+ sign(value) {
24
+ return `${value}.${createHmac("sha256", this.key).update(value).digest("base64url")}`;
25
+ }
26
+ unsign(signed) {
27
+ const i = signed.lastIndexOf(".");
28
+ if (i < 0)
29
+ return undefined;
30
+ const value = signed.slice(0, i);
31
+ const expected = Buffer.from(this.sign(value));
32
+ const given = Buffer.from(signed);
33
+ return expected.length === given.length && timingSafeEqual(expected, given) ? value : undefined;
34
+ }
35
+ }
36
+ export const randomToken = () => randomBytes(32).toString("hex");
37
+ export const sha256 = (s) => createHash("sha256").update(s).digest("hex");
package/dist/fsutil.js ADDED
@@ -0,0 +1,16 @@
1
+ import { chmodSync, renameSync, writeFileSync } from "node:fs";
2
+ /**
3
+ * Writes JSON atomically and owner-only: a crash mid-write can't leave a half-written file
4
+ * (which would read as "no sessions" on restart), and the file is never briefly world-readable.
5
+ */
6
+ export function writeJsonAtomic(file, data) {
7
+ const tmp = `${file}.${process.pid}.tmp`;
8
+ writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
9
+ renameSync(tmp, file);
10
+ try {
11
+ chmodSync(file, 0o600);
12
+ }
13
+ catch {
14
+ /* ignore on platforms without chmod */
15
+ }
16
+ }
package/dist/index.js ADDED
@@ -0,0 +1,198 @@
1
+ import path from "node:path";
2
+ import express from "express";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
5
+ import { mcpAuthRouter, getOAuthProtectedResourceMetadataUrl } from "@modelcontextprotocol/sdk/server/auth/router.js";
6
+ import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
7
+ import { loadServerConfig } from "./config.js";
8
+ import { PUBLIC_DIR, VERSION } from "./meta.js";
9
+ import { SecretBox, sha256 } from "./crypto.js";
10
+ import { ConnectionStore } from "./wise/connections.js";
11
+ import { registerTools } from "./tools.js";
12
+ import { AuthStore } from "./auth/store.js";
13
+ import { WiseOAuthProvider, identify, page } from "./auth/provider.js";
14
+ import { homePage, messagePage, sessionsLoginPage, sessionsPage } from "./auth/pages.js";
15
+ const config = loadServerConfig();
16
+ const publicUrl = new URL(config.PUBLIC_URL);
17
+ const mcpUrl = new URL("/mcp", publicUrl);
18
+ const iconUrl = new URL("/icon.png", publicUrl).href;
19
+ const iconSvgUrl = new URL("/logo.svg", publicUrl).href;
20
+ const box = new SecretBox(config.TOKEN_ENCRYPTION_KEY);
21
+ const connections = new ConnectionStore(config.DATA_DIR, box);
22
+ const store = new AuthStore(config.DATA_DIR);
23
+ const provider = new WiseOAuthProvider(store, connections, mcpUrl, config.allowedRedirectHosts, config.allowedWiseUsers);
24
+ /** Forget Wise tokens that no live session uses. */
25
+ const pruneConnections = () => connections.prune(new Set(store.listSessions().map((s) => s.connectionId)));
26
+ pruneConnections();
27
+ setInterval(pruneConnections, 60 * 60 * 1000).unref();
28
+ const app = express();
29
+ app.set("trust proxy", 1); // behind one reverse proxy (Traefik, Caddy, nginx…) that sets X-Forwarded-For
30
+ app.disable("x-powered-by");
31
+ // --- Icons and logo ------------------------------------------------------------
32
+ const asset = (file, type) => (req, res) => {
33
+ // Pages link assets with ?v=<content hash>, so those can be cached for good; bare URLs only briefly.
34
+ res.setHeader("Cache-Control", req.query.v ? "public, max-age=31536000, immutable" : "public, max-age=3600");
35
+ res.type(type).sendFile(path.join(PUBLIC_DIR, file));
36
+ };
37
+ app.get(["/icon.png", "/favicon.png"], asset("icon.png", "image/png"));
38
+ app.get("/apple-touch-icon.png", asset("apple-touch-icon.png", "image/png"));
39
+ app.get("/favicon.ico", asset("favicon.ico", "image/x-icon"));
40
+ app.get("/logo.png", asset("logo.png", "image/png"));
41
+ app.get("/logo.svg", asset("logo.svg", "image/svg+xml"));
42
+ app.get("/", (_req, res) => page(res).send(homePage(mcpUrl.href)));
43
+ // --- OAuth for MCP clients, login = pasted Wise API token ---------------------
44
+ app.use(mcpAuthRouter({
45
+ provider,
46
+ issuerUrl: publicUrl,
47
+ resourceServerUrl: mcpUrl,
48
+ resourceName: "Wise",
49
+ scopesSupported: ["wise"],
50
+ }));
51
+ const form = express.urlencoded({ extended: false, limit: "8kb" });
52
+ /** Small per-IP limiter for the token forms, so they can't be used to hammer Wise from this server. */
53
+ function rateLimit(max, windowMs) {
54
+ const hits = new Map();
55
+ return (req, res, next) => {
56
+ const now = Date.now();
57
+ if (hits.size > 10_000)
58
+ for (const [k, v] of hits)
59
+ if (v.reset < now)
60
+ hits.delete(k);
61
+ const key = req.ip ?? "unknown";
62
+ let h = hits.get(key);
63
+ if (!h || h.reset < now)
64
+ hits.set(key, (h = { n: 0, reset: now + windowMs }));
65
+ if (++h.n > max) {
66
+ res.setHeader("Retry-After", String(Math.ceil((h.reset - now) / 1000)));
67
+ page(res, 429).send(messagePage("Too many attempts", "Wait a few minutes, then try again."));
68
+ return;
69
+ }
70
+ next();
71
+ };
72
+ }
73
+ const LOGIN_LIMIT = { max: 20, windowMs: 15 * 60 * 1000 };
74
+ app.post("/login", rateLimit(LOGIN_LIMIT.max, LOGIN_LIMIT.windowMs), form, async (req, res) => {
75
+ try {
76
+ await provider.handleLogin(req, res);
77
+ }
78
+ catch (err) {
79
+ console.error("login error", err);
80
+ if (!res.headersSent)
81
+ page(res, 500).send(messagePage("Login failed", "Something went wrong. Start connecting again from your app."));
82
+ }
83
+ });
84
+ // --- /sessions: see and revoke connected sessions (sign in with a Wise token) ---
85
+ const ADMIN_COOKIE = "wise_mcp_admin";
86
+ const ADMIN_TTL_MS = 15 * 60 * 1000;
87
+ function adminUser(req) {
88
+ const raw = (req.headers.cookie ?? "").split(/;\s*/).find((c) => c.startsWith(ADMIN_COOKIE + "="));
89
+ const value = raw && box.unsign(decodeURIComponent(raw.slice(ADMIN_COOKIE.length + 1)));
90
+ if (!value)
91
+ return undefined;
92
+ const [userId, exp] = value.split(":").map(Number);
93
+ return exp > Date.now() && config.allowedWiseUsers.includes(userId) ? userId : undefined;
94
+ }
95
+ function sessionsFor(wiseUserId) {
96
+ return store.listSessions().filter((s) => connections.get(s.connectionId)?.wiseUserId === wiseUserId);
97
+ }
98
+ app.get("/sessions", (req, res) => {
99
+ const userId = adminUser(req);
100
+ if (!userId)
101
+ return page(res).send(sessionsLoginPage());
102
+ const flash = req.query.revoked ? `Revoked ${Number(req.query.revoked) || 0} session(s).` : undefined;
103
+ const sessions = sessionsFor(userId);
104
+ const label = sessions.map((s) => connections.get(s.connectionId)?.wiseUserLabel).find(Boolean) ?? `Wise user ${userId}`;
105
+ page(res).send(sessionsPage({ userName: label, sessions, flash }));
106
+ });
107
+ app.post("/sessions/login", rateLimit(LOGIN_LIMIT.max, LOGIN_LIMIT.windowMs), form, async (req, res) => {
108
+ const who = await identify(String(req.body?.token ?? "").trim(), config.allowedWiseUsers);
109
+ if (!who.ok) {
110
+ if (who.status === 403)
111
+ return page(res, 403).send(messagePage("Not allowed", who.message));
112
+ return page(res, who.status).send(sessionsLoginPage(who.message));
113
+ }
114
+ const cookie = box.sign(`${who.me.id}:${Date.now() + ADMIN_TTL_MS}`);
115
+ res.cookie(ADMIN_COOKIE, cookie, { httpOnly: true, secure: true, sameSite: "strict", maxAge: ADMIN_TTL_MS, path: "/sessions" });
116
+ res.redirect(303, "/sessions");
117
+ });
118
+ app.post("/sessions/revoke", form, (req, res) => {
119
+ const userId = adminUser(req);
120
+ if (!userId)
121
+ return page(res, 401).send(sessionsLoginPage("Session expired, sign in again."));
122
+ const id = String(req.body?.id ?? "");
123
+ const targets = sessionsFor(userId).filter((s) => id === "*" || s.id === id);
124
+ for (const s of targets)
125
+ store.deleteSession(s.id);
126
+ pruneConnections();
127
+ res.redirect(303, `/sessions?revoked=${targets.length}`);
128
+ });
129
+ // --- MCP (stateless Streamable HTTP) ----------------------------------------
130
+ const auth = requireBearerAuth({
131
+ verifier: provider,
132
+ resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpUrl),
133
+ expectedResource: mcpUrl,
134
+ });
135
+ app.post("/mcp", auth, express.json({ limit: "1mb" }), async (req, res) => {
136
+ const { connectionId, sessionId } = req.auth.extra;
137
+ const wise = connections.service(connectionId);
138
+ if (!wise) {
139
+ store.deleteSessionsForConnection(connectionId);
140
+ res.status(401).json({ jsonrpc: "2.0", error: { code: -32001, message: "Wise connection missing, reconnect" }, id: null });
141
+ return;
142
+ }
143
+ const server = new McpServer({
144
+ name: "wise",
145
+ title: "Wise",
146
+ version: VERSION,
147
+ websiteUrl: config.PUBLIC_URL,
148
+ icons: [
149
+ { src: iconUrl, mimeType: "image/png", sizes: ["256x256"] },
150
+ { src: iconSvgUrl, mimeType: "image/svg+xml", sizes: ["any"] },
151
+ ],
152
+ });
153
+ registerTools(server, wise, {
154
+ sessionKey: sessionId,
155
+ connectionId,
156
+ onRevoked: async () => {
157
+ console.warn(`Wise token rejected for connection ${connectionId}; removing it and its sessions`);
158
+ store.deleteSessionsForConnection(connectionId);
159
+ connections.remove(connectionId);
160
+ },
161
+ });
162
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
163
+ res.on("close", () => {
164
+ transport.close();
165
+ server.close();
166
+ });
167
+ try {
168
+ await server.connect(transport);
169
+ await transport.handleRequest(req, res, req.body);
170
+ }
171
+ catch (err) {
172
+ console.error("MCP error", err);
173
+ if (!res.headersSent) {
174
+ res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message: "Internal server error" }, id: null });
175
+ }
176
+ }
177
+ });
178
+ const methodNotAllowed = (_req, res) => {
179
+ res.status(405).set("Allow", "POST").json({
180
+ jsonrpc: "2.0",
181
+ error: { code: -32000, message: "Method not allowed (stateless server)" },
182
+ id: null,
183
+ });
184
+ };
185
+ app.get("/mcp", auth, methodNotAllowed);
186
+ app.delete("/mcp", auth, methodNotAllowed);
187
+ app.get("/health", (_req, res) => {
188
+ res.json({ ok: true, version: VERSION });
189
+ });
190
+ app.listen(config.PORT, () => {
191
+ console.log(`✅ Wise MCP listening on :${config.PORT}`);
192
+ console.log(` MCP endpoint: ${mcpUrl.href}`);
193
+ console.log(` Wise API: ${config.WISE_API_URL}`);
194
+ console.log(` allowed users: ${config.allowedWiseUsers.join(", ") || "none (set ALLOWED_WISE_USERS)"}`);
195
+ console.log(` encryption key: sha256 ${sha256(config.TOKEN_ENCRYPTION_KEY).slice(0, 8)}`);
196
+ console.log(` active sessions: ${store.listSessions().length}`);
197
+ console.log(` writes: ${config.WRITES_ENABLED ? `two-step, transfers paid in the Wise app${config.MAX_TRANSFER_AMOUNT ? ` (max ${config.MAX_TRANSFER_AMOUNT})` : ""}` : "disabled (read-only)"}`);
198
+ });
package/dist/meta.js ADDED
@@ -0,0 +1,10 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ /** Package root (works from src/ under tsx and from dist/ when built or installed via npm). */
5
+ export const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
6
+ export const PUBLIC_DIR = path.join(ROOT, "public");
7
+ const pkg = JSON.parse(readFileSync(path.join(ROOT, "package.json"), "utf8"));
8
+ export const VERSION = pkg.version;
9
+ export const WEBSITE = "https://blanketbyte.com";
10
+ export const REPO_URL = "https://github.com/BlanketByte/wise-mcp";
@@ -0,0 +1,34 @@
1
+ import { randomBytes } from "node:crypto";
2
+ const TTL_MS = 10 * 60 * 1000;
3
+ const PREFIX = { transfer: "TX", recipient: "RCP", cancel_transfer: "CXL" };
4
+ const pending = new Map();
5
+ export function storePending(owner, action, summary) {
6
+ sweep();
7
+ const code = `${PREFIX[action.kind]}-${randomBytes(4).toString("hex").toUpperCase()}`;
8
+ const expiresAt = Date.now() + TTL_MS;
9
+ pending.set(code, { owner, action, summary, expiresAt });
10
+ return { code, expiresAt: new Date(expiresAt) };
11
+ }
12
+ /** Returns and removes the pending action (one-time use). */
13
+ export function takePending(owner, code) {
14
+ sweep();
15
+ const key = code.trim().toUpperCase();
16
+ const p = pending.get(key);
17
+ if (!p || p.owner !== owner)
18
+ return undefined;
19
+ pending.delete(key);
20
+ return p;
21
+ }
22
+ export function discardPending(owner, code) {
23
+ const key = code.trim().toUpperCase();
24
+ const p = pending.get(key);
25
+ if (!p || p.owner !== owner)
26
+ return false;
27
+ return pending.delete(key);
28
+ }
29
+ function sweep() {
30
+ const now = Date.now();
31
+ for (const [k, v] of pending)
32
+ if (v.expiresAt < now)
33
+ pending.delete(k);
34
+ }
package/dist/stdio.js ADDED
@@ -0,0 +1,63 @@
1
+ import { mkdirSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { config } from "./config.js";
6
+ import { PUBLIC_DIR, VERSION, WEBSITE } from "./meta.js";
7
+ import { registerTools } from "./tools.js";
8
+ import { WiseAccessRevokedError, WiseApiError, WiseClient } from "./wise/client.js";
9
+ import { WiseService } from "./wise/service.js";
10
+ /**
11
+ * Local mode: speaks MCP over stdin/stdout for Claude Desktop, Claude Code, Cursor, etc.
12
+ * No OAuth or sessions: the Wise token comes from WISE_API_TOKEN in the app's MCP config.
13
+ * stdout carries the protocol, so everything else goes to stderr.
14
+ *
15
+ * Without a token the server still starts and lists its tools (so directories like Glama can
16
+ * inspect it); every tool call then explains how to set WISE_API_TOKEN.
17
+ */
18
+ const log = (msg) => process.stderr.write(`[wise-mcp] ${msg}\n`);
19
+ const MISSING_TOKEN = "WISE_API_TOKEN is not set. Create a token in Wise → Settings → API tokens and add it to this server's env in your MCP client config.";
20
+ /** Stand-in client used when no token is configured: every Wise call explains what to do. */
21
+ class MissingTokenClient extends WiseClient {
22
+ async request() {
23
+ throw new Error(MISSING_TOKEN);
24
+ }
25
+ }
26
+ const token = process.env.WISE_API_TOKEN?.trim();
27
+ let client;
28
+ if (!token) {
29
+ log(`${MISSING_TOKEN} Starting anyway: tools are listed, but calls will fail until it's set.`);
30
+ client = new MissingTokenClient("", config.WISE_API_URL);
31
+ }
32
+ else {
33
+ client = new WiseClient(token, config.WISE_API_URL);
34
+ try {
35
+ const me = await client.me();
36
+ log(`connected as Wise user ${me.id}`);
37
+ }
38
+ catch (err) {
39
+ if (err instanceof WiseAccessRevokedError || (err instanceof WiseApiError && err.status === 403)) {
40
+ log("Wise rejected WISE_API_TOKEN. Check it's copied fully and not revoked.");
41
+ process.exit(1);
42
+ }
43
+ log(`couldn't reach Wise yet (${err instanceof Error ? err.message : err}); tools will retry`);
44
+ }
45
+ }
46
+ mkdirSync(config.DATA_DIR, { recursive: true });
47
+ let icons = [];
48
+ try {
49
+ icons = [{ src: `data:image/svg+xml;base64,${readFileSync(path.join(PUBLIC_DIR, "logo.svg")).toString("base64")}`, mimeType: "image/svg+xml", sizes: ["any"] }];
50
+ }
51
+ catch {
52
+ /* icon is optional */
53
+ }
54
+ const server = new McpServer({ name: "wise", title: "Wise", version: VERSION, websiteUrl: WEBSITE, icons });
55
+ registerTools(server, new WiseService(client), {
56
+ sessionKey: "local",
57
+ connectionId: "local",
58
+ onRevoked: async () => {
59
+ log("Wise rejected the token (revoked or expired). Update WISE_API_TOKEN and restart.");
60
+ },
61
+ });
62
+ await server.connect(new StdioServerTransport());
63
+ log(`ready (v${VERSION}, writes ${config.WRITES_ENABLED ? "enabled, two-step" : "disabled"}, audit log ${path.join(config.DATA_DIR, "audit.log")})`);