Build a Lightning Network Payment Monitor: Real-Time MCP Server Tutorial
The Lightning Network processes millions of instant Bitcoin payments every day. Sub-second settlement and sub-cent fees have made it one of the most active payment rails in crypto. But monitoring a Lightning node (channel balances, payment flow, routing fees, invoice status) still means juggling terminal commands, REST calls, and dashboards. An MCP server fixes that. Build it once, and Claude becomes your real-time Lightning operations console. Ask “what is my channel balance?” or “show me payments from the last hour,” and it queries your node directly. In this tutorial, you will build a Lightning Network payment monitor as a Model Context Protocol (MCP) server that connects Claude to your LND node’s REST API.
What We’re Building
This MCP server connects Claude to a Lightning Network node running LND (Lightning Network Daemon). It exposes five tools that cover the core monitoring operations:
get_channel_balance— returns local and remote balances across all open channelslist_payments— queries recent outgoing payments with status, fees, and timestampsget_network_stats— pulls network-level data including total channels, total capacity, and average fee ratescheck_invoice_status— looks up an invoice by its payment hash and returns its current stateestimate_routing_fee— estimates the fee for routing a payment of a given amount to a destination
The architecture follows the same pattern from our crypto payment MCP tutorial. Claude sends a tool call over stdio, the MCP server translates it into an HTTP request against the LND REST API, and returns structured data that Claude interprets in natural language. The difference is the data source: instead of a payment gateway REST API, you are talking directly to a Lightning node.
Why LND’s REST API? LND exposes both gRPC and REST interfaces. The REST API runs on port 8080 by default and covers most of the endpoints you need for monitoring. It is simpler to work with in a tutorial context since there is no protobuf compilation or gRPC client setup. For production deployments that need streaming subscriptions (real-time payment notifications via gRPC streams), you can extend this server later. Lightning Labs also released an official AI agent toolkit in February 2026 with an MCP server exposing 18 read-only tools via Lightning Node Connect (LNC). This tutorial builds a custom server from scratch so you understand every layer.
Lightning Network APIs
Before writing code, you need to understand what is available. The Lightning ecosystem offers several API options for node monitoring.
LND REST API
The LND REST API is the primary interface we will use. Key endpoints include:
/v1/balance/channels— Total balance across all channels (local, remote, pending, unsettled)/v1/payments— List of outgoing payments with status and fee details/v1/invoices— List of incoming invoices/v1/invoice/{r_hash_str}— Look up a specific invoice by hash/v1/graph/info— Network-level statistics/v1/channels— Detailed info on all open channels/v2/router/route— Query route and fee estimation
Authentication uses macaroons, which are bearer tokens with fine-grained permission scoping. LND ships with three default macaroons: admin.macaroon (full access), invoice.macaroon (invoice operations only), and readonly.macaroon (read-only access). For a monitoring server, readonly.macaroon is the correct choice. You also need the TLS certificate (tls.cert) to establish a secure connection.
Core Lightning (CLN)
If you run Core Lightning instead of LND, the CLN JSON-RPC API offers equivalent functionality. The tool schemas in this tutorial would stay the same; only the HTTP client layer changes. CLN uses a Unix socket or REST plugin for API access.
Third-Party Aggregators
Services like Mempool.space and Amboss provide public APIs for network-wide statistics without requiring your own node. These are useful for the get_network_stats tool if you want network data without node access. For channel and payment monitoring, you need a direct node connection.
Prerequisites
Before you start, make sure you have the following:
- Node.js 18 or later. The MCP SDK requires a modern Node runtime. Check with
node --version. - npm for installing dependencies.
- Claude Desktop or Claude Code. Either MCP client works. Claude Code is better for developer workflows.
- Access to an LND node. You need the REST API endpoint (default
https://localhost:8080), thereadonly.macaroonfile (hex-encoded), and thetls.certfile. If you do not have a node, you can use a hosted provider like Voltage for testing. - Basic MCP knowledge. If you are new to the protocol, read our MCP explainer first.
Step 1 — Define MCP Tools
Create a new project and install dependencies:
mkdir lightning-mcp-monitor
cd lightning-mcp-monitor
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node
Set up tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}
Update package.json to use ES modules:
{
"name": "lightning-mcp-monitor",
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/server.js"
}
}
Now create src/server.ts and define the five tool schemas. Each schema tells Claude what the tool does and what parameters it accepts:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import fs from "fs";
import https from "https";
// LND connection config from environment
const LND_REST_URL = process.env.LND_REST_URL || "https://localhost:8080";
const LND_MACAROON = process.env.LND_MACAROON || "";
const LND_TLS_CERT_PATH = process.env.LND_TLS_CERT_PATH || "";
if (!LND_MACAROON) {
console.error("LND_MACAROON environment variable is required (hex-encoded readonly.macaroon)");
process.exit(1);
}
const server = new McpServer({
name: "lightning-network-monitor",
version: "1.0.0",
});
// Tool 1: Channel balance
server.tool(
"get_channel_balance",
"Get the total balance across all open Lightning channels. Returns local balance (funds you can send), remote balance (funds you can receive), pending open balance, and unsettled balance.",
{},
async () => {
const data = await lndRequest("/v1/balance/channels");
return formatResponse(data);
}
);
// Tool 2: List payments
server.tool(
"list_payments",
"List recent outgoing Lightning payments. Returns payment hash, amount in satoshis, fee paid, status (SUCCEEDED, FAILED, IN_FLIGHT), and creation timestamp. Use include_incomplete to see in-flight payments.",
{
include_incomplete: z.boolean().default(false)
.describe("Include payments that are still in flight"),
max_payments: z.number().min(1).max(100).default(20)
.describe("Maximum number of payments to return"),
},
async ({ include_incomplete, max_payments }) => {
const params = new URLSearchParams({
include_incomplete: String(include_incomplete),
max_payments: String(max_payments),
reversed: "true",
});
const data = await lndRequest(`/v1/payments?${params.toString()}`);
return formatResponse(data);
}
);
// Tool 3: Network stats
server.tool(
"get_network_stats",
"Get Lightning Network graph statistics from this node's perspective. Returns total number of nodes, total channels, total network capacity in BTC, and this node's public key and alias.",
{},
async () => {
const data = await lndRequest("/v1/graph/info");
return formatResponse(data);
}
);
// Tool 4: Check invoice status
server.tool(
"check_invoice_status",
"Look up the status of a Lightning invoice by its payment hash (r_hash). Returns the invoice amount, memo, whether it has been settled, settle timestamp, and expiry details.",
{
payment_hash: z.string()
.describe("The payment hash (r_hash) of the invoice to look up, hex-encoded"),
},
async ({ payment_hash }) => {
const data = await lndRequest(`/v1/invoice/${payment_hash}`);
return formatResponse(data);
}
);
// Tool 5: Estimate routing fee
server.tool(
"estimate_routing_fee",
"Estimate the routing fee for sending a payment of a given amount to a destination node. Returns the estimated fee in satoshis and the estimated time lock delta.",
{
dest_pub_key: z.string()
.describe("The public key of the destination node (hex-encoded, 66 characters)"),
amount_sat: z.number().positive()
.describe("The payment amount in satoshis"),
},
async ({ dest_pub_key, amount_sat }) => {
const data = await lndRequest("/v2/router/route", {
method: "POST",
body: {
pub_key: dest_pub_key,
amt: String(amount_sat),
},
});
return formatResponse(data);
}
);
Each tool description is written to help Claude decide when to use it. The descriptions mention return values explicitly — this gives Claude enough context to answer follow-up questions without making additional API calls.
Step 2 — Connect to the Lightning API
LND’s REST API requires two credentials: a TLS certificate and a macaroon. The TLS cert establishes a secure connection (LND uses self-signed certificates by default), and the macaroon provides authentication and authorization.
Add the HTTP client above the tool definitions in src/server.ts:
// Build a custom HTTPS agent that trusts the LND TLS certificate
function createHttpsAgent(): https.Agent | undefined {
if (!LND_TLS_CERT_PATH) return undefined;
try {
const cert = fs.readFileSync(LND_TLS_CERT_PATH);
return new https.Agent({ ca: [cert] });
} catch (err) {
console.error(`Warning: Could not read TLS cert at ${LND_TLS_CERT_PATH}`);
return undefined;
}
}
const httpsAgent = createHttpsAgent();
interface LndRequestOptions {
method?: string;
body?: Record<string, unknown>;
}
async function lndRequest(
endpoint: string,
options: LndRequestOptions = {}
): Promise<unknown> {
const { method = "GET", body } = options;
const url = `${LND_REST_URL}${endpoint}`;
const headers: Record<string, string> = {
"Grpc-Metadata-macaroon": LND_MACAROON,
"Content-Type": "application/json",
"Accept": "application/json",
};
const fetchOptions: RequestInit & { dispatcher?: unknown } = {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
};
// For self-signed certs, disable TLS verification in development
// In production, use the proper TLS cert via the HTTPS agent
if (LND_REST_URL.includes("localhost") || LND_REST_URL.includes("127.0.0.1")) {
// Node.js 18+ supports this via the --insecure-http-parser flag
// or by setting NODE_TLS_REJECT_UNAUTHORIZED=0 in env
}
const response = await fetch(url, fetchOptions);
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`LND API error: ${response.status} ${response.statusText} — ${errorText}`
);
}
return response.json();
}
function formatResponse(data: unknown) {
return {
content: [
{
type: "text" as const,
text: JSON.stringify(data, null, 2),
},
],
};
}
The critical header is Grpc-Metadata-macaroon. LND’s REST API expects the macaroon as a hex-encoded string in this header, not as a Bearer token. This is the most common mistake developers make when first connecting to LND. The macaroon file is binary, so you need to convert it to hex first:
# Convert your readonly.macaroon to hex
xxd -ps -u -c 1000 ~/.lnd/data/chain/bitcoin/mainnet/readonly.macaroon
Copy that hex string and use it as the LND_MACAROON environment variable.
For the TLS certificate, LND generates a self-signed cert at ~/.lnd/tls.cert. In production, you pass this to the HTTPS agent so Node.js trusts the connection. For local development, you can set NODE_TLS_REJECT_UNAUTHORIZED=0 in your environment, but never do this in production.
Step 3 — Implement Handlers
The tool handlers above make raw API calls and return the full JSON response. For a better experience, you can transform the responses into cleaner formats that help Claude generate more useful answers. Here is an enhanced version of the channel balance handler:
server.tool(
"get_channel_balance",
"Get the total balance across all open Lightning channels. Returns local balance (funds you can send), remote balance (funds you can receive), pending open balance, and unsettled balance. All amounts are in satoshis.",
{},
async () => {
const raw = await lndRequest("/v1/balance/channels") as {
local_balance?: { sat: string };
remote_balance?: { sat: string };
pending_open_local_balance?: { sat: string };
pending_open_remote_balance?: { sat: string };
unsettled_local_balance?: { sat: string };
unsettled_remote_balance?: { sat: string };
};
const summary = {
local_balance_sat: parseInt(raw.local_balance?.sat || "0"),
remote_balance_sat: parseInt(raw.remote_balance?.sat || "0"),
pending_open_local_sat: parseInt(raw.pending_open_local_balance?.sat || "0"),
pending_open_remote_sat: parseInt(raw.pending_open_remote_balance?.sat || "0"),
unsettled_local_sat: parseInt(raw.unsettled_local_balance?.sat || "0"),
unsettled_remote_sat: parseInt(raw.unsettled_remote_balance?.sat || "0"),
total_sendable_sat: parseInt(raw.local_balance?.sat || "0"),
total_receivable_sat: parseInt(raw.remote_balance?.sat || "0"),
};
return formatResponse(summary);
}
);
The transformation converts LND’s nested balance objects (which use string-encoded satoshis) into flat integer fields with descriptive names. It also adds computed fields like total_sendable_sat and total_receivable_sat that Claude can reference directly when answering questions like “how much can I send right now?”
Apply similar transformations to the payments handler to extract the fields that matter most:
server.tool(
"list_payments",
"List recent outgoing Lightning payments. Returns payment hash, amount in satoshis, fee paid in satoshis, status, creation date, and the number of attempted routes.",
{
include_incomplete: z.boolean().default(false)
.describe("Include payments that are still in flight"),
max_payments: z.number().min(1).max(100).default(20)
.describe("Maximum number of payments to return"),
},
async ({ include_incomplete, max_payments }) => {
const params = new URLSearchParams({
include_incomplete: String(include_incomplete),
max_payments: String(max_payments),
reversed: "true",
});
const raw = await lndRequest(`/v1/payments?${params.toString()}`) as {
payments?: Array<{
payment_hash: string;
value_sat: string;
fee_sat: string;
status: string;
creation_date: string;
htlcs?: unknown[];
}>;
};
const payments = (raw.payments || []).map((p) => ({
payment_hash: p.payment_hash,
amount_sat: parseInt(p.value_sat || "0"),
fee_sat: parseInt(p.fee_sat || "0"),
status: p.status,
created_at: new Date(parseInt(p.creation_date) * 1000).toISOString(),
routes_attempted: p.htlcs?.length || 0,
}));
return formatResponse({
total_payments: payments.length,
payments,
});
}
);
Converting timestamps from Unix epoch to ISO 8601 is a small detail that makes a real difference. Claude can parse ISO dates and do relative time calculations (“this payment was 3 hours ago”) without you writing any date logic.
Step 4 — Add Real-Time Monitoring
The five tools above handle on-demand queries. But Lightning payments settle in seconds, and you may want Claude to alert you when something happens: a new payment arrives, a channel goes offline, or a large HTLC routes through your node.
LND supports streaming via gRPC server-streaming RPCs. The REST API has limited streaming support through long-polling endpoints. For an MCP server, the cleanest approach is a polling-based subscription tool that checks for new events at a configurable interval.
Add a subscribe_invoices tool that polls for newly settled invoices:
server.tool(
"subscribe_invoices",
"Poll for recently settled invoices within a given time window. Returns invoices that were settled in the last N seconds. Use this to monitor incoming payments. Call it periodically or ask Claude to check for new payments.",
{
since_seconds: z.number().min(10).max(3600).default(60)
.describe("Look back window in seconds. Returns invoices settled within this period."),
},
async ({ since_seconds }) => {
const raw = await lndRequest("/v1/invoices?reversed=true&num_max_invoices=50") as {
invoices?: Array<{
memo: string;
value: string;
settled: boolean;
settle_date: string;
payment_request: string;
r_hash: string;
amt_paid_sat: string;
}>;
};
const cutoff = Math.floor(Date.now() / 1000) - since_seconds;
const recentSettled = (raw.invoices || [])
.filter((inv) => inv.settled && parseInt(inv.settle_date) > cutoff)
.map((inv) => ({
memo: inv.memo || "(no memo)",
amount_sat: parseInt(inv.amt_paid_sat || inv.value || "0"),
settled_at: new Date(parseInt(inv.settle_date) * 1000).toISOString(),
payment_hash: Buffer.from(inv.r_hash, "base64").toString("hex"),
}));
return formatResponse({
window_seconds: since_seconds,
settled_invoices: recentSettled.length,
invoices: recentSettled,
});
}
);
This approach has trade-offs. Polling every 60 seconds is fine for a monitoring dashboard but will miss the sub-second settlement experience that makes Lightning special. For true real-time notifications, you would use LND’s gRPC SubscribeInvoices stream and push events to Claude via MCP notifications. The MCP protocol supports server-initiated notifications, but client support varies. Polling is the pragmatic starting point.
A more advanced pattern combines polling with a local event cache. Run a background process that subscribes to LND’s gRPC streams and writes events to a local SQLite database or in-memory queue. The MCP tool then queries the cache instead of hitting LND directly. This decouples the real-time event collection from the AI query layer.
Step 5 — Configure and Test
Wire up the server transport and build:
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Lightning Network MCP monitor running on stdio");
}
main().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});
npx tsc
Add the server to your Claude configuration. For Claude Desktop, edit claude_desktop_config.json:
{
"mcpServers": {
"lightning-monitor": {
"command": "node",
"args": ["/absolute/path/to/lightning-mcp-monitor/dist/server.js"],
"env": {
"LND_REST_URL": "https://your-node-ip:8080",
"LND_MACAROON": "0201036c6e6402f801...",
"LND_TLS_CERT_PATH": "/path/to/.lnd/tls.cert",
"NODE_TLS_REJECT_UNAUTHORIZED": "0"
}
}
}
}
For Claude Code, add to .mcp.json in your project root:
{
"mcpServers": {
"lightning-monitor": {
"command": "node",
"args": ["./lightning-mcp-monitor/dist/server.js"],
"env": {
"LND_REST_URL": "https://your-node-ip:8080",
"LND_MACAROON": "0201036c6e6402f801...",
"LND_TLS_CERT_PATH": "/path/to/.lnd/tls.cert"
}
}
}
}
Test with the MCP Inspector first:
LND_REST_URL=https://localhost:8080 \
LND_MACAROON=$(xxd -ps -u -c 1000 ~/.lnd/data/chain/bitcoin/mainnet/readonly.macaroon) \
LND_TLS_CERT_PATH=~/.lnd/tls.cert \
NODE_TLS_REJECT_UNAUTHORIZED=0 \
npx @modelcontextprotocol/inspector node dist/server.js
Once verified, restart Claude and try these conversations:
You: “What’s my Lightning channel balance?”
Claude calls get_channel_balance and responds:
Your Lightning channel balance:
- Sendable (local): 2,450,000 sats (0.0245 BTC)
- Receivable (remote): 8,120,000 sats (0.0812 BTC)
- Pending open: 500,000 sats in 1 channel awaiting confirmation
- Unsettled: 0 sats
You have strong inbound liquidity — about 3.3x more capacity to receive than to send.
You: “Show me payments from the last hour. Any failures?”
Claude calls list_payments with {"include_incomplete": true, "max_payments": 50}, filters by timestamp, and highlights failures:
In the last hour, you made 7 outgoing payments:
- 5 succeeded (total: 125,400 sats, fees: 12 sats)
- 1 failed (50,000 sats to 02a1b2c3... — no route found)
- 1 in-flight (10,000 sats — still pending)
Average routing fee: 2.4 sats per payment. The failed payment to 02a1b2c3 might need a direct channel or a larger fee limit.
You: “What’s the cheapest route to send 100,000 sats to 02f8a3b7…?”
Claude calls estimate_routing_fee and returns:
Estimated route to 02f8a3b7...:
- Fee: 8 sats (0.008%)
- Time lock delta: 40 blocks
- Hops: 3
That's well within typical fee ranges for a payment this size.
You: “Any new payments received in the last 5 minutes?”
Claude calls subscribe_invoices with {"since_seconds": 300}:
2 invoices settled in the last 5 minutes:
1. 50,000 sats — "Monthly VPN subscription" — settled at 14:23 UTC
2. 12,500 sats — (no memo) — settled at 14:21 UTC
Total received: 62,500 sats ($42.50 at current rates).
Production Considerations
A monitoring MCP server that connects to a live Lightning node carries real security responsibilities. Here is what to address before running this in production.
Macaroon Security
Never expose your admin macaroon through an MCP server. Use readonly.macaroon for monitoring. If you need custom permissions, bake a restricted macaroon:
# Create a macaroon with only balance and invoice read permissions
lncli bakemacaroon info:read offchain:read invoices:read --save_to=monitor.macaroon
Store the hex-encoded macaroon in environment variables, never in code or config files checked into version control. Treat it like an API key, because it is one. For a deeper look at security patterns in Claude Code workflows, see our smart contract auditing guide.
Rate Limiting
LND’s REST API does not impose rate limits by default, but hammering your own node with requests degrades performance. Add a simple in-memory rate limiter to the lndRequest function:
let lastRequestTime = 0;
const MIN_REQUEST_INTERVAL_MS = 200; // Max 5 requests per second
async function lndRequest(
endpoint: string,
options: LndRequestOptions = {}
): Promise<unknown> {
const now = Date.now();
const elapsed = now - lastRequestTime;
if (elapsed < MIN_REQUEST_INTERVAL_MS) {
await new Promise((resolve) =>
setTimeout(resolve, MIN_REQUEST_INTERVAL_MS - elapsed)
);
}
lastRequestTime = Date.now();
// ... rest of the function
}
Response Caching
Network stats and channel balances do not change every second. Cache responses with a short TTL to reduce load on your node:
const cache = new Map<string, { data: unknown; expires: number }>();
function getCached(key: string, ttlMs: number): unknown | null {
const entry = cache.get(key);
if (entry && entry.expires > Date.now()) return entry.data;
return null;
}
function setCache(key: string, data: unknown, ttlMs: number) {
cache.set(key, { data, expires: Date.now() + ttlMs });
}
Use a 30-second TTL for channel balances and a 5-minute TTL for network stats. Payment lists and invoice lookups should not be cached — they need to reflect the latest state.
Multi-Node Monitoring
If you run multiple Lightning nodes (common for routing node operators), extend the server to accept a node identifier parameter. Store connection configs for each node and route requests to the appropriate LND instance. The tool schemas stay the same. Just add an optional node_id parameter to each tool.
Error Handling
LND returns specific error codes for common issues. Map these to helpful messages:
- Status 503: Node is still syncing to the blockchain. Wait for sync to complete.
- Status 401: Macaroon is invalid or expired. Re-generate and update the environment variable.
- Connection refused: LND is not running or the REST port is wrong. Check
lnd.confforrestlisten.
Why Lightning for Merchant Payments
Lightning has grown well beyond a developer experiment into serious payment infrastructure. Merchants accepting Lightning get instant settlement with no 10-minute wait for on-chain confirmations and no chargeback risk. Fees are typically under 1 satoshi for payments under $100, making it cheaper than any credit card processor. The network has grown to over 15,000 nodes and 60,000+ channels in 2026, with total capacity exceeding 5,000 BTC.
For AI agents operating autonomously, Lightning is particularly compelling. An agent can make a micropayment of 100 satoshis ($0.07) without the $2-5 on-chain fee eating into the transaction. Lightning Labs built their AI agent toolkit for exactly this reason: instant settlement and programmatic payments fit naturally into autonomous commerce.
The monitoring server you built in this tutorial is the operations layer. Once your store accepts Lightning payments, you need visibility into channel health, payment flow, and routing performance. An MCP-connected Claude gives you that visibility through conversation rather than dashboards.
Accept Lightning Payments Today
Aurpay’s Lightning Network integration gives your store instant Bitcoin settlement with near-zero fees. No node management required. We handle the infrastructure. Enable Lightning payments.
