From 63b6a9f5595a0c7cad26f905e0af0791a13b0e7c Mon Sep 17 00:00:00 2001 From: gzuuus Date: Tue, 25 Mar 2025 18:11:39 +0100 Subject: [PATCH] refactor: improve code maintainability and add direct server tool registration --- packages/dvmcp-discovery/README.md | 23 ++++ packages/dvmcp-discovery/cli.ts | 127 +++++++++++++++++- packages/dvmcp-discovery/index.ts | 22 ++- packages/dvmcp-discovery/src/config.ts | 28 ++++ .../dvmcp-discovery/src/direct-discovery.ts | 90 +++++++++++++ .../dvmcp-discovery/src/discovery-server.ts | 47 +++++-- packages/dvmcp-discovery/src/nip19-utils.ts | 81 +++++++++++ 7 files changed, 399 insertions(+), 19 deletions(-) create mode 100644 packages/dvmcp-discovery/src/direct-discovery.ts create mode 100644 packages/dvmcp-discovery/src/nip19-utils.ts diff --git a/packages/dvmcp-discovery/README.md b/packages/dvmcp-discovery/README.md index f8073cd..5b7f773 100644 --- a/packages/dvmcp-discovery/README.md +++ b/packages/dvmcp-discovery/README.md @@ -8,6 +8,7 @@ A MCP server implementation that aggregates tools from DVMs across the Nostr net - Provides a unified interface to access tools from multiple DVMs - Tool execution handling and status tracking - Configurable DVM whitelist +- Direct connection to specific providers or servers ## Configuration @@ -46,6 +47,28 @@ For production: bun run start ``` +### Direct Connection Options + +You can connect directly to a specific provider or server without a configuration file: + +#### Connect to a Provider + +Use the `--provider` flag followed by an nprofile entity to discover and register all tools from a specific provider: + +```bash +bun run start --provider nprofile1... +``` + +#### Connect to a Server + +Use the `--server` flag followed by an naddr entity to register only the tools from a specific server: + +```bash +bun run start --server naddr1... +``` + +This is useful when you want to work with a specific subset of tools rather than discovering all tools from a provider. + ## Testing Run the test suite: diff --git a/packages/dvmcp-discovery/cli.ts b/packages/dvmcp-discovery/cli.ts index 771e7d5..cf2d266 100755 --- a/packages/dvmcp-discovery/cli.ts +++ b/packages/dvmcp-discovery/cli.ts @@ -11,13 +11,41 @@ import { join, resolve } from 'path'; import type { Config } from './src/config.js'; import { argv } from 'process'; import { existsSync } from 'fs'; -import { setConfigPath } from './src/config.js'; +import { + setConfigPath, + setInMemoryConfig, + createDefaultConfig, +} from './src/config.js'; +import { decodeNaddr, decodeNprofile } from './src/nip19-utils.js'; +import { + fetchProviderAnnouncement, + fetchServerAnnouncement, + parseAnnouncement, + type DVMAnnouncement, +} from './src/direct-discovery.js'; +import type { DirectServerInfo } from './index.js'; const defaultConfigPath = join(process.cwd(), 'config.dvmcp.yml'); let configPath = defaultConfigPath; +// Check for provider flag +const providerArgIndex = argv.indexOf('--provider'); +const hasProviderFlag = providerArgIndex !== -1 && argv[providerArgIndex + 1]; +const providerValue = hasProviderFlag ? argv[providerArgIndex + 1] : null; + +// Check for server flag +const serverArgIndex = argv.indexOf('--server'); +const hasServerFlag = serverArgIndex !== -1 && argv[serverArgIndex + 1]; +const serverValue = hasServerFlag ? argv[serverArgIndex + 1] : null; + +// Check for config path flag (only used if provider and server flags are not present) const configPathArgIndex = argv.indexOf('--config-path'); -if (configPathArgIndex !== -1 && argv[configPathArgIndex + 1]) { +if ( + !hasProviderFlag && + !hasServerFlag && + configPathArgIndex !== -1 && + argv[configPathArgIndex + 1] +) { configPath = resolve(argv[configPathArgIndex + 1]); console.log(`Using config path: ${configPath}`); setConfigPath(configPath); @@ -89,25 +117,110 @@ const configure = async () => { await generator.generate(); }; -const runApp = async () => { +const runApp = async (directServerInfo?: DirectServerInfo) => { const main = await import('./index.js'); console.log(`${CONFIG_EMOJIS.INFO} Running main application...`); - await main.default(); + await main.default(directServerInfo); +}; + +const setupInMemoryConfig = (relays: string[], pubkey: string) => { + const config = createDefaultConfig(relays); + + config.whitelist = { + allowedDVMs: new Set([pubkey]), + }; + + setInMemoryConfig(config); +}; + +const setupFromProvider = async (nprofileEntity: string) => { + console.log( + `${CONFIG_EMOJIS.INFO} Setting up from provider: ${nprofileEntity}` + ); + + const providerData = decodeNprofile(nprofileEntity); + if (!providerData) { + console.error('Invalid nprofile entity'); + process.exit(1); + } + + try { + const announcement = await fetchProviderAnnouncement(providerData); + if (!announcement) { + console.error('Failed to fetch provider announcement'); + process.exit(1); + } + + setupInMemoryConfig(providerData.relays, providerData.pubkey); + console.log(`${CONFIG_EMOJIS.SUCCESS} Successfully set up from provider`); + } catch (error) { + console.error(`Error: ${error}`); + process.exit(1); + } +}; + +const setupFromServer = async (naddrEntity: string) => { + console.log(`${CONFIG_EMOJIS.INFO} Setting up from server: ${naddrEntity}`); + + const addrData = decodeNaddr(naddrEntity); + if (!addrData) { + console.error('Invalid naddr entity'); + process.exit(1); + } + + try { + const announcement = await fetchServerAnnouncement(addrData); + if (!announcement) { + console.error('Failed to fetch server announcement'); + process.exit(1); + } + + const parsedAnnouncement = parseAnnouncement(announcement); + if (!parsedAnnouncement) { + console.error('Failed to parse server announcement'); + process.exit(1); + } + + setupInMemoryConfig(addrData.relays, addrData.pubkey); + console.log(`${CONFIG_EMOJIS.SUCCESS} Successfully set up from server`); + + return { + pubkey: addrData.pubkey, + announcement: parsedAnnouncement, + }; + } catch (error) { + console.error(`Error: ${error}`); + process.exit(1); + } }; const cliMain = async () => { + // Handle --configure flag if (argv.includes('--configure')) { await configure(); + return; } - if (!existsSync(configPath)) { + // Handle --provider flag + if (hasProviderFlag && providerValue) { + await setupFromProvider(providerValue); + await runApp(); + } + // Handle --server flag + else if (hasServerFlag && serverValue) { + const serverInfo = await setupFromServer(serverValue); + await runApp(serverInfo); + } + // Handle normal config file mode + else if (!existsSync(configPath)) { console.log( `${CONFIG_EMOJIS.INFO} No configuration file found. Starting setup...` ); await configure(); + await runApp(); + } else { + await runApp(); } - - await runApp(); }; cliMain().catch(console.error); diff --git a/packages/dvmcp-discovery/index.ts b/packages/dvmcp-discovery/index.ts index d24dc7c..fe5ac86 100644 --- a/packages/dvmcp-discovery/index.ts +++ b/packages/dvmcp-discovery/index.ts @@ -1,11 +1,29 @@ import { CONFIG } from './src/config'; import { DiscoveryServer } from './src/discovery-server'; +import type { DVMAnnouncement } from './src/direct-discovery'; -async function main() { +export interface DirectServerInfo { + pubkey: string; + announcement: DVMAnnouncement; +} + +async function main(directServerInfo?: DirectServerInfo | null) { try { const server = new DiscoveryServer(CONFIG); - await server.start(); + if (directServerInfo) { + // If we have direct server info, register tools from that server only + console.log( + `Using direct server with pubkey: ${directServerInfo.pubkey}` + ); + await server.registerDirectServerTools( + directServerInfo.pubkey, + directServerInfo.announcement + ); + } else { + // Otherwise do normal discovery + await server.start(); + } console.log(`DVMCP Discovery Server (${CONFIG.mcp.version}) started`); console.log(`Connected to ${CONFIG.nostr.relayUrls.length} relays`); diff --git a/packages/dvmcp-discovery/src/config.ts b/packages/dvmcp-discovery/src/config.ts index ecca174..e1492b1 100644 --- a/packages/dvmcp-discovery/src/config.ts +++ b/packages/dvmcp-discovery/src/config.ts @@ -2,6 +2,8 @@ import { parse } from 'yaml'; import { join } from 'path'; import { existsSync, readFileSync } from 'fs'; import { HEX_KEYS_REGEX } from '@dvmcp/commons/constants'; +import { generateSecretKey } from 'nostr-tools/pure'; +import { bytesToHex } from '@noble/hashes/utils'; export interface Config { nostr: { @@ -19,11 +21,16 @@ export interface Config { } let CONFIG_PATH = join(process.cwd(), 'config.dvmcp.yml'); +let IN_MEMORY_CONFIG: Config | null = null; export function setConfigPath(path: string) { CONFIG_PATH = path.startsWith('/') ? path : join(process.cwd(), path); } +export function setInMemoryConfig(config: Config) { + IN_MEMORY_CONFIG = config; +} + const TEST_CONFIG: Config = { nostr: { privateKey: @@ -77,6 +84,10 @@ function validateRelayUrls(urls: any): string[] { } function loadConfig(): Config { + if (IN_MEMORY_CONFIG) { + return IN_MEMORY_CONFIG; + } + if (process.env.NODE_ENV === 'test') { return TEST_CONFIG; } @@ -126,4 +137,21 @@ function loadConfig(): Config { } } +export function createDefaultConfig(relayUrls: string[]): Config { + return { + nostr: { + privateKey: bytesToHex(generateSecretKey()), + relayUrls: validateRelayUrls(relayUrls), + }, + mcp: { + name: 'DVMCP Discovery', + version: '1.0.0', + about: 'DVMCP Discovery Server for aggregating MCP tools from DVMs', + }, + whitelist: { + allowedDVMs: new Set(), + }, + }; +} + export const CONFIG = loadConfig(); diff --git a/packages/dvmcp-discovery/src/direct-discovery.ts b/packages/dvmcp-discovery/src/direct-discovery.ts new file mode 100644 index 0000000..9b83c2f --- /dev/null +++ b/packages/dvmcp-discovery/src/direct-discovery.ts @@ -0,0 +1,90 @@ +import type { Event, Filter } from 'nostr-tools'; +import { RelayHandler } from '@dvmcp/commons/nostr/relay-handler'; +import { DVM_ANNOUNCEMENT_KIND } from '@dvmcp/commons/constants'; +import type { NaddrData, NprofileData } from './nip19-utils'; + +export interface DVMAnnouncement { + name: string; + about: string; + tools: any[]; +} + +async function fetchAnnouncement( + relays: string[], + filter: Filter, + errorMessage: string +): Promise { + // Create a new relay handler with the provided relays + const relayHandler = new RelayHandler(relays); + + try { + // Query for the announcement event + console.log('Querying for announcement event:', filter); + const events = await relayHandler.queryEvents(filter); + + if (events.length === 0) { + console.error(errorMessage); + return null; + } + + return events[0]; + } catch (error) { + console.error(`Failed to fetch announcement: ${error}`); + return null; + } finally { + relayHandler.cleanup(); + } +} + +export async function fetchProviderAnnouncement( + providerData: NprofileData +): Promise { + // Query for the provider's DVM announcement + const filter: Filter = { + kinds: [DVM_ANNOUNCEMENT_KIND], + authors: [providerData.pubkey], + '#t': ['mcp'], + }; + + const events = await fetchAnnouncement( + providerData.relays, + filter, + 'No DVM announcement found for provider' + ); + + if (!events) return null; + + // If we have multiple events, sort by created_at to get the most recent announcement + if (Array.isArray(events)) { + events.sort((a, b) => b.created_at - a.created_at); + return events[0]; + } + + return events; +} + +export async function fetchServerAnnouncement( + addrData: NaddrData +): Promise { + // Query for the specific announcement event + const filter: Filter = { + kinds: [addrData.kind], + authors: [addrData.pubkey], + '#d': addrData.identifier ? [addrData.identifier] : undefined, + }; + + return fetchAnnouncement( + addrData.relays, + filter, + 'No DVM announcement found for the specified coordinates' + ); +} + +export function parseAnnouncement(event: Event): DVMAnnouncement | null { + try { + return JSON.parse(event.content); + } catch (error) { + console.error(`Failed to parse announcement: ${error}`); + return null; + } +} diff --git a/packages/dvmcp-discovery/src/discovery-server.ts b/packages/dvmcp-discovery/src/discovery-server.ts index 2d41bf4..2a76db0 100644 --- a/packages/dvmcp-discovery/src/discovery-server.ts +++ b/packages/dvmcp-discovery/src/discovery-server.ts @@ -8,12 +8,7 @@ import { DVM_ANNOUNCEMENT_KIND } from '@dvmcp/commons/constants'; import type { Tool } from '@modelcontextprotocol/sdk/types.js'; import { ToolRegistry } from './tool-registry'; import { ToolExecutor } from './tool-executor'; - -interface DVMAnnouncement { - name: string; - about: string; - tools: Tool[]; -} +import type { DVMAnnouncement } from './direct-discovery'; export class DiscoveryServer { private mcpServer: McpServer; @@ -54,6 +49,17 @@ export class DiscoveryServer { await Promise.all(events.map((event) => this.handleDVMAnnouncement(event))); } + private createToolId(toolName: string, pubkey: string): string { + return `${toolName}:${pubkey.slice(0, 4)}`; + } + + private registerToolsFromAnnouncement(pubkey: string, tools: Tool[]): void { + for (const tool of tools) { + const toolId = this.createToolId(tool.name, pubkey); + this.toolRegistry.registerTool(toolId, tool, pubkey); + } + } + private async handleDVMAnnouncement(event: Event) { try { if (!this.isAllowedDVM(event.pubkey)) { @@ -64,10 +70,7 @@ export class DiscoveryServer { const announcement = this.parseAnnouncement(event.content); if (!announcement?.tools) return; - for (const tool of announcement.tools) { - const toolId = `${tool.name}:${event.pubkey.slice(0, 4)}`; - this.toolRegistry.registerTool(toolId, tool, event.pubkey); - } + this.registerToolsFromAnnouncement(event.pubkey, announcement.tools); } catch (error) { console.error('Error processing DVM announcement:', error); } @@ -95,6 +98,30 @@ export class DiscoveryServer { return this.toolRegistry.listTools(); } + public async registerDirectServerTools( + pubkey: string, + announcement: DVMAnnouncement + ) { + console.log('Starting discovery server with direct server tools...'); + + if (!announcement?.tools) { + console.error('No tools found in server announcement'); + return; + } + + this.registerToolsFromAnnouncement(pubkey, announcement.tools); + + console.log( + `Registered ${announcement.tools.length} tools from direct server` + ); + + // Connect the MCP server + const transport = new StdioServerTransport(); + await this.mcpServer.connect(transport); + + console.log('DVMCP Discovery Server started'); + } + public async start() { console.log('Starting discovery server...'); diff --git a/packages/dvmcp-discovery/src/nip19-utils.ts b/packages/dvmcp-discovery/src/nip19-utils.ts new file mode 100644 index 0000000..0c9d409 --- /dev/null +++ b/packages/dvmcp-discovery/src/nip19-utils.ts @@ -0,0 +1,81 @@ +import { nip19 } from 'nostr-tools'; +import { DVM_ANNOUNCEMENT_KIND } from '@dvmcp/commons/constants'; + +// Default fallback relay when no relay hints are provided +export const DEFAULT_FALLBACK_RELAY = 'wss://relay.dvmcp.fun'; + +export interface NprofileData { + pubkey: string; + relays: string[]; +} + +export interface NaddrData { + identifier: string; + pubkey: string; + kind: number; + relays: string[]; +} + +/** + * Decodes an nprofile NIP-19 entity + * @param nprofileEntity The bech32-encoded nprofile string + * @returns The decoded nprofile data or null if invalid + */ +export function decodeNprofile(nprofileEntity: string): NprofileData | null { + try { + const { type, data } = nip19.decode(nprofileEntity); + if (type !== 'nprofile') { + console.error(`Expected nprofile, got ${type}`); + return null; + } + + // Ensure we have at least one relay by using the fallback if necessary + const profileData = data as NprofileData; + if (!profileData.relays || profileData.relays.length === 0) { + console.log( + `No relay hints in nprofile, using fallback relay: ${DEFAULT_FALLBACK_RELAY}` + ); + profileData.relays = [DEFAULT_FALLBACK_RELAY]; + } + + return profileData; + } catch (error) { + console.error(`Failed to decode nprofile: ${error}`); + return null; + } +} + +/** + * Decodes an naddr NIP-19 entity + * @param naddrEntity The bech32-encoded naddr string + * @returns The decoded naddr data or null if invalid + */ +export function decodeNaddr(naddrEntity: string): NaddrData | null { + try { + const { type, data } = nip19.decode(naddrEntity); + if (type !== 'naddr') { + console.error(`Expected naddr, got ${type}`); + return null; + } + + // Validate that the kind is a DVM announcement + if (data.kind !== DVM_ANNOUNCEMENT_KIND) { + console.error(`Expected kind ${DVM_ANNOUNCEMENT_KIND}, got ${data.kind}`); + return null; + } + + // Ensure we have at least one relay by using the fallback if necessary + const addrData = data as NaddrData; + if (!addrData.relays || addrData.relays.length === 0) { + console.log( + `No relay hints in naddr, using fallback relay: ${DEFAULT_FALLBACK_RELAY}` + ); + addrData.relays = [DEFAULT_FALLBACK_RELAY]; + } + + return addrData; + } catch (error) { + console.error(`Failed to decode naddr: ${error}`); + return null; + } +}