albyhub-admin-mcp 0.1.0

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/.env.example ADDED
@@ -0,0 +1,32 @@
1
+ # Copy to .env and fill in. .env is gitignored.
2
+ # Env vars passed by the parent process (Claude Code, etc.) take precedence.
3
+
4
+ # Required ----------------------------------------------------------------
5
+
6
+ # Base URL of your Alby Hub instance. Default for the desktop Hub is
7
+ # http://localhost:8080. If your Hub is exposed remotely (e.g. via Cloudflare
8
+ # Tunnel), point this at that URL. HTTPS strongly recommended for non-localhost.
9
+ ALBYHUB_URL=http://localhost:8080
10
+
11
+ # Alby Hub API access token. Get one from Alby Hub UI:
12
+ # Settings → Developer / Apps → create a new connection with API scopes.
13
+ # Treat as a SECRET — full API tokens can drain your hub's on-chain balance
14
+ # (e.g., by opening channels). Scope down if your Hub supports it.
15
+ ALBYHUB_TOKEN=paste-yours-here
16
+
17
+ # Optional ----------------------------------------------------------------
18
+
19
+ # Force read-only — disables every tool that POSTs/PUTs/DELETEs. The generic
20
+ # proxy_request tool still rejects non-GET methods when this is on.
21
+ # ALBYHUB_READ_ONLY=false
22
+
23
+ # Two-step confirmation for any non-GET request (proxy_request or future write
24
+ # wrappers). Trades agent autonomy for safety.
25
+ # ALBYHUB_REQUIRE_CONFIRM=false
26
+
27
+ # Rolling 60s rate limit on outbound requests (per Hub instance).
28
+ # ALBYHUB_MAX_REQUESTS_PER_MINUTE=30
29
+
30
+ # Logging.
31
+ # ALBYHUB_LOG_PATH=./albyhub-admin-mcp.log
32
+ # ALBYHUB_AUDIT_PATH=./albyhub-admin-mcp-audit.log
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LLMOps.Pro
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # albyhub-admin-mcp
2
+
3
+ **Node-admin operations on your Alby Hub, exposed to an LLM agent.** MCP server that wraps the Alby Hub HTTP API — read node info, balances, channels, NWC sub-wallets. Where NWC ([`nwc-mcp`](https://npmjs.com/package/nwc-mcp)) ends (it's wallet-scoped), this server picks up: hub-wide on-chain balance, channel state, sub-wallet inventory.
4
+
5
+ > **v0.1 — defensive design.** Alby Hub's admin API has evolved across versions and isn't publicly versioned in a stable way. So v0.1 ships **one generic proxy tool** that hits any path (the escape hatch) plus typed wrappers around a few well-established endpoints. If a typed wrapper's endpoint-guess fails against your Hub version, the proxy lets you discover the right path without re-shipping. v0.2 hardens the typed wrappers once smoke-tested against real Hubs.
6
+
7
+ ---
8
+
9
+ ## The six tools
10
+
11
+ | Tool | Method | Path | Purpose |
12
+ |---|---|---|---|
13
+ | `albyhub_proxy_request` | * | * | Generic HTTP proxy — escape hatch for any endpoint not yet wrapped. |
14
+ | `albyhub_confirm_request` | * | * | Two-step confirm dispatcher for proxy_request. |
15
+ | `albyhub_get_node_info` | GET | `/api/info` | Node identity, network, version. |
16
+ | `albyhub_get_balances` | GET | `/api/balances` | On-chain + Lightning aggregate balances. |
17
+ | `albyhub_list_apps` | GET | `/api/apps` | NWC connections (sub-wallets) provisioned on this hub. |
18
+ | `albyhub_list_channels` | GET | `/api/channels` | Lightning channels with status + capacities. |
19
+
20
+ All typed wrappers are safe GETs. Non-GET behavior runs through `albyhub_proxy_request`, which is gated by `ALBYHUB_READ_ONLY` and `ALBYHUB_REQUIRE_CONFIRM`.
21
+
22
+ ---
23
+
24
+ ## Requirements
25
+
26
+ - Node 20+
27
+ - A running Alby Hub instance (desktop app on `http://localhost:8080`, or self-hosted exposed somewhere)
28
+ - An Alby Hub API token (Settings → Developer / Apps in the Hub UI)
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ npx -y albyhub-admin-mcp
34
+ ```
35
+
36
+ ## Configure
37
+
38
+ ```bash
39
+ cp .env.example .env
40
+ # edit .env: set ALBYHUB_URL (default http://localhost:8080) + ALBYHUB_TOKEN
41
+ ```
42
+
43
+ ### Required
44
+
45
+ | Var | Purpose |
46
+ |---|---|
47
+ | `ALBYHUB_URL` | Base URL of your Hub. Default `http://localhost:8080`. Use HTTPS if exposed remotely. |
48
+ | `ALBYHUB_TOKEN` | API access token. **Full-scope tokens can drain the hub's on-chain balance — scope down or use ALBYHUB_READ_ONLY=true if your audience matters.** |
49
+
50
+ ### Optional safety knobs
51
+
52
+ | Var | Default | Purpose |
53
+ |---|---|---|
54
+ | `ALBYHUB_READ_ONLY` | `false` | Refuse all non-GET requests via `proxy_request`. Strongly recommended for first-time setup. |
55
+ | `ALBYHUB_REQUIRE_CONFIRM` | `false` | Two-step confirm for non-GET requests. |
56
+ | `ALBYHUB_MAX_REQUESTS_PER_MINUTE` | `30` | Rolling 60s rate limit. |
57
+ | `ALBYHUB_LOG_PATH` | `./albyhub-admin-mcp.log` | Server log. |
58
+ | `ALBYHUB_AUDIT_PATH` | `./albyhub-admin-mcp-audit.log` | Append-only JSON-line audit log. |
59
+
60
+ ---
61
+
62
+ ## What if my Hub returns 404 on the typed wrappers?
63
+
64
+ The endpoint path probably differs in your Hub version. Use `albyhub_proxy_request` to probe — try `/api/node`, `/info`, `/api/v1/info`, etc. Once you find the right path, you can pin it in your usage (and ping the maintainer to fix the typed wrapper in v0.2).
65
+
66
+ ```
67
+ agent: albyhub_proxy_request({ method: "GET", path: "/api/v1/info" })
68
+ → { status: 200, body: { ... } } // found it
69
+ ```
70
+
71
+ ---
72
+
73
+ ## Safety model
74
+
75
+ The proxy tool's pipeline:
76
+
77
+ 1. **`ALBYHUB_READ_ONLY` gate** — non-GET requests blocked outright.
78
+ 2. **Rate limit** — rolling 60s window on the `requests` bucket.
79
+ 3. **`ALBYHUB_REQUIRE_CONFIRM` gate** — non-GET requests return a token; `albyhub_confirm_request` executes.
80
+ 4. **HTTP request** — Bearer auth, 15s timeout (override via `timeout_ms`).
81
+ 5. **Audit log** — every attempt (ok / blocked / error) as one structured JSON line.
82
+
83
+ GET-only convenience wrappers skip steps 1 + 3 (they're safe by construction) but still go through rate limit + audit.
84
+
85
+ ---
86
+
87
+ ## Companion servers
88
+
89
+ - [`nwc-mcp`](https://npmjs.com/package/nwc-mcp) — wallet ops via NIP-47. Use this for per-sub-wallet spend; use `albyhub-admin-mcp` for hub-wide / node-level operations.
90
+ - [`nostr-ops-mcp`](https://npmjs.com/package/nostr-ops-mcp) — NOSTR identity + publishing.
91
+ - [`marketplace-mcp`](https://npmjs.com/package/marketplace-mcp) — NIP-15 marketplace storefront.
92
+
93
+ ---
94
+
95
+ ## License
96
+
97
+ MIT — see [`LICENSE`](./LICENSE).
98
+
99
+ ## Contact / Issues
100
+
101
+ Built by **LLMOps.Pro**.
102
+
103
+ - **NOSTR:** [`npub1hdg932jvwc3jdvkqywgqv0ue4nn60exrf92asy8mtazt3hjg7d2s2yw0nw`](https://njump.me/npub1hdg932jvwc3jdvkqywgqv0ue4nn60exrf92asy8mtazt3hjg7d2s2yw0nw) — follow, DM, zap.
104
+ - **Lightning Address:** `sovereigncitizens@getalby.com` — for support zaps and "this was useful" tips.
105
+ - **Bug reports / feature requests:** open a GitHub issue (link forthcoming).
106
+ - **Security issues:** please disclose privately via NOSTR DM before opening a public issue.
package/dist/index.js ADDED
@@ -0,0 +1,509 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { dirname, resolve } from "path";
5
+ import { fileURLToPath } from "url";
6
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
+
9
+ // src/config.ts
10
+ import { z } from "zod";
11
+ var boolish = z.string().optional().transform((v) => v === "true" || v === "1");
12
+ var positiveInt = z.coerce.number().int().positive();
13
+ var ConfigSchema = z.object({
14
+ ALBYHUB_URL: z.string().min(1, "ALBYHUB_URL is required").refine(
15
+ (s) => {
16
+ try {
17
+ new URL(s);
18
+ return true;
19
+ } catch {
20
+ return false;
21
+ }
22
+ },
23
+ "ALBYHUB_URL must be a valid URL (e.g. http://localhost:8080)"
24
+ ),
25
+ ALBYHUB_TOKEN: z.string().min(1, "ALBYHUB_TOKEN is required \u2014 get one from your Alby Hub UI Settings \u2192 Developer / Apps"),
26
+ ALBYHUB_READ_ONLY: boolish,
27
+ ALBYHUB_REQUIRE_CONFIRM: boolish,
28
+ ALBYHUB_MAX_REQUESTS_PER_MINUTE: positiveInt.default(30),
29
+ ALBYHUB_LOG_PATH: z.string().default("./albyhub-admin-mcp.log"),
30
+ ALBYHUB_AUDIT_PATH: z.string().default("./albyhub-admin-mcp-audit.log")
31
+ });
32
+ function loadConfig() {
33
+ const parsed = ConfigSchema.safeParse(process.env);
34
+ if (!parsed.success) {
35
+ const issues = parsed.error.issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`).join("\n");
36
+ process.stderr.write(
37
+ `albyhub-admin-mcp: invalid configuration:
38
+ ${issues}
39
+
40
+ Set the required env vars and try again.
41
+ `
42
+ );
43
+ process.exit(1);
44
+ }
45
+ return parsed.data;
46
+ }
47
+
48
+ // src/hub-client.ts
49
+ var HubClient = class {
50
+ constructor(config) {
51
+ this.config = config;
52
+ }
53
+ config;
54
+ get baseUrl() {
55
+ return this.config.ALBYHUB_URL.replace(/\/+$/, "");
56
+ }
57
+ async request(method, path, opts = {}) {
58
+ let url = this.baseUrl + (path.startsWith("/") ? path : "/" + path);
59
+ if (opts.query && Object.keys(opts.query).length > 0) {
60
+ const qs = new URLSearchParams(opts.query);
61
+ url += (url.includes("?") ? "&" : "?") + qs.toString();
62
+ }
63
+ const headers = {
64
+ Authorization: `Bearer ${this.config.ALBYHUB_TOKEN}`,
65
+ Accept: "application/json",
66
+ ...opts.body !== void 0 ? { "Content-Type": "application/json" } : {},
67
+ ...opts.extraHeaders ?? {}
68
+ };
69
+ const init = {
70
+ method: method.toUpperCase(),
71
+ headers
72
+ };
73
+ if (opts.body !== void 0) {
74
+ init.body = typeof opts.body === "string" ? opts.body : JSON.stringify(opts.body);
75
+ }
76
+ const timeoutMs = opts.timeoutMs ?? 15e3;
77
+ const controller = new AbortController();
78
+ init.signal = controller.signal;
79
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
80
+ try {
81
+ const res = await fetch(url, init);
82
+ const text = await res.text();
83
+ let body = text;
84
+ const ct = res.headers.get("content-type") ?? "";
85
+ if (ct.includes("application/json") && text.length > 0) {
86
+ try {
87
+ body = JSON.parse(text);
88
+ } catch {
89
+ }
90
+ }
91
+ const responseHeaders = {};
92
+ res.headers.forEach((v, k) => responseHeaders[k] = v);
93
+ return {
94
+ status: res.status,
95
+ status_text: res.statusText,
96
+ ok: res.ok,
97
+ headers: responseHeaders,
98
+ body
99
+ };
100
+ } finally {
101
+ clearTimeout(timer);
102
+ }
103
+ }
104
+ get(path, query) {
105
+ return this.request("GET", path, { query });
106
+ }
107
+ };
108
+
109
+ // src/safety/audit-log.ts
110
+ import { appendFile } from "fs/promises";
111
+ var AuditLog = class {
112
+ constructor(path) {
113
+ this.path = path;
114
+ }
115
+ path;
116
+ async record(event) {
117
+ const line = JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), ...event }) + "\n";
118
+ try {
119
+ await appendFile(this.path, line, "utf8");
120
+ } catch (err) {
121
+ process.stderr.write(`albyhub-admin-mcp: failed to append audit log: ${String(err)}
122
+ `);
123
+ }
124
+ }
125
+ };
126
+
127
+ // src/safety/confirm.ts
128
+ import { randomBytes } from "crypto";
129
+ var DEFAULT_TTL_MS = 5 * 60 * 1e3;
130
+ var ConfirmStore = class {
131
+ constructor(ttlMs = DEFAULT_TTL_MS) {
132
+ this.ttlMs = ttlMs;
133
+ }
134
+ ttlMs;
135
+ pending = /* @__PURE__ */ new Map();
136
+ prepare(action) {
137
+ this.pruneExpired();
138
+ const token = randomBytes(16).toString("hex");
139
+ const expires_at = Date.now() + this.ttlMs;
140
+ this.pending.set(token, { ...action, token, expires_at });
141
+ return { token, expires_at };
142
+ }
143
+ consume(token) {
144
+ this.pruneExpired();
145
+ const stored = this.pending.get(token);
146
+ if (!stored) return null;
147
+ this.pending.delete(token);
148
+ return stored;
149
+ }
150
+ pruneExpired() {
151
+ const now = Date.now();
152
+ for (const [token, action] of this.pending) {
153
+ if (action.expires_at <= now) this.pending.delete(token);
154
+ }
155
+ }
156
+ };
157
+
158
+ // src/safety/rate-limiter.ts
159
+ var WINDOW_MS = 6e4;
160
+ var RateLimiter = class {
161
+ buckets = /* @__PURE__ */ new Map();
162
+ limits;
163
+ constructor(limits) {
164
+ this.limits = new Map(Object.entries(limits));
165
+ }
166
+ take(bucket) {
167
+ const limit = this.limits.get(bucket);
168
+ if (limit === void 0) return { ok: true };
169
+ const now = Date.now();
170
+ const cutoff = now - WINDOW_MS;
171
+ const entries = (this.buckets.get(bucket) ?? []).filter((ts) => ts > cutoff);
172
+ if (entries.length >= limit) {
173
+ return {
174
+ ok: false,
175
+ reason: `rate limit hit on "${bucket}": ${entries.length}/${limit} in the last 60s`
176
+ };
177
+ }
178
+ entries.push(now);
179
+ this.buckets.set(bucket, entries);
180
+ return { ok: true };
181
+ }
182
+ snapshot() {
183
+ const now = Date.now();
184
+ const cutoff = now - WINDOW_MS;
185
+ const out = {};
186
+ for (const [bucket, limit] of this.limits) {
187
+ const entries = (this.buckets.get(bucket) ?? []).filter((ts) => ts > cutoff);
188
+ out[bucket] = { used: entries.length, limit };
189
+ }
190
+ return out;
191
+ }
192
+ };
193
+
194
+ // src/tools/confirm-request.ts
195
+ import { z as z3 } from "zod";
196
+
197
+ // src/tools/_result.ts
198
+ function textResult(payload) {
199
+ const text = typeof payload === "string" ? payload : JSON.stringify(payload, null, 2);
200
+ return { content: [{ type: "text", text }] };
201
+ }
202
+ function errorResult(message) {
203
+ return { content: [{ type: "text", text: message }], isError: true };
204
+ }
205
+
206
+ // src/tools/proxy-request.ts
207
+ import { z as z2 } from "zod";
208
+ var SAFE_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
209
+ var inputSchema = {
210
+ method: z2.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]).describe("HTTP method. GET/HEAD/OPTIONS are always allowed; others gated by READ_ONLY + REQUIRE_CONFIRM."),
211
+ path: z2.string().min(1).regex(/^\//, "path must start with `/`").describe("Path on the Hub, e.g. `/api/info` or `/api/channels`. Combined with ALBYHUB_URL."),
212
+ query: z2.record(z2.string()).optional().describe("Query-string params (string \u2192 string)."),
213
+ body: z2.any().optional().describe("Request body \u2014 passed as JSON if an object, raw if a string. Ignored for GET/HEAD/OPTIONS."),
214
+ extra_headers: z2.record(z2.string()).optional().describe("Extra HTTP headers to merge in. Useful if your Hub expects `X-API-Key` instead of `Authorization: Bearer`."),
215
+ timeout_ms: z2.number().int().positive().max(6e4).optional().describe("Request timeout in ms (default 15000, max 60000).")
216
+ };
217
+ async function evaluateAndProxy(deps, params, opts = {}) {
218
+ const auditTool = opts.auditTool ?? "albyhub_proxy_request";
219
+ const inputForAudit = {
220
+ method: params.method,
221
+ path: params.path,
222
+ has_body: params.body !== void 0,
223
+ extra_header_keys: params.extra_headers ? Object.keys(params.extra_headers) : []
224
+ };
225
+ const isWrite = !SAFE_METHODS.has(params.method.toUpperCase());
226
+ if (isWrite && deps.config.ALBYHUB_READ_ONLY) {
227
+ await deps.audit.record({
228
+ tool: auditTool,
229
+ outcome: "blocked",
230
+ input: inputForAudit,
231
+ blocked_reason: "ALBYHUB_READ_ONLY=true \u2014 non-GET methods are disabled"
232
+ });
233
+ return errorResult("ALBYHUB_READ_ONLY=true \u2014 non-GET methods are disabled");
234
+ }
235
+ const rate = deps.rateLimiter.take("requests");
236
+ if (!rate.ok) {
237
+ await deps.audit.record({
238
+ tool: auditTool,
239
+ outcome: "blocked",
240
+ input: inputForAudit,
241
+ blocked_reason: rate.reason
242
+ });
243
+ return errorResult(rate.reason);
244
+ }
245
+ if (isWrite && deps.config.ALBYHUB_REQUIRE_CONFIRM && !opts.skipConfirmGate) {
246
+ const summary = `${params.method.toUpperCase()} ${params.path}${params.body !== void 0 ? " (with body)" : ""}`;
247
+ const { token, expires_at } = deps.confirm.prepare({
248
+ tool: auditTool,
249
+ params,
250
+ summary
251
+ });
252
+ await deps.audit.record({
253
+ tool: auditTool,
254
+ outcome: "ok",
255
+ input: inputForAudit,
256
+ result: { confirmation_required: true, token }
257
+ });
258
+ return textResult({
259
+ status: "confirmation_required",
260
+ token,
261
+ expires_at: new Date(expires_at).toISOString(),
262
+ summary,
263
+ next_step: `Call albyhub_confirm_request with token "${token}" to execute.`
264
+ });
265
+ }
266
+ try {
267
+ const response = await deps.hub.request(params.method, params.path, {
268
+ body: params.body,
269
+ query: params.query,
270
+ extraHeaders: params.extra_headers,
271
+ timeoutMs: params.timeout_ms
272
+ });
273
+ await deps.audit.record({
274
+ tool: auditTool,
275
+ outcome: response.ok ? "ok" : "error",
276
+ input: inputForAudit,
277
+ result: {
278
+ status: response.status,
279
+ ok: response.ok
280
+ },
281
+ ...response.ok ? {} : { error: `HTTP ${response.status} ${response.status_text}` }
282
+ });
283
+ return textResult({
284
+ status: response.status,
285
+ status_text: response.status_text,
286
+ ok: response.ok,
287
+ headers: response.headers,
288
+ body: response.body
289
+ });
290
+ } catch (err) {
291
+ const msg = err instanceof Error ? err.message : String(err);
292
+ await deps.audit.record({
293
+ tool: auditTool,
294
+ outcome: "error",
295
+ input: inputForAudit,
296
+ error: msg
297
+ });
298
+ return errorResult(`${auditTool} failed: ${msg}`);
299
+ }
300
+ }
301
+ function registerProxyRequest(server, deps) {
302
+ server.registerTool(
303
+ "albyhub_proxy_request",
304
+ {
305
+ description: "Generic HTTP proxy to your Alby Hub admin API. Use this when no typed convenience tool exists for the endpoint you need. Returns the response status, headers, and (JSON-parsed when possible) body. Non-GET methods are gated by ALBYHUB_READ_ONLY and ALBYHUB_REQUIRE_CONFIRM. Pair with the Hub's own docs to discover endpoints; the typed wrappers (get_node_info, list_apps, get_balances) are layered on top of this and good defaults to start with.",
306
+ inputSchema
307
+ },
308
+ async (args) => evaluateAndProxy(deps, args)
309
+ );
310
+ }
311
+
312
+ // src/tools/confirm-request.ts
313
+ var inputSchema2 = {
314
+ token: z3.string().min(1).describe("Confirmation token from a previous non-GET proxy_request.")
315
+ };
316
+ function registerConfirmRequest(server, deps) {
317
+ server.registerTool(
318
+ "albyhub_confirm_request",
319
+ {
320
+ description: "Execute a previously-prepared non-GET request, identified by its one-time token. Only meaningful when ALBYHUB_REQUIRE_CONFIRM=true. The token is consumed (single use) and the safety pipeline (read-only, rate limit) re-runs before the HTTP call.",
321
+ inputSchema: inputSchema2
322
+ },
323
+ async ({ token }) => {
324
+ const action = deps.confirm.consume(token);
325
+ if (!action) {
326
+ await deps.audit.record({
327
+ tool: "albyhub_confirm_request",
328
+ outcome: "blocked",
329
+ input: { token_prefix: token.slice(0, 8) + "..." },
330
+ blocked_reason: "token unknown or expired"
331
+ });
332
+ return errorResult("Token is unknown or expired. Call the original proxy_request again to get a fresh token.");
333
+ }
334
+ const params = action.params;
335
+ return evaluateAndProxy(deps, params, {
336
+ skipConfirmGate: true,
337
+ auditTool: "albyhub_confirm_request"
338
+ });
339
+ }
340
+ );
341
+ }
342
+
343
+ // src/tools/get-balances.ts
344
+ function registerGetBalances(server, hub, audit) {
345
+ server.registerTool(
346
+ "albyhub_get_balances",
347
+ {
348
+ description: "Fetch hub balances (on-chain + lightning) from GET /api/balances. Distinct from nwc_get_balance \u2014 that one is a single sub-wallet's view via NWC; this one is the hub-wide aggregate including on-chain. If your Hub returns 404, probe with albyhub_proxy_request."
349
+ },
350
+ async () => {
351
+ try {
352
+ const r = await hub.get("/api/balances");
353
+ await audit.record({
354
+ tool: "albyhub_get_balances",
355
+ outcome: r.ok ? "ok" : "error",
356
+ result: { status: r.status, ok: r.ok },
357
+ ...r.ok ? {} : { error: `HTTP ${r.status} ${r.status_text}` }
358
+ });
359
+ return textResult({ status: r.status, ok: r.ok, body: r.body });
360
+ } catch (err) {
361
+ const msg = err instanceof Error ? err.message : String(err);
362
+ await audit.record({ tool: "albyhub_get_balances", outcome: "error", error: msg });
363
+ return errorResult(`albyhub_get_balances failed: ${msg}`);
364
+ }
365
+ }
366
+ );
367
+ }
368
+
369
+ // src/tools/get-node-info.ts
370
+ function registerGetNodeInfo(server, hub, audit) {
371
+ server.registerTool(
372
+ "albyhub_get_node_info",
373
+ {
374
+ description: "Fetch Alby Hub node info (identity, network, version) from GET /api/info. Convenience wrapper. If your Hub returns 404 here, the endpoint path differs in your version \u2014 use albyhub_proxy_request to probe alternatives like /api/node or /info."
375
+ },
376
+ async () => {
377
+ try {
378
+ const r = await hub.get("/api/info");
379
+ await audit.record({
380
+ tool: "albyhub_get_node_info",
381
+ outcome: r.ok ? "ok" : "error",
382
+ result: { status: r.status, ok: r.ok },
383
+ ...r.ok ? {} : { error: `HTTP ${r.status} ${r.status_text}` }
384
+ });
385
+ return textResult({ status: r.status, ok: r.ok, body: r.body });
386
+ } catch (err) {
387
+ const msg = err instanceof Error ? err.message : String(err);
388
+ await audit.record({ tool: "albyhub_get_node_info", outcome: "error", error: msg });
389
+ return errorResult(`albyhub_get_node_info failed: ${msg}`);
390
+ }
391
+ }
392
+ );
393
+ }
394
+
395
+ // src/tools/list-apps.ts
396
+ function registerListApps(server, hub, audit) {
397
+ server.registerTool(
398
+ "albyhub_list_apps",
399
+ {
400
+ description: "List NWC connections / sub-wallets ('apps') currently provisioned on the Hub. Each entry typically includes name, pubkey, scope, daily budget, and creation timestamp. Hits GET /api/apps. Use this to inventory what's connected to your Hub and what each connection's budget cap is."
401
+ },
402
+ async () => {
403
+ try {
404
+ const r = await hub.get("/api/apps");
405
+ await audit.record({
406
+ tool: "albyhub_list_apps",
407
+ outcome: r.ok ? "ok" : "error",
408
+ result: { status: r.status, ok: r.ok, app_count: Array.isArray(r.body) ? r.body.length : null },
409
+ ...r.ok ? {} : { error: `HTTP ${r.status} ${r.status_text}` }
410
+ });
411
+ return textResult({ status: r.status, ok: r.ok, body: r.body });
412
+ } catch (err) {
413
+ const msg = err instanceof Error ? err.message : String(err);
414
+ await audit.record({ tool: "albyhub_list_apps", outcome: "error", error: msg });
415
+ return errorResult(`albyhub_list_apps failed: ${msg}`);
416
+ }
417
+ }
418
+ );
419
+ }
420
+
421
+ // src/tools/list-channels.ts
422
+ function registerListChannels(server, hub, audit) {
423
+ server.registerTool(
424
+ "albyhub_list_channels",
425
+ {
426
+ description: "List Lightning channels \u2014 typically peer pubkey, capacity, local/remote balance, public/private flag, online status. Hits GET /api/channels. The hub-wide view (vs. nwc-mcp which has no channel concept). Useful for spotting offline channels, low inbound, etc."
427
+ },
428
+ async () => {
429
+ try {
430
+ const r = await hub.get("/api/channels");
431
+ await audit.record({
432
+ tool: "albyhub_list_channels",
433
+ outcome: r.ok ? "ok" : "error",
434
+ result: { status: r.status, ok: r.ok, channel_count: Array.isArray(r.body) ? r.body.length : null },
435
+ ...r.ok ? {} : { error: `HTTP ${r.status} ${r.status_text}` }
436
+ });
437
+ return textResult({ status: r.status, ok: r.ok, body: r.body });
438
+ } catch (err) {
439
+ const msg = err instanceof Error ? err.message : String(err);
440
+ await audit.record({ tool: "albyhub_list_channels", outcome: "error", error: msg });
441
+ return errorResult(`albyhub_list_channels failed: ${msg}`);
442
+ }
443
+ }
444
+ );
445
+ }
446
+
447
+ // src/tools/register.ts
448
+ function registerAllTools(deps) {
449
+ const { server, hub, audit } = deps;
450
+ const proxyDeps = {
451
+ config: deps.config,
452
+ hub: deps.hub,
453
+ audit: deps.audit,
454
+ rateLimiter: deps.rateLimiter,
455
+ confirm: deps.confirm
456
+ };
457
+ registerProxyRequest(server, proxyDeps);
458
+ registerConfirmRequest(server, proxyDeps);
459
+ registerGetNodeInfo(server, hub, audit);
460
+ registerGetBalances(server, hub, audit);
461
+ registerListApps(server, hub, audit);
462
+ registerListChannels(server, hub, audit);
463
+ }
464
+
465
+ // src/index.ts
466
+ function tryLoadEnvFile() {
467
+ const here = dirname(fileURLToPath(import.meta.url));
468
+ const path = resolve(here, "..", ".env");
469
+ try {
470
+ process.loadEnvFile(path);
471
+ } catch {
472
+ }
473
+ }
474
+ tryLoadEnvFile();
475
+ async function main() {
476
+ const config = loadConfig();
477
+ const audit = new AuditLog(config.ALBYHUB_AUDIT_PATH);
478
+ const rateLimiter = new RateLimiter({ requests: config.ALBYHUB_MAX_REQUESTS_PER_MINUTE });
479
+ const confirm = new ConfirmStore();
480
+ const hub = new HubClient(config);
481
+ const server = new McpServer(
482
+ { name: "albyhub-admin-mcp", version: "0.1.0" },
483
+ { capabilities: { tools: {} } }
484
+ );
485
+ registerAllTools({ server, config, hub, audit, rateLimiter, confirm });
486
+ const transport = new StdioServerTransport();
487
+ await server.connect(transport);
488
+ await audit.record({
489
+ tool: "_startup",
490
+ outcome: "ok",
491
+ result: {
492
+ hub_url: hub.baseUrl,
493
+ read_only: config.ALBYHUB_READ_ONLY,
494
+ require_confirm: config.ALBYHUB_REQUIRE_CONFIRM,
495
+ rate_limits: rateLimiter.snapshot()
496
+ }
497
+ });
498
+ const shutdown = async (signal) => {
499
+ await audit.record({ tool: "_shutdown", outcome: "ok", result: { signal } });
500
+ process.exit(0);
501
+ };
502
+ process.on("SIGINT", () => void shutdown("SIGINT"));
503
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
504
+ }
505
+ main().catch((err) => {
506
+ process.stderr.write(`albyhub-admin-mcp: fatal: ${err instanceof Error ? err.stack : String(err)}
507
+ `);
508
+ process.exit(1);
509
+ });
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "albyhub-admin-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for node-admin operations on an Alby Hub instance via its HTTP API. Lets agents read node info, balances, channels, and sub-wallet apps; sub-wallet provisioning + channel ops gated behind safety knobs. A generic proxy_request tool covers any endpoint the typed wrappers don't yet expose. Pairs with nwc-mcp (NIP-47 wallet ops) — admin handles what NWC can't reach.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "LLMOps.Pro <https://njump.me/npub1hdg932jvwc3jdvkqywgqv0ue4nn60exrf92asy8mtazt3hjg7d2s2yw0nw>",
8
+ "keywords": [
9
+ "mcp",
10
+ "model-context-protocol",
11
+ "alby",
12
+ "alby-hub",
13
+ "lightning",
14
+ "bitcoin",
15
+ "lnd",
16
+ "node-admin",
17
+ "claude",
18
+ "ai-agent",
19
+ "llm"
20
+ ],
21
+ "bin": {
22
+ "albyhub-admin-mcp": "dist/index.js"
23
+ },
24
+ "main": "./dist/index.js",
25
+ "files": [
26
+ "dist",
27
+ "README.md",
28
+ "LICENSE",
29
+ ".env.example"
30
+ ],
31
+ "scripts": {
32
+ "build": "tsup src/index.ts --format esm --target node20 --clean --shims",
33
+ "dev": "tsx watch src/index.ts",
34
+ "start": "node dist/index.js",
35
+ "typecheck": "tsc --noEmit",
36
+ "test": "vitest run"
37
+ },
38
+ "dependencies": {
39
+ "@modelcontextprotocol/sdk": "^1.0.0",
40
+ "zod": "^3.23.0"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^22.0.0",
44
+ "tsup": "^8.0.0",
45
+ "tsx": "^4.0.0",
46
+ "typescript": "^5.5.0",
47
+ "vitest": "^2.0.0"
48
+ },
49
+ "engines": {
50
+ "node": ">=20"
51
+ },
52
+ "packageManager": "pnpm@9.15.0"
53
+ }