nostr-mcp-server 2.0.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.
Files changed (36) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +498 -0
  3. package/build/__tests__/basic.test.js +87 -0
  4. package/build/__tests__/error-handling.test.js +145 -0
  5. package/build/__tests__/format-conversion.test.js +137 -0
  6. package/build/__tests__/integration.test.js +163 -0
  7. package/build/__tests__/mocks.js +109 -0
  8. package/build/__tests__/nip19-conversion.test.js +268 -0
  9. package/build/__tests__/nips-search.test.js +109 -0
  10. package/build/__tests__/note-creation.test.js +148 -0
  11. package/build/__tests__/note-tools-functions.test.js +173 -0
  12. package/build/__tests__/note-tools-unit.test.js +97 -0
  13. package/build/__tests__/profile-notes-simple.test.js +78 -0
  14. package/build/__tests__/profile-postnote.test.js +120 -0
  15. package/build/__tests__/profile-tools.test.js +90 -0
  16. package/build/__tests__/relay-specification.test.js +136 -0
  17. package/build/__tests__/search-nips-simple.test.js +96 -0
  18. package/build/__tests__/websocket-integration.test.js +257 -0
  19. package/build/__tests__/zap-tools-simple.test.js +72 -0
  20. package/build/__tests__/zap-tools-tests.test.js +197 -0
  21. package/build/index.js +1285 -0
  22. package/build/nips/nips-tools.js +567 -0
  23. package/build/nips-tools.js +421 -0
  24. package/build/note/note-tools.js +296 -0
  25. package/build/note-tools.js +53 -0
  26. package/build/profile/profile-tools.js +260 -0
  27. package/build/utils/constants.js +27 -0
  28. package/build/utils/conversion.js +332 -0
  29. package/build/utils/ephemeral-relay.js +438 -0
  30. package/build/utils/formatting.js +34 -0
  31. package/build/utils/index.js +6 -0
  32. package/build/utils/nip19-tools.js +117 -0
  33. package/build/utils/pool.js +55 -0
  34. package/build/zap/zap-tools.js +980 -0
  35. package/build/zap-tools.js +989 -0
  36. package/package.json +59 -0
