From a75fb9de1c1be196b0244d745409f2113609388e Mon Sep 17 00:00:00 2001 From: gzuuus <116975404+gzuuus@users.noreply.github.com> Date: Sat, 1 Mar 2025 15:38:13 +0100 Subject: [PATCH] refactor: clis, config-generator, readmes (#4) --- README.md | 45 +-- packages/dvmcp-bridge/README.md | 35 +- packages/dvmcp-bridge/cli.ts | 339 +++++++------------ packages/dvmcp-bridge/index.ts | 5 +- packages/dvmcp-bridge/package.json | 7 +- packages/dvmcp-commons/README.md | 14 +- packages/dvmcp-commons/config-generator.ts | 307 ++++++++++++++++++ packages/dvmcp-commons/package.json | 2 +- packages/dvmcp-discovery/README.md | 35 +- packages/dvmcp-discovery/cli.ts | 358 ++++++--------------- packages/dvmcp-discovery/index.ts | 2 +- packages/dvmcp-discovery/package.json | 7 +- 12 files changed, 556 insertions(+), 600 deletions(-) create mode 100644 packages/dvmcp-commons/config-generator.ts diff --git a/README.md b/README.md index ddd329d..51c715f 100644 --- a/README.md +++ b/README.md @@ -1,104 +1,71 @@ # DVMCP: Data Vending Machine Context Protocol - A monorepo containing packages that bridge Model Context Protocol (MCP) servers with Nostr's Data Vending Machine (DVM) ecosystem, enabling AI and computational tools to be discovered and utilized via Nostr's decentralized network. - ## Packages - This monorepo contains the following packages: - ### [@dvmcp/bridge](./packages/dvmcp-bridge) The bridge implementation that connects MCP servers to Nostr's DVM ecosystem. Handles tool announcement, execution, and status updates. - ### [@dvmcp/discovery](./packages/dvmcp-discovery) A MCP server/discovery service that aggregates MCP tools from DVMs and makes their tools available. - ### [@dvmcp/commons](./packages/dvmcp-commons) Shared utilities and components used across DVMCP packages. - ## Installation & Usage +**Prerequisite:** Ensure you have [Bun](https://bun.sh/) installed. ### Quick Start with NPX (No Installation) - You can run the packages directly using `npx` without installing them: - ```bash # Run the bridge npx @dvmcp/bridge - # Run the discovery service npx @dvmcp/discovery ``` - The interactive CLI will guide you through configuration setup on first run. - ### Global Installation - ```bash # Install the packages globally npm install -g @dvmcp/bridge @dvmcp/discovery - # Run the commands dvmcp-bridge dvmcp-discovery ``` - ## Setting Up a Bridge - To expose your MCP server as a DVM on Nostr: - 1. Navigate to the directory where you want to configure the bridge 2. Run: `npx @dvmcp/bridge` 3. Follow the interactive setup to configure: - - Your MCP server path - - Nostr private key (or generate a new one) - - Relays to connect to +- Your MCP server path +- Nostr private key (or generate a new one) +- Relays to connect to 4. The bridge will start and begin proxying requests between Nostr and your MCP server - ## Setting Up a Discovery Service - To aggregate MCP tools from DVMs: - 1. Navigate to your desired directory 2. Run: `npx @dvmcp/discovery` 3. Follow the setup to configure: - - Nostr private key - - Relays to monitor - +- Nostr private key +- Relays to monitor ## Development - For contributors to this repository: - ```bash # Clone the repo git clone https://github.com/gzuuus/dvmcp.git cd dvmcp - # Install dependencies bun install - # Start the bridge in development mode bun run dev --cwd packages/dvmcp-bridge - # Start the discovery service in development mode bun run dev --cwd packages/dvmcp-discovery ``` - ## Documentation - - [DVMCP Specification](./docs/dvmcp-spec.md) - [Bridge Package](./packages/dvmcp-bridge/README.md) - [Discovery Package](./packages/dvmcp-discovery/README.md) - [Commons Package](./packages/dvmcp-commons/README.md) - ## Contributing - Contributions are welcome! Please feel free to submit pull requests or create issues. - ## License - [MIT License](LICENSE) - ## Related Projects - - [Model Context Protocol](https://modelcontextprotocol.io) - [Nostr Protocol](https://github.com/nostr-protocol/nips) \ No newline at end of file diff --git a/packages/dvmcp-bridge/README.md b/packages/dvmcp-bridge/README.md index 627e940..23f82a5 100644 --- a/packages/dvmcp-bridge/README.md +++ b/packages/dvmcp-bridge/README.md @@ -12,41 +12,30 @@ A bridge implementation that connects Model Context Protocol (MCP) servers to No ## Configuration -Create your configuration file by copying the example: +When the package is run for the first time, it will detect if the `config.yml` file exists, and if not, it will launch a configuration wizard to help you create the configuration file. You can also create your configuration file by copying `config.example.yml` and changing the values of the fields ```bash cp config.example.yml config.yml -``` - -Example configuration: - -```yaml -nostr: - privateKey: 'your_private_key_here' - relayUrls: - - 'wss://relay1.com' - - 'wss://relay2.net' - -mcp: - name: 'DVM MCP Bridge' - about: 'MCP-enabled DVM providing AI and computational tools' - clientName: 'DVM MCP Bridge Client' - clientVersion: '1.0.0' - servers: - - name: 'server1' - command: 'node' - args: ['run', 'src/external-mcp-server1.ts'] +nano config.yml ``` ## Usage -Development mode: +**Prerequisite:** Ensure you have [Bun](https://bun.sh/) installed. + +You can run this package directly using `npx`: + +```bash +npx @dvmcp/bridge +``` + +Alternatively, for development: ```bash bun run dev ``` -Production mode: +For production: ```bash bun run start diff --git a/packages/dvmcp-bridge/cli.ts b/packages/dvmcp-bridge/cli.ts index 76dccae..eb18bef 100755 --- a/packages/dvmcp-bridge/cli.ts +++ b/packages/dvmcp-bridge/cli.ts @@ -1,230 +1,127 @@ #!/usr/bin/env bun -import { randomBytes } from 'node:crypto'; -import { createInterface } from 'node:readline'; -import { writeFileSync, existsSync } from 'node:fs'; -import { join, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { parse, stringify } from 'yaml'; -import type { Config } from './src/types.js'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const isNpxRun = !__dirname.includes(process.cwd()); - -if (!isNpxRun) { - process.chdir(__dirname); -} +import { existsSync } from 'node:fs'; +import { join } from 'path'; +import { + ConfigGenerator, + type FieldConfig, +} from '@dvmcp/commons/config-generator'; +import { + generateHexKey, + validateHexKey, + validateRelayUrl, + CONFIG_EMOJIS, +} from '@dvmcp/commons/config-generator'; +import { argv } from 'process'; +import type { Config } from './src/types'; const configPath = join(process.cwd(), 'config.yml'); -const configExamplePath = join(__dirname, 'config.example.yml'); -async function setupConfig() { - console.log('šŸ”§ DVMCP Bridge Configuration Setup šŸ”§'); +const configFields: Record = { + nostr: { + type: 'nested', + description: 'Nostr Configuration', + emoji: CONFIG_EMOJIS.NOSTR, + fields: { + privateKey: { + type: 'hex', + description: 'Private key', + generator: generateHexKey, + validation: validateHexKey, + required: true, + }, + relayUrls: { + type: 'array', + description: 'Relay URLs', + validation: validateRelayUrl, + required: true, + }, + }, + }, + mcp: { + type: 'nested', + description: 'Service Configuration', + emoji: CONFIG_EMOJIS.SERVICE, + fields: { + name: { + type: 'string', + description: 'Service name', + default: 'DVM MCP Bridge', + }, + about: { + type: 'string', + description: 'Service description', + default: 'MCP-enabled DVM providing AI and computational tools', + }, + clientName: { + type: 'string', + description: 'Client name', + default: 'DVM MCP Bridge Client', + required: true, + }, + clientVersion: { + type: 'string', + description: 'Client version', + default: '1.0.0', + required: true, + }, + servers: { + type: 'object-array', + description: 'Server Configuration', + emoji: CONFIG_EMOJIS.SERVER, + required: true, + fields: { + command: { + type: 'string', + description: 'Command', + }, + args: { + type: 'array', + description: 'Arguments', + }, + }, + }, + }, + }, + whitelist: { + type: 'nested', + description: 'Whitelist Configuration', + emoji: CONFIG_EMOJIS.WHITELIST, + fields: { + allowedPubkeys: { + type: 'set', + description: 'Allowed public keys', + }, + }, + }, +}; - // If config exists, ask user if they want to reconfigure - if (existsSync(configPath)) { - const shouldReconfigure = await promptYesNo( - 'Configuration file already exists. Do you want to reconfigure it?' +const configure = async () => { + console.log( + `${CONFIG_EMOJIS.SETUP} DVMCP Bridge Configuration Setup ${CONFIG_EMOJIS.SETUP}` + ); + const generator = new ConfigGenerator(configPath, configFields); + await generator.generate(); +}; + +const runApp = async () => { + const main = await import('./index.js'); + console.log(`${CONFIG_EMOJIS.INFO} Running main application...`); + await main.default(); +}; + +const cliMain = async () => { + if (argv.includes('--configure')) { + await configure(); + } + + if (!existsSync(configPath)) { + console.log( + `${CONFIG_EMOJIS.INFO} No configuration file found. Starting setup...` ); - if (!shouldReconfigure) { - console.log('Using existing configuration.'); - return; - } + await configure(); } - // Load example config as template - if (!existsSync(configExamplePath)) { - console.error('Error: Example configuration file not found!'); - process.exit(1); - } + await runApp(); +}; - // Read and parse example config - const exampleConfigContent = await Bun.file(configExamplePath).text(); - const config: Config = parse(exampleConfigContent); - - // Ensure config structure matches example - config.nostr = config.nostr || {}; - config.mcp = config.mcp || {}; - config.mcp.servers = config.mcp.servers || []; - - console.log('\nšŸ”‘ Nostr Configuration:'); - const useExistingKey = await promptYesNo( - 'Do you have an existing Nostr private key?' - ); - - if (useExistingKey) { - config.nostr.privateKey = await prompt( - 'Enter your Nostr private key (nsec or hex):', - config.nostr.privateKey || '' - ); - } else { - // Generate a random key - config.nostr.privateKey = Buffer.from(randomBytes(32)).toString('hex'); - console.log(`Generated new private key: ${config.nostr.privateKey}`); - } - - console.log('\nšŸ”„ Relay Configuration:'); - let relayUrls = config.nostr.relayUrls || []; - console.log('Current relays:'); - if (relayUrls.length > 0) { - relayUrls.forEach((relay, i) => console.log(` ${i + 1}. ${relay}`)); - } else { - console.log(' No relays configured yet.'); - } - - const addRelays = await promptYesNo('Would you like to add more relays?'); - if (addRelays) { - let addingRelays = true; - while (addingRelays) { - const relay = await prompt( - 'Enter relay URL (or leave empty to finish):', - '' - ); - if (relay) { - relayUrls.push(relay); - } else { - addingRelays = false; - } - } - } - config.nostr.relayUrls = relayUrls; - - console.log('\n🌐 MCP Service Configuration:'); - config.mcp.name = await prompt( - 'Service name:', - config.mcp.name || 'DVM MCP Bridge' - ); - config.mcp.about = await prompt( - 'Service description:', - config.mcp.about || 'MCP-enabled DVM providing AI and computational tools' - ); - config.mcp.clientName = await prompt( - 'Client name:', - config.mcp.clientName || 'DVM MCP Bridge Client' - ); - config.mcp.clientVersion = await prompt( - 'Client version:', - config.mcp.clientVersion || '1.0.0' - ); - - console.log('\nšŸ–„ļø MCP Servers Configuration:'); - console.log('Current configured servers:'); - if (config.mcp.servers.length > 0) { - config.mcp.servers.forEach((server, i) => { - console.log( - ` ${i + 1}. ${server.name} (${server.command} ${server.args.join(' ')})` - ); - }); - } else { - console.log(' No servers configured yet.'); - } - - const configureServers = await promptYesNo( - 'Would you like to configure MCP servers?' - ); - if (configureServers) { - let configuringServers = true; - while (configuringServers) { - console.log('\nConfiguring a new server:'); - const name = await prompt('Server name (or leave empty to finish):', ''); - if (!name) { - configuringServers = false; - continue; - } - const command = await prompt('Command to run server:', 'node'); - const argsStr = await prompt('Command arguments (space-separated):', ''); - const args = argsStr ? argsStr.split(' ') : []; - config.mcp.servers.push({ name, command, args }); - } - } - - console.log('\nšŸ“ Whitelist Configuration:'); - const useWhitelist = await promptYesNo( - 'Would you like to configure a public key whitelist?' - ); - - if (useWhitelist) { - config.whitelist = config.whitelist || {}; - config.whitelist.allowedPubkeys = config.whitelist.allowedPubkeys; - - console.log('Current allowed public keys:'); - if ( - config.whitelist.allowedPubkeys && - config.whitelist.allowedPubkeys.size > 0 - ) { - config.whitelist.allowedPubkeys.forEach((pubkey, i) => - console.log(` ${i + 1}. ${pubkey}`) - ); - } else { - console.log(' No public keys whitelisted yet.'); - } - - let addingPubkeys = true; - while (addingPubkeys) { - const pubkey = await prompt( - 'Enter public key to whitelist (or leave empty to finish):', - '' - ); - if (pubkey) { - if (!config.whitelist.allowedPubkeys) { - config.whitelist.allowedPubkeys = new Set(); - } - config.whitelist.allowedPubkeys.add(pubkey); - } else { - addingPubkeys = false; - } - } - } else if (config.whitelist) { - // If user doesn't want a whitelist but it exists in config, remove it - config.whitelist.allowedPubkeys = undefined; - } - - // Save the config - writeFileSync(configPath, stringify(config)); - console.log(`\nāœ… Configuration saved to ${configPath}`); -} - -async function prompt(question: string, defaultValue = ''): Promise { - const rl = createInterface({ - input: process.stdin, - output: process.stdout, - }); - - return new Promise((resolve) => { - rl.question( - `${question}${defaultValue ? ` (${defaultValue})` : ''} `, - (answer) => { - rl.close(); - resolve(answer || defaultValue); - } - ); - }); -} - -async function promptYesNo( - question: string, - defaultValue = false -): Promise { - const rl = createInterface({ - input: process.stdin, - output: process.stdout, - }); - - const defaultIndicator = defaultValue ? 'Y/n' : 'y/N'; - - return new Promise((resolve) => { - rl.question(`${question} (${defaultIndicator}) `, (answer) => { - rl.close(); - if (answer.trim() === '') { - resolve(defaultValue); - } else { - resolve(answer.toLowerCase().startsWith('y')); - } - }); - }); -} - -await setupConfig(); - -import('./index.js'); +cliMain().catch(console.error); diff --git a/packages/dvmcp-bridge/index.ts b/packages/dvmcp-bridge/index.ts index 0a4b9c4..ab90714 100644 --- a/packages/dvmcp-bridge/index.ts +++ b/packages/dvmcp-bridge/index.ts @@ -2,7 +2,6 @@ import { DVMBridge } from './src/dvm-bridge'; async function main() { const bridge = new DVMBridge(); - const shutdown = async () => { console.log('Shutting down...'); try { @@ -13,10 +12,8 @@ async function main() { process.exit(1); } }; - process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown); - try { await bridge.start(); } catch (error) { @@ -25,4 +22,4 @@ async function main() { } } -main(); +export default main; diff --git a/packages/dvmcp-bridge/package.json b/packages/dvmcp-bridge/package.json index 89e9936..8d338f0 100644 --- a/packages/dvmcp-bridge/package.json +++ b/packages/dvmcp-bridge/package.json @@ -1,6 +1,6 @@ { "name": "@dvmcp/bridge", - "version": "0.1.3", + "version": "0.1.6", "description": "Bridge connecting MCP servers to Nostr's DVM ecosystem", "module": "index.ts", "type": "module", @@ -18,8 +18,7 @@ "scripts": { "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", "dev": "bun --watch index.ts", - "start": "bun run index.ts", - "setup": "bun run cli.ts", + "start": "bun run cli.ts", "typecheck": "tsc --noEmit", "lint": "bun run typecheck && bun run format", "test": "bun test", @@ -36,7 +35,7 @@ "dotenv": "^16.4.7", "nostr-tools": "^2.10.4", "yaml": "^2.7.0", - "@dvmcp/commons": "^0.1.0" + "@dvmcp/commons": "^0.1.1" }, "publishConfig": { "access": "public" diff --git a/packages/dvmcp-commons/README.md b/packages/dvmcp-commons/README.md index b9b8eaa..e6d68b3 100644 --- a/packages/dvmcp-commons/README.md +++ b/packages/dvmcp-commons/README.md @@ -1,15 +1,3 @@ # commons -To install dependencies: - -```bash -bun install -``` - -To run: - -```bash -bun run index.ts -``` - -This project was created using `bun init` in bun v1.2.2. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. +Common package for common functions and constants diff --git a/packages/dvmcp-commons/config-generator.ts b/packages/dvmcp-commons/config-generator.ts new file mode 100644 index 0000000..23187e9 --- /dev/null +++ b/packages/dvmcp-commons/config-generator.ts @@ -0,0 +1,307 @@ +import { createInterface } from 'node:readline'; +import { stringify } from 'yaml'; +import { writeFileSync, existsSync } from 'node:fs'; +import { randomBytes } from 'node:crypto'; +import { HEX_KEYS_REGEX } from './constants'; + +export const CONFIG_EMOJIS = { + NOSTR: 'šŸ”‘', + RELAY: 'šŸ”„', + SERVICE: 'šŸ¤–', + SERVER: 'šŸ–„ļø', + WHITELIST: 'šŸ”’', + SETUP: 'āš™ļø', + SUCCESS: 'āœ…', + INFO: 'ā„¹ļø', + PROMPT: 'āÆ', +} as const; + +export type FieldType = + | 'string' + | 'array' + | 'boolean' + | 'hex' + | 'url' + | 'nested' + | 'object-array' + | 'set'; + +interface ArrayItem { + name: string; + command: string; + args: string[]; + [key: string]: any; +} + +export interface FieldConfig { + type: FieldType; + description?: string; + default?: any; + validation?: (value: any) => boolean; + generator?: () => any; + required?: boolean; + itemType?: FieldConfig; + fields?: Record; + comment?: string; + emoji?: string; +} + +export class ConfigGenerator> { + constructor( + private configPath: string, + private fields: Record + ) {} + + private async prompt(question: string, defaultValue = ''): Promise { + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const answer = await new Promise((resolve) => { + rl.question( + `${question}${defaultValue ? ` (${defaultValue})` : ''} `, + (answer) => resolve(answer || defaultValue) + ); + }); + + rl.close(); + return answer; + } + + private async promptYesNo( + question: string, + defaultValue = false + ): Promise { + const answer = await this.prompt( + `${question} (${defaultValue ? 'Y/n' : 'y/N'})` + ); + return answer.trim() === '' + ? defaultValue + : answer.toLowerCase().startsWith('y'); + } + + private async handleObjectArrayItem( + fields: Record + ): Promise | null> { + const item: Record = { name: '' }; + + const name = await this.prompt( + `${CONFIG_EMOJIS.SERVER} Name (empty to finish):` + ); + if (!name) return null; + + item.name = name; + + for (const [fieldName, fieldConfig] of Object.entries(fields)) { + if (fieldName === 'name') continue; + + const value = await this.handleField(fieldName, fieldConfig, false); + if (value === null) return null; + item[fieldName] = value; + } + + return item; + } + + private async handleField( + fieldName: string, + config: FieldConfig, + showFieldName = true, + currentValue?: any + ): Promise { + const emoji = config.emoji || CONFIG_EMOJIS.PROMPT; + + if (config.type === 'nested') { + console.log(`\n${emoji} ${config.description || fieldName}`); + } + + const handlers: Record Promise> = { + string: async () => + this.promptWithValidation( + `${emoji} ${config.description || (showFieldName ? fieldName : '')}:`, + config, + currentValue || config.default + ), + + hex: async () => { + if ( + config.generator && + (await this.promptYesNo(`${emoji} Generate new ${fieldName}?`)) + ) { + const value = config.generator(); + console.log(`${CONFIG_EMOJIS.PROMPT} ${value}`); + return value; + } + + const existingValue = currentValue || config.default; + existingValue && + console.log(`${CONFIG_EMOJIS.PROMPT} ${existingValue}`); + return this.promptWithValidation( + `${emoji} Enter ${fieldName}:`, + config, + existingValue + ); + }, + + array: async () => { + const array: string[] = currentValue || []; + if (array.length > 0) { + console.log('\nCurrent items:'); + array.forEach((item: string, index: number) => { + console.log(`${CONFIG_EMOJIS.INFO} ${index + 1}. ${item}`); + }); + console.log(''); + } + + if (await this.promptYesNo(`${emoji} Add ${fieldName}?`, true)) { + while (true) { + const item = await this.promptWithValidation( + `${emoji} Value (empty to finish):`, + config, + '', + true + ); + if (!item) break; + array.push(item); + } + } + return array; + }, + + set: async () => { + const items: string[] = Array.from(currentValue || []); + if (await this.promptYesNo('Would you like to add items?', false)) { + while (true) { + const item = await this.promptWithValidation( + 'Enter item (empty to finish):', + config, + '', + true + ); + if (!item) break; + items.push(item); + } + } + return new Set(items); + }, + + 'object-array': async () => { + const array: ArrayItem[] = currentValue || []; + if (array.length > 0) { + console.log('\nCurrent servers:'); + array.forEach((item: ArrayItem, index: number) => { + console.log( + `${CONFIG_EMOJIS.INFO} ${index + 1}. ${item.name} (${item.command} ${item.args.join(' ')})` + ); + }); + console.log(''); + } + + if ( + (await this.promptYesNo(`${emoji} Add new ${fieldName}?`, true)) && + config.fields + ) { + while (true) { + const item = await this.handleObjectArrayItem(config.fields); + if (!item) break; + array.push(item as ArrayItem); + console.log(''); + } + } + return array; + }, + + nested: async () => { + const nestedObj: Record = {}; + if (config.fields) { + for (const [key, fieldConfig] of Object.entries(config.fields)) { + nestedObj[key] = await this.handleField( + key, + fieldConfig, + true, + currentValue?.[key] + ); + } + } + return nestedObj; + }, + + boolean: async () => false, + url: async () => + this.promptWithValidation( + `${emoji} Enter URL:`, + config, + currentValue || config.default + ), + }; + + return handlers[config.type](); + } + + private async validateInput( + value: any, + config: FieldConfig, + allowEmpty = true + ): Promise { + if (allowEmpty && value === '') return true; + + if (!config.validation) return true; + + const isValid = config.validation(value); + if (!isValid) { + console.log('āŒ Invalid input. Please try again.'); + } + return isValid; + } + + private async promptWithValidation( + question: string, + config: FieldConfig, + defaultValue = '', + allowEmpty = true + ): Promise { + while (true) { + const value = await this.prompt(question, defaultValue); + if (!value && !config.required && allowEmpty) return value; + if (await this.validateInput(value, config, allowEmpty)) return value; + } + } + + async generate(): Promise { + console.log(`\n${CONFIG_EMOJIS.SETUP} Configuration Setup\n`); + + if (existsSync(this.configPath)) { + const reconfigure = await this.promptYesNo( + `${CONFIG_EMOJIS.PROMPT} Configuration exists. Reconfigure?`, + true + ); + if (!reconfigure) process.exit(0); + } + + const config: Record = {}; + for (const [fieldName, fieldConfig] of Object.entries(this.fields)) { + config[fieldName] = await this.handleField(fieldName, fieldConfig); + } + + writeFileSync(this.configPath, stringify(config)); + console.log(`\n${CONFIG_EMOJIS.SUCCESS} Configuration saved successfully!`); + + return config as T; + } +} + +export const generateHexKey = () => + Buffer.from(randomBytes(32)).toString('hex'); + +export const validateHexKey = (value: string) => HEX_KEYS_REGEX.test(value); + +export const validateRelayUrl = (url: string) => { + try { + const trimmedUrl = url.trim(); + new URL(trimmedUrl); + return trimmedUrl.startsWith('ws://') || trimmedUrl.startsWith('wss://'); + } catch { + return false; + } +}; diff --git a/packages/dvmcp-commons/package.json b/packages/dvmcp-commons/package.json index f120b23..22d4b69 100644 --- a/packages/dvmcp-commons/package.json +++ b/packages/dvmcp-commons/package.json @@ -1,6 +1,6 @@ { "name": "@dvmcp/commons", - "version": "0.1.0", + "version": "0.1.1", "description": "Shared utilities for DVMCP packages", "type": "module", "exports": { diff --git a/packages/dvmcp-discovery/README.md b/packages/dvmcp-discovery/README.md index 43659a3..c22eab9 100644 --- a/packages/dvmcp-discovery/README.md +++ b/packages/dvmcp-discovery/README.md @@ -11,41 +11,30 @@ A MCP server implementation that aggregates tools from DVMs across the Nostr net ## Configuration -Create your configuration file by copying the example: +When the package is run for the first time, it will detect if the `config.yml` file exists, and if not, it will launch a configuration wizard to help you create the configuration file. You can also create your configuration file by copying `config.example.yml` and changing the values of the fields ```bash cp config.example.yml config.yml -``` - -Example configuration: - -```yaml -nostr: - privateKey: 'your_private_key_here' - relayUrls: - - 'wss://relay.damus.io' - - 'wss://relay.nostr.band' - -mcp: - name: 'DVMCP Discovery' - version: '1.0.0' - about: 'DVMCP Discovery Server for aggregating MCP tools from DVMs' -# Optional: whitelist specific DVMs -# whitelist: -# allowedDVMs: -# - 'pubkey1' -# - 'pubkey2' +nano config.yml ``` ## Usage -Development mode: +**Prerequisite:** Ensure you have [Bun](https://bun.sh/) installed. + +You can run this package directly using `npx`: + +```bash +npx @dvmcp/discovery +``` + +Alternatively, for development: ```bash bun run dev ``` -Production mode: +For production: ```bash bun run start diff --git a/packages/dvmcp-discovery/cli.ts b/packages/dvmcp-discovery/cli.ts index 169d06d..3838f41 100755 --- a/packages/dvmcp-discovery/cli.ts +++ b/packages/dvmcp-discovery/cli.ts @@ -1,280 +1,104 @@ #!/usr/bin/env bun -import { randomBytes } from 'node:crypto'; -import { createInterface } from 'node:readline'; -import { writeFileSync, existsSync } from 'node:fs'; -import { join, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { parse, stringify } from 'yaml'; -import { HEX_KEYS_REGEX } from '@dvmcp/commons/constants'; -import type { Config } from './src/config'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const isNpxRun = !__dirname.includes(process.cwd()); -if (!isNpxRun) { - process.chdir(__dirname); -} +import { + ConfigGenerator, + generateHexKey, + type FieldConfig, + CONFIG_EMOJIS, + validateHexKey, + validateRelayUrl, +} from '@dvmcp/commons/config-generator'; +import { join } from 'path'; +import type { Config } from './src/config.js'; +import { argv } from 'process'; +import { existsSync } from 'fs'; const configPath = join(process.cwd(), 'config.yml'); -const configExamplePath = join(__dirname, 'config.example.yml'); -async function setupConfig() { - console.log('šŸ”§ DVMCP Discovery Configuration Setup šŸ”§'); +const configFields: Record = { + nostr: { + type: 'nested', + description: 'Nostr Configuration', + emoji: CONFIG_EMOJIS.NOSTR, + fields: { + privateKey: { + type: 'hex', + description: 'Private key', + generator: generateHexKey, + validation: validateHexKey, + required: true, + }, + relayUrls: { + type: 'array', + description: 'Relay URLs', + validation: validateRelayUrl, + required: true, + }, + }, + }, + mcp: { + type: 'nested', + description: 'Service Configuration', + emoji: CONFIG_EMOJIS.SERVICE, + fields: { + name: { + type: 'string', + description: 'Service name', + default: 'DVMCP Discovery', + }, + version: { + type: 'string', + description: 'Service version', + default: '1.0.0', + required: true, + }, + about: { + type: 'string', + description: 'Service description', + default: 'DVMCP Discovery Server for aggregating MCP tools from DVMs', + }, + }, + }, + whitelist: { + type: 'nested', + description: 'DVM Whitelist Configuration', + emoji: CONFIG_EMOJIS.WHITELIST, + fields: { + allowedDVMs: { + type: 'set', + description: 'Allowed DVM public keys', + validation: validateHexKey, + }, + }, + }, +}; - // If config exists, ask user if they want to reconfigure - if (existsSync(configPath)) { - const shouldReconfigure = await promptYesNo( - 'Configuration file already exists. Do you want to reconfigure it?' - ); - if (!shouldReconfigure) { - console.log('Using existing configuration.'); - return; - } - } - - // Load example config as template - if (!existsSync(configExamplePath)) { - console.error('Error: Example configuration file not found!'); - process.exit(1); - } - - // Read and parse example config - const exampleConfigContent = await Bun.file(configExamplePath).text(); - let config: Config = parse(exampleConfigContent); - - // If existing config, load it instead of the example - if (existsSync(configPath)) { - const existingConfigContent = await Bun.file(configPath).text(); - try { - config = parse(existingConfigContent); - } catch (error) { - console.warn( - 'Warning: Could not parse existing config. Using example config as base.' - ); - } - } - - config.nostr = config.nostr || {}; - config.mcp = config.mcp || {}; - config.whitelist = config.whitelist || {}; - - console.log('\nšŸ”‘ Nostr Configuration:'); - // Check if private key exists and is valid - const hasValidKey = - config.nostr.privateKey && - HEX_KEYS_REGEX.test(config.nostr.privateKey) && - config.nostr.privateKey !== 'your_private_key_here'; - - const useExistingKey = hasValidKey - ? await promptYesNo('Use existing private key?', true) - : false; - - if (!useExistingKey) { - const useCustomKey = await promptYesNo( - 'Would you like to enter a custom private key?' - ); - if (useCustomKey) { - let validKey = false; - while (!validKey) { - const defaultKey = hasValidKey ? config.nostr.privateKey : ''; - config.nostr.privateKey = await prompt( - 'Enter your Nostr private key (hex format):', - defaultKey - ); - if (HEX_KEYS_REGEX.test(config.nostr.privateKey)) { - validKey = true; - } else { - console.log( - 'āŒ Invalid key format. Please enter a 32-byte hex string.' - ); - } - } - } else { - // Generate a random key - config.nostr.privateKey = Buffer.from(randomBytes(32)).toString('hex'); - console.log(`Generated new private key: ${config.nostr.privateKey}`); - } - } - - console.log('\nšŸ”„ Relay Configuration:'); - let relayUrls = config.nostr.relayUrls || []; - console.log('Current relays:'); - if (relayUrls.length > 0) { - relayUrls.forEach((relay, i) => console.log(` ${i + 1}. ${relay}`)); - } else { - console.log(' No relays configured yet.'); - } - - const addRelays = await promptYesNo('Would you like to add more relays?'); - if (addRelays) { - let addingRelays = true; - while (addingRelays) { - const relay = await prompt( - 'Enter relay URL (or leave empty to finish):', - '' - ); - if (relay) { - try { - const trimmedUrl = relay.trim(); - new URL(trimmedUrl); - if ( - !trimmedUrl.startsWith('ws://') && - !trimmedUrl.startsWith('wss://') - ) { - console.log('āŒ Relay URL must start with ws:// or wss://'); - continue; - } - relayUrls.push(trimmedUrl); - } catch (error) { - console.log(`āŒ Invalid relay URL: ${relay}`); - } - } else { - addingRelays = false; - } - } - } - - const removeRelays = - relayUrls.length > 0 && - (await promptYesNo('Would you like to remove any relays?')); - if (removeRelays) { - let removingRelays = true; - while (removingRelays && relayUrls.length > 0) { - console.log('Current relays:'); - relayUrls.forEach((relay, i) => console.log(` ${i + 1}. ${relay}`)); - - const indexStr = await prompt( - 'Enter number of relay to remove (or leave empty to finish):', - '' - ); - if (!indexStr) { - removingRelays = false; - continue; - } - - const index = parseInt(indexStr, 10) - 1; - if (isNaN(index) || index < 0 || index >= relayUrls.length) { - console.log('Invalid relay number. Please try again.'); - continue; - } - - relayUrls.splice(index, 1); - console.log('Relay removed.'); - - if (relayUrls.length === 0) { - console.log('No relays remaining.'); - break; - } - } - } - - config.nostr.relayUrls = relayUrls; - - console.log('\n🌐 MCP Service Configuration:'); - config.mcp.name = await prompt( - 'Service name:', - config.mcp.name || 'DVMCP Discovery' - ); - config.mcp.version = await prompt( - 'Service version:', - config.mcp.version || '1.0.0' - ); - config.mcp.about = await prompt( - 'Service description:', - config.mcp.about || - 'DVMCP Discovery Server for aggregating MCP tools from DVMs' +const configure = async () => { + console.log( + `${CONFIG_EMOJIS.SETUP} DVMCP Discovery Configuration Setup ${CONFIG_EMOJIS.SETUP}` ); + const generator = new ConfigGenerator(configPath, configFields); + await generator.generate(); +}; - console.log('\nšŸ“ DVM Whitelist Configuration:'); - const useWhitelist = await promptYesNo( - 'Would you like to configure a DVM whitelist?' - ); +const runApp = async () => { + const main = await import('./index.js'); + console.log(`${CONFIG_EMOJIS.INFO} Running main application...`); + await main.default(); +}; - if (useWhitelist) { - config.whitelist = config.whitelist || {}; - config.whitelist.allowedDVMs = - config.whitelist.allowedDVMs || new Set(); - - console.log('Current whitelisted DVMs:'); - if (config.whitelist.allowedDVMs && config.whitelist.allowedDVMs.size > 0) { - Array.from(config.whitelist.allowedDVMs).forEach((pubkey, i) => - console.log(` ${i + 1}. ${pubkey}`) - ); - } else { - console.log(' No DVMs whitelisted yet.'); - } - - let addingDVMs = true; - while (addingDVMs) { - const pubkey = await prompt( - 'Enter DVM public key to whitelist (or leave empty to finish):', - '' - ); - if (pubkey) { - if (HEX_KEYS_REGEX.test(pubkey.trim())) { - config.whitelist.allowedDVMs.add(pubkey.trim()); - } else { - console.log( - 'āŒ Invalid public key format. Please enter a 32-byte hex string.' - ); - } - } else { - addingDVMs = false; - } - } - } else if (config.whitelist?.allowedDVMs) { - // If user doesn't want a whitelist but it exists in config, ask if they want to clear it - const clearWhitelist = await promptYesNo( - 'Do you want to clear the existing whitelist?' - ); - if (clearWhitelist) { - config.whitelist.allowedDVMs = undefined; - console.log('Whitelist cleared.'); - } +const cliMain = async () => { + if (argv.includes('--configure')) { + await configure(); } - // Save the config - writeFileSync(configPath, stringify(config)); - console.log(`\nāœ… Configuration saved to ${configPath}`); -} - -async function prompt(question: string, defaultValue = ''): Promise { - const rl = createInterface({ - input: process.stdin, - output: process.stdout, - }); - return new Promise((resolve) => { - rl.question( - `${question}${defaultValue ? ` (${defaultValue})` : ''} `, - (answer) => { - rl.close(); - resolve(answer || defaultValue); - } + if (!existsSync(configPath)) { + console.log( + `${CONFIG_EMOJIS.INFO} No configuration file found. Starting setup...` ); - }); -} + await configure(); + } -async function promptYesNo( - question: string, - defaultValue = false -): Promise { - const rl = createInterface({ - input: process.stdin, - output: process.stdout, - }); - const defaultIndicator = defaultValue ? 'Y/n' : 'y/N'; - return new Promise((resolve) => { - rl.question(`${question} (${defaultIndicator}) `, (answer) => { - rl.close(); - if (answer.trim() === '') { - resolve(defaultValue); - } else { - resolve(answer.toLowerCase().startsWith('y')); - } - }); - }); -} + await runApp(); +}; -await setupConfig(); -import('./index.js'); +cliMain().catch(console.error); diff --git a/packages/dvmcp-discovery/index.ts b/packages/dvmcp-discovery/index.ts index 1035be4..d24dc7c 100644 --- a/packages/dvmcp-discovery/index.ts +++ b/packages/dvmcp-discovery/index.ts @@ -24,4 +24,4 @@ async function main() { } } -main(); +export default main; diff --git a/packages/dvmcp-discovery/package.json b/packages/dvmcp-discovery/package.json index f1243f2..7da7bb9 100644 --- a/packages/dvmcp-discovery/package.json +++ b/packages/dvmcp-discovery/package.json @@ -1,6 +1,6 @@ { "name": "@dvmcp/discovery", - "version": "0.1.4", + "version": "0.1.7", "description": "Discovery service for MCP tools in the Nostr DVM ecosystem", "module": "index.ts", "type": "module", @@ -17,8 +17,7 @@ "scripts": { "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", "dev": "bun --watch index.ts", - "start": "bun run index.ts", - "setup": "bun run cli.ts", + "start": "bun run cli.ts", "typecheck": "tsc --noEmit", "lint": "bun run typecheck && bun run format", "test": "bun test", @@ -34,7 +33,7 @@ "@modelcontextprotocol/sdk": "^1.5.0", "nostr-tools": "^2.10.4", "yaml": "^2.7.0", - "@dvmcp/commons": "^0.1.0" + "@dvmcp/commons": "^0.1.1" }, "publishConfig": { "access": "public"