feat: working on distribution

This commit is contained in:
gzuuus
2025-02-28 17:32:37 +01:00
parent 9ba1297ae3
commit 16caaac124
30 changed files with 348 additions and 70 deletions

175
packages/dvmcp-commons/.gitignore vendored Normal file
View File

@@ -0,0 +1,175 @@
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
# Logs
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Caches
.cache
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

View File

@@ -0,0 +1,6 @@
node_modules
*.log
*.lock
.DS_Store
*.test.ts
*.test.js

View File

@@ -0,0 +1,15 @@
# commons
To install dependencies:
```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,5 @@
export const HEX_KEYS_REGEX = /^(?:[0-9a-fA-F]{64})$/;
export const DVM_ANNOUNCEMENT_KIND = 31990;
export const DVM_NOTICE_KIND = 7000;
export const TOOL_REQUEST_KIND = 5910;
export const TOOL_RESPONSE_KIND = 6910;

View File

@@ -0,0 +1,30 @@
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

@@ -0,0 +1,28 @@
import { hexToBytes } from '@noble/hashes/utils';
import { getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import type { Event, UnsignedEvent } from 'nostr-tools/pure';
export const createKeyManager = (privateKeyHex: string) => {
const privateKeyBytes = hexToBytes(privateKeyHex);
const pubkey = getPublicKey(privateKeyBytes);
class Manager {
public readonly pubkey = pubkey;
signEvent(eventInitial: UnsignedEvent): Event {
return finalizeEvent(eventInitial, privateKeyBytes);
}
createEventTemplate(kind: number): UnsignedEvent {
return {
kind,
pubkey: this.pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: '',
};
}
}
return new Manager();
};

View File

@@ -0,0 +1,197 @@
import { serve, type ServerWebSocket } from 'bun';
import {
finalizeEvent,
generateSecretKey,
type Filter,
type NostrEvent,
type UnsignedEvent,
} from 'nostr-tools';
import {
DVM_ANNOUNCEMENT_KIND,
TOOL_REQUEST_KIND,
TOOL_RESPONSE_KIND,
} from '../constants';
const relayPort = 3334;
let mockEvents: NostrEvent[] = [];
const mockDVMAnnouncement = {
kind: DVM_ANNOUNCEMENT_KIND,
content: JSON.stringify({
name: 'Test DVM',
about: 'A test DVM instance',
tools: [
{
name: 'test-echo',
description: 'Echo test tool',
inputSchema: {
type: 'object',
properties: {
text: { type: 'string' },
},
required: ['text'],
},
},
],
}),
created_at: Math.floor(Date.now() / 1000),
tags: [
['d', 'dvm-announcement'],
['k', `${TOOL_REQUEST_KIND}`],
['capabilities', 'mcp-1.0'],
['t', 'mcp'],
['t', 'test-echo'],
],
} as UnsignedEvent;
const finalizedEvent = finalizeEvent(mockDVMAnnouncement, generateSecretKey());
mockEvents.push(finalizedEvent);
const handleToolExecution = (event: NostrEvent) => {
if (event.kind === TOOL_REQUEST_KIND) {
const commandTag = event.tags.find((tag) => tag[0] === 'c');
if (commandTag && commandTag[1] === 'execute-tool') {
const request = JSON.parse(event.content);
console.log('Processing execution request:', request);
const responseEvent = {
kind: TOOL_RESPONSE_KIND,
content: JSON.stringify({
content: [
{
type: 'text',
text: `[test] ${request.parameters.text}`,
},
],
}),
created_at: Math.floor(Date.now() / 1000),
tags: [
['e', event.id],
['p', event.pubkey],
['c', 'execute-tool-response'],
],
} as UnsignedEvent;
console.log('Created response event:', responseEvent);
const finalizedResponse = finalizeEvent(
responseEvent,
generateSecretKey()
);
mockEvents.push(finalizedResponse);
return finalizedResponse;
}
}
return null;
};
const server = serve({
port: relayPort,
fetch(req, server) {
if (server.upgrade(req)) {
return;
}
return new Response('Upgrade failed', { status: 500 });
},
websocket: {
message(ws, message: string | Buffer) {
try {
const data = JSON.parse(message as string);
console.log('Received message:', data);
if (data[0] === 'REQ') {
const subscriptionId = data[1];
const filter = data[2] as Filter;
activeSubscriptions.set(subscriptionId, { ws, filter });
const filteredEvents = mockEvents.filter((event) => {
let matches = true;
if (filter.kinds && !filter.kinds.includes(event.kind)) {
matches = false;
}
if (filter.since && event.created_at < filter.since) {
matches = false;
}
return matches;
});
console.log(
`Sending ${filteredEvents.length} filtered events for subscription ${subscriptionId}`
);
filteredEvents.forEach((event) => {
ws.send(JSON.stringify(['EVENT', subscriptionId, event]));
});
ws.send(JSON.stringify(['EOSE', subscriptionId]));
} else if (data[0] === 'EVENT') {
const event: NostrEvent = data[1];
mockEvents.push(event);
const response = handleToolExecution(event);
if (response) {
console.log('Created response event:', response);
mockEvents.push(response);
for (const [subId, sub] of activeSubscriptions) {
if (
!sub.filter.kinds ||
sub.filter.kinds.includes(response.kind)
) {
if (
!sub.filter.since ||
response.created_at >= sub.filter.since
) {
console.log(`Sending response to subscription ${subId}`);
sub.ws.send(JSON.stringify(['EVENT', subId, response]));
}
}
}
}
ws.send(JSON.stringify(['OK', event.id, true, '']));
} else if (data[0] === 'CLOSE') {
const subscriptionId = data[1];
activeSubscriptions.delete(subscriptionId);
console.log(`Subscription closed: ${subscriptionId}`);
}
} catch (error) {
console.error('Error processing message:', error);
}
},
open() {
console.log('Client connected');
},
close() {
console.log('Client disconnected');
},
},
});
console.log(`Mock Nostr Relay started on port ${relayPort}`);
const activeSubscriptions = new Map<
string,
{
ws: ServerWebSocket<unknown>;
filter: Filter;
}
>();
const stop = async () => {
for (const [_, sub] of activeSubscriptions) {
try {
sub.ws.close();
} catch (e) {
console.debug('Warning during subscription cleanup:', e);
}
}
activeSubscriptions.clear();
mockEvents = [];
server.stop();
};
export { server, mockEvents, stop };

View File

@@ -0,0 +1,105 @@
import { SimplePool } from 'nostr-tools/pool';
import type { Event } from 'nostr-tools/pure';
import type { SubCloser } from 'nostr-tools/pool';
import WebSocket from 'ws';
import { useWebSocketImplementation } from 'nostr-tools/pool';
import type { Filter } from 'nostr-tools';
import {
DVM_NOTICE_KIND,
TOOL_REQUEST_KIND,
TOOL_RESPONSE_KIND,
} from '../constants';
useWebSocketImplementation(WebSocket);
export class RelayHandler {
private pool: SimplePool;
private relayUrls: string[];
private subscriptions: SubCloser[] = [];
private reconnectInterval?: ReturnType<typeof setTimeout>;
constructor(relayUrls: string[]) {
this.pool = new SimplePool();
this.relayUrls = relayUrls;
this.startReconnectLoop();
}
private startReconnectLoop() {
this.reconnectInterval = setInterval(() => {
this.relayUrls.forEach((url) => {
const normalizedUrl = new URL(url).href;
if (!this.getConnectionStatus().get(normalizedUrl)) {
this.ensureRelay(url);
}
});
}, 10000);
}
private async ensureRelay(url: string) {
try {
await this.pool.ensureRelay(url, { connectionTimeout: 5000 });
console.log(`Connected to relay: ${url}`);
} catch (error) {
console.log(`Failed to connect to relay ${url}:`, error);
}
}
async publishEvent(event: Event): Promise<void> {
try {
await Promise.any(this.pool.publish(this.relayUrls, event));
console.log(
`Event published(${event.kind}), id: ${event.id.slice(0, 12)}`
);
} catch (error) {
console.error('Failed to publish event:', error);
throw error;
}
}
subscribeToRequests(
onRequest: (event: Event) => void,
filter?: Filter
): SubCloser {
const defaultFilter: Filter = {
kinds: [TOOL_REQUEST_KIND, TOOL_RESPONSE_KIND, DVM_NOTICE_KIND],
since: Math.floor(Date.now() / 1000),
};
const filters: Filter[] = [filter || defaultFilter];
const sub = this.pool.subscribeMany(this.relayUrls, filters, {
onevent(event) {
console.log(
`Event received(${event.kind}), id: ${event.id.slice(0, 12)}`
);
onRequest(event);
},
oneose() {
console.log('Reached end of stored events');
},
onclose(reasons) {
console.log('Subscription closed:', reasons);
},
});
this.subscriptions.push(sub);
return sub;
}
async queryEvents(filter: Filter): Promise<Event[]> {
return await this.pool.querySync(this.relayUrls, filter);
}
cleanup() {
if (this.reconnectInterval) {
clearInterval(this.reconnectInterval);
}
this.subscriptions.forEach((sub) => sub.close());
this.subscriptions = [];
this.pool.close(this.relayUrls);
}
getConnectionStatus(): Map<string, boolean> {
return this.pool.listConnectionStatus();
}
}

View File

@@ -0,0 +1,26 @@
{
"name": "@dvmcp/commons",
"version": "0.1.0",
"description": "Shared utilities for DVMCP packages",
"type": "module",
"exports": {
"./*": "./*.ts",
"./*/*": "./*/*.ts"
},
"files": [
"**/*.ts",
"**/*.js",
"!**/*.test.ts",
"!**/*.test.js"
],
"scripts": {
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"publishConfig": {
"access": "public"
}
}

View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
// Enable latest features
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}