mirror of
https://github.com/aljazceru/dvmcp.git
synced 2025-12-17 05:14:24 +01:00
refactor: improve code maintainability and add direct server tool registration
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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();
|
||||
|
||||
90
packages/dvmcp-discovery/src/direct-discovery.ts
Normal file
90
packages/dvmcp-discovery/src/direct-discovery.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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...');
|
||||
|
||||
|
||||
81
packages/dvmcp-discovery/src/nip19-utils.ts
Normal file
81
packages/dvmcp-discovery/src/nip19-utils.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user