initial commit

This commit is contained in:
pablof7z
2025-03-29 09:34:43 +00:00
commit 6d0e5a8e79
38 changed files with 2934 additions and 0 deletions

100
logic/create-pubkey.ts Normal file
View File

@@ -0,0 +1,100 @@
import NDK, { NDKPrivateKeySigner, NDKUser, NDKEvent } from "@nostr-dev-kit/ndk";
import { z } from "zod";
import { getUser, saveUser } from "../config.js";
import { log } from "../lib/utils/log.js";
import { ndk } from "../ndk.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
export async function createPubkey({
username,
display_name,
about,
}: {
username: string;
display_name: string;
about?: string;
}) {
try {
// Check if username already exists
const existingUser = getUser(username);
if (existingUser) {
throw new Error(`Username "${username}" already exists`);
}
// Generate a new keypair for this user
const signer = NDKPrivateKeySigner.generate();
// Create profile metadata event (kind 0)
const profileContent = JSON.stringify({
display_name,
name: display_name,
about: about || "",
});
// Create the event
const event = new NDKEvent(ndk, {
kind: 0,
content: profileContent,
tags: [],
});
// Sign with the new signer
await event.sign(signer);
// Publish the event
await event.publish();
// Save the user to the config
saveUser(username, {
nsec: signer.privateKey,
display_name,
about: about || "",
});
// Create user object for return
const user = ndk.getUser({ pubkey: signer.pubkey });
// Return success message
log(`Created pubkey for ${username} with public key ${signer.pubkey}`);
return {
user,
message: `Created pubkey for ${username} with npub ${user.npub}`,
};
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : String(error);
throw new Error(`Failed to create pubkey: ${errorMessage}`);
}
}
export function addCreatePubkeyCommand(server: McpServer) {
server.tool(
"create_pubkey",
"Create a new keypair and save it to config",
{
username: z.string().describe("Username to create"),
display_name: z.string().describe("Display name for the pubkey"),
about: z
.string()
.optional()
.describe("About information for this pubkey"),
},
async ({ username, display_name, about }) => {
const result = await createPubkey({
username,
display_name,
about,
});
return {
content: [
{
type: "text",
text: result.message,
},
],
};
}
);
}

95
logic/find_snippets.ts Normal file
View File

@@ -0,0 +1,95 @@
import { z } from "zod";
import {
formatPartialMatches,
formatSnippets,
getSnippets,
} from "../lib/nostr/snippets.js";
import type { FindSnippetsParams } from "../lib/types/index.js";
import { log } from "../lib/utils/log.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
/**
* Find code snippets with optional filtering
* @param params Search parameters for filtering snippets
* @returns Formatted snippets that match the criteria
*/
export async function findSnippets({
since,
until,
authors,
languages,
tags,
}: FindSnippetsParams) {
try {
const { snippets, otherSnippets } = await getSnippets({
limit: 500, // Get more snippets to find the max matches
since,
until,
authors,
languages,
tags,
});
if (snippets.length === 0) {
return {
content: [
{
type: "text" as const,
text: "No code snippets found matching the criteria.",
},
],
};
}
// Format snippets for display
const formattedSnippets = formatSnippets(snippets);
const partialMatchesText = formatPartialMatches(otherSnippets);
return {
content: [
{
type: "text" as const,
text: `Found ${snippets.length} code snippets:\n\n${formattedSnippets}${partialMatchesText}`,
},
],
};
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : String(error);
throw new Error(`Failed to find snippets: ${errorMessage}`);
}
}
export function addFindSnippetsCommand(server: McpServer) {
server.tool(
"find_snippets",
"Find code snippets with optional filtering by author, language, and tags",
{
since: z
.number()
.optional()
.describe("Unix timestamp to fetch snippets from"),
until: z
.number()
.optional()
.describe("Unix timestamp to fetch snippets until"),
authors: z
.array(z.string())
.optional()
.describe(
"List of author names to filter by (in username format!)"
),
languages: z
.array(z.string())
.optional()
.describe("List of programming languages to filter by"),
tags: z
.array(z.string())
.optional()
.describe(
"List of tags to filter by, be exhaustive, e.g. [ 'ndk', 'nostr', 'pubkey', 'signer' ]"
),
},
async (args) => findSnippets(args)
);
}

77
logic/find_user.ts Normal file
View File

