feat: dvmcp discover (#3)

This commit is contained in:
gzuuus
2025-02-16 13:34:42 +00:00
committed by GitHub
parent ee26c01b3b
commit 4ca39c4ec6
35 changed files with 1382 additions and 73 deletions

View File

@@ -7,11 +7,24 @@
"prettier": "^3.5.1", "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", "name": "mcp-dvm-bridge",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.4.1", "@modelcontextprotocol/sdk": "^1.4.1",
"@noble/hashes": "^1.7.1", "commons": "workspace:*",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"nostr-tools": "^2.10.4", "nostr-tools": "^2.10.4",
"yaml": "^2.7.0", "yaml": "^2.7.0",
@@ -23,9 +36,23 @@
"typescript": "^5.0.0", "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": { "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=="], "@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/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=="], "@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=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"commons": ["commons@workspace:packages/commons"],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="], "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": ["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=="], "eventsource-parser": ["eventsource-parser@3.0.0", "", {}, "sha512-T1C0XCUimhxVQzW4zFipdx0SficT651NnkR0ZSH3yQwh+mFMdLfgjABVi4YtMTtaL4s168593DaoaRLMqryavA=="],
@@ -65,7 +96,7 @@
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "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=="], "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=="], "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=="], "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/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=="], "@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=="], "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=="],
} }
} }

175
packages/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,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

