Nostr MCP Server
by AustinKelsay
Verified
import { z } from "zod";
import { decode } from "light-bolt11-decoder";
import * as nip19 from "nostr-tools/nip19";
import { SimplePool } from "nostr-tools/pool";
// Set a reasonable timeout for queries
export const QUERY_TIMEOUT = 8000;
// Define default relays
export const DEFAULT_RELAYS = [
"wss://relay.damus.io",
"wss://relay.nostr.band",
"wss://relay.primal.net",
"wss://nos.lol",
"wss://relay.current.fyi",
"wss://nostr.bitcoiner.social"
];
// Type definitions for Nostr
export interface NostrEvent {
id: string;
pubkey: string;
created_at: number;
kind: number;
tags: string[][];
content: string;
sig: string;
}
export interface NostrFilter {
ids?: string[];
authors?: string[];
kinds?: number[];
since?: number;
until?: number;
limit?: number;
[key: `#${string}`]: string[];
}
// Define event kinds
export const KINDS = {
Metadata: 0,
Text: 1,
ZapRequest: 9734,
ZapReceipt: 9735
};
// Zap-specific interfaces based on NIP-57
export interface ZapRequest {
kind: 9734;
content: string;
tags: string[][];
pubkey: string;
id: string;
sig: string;
created_at: number;
}
export interface ZapReceipt {
kind: 9735;
content: string;
tags: string[][];
pubkey: string;
id: string;
sig: string;
created_at: number;
}
export interface ZapRequestData {
pubkey: string;
content: string;
created_at: number;
id: string;
amount?: number;
relays?: string[];
event?: string;
lnurl?: string;
}
// Define a zap direction type for better code clarity
export type ZapDirection = 'sent' | 'received' | 'self' | 'unknown';
// Define a cached zap type that includes direction
export interface CachedZap extends ZapReceipt {
direction?: ZapDirection;
amountSats?: number;
targetPubkey?: string;
targetEvent?: string;
targetCoordinate?: string;
processedAt: number;
}
// Simple cache implementation for zap receipts
export class ZapCache {
private cache: Map<string, CachedZap> = new Map();
private maxSize: number;
private ttlMs: number;
constructor(maxSize = 1000, ttlMinutes = 10) {
this.maxSize = maxSize;
this.ttlMs = ttlMinutes * 60 * 1000;
}
add(zapReceipt: ZapReceipt, enrichedData?: Partial<CachedZap>): CachedZap {
// Create enriched zap with processing timestamp
const cachedZap: CachedZap = {
...zapReceipt,
...enrichedData,
processedAt: Date.now()
};
// Add to cache
this.cache.set(zapReceipt.id, cachedZap);
// Clean cache if it exceeds max size
if (this.cache.size > this.maxSize) {
this.cleanup();
}
return cachedZap;
}
get(id: string): CachedZap | undefined {
const cachedZap = this.cache.get(id);
// Return undefined if not found or expired
if (!cachedZap || Date.now() - cachedZap.processedAt > this.ttlMs) {
if (cachedZap) {
// Remove expired entry
this.cache.delete(id);
}
return undefined;
}
return cachedZap;
}
has(id: string): boolean {
return this.get(id) !== undefined;
}
cleanup(): void {
const now = Date.now();
// Remove expired entries
for (const [id, zap] of this.cache.entries()) {
if (now - zap.processedAt > this.ttlMs) {
this.cache.delete(id);
}
}
// If still too large, remove oldest entries
if (this.cache.size > this.maxSize) {
const sortedEntries = Array.from(this.cache.entries())
.sort((a, b) => a[1].processedAt - b[1].processedAt);
const entriesToRemove = sortedEntries.slice(0, sortedEntries.length - Math.floor(this.maxSize * 0.75));
for (const [id] of entriesToRemove) {
this.cache.delete(id);
}
}
}
clear(): void {
this.cache.clear();
}
size(): number {
return this.cache.size;
}
}
// Create a global cache instance
export const zapCache = new ZapCache();
// Helper function to get a fresh pool for each request
export function getFreshPool(): SimplePool {
return new SimplePool();
}
// Helper function to convert npub to hex
export function npubToHex(pubkey: string): string | null {
if (!pubkey) return null;
try {
// Clean up input
pubkey = pubkey.trim();
// Check if the input is already a hex key (case insensitive check, but return lowercase)
if (/^[0-9a-fA-F]{64}$/i.test(pubkey)) {
return pubkey.toLowerCase();
}
// Check if the input is an npub
if (pubkey.startsWith('npub1')) {
try {
const { type, data } = nip19.decode(pubkey);
if (type === 'npub') {
return data as string;
}
} catch (decodeError) {
console.error("Error decoding npub:", decodeError);
return null;
}
}
// Not a valid pubkey format
return null;
} catch (error) {
console.error("Error converting npub to hex:", error);
return null;
}
}
// Helper function to convert hex to npub
export function hexToNpub(hex: string): string | null {
if (!hex) return null;
try {
// Clean up input
hex = hex.trim();
// Check if the input is already an npub
if (hex.startsWith('npub1')) {
// Validate that it's a proper npub by trying to decode it
try {
const { type } = nip19.decode(hex);
if (type === 'npub') {
return hex;
}
} catch (e) {
// Not a valid npub
return null;
}
}
// Check if the input is a valid hex key (case insensitive, but convert to lowercase)
if (/^[0-9a-fA-F]{64}$/i.test(hex)) {
try {
return nip19.npubEncode(hex.toLowerCase());
} catch (encodeError) {
console.error("Error encoding hex to npub:", encodeError);
return null;
}
}
// Not a valid hex key
return null;
} catch (error) {
console.error("Error converting hex to npub:", error);
return null;
}
}
// Helper function to format public key for display
export function formatPubkey(pubkey: string, useShortFormat = false): string {
if (!pubkey) return "Unknown";
try {
// Clean up input
pubkey = pubkey.trim();
// Get npub representation if we have a hex key
let npub: string | null = null;
if (pubkey.startsWith('npub1')) {
// Validate that it's a proper npub
try {
const { type } = nip19.decode(pubkey);
if (type === 'npub') {
npub = pubkey;
}
} catch (e) {
// Not a valid npub, fall back to original
return pubkey;
}
} else if (/^[0-9a-fA-F]{64}$/i.test(pubkey)) {
// Convert hex to npub
npub = hexToNpub(pubkey);
}
// If we couldn't get a valid npub, return the original
if (!npub) {
return pubkey;
}
// Format according to preference
if (useShortFormat) {
// Short format: npub1abc...xyz
return `${npub.slice(0, 8)}...${npub.slice(-4)}`;
} else {
// Full format: npub1abc...xyz (hex)
const hex = npubToHex(npub);
if (hex) {
return `${npub} (${hex.slice(0, 6)}...${hex.slice(-6)})`;
} else {
return npub;
}
}
} catch (error) {
// Return original on error
console.error("Error formatting pubkey:", error);
return pubkey;
}
}
// Parse zap request data from description tag in zap receipt
export function parseZapRequestData(zapReceipt: NostrEvent): ZapRequestData | undefined {
try {
// Find the description tag which contains the zap request JSON
const descriptionTag = zapReceipt.tags.find(tag => tag[0] === "description" && tag.length > 1);
if (!descriptionTag || !descriptionTag[1]) {
return undefined;
}
// Parse the zap request JSON - this contains a serialized ZapRequest
const zapRequest: ZapRequest = JSON.parse(descriptionTag[1]);
// Convert to the ZapRequestData format
const zapRequestData: ZapRequestData = {
pubkey: zapRequest.pubkey,
content: zapRequest.content,
created_at: zapRequest.created_at,
id: zapRequest.id,
};
// Extract additional data from ZapRequest tags
zapRequest.tags.forEach(tag => {
if (tag[0] === 'amount' && tag[1]) {
zapRequestData.amount = parseInt(tag[1], 10);
} else if (tag[0] === 'relays' && tag.length > 1) {
zapRequestData.relays = tag.slice(1);
} else if (tag[0] === 'e' && tag[1]) {
zapRequestData.event = tag[1];
} else if (tag[0] === 'lnurl' && tag[1]) {
zapRequestData.lnurl = tag[1];
}
});
return zapRequestData;
} catch (error) {
console.error("Error parsing zap request data:", error);
return undefined;
}
}
// Helper function to extract and decode bolt11 invoice from a zap receipt
export function decodeBolt11FromZap(zapReceipt: NostrEvent): any | undefined {
try {
// Find the bolt11 tag
const bolt11Tag = zapReceipt.tags.find(tag => tag[0] === "bolt11" && tag.length > 1);
if (!bolt11Tag || !bolt11Tag[1]) {
return undefined;
}
// Decode the bolt11 invoice
const decodedInvoice = decode(bolt11Tag[1]);
return decodedInvoice;
} catch (error) {
console.error("Error decoding bolt11 invoice:", error);
return undefined;
}
}
// Extract amount in sats from decoded bolt11 invoice
export function getAmountFromDecodedInvoice(decodedInvoice: any): number | undefined {
try {
if (!decodedInvoice || !decodedInvoice.sections) {
return undefined;
}
// Find the amount section
const amountSection = decodedInvoice.sections.find((section: any) => section.name === "amount");
if (!amountSection) {
return undefined;
}
// Convert msats to sats
const amountMsats = amountSection.value;
const amountSats = Math.floor(amountMsats / 1000);
return amountSats;
} catch (error) {
console.error("Error extracting amount from decoded invoice:", error);
return undefined;
}
}
// Validate a zap receipt according to NIP-57 Appendix F
export function validateZapReceipt(zapReceipt: NostrEvent, zapRequest?: ZapRequest): { valid: boolean, reason?: string } {
try {
// 1. Must be kind 9735
if (zapReceipt.kind !== KINDS.ZapReceipt) {
return { valid: false, reason: "Not a zap receipt (kind 9735)" };
}
// 2. Must have a bolt11 tag
const bolt11Tag = zapReceipt.tags.find(tag => tag[0] === "bolt11" && tag.length > 1);
if (!bolt11Tag || !bolt11Tag[1]) {
return { valid: false, reason: "Missing bolt11 tag" };
}
// 3. Must have a description tag with the zap request
const descriptionTag = zapReceipt.tags.find(tag => tag[0] === "description" && tag.length > 1);
if (!descriptionTag || !descriptionTag[1]) {
return { valid: false, reason: "Missing description tag" };
}
// 4. Parse the zap request from the description tag if not provided
let parsedZapRequest: ZapRequest;
try {
parsedZapRequest = zapRequest || JSON.parse(descriptionTag[1]);
} catch (e) {
return { valid: false, reason: "Invalid zap request JSON in description tag" };
}
// 5. Validate the zap request structure
if (parsedZapRequest.kind !== KINDS.ZapRequest) {
return { valid: false, reason: "Invalid zap request kind" };
}
// 6. Check that the p tag from the zap request is included in the zap receipt
const requestedRecipientPubkey = parsedZapRequest.tags.find(tag => tag[0] === 'p' && tag.length > 1)?.[1];
const receiptRecipientTag = zapReceipt.tags.find(tag => tag[0] === 'p' && tag.length > 1);
if (!requestedRecipientPubkey || !receiptRecipientTag || receiptRecipientTag[1] !== requestedRecipientPubkey) {
return { valid: false, reason: "Recipient pubkey mismatch" };
}
// 7. Check for optional e tag consistency if present in the zap request
const requestEventTag = parsedZapRequest.tags.find(tag => tag[0] === 'e' && tag.length > 1);
if (requestEventTag) {
const receiptEventTag = zapReceipt.tags.find(tag => tag[0] === 'e' && tag.length > 1);
if (!receiptEventTag || receiptEventTag[1] !== requestEventTag[1]) {
return { valid: false, reason: "Event ID mismatch" };
}
}
// 8. Check for optional amount consistency
const amountTag = parsedZapRequest.tags.find(tag => tag[0] === 'amount' && tag.length > 1);
if (amountTag) {
// Decode the bolt11 invoice to verify the amount
const decodedInvoice = decodeBolt11FromZap(zapReceipt);
if (decodedInvoice) {
const invoiceAmountMsats = decodedInvoice.sections.find((s: any) => s.name === "amount")?.value;
const requestAmountMsats = parseInt(amountTag[1], 10);
if (invoiceAmountMsats && Math.abs(invoiceAmountMsats - requestAmountMsats) > 10) { // Allow small rounding differences
return { valid: false, reason: "Amount mismatch between request and invoice" };
}
}
}
return { valid: true };
} catch (error) {
return { valid: false, reason: `Validation error: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Determine the direction of a zap relative to a pubkey
export function determineZapDirection(zapReceipt: ZapReceipt, pubkey: string): ZapDirection {
try {
// Check if received via lowercase 'p' tag (recipient)
const isReceived = zapReceipt.tags.some(tag => tag[0] === 'p' && tag[1] === pubkey);
// Check if sent via uppercase 'P' tag (sender, per NIP-57)
let isSent = zapReceipt.tags.some(tag => tag[0] === 'P' && tag[1] === pubkey);
if (!isSent) {
// Fallback: check description tag for the sender pubkey
const descriptionTag = zapReceipt.tags.find(tag => tag[0] === "description" && tag.length > 1);
if (descriptionTag && descriptionTag[1]) {
try {
const zapRequest: ZapRequest = JSON.parse(descriptionTag[1]);
isSent = zapRequest && zapRequest.pubkey === pubkey;
} catch (e) {
// Ignore parsing errors
}
}
}
// Determine direction
if (isSent && isReceived) {
return 'self';
} else if (isSent) {
return 'sent';
} else if (isReceived) {
return 'received';
} else {
return 'unknown';
}
} catch (error) {
console.error("Error determining zap direction:", error);
return 'unknown';
}
}
// Process a zap receipt into an enriched cached zap
export function processZapReceipt(zapReceipt: ZapReceipt, pubkey: string): CachedZap {
// Check if we already have this zap in the cache
const existingCachedZap = zapCache.get(zapReceipt.id);
if (existingCachedZap) {
return existingCachedZap;
}
try {
// Determine direction relative to the specified pubkey
const direction = determineZapDirection(zapReceipt, pubkey);
// Extract target pubkey (recipient)
const targetPubkey = zapReceipt.tags.find(tag => tag[0] === 'p' && tag.length > 1)?.[1];
// Extract target event if any
const targetEvent = zapReceipt.tags.find(tag => tag[0] === 'e' && tag.length > 1)?.[1];
// Extract target coordinate if any (a tag)
const targetCoordinate = zapReceipt.tags.find(tag => tag[0] === 'a' && tag.length > 1)?.[1];
// Parse zap request to get additional data
const zapRequestData = parseZapRequestData(zapReceipt);
// Decode bolt11 invoice to get amount
const decodedInvoice = decodeBolt11FromZap(zapReceipt);
const amountSats = decodedInvoice ?
getAmountFromDecodedInvoice(decodedInvoice) :
(zapRequestData?.amount ? Math.floor(zapRequestData.amount / 1000) : undefined);
// Create enriched zap and add to cache
return zapCache.add(zapReceipt, {
direction,
amountSats,
targetPubkey,
targetEvent,
targetCoordinate
});
} catch (error) {
console.error("Error processing zap receipt:", error);
// Still cache the basic zap with unknown direction
return zapCache.add(zapReceipt, { direction: 'unknown' });
}
}
// Helper function to format zap receipt with enhanced information
export function formatZapReceipt(zap: NostrEvent, pubkeyContext?: string): string {
if (!zap) return "";
try {
// Cast to ZapReceipt for better type safety since we know we're dealing with kind 9735
const zapReceipt = zap as ZapReceipt;
// Process the zap receipt with context if provided
let enrichedZap: CachedZap;
if (pubkeyContext) {
enrichedZap = processZapReceipt(zapReceipt, pubkeyContext);
} else {
// Check if it's already in cache
const cachedZap = zapCache.get(zapReceipt.id);
if (cachedZap) {
enrichedZap = cachedZap;
} else {
// Process without context - won't have direction information
enrichedZap = {
...zapReceipt,
processedAt: Date.now()
};
}
}
// Get basic zap info
const created = new Date(zapReceipt.created_at * 1000).toLocaleString();
// Get sender information from P tag or description
let sender = "Unknown";
let senderPubkey: string | undefined;
const senderPTag = zapReceipt.tags.find(tag => tag[0] === 'P' && tag.length > 1);
if (senderPTag && senderPTag[1]) {
senderPubkey = senderPTag[1];
const npub = hexToNpub(senderPubkey);
sender = npub ? `${npub.slice(0, 8)}...${npub.slice(-4)}` : `${senderPubkey.slice(0, 8)}...${senderPubkey.slice(-8)}`;
} else {
// Try to get from description
const zapRequestData = parseZapRequestData(zapReceipt);
if (zapRequestData?.pubkey) {
senderPubkey = zapRequestData.pubkey;
const npub = hexToNpub(senderPubkey);
sender = npub ? `${npub.slice(0, 8)}...${npub.slice(-4)}` : `${senderPubkey.slice(0, 8)}...${senderPubkey.slice(-8)}`;
}
}
// Get recipient information
const recipient = zapReceipt.tags.find(tag => tag[0] === 'p' && tag.length > 1)?.[1];
let formattedRecipient = "Unknown";
if (recipient) {
const npub = hexToNpub(recipient);
formattedRecipient = npub ? `${npub.slice(0, 8)}...${npub.slice(-4)}` : `${recipient.slice(0, 8)}...${recipient.slice(-8)}`;
}
// Get amount
let amount: string = enrichedZap.amountSats !== undefined ?
`${enrichedZap.amountSats} sats` :
"Unknown";
// Get comment
let comment = "No comment";
const zapRequestData = parseZapRequestData(zapReceipt);
if (zapRequestData?.content) {
comment = zapRequestData.content;
}
// Check if this zap is for a specific event or coordinate
let zapTarget = "User";
let targetId = "";
if (enrichedZap.targetEvent) {
zapTarget = "Event";
targetId = enrichedZap.targetEvent;
} else if (enrichedZap.targetCoordinate) {
zapTarget = "Replaceable Event";
targetId = enrichedZap.targetCoordinate;
}
// Format the output with all available information
const lines = [
`From: ${sender}`,
`To: ${formattedRecipient}`,
`Amount: ${amount}`,
`Created: ${created}`,
`Target: ${zapTarget}${targetId ? ` (${targetId.slice(0, 8)}...)` : ''}`,
`Comment: ${comment}`,
];
// Add payment preimage if available
const preimageTag = zapReceipt.tags.find(tag => tag[0] === "preimage" && tag.length > 1);
if (preimageTag && preimageTag[1]) {
lines.push(`Preimage: ${preimageTag[1].slice(0, 10)}...`);
}
// Add payment hash if available in bolt11 invoice
const decodedInvoice = decodeBolt11FromZap(zapReceipt);
if (decodedInvoice) {
const paymentHashSection = decodedInvoice.sections.find((section: any) => section.name === "payment_hash");
if (paymentHashSection) {
lines.push(`Payment Hash: ${paymentHashSection.value.slice(0, 10)}...`);
}
}
// Add direction information if available
if (enrichedZap.direction && enrichedZap.direction !== 'unknown') {
const directionLabels = {
'sent': '↑ SENT',
'received': '↓ RECEIVED',
'self': '↻ SELF ZAP'
};
lines.unshift(`[${directionLabels[enrichedZap.direction]}]`);
}
lines.push('---');
return lines.join("\n");
} catch (error) {
console.error("Error formatting zap receipt:", error);
return "Error formatting zap receipt";
}
}
// Export the tool configurations
export const getReceivedZapsToolConfig = {
pubkey: z.string().describe("Public key of the Nostr user (hex format or npub format)"),
limit: z.number().min(1).max(100).default(10).describe("Maximum number of zaps to fetch"),
relays: z.array(z.string()).optional().describe("Optional list of relays to query"),
validateReceipts: z.boolean().default(true).describe("Whether to validate zap receipts according to NIP-57"),
debug: z.boolean().default(false).describe("Enable verbose debug logging"),
};
export const getSentZapsToolConfig = {
pubkey: z.string().describe("Public key of the Nostr user (hex format or npub format)"),
limit: z.number().min(1).max(100).default(10).describe("Maximum number of zaps to fetch"),
relays: z.array(z.string()).optional().describe("Optional list of relays to query"),
validateReceipts: z.boolean().default(true).describe("Whether to validate zap receipts according to NIP-57"),
debug: z.boolean().default(false).describe("Enable verbose debug logging"),
};
export const getAllZapsToolConfig = {
pubkey: z.string().describe("Public key of the Nostr user (hex format or npub format)"),
limit: z.number().min(1).max(100).default(20).describe("Maximum number of total zaps to fetch"),
relays: z.array(z.string()).optional().describe("Optional list of relays to query"),
validateReceipts: z.boolean().default(true).describe("Whether to validate zap receipts according to NIP-57"),
debug: z.boolean().default(false).describe("Enable verbose debug logging"),
};