From 792e7ae5d14a3405dc7d199a94f8a7939d5c9645 Mon Sep 17 00:00:00 2001 From: dbth <1097224+believethehype@users.noreply.github.com> Date: Thu, 13 Feb 2025 19:31:40 +0100 Subject: [PATCH] add dvmcp MCP Server js --- .gitignore | 2 + nostr_dvm/tasks/mcpbridge.py | 25 ++-- tests/{ => mcp}/mcp_dvm_client.py | 10 +- tests/{ => mcp}/mcp_server_config.json | 2 +- tests/{ => mcp}/mcp_test.py | 4 +- tests/mcp/nostr_dvmcp_server.js | 181 +++++++++++++++++++++++++ tests/mcp/package.json | 21 +++ tests/mcp_server.py | 18 --- tests/mcp_server_nostr.py | 45 ------ 9 files changed, 226 insertions(+), 82 deletions(-) rename tests/{ => mcp}/mcp_dvm_client.py (89%) rename tests/{ => mcp}/mcp_server_config.json (92%) rename tests/{ => mcp}/mcp_test.py (97%) create mode 100644 tests/mcp/nostr_dvmcp_server.js create mode 100644 tests/mcp/package.json delete mode 100644 tests/mcp_server.py delete mode 100644 tests/mcp_server_nostr.py diff --git a/.gitignore b/.gitignore index 6de185d..420fce6 100644 --- a/.gitignore +++ b/.gitignore @@ -201,3 +201,5 @@ tests/gifs/lock.mdb tests/gif_library.py /.idea /.idea +/tests/mcp/package-lock.json +/tests/mcp/node_modules diff --git a/nostr_dvm/tasks/mcpbridge.py b/nostr_dvm/tasks/mcpbridge.py index a298823..d8aade9 100644 --- a/nostr_dvm/tasks/mcpbridge.py +++ b/nostr_dvm/tasks/mcpbridge.py @@ -70,13 +70,15 @@ class MCPBridge(DVMTaskInterface): if self.options.get("server_names"): self.server_names = (self.options.get("server_names")) - c = "list-tools" + c = "execute-tool" for tag in event.tags().to_vec(): - if tag.as_vec()[0] == 'c': + if tag.as_vec()[0] == "c": c = tag.as_vec()[1] + print(c) content = event.content() - + print(c) + print(content) options = { "command" : c, @@ -110,7 +112,7 @@ class MCPBridge(DVMTaskInterface): return json.dumps(final_tools) - elif options["command"] == "execute-tool": + else: print(options["payload"]) ob = json.loads(options["payload"]) @@ -118,7 +120,7 @@ class MCPBridge(DVMTaskInterface): tool_name = ob["name"] tool_args = ob["parameters"] tool_response = await self.call_tool(config_path, server_names, tool_name, tool_args) - + print(tool_response) return json.dumps(tool_response) @@ -153,11 +155,11 @@ class MCPBridge(DVMTaskInterface): tools = await send_tools_list(read_stream, write_stream) if tools is not None: alltools.append((server_name, tools)) - raise Exception("I'm gonna leave you.") + raise BaseException() else: print("nada") - except: + except BaseException as e: pass print("Ignore the error. We're good.") @@ -194,17 +196,18 @@ class MCPBridge(DVMTaskInterface): server_has_tool = True if server_has_tool is False: print("no tool in server") - raise Exception() + raise BaseException() else: + print(tool_args) tool_response = await send_call_tool( tool_name, tool_args, read_stream, write_stream) - raise Exception() # Until we find a better way to leave the async with + raise BaseException() # Until we find a better way to leave the async with except: pass - raise Exception() - except: + raise BaseException() + except BaseException as e: pass return tool_response diff --git a/tests/mcp_dvm_client.py b/tests/mcp/mcp_dvm_client.py similarity index 89% rename from tests/mcp_dvm_client.py rename to tests/mcp/mcp_dvm_client.py index 83ea1fb..a65a866 100644 --- a/tests/mcp_dvm_client.py +++ b/tests/mcp/mcp_dvm_client.py @@ -48,7 +48,7 @@ async def nostr_client_test_mcp_execute_tool(tool_name, tool_parameters, dvm_pub ptag = Tag.parse(["p", dvm_pubkey]) payload = {"name": tool_name, - "parameters": tool_parameters + "parameters": json.loads(tool_parameters) } event = EventBuilder(EventDefinitions.KIND_NIP90_MCP, json.dumps(payload)).tags( @@ -99,15 +99,15 @@ async def nostr_client(): - dvm_pubkey = "12e76a4504c09f0b4b02d8c7497525136c18520b23b9a035c998d23c817f381d" + dvm_pubkey = "85a319f3084cd23d3534d0ab2ddb80454df5dbd296d688257b4a336ed571d36a" #await nostr_client_test_mcp_get_tools(dvm_pubkey=dvm_pubkey) - #await nostr_client_test_mcp_execute_tool(tool_name="get-crypto-price", tool_parameters={"symbol": "BTC"}, dvm_pubkey=dvm_pubkey) + await nostr_client_test_mcp_execute_tool(tool_name="get-crypto-price", tool_parameters=json.dumps({"symbol": "BTC"}), dvm_pubkey=dvm_pubkey) #await nostr_client_test_mcp_execute_tool(tool_name="echo_tool", tool_parameters={"message": "Hello"}, dvm_pubkey=dvm_pubkey) #await nostr_client_test_mcp_get_tools(dvm_pubkey=dvm_pubkey) - await nostr_client_test_mcp_execute_tool(tool_name="extract", tool_parameters={"url": "https://en.wikipedia.org/wiki/Nostr"}, dvm_pubkey=dvm_pubkey) + #await nostr_client_test_mcp_execute_tool(tool_name="extract", tool_parameters={"url": "https://en.wikipedia.org/wiki/Nostr"}, dvm_pubkey=dvm_pubkey) class NotificationHandler(HandleNotification): @@ -127,7 +127,7 @@ async def nostr_client(): if __name__ == '__main__': - env_path = Path('.env') + env_path = Path('../.env') if env_path.is_file(): print(f'loading environment from {env_path.resolve()}') dotenv.load_dotenv(env_path, verbose=True, override=True) diff --git a/tests/mcp_server_config.json b/tests/mcp/mcp_server_config.json similarity index 92% rename from tests/mcp_server_config.json rename to tests/mcp/mcp_server_config.json index db8e544..defc76e 100644 --- a/tests/mcp_server_config.json +++ b/tests/mcp/mcp_server_config.json @@ -18,7 +18,7 @@ "mcp[cli]", "mcp", "run", - "tests/mcp_server.py" + "tests/mcp/mcp_server.py" ] } } diff --git a/tests/mcp_test.py b/tests/mcp/mcp_test.py similarity index 97% rename from tests/mcp_test.py rename to tests/mcp/mcp_test.py index e8cdb1d..933ada5 100644 --- a/tests/mcp_test.py +++ b/tests/mcp/mcp_test.py @@ -94,9 +94,9 @@ def playground(announce=False): if __name__ == '__main__': - env_path = Path('.env') + env_path = Path('../.env') if not env_path.is_file(): - with open('.env', 'w') as f: + with open('../.env', 'w') as f: print("Writing new .env file") f.write('') if env_path.is_file(): diff --git a/tests/mcp/nostr_dvmcp_server.js b/tests/mcp/nostr_dvmcp_server.js new file mode 100644 index 0000000..fa4b1d8 --- /dev/null +++ b/tests/mcp/nostr_dvmcp_server.js @@ -0,0 +1,181 @@ +import {McpServer} from "@modelcontextprotocol/sdk/server/mcp.js"; +import {StdioServerTransport} from "@modelcontextprotocol/sdk/server/stdio.js"; +import {z} from "zod"; +import { + Alphabet, + Client, + ClientBuilder, + Duration, + EventBuilder, + Filter, + Keys, + Kind, + loadWasmAsync, + loadWasmSync, + NostrSigner, + SingleLetterTag, + Tag + +} from "@rust-nostr/nostr-sdk"; + +// To use in a config, such as in Claude Desktop +// +// "mcpServers": { +// "nostrdvmcp": { +// "command": "node", +// "args": [ +// "/nostr_dvmcp_server.js" +// ] +// } +// } +// } + + + +//Basic config +const name = "nostr-mcp-server" +const version = "0.0.1" + +const config = { + name: name, + version: version, + capabilities: { + logging: {}, + }, + timeout: 1000 +} + const relays = + + [ "wss://relay.nostrdvm.com", + "wss://nostr.mom", + "wss://nostr.oxtr.dev", + "wss://relay.damus.io", + ]; + +// replace publickey with a unique one (it doesn't matter as much, we just do the requests) +let pkey = "e318cb3e6ac163814dd297c2c7d745faacfbc2a826eb4f6d6c81430426a83c2b" + +// Create an MCP server for Stdio +const server = new McpServer(config); + +//fetch nip89s of mcp servers TODO: allow to filter +await getnip89s() + + +//define getNip89s. Fetch from Nostr and add as tools to server. +async function getnip89s() { + await loadWasmAsync(); + + + + let keys = Keys.parse(pkey) + let signer = NostrSigner.keys(keys) + let client = new ClientBuilder().signer(signer).build() + + for (const relay of relays) { + await client.addRelay(relay); + } + await client.connect(); + + + const filter = new Filter().kind(new Kind(31990)).customTags(SingleLetterTag.lowercase(Alphabet.K), ["5910"]) + let evts = await client.fetchEvents(filter, Duration.fromSecs(5)) + + for (let evt of evts.toVec()) { + + let pubkey = evt.author.toHex() + let content_json = JSON.parse(evt.content) + let tools = content_json.tools + for (let tool of tools) { + if (tool.inputSchema === undefined || tool.inputSchema.properties === undefined) { + continue + } + + //TODO convert inputSchema.properties? to zod schema or to any other way so it works. + let inputSchema = {symbol: z.string()} + + server.tool(tool.name, tool.description, inputSchema, + async (args) => { + return await handle_dvm_request(args, tool.name, pubkey) + }); + } + } + +} + +async function handle_dvm_request(args, name, pubkey) { + await loadWasmSync(); + + + // Generate new random keys + let keys = Keys.parse(pkey) + + let request_kind = new Kind(5910); + let response_kind = new Kind(6910); + + let payload = { + "name": name, + "parameters": args + } + let signer = NostrSigner.keys(keys); + let client = new Client(signer); + for (const relay of relays) { + await client.addRelay(relay); + } + await client.connect(); + var relays_list = merge(["relays"], relays) + + let tags = [ + Tag.parse(["c", "execute-tool"]), + Tag.parse(["p", pubkey]), + Tag.parse(["output", "application/json"]), + Tag.parse(relays_list) + ] + + let event = new EventBuilder(request_kind, JSON.stringify(payload)).tags(tags) + //send our request to the DVM + await client.sendEventBuilder(event); + + + let abortable + const filter = new Filter().pubkey(keys.publicKey).kind(response_kind).limit(0); // Limit set to 0 to get only new events! Timestamp.now() CAN'T be used for gift wrap since the timestamps are tweaked! + //listen for a response + await client.subscribe(filter); + + + var result = "" + const handle = { + handleEvent: async (relayUrl, subscriptionId, event) => { + //TODO More logic / safety checks + result = JSON.parse(event.content).content[0].text + abortable.abort() + return true + }, + + handleMsg: async (relayUrl, message) => { + //console.log("Received message from", relayUrl, message.asJson()); + } + + }; + + abortable = client.handleNotifications(handle) + + //Wait till we have our response + await new Promise((resolve) => { + const checkAbort = setInterval(() => { + if (abortable.is_aborted()) { + clearInterval(checkAbort); + resolve(); + } + }, 100); + }); + // Then return the final result. + return { + content: [{ type: "text", text: `${result}` }] + }; +} + +//Connection +// Connect stdio +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/tests/mcp/package.json b/tests/mcp/package.json new file mode 100644 index 0000000..0fb115d --- /dev/null +++ b/tests/mcp/package.json @@ -0,0 +1,21 @@ +{ + "name": "vue", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "build-only": "vite build", + "type-check": "vue-tsc --build --force" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.5.0", + "@rust-nostr/nostr-sdk": "0.39.0", + "zod": "^3.24.2" + }, + "optionalDependencies": { + "@rollup/rollup-linux-x64-gnu": "4.6.1" + } +} diff --git a/tests/mcp_server.py b/tests/mcp_server.py deleted file mode 100644 index 2271360..0000000 --- a/tests/mcp_server.py +++ /dev/null @@ -1,18 +0,0 @@ -from mcp.server.fastmcp import FastMCP - -mcp = FastMCP("Echo") - -@mcp.resource("echo://{message}") -def echo_resource(message: str) -> str: - """Echo a message as a resource""" - return f"Resource echo: {message}" - -@mcp.tool() -def echo_tool(message: str) -> str: - """Echo a message as a tool""" - return f"Tool echo: {message}" - -@mcp.prompt() -def echo_prompt(message: str) -> str: - """Create an echo prompt""" - return f"Please process this message: {message}" \ No newline at end of file diff --git a/tests/mcp_server_nostr.py b/tests/mcp_server_nostr.py deleted file mode 100644 index 4c1bde9..0000000 --- a/tests/mcp_server_nostr.py +++ /dev/null @@ -1,45 +0,0 @@ -import json - -from mcp.server.fastmcp import FastMCP - -mcp = FastMCP("Nostr", dependencies=["nostr_dvm==1.1.0"]) - -@mcp.resource("echo://{message}") -def echo_resource(message: str) -> str: - """Echo a message as a resource""" - return f"Resource echo: {message}" - - - -@mcp.tool() -async def get_mcp_dvm_tool() -> str: - """Fetch Tools from Data Vending Machines""" - - from nostr_sdk import Keys, NostrSigner, Client - from nostr_dvm.utils.nip89_utils import nip89_fetch_all_dvms_by_kind - from nostr_dvm.utils.nostr_utils import check_and_set_private_key - - keys = Keys.parse(check_and_set_private_key("test_client")) - - signer = NostrSigner.keys(keys) - client = Client(signer) - - await client.add_relay("wss://relay.damus.io") - await client.add_relay("wss://nostr.mom") - await client.add_relay("wss://nostr.oxtr.dev") - await client.add_relay("wss://relay.nostrdvm.com") - await client.connect() - nip89s = await nip89_fetch_all_dvms_by_kind(client, 5910) - tools = [] - for announcement in nip89s: - print(announcement.as_json()["tools"]) - tools.append(announcement.as_json()["tools"]) - - return str(tools) - - - -@mcp.prompt() -def echo_prompt(message: str) -> str: - """Create an echo prompt""" - return f"Please process this message: {message}" \ No newline at end of file