refactor: clis, config-generator, readmes (#4)

This commit is contained in:
gzuuus
2025-03-01 15:38:13 +01:00
committed by GitHub
parent db0da6909c
commit a75fb9de1c
12 changed files with 556 additions and 600 deletions

View File

@@ -1,51 +1,35 @@
# DVMCP: Data Vending Machine Context Protocol # 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. 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 ## Packages
This monorepo contains the following packages: This monorepo contains the following packages:
### [@dvmcp/bridge](./packages/dvmcp-bridge) ### [@dvmcp/bridge](./packages/dvmcp-bridge)
The bridge implementation that connects MCP servers to Nostr's DVM ecosystem. Handles tool announcement, execution, and status updates. The bridge implementation that connects MCP servers to Nostr's DVM ecosystem. Handles tool announcement, execution, and status updates.
### [@dvmcp/discovery](./packages/dvmcp-discovery) ### [@dvmcp/discovery](./packages/dvmcp-discovery)
A MCP server/discovery service that aggregates MCP tools from DVMs and makes their tools available. A MCP server/discovery service that aggregates MCP tools from DVMs and makes their tools available.
### [@dvmcp/commons](./packages/dvmcp-commons) ### [@dvmcp/commons](./packages/dvmcp-commons)
Shared utilities and components used across DVMCP packages. Shared utilities and components used across DVMCP packages.
## Installation & Usage ## Installation & Usage
**Prerequisite:** Ensure you have [Bun](https://bun.sh/) installed.
### Quick Start with NPX (No Installation) ### Quick Start with NPX (No Installation)
You can run the packages directly using `npx` without installing them: You can run the packages directly using `npx` without installing them:
```bash ```bash
# Run the bridge # Run the bridge
npx @dvmcp/bridge npx @dvmcp/bridge
# Run the discovery service # Run the discovery service
npx @dvmcp/discovery npx @dvmcp/discovery
``` ```
The interactive CLI will guide you through configuration setup on first run. The interactive CLI will guide you through configuration setup on first run.
### Global Installation ### Global Installation
```bash ```bash
# Install the packages globally # Install the packages globally
npm install -g @dvmcp/bridge @dvmcp/discovery npm install -g @dvmcp/bridge @dvmcp/discovery
# Run the commands # Run the commands
dvmcp-bridge dvmcp-bridge
dvmcp-discovery dvmcp-discovery
``` ```
## Setting Up a Bridge ## Setting Up a Bridge
To expose your MCP server as a DVM on Nostr: To expose your MCP server as a DVM on Nostr:
1. Navigate to the directory where you want to configure the bridge 1. Navigate to the directory where you want to configure the bridge
2. Run: `npx @dvmcp/bridge` 2. Run: `npx @dvmcp/bridge`
3. Follow the interactive setup to configure: 3. Follow the interactive setup to configure:
@@ -53,52 +37,35 @@ To expose your MCP server as a DVM on Nostr:
- Nostr private key (or generate a new one) - Nostr private key (or generate a new one)
- Relays to connect to - Relays to connect to
4. The bridge will start and begin proxying requests between Nostr and your MCP server 4. The bridge will start and begin proxying requests between Nostr and your MCP server
## Setting Up a Discovery Service ## Setting Up a Discovery Service
To aggregate MCP tools from DVMs: To aggregate MCP tools from DVMs:
1. Navigate to your desired directory 1. Navigate to your desired directory
2. Run: `npx @dvmcp/discovery` 2. Run: `npx @dvmcp/discovery`
3. Follow the setup to configure: 3. Follow the setup to configure:
- Nostr private key - Nostr private key
- Relays to monitor - Relays to monitor
## Development ## Development
For contributors to this repository: For contributors to this repository:
```bash ```bash
# Clone the repo # Clone the repo
git clone https://github.com/gzuuus/dvmcp.git git clone https://github.com/gzuuus/dvmcp.git
cd dvmcp cd dvmcp
# Install dependencies # Install dependencies
bun install bun install
# Start the bridge in development mode # Start the bridge in development mode
bun run dev --cwd packages/dvmcp-bridge bun run dev --cwd packages/dvmcp-bridge
# Start the discovery service in development mode # Start the discovery service in development mode
bun run dev --cwd packages/dvmcp-discovery bun run dev --cwd packages/dvmcp-discovery
``` ```
## Documentation ## Documentation
- [DVMCP Specification](./docs/dvmcp-spec.md) - [DVMCP Specification](./docs/dvmcp-spec.md)
- [Bridge Package](./packages/dvmcp-bridge/README.md) - [Bridge Package](./packages/dvmcp-bridge/README.md)
- [Discovery Package](./packages/dvmcp-discovery/README.md) - [Discovery Package](./packages/dvmcp-discovery/README.md)
- [Commons Package](./packages/dvmcp-commons/README.md) - [Commons Package](./packages/dvmcp-commons/README.md)
## Contributing ## Contributing
Contributions are welcome! Please feel free to submit pull requests or create issues. Contributions are welcome! Please feel free to submit pull requests or create issues.
## License ## License
[MIT License](LICENSE) [MIT License](LICENSE)
## Related Projects ## Related Projects
- [Model Context Protocol](https://modelcontextprotocol.io) - [Model Context Protocol](https://modelcontextprotocol.io)
- [Nostr Protocol](https://github.com/nostr-protocol/nips) - [Nostr Protocol](https://github.com/nostr-protocol/nips)

View File

@@ -12,41 +12,30 @@ A bridge implementation that connects Model Context Protocol (MCP) servers to No
## Configuration ## 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 ```bash
cp config.example.yml config.yml cp config.example.yml config.yml
``` nano 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']
``` ```
## Usage ## 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 ```bash
bun run dev bun run dev
``` ```
Production mode: For production:
```bash ```bash
bun run start bun run start

View File

@@ -1,230 +1,127 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import { randomBytes } from 'node:crypto'; import { existsSync } from 'node:fs';
import { createInterface } from 'node:readline'; import { join } from 'path';
import { writeFileSync, existsSync } from 'node:fs'; import {
import { join, dirname } from 'node:path'; ConfigGenerator,
import { fileURLToPath } from 'node:url'; type FieldConfig,
import { parse, stringify } from 'yaml'; } from '@dvmcp/commons/config-generator';
import type { Config } from './src/types.js'; import {
generateHexKey,
const __filename = fileURLToPath(import.meta.url); validateHexKey,
const __dirname = dirname(__filename); validateRelayUrl,
CONFIG_EMOJIS,
const isNpxRun = !__dirname.includes(process.cwd()); } from '@dvmcp/commons/config-generator';
import { argv } from 'process';
if (!isNpxRun) { import type { Config } from './src/types';
process.chdir(__dirname);
}
const configPath = join(process.cwd(), 'config.yml'); const configPath = join(process.cwd(), 'config.yml');
const configExamplePath = join(__dirname, 'config.example.yml');
async function setupConfig() { const configFields: Record<string, FieldConfig> = {
console.log('🔧 DVMCP Bridge Configuration Setup 🔧'); 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 const configure = async () => {
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( console.log(
` ${i + 1}. ${server.name} (${server.command} ${server.args.join(' ')})` `${CONFIG_EMOJIS.SETUP} DVMCP Bridge Configuration Setup ${CONFIG_EMOJIS.SETUP}`
); );
}); const generator = new ConfigGenerator<Config>(configPath, configFields);
} else { await generator.generate();
console.log(' No servers configured yet.'); };
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();
} }
const configureServers = await promptYesNo( if (!existsSync(configPath)) {
'Would you like to configure MCP servers?' console.log(
`${CONFIG_EMOJIS.INFO} No configuration file found. Starting setup...`
); );
if (configureServers) { await configure();
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:'); await runApp();
const useWhitelist = await promptYesNo( };
'Would you like to configure a public key whitelist?'
);
if (useWhitelist) { cliMain().catch(console.error);
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<string>();
}
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<string> {
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<boolean> {
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');

View File

@@ -2,7 +2,6 @@ import { DVMBridge } from './src/dvm-bridge';
async function main() { async function main() {
const bridge = new DVMBridge(); const bridge = new DVMBridge();
const shutdown = async () => { const shutdown = async () => {
console.log('Shutting down...'); console.log('Shutting down...');
try { try {
@@ -13,10 +12,8 @@ async function main() {
process.exit(1); process.exit(1);
} }
}; };
process.on('SIGINT', shutdown); process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown); process.on('SIGTERM', shutdown);
try { try {
await bridge.start(); await bridge.start();
} catch (error) { } catch (error) {
@@ -25,4 +22,4 @@ async function main() {
} }
} }
main(); export default main;

View File

@@ -1,6 +1,6 @@
{ {
"name": "@dvmcp/bridge", "name": "@dvmcp/bridge",
"version": "0.1.3", "version": "0.1.6",
"description": "Bridge connecting MCP servers to Nostr's DVM ecosystem", "description": "Bridge connecting MCP servers to Nostr's DVM ecosystem",
"module": "index.ts", "module": "index.ts",
"type": "module", "type": "module",
@@ -18,8 +18,7 @@
"scripts": { "scripts": {
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
"dev": "bun --watch index.ts", "dev": "bun --watch index.ts",
"start": "bun run index.ts", "start": "bun run cli.ts",
"setup": "bun run cli.ts",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "bun run typecheck && bun run format", "lint": "bun run typecheck && bun run format",
"test": "bun test", "test": "bun test",
@@ -36,7 +35,7 @@
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"nostr-tools": "^2.10.4", "nostr-tools": "^2.10.4",
"yaml": "^2.7.0", "yaml": "^2.7.0",
"@dvmcp/commons": "^0.1.0" "@dvmcp/commons": "^0.1.1"
}, },
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"

View File

@@ -1,15 +1,3 @@
# commons # commons
To install dependencies: Common package for common functions and constants
```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.

View File

@@ -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<string, FieldConfig>;
comment?: string;
emoji?: string;
}
export class ConfigGenerator<T extends Record<string, any>> {
constructor(
private configPath: string,
private fields: Record<string, FieldConfig>
) {}
private async prompt(question: string, defaultValue = ''): Promise<string> {
const rl = createInterface({
input: process.stdin,
output: process.stdout,
});
const answer = await new Promise<string>((resolve) => {
rl.question(
`${question}${defaultValue ? ` (${defaultValue})` : ''} `,
(answer) => resolve(answer || defaultValue)
);
});
rl.close();
return answer;
}
private async promptYesNo(
question: string,
defaultValue = false
): Promise<boolean> {
const answer = await this.prompt(
`${question} (${defaultValue ? 'Y/n' : 'y/N'})`
);
return answer.trim() === ''
? defaultValue
: answer.toLowerCase().startsWith('y');
}
private async handleObjectArrayItem(
fields: Record<string, FieldConfig>
): Promise<Record<string, any> | null> {
const item: Record<string, any> = { 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<any> {
const emoji = config.emoji || CONFIG_EMOJIS.PROMPT;
if (config.type === 'nested') {
console.log(`\n${emoji} ${config.description || fieldName}`);
}
const handlers: Record<FieldType, () => Promise<any>> = {
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<string, any> = {};
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<boolean> {
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<string> {
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<T> {
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<string, any> = {};
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;
}
};

View File

@@ -1,6 +1,6 @@
{ {
"name": "@dvmcp/commons", "name": "@dvmcp/commons",
"version": "0.1.0", "version": "0.1.1",
"description": "Shared utilities for DVMCP packages", "description": "Shared utilities for DVMCP packages",
"type": "module", "type": "module",
"exports": { "exports": {

View File

@@ -11,41 +11,30 @@ A MCP server implementation that aggregates tools from DVMs across the Nostr net
## Configuration ## 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 ```bash
cp config.example.yml config.yml cp config.example.yml config.yml
``` nano 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'
``` ```
## Usage ## 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 ```bash
bun run dev bun run dev
``` ```
Production mode: For production:
```bash ```bash
bun run start bun run start

View File

@@ -1,280 +1,104 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import { randomBytes } from 'node:crypto'; import {
import { createInterface } from 'node:readline'; ConfigGenerator,
import { writeFileSync, existsSync } from 'node:fs'; generateHexKey,
import { join, dirname } from 'node:path'; type FieldConfig,
import { fileURLToPath } from 'node:url'; CONFIG_EMOJIS,
import { parse, stringify } from 'yaml'; validateHexKey,
import { HEX_KEYS_REGEX } from '@dvmcp/commons/constants'; validateRelayUrl,
import type { Config } from './src/config'; } from '@dvmcp/commons/config-generator';
import { join } from 'path';
const __filename = fileURLToPath(import.meta.url); import type { Config } from './src/config.js';
const __dirname = dirname(__filename); import { argv } from 'process';
import { existsSync } from 'fs';
const isNpxRun = !__dirname.includes(process.cwd());
if (!isNpxRun) {
process.chdir(__dirname);
}
const configPath = join(process.cwd(), 'config.yml'); const configPath = join(process.cwd(), 'config.yml');
const configExamplePath = join(__dirname, 'config.example.yml');
async function setupConfig() { const configFields: Record<string, FieldConfig> = {
console.log('🔧 DVMCP Discovery Configuration Setup 🔧'); 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 const configure = async () => {
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( console.log(
'❌ Invalid key format. Please enter a 32-byte hex string.' `${CONFIG_EMOJIS.SETUP} DVMCP Discovery Configuration Setup ${CONFIG_EMOJIS.SETUP}`
); );
} const generator = new ConfigGenerator<Config>(configPath, configFields);
} await generator.generate();
} else { };
// Generate a random key
config.nostr.privateKey = Buffer.from(randomBytes(32)).toString('hex'); const runApp = async () => {
console.log(`Generated new private key: ${config.nostr.privateKey}`); 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();
} }
console.log('\n🔄 Relay Configuration:'); if (!existsSync(configPath)) {
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<string>();
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( console.log(
'❌ Invalid public key format. Please enter a 32-byte hex string.' `${CONFIG_EMOJIS.INFO} No configuration file found. Starting setup...`
); );
} await configure();
} 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 await runApp();
writeFileSync(configPath, stringify(config)); };
console.log(`\n✅ Configuration saved to ${configPath}`);
}
async function prompt(question: string, defaultValue = ''): Promise<string> { cliMain().catch(console.error);
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<boolean> {
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');

View File

@@ -24,4 +24,4 @@ async function main() {
} }
} }
main(); export default main;

View File

@@ -1,6 +1,6 @@
{ {
"name": "@dvmcp/discovery", "name": "@dvmcp/discovery",
"version": "0.1.4", "version": "0.1.7",
"description": "Discovery service for MCP tools in the Nostr DVM ecosystem", "description": "Discovery service for MCP tools in the Nostr DVM ecosystem",
"module": "index.ts", "module": "index.ts",
"type": "module", "type": "module",
@@ -17,8 +17,7 @@
"scripts": { "scripts": {
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
"dev": "bun --watch index.ts", "dev": "bun --watch index.ts",
"start": "bun run index.ts", "start": "bun run cli.ts",
"setup": "bun run cli.ts",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "bun run typecheck && bun run format", "lint": "bun run typecheck && bun run format",
"test": "bun test", "test": "bun test",
@@ -34,7 +33,7 @@
"@modelcontextprotocol/sdk": "^1.5.0", "@modelcontextprotocol/sdk": "^1.5.0",
"nostr-tools": "^2.10.4", "nostr-tools": "^2.10.4",
"yaml": "^2.7.0", "yaml": "^2.7.0",
"@dvmcp/commons": "^0.1.0" "@dvmcp/commons": "^0.1.1"
}, },
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"