From 4ca39c4ec69e2d4d44a641512a039e11bda18a90 Mon Sep 17 00:00:00 2001 From: gzuuus <116975404+gzuuus@users.noreply.github.com> Date: Sun, 16 Feb 2025 13:34:42 +0000 Subject: [PATCH] feat: dvmcp discover (#3) --- bun.lock | 47 ++++- packages/commons/.gitignore | 175 ++++++++++++++++++ packages/commons/README.md | 15 ++ packages/commons/constants.ts | 5 + .../src/tests => commons}/mock-server.ts | 0 .../keys.ts => commons/nostr/key-manager.ts} | 3 - packages/commons/nostr/mock-relay.ts | 175 ++++++++++++++++++ .../nostr/relay-handler.ts} | 21 ++- packages/commons/package.json | 14 ++ packages/commons/tsconfig.json | 27 +++ packages/dvm-mcp-bridge/.gitkeep | 0 .../config.example.yml | 0 packages/dvmcp-bridge/index.ts | 28 +++ .../package.json | 12 +- .../nostr => dvmcp-bridge/src}/announcer.ts | 17 +- .../src/config.ts | 2 +- .../src/dvm-bridge.ts | 47 ++--- .../tests => dvmcp-bridge/src}/keys.test.ts | 2 +- .../src/mcp-client.ts | 0 .../src}/mcp-pool.test.ts | 4 +- .../src/mcp-pool.ts | 0 packages/dvmcp-bridge/src/mock-server.ts | 30 +++ packages/dvmcp-bridge/src/relay.ts | 4 + .../src/types.ts | 0 packages/dvmcp-discovery/.gitignore | 175 ++++++++++++++++++ packages/dvmcp-discovery/README.md | 15 ++ packages/dvmcp-discovery/config.example.yml | 21 +++ packages/dvmcp-discovery/index.ts | 27 +++ packages/dvmcp-discovery/package.json | 24 +++ packages/dvmcp-discovery/src/config.ts | 125 +++++++++++++ .../dvmcp-discovery/src/discovery-server.ts | 113 +++++++++++ .../dvmcp-discovery/src/discovery.test.ts | 78 ++++++++ packages/dvmcp-discovery/src/tool-executor.ts | 114 ++++++++++++ packages/dvmcp-discovery/src/tool-registry.ts | 108 +++++++++++ packages/dvmcp-discovery/tsconfig.json | 27 +++ 35 files changed, 1382 insertions(+), 73 deletions(-) create mode 100644 packages/commons/.gitignore create mode 100644 packages/commons/README.md create mode 100644 packages/commons/constants.ts rename packages/{mcp-dvm-bridge/src/tests => commons}/mock-server.ts (100%) rename packages/{mcp-dvm-bridge/src/nostr/keys.ts => commons/nostr/key-manager.ts} (87%) create mode 100644 packages/commons/nostr/mock-relay.ts rename packages/{mcp-dvm-bridge/src/nostr/relay.ts => commons/nostr/relay-handler.ts} (86%) create mode 100644 packages/commons/package.json create mode 100644 packages/commons/tsconfig.json delete mode 100644 packages/dvm-mcp-bridge/.gitkeep rename packages/{mcp-dvm-bridge => dvmcp-bridge}/config.example.yml (100%) create mode 100644 packages/dvmcp-bridge/index.ts rename packages/{mcp-dvm-bridge => dvmcp-bridge}/package.json (70%) rename packages/{mcp-dvm-bridge/src/nostr => dvmcp-bridge/src}/announcer.ts (70%) rename packages/{mcp-dvm-bridge => dvmcp-bridge}/src/config.ts (98%) rename packages/{mcp-dvm-bridge => dvmcp-bridge}/src/dvm-bridge.ts (81%) rename packages/{mcp-dvm-bridge/src/tests => dvmcp-bridge/src}/keys.test.ts (93%) rename packages/{mcp-dvm-bridge => dvmcp-bridge}/src/mcp-client.ts (100%) rename packages/{mcp-dvm-bridge/src/tests => dvmcp-bridge/src}/mcp-pool.test.ts (93%) rename packages/{mcp-dvm-bridge => dvmcp-bridge}/src/mcp-pool.ts (100%) create mode 100644 packages/dvmcp-bridge/src/mock-server.ts create mode 100644 packages/dvmcp-bridge/src/relay.ts rename packages/{mcp-dvm-bridge => dvmcp-bridge}/src/types.ts (100%) create mode 100644 packages/dvmcp-discovery/.gitignore create mode 100644 packages/dvmcp-discovery/README.md create mode 100644 packages/dvmcp-discovery/config.example.yml create mode 100644 packages/dvmcp-discovery/index.ts create mode 100644 packages/dvmcp-discovery/package.json create mode 100644 packages/dvmcp-discovery/src/config.ts create mode 100644 packages/dvmcp-discovery/src/discovery-server.ts create mode 100644 packages/dvmcp-discovery/src/discovery.test.ts create mode 100644 packages/dvmcp-discovery/src/tool-executor.ts create mode 100644 packages/dvmcp-discovery/src/tool-registry.ts create mode 100644 packages/dvmcp-discovery/tsconfig.json diff --git a/bun.lock b/bun.lock index 260835b..79b3852 100644 --- a/bun.lock +++ b/bun.lock @@ -7,11 +7,24 @@ "prettier": "^3.5.1", }, }, - "packages/mcp-dvm-bridge": { + "packages/commons": { + "name": "commons", + "dependencies": { + "@noble/hashes": "^1.7.1", + "nostr-tools": "^2.10.4", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5.0.0", + }, + }, + "packages/dvmcp-bridge": { "name": "mcp-dvm-bridge", "dependencies": { "@modelcontextprotocol/sdk": "^1.4.1", - "@noble/hashes": "^1.7.1", + "commons": "workspace:*", "dotenv": "^16.4.7", "nostr-tools": "^2.10.4", "yaml": "^2.7.0", @@ -23,9 +36,23 @@ "typescript": "^5.0.0", }, }, + "packages/dvmcp-discovery": { + "name": "dvmcp-discovery", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.5.0", + "commons": "workspace:*", + "nostr-tools": "^2.10.4", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5.0.0", + }, + }, }, "packages": { - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.4.1", "", { "dependencies": { "content-type": "^1.0.5", "eventsource": "^3.0.2", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-wS6YC4lkUZ9QpP+/7NBTlVNiEvsnyl0xF7rRusLF+RsG0xDPc/zWR7fEEyhKnnNutGsDAZh59l/AeoWGwIb1+g=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.5.0", "", { "dependencies": { "content-type": "^1.0.5", "eventsource": "^3.0.2", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-IJ+5iVVs8FCumIHxWqpwgkwOzyhtHVKy45s6Ug7Dv0MfRpaYisH8QQ87rIWeWdOzlk8sfhitZ7HCyQZk7d6b8w=="], "@noble/ciphers": ["@noble/ciphers@0.5.3", "", {}, "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w=="], @@ -41,7 +68,7 @@ "@types/bun": ["@types/bun@1.2.2", "", { "dependencies": { "bun-types": "1.2.2" } }, "sha512-tr74gdku+AEDN5ergNiBnplr7hpDp3V1h7fqI2GcR/rsUaM39jpSeKH0TFibRvU0KwniRx5POgaYnaXbk0hU+w=="], - "@types/node": ["@types/node@22.13.1", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew=="], + "@types/node": ["@types/node@22.13.4", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-ywP2X0DYtX3y08eFVx5fNIw7/uIv8hYUKgXoK8oayJlLnKcRfEYCxWMVE1XagUdVtCJlZT1AU4LXEABW+L1Peg=="], "@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="], @@ -49,12 +76,16 @@ "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + "commons": ["commons@workspace:packages/commons"], + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], "dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="], + "dvmcp-discovery": ["dvmcp-discovery@workspace:packages/dvmcp-discovery"], + "eventsource": ["eventsource@3.0.5", "", { "dependencies": { "eventsource-parser": "^3.0.0" } }, "sha512-LT/5J605bx5SNyE+ITBDiM3FxffBiq9un7Vx0EwMDM3vg8sWKx/tO2zC+LMqZ+smAM0F2hblaDZUVZF0te2pSw=="], "eventsource-parser": ["eventsource-parser@3.0.0", "", {}, "sha512-T1C0XCUimhxVQzW4zFipdx0SficT651NnkR0ZSH3yQwh+mFMdLfgjABVi4YtMTtaL4s168593DaoaRLMqryavA=="], @@ -65,7 +96,7 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - "mcp-dvm-bridge": ["mcp-dvm-bridge@workspace:packages/mcp-dvm-bridge"], + "mcp-dvm-bridge": ["mcp-dvm-bridge@workspace:packages/dvmcp-bridge"], "nostr-tools": ["nostr-tools@2.10.4", "", { "dependencies": { "@noble/ciphers": "^0.5.1", "@noble/curves": "1.2.0", "@noble/hashes": "1.3.1", "@scure/base": "1.1.1", "@scure/bip32": "1.3.1", "@scure/bip39": "1.2.1" }, "optionalDependencies": { "nostr-wasm": "0.1.0" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-biU7sk+jxHgVASfobg2T5ttxOGGSt69wEVBC51sHHOEaKAAdzHBLV/I2l9Rf61UzClhliZwNouYhqIso4a3HYg=="], @@ -91,7 +122,7 @@ "yaml": ["yaml@2.7.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA=="], - "zod": ["zod@3.24.1", "", {}, "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A=="], + "zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="], "zod-to-json-schema": ["zod-to-json-schema@3.24.1", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-3h08nf3Vw3Wl3PK+q3ow/lIil81IT2Oa7YpQyUUDsEWbXveMesdfK1xBd2RhCkynwZndAxixji/7SYJJowr62w=="], @@ -99,10 +130,12 @@ "@scure/bip32/@noble/curves": ["@noble/curves@1.1.0", "", { "dependencies": { "@noble/hashes": "1.3.1" } }, "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA=="], - "@scure/bip32/@noble/hashes": ["@noble/hashes@1.3.1", "", {}, "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA=="], + "@scure/bip32/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="], "@scure/bip39/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="], "nostr-tools/@noble/hashes": ["@noble/hashes@1.3.1", "", {}, "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA=="], + + "@scure/bip32/@noble/curves/@noble/hashes": ["@noble/hashes@1.3.1", "", {}, "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA=="], } } diff --git a/packages/commons/.gitignore b/packages/commons/.gitignore new file mode 100644 index 0000000..9b1ee42 --- /dev/null +++ b/packages/commons/.gitignore @@ -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 diff --git a/packages/commons/README.md b/packages/commons/README.md new file mode 100644 index 0000000..b9b8eaa --- /dev/null +++ b/packages/commons/README.md @@ -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. diff --git a/packages/commons/constants.ts b/packages/commons/constants.ts new file mode 100644 index 0000000..f35fca3 --- /dev/null +++ b/packages/commons/constants.ts @@ -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; \ No newline at end of file diff --git a/packages/mcp-dvm-bridge/src/tests/mock-server.ts b/packages/commons/mock-server.ts similarity index 100% rename from packages/mcp-dvm-bridge/src/tests/mock-server.ts rename to packages/commons/mock-server.ts diff --git a/packages/mcp-dvm-bridge/src/nostr/keys.ts b/packages/commons/nostr/key-manager.ts similarity index 87% rename from packages/mcp-dvm-bridge/src/nostr/keys.ts rename to packages/commons/nostr/key-manager.ts index 29126dd..a8da004 100644 --- a/packages/mcp-dvm-bridge/src/nostr/keys.ts +++ b/packages/commons/nostr/key-manager.ts @@ -1,7 +1,6 @@ import { hexToBytes } from '@noble/hashes/utils'; import { getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import type { Event, UnsignedEvent } from 'nostr-tools/pure'; -import { CONFIG } from '../config'; export const createKeyManager = (privateKeyHex: string) => { const privateKeyBytes = hexToBytes(privateKeyHex); @@ -27,5 +26,3 @@ export const createKeyManager = (privateKeyHex: string) => { return new Manager(); }; - -export const keyManager = createKeyManager(CONFIG.nostr.privateKey); diff --git a/packages/commons/nostr/mock-relay.ts b/packages/commons/nostr/mock-relay.ts new file mode 100644 index 0000000..8a5b7a5 --- /dev/null +++ b/packages/commons/nostr/mock-relay.ts @@ -0,0 +1,175 @@ +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; + 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 }; \ No newline at end of file diff --git a/packages/mcp-dvm-bridge/src/nostr/relay.ts b/packages/commons/nostr/relay-handler.ts similarity index 86% rename from packages/mcp-dvm-bridge/src/nostr/relay.ts rename to packages/commons/nostr/relay-handler.ts index 1599d90..691d677 100644 --- a/packages/mcp-dvm-bridge/src/nostr/relay.ts +++ b/packages/commons/nostr/relay-handler.ts @@ -4,7 +4,7 @@ import type { SubCloser } from 'nostr-tools/pool'; import WebSocket from 'ws'; import { useWebSocketImplementation } from 'nostr-tools/pool'; import type { Filter } from 'nostr-tools'; -import { CONFIG } from '../config'; +import { DVM_NOTICE_KIND, TOOL_REQUEST_KIND, TOOL_RESPONSE_KIND } from '../constants'; useWebSocketImplementation(WebSocket); @@ -52,13 +52,16 @@ export class RelayHandler { } } - subscribeToRequests(onRequest: (event: Event) => void): SubCloser { - const filters: Filter[] = [ - { - kinds: [5910], - since: Math.floor(Date.now() / 1000), - }, - ]; + 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) { @@ -96,5 +99,3 @@ export class RelayHandler { return this.pool.listConnectionStatus(); } } - -export default new RelayHandler(CONFIG.nostr.relayUrls); diff --git a/packages/commons/package.json b/packages/commons/package.json new file mode 100644 index 0000000..1533d91 --- /dev/null +++ b/packages/commons/package.json @@ -0,0 +1,14 @@ +{ + "name": "commons", + "type": "module", + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "@noble/hashes": "^1.7.1", + "nostr-tools": "^2.10.4" + } +} diff --git a/packages/commons/tsconfig.json b/packages/commons/tsconfig.json new file mode 100644 index 0000000..238655f --- /dev/null +++ b/packages/commons/tsconfig.json @@ -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 + } +} diff --git a/packages/dvm-mcp-bridge/.gitkeep b/packages/dvm-mcp-bridge/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/packages/mcp-dvm-bridge/config.example.yml b/packages/dvmcp-bridge/config.example.yml similarity index 100% rename from packages/mcp-dvm-bridge/config.example.yml rename to packages/dvmcp-bridge/config.example.yml diff --git a/packages/dvmcp-bridge/index.ts b/packages/dvmcp-bridge/index.ts new file mode 100644 index 0000000..0a4b9c4 --- /dev/null +++ b/packages/dvmcp-bridge/index.ts @@ -0,0 +1,28 @@ +import { DVMBridge } from './src/dvm-bridge'; + +async function main() { + const bridge = new DVMBridge(); + + const shutdown = async () => { + console.log('Shutting down...'); + try { + await bridge.stop(); + process.exit(0); + } catch (error) { + console.error('Error during shutdown:', error); + process.exit(1); + } + }; + + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + + try { + await bridge.start(); + } catch (error) { + console.error('Failed to start service:', error); + process.exit(1); + } +} + +main(); diff --git a/packages/mcp-dvm-bridge/package.json b/packages/dvmcp-bridge/package.json similarity index 70% rename from packages/mcp-dvm-bridge/package.json rename to packages/dvmcp-bridge/package.json index 78e12ac..46c83c9 100644 --- a/packages/mcp-dvm-bridge/package.json +++ b/packages/dvmcp-bridge/package.json @@ -1,12 +1,12 @@ { - "name": "mcp-dvm-bridge", - "module": "src/dvm-bridge.ts", + "name": "dvmcp-bridge", + "module": "index.ts", "type": "module", "license": "MIT", "scripts": { "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", - "dev": "bun --watch src/dvm-bridge.ts", - "start": "bun run src/dvm-bridge.ts", + "dev": "bun --watch index.ts", + "start": "bun run index.ts", "typecheck": "tsc --noEmit", "lint": "bun run typecheck && bun run format", "test": "bun test" @@ -19,9 +19,9 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.4.1", - "@noble/hashes": "^1.7.1", "dotenv": "^16.4.7", "nostr-tools": "^2.10.4", - "yaml": "^2.7.0" + "yaml": "^2.7.0", + "commons": "workspace:*" } } diff --git a/packages/mcp-dvm-bridge/src/nostr/announcer.ts b/packages/dvmcp-bridge/src/announcer.ts similarity index 70% rename from packages/mcp-dvm-bridge/src/nostr/announcer.ts rename to packages/dvmcp-bridge/src/announcer.ts index b704825..48db906 100644 --- a/packages/mcp-dvm-bridge/src/nostr/announcer.ts +++ b/packages/dvmcp-bridge/src/announcer.ts @@ -1,8 +1,11 @@ -import { CONFIG } from '../config'; -import { RelayHandler } from './relay'; -import { keyManager } from './keys'; -import relayHandler from './relay'; -import type { MCPPool } from '../mcp-pool'; +import type { RelayHandler } from 'commons/nostr/relay-handler'; +import { CONFIG } from './config'; +import { createKeyManager } from 'commons/nostr/key-manager'; +import type { MCPPool } from './mcp-pool'; +import { relayHandler } from './relay'; +import { DVM_ANNOUNCEMENT_KIND, TOOL_REQUEST_KIND } from 'commons/constants'; + +export const keyManager = createKeyManager(CONFIG.nostr.privateKey); export class NostrAnnouncer { private relayHandler: RelayHandler; @@ -27,7 +30,7 @@ export class NostrAnnouncer { async announceService() { const tools = await this.mcpPool.listTools(); const event = keyManager.signEvent({ - ...keyManager.createEventTemplate(31990), + ...keyManager.createEventTemplate(DVM_ANNOUNCEMENT_KIND), content: JSON.stringify({ name: CONFIG.mcp.name, about: CONFIG.mcp.about, @@ -35,7 +38,7 @@ export class NostrAnnouncer { }), tags: [ ['d', 'dvm-announcement'], - ['k', '5910'], + ['k', `${TOOL_REQUEST_KIND}`], ['capabilities', 'mcp-1.0'], ['t', 'mcp'], ...tools.map((tool) => ['t', tool.name]), diff --git a/packages/mcp-dvm-bridge/src/config.ts b/packages/dvmcp-bridge/src/config.ts similarity index 98% rename from packages/mcp-dvm-bridge/src/config.ts rename to packages/dvmcp-bridge/src/config.ts index 09c10ae..8f8974f 100644 --- a/packages/mcp-dvm-bridge/src/config.ts +++ b/packages/dvmcp-bridge/src/config.ts @@ -1,10 +1,10 @@ import { parse } from 'yaml'; import { join } from 'path'; import { existsSync, readFileSync } from 'fs'; +import { HEX_KEYS_REGEX } from 'commons/constants'; import type { AppConfig, MCPServerConfig } from './types'; const CONFIG_PATH = join(process.cwd(), 'config.yml'); -const HEX_KEYS_REGEX = /^(?:[0-9a-fA-F]{64})$/; const TEST_CONFIG: AppConfig = { nostr: { diff --git a/packages/mcp-dvm-bridge/src/dvm-bridge.ts b/packages/dvmcp-bridge/src/dvm-bridge.ts similarity index 81% rename from packages/mcp-dvm-bridge/src/dvm-bridge.ts rename to packages/dvmcp-bridge/src/dvm-bridge.ts index 9276d4b..0d203f4 100644 --- a/packages/mcp-dvm-bridge/src/dvm-bridge.ts +++ b/packages/dvmcp-bridge/src/dvm-bridge.ts @@ -1,10 +1,10 @@ -import { NostrAnnouncer } from './nostr/announcer'; -import { RelayHandler } from './nostr/relay'; -import { keyManager } from './nostr/keys'; -import relayHandler from './nostr/relay'; +import { keyManager, NostrAnnouncer } from './announcer'; import type { Event } from 'nostr-tools/pure'; import { CONFIG } from './config'; import { MCPPool } from './mcp-pool'; +import { RelayHandler } from 'commons/nostr/relay-handler'; +import { relayHandler } from './relay'; +import { DVM_NOTICE_KIND, TOOL_REQUEST_KIND, TOOL_RESPONSE_KIND } from 'commons/constants'; export class DVMBridge { private mcpPool: MCPPool; @@ -73,13 +73,13 @@ export class DVMBridge { private async handleRequest(event: Event) { try { if (this.isWhitelisted(event.pubkey)) { - if (event.kind === 5910) { + if (event.kind === TOOL_REQUEST_KIND) { const command = event.tags.find((tag) => tag[0] === 'c')?.[1]; if (command === 'list-tools') { const tools = await this.mcpPool.listTools(); const response = keyManager.signEvent({ - ...keyManager.createEventTemplate(6910), + ...keyManager.createEventTemplate(TOOL_RESPONSE_KIND), content: JSON.stringify({ tools, }), @@ -94,7 +94,7 @@ export class DVMBridge { } else { const jobRequest = JSON.parse(event.content); const processingStatus = keyManager.signEvent({ - ...keyManager.createEventTemplate(7000), + ...keyManager.createEventTemplate(DVM_NOTICE_KIND), tags: [ ['status', 'processing'], ['e', event.id], @@ -109,7 +109,7 @@ export class DVMBridge { jobRequest.parameters ); const successStatus = keyManager.signEvent({ - ...keyManager.createEventTemplate(7000), + ...keyManager.createEventTemplate(DVM_NOTICE_KIND), tags: [ ['status', 'success'], ['e', event.id], @@ -118,7 +118,7 @@ export class DVMBridge { }); await this.relayHandler.publishEvent(successStatus); const response = keyManager.signEvent({ - ...keyManager.createEventTemplate(6910), + ...keyManager.createEventTemplate(TOOL_RESPONSE_KIND), content: JSON.stringify(result), tags: [ ['request', JSON.stringify(event)], @@ -129,7 +129,7 @@ export class DVMBridge { await this.relayHandler.publishEvent(response); } catch (error) { const errorStatus = keyManager.signEvent({ - ...keyManager.createEventTemplate(7000), + ...keyManager.createEventTemplate(DVM_NOTICE_KIND), tags: [ [ 'status', @@ -146,7 +146,7 @@ export class DVMBridge { } } else { const errorStatus = keyManager.signEvent({ - ...keyManager.createEventTemplate(7000), + ...keyManager.createEventTemplate(DVM_NOTICE_KIND), content: 'Unauthorized: Pubkey not in whitelist', tags: [ ['status', 'error'], @@ -161,28 +161,3 @@ export class DVMBridge { } } } - -if (import.meta.main) { - const bridge = new DVMBridge(); - - const shutdown = async () => { - console.log('Shutting down...'); - try { - await bridge.stop(); - process.exit(0); - } catch (error) { - console.error('Error during shutdown:', error); - process.exit(1); - } - }; - - process.on('SIGINT', shutdown); - process.on('SIGTERM', shutdown); - - try { - await bridge.start(); - } catch (error) { - console.error('Failed to start service:', error); - process.exit(1); - } -} diff --git a/packages/mcp-dvm-bridge/src/tests/keys.test.ts b/packages/dvmcp-bridge/src/keys.test.ts similarity index 93% rename from packages/mcp-dvm-bridge/src/tests/keys.test.ts rename to packages/dvmcp-bridge/src/keys.test.ts index bf122a2..ba43861 100644 --- a/packages/mcp-dvm-bridge/src/tests/keys.test.ts +++ b/packages/dvmcp-bridge/src/keys.test.ts @@ -1,5 +1,5 @@ import { expect, test, describe } from 'bun:test'; -import { createKeyManager } from '../nostr/keys'; +import { createKeyManager } from 'commons/nostr/key-manager'; describe('KeyManager', () => { const testPrivateKey = diff --git a/packages/mcp-dvm-bridge/src/mcp-client.ts b/packages/dvmcp-bridge/src/mcp-client.ts similarity index 100% rename from packages/mcp-dvm-bridge/src/mcp-client.ts rename to packages/dvmcp-bridge/src/mcp-client.ts diff --git a/packages/mcp-dvm-bridge/src/tests/mcp-pool.test.ts b/packages/dvmcp-bridge/src/mcp-pool.test.ts similarity index 93% rename from packages/mcp-dvm-bridge/src/tests/mcp-pool.test.ts rename to packages/dvmcp-bridge/src/mcp-pool.test.ts index 7478a6e..a67305f 100644 --- a/packages/mcp-dvm-bridge/src/tests/mcp-pool.test.ts +++ b/packages/dvmcp-bridge/src/mcp-pool.test.ts @@ -1,8 +1,8 @@ import { expect, test, describe, beforeAll, afterAll } from 'bun:test'; -import { MCPPool } from '../mcp-pool'; import { join } from 'path'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; -import { createMockServer } from './mock-server'; +import { MCPPool } from './mcp-pool'; +import { createMockServer } from 'commons/mock-server'; describe('MCPPool', () => { let mcpPool: MCPPool; diff --git a/packages/mcp-dvm-bridge/src/mcp-pool.ts b/packages/dvmcp-bridge/src/mcp-pool.ts similarity index 100% rename from packages/mcp-dvm-bridge/src/mcp-pool.ts rename to packages/dvmcp-bridge/src/mcp-pool.ts diff --git a/packages/dvmcp-bridge/src/mock-server.ts b/packages/dvmcp-bridge/src/mock-server.ts new file mode 100644 index 0000000..4e2d300 --- /dev/null +++ b/packages/dvmcp-bridge/src/mock-server.ts @@ -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'); +} diff --git a/packages/dvmcp-bridge/src/relay.ts b/packages/dvmcp-bridge/src/relay.ts new file mode 100644 index 0000000..8182485 --- /dev/null +++ b/packages/dvmcp-bridge/src/relay.ts @@ -0,0 +1,4 @@ +import { RelayHandler } from 'commons/nostr/relay-handler'; +import { CONFIG } from './config'; + +export const relayHandler = new RelayHandler(CONFIG.nostr.relayUrls); diff --git a/packages/mcp-dvm-bridge/src/types.ts b/packages/dvmcp-bridge/src/types.ts similarity index 100% rename from packages/mcp-dvm-bridge/src/types.ts rename to packages/dvmcp-bridge/src/types.ts diff --git a/packages/dvmcp-discovery/.gitignore b/packages/dvmcp-discovery/.gitignore new file mode 100644 index 0000000..9b1ee42 --- /dev/null +++ b/packages/dvmcp-discovery/.gitignore @@ -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 diff --git a/packages/dvmcp-discovery/README.md b/packages/dvmcp-discovery/README.md new file mode 100644 index 0000000..39aa8e8 --- /dev/null +++ b/packages/dvmcp-discovery/README.md @@ -0,0 +1,15 @@ +# dvmcp-discovery + +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. diff --git a/packages/dvmcp-discovery/config.example.yml b/packages/dvmcp-discovery/config.example.yml new file mode 100644 index 0000000..951b83c --- /dev/null +++ b/packages/dvmcp-discovery/config.example.yml @@ -0,0 +1,21 @@ +nostr: + # Your DVM's private key (32-byte hex string) + privateKey: "your_private_key_here" + # List of relays to connect to + relayUrls: + - "wss://relay.damus.io" + - "wss://relay.nostr.band" + +mcp: + # Server name + name: "DVMCP Discovery" + # Server version + version: "1.0.0" + # Server description + about: "DVMCP Discovery Server for aggregating MCP tools from DVMs" + +whitelist: + # Optional: List of allowed DVM pubkeys + allowedDVMs: + - "pubkey1" + - "pubkey2" \ No newline at end of file diff --git a/packages/dvmcp-discovery/index.ts b/packages/dvmcp-discovery/index.ts new file mode 100644 index 0000000..1035be4 --- /dev/null +++ b/packages/dvmcp-discovery/index.ts @@ -0,0 +1,27 @@ +import { CONFIG } from './src/config'; +import { DiscoveryServer } from './src/discovery-server'; + +async function main() { + try { + const server = new DiscoveryServer(CONFIG); + + await server.start(); + + console.log(`DVMCP Discovery Server (${CONFIG.mcp.version}) started`); + console.log(`Connected to ${CONFIG.nostr.relayUrls.length} relays`); + + // Handle shutdown + const cleanup = () => { + server.cleanup(); + process.exit(0); + }; + + process.on('SIGINT', cleanup); + process.on('SIGTERM', cleanup); + } catch (error) { + console.error('Failed to start server:', error); + process.exit(1); + } +} + +main(); diff --git a/packages/dvmcp-discovery/package.json b/packages/dvmcp-discovery/package.json new file mode 100644 index 0000000..0e363aa --- /dev/null +++ b/packages/dvmcp-discovery/package.json @@ -0,0 +1,24 @@ +{ + "name": "dvmcp-discovery", + "module": "index.ts", + "type": "module", + "scripts": { + "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", + "dev": "bun --watch index.ts", + "start": "bun run index.ts", + "typecheck": "tsc --noEmit", + "lint": "bun run typecheck && bun run format", + "test": "bun test" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.5.0", + "nostr-tools": "^2.10.4", + "commons": "workspace:*" + } +} diff --git a/packages/dvmcp-discovery/src/config.ts b/packages/dvmcp-discovery/src/config.ts new file mode 100644 index 0000000..5c073ca --- /dev/null +++ b/packages/dvmcp-discovery/src/config.ts @@ -0,0 +1,125 @@ +import { parse } from 'yaml'; +import { join } from 'path'; +import { existsSync, readFileSync } from 'fs'; +import { HEX_KEYS_REGEX } from 'commons/constants'; + +export interface Config { + nostr: { + privateKey: string; + relayUrls: string[]; + }; + mcp: { + name: string; + version: string; + about: string; + }; + whitelist?: { + allowedDVMs?: Set; + }; +} + +const CONFIG_PATH = join(process.cwd(), 'config.yml'); + +const TEST_CONFIG: Config = { + nostr: { + privateKey: + '034cf6179a62e5aaf12bd67dc7d19be2f0fae9065fccaddd4607c2ca041fdaf9', + relayUrls: ['ws://localhost:3334'], + }, + mcp: { + name: 'Test DVMCP Discovery', + version: '1.0.0', + about: 'Test DVMCP Discovery Server', + }, + whitelist: { + allowedDVMs: new Set(), + }, +}; + +function validateRequiredField(value: any, fieldName: string): string { + if (!value) { + throw new Error(`Missing required config field: ${fieldName}`); + } + return value; +} + +function getConfigValue( + value: string | undefined, + defaultValue: string +): string { + return value || defaultValue; +} + +function validateRelayUrls(urls: any): string[] { + if (!Array.isArray(urls) || urls.length === 0) { + throw new Error( + 'At least one relay URL must be provided in nostr.relayUrls' + ); + } + return urls.map((url: string) => { + try { + const trimmedUrl = url.trim(); + new URL(trimmedUrl); + if (!trimmedUrl.startsWith('ws://') && !trimmedUrl.startsWith('wss://')) { + throw new Error( + `Relay URL must start with ws:// or wss://: ${trimmedUrl}` + ); + } + return trimmedUrl; + } catch (error) { + throw new Error(`Invalid relay URL: ${url}`); + } + }); +} + +function loadConfig(): Config { + if (process.env.NODE_ENV === 'test') { + return TEST_CONFIG; + } + + if (!existsSync(CONFIG_PATH)) { + throw new Error( + 'No config.yml file found. Please create one based on config.example.yml' + ); + } + + try { + const configFile = readFileSync(CONFIG_PATH, 'utf8'); + const rawConfig = parse(configFile); + + const config: Config = { + nostr: { + privateKey: validateRequiredField( + rawConfig.nostr?.privateKey, + 'nostr.privateKey' + ), + relayUrls: validateRelayUrls(rawConfig.nostr?.relayUrls), + }, + mcp: { + name: getConfigValue(rawConfig.mcp?.name, 'DVMCP Discovery'), + version: validateRequiredField(rawConfig.mcp?.version, 'mcp.version'), + about: getConfigValue( + rawConfig.mcp?.about, + 'DVMCP Discovery Server for aggregating MCP tools from DVMs' + ), + }, + whitelist: { + allowedDVMs: rawConfig.whitelist?.allowedDVMs + ? new Set( + rawConfig.whitelist.allowedDVMs.map((pk: string) => pk.trim()) + ) + : undefined, + }, + }; + + if (!HEX_KEYS_REGEX.test(config.nostr.privateKey)) { + throw new Error('privateKey must be a 32-byte hex string'); + } + + return config; + } catch (error) { + throw new Error(`Failed to load config: ${error}`); + } +} + +export const CONFIG = loadConfig(); diff --git a/packages/dvmcp-discovery/src/discovery-server.ts b/packages/dvmcp-discovery/src/discovery-server.ts new file mode 100644 index 0000000..a58634f --- /dev/null +++ b/packages/dvmcp-discovery/src/discovery-server.ts @@ -0,0 +1,113 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { type Event, type Filter } from 'nostr-tools'; +import { RelayHandler } from 'commons/nostr/relay-handler'; +import { createKeyManager } from 'commons/nostr/key-manager'; +import { CONFIG, type Config } from './config'; +import { DVM_ANNOUNCEMENT_KIND } from 'commons/constants'; +import type { Tool } from '@modelcontextprotocol/sdk/types.js'; +import type { SubCloser } from 'nostr-tools/abstract-pool'; +import { ToolRegistry } from './tool-registry'; +import { ToolExecutor } from './tool-executor'; + +interface DVMAnnouncement { + name: string; + about: string; + tools: Tool[]; +} + +export class DiscoveryServer { + private mcpServer: McpServer; + private relayHandler: RelayHandler; + private keyManager: ReturnType; + private toolRegistry: ToolRegistry; + private toolExecutor: ToolExecutor; + + constructor(config: Config) { + this.relayHandler = new RelayHandler(config.nostr.relayUrls); + this.keyManager = createKeyManager(config.nostr.privateKey); + this.mcpServer = new McpServer({ + name: config.mcp.name, + version: config.mcp.version, + }); + + this.toolRegistry = new ToolRegistry(this.mcpServer); + this.toolExecutor = new ToolExecutor(this.relayHandler, this.keyManager); + + this.toolRegistry.setExecutionCallback(async (toolId, args) => { + const tool = this.toolRegistry.getTool(toolId); + if (!tool) throw new Error('Tool not found'); + return this.toolExecutor.executeTool(tool, args); + }); + } + + private async startDiscovery() { + const filter: Filter = { + kinds: [DVM_ANNOUNCEMENT_KIND], + '#t': ['mcp'], + }; + + const events = await this.relayHandler.queryEvents(filter); + await Promise.all(events.map((event) => this.handleDVMAnnouncement(event))); + } + + private async handleDVMAnnouncement(event: Event) { + try { + if (!this.isAllowedDVM(event.pubkey)) { + console.log('DVM not in whitelist:', event.pubkey); + return; + } + + const announcement = this.parseAnnouncement(event.content); + if (!announcement?.tools) return; + + for (const tool of announcement.tools) { + const toolId = `${event.pubkey.slice(0, 12)}:${tool.name}`; + this.toolRegistry.registerTool(toolId, tool); + } + } catch (error) { + console.error('Error processing DVM announcement:', error); + } + } + + private isAllowedDVM(pubkey: string): boolean { + if ( + !CONFIG.whitelist?.allowedDVMs || + CONFIG.whitelist.allowedDVMs.size === 0 + ) { + return true; + } + return CONFIG.whitelist.allowedDVMs.has(pubkey); + } + + private parseAnnouncement(content: string): DVMAnnouncement | null { + try { + return JSON.parse(content); + } catch { + return null; + } + } + + public async listTools(): Promise { + return this.toolRegistry.listTools(); + } + + public async start() { + console.log('Starting discovery server...'); + + await this.startDiscovery(); + console.log(`Discovered ${this.toolRegistry.listTools().length} tools`); + + const transport = new StdioServerTransport(); + await this.mcpServer.connect(transport); + + console.log('DVMCP Discovery Server started'); + } + + public async cleanup(): Promise { + this.toolExecutor.cleanup(); + await new Promise((resolve) => setTimeout(resolve, 10)); + this.relayHandler.cleanup(); + this.toolRegistry.clear(); + } +} diff --git a/packages/dvmcp-discovery/src/discovery.test.ts b/packages/dvmcp-discovery/src/discovery.test.ts new file mode 100644 index 0000000..5f78ca1 --- /dev/null +++ b/packages/dvmcp-discovery/src/discovery.test.ts @@ -0,0 +1,78 @@ +import { expect, test, describe, beforeAll, afterAll } from 'bun:test'; +import { DiscoveryServer } from './discovery-server'; +import { CONFIG } from './config'; +import { + server as mockRelay, + stop as stopRelay, +} from 'commons/nostr/mock-relay'; +import type { Tool } from '@modelcontextprotocol/sdk/types.js'; + +describe('DiscoveryServer E2E', () => { + let discoveryServer: DiscoveryServer; + let relayConnected = false; + + beforeAll(async () => { + mockRelay; + relayConnected = true; + + const testConfig = { + ...CONFIG, + nostr: { + ...CONFIG.nostr, + relayUrls: ['ws://localhost:3334'], + }, + }; + + discoveryServer = new DiscoveryServer(testConfig); + await discoveryServer.start(); + }); + afterAll(async () => { + if (discoveryServer) { + await discoveryServer.cleanup(); + } + + stopRelay(); + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + test('should list discovered tools', async () => { + const tools = await discoveryServer.listTools(); + console.log('Number of tools:', tools.length); + console.log('Final discovered tools:', tools); + + expect(tools.length).toBeGreaterThan(0); + const mockTool = tools.find((t) => t.name === 'test-echo'); + expect(mockTool).toBeDefined(); + expect(mockTool?.description).toBe('Echo test tool'); + }); + + test('should execute discovered tool', async () => { + const tools = await discoveryServer.listTools(); + const mockTool = tools.find((t) => t.name === 'test-echo') as Tool; + expect(mockTool).toBeDefined(); + console.log('Found tool:', mockTool); + + const toolRegistry = discoveryServer['toolRegistry']; + const toolIds = Array.from(toolRegistry['discoveredTools'].keys()); + console.log('Available tool IDs:', toolIds); + + const toolId = toolIds.find((id) => id.endsWith(`:${mockTool.name}`)); + expect(toolId).toBeDefined(); + console.log('Selected tool ID:', toolId); + + console.log('Executing tool...'); + + const result = await discoveryServer['toolExecutor'].executeTool(mockTool, { + text: 'Hello from test', + }); + + console.log('Execution result:', result); + expect(result).toBeDefined(); + expect(result).toEqual([ + { + type: 'text', + text: '[test] Hello from test', + }, + ]); + }); +}); diff --git a/packages/dvmcp-discovery/src/tool-executor.ts b/packages/dvmcp-discovery/src/tool-executor.ts new file mode 100644 index 0000000..2a8971d --- /dev/null +++ b/packages/dvmcp-discovery/src/tool-executor.ts @@ -0,0 +1,114 @@ +import { type Event } from 'nostr-tools'; +import { RelayHandler } from 'commons/nostr/relay-handler'; +import { createKeyManager } from 'commons/nostr/key-manager'; +import { type Tool } from '@modelcontextprotocol/sdk/types.js'; +import { + TOOL_REQUEST_KIND, + TOOL_RESPONSE_KIND, + DVM_NOTICE_KIND, +} from 'commons/constants'; + +interface ExecutionContext { + timeoutId: ReturnType; + cleanup: () => void; +} + +export class ToolExecutor { + private executionSubscriptions: Map void> = new Map(); + private static readonly EXECUTION_TIMEOUT = 30000; + + constructor( + private relayHandler: RelayHandler, + private keyManager: ReturnType + ) {} + + public async executeTool(tool: Tool, params: unknown): Promise { + return new Promise((resolve, reject) => { + const request = this.createToolRequest(tool, params); + const executionId = request.id; + const context = this.createExecutionContext(executionId); + + const subscription = this.relayHandler.subscribeToRequests( + (event) => { + if (event.tags.some((t) => t[0] === 'e' && t[1] === executionId)) { + this.handleToolResponse(event, context, resolve, reject); + } + }, + { + kinds: [TOOL_RESPONSE_KIND, DVM_NOTICE_KIND], + since: Math.floor(Date.now() / 1000), + } + ); + + this.executionSubscriptions.set(executionId, subscription.close); + + this.relayHandler.publishEvent(request).catch((err) => { + clearTimeout(context.timeoutId); + context.cleanup(); + reject(err); + }); + }); + } + + public cleanup(): void { + for (const sub of this.executionSubscriptions.values()) { + sub(); + } + this.executionSubscriptions.clear(); + } + + private createExecutionContext(executionId: string): ExecutionContext { + const timeoutId = setTimeout(() => { + console.log('Execution timeout for:', executionId); + this.cleanupExecution(executionId); + }, ToolExecutor.EXECUTION_TIMEOUT); + + const cleanup = () => this.cleanupExecution(executionId); + return { timeoutId, cleanup }; + } + + private cleanupExecution(executionId: string): void { + const sub = this.executionSubscriptions.get(executionId); + if (sub) { + sub(); + this.executionSubscriptions.delete(executionId); + } + } + + private handleToolResponse( + event: Event, + context: ExecutionContext, + resolve: (value: unknown) => void, + reject: (reason: Error) => void + ): void { + if (event.kind === TOOL_RESPONSE_KIND) { + try { + const result = JSON.parse(event.content); + clearTimeout(context.timeoutId); + context.cleanup(); + resolve(result.content); + } catch (error) { + clearTimeout(context.timeoutId); + context.cleanup(); + reject(error instanceof Error ? error : new Error(String(error))); + } + } else if (event.kind === DVM_NOTICE_KIND) { + const status = event.tags.find((t) => t[0] === 'status')?.[1]; + if (status === 'error') { + clearTimeout(context.timeoutId); + context.cleanup(); + reject(new Error(event.content)); + } + } + } + + private createToolRequest(tool: Tool, params: unknown): Event { + const request = this.keyManager.createEventTemplate(TOOL_REQUEST_KIND); + request.content = JSON.stringify({ + name: tool.name, + parameters: params, + }); + request.tags.push(['c', 'execute-tool']); + return this.keyManager.signEvent(request); + } +} diff --git a/packages/dvmcp-discovery/src/tool-registry.ts b/packages/dvmcp-discovery/src/tool-registry.ts new file mode 100644 index 0000000..ea7dc3e --- /dev/null +++ b/packages/dvmcp-discovery/src/tool-registry.ts @@ -0,0 +1,108 @@ +import { type Tool } from '@modelcontextprotocol/sdk/types.js'; +import { ToolSchema } from '@modelcontextprotocol/sdk/types.js'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; + +export class ToolRegistry { + private discoveredTools: Map = new Map(); + + constructor(private mcpServer: McpServer) {} + + public registerTool(toolId: string, tool: Tool): void { + try { + ToolSchema.parse(tool); + this.discoveredTools.set(toolId, tool); + this.registerWithMcp(toolId, tool); + } catch (error) { + console.error(`Invalid MCP tool format for ${toolId}:`, error); + } + } + + public getTool(toolId: string): Tool | undefined { + return this.discoveredTools.get(toolId); + } + + public listTools(): Tool[] { + return Array.from(this.discoveredTools.values()); + } + + public clear(): void { + this.discoveredTools.clear(); + } + + private registerWithMcp(toolId: string, tool: Tool): void { + try { + this.mcpServer.tool( + toolId, + tool.description ?? '', + this.mapJsonSchemaToZod(tool.inputSchema), + async (args: unknown) => { + try { + const result = await this.executionCallback?.(toolId, args); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(result), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + return { + content: [ + { + type: 'text' as const, + text: `Error: ${errorMessage}`, + }, + ], + isError: true, + }; + } + } + ); + console.log('Tool registered successfully:', toolId); + } catch (error) { + console.error('Error registering tool:', toolId, error); + } + } + + private executionCallback?: ( + toolId: string, + args: unknown + ) => Promise; + + public setExecutionCallback( + callback: (toolId: string, args: unknown) => Promise + ): void { + this.executionCallback = callback; + } + + private mapJsonSchemaToZod(schema: Tool['inputSchema']): z.ZodRawShape { + const properties: z.ZodRawShape = {}; + if (schema.properties) { + for (const [key, prop] of Object.entries(schema.properties)) { + if (typeof prop === 'object' && prop && 'type' in prop) { + switch (prop.type) { + case 'string': + properties[key] = z.string(); + break; + case 'number': + properties[key] = z.number(); + break; + case 'integer': + properties[key] = z.number().int(); + break; + case 'boolean': + properties[key] = z.boolean(); + break; + default: + properties[key] = z.any(); + } + } + } + } + return properties; + } +} diff --git a/packages/dvmcp-discovery/tsconfig.json b/packages/dvmcp-discovery/tsconfig.json new file mode 100644 index 0000000..238655f --- /dev/null +++ b/packages/dvmcp-discovery/tsconfig.json @@ -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 + } +}