Refactor/clis (#6)

This commit is contained in:
gzuuus
2025-03-19 17:31:33 +01:00
committed by GitHub
parent a75fb9de1c
commit 09debf6807
28 changed files with 219 additions and 104 deletions

2
.gitignore vendored
View File

@@ -178,4 +178,4 @@ dist
codebase*
.aider*
.prettierrc
config.yml
config.dvmcp.yml

View File

@@ -42,8 +42,8 @@ This specification defines these event kinds:
| Kind | Description |
| ----- | ------------------------------------- |
| 31990 | DVM Service Announcement (via NIP-89) |
| 5910 | DVM-MCP Bridge Requests |
| 6910 | DVM-MCP Bridge Responses |
| 5910 | DVMCP Bridge Requests |
| 6910 | DVMCP Bridge Responses |
| 7000 | Job Feedback |
Operations are differentiated using the `c` tag, which specifies the command being executed:
@@ -339,7 +339,7 @@ For any error, DVMs MUST:
sequenceDiagram
participant Client as Nostr Client
participant Relay as Nostr Relay
participant DVM as MCP-DVM Bridge
participant DVM as DVMCP-Bridge
participant Server as MCP Server
rect rgb(240, 240, 240)

View File

@@ -4,4 +4,4 @@ node_modules
.DS_Store
*.test.ts
*.test.js
config.yml
config.dvmcp.yml

View File

@@ -12,11 +12,11 @@ A bridge implementation that connects Model Context Protocol (MCP) servers to No
## Configuration
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
When the package is run for the first time, it will detect if the `'config.dvmcp.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
cp config.example.yml config.yml
nano config.yml
cp config.example.yml config.dvmcp.yml
nano config.dvmcp.yml
```
## Usage

View File

@@ -14,7 +14,7 @@ import {
import { argv } from 'process';
import type { Config } from './src/types';
const configPath = join(process.cwd(), 'config.yml');
const configPath = join(process.cwd(), 'config.dvmcp.yml');
const configFields: Record<string, FieldConfig> = {
nostr: {

View File

@@ -15,7 +15,9 @@ mcp:
# Required client information
clientName: "DVM MCP Bridge Client"
clientVersion: "1.0.0"
# optional metadata
picture: "https://image.nostr.build/5bf2e2eb3b858bf72c23e53ed1f41ed0f65b2c8a805eaa48dd506b7cfec4ab88.png"
website: "https://github.com/gzuuus/dvmcp"
# MCP Servers Configuration accepts multiple servers
servers:
- name: "server1"

View File

@@ -0,0 +1,18 @@
nostr:
privateKey: 0e79e5e90754cb23275477a32bd1c42bee28dc78a0b10693dfd8cac6852363fd
relayUrls:
- ws://localhost:10547
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: hello
command: bun
args:
- --cwd
- /home/user/Documents/dev/gz/js-ts/bun/mcp-echo
- start:mcp
whitelist:
allowedPubkeys: []

View File

@@ -1,6 +1,6 @@
{
"name": "@dvmcp/bridge",
"version": "0.1.6",
"version": "0.1.11",
"description": "Bridge connecting MCP servers to Nostr's DVM ecosystem",
"module": "index.ts",
"type": "module",
@@ -35,7 +35,7 @@
"dotenv": "^16.4.7",
"nostr-tools": "^2.10.4",
"yaml": "^2.7.0",
"@dvmcp/commons": "^0.1.1"
"@dvmcp/commons": "^0.1.2"
},
"publishConfig": {
"access": "public"

View File

@@ -37,6 +37,9 @@ export class NostrAnnouncer {
content: JSON.stringify({
name: CONFIG.mcp.name,
about: CONFIG.mcp.about,
picture: CONFIG.mcp.picture,
website: CONFIG.mcp.website,
banner: CONFIG.mcp.banner,
tools: tools,
}),
tags: [

View File

@@ -4,7 +4,7 @@ import { existsSync, readFileSync } from 'fs';
import { HEX_KEYS_REGEX } from '@dvmcp/commons/constants';
import type { Config, MCPServerConfig } from './types';
const CONFIG_PATH = join(process.cwd(), 'config.yml');
const CONFIG_PATH = join(process.cwd(), 'config.dvmcp.yml');
const TEST_CONFIG: Config = {
nostr: {
@@ -87,7 +87,7 @@ function loadConfig(): Config {
if (!existsSync(CONFIG_PATH)) {
throw new Error(
'No config.yml file found. Please create one based on config.example.yml'
'No config.dvmcp.yml file found. Please create one based on config.example.yml'
);
}
@@ -117,6 +117,9 @@ function loadConfig(): Config {
rawConfig.mcp?.clientVersion,
'mcp.clientVersion'
),
picture: rawConfig.mcp?.picture,
website: rawConfig.mcp?.website,
banner: rawConfig.mcp?.banner,
servers: validateMCPServers(rawConfig.mcp?.servers),
},
whitelist: {

View File

@@ -24,7 +24,10 @@ export class DVMBridge {
}
private isWhitelisted(pubkey: string): boolean {
if (!CONFIG.whitelist.allowedPubkeys) {
if (
!CONFIG.whitelist.allowedPubkeys ||
CONFIG.whitelist.allowedPubkeys.size == 0
) {
return true;
}
return CONFIG.whitelist.allowedPubkeys.has(pubkey);
@@ -47,7 +50,12 @@ export class DVMBridge {
await this.nostrAnnouncer.updateAnnouncement();
console.log('Setting up request handlers...');
this.relayHandler.subscribeToRequests(this.handleRequest.bind(this));
const publicKey = keyManager.getPublicKey();
this.relayHandler.subscribeToRequests(this.handleRequest.bind(this), {
kinds: [TOOL_REQUEST_KIND],
'#p': [publicKey],
since: Math.floor(Date.now() / 1000),
});
this.isRunning = true;
console.log('DVM Bridge is now running and ready to handle requests');
@@ -88,14 +96,14 @@ export class DVMBridge {
tools,
}),
tags: [
['request', JSON.stringify(event)],
['c', 'list-tools-response'],
['e', event.id],
['p', event.pubkey],
],
});
await this.relayHandler.publishEvent(response);
} else {
} else if (command === 'execute-tool') {
const jobRequest = JSON.parse(event.content);
const processingStatus = keyManager.signEvent({
...keyManager.createEventTemplate(DVM_NOTICE_KIND),
@@ -125,7 +133,7 @@ export class DVMBridge {
...keyManager.createEventTemplate(TOOL_RESPONSE_KIND),
content: JSON.stringify(result),
tags: [
['request', JSON.stringify(event)],
['c', 'execute-tool-response'],
['e', event.id],
['p', event.pubkey],
],
@@ -159,6 +167,7 @@ export class DVMBridge {
],
});
await this.relayHandler.publishEvent(errorStatus);
return;
}
} catch (error) {
console.error('Error handling request:', error);

View File

@@ -22,4 +22,9 @@ describe('KeyManager', () => {
expect(signedEvent.sig).toBeDefined();
expect(signedEvent.pubkey).toBe(keyManager.pubkey);
});
test('should get public key', () => {
const publicKey = keyManager.getPublicKey();
expect(publicKey).toBeDefined();
});
});

View File

@@ -9,7 +9,10 @@ describe('MCPPool', () => {
let transports: any[] = [];
const serverNames = ['server1', 'server2', 'server3', 'server4'];
beforeAll(async () => {
const mockServerPath = join(import.meta.dir, 'mock-server.ts');
const mockServerPath = join(
import.meta.dir,
'../../dvmcp-commons/mock-server.ts'
);
const serverConfigs = serverNames.map((name) => ({
name,

View File

@@ -1,30 +0,0 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
export const createMockServer = async (name: string) => {
const server = new McpServer({
name: `Mock ${name}`,
version: '1.0.0',
});
server.tool(
`${name}-echo`,
`Echo tool for ${name}`,
{
text: z.string(),
},
async ({ text }) => ({
content: [{ type: 'text' as const, text: `[${name}] ${text}` }],
})
);
const transport = new StdioServerTransport();
await server.connect(transport);
return { server, transport };
};
if (import.meta.path === Bun.main) {
await createMockServer(process.argv[2] || 'default');
}

View File

@@ -8,6 +8,9 @@ export interface MCPConfig {
about: string;
clientName: string;
clientVersion: string;
picture?: string;
website?: string;
banner?: string;
servers: MCPServerConfig[];
}

View File

@@ -1,6 +1,6 @@
import { createInterface } from 'node:readline';
import { stringify } from 'yaml';
import { writeFileSync, existsSync } from 'node:fs';
import { parse, stringify } from 'yaml';
import { writeFileSync, existsSync, readFileSync } from 'node:fs';
import { randomBytes } from 'node:crypto';
import { HEX_KEYS_REGEX } from './constants';
@@ -47,10 +47,21 @@ export interface FieldConfig {
}
export class ConfigGenerator<T extends Record<string, any>> {
private currentConfig: T | null = null;
constructor(
private configPath: string,
private fields: Record<string, FieldConfig>
) {}
) {
if (existsSync(configPath)) {
try {
this.currentConfig = parse(readFileSync(configPath, 'utf8'));
} catch (error) {
console.warn(
`${CONFIG_EMOJIS.INFO} Could not parse existing configuration`
);
}
}
}
private async prompt(question: string, defaultValue = ''): Promise<string> {
const rl = createInterface({
@@ -112,6 +123,27 @@ export class ConfigGenerator<T extends Record<string, any>> {
): Promise<any> {
const emoji = config.emoji || CONFIG_EMOJIS.PROMPT;
if (currentValue !== undefined) {
if (config.type !== 'nested' && config.type !== 'object-array') {
console.log(
`${CONFIG_EMOJIS.INFO} Current value: ${
typeof currentValue === 'object'
? JSON.stringify(currentValue)
: currentValue
}`
);
}
const keepCurrent = await this.promptYesNo(
`${emoji} Keep current ${fieldName}?`,
true
);
if (keepCurrent) {
return currentValue;
}
}
if (config.type === 'nested') {
console.log(`\n${emoji} ${config.description || fieldName}`);
}
@@ -151,7 +183,20 @@ export class ConfigGenerator<T extends Record<string, any>> {
array.forEach((item: string, index: number) => {
console.log(`${CONFIG_EMOJIS.INFO} ${index + 1}. ${item}`);
});
console.log('');
if (await this.promptYesNo(`${emoji} Remove any items?`, false)) {
while (true) {
const index =
parseInt(
await this.prompt('Enter index to remove (0 to finish):')
) - 1;
if (isNaN(index) || index < 0) break;
if (index < array.length) {
array.splice(index, 1);
console.log('Item removed');
}
}
}
}
if (await this.promptYesNo(`${emoji} Add ${fieldName}?`, true)) {
@@ -187,7 +232,7 @@ export class ConfigGenerator<T extends Record<string, any>> {
},
'object-array': async () => {
const array: ArrayItem[] = currentValue || [];
let array: ArrayItem[] = currentValue || [];
if (array.length > 0) {
console.log('\nCurrent servers:');
array.forEach((item: ArrayItem, index: number) => {
@@ -196,19 +241,49 @@ export class ConfigGenerator<T extends Record<string, any>> {
);
});
console.log('');
}
const keepCurrent = await this.promptYesNo(
`${emoji} Keep current ${fieldName}?`,
true
);
if (!keepCurrent) {
array = [];
console.log(`${CONFIG_EMOJIS.INFO} Cleared existing servers.`);
} else {
if (
(await this.promptYesNo(`${emoji} Add new ${fieldName}?`, true)) &&
config.fields
await this.promptYesNo(
`${emoji} Remove any existing servers?`,
false
)
) {
while (true) {
const item = await this.handleObjectArrayItem(config.fields);
const index =
parseInt(
await this.prompt(
'Enter server number to remove (0 to finish):'
)
) - 1;
if (isNaN(index) || index < 0) break;
if (index < array.length) {
console.log(
`${CONFIG_EMOJIS.INFO} Removed server: ${array[index].name}`
);
array.splice(index, 1);
}
}
}
}
}
if (await this.promptYesNo(`${emoji} Add new ${fieldName}?`, true)) {
while (true) {
const item = await this.handleObjectArrayItem(config.fields!);
if (!item) break;
array.push(item as ArrayItem);
console.log(`${CONFIG_EMOJIS.INFO} Added new server: ${item.name}`);
console.log('');
}
}
return array;
},
@@ -269,19 +344,15 @@ export class ConfigGenerator<T extends Record<string, any>> {
}
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);
const currentValue = this.currentConfig?.[fieldName];
config[fieldName] = await this.handleField(
fieldName,
fieldConfig,
true,
currentValue
);
}
writeFileSync(this.configPath, stringify(config));

View File

@@ -22,6 +22,9 @@ export const createKeyManager = (privateKeyHex: string) => {
content: '',
};
}
getPublicKey(): string {
return this.pubkey;
}
}
return new Manager();

View File

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

View File

@@ -4,4 +4,4 @@ node_modules
.DS_Store
*.test.ts
*.test.js
config.yml
config.dvmcp.yml

View File

@@ -11,11 +11,11 @@ A MCP server implementation that aggregates tools from DVMs across the Nostr net
## Configuration
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
When the package is run for the first time, it will detect if the `config.dvmcp.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
cp config.example.yml config.yml
nano config.yml
cp config.example.yml config.dvmcp.yml
nano config.dvmcp.yml
```
## Usage

View File

@@ -12,7 +12,7 @@ import type { Config } from './src/config.js';
import { argv } from 'process';
import { existsSync } from 'fs';
const configPath = join(process.cwd(), 'config.yml');
const configPath = join(process.cwd(), 'config.dvmcp.yml');
const configFields: Record<string, FieldConfig> = {
nostr: {

View File

@@ -0,0 +1,10 @@
nostr:
privateKey: c5af6a2588250d3dfe82a9cc1c054697d7483b53850d7e6552620ff8e565630c
relayUrls:
- wss://relay.dvmcp.fun
mcp:
name: DVMCP Discovery
version: 1.0.0
about: DVMCP Discovery Server for aggregating MCP tools from DVMs
whitelist:
allowedDVMs: []

View File

@@ -1,6 +1,6 @@
{
"name": "@dvmcp/discovery",
"version": "0.1.7",
"version": "0.1.11",
"description": "Discovery service for MCP tools in the Nostr DVM ecosystem",
"module": "index.ts",
"type": "module",
@@ -33,7 +33,7 @@
"@modelcontextprotocol/sdk": "^1.5.0",
"nostr-tools": "^2.10.4",
"yaml": "^2.7.0",
"@dvmcp/commons": "^0.1.1"
"@dvmcp/commons": "^0.1.2"
},
"publishConfig": {
"access": "public"

View File

@@ -18,7 +18,7 @@ export interface Config {
};
}
const CONFIG_PATH = join(process.cwd(), 'config.yml');
const CONFIG_PATH = join(process.cwd(), 'config.dvmcp.yml');
const TEST_CONFIG: Config = {
nostr: {
@@ -79,7 +79,7 @@ function loadConfig(): Config {
if (!existsSync(CONFIG_PATH)) {
throw new Error(
'No config.yml file found. Please create one based on config.example.yml'
'No config.dvmcp.yml file found. Please create one based on config.example.yml'
);
}

View File

@@ -61,7 +61,7 @@ export class DiscoveryServer {
if (!announcement?.tools) return;
for (const tool of announcement.tools) {
const toolId = `${event.pubkey.slice(0, 12)}:${tool.name}`;
const toolId = `${tool.name}:${event.pubkey.slice(0, 4)}`;
this.toolRegistry.registerTool(toolId, tool);
}
} catch (error) {
@@ -72,7 +72,7 @@ export class DiscoveryServer {
private isAllowedDVM(pubkey: string): boolean {
if (
!CONFIG.whitelist?.allowedDVMs ||
CONFIG.whitelist.allowedDVMs.size === 0
CONFIG.whitelist.allowedDVMs.size == 0
) {
return true;
}

View File

@@ -56,7 +56,7 @@ describe('DiscoveryServer E2E', () => {
const toolIds = Array.from(toolRegistry['discoveredTools'].keys());
console.log('Available tool IDs:', toolIds);
const toolId = toolIds.find((id) => id.endsWith(`:${mockTool.name}`));
const toolId = toolIds.find((id) => id.startsWith(`${mockTool.name}`));
expect(toolId).toBeDefined();
console.log('Selected tool ID:', toolId);

View File

@@ -104,10 +104,18 @@ export class ToolExecutor {
private createToolRequest(tool: Tool, params: unknown): Event {
const request = this.keyManager.createEventTemplate(TOOL_REQUEST_KIND);
const parameters =
!tool.inputSchema.properties ||
Object.keys(tool.inputSchema.properties).length === 0
? {}
: params;
request.content = JSON.stringify({
name: tool.name,
parameters: params,
parameters,
});
request.tags.push(['c', 'execute-tool']);
return this.keyManager.signEvent(request);
}

View File

@@ -81,27 +81,34 @@ export class ToolRegistry {
}
private mapJsonSchemaToZod(schema: Tool['inputSchema']): z.ZodRawShape {
if (!schema.properties || Object.keys(schema.properties).length === 0) {
return { _: z.object({}).optional() };
}
const properties: z.ZodRawShape = {};
if (schema.properties) {
for (const [key, prop] of Object.entries(schema.properties)) {
if (typeof prop === 'object' && prop && 'type' in prop) {
let zodType: z.ZodType;
switch (prop.type) {
case 'string':
properties[key] = z.string();
zodType = z.string();
break;
case 'number':
properties[key] = z.number();
zodType = z.number();
break;
case 'integer':
properties[key] = z.number().int();
zodType = z.number().int();
break;
case 'boolean':
properties[key] = z.boolean();
zodType = z.boolean();
break;
default:
properties[key] = z.any();
}
zodType = z.any();
}
properties[key] =
Array.isArray(schema.required) && schema.required.includes(key)
? zodType
: zodType.optional();
}
}
return properties;