diff --git a/README.md b/README.md index ed98179..ddd329d 100644 --- a/README.md +++ b/README.md @@ -6,36 +6,76 @@ A monorepo containing packages that bridge Model Context Protocol (MCP) servers This monorepo contains the following packages: -### [@dvmcp-bridge](./packages/dvmcp-bridge) -The bridge implementation let's you connect MCP servers to Nostr's DVM ecosystem. Handles tool announcement, execution, and status updates. +### [@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 make their tools available +### [@dvmcp/discovery](./packages/dvmcp-discovery) +A MCP server/discovery service that aggregates MCP tools from DVMs and makes their tools available. -### [@commons](./packages/commons) +### [@dvmcp/commons](./packages/dvmcp-commons) Shared utilities and components used across DVMCP packages. -## Getting Started +## Installation & Usage + +### Quick Start with NPX (No Installation) + +You can run the packages directly using `npx` without installing them: -1. Install dependencies: ```bash -bun install +# Run the bridge +npx @dvmcp/bridge + +# Run the discovery service +npx @dvmcp/discovery ``` -2. Set up configurations: -```bash -# For the bridge -cp packages/dvmcp-bridge/config.example.yml packages/dvmcp-bridge/config.yml +The interactive CLI will guide you through configuration setup on first run. -# For the discovery service -cp packages/dvmcp-discovery/config.example.yml packages/dvmcp-discovery/config.yml +### Global Installation + +```bash +# Install the packages globally +npm install -g @dvmcp/bridge @dvmcp/discovery + +# Run the commands +dvmcp-bridge +dvmcp-discovery ``` -3. Edit the configuration files according to your needs. +## 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 +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 ## 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 @@ -43,22 +83,12 @@ bun run dev --cwd packages/dvmcp-bridge bun run dev --cwd packages/dvmcp-discovery ``` -## Production - -```bash -# Start the bridge -bun run start --cwd packages/dvmcp-bridge - -# Start the discovery service -bun run start --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/commons/README.md) +- [Commons Package](./packages/dvmcp-commons/README.md) ## Contributing diff --git a/package.json b/package.json index bfdcb96..ba1576d 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "scripts": { "start:mcp-dvm": "cd packages/mcp-dvm-bridge && bun start", "format": "prettier --write \"packages/**/*.{ts,tsx,js,jsx,json,md}\"", - "publish:commons": "cd packages/commons && npm publish --access public", + "publish:commons": "cd packages/dvmcp-commons && npm publish --access public", "publish:bridge": "cd packages/dvmcp-bridge && npm publish --access public", "publish:discovery": "cd packages/dvmcp-discovery && npm publish --access public", "publish:all": "npm run publish:commons && npm run publish:bridge && npm run publish:discovery" diff --git a/packages/dvmcp-bridge/cli.ts b/packages/dvmcp-bridge/cli.ts index 0691c21..d5561b2 100755 --- a/packages/dvmcp-bridge/cli.ts +++ b/packages/dvmcp-bridge/cli.ts @@ -1,31 +1,229 @@ -#!/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 { existsSync, copyFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; +import { parse, stringify } from 'yaml'; +import type { Config } from './src/types.js'; -// Ensure we can run from any directory const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -process.chdir(__dirname); -// Check for config file -const configPath = join(process.cwd(), 'config.yml'); -const configExamplePath = join(process.cwd(), 'config.example.yml'); +const isNpxRun = !__dirname.includes(process.cwd()); -if (!existsSync(configPath)) { - console.log('Configuration file not found at config.yml'); - console.log('You can create one by copying the example:'); - console.log('cp config.example.yml config.yml'); - - // Automatically copy example config if it exists - if (existsSync(configExamplePath)) { - console.log('Creating config.yml from example...'); - copyFileSync(configExamplePath, configPath); - console.log( - 'āœ… Created config.yml - please edit this file with your settings!' - ); - } +if (!isNpxRun) { + process.chdir(__dirname); } -// Run the application -import './index.js'; +const configPath = join(process.cwd(), 'config.yml'); +const configExamplePath = join(__dirname, 'config.example.yml'); + +async function setupConfig() { + console.log('šŸ”§ DVMCP Bridge Configuration Setup šŸ”§'); + + // 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(); + 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'); diff --git a/packages/dvmcp-bridge/package.json b/packages/dvmcp-bridge/package.json index 66bc646..cf3d31e 100644 --- a/packages/dvmcp-bridge/package.json +++ b/packages/dvmcp-bridge/package.json @@ -19,6 +19,7 @@ "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", "dev": "bun --watch index.ts", "start": "bun run index.ts", + "setup": "bun run cli.ts", "typecheck": "tsc --noEmit", "lint": "bun run typecheck && bun run format", "test": "bun test", diff --git a/packages/dvmcp-bridge/src/config.ts b/packages/dvmcp-bridge/src/config.ts index 7989a89..b507aab 100644 --- a/packages/dvmcp-bridge/src/config.ts +++ b/packages/dvmcp-bridge/src/config.ts @@ -2,11 +2,11 @@ import { parse } from 'yaml'; import { join } from 'path'; import { existsSync, readFileSync } from 'fs'; import { HEX_KEYS_REGEX } from '@dvmcp/commons/constants'; -import type { AppConfig, MCPServerConfig } from './types'; +import type { Config, MCPServerConfig } from './types'; const CONFIG_PATH = join(process.cwd(), 'config.yml'); -const TEST_CONFIG: AppConfig = { +const TEST_CONFIG: Config = { nostr: { privateKey: 'd4d4d7aae7857054596c4c0976b22a73acac3a10d30bf56db35ee038bbf0dd44', @@ -80,7 +80,7 @@ function validateMCPServers(servers: any): MCPServerConfig[] { }); } -function loadConfig(): AppConfig { +function loadConfig(): Config { if (process.env.NODE_ENV === 'test') { return TEST_CONFIG; } @@ -95,7 +95,7 @@ function loadConfig(): AppConfig { const configFile = readFileSync(CONFIG_PATH, 'utf8'); const rawConfig = parse(configFile); - const config: AppConfig = { + const config: Config = { nostr: { privateKey: validateRequiredField( rawConfig.nostr?.privateKey, diff --git a/packages/dvmcp-bridge/src/types.ts b/packages/dvmcp-bridge/src/types.ts index 4b11c33..9a47840 100644 --- a/packages/dvmcp-bridge/src/types.ts +++ b/packages/dvmcp-bridge/src/types.ts @@ -15,7 +15,7 @@ export interface WhitelistConfig { allowedPubkeys: Set | undefined; } -export interface AppConfig { +export interface Config { nostr: NostrConfig; mcp: MCPConfig; whitelist: WhitelistConfig; diff --git a/packages/dvmcp-discovery/cli.ts b/packages/dvmcp-discovery/cli.ts index 0691c21..4219821 100755 --- a/packages/dvmcp-discovery/cli.ts +++ b/packages/dvmcp-discovery/cli.ts @@ -1,31 +1,279 @@ -#!/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 { existsSync, copyFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; +import { parse, stringify } from 'yaml'; +import { HEX_KEYS_REGEX } from '@dvmcp/commons/constants'; +import type { Config } from './src/config'; -// Ensure we can run from any directory const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -process.chdir(__dirname); -// Check for config file -const configPath = join(process.cwd(), 'config.yml'); -const configExamplePath = join(process.cwd(), 'config.example.yml'); - -if (!existsSync(configPath)) { - console.log('Configuration file not found at config.yml'); - console.log('You can create one by copying the example:'); - console.log('cp config.example.yml config.yml'); - - // Automatically copy example config if it exists - if (existsSync(configExamplePath)) { - console.log('Creating config.yml from example...'); - copyFileSync(configExamplePath, configPath); - console.log( - 'āœ… Created config.yml - please edit this file with your settings!' - ); - } +const isNpxRun = !__dirname.includes(process.cwd()); +if (!isNpxRun) { + process.chdir(__dirname); } -// Run the application -import './index.js'; +const configPath = join(process.cwd(), 'config.yml'); +const configExamplePath = join(__dirname, 'config.example.yml'); + +async function setupConfig() { + console.log('šŸ”§ DVMCP Discovery Configuration Setup šŸ”§'); + + // 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' + ); + + console.log('\nšŸ“ DVM Whitelist Configuration:'); + const useWhitelist = await promptYesNo( + 'Would you like to configure a DVM whitelist?' + ); + + 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.'); + } + } + + // 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'); diff --git a/packages/dvmcp-discovery/config.example.yml b/packages/dvmcp-discovery/config.example.yml index 19cf0cc..1d2ef47 100644 --- a/packages/dvmcp-discovery/config.example.yml +++ b/packages/dvmcp-discovery/config.example.yml @@ -3,8 +3,8 @@ nostr: privateKey: "your_private_key_here" # List of relays to connect to relayUrls: - - "wss://relay.damus.io" - - "wss://relay.nostr.band" + - "wss://relay1.com" + - "wss://relay2.net" mcp: # Server name diff --git a/packages/dvmcp-discovery/package.json b/packages/dvmcp-discovery/package.json index ffdb45b..fa5aa0b 100644 --- a/packages/dvmcp-discovery/package.json +++ b/packages/dvmcp-discovery/package.json @@ -18,6 +18,7 @@ "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", "dev": "bun --watch index.ts", "start": "bun run index.ts", + "setup": "bun run cli.ts", "typecheck": "tsc --noEmit", "lint": "bun run typecheck && bun run format", "test": "bun test",