@@ -1,7 +1,6 @@
import { hexToBytes } from '@noble/hashes/utils'; import { hexToBytes } from '@noble/hashes/utils';
import { getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import type { Event, UnsignedEvent } from 'nostr-tools/pure'; import type { Event, UnsignedEvent } from 'nostr-tools/pure';
import { CONFIG } from '../config';
export const createKeyManager = (privateKeyHex: string) => { export const createKeyManager = (privateKeyHex: string) => {
const privateKeyBytes = hexToBytes(privateKeyHex); const privateKeyBytes = hexToBytes(privateKeyHex);
@@ -27,5 +26,3 @@ export const createKeyManager = (privateKeyHex: string) => {
return new Manager(); return new Manager();
}; };
export const keyManager = createKeyManager(CONFIG.nostr.privateKey);

View File

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

@@ -4,7 +4,7 @@ import type { SubCloser } from 'nostr-tools/pool';
import WebSocket from 'ws'; import WebSocket from 'ws';
import { useWebSocketImplementation } from 'nostr-tools/pool'; import { useWebSocketImplementation } from 'nostr-tools/pool';
import type { Filter } from 'nostr-tools'; import type { Filter } from 'nostr-tools';
import { CONFIG } from '../config'; import { DVM_NOTICE_KIND, TOOL_REQUEST_KIND, TOOL_RESPONSE_KIND } from '../constants';
useWebSocketImplementation(WebSocket); useWebSocketImplementation(WebSocket);
@@ -52,13 +52,16 @@ export class RelayHandler {
} }
} }
subscribeToRequests(onRequest: (event: Event) => void): SubCloser { subscribeToRequests(
const filters: Filter[] = [ onRequest: (event: Event) => void,
{ filter?: Filter
kinds: [5910], ): SubCloser {
const defaultFilter: Filter = {
kinds: [TOOL_REQUEST_KIND, TOOL_RESPONSE_KIND, DVM_NOTICE_KIND],
since: Math.floor(Date.now() / 1000), since: Math.floor(Date.now() / 1000),
}, };
];
const filters: Filter[] = [filter || defaultFilter];
const sub = this.pool.subscribeMany(this.relayUrls, filters, { const sub = this.pool.subscribeMany(this.relayUrls, filters, {
onevent(event) { onevent(event) {
@@ -96,5 +99,3 @@ export class RelayHandler {
return this.pool.listConnectionStatus(); return this.pool.listConnectionStatus();
} }
} }
export default new RelayHandler(CONFIG.nostr.relayUrls);

View File

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

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

View File

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

View File

@@ -1,12 +1,12 @@
{ {
"name": "mcp-dvm-bridge", "name": "dvmcp-bridge",
"module": "src/dvm-bridge.ts", "module": "index.ts",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
"dev": "bun --watch src/dvm-bridge.ts", "dev": "bun --watch index.ts",
"start": "bun run src/dvm-bridge.ts", "start": "bun run index.ts",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "bun run typecheck && bun run format", "lint": "bun run typecheck && bun run format",
"test": "bun test" "test": "bun test"
@@ -19,9 +19,9 @@
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.4.1", "@modelcontextprotocol/sdk": "^1.4.1",
"@noble/hashes": "^1.7.1",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"nostr-tools": "^2.10.4", "nostr-tools": "^2.10.4",
"yaml": "^2.7.0" "yaml": "^2.7.0",
"commons": "workspace:*"
} }
} }

View File

@@ -1,8 +1,11 @@
import { CONFIG } from '../config'; import type { RelayHandler } from 'commons/nostr/relay-handler';
import { RelayHandler } from './relay'; import { CONFIG } from './config';
import { keyManager } from './keys'; import { createKeyManager } from 'commons/nostr/key-manager';
import relayHandler from './relay'; import type { MCPPool } from './mcp-pool';
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 { export class NostrAnnouncer {
private relayHandler: RelayHandler; private relayHandler: RelayHandler;
@@ -27,7 +30,7 @@ export class NostrAnnouncer {
async announceService() { async announceService() {
const tools = await this.mcpPool.listTools(); const tools = await this.mcpPool.listTools();
const event = keyManager.signEvent({ const event = keyManager.signEvent({
...keyManager.createEventTemplate(31990), ...keyManager.createEventTemplate(DVM_ANNOUNCEMENT_KIND),
content: JSON.stringify({ content: JSON.stringify({
name: CONFIG.mcp.name, name: CONFIG.mcp.name,
about: CONFIG.mcp.about, about: CONFIG.mcp.about,
@@ -35,7 +38,7 @@ export class NostrAnnouncer {
}), }),
tags: [ tags: [
['d', 'dvm-announcement'], ['d', 'dvm-announcement'],
['k', '5910'], ['k', `${TOOL_REQUEST_KIND}`],
['capabilities', 'mcp-1.0'], ['capabilities', 'mcp-1.0'],
['t', 'mcp'], ['t', 'mcp'],
...tools.map((tool) => ['t', tool.name]), ...tools.map((tool) => ['t', tool.name]),

View File

@@ -1,10 +1,10 @@
import { parse } from 'yaml'; import { parse } from 'yaml';
import { join } from 'path'; import { join } from 'path';
import { existsSync, readFileSync } from 'fs'; import { existsSync, readFileSync } from 'fs';
import { HEX_KEYS_REGEX } from 'commons/constants';
import type { AppConfig, MCPServerConfig } from './types'; import type { AppConfig, MCPServerConfig } from './types';
const CONFIG_PATH = join(process.cwd(), 'config.yml'); const CONFIG_PATH = join(process.cwd(), 'config.yml');
const HEX_KEYS_REGEX = /^(?:[0-9a-fA-F]{64})$/;
const TEST_CONFIG: AppConfig = { const TEST_CONFIG: AppConfig = {
nostr: { nostr: {

View File

@@ -1,10 +1,10 @@
import { NostrAnnouncer } from './nostr/announcer'; import { keyManager, NostrAnnouncer } from './announcer';
import { RelayHandler } from './nostr/relay';
import { keyManager } from './nostr/keys';
import relayHandler from './nostr/relay';
import type { Event } from 'nostr-tools/pure'; import type { Event } from 'nostr-tools/pure';
import { CONFIG } from './config'; import { CONFIG } from './config';
import { MCPPool } from './mcp-pool'; 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 { export class DVMBridge {
private mcpPool: MCPPool; private mcpPool: MCPPool;
@@ -73,13 +73,13 @@ export class DVMBridge {
private async handleRequest(event: Event) { private async handleRequest(event: Event) {
try { try {
if (this.isWhitelisted(event.pubkey)) { 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]; const command = event.tags.find((tag) => tag[0] === 'c')?.[1];
if (command === 'list-tools') { if (command === 'list-tools') {
const tools = await this.mcpPool.listTools(); const tools = await this.mcpPool.listTools();
const response = keyManager.signEvent({ const response = keyManager.signEvent({
...keyManager.createEventTemplate(6910), ...keyManager.createEventTemplate(TOOL_RESPONSE_KIND),
content: JSON.stringify({ content: JSON.stringify({
tools, tools,
}), }),
@@ -94,7 +94,7 @@ export class DVMBridge {
} else { } else {
const jobRequest = JSON.parse(event.content); const jobRequest = JSON.parse(event.content);
const processingStatus = keyManager.signEvent({ const processingStatus = keyManager.signEvent({
...keyManager.createEventTemplate(7000), ...keyManager.createEventTemplate(DVM_NOTICE_KIND),
tags: [ tags: [
['status', 'processing'], ['status', 'processing'],
['e', event.id], ['e', event.id],
@@ -109,7 +109,7 @@ export class DVMBridge {
jobRequest.parameters jobRequest.parameters
); );
const successStatus = keyManager.signEvent({ const successStatus = keyManager.signEvent({
...keyManager.createEventTemplate(7000), ...keyManager.createEventTemplate(DVM_NOTICE_KIND),
tags: [ tags: [
['status', 'success'], ['status', 'success'],
['e', event.id], ['e', event.id],
@@ -118,7 +118,7 @@ export class DVMBridge {
}); });
await this.relayHandler.publishEvent(successStatus); await this.relayHandler.publishEvent(successStatus);
const response = keyManager.signEvent({ const response = keyManager.signEvent({
...keyManager.createEventTemplate(6910), ...keyManager.createEventTemplate(TOOL_RESPONSE_KIND),
content: JSON.stringify(result), content: JSON.stringify(result),
tags: [ tags: [
['request', JSON.stringify(event)], ['request', JSON.stringify(event)],
@@ -129,7 +129,7 @@ export class DVMBridge {
await this.relayHandler.publishEvent(response); await this.relayHandler.publishEvent(response);
} catch (error) { } catch (error) {
const errorStatus = keyManager.signEvent({ const errorStatus = keyManager.signEvent({
...keyManager.createEventTemplate(7000), ...keyManager.createEventTemplate(DVM_NOTICE_KIND),
tags: [ tags: [
[ [
'status', 'status',
@@ -146,7 +146,7 @@ export class DVMBridge {
} }
} else { } else {
const errorStatus = keyManager.signEvent({ const errorStatus = keyManager.signEvent({
...keyManager.createEventTemplate(7000), ...keyManager.createEventTemplate(DVM_NOTICE_KIND),
content: 'Unauthorized: Pubkey not in whitelist', content: 'Unauthorized: Pubkey not in whitelist',
tags: [ tags: [
['status', 'error'], ['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);
}
}

View File

@@ -1,5 +1,5 @@
import { expect, test, describe } from 'bun:test'; import { expect, test, describe } from 'bun:test';
import { createKeyManager } from '../nostr/keys'; import { createKeyManager } from 'commons/nostr/key-manager';
describe('KeyManager', () => { describe('KeyManager', () => {
const testPrivateKey = const testPrivateKey =

View File

@@ -1,8 +1,8 @@
import { expect, test, describe, beforeAll, afterAll } from 'bun:test'; import { expect, test, describe, beforeAll, afterAll } from 'bun:test';
import { MCPPool } from '../mcp-pool';
import { join } from 'path'; import { join } from 'path';
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; 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', () => { describe('MCPPool', () => {
let mcpPool: MCPPool; let mcpPool: MCPPool;

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,4 @@
import { RelayHandler } from 'commons/nostr/relay-handler';
import { CONFIG } from './config';
export const relayHandler = new RelayHandler(CONFIG.nostr.relayUrls);

175
packages/dvmcp-discovery/.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,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.

View File

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

View File

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

View File

@@ -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:*"
}
}

View File

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

View File

@@ -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<typeof createKeyManager>;
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<Tool[]> {
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<void> {
this.toolExecutor.cleanup();
await new Promise((resolve) => setTimeout(resolve, 10));
this.relayHandler.cleanup();
this.toolRegistry.clear();
}
}

View File

@@ -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',
},
]);
});
});

View File

@@ -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<typeof setTimeout>;
cleanup: () => void;
}
export class ToolExecutor {
private executionSubscriptions: Map<string, () => void> = new Map();
private static readonly EXECUTION_TIMEOUT = 30000;
constructor(
private relayHandler: RelayHandler,
private keyManager: ReturnType<typeof createKeyManager>
) {}
public async executeTool(tool: Tool, params: unknown): Promise<unknown> {
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);
}
}

View File

@@ -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<string, Tool> = 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<unknown>;
public setExecutionCallback(
callback: (toolId: string, args: unknown) => Promise<unknown>
): 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;
}
}

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