refactor: improve code maintainability and add direct server tool registration

This commit is contained in:
gzuuus
2025-03-25 18:11:39 +01:00
parent 6b1442178f
commit 63b6a9f559
7 changed files with 399 additions and 19 deletions

View File

@@ -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:

View File

@@ -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();
}
};
cliMain().catch(console.error);

View File

@@ -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);
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`);

View File

@@ -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();

View File

@@ -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<Event | null> {
// 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<Event | null> {
// 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<Event | null> {
// 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;
}
}

View File

@@ -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...');

View File

@@ -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;
}
}