@@ -0,0 +1,77 @@
import { z } from "zod";
import { ndk } from "../ndk.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { knownUsers } from "../users.js";
import { queryUser } from "../users.js";
/**
* Find a user by name, npub, or other profile information
* @param query The search query to find a user
* @returns Results with formatted user information
*/
export async function findUser(query: string) {
try {
const pubkeys = queryUser(query);
if (pubkeys.length === 0) {
return {
content: [
{
type: "text" as const,
text: "No users found matching the query.",
},
],
};
}
// Format the found users for display
const formattedUsers = pubkeys.map(formatUser).join("\n\n---\n\n");
return {
content: [
{
type: "text" as const,
text: `Found ${pubkeys.length} users:\n\n${formattedUsers}`,
},
],
};
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : String(error);
throw new Error(`Failed to find user: ${errorMessage}`);
}
}
export function addFindUserCommand(server: McpServer) {
server.tool(
"find_user",
"Find a user by name, npub, or other profile information",
{
query: z.string().describe("The search query to find a user"),
},
async ({ query }) => {
return findUser(query);
}
);
}
/**
* Format user profile data for display
* @param pubkey User public key
* @returns Formatted string representation
*/
function formatUser(pubkey: string): string {
const profile = knownUsers[pubkey]?.profile;
const user = ndk.getUser({ pubkey });
const keys: Record<string, string> = {
Npub: user.npub,
};
if (profile?.name) keys.Name = profile.name;
if (profile?.about) keys.About = profile.about;
if (profile?.picture) keys.Picture = profile.picture;
return Object.entries(keys)
.map(([key, value]) => `${key}: ${value}`)
.join("\n");
}

68
logic/list_usernames.ts Normal file
View File