@@ -0,0 +1,421 @@
1
+ import { z } from "zod";
2
+ import fetch from "node-fetch";
3
+ import * as fs from 'fs';
4
+ import * as path from 'path';
5
+ // Cache configuration
6
+ const CACHE_TTL = 1000 * 60 * 60 * 24; // 24 hours
7
+ const CACHE_FILE = path.join(process.cwd(), 'nips-cache.json');
8
+ let nipsCache = [];
9
+ let lastFetchTime = 0;
10
+ let searchIndex = {
11
+ titleIndex: new Map(),
12
+ descriptionIndex: new Map(),
13
+ contentIndex: new Map(),
14
+ numberIndex: new Map(),
15
+ kindIndex: new Map(),
16
+ tagIndex: new Map()
17
+ };
18
+ // Load cache from disk on startup
19
+ function loadCacheFromDisk() {
20
+ try {
21
+ if (fs.existsSync(CACHE_FILE)) {
22
+ const cacheData = fs.readFileSync(CACHE_FILE, 'utf8');
23
+ const cacheObj = JSON.parse(cacheData);
24
+ if (cacheObj && Array.isArray(cacheObj.nips) && typeof cacheObj.timestamp === 'number') {
25
+ nipsCache = cacheObj.nips;
26
+ lastFetchTime = cacheObj.timestamp;
27
+ // Check if cache is fresh enough
28
+ if (Date.now() - lastFetchTime < CACHE_TTL) {
29
+ console.log(`Loaded ${nipsCache.length} NIPs from cache file`);
30
+ buildSearchIndex();
31
+ return true;
32
+ }
33
+ else {
34
+ console.log('Cache file exists but is expired');
35
+ // We'll still use it temporarily but will refresh
36
+ buildSearchIndex();
37
+ return false;
38
+ }
39
+ }
40
+ }
41
+ return false;
42
+ }
43
+ catch (error) {
44
+ console.error('Error loading cache from disk:', error);
45
+ return false;
46
+ }
47
+ }
48
+ // Save cache to disk
49
+ function saveCacheToDisk() {
50
+ try {
51
+ const cacheObj = {
52
+ nips: nipsCache,
53
+ timestamp: lastFetchTime
54
+ };
55
+ fs.writeFileSync(CACHE_FILE, JSON.stringify(cacheObj, null, 2), 'utf8');
56
+ console.log(`Saved ${nipsCache.length} NIPs to cache file`);
57
+ }
58
+ catch (error) {
59
+ console.error('Error saving cache to disk:', error);
60
+ }
61
+ }
62
+ // Build search index from nips cache
63
+ function buildSearchIndex() {
64
+ // Reset indexes
65
+ searchIndex = {
66
+ titleIndex: new Map(),
67
+ descriptionIndex: new Map(),
68
+ contentIndex: new Map(),
69
+ numberIndex: new Map(),
70
+ kindIndex: new Map(),
71
+ tagIndex: new Map()
72
+ };
73
+ for (const nip of nipsCache) {
74
+ // Index NIP number
75
+ searchIndex.numberIndex.set(nip.number.toString(), nip.number);
76
+ // Index title words
77
+ const titleWords = nip.title.toLowerCase().split(/\W+/).filter(word => word.length > 0);
78
+ for (const word of titleWords) {
79
+ if (!searchIndex.titleIndex.has(word)) {
80
+ searchIndex.titleIndex.set(word, new Set());
81
+ }
82
+ searchIndex.titleIndex.get(word)?.add(nip.number);
83
+ }
84
+ // Index description words
85
+ const descWords = nip.description.toLowerCase().split(/\W+/).filter(word => word.length > 0);
86
+ for (const word of descWords) {
87
+ if (!searchIndex.descriptionIndex.has(word)) {
88
+ searchIndex.descriptionIndex.set(word, new Set());
89
+ }
90
+ searchIndex.descriptionIndex.get(word)?.add(nip.number);
91
+ }
92
+ // Index content (more selective to save memory)
93
+ const contentWords = new Set(nip.content.toLowerCase()
94
+ .split(/\W+/)
95
+ .filter(word => word.length > 3) // Only index words longer than 3 chars
96
+ );
97
+ for (const word of contentWords) {
98
+ if (!searchIndex.contentIndex.has(word)) {
99
+ searchIndex.contentIndex.set(word, new Set());
100
+ }
101
+ searchIndex.contentIndex.get(word)?.add(nip.number);
102
+ }
103
+ // Index kind
104
+ if (nip.kind !== undefined) {
105
+ if (!searchIndex.kindIndex.has(nip.kind)) {
106
+ searchIndex.kindIndex.set(nip.kind, new Set());
107
+ }
108
+ searchIndex.kindIndex.get(nip.kind)?.add(nip.number);
109
+ }
110
+ // Index tags
111
+ if (nip.tags) {
112
+ for (const tag of nip.tags) {
113
+ const normalizedTag = tag.toLowerCase().trim();
114
+ if (!searchIndex.tagIndex.has(normalizedTag)) {
115
+ searchIndex.tagIndex.set(normalizedTag, new Set());
116
+ }
117
+ searchIndex.tagIndex.get(normalizedTag)?.add(nip.number);
118
+ }
119
+ }
120
+ }
121
+ console.log('Built search index for NIPs');
122
+ }
123
+ // Function to fetch NIPs from GitHub with retries
124
+ async function fetchNipsFromGitHub(retries = 3) {
125
+ for (let attempt = 1; attempt <= retries; attempt++) {
126
+ try {
127
+ console.log(`Fetching NIPs from GitHub (attempt ${attempt}/${retries})`);
128
+ // Fetch the NIPs directory listing
129
+ const headers = {};
130
+ // Use conditional request if we already have data
131
+ if (nipsCache.length > 0) {
132
+ headers['If-Modified-Since'] = new Date(lastFetchTime).toUTCString();
133
+ }
134
+ const response = await fetch('https://api.github.com/repos/nostr-protocol/nips/contents', { headers });
135
+ // If not modified, use cache
136
+ if (response.status === 304) {
137
+ console.log('NIPs not modified since last fetch, using cache');
138
+ return nipsCache;
139
+ }
140
+ if (!response.ok)
141
+ throw new Error(`GitHub API error: ${response.statusText}`);
142
+ const files = await response.json();
143
+ // Filter for NIP markdown files
144
+ const nipFiles = files.filter((file) => file.name.match(/^\d+\.md$/) ||
145
+ file.name.match(/^[0-9A-Fa-f]+\.md$/));
146
+ console.log(`Found ${nipFiles.length} NIP files to process`);
147
+ // Process files in batches to avoid overwhelming the GitHub API
148
+ const batchSize = 5;
149
+ const nips = [];
150
+ for (let i = 0; i < nipFiles.length; i += batchSize) {
151
+ const batch = nipFiles.slice(i, i + batchSize);
152
+ const batchPromises = batch.map(file => fetchNipFile(file));
153
+ const batchResults = await Promise.all(batchPromises);
154
+ const validNips = batchResults.filter((nip) => nip !== null);
155
+ nips.push(...validNips);
156
+ // Add a small delay between batches to avoid rate limiting
157
+ if (i + batchSize < nipFiles.length) {
158
+ await new Promise(resolve => setTimeout(resolve, 300));
159
+ }
160
+ }
161
+ console.log(`Successfully processed ${nips.length} NIPs`);
162
+ return nips;
163
+ }
164
+ catch (error) {
165
+ console.error(`Error fetching NIPs from GitHub (attempt ${attempt}/${retries}):`, error);
166
+ if (attempt === retries) {
167
+ // On final retry failure, return cache if available or empty array
168
+ console.warn('All GitHub fetch attempts failed, using cached data if available');
169
+ return nipsCache.length > 0 ? nipsCache : [];
170
+ }
171
+ // Wait before retrying (with increasing backoff)
172
+ await new Promise(resolve => setTimeout(resolve, attempt * 1000));
173
+ }
174
+ }
175
+ return [];
176
+ }
177
+ // Helper to fetch a single NIP file
178
+ async function fetchNipFile(file) {
179
+ try {
180
+ const contentResponse = await fetch(file.download_url);
181
+ if (!contentResponse.ok) {
182
+ console.warn(`Failed to fetch ${file.name}: ${contentResponse.statusText}`);
183
+ return null;
184
+ }
185
+ const content = await contentResponse.text();
186
+ const numberMatch = file.name.match(/^(\d+|[0-9A-Fa-f]+)\.md$/);
187
+ if (!numberMatch)
188
+ return null;
189
+ const numberStr = numberMatch[1];
190
+ const number = numberStr.match(/^[0-9A-Fa-f]+$/) ?
191
+ parseInt(numberStr, 16) :
192
+ parseInt(numberStr, 10);
193
+ const lines = content.split('\n');
194
+ const title = lines[0].replace(/^#\s*/, '').trim();
195
+ const description = lines[1]?.trim() || `NIP-${number} description`;
196
+ const statusMatch = content.match(/Status:\s*(draft|final|deprecated)/i);
197
+ const status = statusMatch ? statusMatch[1].toLowerCase() : "draft";
198
+ const kindMatch = content.match(/Kind:\s*(\d+)/i);
199
+ const kind = kindMatch ? parseInt(kindMatch[1], 10) : undefined;
200
+ const tags = [];
201
+ const tagMatches = content.matchAll(/Tags:\s*([^\n]+)/gi);
202
+ for (const match of tagMatches) {
203
+ tags.push(...match[1].split(',').map((tag) => tag.trim()));
204
+ }
205
+ return {
206
+ number,
207
+ title,
208
+ description,
209
+ status,
210
+ kind,
211
+ tags: tags.length > 0 ? tags : undefined,
212
+ content
213
+ };
214
+ }
215
+ catch (error) {
216
+ console.error(`Error processing NIP ${file.name}:`, error);
217
+ return null;
218
+ }
219
+ }
220
+ // Function to get NIPs with improved caching
221
+ async function getNips(forceRefresh = false) {
222
+ const now = Date.now();
223
+ // First attempt to load from memory cache
224
+ if (!forceRefresh && nipsCache.length > 0 && now - lastFetchTime < CACHE_TTL) {
225
+ return nipsCache;
226
+ }
227
+ // If no memory cache, try loading from disk
228
+ if (!forceRefresh && nipsCache.length === 0) {
229
+ const loaded = loadCacheFromDisk();
230
+ if (loaded && now - lastFetchTime < CACHE_TTL) {
231
+ return nipsCache;
232
+ }
233
+ }
234
+ // Fetch fresh data if needed
235
+ try {
236
+ const nips = await fetchNipsFromGitHub();
237
+ // Only update cache if we got new data
238
+ if (nips.length > 0) {
239
+ nipsCache = nips;
240
+ lastFetchTime = now;
241
+ // Save to disk and build search index
242
+ saveCacheToDisk();
243
+ buildSearchIndex();
244
+ }
245
+ return nipsCache;
246
+ }
247
+ catch (error) {
248
+ console.error("Error refreshing NIPs:", error);
249
+ // If we already have cached data, use it even if expired
250
+ if (nipsCache.length > 0) {
251
+ console.warn("Using expired cache due to fetch error");
252
+ return nipsCache;
253
+ }
254
+ // Last resort - try to load from disk regardless of timestamp
255
+ if (loadCacheFromDisk()) {
256
+ return nipsCache;
257
+ }
258
+ // No options left
259
+ return [];
260
+ }
261
+ }
262
+ // Helper function to calculate relevance score using the search index
263
+ function calculateRelevance(nip, searchTerms) {
264
+ const matchedTerms = [];
265
+ let score = 0;
266
+ // Convert search terms to lowercase for case-insensitive matching
267
+ const lowerSearchTerms = searchTerms.map(term => term.toLowerCase());
268
+ for (const term of lowerSearchTerms) {
269
+ // Check for exact NIP number match (highest priority)
270
+ if (nip.number.toString() === term) {
271
+ score += 10;
272
+ matchedTerms.push(term);
273
+ continue;
274
+ }
275
+ // Check title matches (high weight)
276
+ if (searchIndex.titleIndex.has(term) &&
277
+ searchIndex.titleIndex.get(term)?.has(nip.number)) {
278
+ score += 3;
279
+ matchedTerms.push(term);
280
+ }
281
+ // Check description matches (medium weight)
282
+ if (searchIndex.descriptionIndex.has(term) &&
283
+ searchIndex.descriptionIndex.get(term)?.has(nip.number)) {
284
+ score += 2;
285
+ if (!matchedTerms.includes(term)) {
286
+ matchedTerms.push(term);
287
+ }
288
+ }
289
+ // Check content matches (lower weight)
290
+ if (searchIndex.contentIndex.has(term) &&
291
+ searchIndex.contentIndex.get(term)?.has(nip.number)) {
292
+ score += 1;
293
+ if (!matchedTerms.includes(term)) {
294
+ matchedTerms.push(term);
295
+ }
296
+ }
297
+ // Check kind match
298
+ if (nip.kind !== undefined && nip.kind.toString() === term) {
299
+ score += 4;
300
+ if (!matchedTerms.includes(term)) {
301
+ matchedTerms.push(term);
302
+ }
303
+ }
304
+ // Check tag matches
305
+ if (nip.tags && nip.tags.some(tag => tag.toLowerCase() === term)) {
306
+ score += 3;
307
+ if (!matchedTerms.includes(term)) {
308
+ matchedTerms.push(term);
309
+ }
310
+ }
311
+ // Partial matches in title (very important)
312
+ if (nip.title.toLowerCase().includes(term)) {
313
+ score += 2;
314
+ if (!matchedTerms.includes(term)) {
315
+ matchedTerms.push(term);
316
+ }
317
+ }
318
+ }
319
+ return { score, matchedTerms };
320
+ }
321
+ // Improved search function
322
+ export async function searchNips(query, limit = 10) {
323
+ // Ensure we have NIPs data and the search index is built
324
+ const nips = await getNips();
325
+ if (nips.length === 0) {
326
+ console.error("No NIPs available for search");
327
+ return [];
328
+ }
329
+ // Handle direct NIP number search as a special case
330
+ const nipNumberMatch = query.match(/^(?:NIP-?)?(\d+)$/i);
331
+ if (nipNumberMatch) {
332
+ const nipNumber = parseInt(nipNumberMatch[1], 10);
333
+ const directNip = nips.find(nip => nip.number === nipNumber);
334
+ if (directNip) {
335
+ return [{
336
+ nip: directNip,
337
+ relevance: 100,
338
+ matchedTerms: [nipNumber.toString()]
339
+ }];
340
+ }
341
+ }
342
+ // Split query into terms and filter out empty strings
343
+ const searchTerms = query.split(/\s+/).filter(term => term.length > 0);
344
+ // Search through all NIPs
345
+ const results = nips.map(nip => {
346
+ const { score, matchedTerms } = calculateRelevance(nip, searchTerms);
347
+ return {
348
+ nip,
349
+ relevance: score,
350
+ matchedTerms
351
+ };
352
+ })
353
+ // Filter out results with no matches
354
+ .filter(result => result.relevance > 0)
355
+ // Sort by relevance (highest first)
356
+ .sort((a, b) => b.relevance - a.relevance)
357
+ // Limit results
358
+ .slice(0, limit);
359
+ return results;
360
+ }
361
+ // Improved function to get a specific NIP by number
362
+ export async function getNipByNumber(number) {
363
+ const nips = await getNips();
364
+ const nipNumber = typeof number === 'string' ? parseInt(number, 10) : number;
365
+ return nips.find(nip => nip.number === nipNumber);
366
+ }
367
+ // Improved function to get NIPs by kind
368
+ export async function getNipsByKind(kind) {
369
+ const nips = await getNips();
370
+ return nips.filter(nip => nip.kind === kind);
371
+ }
372
+ // Improved function to get NIPs by status
373
+ export async function getNipsByStatus(status) {
374
+ const nips = await getNips();
375
+ return nips.filter(nip => nip.status === status);
376
+ }
377
+ // Force a refresh of the NIPs cache
378
+ export async function refreshNipsCache() {
379
+ try {
380
+ await getNips(true);
381
+ return true;
382
+ }
383
+ catch (error) {
384
+ console.error("Error refreshing NIPs cache:", error);
385
+ return false;
386
+ }
387
+ }
388
+ // Export schema for the search tool
389
+ export const searchNipsSchema = z.object({
390
+ query: z.string().describe("Search query to find relevant NIPs"),
391
+ limit: z.number().min(1).max(50).default(10).describe("Maximum number of results to return"),
392
+ includeContent: z.boolean().default(false).describe("Whether to include the full content of each NIP in the results"),
393
+ });
394
+ // Format a NIP search result with cleaner output
395
+ export function formatNipResult(result, includeContent = false) {
396
+ const { nip, relevance, matchedTerms } = result;
397
+ const lines = [
398
+ `NIP-${nip.number}: ${nip.title}`,
399
+ `Status: ${nip.status}`,
400
+ nip.kind ? `Kind: ${nip.kind}` : null,
401
+ `Description: ${nip.description}`,
402
+ `Relevance Score: ${relevance}`,
403
+ matchedTerms.length > 0 ? `Matched Terms: ${matchedTerms.join(", ")}` : null,
404
+ ].filter(Boolean);
405
+ if (includeContent) {
406
+ lines.push("", "Content:", nip.content);
407
+ }
408
+ lines.push("---");
409
+ return lines.join("\n");
410
+ }
411
+ // Initialize by loading cache on module import
412
+ (async () => {
413
+ // Try to load from disk first
414
+ if (!loadCacheFromDisk()) {
415
+ // If disk cache not available or expired, fetch in background
416
+ console.log('Fetching NIPs in background...');
417
+ getNips().catch(error => {
418
+ console.error('Error initializing NIPs cache:', error);
419
+ });
420
+ }
421
+ })();