@@ -0,0 +1,68 @@
import { z } from "zod";
import { ndk } from "../ndk.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { getAllUsers } from "../config.js";
import { NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
/**
* List all available usernames in the system
* @returns Formatted list of available usernames
*/
export async function listUsernames() {
try {
const users = await Promise.all(
Object.values(getAllUsers()).map(async (user) => {
const signer = new NDKPrivateKeySigner(user.nsec);
const npub = (await signer.user()).npub;
return {
name: user.display_name,
npub: npub,
};
})
);
if (users.length === 0) {
return {
content: [
{
type: "text" as const,
text: "No users found. Try creating a user with the create_pubkey command first.",
},
],
};
}
const usersList = users
.map((user) => `${user.name} - ${user.npub}`)
.join("\n");
return {
content: [
{
type: "text" as const,
text: `Available users (${users.length}):\n${usersList}`,
},
],
};
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : String(error);
throw new Error(`Failed to list usernames: ${errorMessage}`);
}
}
export function addListUsernamesCommand(server: McpServer) {
server.tool(
"list_usernames",
"List all available usernames in the system",
{
random_string: z
.string()
.optional()
.describe("Dummy parameter for no-parameter tools"),
},
async () => {
return listUsernames();
}
);
}

View File

@@ -0,0 +1,253 @@
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { z } from "zod";
import { SNIPPET_KIND, getSigner } from "../lib/nostr/utils.js";
import { ndk } from "../ndk.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { writeFileSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { homedir, tmpdir } from "node:os";
import { readConfig, getUser } from "../config.js";
import { existsSync } from "node:fs";
import * as Bun from "bun";
function log(message: string, ...args: any[]) {
// append to ~/.nmcp-nostr.log
const logFilePath = join(homedir(), ".nmcp-nostr.log");
const logMessage = `${new Date().toISOString()} - ${message}\n`;
writeFileSync(logFilePath, logMessage, { flag: "a" });
}
/**
* Parse metadata from the beginning of a file
* Format:
* ---METADATA---
* Title: My Title
* Description: My description goes here...
* Language: javascript
* Tags: tag1, tag2, tag3, tag4, tag5
* ---CODE---
*/
export function parseMetadata(fileContent: string): {
metadata: { title: string; description: string; language: string; tags: string[] };
code: string
} {
// Match the metadata and code sections - this regex was matching incorrectly
// Making it non-greedy for the first part and fixing the boundary for CODE marker
const metadataRegex = /^---METADATA---([\s\S]*?)(?=^---CODE---$)(^---CODE---$)([\s\S]*)$/m;
const matches = fileContent.match(metadataRegex);
if (!matches || matches.length < 4) {
throw new Error("Invalid file format: metadata section not found");
}
const metadataSection = matches[1] || "";
let codeSection = matches[3] || "";
// Remove leading newline from code section if present
if (codeSection.startsWith("\n")) {
codeSection = codeSection.substring(1);
}
// Parse each field with proper multiline flag
const titleMatch = metadataSection.match(/^Title:\s*(.+)$/m);
const title = titleMatch && titleMatch[1] ? titleMatch[1].trim() : "";
// Extract description which can be multiline but should stop at Language: or Tags:
const descriptionLines = [];
let inDescription = false;
// Process line by line
const lines = metadataSection.split('\n');
for (const line of lines) {
if (line.trim().startsWith('Description:')) {
inDescription = true;
const content = line.replace(/^Description:\s*/, '').trim();
if (content) {
descriptionLines.push(content);
}
} else if (line.trim().startsWith('Language:') || line.trim().startsWith('Tags:')) {
inDescription = false;
} else if (inDescription) {
descriptionLines.push(line);
}
}
const description = descriptionLines.join('\n').trim();
const languageMatch = metadataSection.match(/^Language:\s*(.+)$/m);
const language = languageMatch && languageMatch[1] ? languageMatch[1].trim() : "";
const tagsMatch = metadataSection.match(/^Tags:\s*(.+)$/m);
const tagsString = tagsMatch && tagsMatch[1] ? tagsMatch[1].trim() : "";
const tags = tagsString.split(',').map(tag => tag.trim()).filter(Boolean);
return {
metadata: {
title,
description,
language,
tags
},
code: codeSection
};
}
/**
* Create a file with metadata and code sections
*/
export function createFileWithMetadata(
title: string,
description: string,
language: string,
tags: string[],
code: string
): string {
return `---METADATA---
# Edit the metadata below. Keep the format exactly as shown (Title:, Description:, Language:, Tags:)
# Description needs to be at least 140 characters and Tags need at least 5 entries
# Don't remove the ---METADATA--- and ---CODE--- markers!
Title: ${title}
Description: ${description}
Language: ${language}
Tags: ${tags.join(', ')}
---CODE---
${code}`;
}
/**
* Publish a code snippet to Nostr
* @param title Title of the snippet
* @param description Description of the snippet
* @param language Programming language
* @param code The code snippet content
* @param tags Tags to categorize the snippet
* @param username Username to publish as
* @returns Publication results
*/
export async function publishCodeSnippet(
title: string,
description: string,
language: string,
code: string,
tags: string[] = [],
username?: string
): Promise<{ content: Array<{ type: "text", text: string }> }> {
try {
// Validate minimum requirements
// if (tags.length < 5) {
// throw new Error(
// "Insufficient tags. At least 5 tags are required. Please add more relevant and accurate information."
// );
// }
// if (description.length < 140) {
// throw new Error(
// "Description is too short. At least 140 characters are required. Please add more relevant and accurate information."
// );
// }
// put the code snippet in a temp file and run the command in config.editor or `code` and wait until it's closed -- then read the file and publish it
const config = readConfig();
const tempFilePath = join(tmpdir(), `snippet-${Date.now()}.${language}`);
// Create file content with metadata section for editing
const fileContent = createFileWithMetadata(title, description, language, tags, code);
// Write the content to the temp file
writeFileSync(tempFilePath, fileContent);
// Use the editor specified in config, or default to 'code' (VS Code)
const editorCommand = (config.editor || 'code --wait').split(' ');
// Spawn the editor process - first arg is the command array including both the command and its arguments
const process = Bun.spawn([...editorCommand, tempFilePath]);
log("spawned editor process to edit " + tempFilePath);
// Wait for the editor to close
await process.exited;
// Read the potentially modified content from the temp file
let updatedTitle = title;
let updatedDescription = description;
let updatedLanguage = language;
let updatedTags = tags;
let updatedCode = code;
if (existsSync(tempFilePath)) {
const updatedContent = readFileSync(tempFilePath, "utf-8");
try {
log("updatedContent: " + updatedContent);
const parsed = parseMetadata(updatedContent);
updatedTitle = parsed.metadata.title || title;
updatedDescription = parsed.metadata.description || description;
updatedLanguage = parsed.metadata.language || language;
updatedTags = parsed.metadata.tags.length >= 5 ? parsed.metadata.tags : tags;
updatedCode = parsed.code;
} catch (error) {
log("error " + error);
console.error("Error parsing metadata:", error);
// Fallback to using the file content as just code if metadata parsing fails
updatedCode = updatedContent;
}
} else {
log("tempFilePath does not exist", tempFilePath);
}
const eventTags = [
["name", updatedTitle],
["description", updatedDescription],
["l", updatedLanguage],
...updatedTags.map((tag) => ["t", tag]),
];
const event = new NDKEvent(ndk, {
kind: SNIPPET_KIND,
content: updatedCode,
tags: eventTags,
});
// Get the appropriate signer based on username
const signer = await getSigner(username);
// Sign the event with the selected signer
await event.sign(signer);
// Publish the already signed event
await event.publish();
return {
content: [
{
type: "text",
text: `Published code snippet "${updatedTitle}" to Nostr: The snippet can be seeen in https://snipsnip.dev/snippet/${event.id} or https://njump.me/${event.encode()}`,
},
],
};
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : String(error);
throw new Error(`Failed to publish code snippet: ${errorMessage}`);
}
}
export function addPublishCodeSnippetCommand(server: McpServer) {
server.tool(
"publish-new-code-snippet",
"Publish a new code snippet to Nostr",
{
title: z.string(),
description: z.string(),
language: z.string(),
code: z.string(),
tags: z.array(z.string()),
username: z.string().optional().describe(
"Username to publish as (you can see list_usernames to see available usernames)"
),
},
async ({ title, description, language, code, tags = [], username }, _extra) => {
return publishCodeSnippet(title, description, language, code, tags, username);
}
);
}

139
logic/publish.ts Normal file
View File

@@ -0,0 +1,139 @@
import {
NDKEvent
} from "@nostr-dev-kit/ndk";
import type { NDKFilter } from "@nostr-dev-kit/ndk";
import { z } from "zod";
import { identifierToPubkeys, getSigner } from "../lib/nostr/utils.js";
import { ndk } from "../ndk.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { knownUsers } from "../users.js";
/**
* Publish a note to Nostr
* @param content The content of the note to publish
* @param username Username to publish as
* @param obey Array of pubkeys, npubs, or names to wait for replies from
* @returns Publication results
*/
export async function publishNote(
content: string,
username?: string,
obey?: string[]
): Promise<{ content: Array<{ type: "text", text: string }> }> {
const userToPublishAs = username ?? "main";
try {
// Create the event
const event = new NDKEvent(ndk, {
kind: 1,
content,
tags: [],
});
// Get the appropriate signer based on username
const signer = await getSigner(userToPublishAs);
// Sign the event with the selected signer
await event.sign(signer);
// Publish the already signed event
await event.publish();
const eventId = event.id;
// If obey parameter exists, wait for replies from the specified users
if (obey && obey.length > 0) {
// Convert all identifiers to pubkeys
const pubkeysToObey: string[] = [];
for (const identifier of obey) {
const pubkeys = identifierToPubkeys(identifier);
if (pubkeys.length > 0) {
pubkeysToObey.push(...pubkeys);
}
}
if (pubkeysToObey.length === 0) {
throw new Error("No valid users found to obey");
}
// Set up filter to listen for replies
const filter: NDKFilter = {
kinds: [1], // Normal notes
authors: pubkeysToObey,
"#e": [eventId], // References to our event
};
// Wait for a reply
return await new Promise((resolve, reject) => {
// Set a timeout of 5 minutes
const timeout = setTimeout(
() => {
sub.stop();
reject(new Error("Timed out waiting for reply"));
},
5 * 60 * 1000
);
// Subscribe to replies
const sub = ndk.subscribe(filter);
sub.on("event", (replyEvent) => {
clearTimeout(timeout);
sub.stop();
// Format the reply output
const npub = replyEvent.author.npub;
const name = knownUsers[replyEvent.pubkey]?.profile?.name;
const replyFrom = name ? `${name} (${npub})` : npub;
resolve({
content: [
{
type: "text",
text: `${replyFrom} says:\n\n${replyEvent.content}`,
},
],
});
});
});
}
return {
content: [
{
type: "text",
text: `Published to Nostr with ID: ${event.encode()}`,
},
],
};
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : String(error);
throw new Error(`Failed to publish: ${errorMessage}`);
}
}
export function addPublishCommand(server: McpServer) {
// Add publish tool
server.tool(
"publish",
"Publish a tweet to Nostr",
{
content: z
.string()
.describe("The content of the tweet you want to publish"),
username: z
.string()
.optional()
.describe(
"Username to publish as (you can see list_usernames to see available usernames)"
),
obey: z
.array(z.string())
.optional()
.describe(
"Array of pubkeys, npubs, or names to wait for replies from"
),
},
async ({ content, username, obey }, _extra) => {
return await publishNote(content, username, obey);
}
);
}