diff --git a/.changeset/grumpy-rats-spend.md b/.changeset/grumpy-rats-spend.md deleted file mode 100644 index 789f572..0000000 --- a/.changeset/grumpy-rats-spend.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@dvmcp/discovery': patch -'@dvmcp/bridge': patch ---- - -implement ping capability diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 65c3eba..91c4a6e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,4 +23,4 @@ jobs: run: bun test - name: Check formatting - run: bun run format --check \ No newline at end of file + run: bun run fmt --check \ No newline at end of file diff --git a/README.md b/README.md index 51c715f..d1c4e33 100644 --- a/README.md +++ b/README.md @@ -11,39 +11,43 @@ Shared utilities and components used across DVMCP packages. ## Installation & Usage **Prerequisite:** Ensure you have [Bun](https://bun.sh/) installed. -### Quick Start with NPX (No Installation) -You can run the packages directly using `npx` without installing them: +### Quick Start with Bunx (No Installation) +You can run the packages directly using `bunx` without installing them: ```bash # Run the bridge -npx @dvmcp/bridge +bunx dvmcp-bridge # Run the discovery service -npx @dvmcp/discovery +bunx dvmcp-discovery ``` The interactive CLI will guide you through configuration setup on first run. + ### Global Installation ```bash # Install the packages globally -npm install -g @dvmcp/bridge @dvmcp/discovery +bun install -g @dvmcp/bridge @dvmcp/discovery # Run the commands dvmcp-bridge dvmcp-discovery ``` + ## Setting Up a Bridge To expose your MCP server as a DVM on Nostr: 1. Navigate to the directory where you want to configure the bridge -2. Run: `npx @dvmcp/bridge` +2. Run: `bunx dvmcp-bridge` 3. Follow the interactive setup to configure: -- Your MCP server path -- Nostr private key (or generate a new one) -- Relays to connect to + - Your MCP server path + - Nostr private key (or generate a new one) + - Relays to connect to 4. The bridge will start and begin proxying requests between Nostr and your MCP server + ## Setting Up a Discovery Service To aggregate MCP tools from DVMs: 1. Navigate to your desired directory -2. Run: `npx @dvmcp/discovery` +2. Run: `bunx dvmcp-discovery` 3. Follow the setup to configure: -- Nostr private key -- Relays to monitor + - Nostr private key + - Relays to monitor + ## Development For contributors to this repository: ```bash @@ -56,16 +60,4 @@ bun install bun run dev --cwd packages/dvmcp-bridge # Start the discovery service in development mode bun run dev --cwd packages/dvmcp-discovery -``` -## Documentation -- [DVMCP Specification](./docs/dvmcp-spec.md) -- [Bridge Package](./packages/dvmcp-bridge/README.md) -- [Discovery Package](./packages/dvmcp-discovery/README.md) -- [Commons Package](./packages/dvmcp-commons/README.md) -## Contributing -Contributions are welcome! Please feel free to submit pull requests or create issues. -## License -[MIT License](LICENSE) -## Related Projects -- [Model Context Protocol](https://modelcontextprotocol.io) -- [Nostr Protocol](https://github.com/nostr-protocol/nips) \ No newline at end of file +``` \ No newline at end of file diff --git a/ROADMAP.md b/ROADMAP.md index e1a7288..53ebd54 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,9 +1,10 @@ - [x] Add flag `--config-path` to cli - [x] Add one command, configless, quick run mode in discovery package -- [ ] Review for 2025-03-26 -- [ ] Add payments (#10) -- [ ] Add env variables(#7) -- [ ] Add resources to bridge package -- [ ] Add sessions -- [ ] Add encryption -- [ ] Discovery cli to bootstrap configuration +- [x] Review for 2025-03-26 +- [x] Add payments (#10) +- [x] Add env variables(#7) +- [x] Add resources to bridge package +- [x] Add encryption +- [x] Discovery cli to bootstrap configuration +- [ ] Unannounced/Private servers +- [ ] Improve docs \ No newline at end of file diff --git a/bun.lock b/bun.lock index 3067d44..9bac5a5 100644 --- a/bun.lock +++ b/bun.lock @@ -8,7 +8,7 @@ }, "devDependencies": { "@changesets/cli": "^2.29.4", - "@types/node": "^22.15.19", + "@types/node": "^22.15.31", "prettier": "^3.5.3", }, }, @@ -23,7 +23,6 @@ "@getalby/lightning-tools": "^5.1.2", "@modelcontextprotocol/sdk": "^1.11.4", "@types/yargs": "^17.0.33", - "dotenv": "^16.5.0", "nostr-tools": "^2.13.0", "yaml": "^2.8.0", "yargs": "^17.7.2", @@ -59,8 +58,10 @@ "dependencies": { "@dvmcp/commons": "^0.2.5", "@modelcontextprotocol/sdk": "^1.11.4", + "@types/yargs": "^17.0.33", "nostr-tools": "^2.13.0", "yaml": "^2.8.0", + "yargs": "^17.7.2", }, "devDependencies": { "@dvmcp/commons": "workspace:*", @@ -141,7 +142,7 @@ "@scure/bip39": ["@scure/bip39@1.2.1", "", { "dependencies": { "@noble/hashes": "~1.3.0", "@scure/base": "~1.1.0" } }, "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg=="], - "@types/bun": ["@types/bun@1.2.15", "", { "dependencies": { "bun-types": "1.2.15" } }, "sha512-U1ljPdBEphF0nw1MIk0hI7kPg7dFdPyM7EenHsp6W5loNHl7zqy6JQf/RKCgnUn2KDzUpkBwHPnEJEjII594bA=="], + "@types/bun": ["@types/bun@1.2.16", "", { "dependencies": { "bun-types": "1.2.16" } }, "sha512-1aCZJ/6nSiViw339RsaNhkNoEloLaPzZhxMOYEa7OzRzO41IGg5n/7I43/ZIAW/c+Q6cT12Vf7fOZOoVIzb5BQ=="], "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], @@ -173,7 +174,7 @@ "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], - "bun-types": ["bun-types@1.2.15", "", { "dependencies": { "@types/node": "*" } }, "sha512-NarRIaS+iOaQU1JPfyKhZm4AsUOrwUOqRNHY0XxI8GI8jYxiLXLcdjYMG9UKS+fwWasc1uw1htV9AX24dD+p4w=="], + "bun-types": ["bun-types@1.2.16", "", { "dependencies": { "@types/node": "*" } }, "sha512-ciXLrHV4PXax9vHvUrkvun9VPVGOVwbbbBF/Ev1cXz12lyEZMoJpIJABOfPcN9gDJRaiKF9MVbSygLg4NXu3/A=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -211,8 +212,6 @@ "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], - "dotenv": ["dotenv@16.5.0", "", {}, "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg=="], - "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], diff --git a/docs/dvmcp-spec-2025-03-26.md b/docs/dvmcp-spec-2025-03-26.md index 4e4fc05..0309969 100644 --- a/docs/dvmcp-spec-2025-03-26.md +++ b/docs/dvmcp-spec-2025-03-26.md @@ -24,6 +24,13 @@ This document defines how Nostr and Data Vending Machines can be used to expose - [Prompts](#prompts) - [Completions](#completions) - [Ping](#ping) +- [Encryption](#encryption) + - [Overview](#overview-1) + - [Encryption Support Discovery](#encryption-support-discovery) + - [Message Encryption Flow](#message-encryption-flow) + - [Encrypted Event Structure](#encrypted-event-structure) + - [Key Management](#key-management) + - [Implementation Guidelines](#implementation-guidelines) - [Notifications](#notifications) - [MCP Notifications](#mcp-notifications) - [Nostr-Specific Notifications](#nostr-specific-notifications) @@ -131,6 +138,9 @@ This specification defines these event kinds: | 25910 | Requests | | 26910 | Responses | | 21316 | Feedback/Notifications | +| 1059 | Encrypted Messages (NIP-59 Gift Wrap) | + +**Note on Encryption**: When encryption is enabled, ephemeral events (kinds 25910, 26910, 21316) are wrapped using NIP-17/NIP-59 encryption and published as kind 1059 events. Addressable events (kinds 31316-31319) remain unencrypted for discoverability. ## Server Discovery DVMCP provides two methods of server discovery, the main differences between these two methods being the visibility of the servers and the way they are advertised. Public servers can advertise themselves and their capabilities to improve discoverability when providing a "public" or accessible service. Private servers may not advertise themselves and their capabilities, but they can be discovered by clients that know the provider's public key or server identifier. @@ -944,6 +954,238 @@ If a ping cannot be processed due to protocol errors: } ``` +## Encryption + +### Overview + +DVMCP supports optional end-to-end encryption for enhanced privacy and security using the Nostr protocol's encryption standards. This feature leverages NIP-17 (Private Direct Messages) for secure message encryption and NIP-59 (Gift Wrap) for metadata protection, ensuring that: + +1. **Message Content Privacy**: All DVMCP message content is encrypted using NIP-44 encryption +2. **Metadata Protection**: Gift wrapping hides participant identities, timestamps, and message patterns +3. **Forward Secrecy**: Messages can be configured for automatic expiration +4. **Selective Encryption**: Clients and servers can negotiate encryption on a per-session basis + +Encryption in DVMCP maintains full compatibility with the standard protocol while adding an additional privacy layer. When encryption is enabled, all ephemeral events (requests, responses, and notifications) are encrypted using the NIP-17/NIP-59 pattern, while addressable events (server announcements and capability lists) remain unencrypted for discoverability. + +### Encryption Support Discovery + +Encryption support is advertised through the [`support_encryption`](docs/dvmcp-spec-2025-03-26.md:179) tag in server announcement events: + +```json +{ + "kind": 31316, + "pubkey": "", + "tags": [ + ["d", ""], + ["support_encryption", "true"], + // ... other tags + ], + // ... rest of announcement +} +``` + +Clients can discover encryption support by: + +1. **Public Server Discovery**: Check for the [`support_encryption`](docs/dvmcp-spec-2025-03-26.md:179) tag in server announcements (kind 31316) +2. **Direct Discovery**: Include encryption capability in initialization requests and check server responses +3. **Non-announced/Direct Discovery**: For servers that are not publicly announced or during direct discovery, clients wanting to use encryption should attempt to use encryption first and fall back to unencrypted communication if desired or if encryption fails +4. **Dynamic Negotiation**: Attempt encrypted communication and fall back to unencrypted if not supported + +### Message Encryption Flow + +When encryption is enabled, DVMCP messages follow the NIP-17 pattern with NIP-59 gift wrapping: + +#### 1. Content Preparation +The original DVMCP message content is prepared as usual: +```json +{ + "method": "tools/call", + "params": { + "name": "get_weather", + "arguments": { "location": "New York" } + } +} +``` + +#### 2. Seal Creation (NIP-17) +The DVMCP content is encrypted and sealed as an unsigned kind 14 event: +```json +{ + "id": "", + "pubkey": "", + "created_at": "", + "kind": 14, + "tags": [ + ["p", ""], + ["s", ""] // Optional: Server identifier when targeting specific server + ], + "content": "" +} +``` + +This unsigned event is then sealed (kind 13) with NIP-44 encryption: +```json +{ + "id": "", + "pubkey": "", + "created_at": "", + "kind": 13, + "tags": [], + "content": "", + "sig": "" +} +``` + +#### 3. Gift Wrapping (NIP-59) +The seal is gift-wrapped (kind 1059) with a random key: +```json +{ + "id": "", + "pubkey": "", + "created_at": "", + "kind": 1059, + "tags": [ + ["p", ""] + ], + "content": "", + "sig": "" +} +``` + +### Encrypted Event Structure + +When encryption is active, DVMCP events are transformed as follows: + +#### Original DVMCP Request +```json +{ + "kind": 25910, + "pubkey": "", + "content": "{\"method\":\"tools/call\",\"params\":{\"name\":\"get_weather\",\"arguments\":{\"location\":\"New York\"}}}", + "tags": [ + ["method", "tools/call"], + ["p", ""], + ["s", ""] + ] +} +``` + +#### Encrypted DVMCP Request +```json +{ + "kind": 1059, + "pubkey": "", + "created_at": "", + "content": "", + "tags": [ + ["p", ""] + ], + "sig": "" +} +``` + +The inner content (after decryption) maintains the original DVMCP structure with all tags and metadata intact. + +#### Encrypted Response Structure + +Server responses follow the same pattern: +```json +{ + "kind": 1059, + "pubkey": "", + "created_at": "", + "content": "", + "tags": [ + ["p", ""] + ], + "sig": "" +} +``` + +The decrypted inner content contains the standard DVMCP response format. + +### Key Management + +DVMCP encryption follows Nostr's standard key management practices: + +#### 1. Identity Keys +- **Client Identity**: Client's main Nostr private/public key pair +- **Server Identity**: Provider's Nostr private/public key pair +- **Conversation Keys**: Derived using NIP-44 key derivation between client and server keys + +#### 2. Ephemeral Keys +- **Gift Wrap Keys**: Random one-time-use key pairs for each gift wrap +- **Key Rotation**: New ephemeral keys generated for each message +- **Key Disposal**: Ephemeral private keys discarded immediately after use + +#### 3. Key Discovery +- **Public Key Exchange**: Client and server public keys exchanged during discovery/initialization +- **Relay Preferences**: Clients should respect server's preferred relays (kind 10050) for encrypted message delivery + +### Implementation Guidelines + +#### 1. Encryption Negotiation +- **Capability Advertisement**: Servers MUST advertise encryption support in announcements +- **Client Detection**: Clients SHOULD attempt encrypted communication when supported +- **Graceful Fallback**: Implementations MUST handle encryption failures gracefully +- **Mixed Mode Support**: Systems MAY support both encrypted and unencrypted sessions simultaneously + +#### 2. Message Routing +- **Relay Selection**: Use recipient's preferred relays (NIP-10050) for encrypted messages +- **Delivery Confirmation**: Implement appropriate timeout and retry mechanisms +- **Relay Privacy**: Choose relays that respect encrypted message privacy + +#### 3. Performance Considerations +- **Encryption Overhead**: Account for additional processing time for encryption/decryption +- **Message Size**: Encrypted messages are larger due to wrapping layers +- **Caching**: Avoid caching decrypted content; re-decrypt as needed + +#### 4. Security Best Practices +- **Key Hygiene**: Properly dispose of ephemeral keys after use +- **Timestamp Randomization**: Randomize timestamps within 2-day windows +- **Metadata Minimization**: Avoid leaking patterns through timing or relay selection +- **Forward Secrecy**: Support message expiration through appropriate tagging + +#### 5. Error Handling +- **Decryption Failures**: Handle gracefully with appropriate error messages +- **Key Mismatches**: Provide clear feedback for key-related issues +- **Relay Failures**: Implement retry logic for delivery failures + +#### Example Implementation Flow +```mermaid +sequenceDiagram + participant Client as DVMCP Client + participant Relay as Nostr Relay + participant Server as DVMCP Server + + Note over Client,Server: Encryption Negotiation + Client->>Relay: Check server announcement (kind 31316) + Relay-->>Client: Server supports encryption (support_encryption: true) + + Note over Client,Server: Encrypted Request Flow + Client->>Client: Prepare DVMCP request + Client->>Client: Create unsigned kind 14 with DVMCP content + Client->>Client: Seal as kind 13 (encrypted) + Client->>Client: Gift wrap as kind 1059 (double encrypted) + Client->>Relay: Publish encrypted gift wrap + + Relay-->>Server: Deliver encrypted message + Server->>Server: Unwrap gift wrap (kind 1059) + Server->>Server: Decrypt seal (kind 13) + Server->>Server: Extract DVMCP request (kind 14) + Server->>Server: Process DVMCP request + + Note over Client,Server: Encrypted Response Flow + Server->>Server: Prepare DVMCP response + Server->>Server: Create unsigned kind 14 with response + Server->>Server: Seal as kind 13 (encrypted) + Server->>Server: Gift wrap as kind 1059 (double encrypted) + Server->>Relay: Publish encrypted response + + Relay-->>Client: Deliver encrypted response + Client->>Client: Decrypt and process response +``` + ## Notifications Notifications in DVMCP are divided into two categories: MCP-compliant notifications that follow the Model Context Protocol specification, and Nostr-specific notifications that leverage Nostr's event-based architecture for features like payment handling. @@ -1071,6 +1313,10 @@ DVMCP handles two types of errors: protocol errors and execution errors. 6. Process notifications according to the MCP specification 7. Use standard Nostr tags for Nostr-specific features (like payments) 8. Respond promptly to ping requests with empty responses +9. Advertise encryption support through the `support_encryption` tag when available +10. Handle both encrypted and unencrypted messages when encryption is supported +11. Properly decrypt NIP-17/NIP-59 wrapped messages when encryption is enabled +12. Use appropriate key management practices for encryption keys ### Clients MUST: @@ -1081,6 +1327,9 @@ DVMCP handles two types of errors: protocol errors and execution errors. 5. Subscribe to notifications from the server is interacting with 6. Send the initialized notification when using Direct Discovery (private servers) 7. Handle ping requests and responses appropriately for connection health monitoring +8. Respect server encryption capabilities and negotiate appropriately +9. Implement proper NIP-17/NIP-59 encryption when communicating with encryption-enabled servers +10. Handle decryption failures gracefully with appropriate fallback mechanisms ## Complete Protocol Flow diff --git a/package.json b/package.json index c6a1724..8d4dd17 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dvmcp", "scripts": { - "format": "prettier --write \"packages/**/*.{ts,tsx,js,jsx,json,md}\"", + "fmt": "prettier --write \"packages/**/*.{ts,tsx,js,jsx,json,md}\"", "typecheck": "tsc --noEmit", "changeset": "changeset", "version-packages": "changeset version", diff --git a/packages/dvmcp-bridge/CHANGELOG.md b/packages/dvmcp-bridge/CHANGELOG.md index d6b02b4..90da64e 100644 --- a/packages/dvmcp-bridge/CHANGELOG.md +++ b/packages/dvmcp-bridge/CHANGELOG.md @@ -1,5 +1,14 @@ # @dvmcp/bridge +## 0.2.3 + +### Patch Changes + +- feat: encryption features +- f79d9a4: implement ping capability +- Updated dependencies + - @dvmcp/commons@0.2.6 + ## 0.2.2 ### Patch Changes diff --git a/packages/dvmcp-bridge/README.md b/packages/dvmcp-bridge/README.md index fadf68e..9f8fd57 100644 --- a/packages/dvmcp-bridge/README.md +++ b/packages/dvmcp-bridge/README.md @@ -9,6 +9,7 @@ A bridge implementation that connects Model Context Protocol (MCP) servers to No - Tool discovery and execution through DVM kind:5910/6910 events - Job status updates and payment handling via kind:7000 events - Service announcement deletion using NIP-09 +- Encrypted communication support using NIP-17/NIP-59 - Comprehensive error handling ## Configuration @@ -39,58 +40,17 @@ npx dvmcp-bridge --config-path /path/to/custom/config.yml You can configure the bridge using environment variables. The following variables are supported: -``` -DVMCP_NOSTR_PRIVATE_KEY= -DVMCP_NOSTR_RELAY_URLS=wss://relay1.com,wss://relay2.com -DVMCP_MCP_NAME="My DVM Bridge" -DVMCP_MCP_ABOUT="My custom DVM bridge description" -DVMCP_MCP_CLIENT_NAME="My Client" -DVMCP_MCP_CLIENT_VERSION="1.0.0" -DVMCP_MCP_PICTURE="https://example.com/picture.jpg" -DVMCP_MCP_WEBSITE="https://example.com" -DVMCP_MCP_BANNER="https://example.com/banner.jpg" -DVMCP_WHITELIST_ALLOWED_PUBKEYS=pubkey1,pubkey2 -DVMCP_LIGHTNING_ADDRESS="your-lightning-address@provider.com" -DVMCP_LIGHTNING_ZAP_RELAYS=wss://relay1.com,wss://relay2.com -``` +// TODO: Improve docs, add env variables ### Command-Line Arguments You can also configure the bridge using command-line arguments, which have the highest priority: -```bash -npx @dvmcp/bridge \ - --nostr-private-key \ - --nostr-relay-urls wss://relay1.com,wss://relay2.com \ - --mcp-name "My DVM Bridge" \ - --mcp-about "My custom DVM bridge description" \ - --mcp-client-name "My Client" \ - --mcp-client-version "1.0.0" \ - --mcp-picture "https://example.com/picture.jpg" \ - --mcp-website "https://example.com" \ - --mcp-banner "https://example.com/banner.jpg" \ - --whitelist-allowed-pubkeys pubkey1,pubkey2 \ - --lightning-address "your-lightning-address@provider.com" \ - --lightning-zap-relays wss://relay1.com,wss://relay2.com -``` +// TODO: Add command line arguments Shorthand flags are available for some options: -- `-c` for `--config-path` -- `-r` for `--nostr-relay-urls` -- `-v` for `--verbose` -- `-h` for `--help` - -### Configuration Priority - -When multiple configuration sources provide values for the same setting, the priority order is: - -1. Command-line arguments (highest priority) -2. Environment variables -3. Configuration file -4. Default values (lowest priority) - -This means that command-line arguments will override environment variables, which will override values from the configuration file, which will override default values. +... ### Viewing Configuration @@ -100,6 +60,16 @@ Use the `--verbose` or `-v` flag to display the current configuration: npx @dvmcp/bridge --verbose ``` +## Encryption Support + +The DVMCP Bridge supports a flexible encryption system to secure communication. It offers three distinct modes: + +- **DISABLED**: No encryption is used for communication. +- **OPTIONAL**: (Default) Encrypted and unencrypted messages are accepted, and responses mirror the format of the incoming message. This provides maximum compatibility. +- **REQUIRED**: Only encrypted communication is accepted and generated, ensuring high security. + +For a detailed explanation of the available encryption modes and their behavior, including configuration examples, please refer to the [DVMCP Encryption Configuration Guide](../dvmcp-commons/src/encryption/README.md). + ## Usage **Prerequisite:** Ensure you have [Bun](https://bun.sh/) installed. diff --git a/packages/dvmcp-bridge/config.example.yml b/packages/dvmcp-bridge/config.example.yml index 2080a78..ae89b75 100644 --- a/packages/dvmcp-bridge/config.example.yml +++ b/packages/dvmcp-bridge/config.example.yml @@ -81,7 +81,15 @@ lightning: - "wss://relay.damus.io" - "wss://nostr.mutinywallet.com" - # [Optional] Whitelist Configuration +# [Optional] Encryption Configuration (NIP-17/NIP-59 support) +encryption: + # Encryption mode: 'disabled', 'optional' (default), or 'required' + # - disabled: No encryption support + # - optional: Message format mirroring (encrypted request -> encrypted response) + # - required: Only accepts encrypted communication + mode: "optional" + +# [Optional] Whitelist Configuration # whitelist: # List of allowed public keys (leave empty for no restrictions) # allowedPubkeys: [] diff --git a/packages/dvmcp-bridge/package.json b/packages/dvmcp-bridge/package.json index f4dd1e2..dbea185 100644 --- a/packages/dvmcp-bridge/package.json +++ b/packages/dvmcp-bridge/package.json @@ -1,6 +1,6 @@ { "name": "@dvmcp/bridge", - "version": "0.2.2", + "version": "0.2.3", "description": "Bridge connecting MCP servers to Nostr's DVM ecosystem", "module": "index.ts", "type": "module", @@ -16,11 +16,11 @@ "config.example.yml" ], "scripts": { - "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", + "fmt": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", "dev": "bun --watch index.ts", "start": "DEBUG=* bun run cli.ts", "typecheck": "tsc --noEmit", - "lint": "bun run typecheck && bun run format", + "lint": "bun run typecheck && bun run fmt", "test": "bun test", "prepublishOnly": "bun run lint && bun run test" }, @@ -32,11 +32,10 @@ "typescript": "^5.8.3" }, "dependencies": { - "@dvmcp/commons": "^0.2.5", + "@dvmcp/commons": "^0.2.6", "@getalby/lightning-tools": "^5.1.2", "@modelcontextprotocol/sdk": "^1.11.4", "@types/yargs": "^17.0.33", - "dotenv": "^16.5.0", "nostr-tools": "^2.13.0", "yaml": "^2.8.0", "yargs": "^17.7.2" diff --git a/packages/dvmcp-bridge/src/announcer.ts b/packages/dvmcp-bridge/src/announcer.ts index 54462ab..747fae1 100644 --- a/packages/dvmcp-bridge/src/announcer.ts +++ b/packages/dvmcp-bridge/src/announcer.ts @@ -11,6 +11,9 @@ import { TAG_UNIQUE_IDENTIFIER, TAG_KIND, TAG_SERVER_IDENTIFIER, + TAG_SUPPORT_ENCRYPTION, + TAG_EVENT_ID, + TAG_CAPABILITY, } from '@dvmcp/commons/core'; import type { Event } from 'nostr-tools/pure'; import { loggerBridge } from '@dvmcp/commons/core'; @@ -24,6 +27,7 @@ import { type ListResourceTemplatesResult, } from '@modelcontextprotocol/sdk/types.js'; import { slugify } from '@dvmcp/commons/core'; +import { EncryptionMode } from '@dvmcp/commons/encryption'; function getNip89Tags(cfg: DvmcpBridgeConfig['mcp']): string[][] { const keys = ['name', 'about', 'picture', 'website', 'banner'] as const; @@ -91,6 +95,11 @@ export class NostrAnnouncer { [TAG_KIND, `${REQUEST_KIND}`], ...getNip89Tags(this.config.mcp), ]; + + // Add encryption support tag conditionally + if (this.config.encryption?.mode !== EncryptionMode.DISABLED) { + tags.push([TAG_SUPPORT_ENCRYPTION, 'true']); + } const event = this.keyManager.signEvent({ ...this.keyManager.createEventTemplate(SERVER_ANNOUNCEMENT_KIND), content: announcementContent, @@ -113,7 +122,12 @@ export class NostrAnnouncer { for (const tool of toolsResult.tools) { const pricing = this.mcpPool.getToolPricing(tool.name); if (pricing?.price) { - tags.push(['cap', tool.name, pricing.price, pricing.unit || 'sats']); + tags.push([ + TAG_CAPABILITY, + tool.name, + pricing.price, + pricing.unit || 'sats', + ]); } } } @@ -141,7 +155,7 @@ export class NostrAnnouncer { const pricing = this.mcpPool.getResourcePricing(resource.uri); if (pricing?.price) { tags.push([ - 'cap', + TAG_CAPABILITY, resource.uri, pricing.price, pricing.unit || 'sats', @@ -182,7 +196,7 @@ export class NostrAnnouncer { // Add capability tags for each resource template name for (const template of resourceTemplatesResult.resourceTemplates) { if (template.name) { - tags.push(['cap', template.name]); + tags.push([TAG_CAPABILITY, template.name]); } } @@ -211,7 +225,7 @@ export class NostrAnnouncer { const pricing = this.mcpPool.getPromptPricing(prompt.name); if (pricing?.price) { tags.push([ - 'cap', + TAG_CAPABILITY, prompt.name, pricing.price, pricing.unit || 'sats', @@ -334,7 +348,7 @@ export class NostrAnnouncer { ...this.keyManager.createEventTemplate(5), content: reason, tags: [ - ...events.map((ev) => ['e', ev.id]), + ...events.map((ev) => [TAG_EVENT_ID, ev.id]), [TAG_UNIQUE_IDENTIFIER, this.serverId], ], }); diff --git a/packages/dvmcp-bridge/src/config-schema.ts b/packages/dvmcp-bridge/src/config-schema.ts index 99f7329..b638a92 100644 --- a/packages/dvmcp-bridge/src/config-schema.ts +++ b/packages/dvmcp-bridge/src/config-schema.ts @@ -5,6 +5,8 @@ */ import type { ConfigSchema } from '@dvmcp/commons/config'; +import type { EncryptionConfig } from '@dvmcp/commons/encryption'; +import { EncryptionMode } from '@dvmcp/commons/encryption'; /** * Nostr protocol configuration. @@ -190,6 +192,10 @@ export interface DvmcpBridgeConfig { * Optional Lightning payment configuration. */ lightning?: LightningConfig; + /** + * Optional encryption configuration. + */ + encryption?: EncryptionConfig; } /** @@ -394,6 +400,19 @@ export const dvmcpBridgeConfigSchema: ConfigSchema = { }, }, }, + encryption: { + type: 'object', + required: false, + doc: 'Optional encryption configuration for NIP-17/NIP-59 support.', + fields: { + mode: { + type: 'string', + required: false, + default: EncryptionMode.OPTIONAL, + doc: 'Encryption mode: disabled, optional (mirrors incoming format), or required.', + }, + }, + }, } as const; export type DvmcpBridgeConfigSchema = typeof dvmcpBridgeConfigSchema; diff --git a/packages/dvmcp-bridge/src/dvm-bridge.ts b/packages/dvmcp-bridge/src/dvm-bridge.ts index 0dac644..3fc4690 100644 --- a/packages/dvmcp-bridge/src/dvm-bridge.ts +++ b/packages/dvmcp-bridge/src/dvm-bridge.ts @@ -7,15 +7,19 @@ import { REQUEST_KIND, RESPONSE_KIND, NOTIFICATION_KIND, + GIFT_WRAP_KIND, TAG_METHOD, TAG_PUBKEY, TAG_EVENT_ID, TAG_STATUS, TAG_SERVER_IDENTIFIER, + MCPMETHODS, } from '@dvmcp/commons/core'; import { loggerBridge } from '@dvmcp/commons/core'; -import type { NostrEvent } from 'nostr-tools'; +import { type NostrEvent, getEventHash } from 'nostr-tools'; import { getServerId } from './utils'; +import { EncryptionManager, EncryptionMode } from '@dvmcp/commons/encryption'; +import { EventPublisher } from '@dvmcp/commons/nostr'; import { handleToolsList, handleToolsCall } from './handlers/tool-handlers'; import { @@ -30,10 +34,19 @@ import { handleCompletionComplete, handlePing, } from './handlers'; + +export interface ResponseContext { + recipientPubkey: string; + shouldEncrypt: boolean; + encryptionManager?: EncryptionManager; +} + export class DVMBridge { private mcpPool: MCPPool; private nostrAnnouncer: NostrAnnouncer; private relayHandler: RelayHandler; + private encryptionManager: EncryptionManager | null = null; + private eventPublisher: EventPublisher; private isRunning: boolean = false; public readonly serverId: string; public readonly keyManager: ReturnType; @@ -60,6 +73,23 @@ export class DVMBridge { loggerBridge(`Using custom server ID from config: ${this.serverId}`); } + // Initialize encryption manager if encryption is configured + if (this.config.encryption) { + this.encryptionManager = new EncryptionManager(this.config.encryption); + loggerBridge( + `Encryption support enabled (${this.config.encryption.mode || 'optional'} mode)` + ); + } else { + loggerBridge('Encryption support disabled'); + } + + // Initialize centralized event publisher + this.eventPublisher = new EventPublisher( + this.relayHandler, + this.keyManager, + this.encryptionManager || undefined + ); + this.nostrAnnouncer = new NostrAnnouncer( this.mcpPool, config, @@ -103,8 +133,14 @@ export class DVMBridge { loggerBridge('Setting up request handlers...'); const publicKey = this.keyManager.getPublicKey(); const subscribe = () => { + // Subscribe to both regular and encrypted events + const kinds = [REQUEST_KIND, NOTIFICATION_KIND]; + if (this.encryptionManager?.isEncryptionEnabled()) { + kinds.push(GIFT_WRAP_KIND); + } + this.relayHandler.subscribeToRequests(this.handleRequest.bind(this), { - kinds: [REQUEST_KIND, NOTIFICATION_KIND], + kinds, '#p': [publicKey], since: Math.floor(Date.now() / 1000), }); @@ -161,16 +197,150 @@ export class DVMBridge { } } + /** + * Decrypt an encrypted event and extract the real sender's public key + * Uses centralized EncryptionManager for cleaner implementation + */ + private async decryptEventAndExtractSender( + giftWrapEvent: NostrEvent + ): Promise<{ + eventTemplate: NostrEvent; + realSenderPubkey: string; + } | null> { + try { + if (!this.encryptionManager || giftWrapEvent.kind !== GIFT_WRAP_KIND) { + return null; + } + + const decryptionResult = + await this.encryptionManager.decryptEventAndExtractSender( + giftWrapEvent, + this.keyManager.getPrivateKey() + ); + + if (!decryptionResult) { + return null; + } + + return { + eventTemplate: decryptionResult.decryptedEvent, + realSenderPubkey: decryptionResult.sender, + }; + } catch (error) { + loggerBridge('Error in decryptEventAndExtractSender:', error); + return null; + } + } + private async handleRequest(event: NostrEvent): Promise { try { - const tags = event.tags; - const kind = event.kind; - const pubkey = event.pubkey; - const id = event.id; - const method = tags.find((tag) => tag[0] === TAG_METHOD)?.[1] || ''; - const serverIdentifier = - tags.find((tag) => tag[0] === TAG_SERVER_IDENTIFIER)?.[1] || ''; + // Check if this is an encrypted event + if ( + event.kind === GIFT_WRAP_KIND && + this.encryptionManager?.isEncryptionEnabled() + ) { + loggerBridge('Received encrypted event, attempting to decrypt...'); + + // Try to decrypt the event and extract the real sender's pubkey + const decryptionResult = await this.decryptEventAndExtractSender(event); + + if (!decryptionResult) { + loggerBridge( + 'Failed to decrypt event - may not be intended for this server' + ); + return; + } + + // Process the decrypted event with the real sender's pubkey + loggerBridge('Successfully decrypted event, processing...'); + // Construct a NostrEvent from the decrypted template and real sender pubkey + // The ID should be computed from the inner event content, not the gift wrap + const innerEventId = getEventHash({ + ...decryptionResult.eventTemplate, + pubkey: decryptionResult.realSenderPubkey, + }); + + const reconstructedEvent: NostrEvent = { + ...decryptionResult.eventTemplate, + id: innerEventId, // Use computed ID of the inner event - this is what discovery client expects + pubkey: decryptionResult.realSenderPubkey, + sig: event.sig, // Use original signature (though it's from the ephemeral key) + }; + + await this.processDecryptedRequest( + reconstructedEvent, + decryptionResult.realSenderPubkey + ); + return; + } + + // Handle regular unencrypted events + if (this.config.encryption?.mode !== EncryptionMode.REQUIRED) { + await this.processRegularRequest(event); + } + } catch (error) { + console.error('Error handling request:', error); + } + } + + private async processRegularRequest(event: NostrEvent): Promise { + const tags = event.tags; + const kind = event.kind; + const pubkey = event.pubkey; + const id = event.id; + const method = tags.find((tag) => tag[0] === TAG_METHOD)?.[1] || ''; + const serverIdentifier = + tags.find((tag) => tag[0] === TAG_SERVER_IDENTIFIER)?.[1] || ''; + + await this.processRequest( + event, + kind, + pubkey, + id, + method, + serverIdentifier + ); + } + + private async processDecryptedRequest( + decryptedEvent: NostrEvent, + realSenderPubkey: string + ): Promise { + // The decryptedEvent now contains the reconstructed DVMCP message + // and realSenderPubkey contains the actual sender's public key + + const tags = decryptedEvent.tags; + const kind = decryptedEvent.kind; + const pubkey = realSenderPubkey; // Use the real sender's pubkey + const id = decryptedEvent.id; + const method = tags.find((tag) => tag[0] === TAG_METHOD)?.[1] || ''; + const serverIdentifier = + tags.find((tag) => tag[0] === TAG_SERVER_IDENTIFIER)?.[1] || ''; + loggerBridge( + `Processing encrypted request from real sender: ${realSenderPubkey}` + ); + await this.processRequest( + decryptedEvent, + kind, + pubkey, + id, + method, + serverIdentifier, + true + ); + } + + private async processRequest( + event: NostrEvent, + kind: number, + pubkey: string, + id: string, + method: string, + serverIdentifier: string, + isEncrypted: boolean = false + ): Promise { + try { // For ping requests, if no server ID is specified, we should still respond // For all other methods, server ID must match if ( @@ -191,92 +361,116 @@ export class DVMBridge { } if (!this.isWhitelisted(pubkey)) { - const errorStatus = this.keyManager.signEvent({ - ...this.keyManager.createEventTemplate(NOTIFICATION_KIND), - content: 'Unauthorized: Pubkey not in whitelist', - tags: [ + const shouldEncryptResponse = + this.encryptionManager?.shouldEncryptResponse(isEncrypted) || false; + + await this.eventPublisher.publishNotification( + 'Unauthorized: Pubkey not in whitelist', + pubkey, + [ [TAG_STATUS, 'error'], [TAG_EVENT_ID, id], [TAG_PUBKEY, pubkey], ], - }); - await this.relayHandler.publishEvent(errorStatus); + shouldEncryptResponse + ); return; } + if (kind === REQUEST_KIND) { + const shouldEncryptResponse = + this.encryptionManager?.shouldEncryptResponse(isEncrypted) || false; + + const responseContext: ResponseContext = { + recipientPubkey: pubkey, + shouldEncrypt: shouldEncryptResponse, + encryptionManager: this.encryptionManager || undefined, + }; + switch (method) { - case 'initialize': + case MCPMETHODS.initialize: break; - case 'ping': - await handlePing(event, this.keyManager, this.relayHandler); + case MCPMETHODS.ping: + await handlePing( + event, + this.keyManager, + this.relayHandler, + responseContext + ); break; - case 'tools/list': + case MCPMETHODS.toolsList: await handleToolsList( event, this.mcpPool, this.keyManager, - this.relayHandler + this.relayHandler, + responseContext ); break; - case 'tools/call': + case MCPMETHODS.toolsCall: await handleToolsCall( event, this.mcpPool, this.keyManager, this.relayHandler, - this.config + this.config, + responseContext ); break; - case 'resources/list': + case MCPMETHODS.resourcesList: await handleResourcesList( event, this.mcpPool, this.keyManager, - this.relayHandler + this.relayHandler, + responseContext ); break; - case 'resources/read': + case MCPMETHODS.resourcesRead: await handleResourcesRead( event, this.mcpPool, this.keyManager, this.relayHandler, - this.config + this.config, + responseContext ); break; - case 'resources/templates/list': + case MCPMETHODS.resourcesTemplatesList: await handleResourceTemplatesList( event, this.mcpPool, this.keyManager, - this.relayHandler + this.relayHandler, + responseContext ); break; - case 'prompts/list': + case MCPMETHODS.promptsList: await handlePromptsList( event, this.mcpPool, this.keyManager, - this.relayHandler + this.relayHandler, + responseContext ); break; - case 'prompts/get': + case MCPMETHODS.promptsGet: await handlePromptsGet( event, this.mcpPool, this.keyManager, this.relayHandler, - this.config + responseContext ); break; - case 'completion/complete': - const completionResponse = await handleCompletionComplete( + case MCPMETHODS.completionComplete: + await handleCompletionComplete( event, this.mcpPool, - this.keyManager + this.keyManager, + this.relayHandler, + responseContext ); - if (!completionResponse) break; - await this.relayHandler.publishEvent(completionResponse); break; default: const notImpl = this.keyManager.signEvent({ @@ -293,14 +487,24 @@ export class DVMBridge { [TAG_PUBKEY, pubkey], ], }); - await this.relayHandler.publishEvent(notImpl); + await this.publishResponse(notImpl, responseContext); } } else if (kind === NOTIFICATION_KIND) { - if (method === 'notifications/cancel') { + if (method === MCPMETHODS.notificationsCancel) { + const shouldEncryptResponse = + this.encryptionManager?.shouldEncryptResponse(isEncrypted) || false; + + const responseContext: ResponseContext = { + recipientPubkey: pubkey, + shouldEncrypt: shouldEncryptResponse, + encryptionManager: this.encryptionManager || undefined, + }; + await handleNotificationsCancel( event, this.keyManager, - this.relayHandler + this.relayHandler, + responseContext ); } else { loggerBridge(`Received unhandled notification type: ${method}`); @@ -309,7 +513,21 @@ export class DVMBridge { loggerBridge(`Received unhandled event kind: ${kind}`); } } catch (error) { - console.error('Error handling request:', error); + console.error('Error processing request:', error); } } + + /** + * Publishes a response using the centralized event publisher + */ + private async publishResponse( + event: NostrEvent, + responseContext: ResponseContext + ): Promise { + await this.eventPublisher.publishResponse( + event, + responseContext.recipientPubkey, + responseContext.shouldEncrypt + ); + } } diff --git a/packages/dvmcp-bridge/src/handlers/completion-handlers.ts b/packages/dvmcp-bridge/src/handlers/completion-handlers.ts index 34836ee..2109f41 100644 --- a/packages/dvmcp-bridge/src/handlers/completion-handlers.ts +++ b/packages/dvmcp-bridge/src/handlers/completion-handlers.ts @@ -1,59 +1,55 @@ import { type Event as NostrEvent } from 'nostr-tools'; import { loggerBridge } from '@dvmcp/commons/core'; import { MCPPool } from '../mcp-pool'; -import { RESPONSE_KIND, TAG_EVENT_ID } from '@dvmcp/commons/core'; +import { RESPONSE_KIND, TAG_EVENT_ID, TAG_PUBKEY } from '@dvmcp/commons/core'; import type { CompleteRequest, CompleteResult, } from '@modelcontextprotocol/sdk/types.js'; import type { KeyManager } from '@dvmcp/commons/nostr'; - -/** - * Create a response event for a request event - * @param requestEvent - The request event to respond to - * @param content - The content of the response - * @param keyManager - The key manager to sign the event - * @returns The response event - */ -function createResponseEvent( - requestEvent: NostrEvent, - content: CompleteResult, - keyManager: KeyManager -): NostrEvent { - const responseTemplate = keyManager.createEventTemplate(RESPONSE_KIND); - responseTemplate.content = JSON.stringify(content); - responseTemplate.tags.push([TAG_EVENT_ID, requestEvent.id]); - return keyManager.signEvent(responseTemplate); -} +import type { RelayHandler } from '@dvmcp/commons/nostr'; +import { getResponsePublisher } from '../utils/response-publisher.js'; +import type { ResponseContext } from '../dvm-bridge.js'; /** * Handle a completion/complete request from a client - * @param event - The Nostr event containing the request - * @param mcpPool - The MCP pool to route the request to - * @param keyManager - The key manager to sign the response - * @returns A promise that resolves to a Nostr event containing the response */ export async function handleCompletionComplete( event: NostrEvent, mcpPool: MCPPool, - keyManager: KeyManager -): Promise { + keyManager: KeyManager, + relayHandler: RelayHandler, + responseContext: ResponseContext +): Promise { + const id = event.id; + const pubkey = event.pubkey; + try { - // Parse the request content const request: CompleteRequest = JSON.parse(event.content); - // Call the complete method on the MCP pool const result: CompleteResult | undefined = await mcpPool.complete( request.params ); if (!result) { - // If no result, it means the server doesn't support completions or the reference wasn't found return undefined; } - // Return the completion result - return createResponseEvent(event, result, keyManager); + const response = keyManager.signEvent({ + ...keyManager.createEventTemplate(RESPONSE_KIND), + content: JSON.stringify(result), + tags: [ + [TAG_EVENT_ID, id], + [TAG_PUBKEY, pubkey], + ], + }); + + const publisher = getResponsePublisher( + relayHandler, + keyManager, + responseContext.encryptionManager + ); + await publisher.publishResponse(response, responseContext); } catch (error) { loggerBridge('[handleCompletionComplete] Error:', error); return undefined; diff --git a/packages/dvmcp-bridge/src/handlers/notification-handlers.ts b/packages/dvmcp-bridge/src/handlers/notification-handlers.ts index 1c7663d..e7daaaa 100644 --- a/packages/dvmcp-bridge/src/handlers/notification-handlers.ts +++ b/packages/dvmcp-bridge/src/handlers/notification-handlers.ts @@ -1,12 +1,14 @@ -import { loggerBridge } from '@dvmcp/commons/core'; +import { loggerBridge, MCPMETHODS } from '@dvmcp/commons/core'; import { TAG_EVENT_ID, TAG_PUBKEY, TAG_STATUS, - NOTIFICATION_KIND, + TAG_METHOD, } from '@dvmcp/commons/core'; import type { KeyManager, RelayHandler } from '@dvmcp/commons/nostr'; +import { EventPublisher } from '@dvmcp/commons/nostr'; import type { NostrEvent } from 'nostr-tools'; +import type { ResponseContext } from '../dvm-bridge'; // TODO: actually cancel the job /** * Handles the notifications/cancel method @@ -14,7 +16,8 @@ import type { NostrEvent } from 'nostr-tools'; export async function handleNotificationsCancel( event: NostrEvent, keyManager: KeyManager, - relayHandler: RelayHandler + relayHandler: RelayHandler, + responseContext?: ResponseContext ): Promise { const pubkey = event.pubkey; const tags = event.tags; @@ -25,20 +28,27 @@ export async function handleNotificationsCancel( if (eventIdToCancel) { loggerBridge(`Received cancel request for job: ${eventIdToCancel}`); - // Send cancellation acknowledgment - const cancelAckStatus = keyManager.signEvent({ - ...keyManager.createEventTemplate(NOTIFICATION_KIND), - content: JSON.stringify({ - method: 'notifications/progress', + // Send cancellation acknowledgment using centralized event publisher + const eventPublisher = new EventPublisher( + relayHandler, + keyManager, + responseContext?.encryptionManager + ); + + await eventPublisher.publishNotification( + JSON.stringify({ + method: MCPMETHODS.notificationsProgress, params: { message: 'cancellation-acknowledged' }, }), - tags: [ + pubkey, + [ [TAG_STATUS, 'cancelled'], [TAG_EVENT_ID, eventIdToCancel], [TAG_PUBKEY, pubkey], + [TAG_METHOD, MCPMETHODS.notificationsProgress], ], - }); - await relayHandler.publishEvent(cancelAckStatus); + responseContext?.shouldEncrypt || false + ); } else { loggerBridge('Received cancel notification without event ID'); } diff --git a/packages/dvmcp-bridge/src/handlers/payment-handler.ts b/packages/dvmcp-bridge/src/handlers/payment-handler.ts index 56ed1b9..6c8086d 100644 --- a/packages/dvmcp-bridge/src/handlers/payment-handler.ts +++ b/packages/dvmcp-bridge/src/handlers/payment-handler.ts @@ -1,13 +1,12 @@ -import { loggerBridge } from '@dvmcp/commons/core'; +import { loggerBridge, TAG_INVOICE } from '@dvmcp/commons/core'; import { TAG_AMOUNT, TAG_EVENT_ID, TAG_PUBKEY, TAG_STATUS, - NOTIFICATION_KIND, } from '@dvmcp/commons/core'; import type { DvmcpBridgeConfig } from '../config-schema.js'; -import { RelayHandler } from '@dvmcp/commons/nostr'; +import { RelayHandler, EventPublisher } from '@dvmcp/commons/nostr'; import type { KeyManager } from '@dvmcp/commons/nostr'; import { LightningAddress } from '@getalby/lightning-tools'; import type { Event } from 'nostr-tools/pure'; @@ -24,6 +23,9 @@ import { createNostrProvider } from '@dvmcp/commons/nostr'; * @param config The bridge configuration * @param keyManager The key manager instance * @param relayHandler The relay handler instance + * @param unit The payment unit (default: 'sats') + * @param timeoutMs Payment timeout in milliseconds + * @param shouldEncrypt Whether to encrypt notifications * @returns A boolean indicating whether payment was successful */ export async function handlePaymentFlow( @@ -35,9 +37,12 @@ export async function handlePaymentFlow( keyManager: KeyManager, relayHandler: RelayHandler, unit: string = 'sats', - timeoutMs?: number + timeoutMs?: number, + shouldEncrypt: boolean = false ): Promise { try { + const eventPublisher = new EventPublisher(relayHandler, keyManager); + // Generate zap request const zapRequest = await generateZapRequest( price, @@ -53,18 +58,18 @@ export async function handlePaymentFlow( return false; } - // Send payment required notification - await relayHandler.publishEvent( - keyManager.signEvent({ - ...keyManager.createEventTemplate(NOTIFICATION_KIND), - tags: [ - [TAG_STATUS, 'payment-required'], - [TAG_AMOUNT, price, unit], - ['invoice', zapRequest.paymentRequest], - [TAG_EVENT_ID, eventId], - [TAG_PUBKEY, pubkey], - ], - }) + // Send payment required notification using centralized event publisher + await eventPublisher.publishNotification( + '', + pubkey, + [ + [TAG_STATUS, 'payment-required'], + [TAG_AMOUNT, price, unit], + [TAG_INVOICE, zapRequest.paymentRequest], + [TAG_EVENT_ID, eventId], + [TAG_PUBKEY, pubkey], + ], + shouldEncrypt ); // Verify payment with timeout @@ -75,17 +80,17 @@ export async function handlePaymentFlow( timeoutMs ); - // Send appropriate notification based on payment status + // Send appropriate notification based on payment status using centralized event publisher const status = paymentVerified ? 'payment-accepted' : 'error'; - await relayHandler.publishEvent( - keyManager.signEvent({ - ...keyManager.createEventTemplate(NOTIFICATION_KIND), - tags: [ - [TAG_STATUS, status], - [TAG_EVENT_ID, eventId], - [TAG_PUBKEY, pubkey], - ], - }) + await eventPublisher.publishNotification( + '', + pubkey, + [ + [TAG_STATUS, status], + [TAG_EVENT_ID, eventId], + [TAG_PUBKEY, pubkey], + ], + shouldEncrypt ); return paymentVerified; diff --git a/packages/dvmcp-bridge/src/handlers/payment-processor.ts b/packages/dvmcp-bridge/src/handlers/payment-processor.ts index d4be57d..4ff1092 100644 --- a/packages/dvmcp-bridge/src/handlers/payment-processor.ts +++ b/packages/dvmcp-bridge/src/handlers/payment-processor.ts @@ -1,15 +1,15 @@ -import { loggerBridge } from '@dvmcp/commons/core'; +import { loggerBridge, MCPMETHODS } from '@dvmcp/commons/core'; +import type { DvmcpBridgeConfig, MCPPricingConfig } from '../config-schema.js'; +import type { KeyManager } from '@dvmcp/commons/nostr'; // KeyManager might still be needed for handlePaymentFlow +import type { RelayHandler } from '@dvmcp/commons/nostr'; // RelayHandler might still be needed for handlePaymentFlow +import { handlePaymentFlow } from './payment-handler'; +import type { ResponsePublisher } from '../utils/response-publisher.js'; import { TAG_EVENT_ID, + TAG_METHOD, TAG_PUBKEY, TAG_STATUS, - TAG_METHOD, - NOTIFICATION_KIND, } from '@dvmcp/commons/core'; -import type { DvmcpBridgeConfig, MCPPricingConfig } from '../config-schema.js'; -import type { RelayHandler } from '@dvmcp/commons/nostr'; -import type { KeyManager } from '@dvmcp/commons/nostr'; -import { handlePaymentFlow } from './payment-handler'; const DEFAULT_PAYMENT_TIMEOUT_MS = 10 * 60 * 1000; @@ -19,6 +19,7 @@ export class PaymentProcessor { private config: DvmcpBridgeConfig, private keyManager: KeyManager, private relayHandler: RelayHandler, + private notificationPublisher: ResponsePublisher, private paymentTimeoutMs: number = DEFAULT_PAYMENT_TIMEOUT_MS ) {} @@ -30,6 +31,7 @@ export class PaymentProcessor { * @param capabilityType The type of capability (tool, prompt, resource) * @param eventId The original event ID that triggered this payment flow * @param pubkey The public key of the user making the request + * @param shouldEncrypt Whether to encrypt notifications * @returns A boolean indicating whether payment was successful or not required */ async processPaymentIfRequired( @@ -37,19 +39,33 @@ export class PaymentProcessor { capabilityName: string, capabilityType: 'tool' | 'prompt' | 'resource', eventId: string, - pubkey: string + pubkey: string, + shouldEncrypt: boolean = false ): Promise { - await this.sendProcessingNotification(eventId, pubkey); - const capabilityId = `${capabilityType}: ${capabilityName}`; + const capabilityId = `${capabilityType}:${capabilityName}`; if (!pricing?.price) { loggerBridge(`No payment required for ${capabilityId}`); return true; } + await this.notificationPublisher.publishNotification( + JSON.stringify({ + method: MCPMETHODS.notificationsProgress, + params: { message: 'processing payment' }, + }), + pubkey, + [ + [TAG_PUBKEY, pubkey], + [TAG_EVENT_ID, eventId], + [TAG_METHOD, MCPMETHODS.notificationsProgress], + ], + shouldEncrypt + ); + // Handle payment flow with timeout try { - return await Promise.race([ + const paymentSuccessful = await Promise.race([ handlePaymentFlow( pricing.price, capabilityId, @@ -59,78 +75,43 @@ export class PaymentProcessor { this.keyManager, this.relayHandler, pricing.unit || 'sats', - this.paymentTimeoutMs + this.paymentTimeoutMs, + shouldEncrypt ), this.createPaymentTimeout(capabilityId), ]); + + if (!paymentSuccessful) { + loggerBridge(`Payment failed or timed out for ${capabilityId}`); + await this.notificationPublisher.publishNotification( + 'Payment failed or timed out', + pubkey, + [ + [TAG_STATUS, 'error'], + [TAG_EVENT_ID, eventId], + [TAG_PUBKEY, pubkey], + ], + shouldEncrypt + ); + return false; + } + return true; } catch (error) { loggerBridge(`Payment error for ${capabilityId} - ${error}`); - await this.sendErrorNotification( - eventId, + await this.notificationPublisher.publishNotification( + error instanceof Error ? error.message : String(error), pubkey, - error instanceof Error ? error.message : String(error) + [ + [TAG_STATUS, 'error'], + [TAG_EVENT_ID, eventId], + [TAG_PUBKEY, pubkey], + ], + shouldEncrypt ); return false; } } - private async sendProcessingNotification( - eventId: string, - pubkey: string - ): Promise { - const processingStatus = this.keyManager.signEvent({ - ...this.keyManager.createEventTemplate(NOTIFICATION_KIND), - content: JSON.stringify({ - method: 'notifications/progress', - params: { message: 'processing' }, - }), - tags: [ - [TAG_PUBKEY, pubkey], - [TAG_EVENT_ID, eventId], - [TAG_METHOD, 'notifications/progress'], - ], - }); - await this.relayHandler.publishEvent(processingStatus); - } - - /** - * Send a success notification to the client - */ - async sendSuccessNotification( - eventId: string, - pubkey: string - ): Promise { - const successStatus = this.keyManager.signEvent({ - ...this.keyManager.createEventTemplate(NOTIFICATION_KIND), - tags: [ - [TAG_STATUS, 'success'], - [TAG_EVENT_ID, eventId], - [TAG_PUBKEY, pubkey], - ], - }); - await this.relayHandler.publishEvent(successStatus); - } - - /** - * Send an error notification to the client - */ - async sendErrorNotification( - eventId: string, - pubkey: string, - reason?: string - ): Promise { - const errorStatus = this.keyManager.signEvent({ - ...this.keyManager.createEventTemplate(NOTIFICATION_KIND), - content: reason || 'Unknown error', - tags: [ - [TAG_STATUS, 'error'], - [TAG_EVENT_ID, eventId], - [TAG_PUBKEY, pubkey], - ], - }); - await this.relayHandler.publishEvent(errorStatus); - } - private createPaymentTimeout(capabilityId: string): Promise { return new Promise((resolve) => { setTimeout(() => { diff --git a/packages/dvmcp-bridge/src/handlers/ping-handlers.ts b/packages/dvmcp-bridge/src/handlers/ping-handlers.ts index 918ad9d..04ebe6b 100644 --- a/packages/dvmcp-bridge/src/handlers/ping-handlers.ts +++ b/packages/dvmcp-bridge/src/handlers/ping-handlers.ts @@ -7,6 +7,8 @@ import { TAG_PUBKEY, loggerBridge, } from '@dvmcp/commons/core'; +import type { ResponseContext } from '../dvm-bridge.js'; +import { getResponsePublisher } from '../utils/response-publisher'; /** * Handle ping requests from clients @@ -15,7 +17,8 @@ import { export async function handlePing( event: NostrEvent, keyManager: KeyManager, - relayHandler: RelayHandler + relayHandler: RelayHandler, + responseContext: ResponseContext ): Promise { const pubkey = event.pubkey; const id = event.id; @@ -23,7 +26,6 @@ export async function handlePing( loggerBridge(`Handling ping request from ${pubkey}`); try { - // Create ping response with empty content as per DVMCP spec const response = keyManager.signEvent({ ...keyManager.createEventTemplate(RESPONSE_KIND), content: JSON.stringify({}), @@ -33,7 +35,12 @@ export async function handlePing( ], }); - await relayHandler.publishEvent(response); + const publisher = getResponsePublisher( + relayHandler, + keyManager, + responseContext.encryptionManager + ); + await publisher.publishResponse(response, responseContext); loggerBridge(`Sent ping response to ${pubkey}`); } catch (error) { console.error('Error handling ping request:', error); @@ -53,6 +60,11 @@ export async function handlePing( ], }); - await relayHandler.publishEvent(errorResponse); + const publisher = getResponsePublisher( + relayHandler, + keyManager, + responseContext.encryptionManager + ); + await publisher.publishResponse(errorResponse, responseContext); } } diff --git a/packages/dvmcp-bridge/src/handlers/prompt-handlers.ts b/packages/dvmcp-bridge/src/handlers/prompt-handlers.ts index 84f86ee..cfbd4f0 100644 --- a/packages/dvmcp-bridge/src/handlers/prompt-handlers.ts +++ b/packages/dvmcp-bridge/src/handlers/prompt-handlers.ts @@ -1,18 +1,17 @@ import { TAG_EVENT_ID, TAG_PUBKEY, RESPONSE_KIND } from '@dvmcp/commons/core'; import type { MCPPool } from '../mcp-pool'; -import type { DvmcpBridgeConfig } from '../config-schema.js'; import type { RelayHandler } from '@dvmcp/commons/nostr'; import type { KeyManager } from '@dvmcp/commons/nostr'; import type { NostrEvent } from 'nostr-tools'; import { - GetPromptRequestSchema, - ListPromptsRequestSchema, - type GetPromptResult, type ListPromptsResult, + ListPromptsRequestSchema, + GetPromptRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; import { createProtocolErrorResponse } from '../utils'; import { loggerBridge } from '@dvmcp/commons/core'; -import { PaymentProcessor } from './payment-processor'; +import type { ResponseContext } from '../dvm-bridge.js'; +import { getResponsePublisher } from '../utils/response-publisher'; /** * Handles the prompts/list method request @@ -21,23 +20,28 @@ export async function handlePromptsList( event: NostrEvent, mcpPool: MCPPool, keyManager: KeyManager, - relayHandler: RelayHandler + relayHandler: RelayHandler, + responseContext: ResponseContext ): Promise { const { success, error } = ListPromptsRequestSchema.safeParse( JSON.parse(event.content) ); if (!success) { loggerBridge('prompts list request error', error); - await relayHandler.publishEvent( - createProtocolErrorResponse( - event.id, - event.pubkey, - -32700, - JSON.stringify(error), - keyManager, - RESPONSE_KIND - ) + const errorResponse = createProtocolErrorResponse( + event.id, + event.pubkey, + -32700, + JSON.stringify(error), + keyManager, + RESPONSE_KIND + ); + const publisher = getResponsePublisher( + relayHandler, + keyManager, + responseContext.encryptionManager ); + await publisher.publishResponse(errorResponse, responseContext); return; } const id = event.id; @@ -53,7 +57,12 @@ export async function handlePromptsList( [TAG_PUBKEY, pubkey], ], }); - await relayHandler.publishEvent(response); + const publisher = getResponsePublisher( + relayHandler, + keyManager, + responseContext.encryptionManager + ); + await publisher.publishResponse(response, responseContext); } catch (err) { const errorResp = keyManager.signEvent({ ...keyManager.createEventTemplate(RESPONSE_KIND), @@ -69,7 +78,12 @@ export async function handlePromptsList( [TAG_PUBKEY, pubkey], ], }); - await relayHandler.publishEvent(errorResp); + const publisher = getResponsePublisher( + relayHandler, + keyManager, + responseContext.encryptionManager + ); + await publisher.publishResponse(errorResp, responseContext); } } @@ -81,7 +95,7 @@ export async function handlePromptsGet( mcpPool: MCPPool, keyManager: KeyManager, relayHandler: RelayHandler, - config: DvmcpBridgeConfig + responseContext: ResponseContext ): Promise { const { success, @@ -90,84 +104,49 @@ export async function handlePromptsGet( } = GetPromptRequestSchema.safeParse(JSON.parse(event.content)); if (!success) { loggerBridge('prompts get request error', error); - await relayHandler.publishEvent( - createProtocolErrorResponse( - event.id, - event.pubkey, - -32700, - JSON.stringify(error), - keyManager, - RESPONSE_KIND - ) + const errorResponse = createProtocolErrorResponse( + event.id, + event.pubkey, + -32700, + JSON.stringify(error), + keyManager, + RESPONSE_KIND + ); + const publisher = getResponsePublisher( + relayHandler, + keyManager, + responseContext.encryptionManager ); + await publisher.publishResponse(errorResponse, responseContext); return; } const id = event.id; const pubkey = event.pubkey; - // Create payment processor - const paymentProcessor = new PaymentProcessor( - config, - keyManager, - relayHandler - ); - try { if (!getParams.params.name) { throw new Error('Prompt name is required'); } - const promptName = getParams.params.name; - - // Check if prompt requires payment - const pricing = mcpPool.getPromptPricing(promptName); - - // Process payment if required - const paymentSuccessful = await paymentProcessor.processPaymentIfRequired( - pricing, - promptName, - 'prompt', - id, - pubkey + const promptResult = await mcpPool.getPrompt( + getParams.params.name, + getParams.params.arguments ); - - if (!paymentSuccessful) { - // Payment failed, exit early - return; - } - - const promptArgs = getParams.params.arguments || {}; - loggerBridge(`Getting prompt '${promptName}' with arguments:`, promptArgs); - - const prompt: GetPromptResult | undefined = await mcpPool.getPrompt( - promptName, - promptArgs - ); - if (!prompt) { - throw new Error(`Prompt not found: ${promptName}`); - } - - // Send success notification - await paymentProcessor.sendSuccessNotification(id, pubkey); - const response = keyManager.signEvent({ ...keyManager.createEventTemplate(RESPONSE_KIND), - content: JSON.stringify(prompt), + content: JSON.stringify(promptResult), tags: [ [TAG_EVENT_ID, id], [TAG_PUBKEY, pubkey], ], }); - await relayHandler.publishEvent(response); - } catch (err) { - // Send error notification - await paymentProcessor.sendErrorNotification( - id, - pubkey, - err instanceof Error ? err.message : String(err) + const publisher = getResponsePublisher( + relayHandler, + keyManager, + responseContext.encryptionManager ); - - // Send error response + await publisher.publishResponse(response, responseContext); + } catch (err) { const errorResp = keyManager.signEvent({ ...keyManager.createEventTemplate(RESPONSE_KIND), content: JSON.stringify({ @@ -182,6 +161,11 @@ export async function handlePromptsGet( [TAG_PUBKEY, pubkey], ], }); - await relayHandler.publishEvent(errorResp); + const publisher = getResponsePublisher( + relayHandler, + keyManager, + responseContext.encryptionManager + ); + await publisher.publishResponse(errorResp, responseContext); } } diff --git a/packages/dvmcp-bridge/src/handlers/resource-handlers.ts b/packages/dvmcp-bridge/src/handlers/resource-handlers.ts index 0d60370..58cc745 100644 --- a/packages/dvmcp-bridge/src/handlers/resource-handlers.ts +++ b/packages/dvmcp-bridge/src/handlers/resource-handlers.ts @@ -1,4 +1,9 @@ -import { TAG_EVENT_ID, TAG_PUBKEY, RESPONSE_KIND } from '@dvmcp/commons/core'; +import { + TAG_EVENT_ID, + TAG_PUBKEY, + RESPONSE_KIND, + TAG_STATUS, +} from '@dvmcp/commons/core'; import type { MCPPool } from '../mcp-pool'; import type { DvmcpBridgeConfig } from '../config-schema.js'; import type { RelayHandler } from '@dvmcp/commons/nostr'; @@ -14,6 +19,8 @@ import { import { createProtocolErrorResponse } from '../utils'; import { loggerBridge } from '@dvmcp/commons/core'; import { PaymentProcessor } from './payment-processor'; +import type { ResponseContext } from '../dvm-bridge.js'; +import { getResponsePublisher } from '../utils/response-publisher'; /** * Handles the resources/list method request @@ -22,23 +29,28 @@ export async function handleResourcesList( event: NostrEvent, mcpPool: MCPPool, keyManager: KeyManager, - relayHandler: RelayHandler + relayHandler: RelayHandler, + responseContext: ResponseContext ): Promise { const { success, error } = ListResourcesRequestSchema.safeParse( JSON.parse(event.content) ); if (!success) { loggerBridge('resources list request error', error); - await relayHandler.publishEvent( - createProtocolErrorResponse( - event.id, - event.pubkey, - -32700, - JSON.stringify(error), - keyManager, - RESPONSE_KIND - ) + const errorResponse = createProtocolErrorResponse( + event.id, + event.pubkey, + -32700, + JSON.stringify(error), + keyManager, + RESPONSE_KIND + ); + const publisher = getResponsePublisher( + relayHandler, + keyManager, + responseContext.encryptionManager ); + await publisher.publishResponse(errorResponse, responseContext); return; } const id = event.id; @@ -54,7 +66,12 @@ export async function handleResourcesList( [TAG_PUBKEY, pubkey], ], }); - await relayHandler.publishEvent(response); + const publisher = getResponsePublisher( + relayHandler, + keyManager, + responseContext.encryptionManager + ); + await publisher.publishResponse(response, responseContext); } catch (err) { const errorResp = keyManager.signEvent({ ...keyManager.createEventTemplate(RESPONSE_KIND), @@ -70,7 +87,12 @@ export async function handleResourcesList( [TAG_PUBKEY, pubkey], ], }); - await relayHandler.publishEvent(errorResp); + const publisher = getResponsePublisher( + relayHandler, + keyManager, + responseContext.encryptionManager + ); + await publisher.publishResponse(errorResp, responseContext); } } @@ -81,7 +103,8 @@ export async function handleResourceTemplatesList( event: NostrEvent, mcpPool: MCPPool, keyManager: KeyManager, - relayHandler: RelayHandler + relayHandler: RelayHandler, + responseContext: ResponseContext ): Promise { const id = event.id; const pubkey = event.pubkey; @@ -97,7 +120,12 @@ export async function handleResourceTemplatesList( [TAG_PUBKEY, pubkey], ], }); - await relayHandler.publishEvent(response); + const publisher = getResponsePublisher( + relayHandler, + keyManager, + responseContext.encryptionManager + ); + await publisher.publishResponse(response, responseContext); } catch (err) { const errorResp = keyManager.signEvent({ ...keyManager.createEventTemplate(RESPONSE_KIND), @@ -113,7 +141,12 @@ export async function handleResourceTemplatesList( [TAG_PUBKEY, pubkey], ], }); - await relayHandler.publishEvent(errorResp); + const publisher = getResponsePublisher( + relayHandler, + keyManager, + responseContext.encryptionManager + ); + await publisher.publishResponse(errorResp, responseContext); } } @@ -125,7 +158,8 @@ export async function handleResourcesRead( mcpPool: MCPPool, keyManager: KeyManager, relayHandler: RelayHandler, - config: DvmcpBridgeConfig + config: DvmcpBridgeConfig, + responseContext: ResponseContext ): Promise { const { success, @@ -134,26 +168,36 @@ export async function handleResourcesRead( } = ReadResourceRequestSchema.safeParse(JSON.parse(event.content)); if (!success) { loggerBridge('resources read request error', error); - await relayHandler.publishEvent( - createProtocolErrorResponse( - event.id, - event.pubkey, - -32700, - JSON.stringify(error), - keyManager, - RESPONSE_KIND - ) + const errorResponse = createProtocolErrorResponse( + event.id, + event.pubkey, + -32700, + JSON.stringify(error), + keyManager, + RESPONSE_KIND ); + const publisher = getResponsePublisher( + relayHandler, + keyManager, + responseContext.encryptionManager + ); + await publisher.publishResponse(errorResponse, responseContext); return; } const id = event.id; const pubkey = event.pubkey; // Create payment processor + const publisher = getResponsePublisher( + relayHandler, + keyManager, + responseContext.encryptionManager + ); const paymentProcessor = new PaymentProcessor( config, keyManager, - relayHandler + relayHandler, + publisher ); try { @@ -162,24 +206,22 @@ export async function handleResourcesRead( } const resourceUri = readParams.params.uri; - // Check if resource requires payment const pricing = mcpPool.getResourcePricing(resourceUri); - // Process payment if required const paymentSuccessful = await paymentProcessor.processPaymentIfRequired( pricing, resourceUri, 'resource', id, - pubkey + pubkey, + responseContext.shouldEncrypt ); if (!paymentSuccessful) { // Payment failed, exit early return; } - const resourceResult: ReadResourceResult | undefined = await mcpPool.readResource(resourceUri); @@ -187,9 +229,6 @@ export async function handleResourcesRead( throw new Error(`Resource not found: ${resourceUri}`); } - // Send success notification - await paymentProcessor.sendSuccessNotification(id, pubkey); - const response = keyManager.signEvent({ ...keyManager.createEventTemplate(RESPONSE_KIND), content: JSON.stringify(resourceResult), @@ -198,13 +237,18 @@ export async function handleResourcesRead( [TAG_PUBKEY, pubkey], ], }); - await relayHandler.publishEvent(response); + await publisher.publishResponse(response, responseContext); } catch (err) { // Send error notification - await paymentProcessor.sendErrorNotification( - id, + await publisher.publishNotification( + err instanceof Error ? err.message : String(err), pubkey, - err instanceof Error ? err.message : String(err) + [ + [TAG_STATUS, 'error'], + [TAG_EVENT_ID, id], + [TAG_PUBKEY, pubkey], + ], + responseContext.shouldEncrypt ); // Send error response @@ -222,6 +266,6 @@ export async function handleResourcesRead( [TAG_PUBKEY, pubkey], ], }); - await relayHandler.publishEvent(errorResp); + await publisher.publishResponse(errorResp, responseContext); } } diff --git a/packages/dvmcp-bridge/src/handlers/tool-handlers.ts b/packages/dvmcp-bridge/src/handlers/tool-handlers.ts index 8bd38a8..a686519 100644 --- a/packages/dvmcp-bridge/src/handlers/tool-handlers.ts +++ b/packages/dvmcp-bridge/src/handlers/tool-handlers.ts @@ -1,5 +1,11 @@ import { loggerBridge } from '@dvmcp/commons/core'; -import { TAG_EVENT_ID, TAG_PUBKEY, RESPONSE_KIND } from '@dvmcp/commons/core'; +import { + TAG_EVENT_ID, + TAG_PUBKEY, + RESPONSE_KIND, + TAG_STATUS, + TAG_METHOD, +} from '@dvmcp/commons/core'; import type { MCPPool } from '../mcp-pool'; import type { DvmcpBridgeConfig } from '../config-schema.js'; import type { RelayHandler } from '@dvmcp/commons/nostr'; @@ -11,6 +17,8 @@ import { type CallToolResult, } from '@modelcontextprotocol/sdk/types.js'; import { PaymentProcessor } from './payment-processor'; +import type { ResponseContext } from '../dvm-bridge.js'; +import { getResponsePublisher } from '../utils/response-publisher'; import { createProtocolErrorResponse } from '../utils.js'; /** @@ -20,23 +28,28 @@ export async function handleToolsList( event: NostrEvent, mcpPool: MCPPool, keyManager: KeyManager, - relayHandler: RelayHandler + relayHandler: RelayHandler, + responseContext: ResponseContext ): Promise { const { success, error } = ListToolsRequestSchema.safeParse( JSON.parse(event.content) ); if (!success) { loggerBridge('tools list request error', error); - await relayHandler.publishEvent( - createProtocolErrorResponse( - event.id, - event.pubkey, - -32700, - JSON.stringify(error), - keyManager, - RESPONSE_KIND - ) + const errorResponse = createProtocolErrorResponse( + event.id, + event.pubkey, + -32700, + JSON.stringify(error), + keyManager, + RESPONSE_KIND ); + const publisher = getResponsePublisher( + relayHandler, + keyManager, + responseContext.encryptionManager + ); + await publisher.publishResponse(errorResponse, responseContext); return; } const id = event.id; @@ -53,7 +66,12 @@ export async function handleToolsList( ], }); loggerBridge('tools list response', response); - await relayHandler.publishEvent(response); + const publisher = getResponsePublisher( + relayHandler, + keyManager, + responseContext.encryptionManager + ); + await publisher.publishResponse(response, responseContext); } catch (err) { const errorResp = keyManager.signEvent({ ...keyManager.createEventTemplate(RESPONSE_KIND), @@ -69,7 +87,12 @@ export async function handleToolsList( [TAG_PUBKEY, pubkey], ], }); - await relayHandler.publishEvent(errorResp); + const publisher = getResponsePublisher( + relayHandler, + keyManager, + responseContext.encryptionManager + ); + await publisher.publishResponse(errorResp, responseContext); } } @@ -81,7 +104,8 @@ export async function handleToolsCall( mcpPool: MCPPool, keyManager: KeyManager, relayHandler: RelayHandler, - config: DvmcpBridgeConfig + config: DvmcpBridgeConfig, + responseContext: ResponseContext ): Promise { const { success, @@ -90,16 +114,20 @@ export async function handleToolsCall( } = CallToolRequestSchema.safeParse(JSON.parse(event.content)); if (!success) { loggerBridge('tools call request error', error); - await relayHandler.publishEvent( - createProtocolErrorResponse( - event.id, - event.pubkey, - -32700, - JSON.stringify(error), - keyManager, - RESPONSE_KIND - ) + const errorResponse = createProtocolErrorResponse( + event.id, + event.pubkey, + -32700, + JSON.stringify(error), + keyManager, + RESPONSE_KIND + ); + const publisher = getResponsePublisher( + relayHandler, + keyManager, + responseContext.encryptionManager ); + await publisher.publishResponse(errorResponse, responseContext); return; } const id = event.id; @@ -107,11 +135,18 @@ export async function handleToolsCall( // Processing notification will be sent by the payment processor + const publisher = getResponsePublisher( + relayHandler, + keyManager, + responseContext.encryptionManager + ); + // Create payment processor const paymentProcessor = new PaymentProcessor( config, keyManager, - relayHandler + relayHandler, + publisher ); try { @@ -124,7 +159,8 @@ export async function handleToolsCall( jobRequest.params.name, 'tool', id, - pubkey + pubkey, + responseContext.shouldEncrypt ); if (!paymentSuccessful) { @@ -138,9 +174,6 @@ export async function handleToolsCall( jobRequest.params.arguments! ); - // Send success notification - await paymentProcessor.sendSuccessNotification(id, pubkey); - // Send response const response = keyManager.signEvent({ ...keyManager.createEventTemplate(RESPONSE_KIND), @@ -150,13 +183,18 @@ export async function handleToolsCall( [TAG_PUBKEY, pubkey], ], }); - await relayHandler.publishEvent(response); + await publisher.publishResponse(response, responseContext); } catch (error) { // Send error notification - await paymentProcessor.sendErrorNotification( - id, + await publisher.publishNotification( + error instanceof Error ? error.message : String(error), pubkey, - error instanceof Error ? error.message : String(error) + [ + [TAG_STATUS, 'error'], + [TAG_EVENT_ID, id], + [TAG_PUBKEY, pubkey], + ], + responseContext.shouldEncrypt ); // Send error response @@ -175,6 +213,6 @@ export async function handleToolsCall( [TAG_PUBKEY, pubkey], ], }); - await relayHandler.publishEvent(errorResp); + await publisher.publishResponse(errorResp, responseContext); } } diff --git a/packages/dvmcp-bridge/src/mcp-client.ts b/packages/dvmcp-bridge/src/mcp-client.ts index 7ea9383..704bf7f 100644 --- a/packages/dvmcp-bridge/src/mcp-client.ts +++ b/packages/dvmcp-bridge/src/mcp-client.ts @@ -17,6 +17,7 @@ import { type ListResourceTemplatesResult, } from '@modelcontextprotocol/sdk/types.js'; +// FIXME TODO: If a configured server doesnt have tools the initialization fails, for example a server with just resources: 'Failed to start DVM Bridge: 1021' export class MCPClientHandler { private client: Client; private transport: StdioClientTransport; diff --git a/packages/dvmcp-bridge/src/mcp-pool.test.ts b/packages/dvmcp-bridge/src/mcp-pool.test.ts index 2128119..b94f218 100644 --- a/packages/dvmcp-bridge/src/mcp-pool.test.ts +++ b/packages/dvmcp-bridge/src/mcp-pool.test.ts @@ -1,5 +1,4 @@ import { expect, test, describe, beforeAll, afterAll } from 'bun:test'; -import { join } from 'path'; import type { CallToolResult, ReadResourceResult, diff --git a/packages/dvmcp-bridge/src/mcp-pool.ts b/packages/dvmcp-bridge/src/mcp-pool.ts index afed6e8..ef65f30 100644 --- a/packages/dvmcp-bridge/src/mcp-pool.ts +++ b/packages/dvmcp-bridge/src/mcp-pool.ts @@ -166,12 +166,15 @@ export class MCPPool { async listResourceTemplates(): Promise { const allResourcesTemplates: ResourceTemplate[] = []; + for (const [clientName, client] of this.clients.entries()) { const caps = client.getServerCapabilities(); + if (caps && caps.resources) { try { const resObj: ListResourceTemplatesResult = await client.listResourceTemplates(); + if (resObj && resObj.resourceTemplates) { for (const resource of resObj.resourceTemplates) { if (typeof resource.uriTemplate === 'string') { @@ -182,12 +185,13 @@ export class MCPPool { } } catch (err) { loggerBridge( - `[listResources] Failed for client '${clientName}':`, + `[listResourceTemplates] Failed for client '${clientName}':`, err ); } } } + return { resourceTemplates: allResourcesTemplates }; } @@ -214,51 +218,37 @@ export class MCPPool { return { prompts: allPrompts }; } - private async ensureHandler( - registry: Map, - key: K, - refreshMethod: () => Promise - ): Promise { - let handler = registry.get(key); - if (handler) return handler; - - if (registry.size === 0 || !handler) { - await refreshMethod(); - return registry.get(key); - } - } - async readResource( resourceUri: string ): Promise { const handler = await this.ensureHandler( this.resourceRegistry, resourceUri, - () => this.listResources() + async () => { + await this.listResources(); + await this.listResourceTemplates(); + } ); if (!handler) { - loggerBridge( - `[readResource] Resource handler not found for: ${resourceUri}` - ); + loggerBridge(`[readResource] No handler found for: ${resourceUri}`); return undefined; } try { - const result: ReadResourceResult | undefined = - await handler.readResource(resourceUri); + // Normalize empty/root paths to URL-encoded format for MCP server compatibility + const normalizedUri = resourceUri.replace(/:\/{2,3}$/, '://%2F'); + + const result = await handler.readResource(normalizedUri); if (!result) { - loggerBridge( - `[readResource] Empty result for resource: ${resourceUri}` - ); - return undefined; + loggerBridge(`[readResource] Empty result for: ${resourceUri}`); } return result; } catch (err: any) { loggerBridge( - `[readResource] Failed to read '${resourceUri}' from backend:`, - err && (err.message || err) + `[readResource] Failed to read '${resourceUri}':`, + err?.message || err ); return undefined; } @@ -482,4 +472,60 @@ export class MCPPool { Array.from(this.clients.values()).map((client) => client.disconnect()) ); } + + /** + * Checks if a URI matches a URI template pattern + */ + private matchesUriTemplate(uri: string, template: string): boolean { + if (uri === template) return true; + if (!template.includes('{')) return false; + + const pattern = template + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + .replace(/\\{[^}]+\\}/g, '(.*)'); + + return new RegExp(`^${pattern}$`).test(uri); + } + + /** + * Finds a handler for a resource URI (exact match or template match) + */ + private findResourceHandler(uri: string): MCPClientHandler | undefined { + // Try exact match first + const exactMatch = this.resourceRegistry.get(uri); + if (exactMatch) return exactMatch; + + // Try template matching + for (const [template, handler] of this.resourceRegistry.entries()) { + if (this.matchesUriTemplate(uri, template)) { + return handler; + } + } + + return undefined; + } + + /** + * Ensures a handler exists, refreshing registry if needed + */ + private async ensureHandler( + registry: Map, + key: K, + refreshMethod: () => Promise + ): Promise { + // Check if handler exists + const handler = + registry === this.resourceRegistry + ? this.findResourceHandler(key as string) + : registry.get(key); + + if (handler) return handler; + + // Refresh and try again + await refreshMethod(); + + return registry === this.resourceRegistry + ? this.findResourceHandler(key as string) + : registry.get(key); + } } diff --git a/packages/dvmcp-bridge/src/utils/response-publisher.ts b/packages/dvmcp-bridge/src/utils/response-publisher.ts new file mode 100644 index 0000000..dcc8ae7 --- /dev/null +++ b/packages/dvmcp-bridge/src/utils/response-publisher.ts @@ -0,0 +1,83 @@ +import type { NostrEvent } from 'nostr-tools'; +import type { RelayHandler } from '@dvmcp/commons/nostr'; +import type { KeyManager } from '@dvmcp/commons/nostr'; +import { EventPublisher } from '@dvmcp/commons/nostr'; +import type { EncryptionManager } from '@dvmcp/commons/encryption'; +import type { ResponseContext } from '../dvm-bridge'; + +/** + * Centralized response publishing utility for bridge handlers + */ +export class ResponsePublisher { + private eventPublisher: EventPublisher; + + constructor( + relayHandler: RelayHandler, + keyManager: KeyManager, + encryptionManager?: EncryptionManager + ) { + this.eventPublisher = new EventPublisher( + relayHandler, + keyManager, + encryptionManager + ); + } + + /** + * Publish a response with encryption support + */ + async publishResponse( + response: NostrEvent, + responseContext: ResponseContext + ): Promise { + await this.eventPublisher.publishResponse( + response, + responseContext.recipientPubkey, + responseContext.shouldEncrypt + ); + } + + /** + * Publish a notification with encryption support + */ + async publishNotification( + content: string, + recipientPubkey: string, + tags: string[][], + shouldEncrypt: boolean = false + ): Promise { + await this.eventPublisher.publishNotification( + content, + recipientPubkey, + tags, + shouldEncrypt + ); + } +} + +let responsePublisher: ResponsePublisher | null = null; + +/** + * Get or create a singleton ResponsePublisher instance + */ +export function getResponsePublisher( + relayHandler: RelayHandler, + keyManager: KeyManager, + encryptionManager?: EncryptionManager +): ResponsePublisher { + if (!responsePublisher) { + responsePublisher = new ResponsePublisher( + relayHandler, + keyManager, + encryptionManager + ); + } + return responsePublisher; +} + +/** + * Reset the singleton instance (useful for testing) + */ +export function resetResponsePublisher(): void { + responsePublisher = null; +} diff --git a/packages/dvmcp-commons/CHANGELOG.md b/packages/dvmcp-commons/CHANGELOG.md index a7c97f7..9ea1a16 100644 --- a/packages/dvmcp-commons/CHANGELOG.md +++ b/packages/dvmcp-commons/CHANGELOG.md @@ -1,5 +1,11 @@ # @dvmcp/commons +## 0.2.6 + +### Patch Changes + +- feat: encryption features + ## 0.2.5 ### Patch Changes diff --git a/packages/dvmcp-commons/package.json b/packages/dvmcp-commons/package.json index 673ad72..abb6812 100644 --- a/packages/dvmcp-commons/package.json +++ b/packages/dvmcp-commons/package.json @@ -1,6 +1,6 @@ { "name": "@dvmcp/commons", - "version": "0.2.5", + "version": "0.2.6", "description": "Shared utilities for DVMCP packages", "type": "module", "main": "./src/index.ts", @@ -17,6 +17,9 @@ "./nostr": { "import": "./src/nostr/index.ts" }, + "./encryption": { + "import": "./src/encryption/index.ts" + }, "./nostr/mock-relay": { "import": "./src/nostr/mock-relay.ts" }, @@ -31,7 +34,7 @@ "!**/*.test.js" ], "scripts": { - "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", + "fmt": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", "typecheck": "tsc --noEmit", "test": "bun test" }, diff --git a/packages/dvmcp-commons/src/config/utils.ts b/packages/dvmcp-commons/src/config/utils.ts index abfc7a0..c2b324a 100644 --- a/packages/dvmcp-commons/src/config/utils.ts +++ b/packages/dvmcp-commons/src/config/utils.ts @@ -6,30 +6,57 @@ import type { ValidationError } from './types'; * @returns Object with default values */ export function getDefaults(schema: any): any { - if (!schema) return undefined; + // Base case: if schema is null, undefined, or not an object, it has no defaults. + if (!schema || typeof schema !== 'object') { + return undefined; + } - if (schema.type === 'object') { - const result: any = {}; - if (schema.fields) { + // Case 1: schema is a ConfigFieldMeta object (it has a 'type' property). + if ('type' in schema) { + // If the field itself has an explicit 'default' value, that takes precedence. + if ('default' in schema) { + return schema.default; + } + + // If it's an 'object' type with 'fields', recurse to build defaults for its children. + if (schema.type === 'object' && schema.fields) { + const subDefaults: Record = {}; + let hasSubContent = false; for (const key in schema.fields) { - const field = schema.fields[key]; - if ('default' in field) { - result[key] = field.default; - } else { - const value = getDefaults(field); - if (value !== undefined) { - result[key] = value; + if (Object.prototype.hasOwnProperty.call(schema.fields, key)) { + const fieldValue = getDefaults(schema.fields[key]); // Recursive call + if (fieldValue !== undefined) { + subDefaults[key] = fieldValue; + hasSubContent = true; } } } + return hasSubContent ? subDefaults : undefined; } - return result; // Always return the result object even if empty + + // If it's an 'array' type (and no explicit 'default' was found above), default to an empty array. + if (schema.type === 'array') { + return []; + } + + // Other types (string, number, boolean) without an explicit 'default' have no default value. + return undefined; } - if (schema.type === 'array') { - return []; + // Case 2: schema is the root ConfigSchema (a plain object of ConfigFieldMeta, no 'type' property). + else { + const rootDefaults: Record = {}; + let hasRootContent = false; + for (const key in schema) { + if (Object.prototype.hasOwnProperty.call(schema, key)) { + const fieldValue = getDefaults(schema[key]); // Recursive call for each top-level field + if (fieldValue !== undefined) { + rootDefaults[key] = fieldValue; + hasRootContent = true; + } + } + } + return hasRootContent ? rootDefaults : undefined; } - if ('default' in schema) return schema.default; - return undefined; } /** diff --git a/packages/dvmcp-commons/src/core/constants.ts b/packages/dvmcp-commons/src/core/constants.ts index 274e051..068d2b2 100644 --- a/packages/dvmcp-commons/src/core/constants.ts +++ b/packages/dvmcp-commons/src/core/constants.ts @@ -8,6 +8,9 @@ export const PROMPTS_LIST_KIND = 31319; // Addressable: Prompts List export const REQUEST_KIND = 25910; // Ephemeral: Client Requests export const RESPONSE_KIND = 26910; // Ephemeral: Server Responses export const NOTIFICATION_KIND = 21316; // Ephemeral: Feedback/Notifications +export const GIFT_WRAP_KIND = 1059; // Gift Wrap (NIP-59): Encrypted messages +export const SEALED_DIRECT_MESSAGE_KIND = 13; +export const PRIVATE_DIRECT_MESSAGE_KIND = 14; // Common Tags for DVMCP Events export const TAG_UNIQUE_IDENTIFIER = 'd'; // Unique identifier (addressable events) or Server ID (init response) @@ -20,11 +23,18 @@ export const TAG_KIND = 'k'; // Accepted request kind (server announcement) export const TAG_STATUS = 'status'; // Nostr-specific notification status (e.g., 'payment-required') export const TAG_AMOUNT = 'amount'; // Nostr-specific notification amount/invoice export const TAG_INVOICE = 'invoice'; // Nostr-specific notification invoice +export const TAG_SUPPORT_ENCRYPTION = 'support_encryption'; export const MCPMETHODS = { + initialize: 'initialize', toolsList: 'tools/list', toolsCall: 'tools/call', resourcesList: 'resources/list', + resourcesTemplatesList: 'resources/templates/list', resourcesRead: 'resources/read', promptsList: 'prompts/list', - promptsCall: 'prompts/call', + promptsGet: 'prompts/get', + completionComplete: 'completion/complete', + ping: 'ping', + notificationsCancel: 'notifications/cancel', + notificationsProgress: 'notifications/progress', } as const; diff --git a/packages/dvmcp-commons/src/encryption/README.md b/packages/dvmcp-commons/src/encryption/README.md new file mode 100644 index 0000000..3a7f55d --- /dev/null +++ b/packages/dvmcp-commons/src/encryption/README.md @@ -0,0 +1,51 @@ +# DVMCP Encryption Configuration + +This document explains the simplified encryption configuration system for DVMCP packages. + +## Overview + +The DVMCP encryption system uses a clean `EncryptionMode` enum with three simple modes that provide clear, predictable behavior. The system follows a **message format mirroring** approach in optional mode, where responses match the encryption format of incoming messages. + +## Encryption Modes + +### `EncryptionMode.DISABLED` + +- **Behavior**: Encryption is completely disabled +- **Use case**: Legacy systems or when encryption is not needed +- **Response behavior**: Always responds with unencrypted messages + +### `EncryptionMode.OPTIONAL` (Default) + +- **Behavior**: **Message format mirroring** - responds in the same format as received +- **Use case**: Most deployments - maximum compatibility and flexibility +- **Response behavior**: + - Encrypted request → Encrypted response + - Unencrypted request → Unencrypted response + +### `EncryptionMode.REQUIRED` + +- **Behavior**: Only encrypted communication is accepted +- **Use case**: High-security deployments +- **Response behavior**: Always responds with encrypted messages, rejects unencrypted requests + +### Default Behavior + +When no encryption configuration is provided, the system defaults to `EncryptionMode.OPTIONAL`, which provides the best balance of security and compatibility. + +### YAML Configuration + +For configurations using YAML files (e.g., `config.dvmcp.yml`), you can specify the encryption mode as follows: + +```yaml +# Default - message format mirroring +encryption: + mode: "optional" + +# High security - encryption required +encryption: + mode: "required" + +# Legacy compatibility - no encryption +encryption: + mode: "disabled" +``` diff --git a/packages/dvmcp-commons/src/encryption/encryption-manager.test.ts b/packages/dvmcp-commons/src/encryption/encryption-manager.test.ts new file mode 100644 index 0000000..4a838b4 --- /dev/null +++ b/packages/dvmcp-commons/src/encryption/encryption-manager.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'bun:test'; +import { EncryptionManager } from './encryption-manager'; +import { EncryptionMode } from './types'; +import type { EncryptionConfig } from './types'; + +describe('EncryptionManager', () => { + describe('Mode Configuration', () => { + it('should use explicit mode when provided', () => { + const config: EncryptionConfig = { mode: EncryptionMode.REQUIRED }; + const manager = new EncryptionManager(config); + + expect(manager.getEncryptionMode()).toBe(EncryptionMode.REQUIRED); + expect(manager.isEncryptionRequired()).toBe(true); + expect(manager.shouldAttemptEncryption()).toBe(true); + }); + + it('should default to OPTIONAL when no config provided', () => { + const manager = new EncryptionManager({}); + + expect(manager.getEncryptionMode()).toBe(EncryptionMode.OPTIONAL); + expect(manager.isEncryptionEnabled()).toBe(true); + expect(manager.isEncryptionRequired()).toBe(false); + expect(manager.shouldAttemptEncryption()).toBe(false); + }); + }); + + describe('Encryption Modes', () => { + it('should handle DISABLED mode correctly', () => { + const manager = new EncryptionManager({ mode: EncryptionMode.DISABLED }); + + expect(manager.isEncryptionEnabled()).toBe(false); + expect(manager.isEncryptionRequired()).toBe(false); + expect(manager.shouldAttemptEncryption()).toBe(false); + + // Should never encrypt responses regardless of incoming format + expect(manager.shouldEncryptResponse(true)).toBe(false); + expect(manager.shouldEncryptResponse(false)).toBe(false); + }); + + it('should handle OPTIONAL mode correctly', () => { + const manager = new EncryptionManager({ mode: EncryptionMode.OPTIONAL }); + + expect(manager.isEncryptionEnabled()).toBe(true); + expect(manager.isEncryptionRequired()).toBe(false); + expect(manager.shouldAttemptEncryption()).toBe(false); + + // Should mirror incoming message format + expect(manager.shouldEncryptResponse(true)).toBe(true); + expect(manager.shouldEncryptResponse(false)).toBe(false); + }); + + it('should handle REQUIRED mode correctly', () => { + const manager = new EncryptionManager({ mode: EncryptionMode.REQUIRED }); + + expect(manager.isEncryptionEnabled()).toBe(true); + expect(manager.isEncryptionRequired()).toBe(true); + expect(manager.shouldAttemptEncryption()).toBe(true); + + // Should always encrypt responses regardless of incoming format + expect(manager.shouldEncryptResponse(true)).toBe(true); + expect(manager.shouldEncryptResponse(false)).toBe(true); + }); + }); + + describe('Message Format Mirroring', () => { + it('should mirror encrypted incoming messages in OPTIONAL mode', () => { + const manager = new EncryptionManager({ mode: EncryptionMode.OPTIONAL }); + + // If we receive an encrypted message, respond with encrypted + expect(manager.shouldEncryptResponse(true)).toBe(true); + }); + + it('should mirror unencrypted incoming messages in OPTIONAL mode', () => { + const manager = new EncryptionManager({ mode: EncryptionMode.OPTIONAL }); + + // If we receive an unencrypted message, respond with unencrypted + expect(manager.shouldEncryptResponse(false)).toBe(false); + }); + + it('should always encrypt in REQUIRED mode regardless of incoming format', () => { + const manager = new EncryptionManager({ mode: EncryptionMode.REQUIRED }); + + expect(manager.shouldEncryptResponse(true)).toBe(true); + expect(manager.shouldEncryptResponse(false)).toBe(true); + }); + + it('should never encrypt in DISABLED mode regardless of incoming format', () => { + const manager = new EncryptionManager({ mode: EncryptionMode.DISABLED }); + + expect(manager.shouldEncryptResponse(true)).toBe(false); + expect(manager.shouldEncryptResponse(false)).toBe(false); + }); + }); +}); diff --git a/packages/dvmcp-commons/src/encryption/encryption-manager.ts b/packages/dvmcp-commons/src/encryption/encryption-manager.ts new file mode 100644 index 0000000..105a8c8 --- /dev/null +++ b/packages/dvmcp-commons/src/encryption/encryption-manager.ts @@ -0,0 +1,281 @@ +import type { + EventTemplate, + Event as NostrEvent, + UnsignedEvent, +} from 'nostr-tools'; +import { nip44 } from 'nostr-tools'; +import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'; +import { finalizeEvent } from 'nostr-tools/pure'; +import { hexToBytes } from '@noble/hashes/utils'; +import type { EncryptionConfig } from './types'; +import { EncryptionMode } from './types'; +import { + GIFT_WRAP_KIND, + PRIVATE_DIRECT_MESSAGE_KIND, + SEALED_DIRECT_MESSAGE_KIND, + TAG_PUBKEY, +} from '../core/constants'; + +export interface DecryptedMessage { + content: any; + sender: string; + event: NostrEvent; +} + +/** + * Centralized encryption manager that handles all NIP-17/NIP-59 operations + */ +export class EncryptionManager { + private mode: EncryptionMode; + + constructor(config: EncryptionConfig) { + this.mode = config.mode ?? EncryptionMode.OPTIONAL; + } + + public getEncryptionMode(): EncryptionMode { + return this.mode; + } + + public isEncryptionEnabled(): boolean { + return this.mode !== EncryptionMode.DISABLED; + } + + public isEncryptionRequired(): boolean { + return this.mode === EncryptionMode.REQUIRED; + } + + /** + * Determines if we should encrypt outgoing messages based on incoming message format + * @param incomingWasEncrypted - Whether the incoming message was encrypted + */ + public shouldEncryptResponse(incomingWasEncrypted: boolean): boolean { + switch (this.mode) { + case EncryptionMode.DISABLED: + return false; + case EncryptionMode.REQUIRED: + return true; + case EncryptionMode.OPTIONAL: + return incomingWasEncrypted; // Mirror the incoming format + default: + return false; + } + } + + /** + * Determines if we should attempt encryption for outgoing requests (when no incoming context) + */ + public shouldAttemptEncryption(): boolean { + return this.mode === EncryptionMode.REQUIRED; + } + + /** + * Encrypt a message using NIP-17/NIP-59 gift wrap scheme + * @param senderPrivateKey - Sender's private key + * @param recipientPublicKey - Recipient's public key + * @param eventTemplate - Event to encrypt + * @returns Gift wrapped event or null if encryption fails + */ + public async encryptMessage( + senderPrivateKey: string, + recipientPublicKey: string, + eventTemplate: EventTemplate + ): Promise { + if (this.mode === EncryptionMode.DISABLED) { + return null; + } + + try { + // Step 1: Create the rumor (original message without signature) + const rumor = { + ...eventTemplate, + pubkey: getPublicKey(hexToBytes(senderPrivateKey)), + }; + + // Step 2: Create seal (kind 13) - encrypt the rumor + const sealPrivateKey = generateSecretKey(); + const sealPublicKey = getPublicKey(sealPrivateKey); + + const encryptedRumor = nip44.v2.encrypt( + JSON.stringify(rumor), + nip44.v2.utils.getConversationKey(sealPrivateKey, recipientPublicKey) + ); + + const seal: UnsignedEvent = { + kind: SEALED_DIRECT_MESSAGE_KIND, + content: encryptedRumor, + tags: [], + created_at: Math.floor(Date.now() / 1000), + pubkey: sealPublicKey, + }; + + const signedSeal = finalizeEvent(seal, sealPrivateKey); + + // Step 3: Create gift wrap (kind 1059) - encrypt the seal + const giftWrapPrivateKey = generateSecretKey(); + const giftWrapPublicKey = getPublicKey(giftWrapPrivateKey); + + const encryptedSeal = nip44.v2.encrypt( + JSON.stringify(signedSeal), + nip44.v2.utils.getConversationKey( + giftWrapPrivateKey, + recipientPublicKey + ) + ); + + const giftWrap: UnsignedEvent = { + kind: GIFT_WRAP_KIND, + content: encryptedSeal, + tags: [[TAG_PUBKEY, recipientPublicKey]], + created_at: Math.floor(Date.now() / 1000), + pubkey: giftWrapPublicKey, + }; + + return finalizeEvent(giftWrap, giftWrapPrivateKey); + } catch (error) { + console.error('Encryption failed:', error); + return null; + } + } + + /** + * Decrypt a gift wrapped message and extract sender information + * @param event - Gift wrap event to decrypt + * @param recipientPrivateKey - Recipient's private key + * @returns Decrypted message with sender info or null if decryption fails + */ + public async decryptMessage( + event: NostrEvent, + recipientPrivateKey: string + ): Promise { + if (!this.isEncryptionEnabled() || event.kind !== GIFT_WRAP_KIND) { + return null; + } + + try { + const recipientPublicKey = getPublicKey(hexToBytes(recipientPrivateKey)); + + // Check if this gift wrap is for us + const isForUs = event.tags.some( + (tag) => tag[0] === TAG_PUBKEY && tag[1] === recipientPublicKey + ); + + if (!isForUs) { + return null; + } + + // Step 1: Decrypt the gift wrap to get the seal + const conversationKey = nip44.v2.utils.getConversationKey( + hexToBytes(recipientPrivateKey), + event.pubkey + ); + + const decryptedSealJson = nip44.v2.decrypt( + event.content, + conversationKey + ); + const seal = JSON.parse(decryptedSealJson) as NostrEvent; + + if (seal.kind !== SEALED_DIRECT_MESSAGE_KIND) { + console.error('Invalid seal kind:', seal.kind); + return null; + } + + // Step 2: Decrypt the seal to get the rumor + const sealConversationKey = nip44.v2.utils.getConversationKey( + hexToBytes(recipientPrivateKey), + seal.pubkey + ); + + const decryptedRumorJson = nip44.v2.decrypt( + seal.content, + sealConversationKey + ); + const rumor = JSON.parse(decryptedRumorJson); + + // Step 3: Parse the rumor content + let actualContent; + if (rumor.kind === PRIVATE_DIRECT_MESSAGE_KIND) { + // If it's a kind 14, the content might be JSON-encoded DVMCP message + try { + actualContent = JSON.parse(rumor.content); + } catch { + actualContent = rumor.content; + } + } else { + actualContent = rumor; + } + + return { + content: actualContent, + sender: rumor.pubkey, + event: { + ...rumor, + id: event.id, // Keep original gift wrap ID for tracking + sig: event.sig, + } as NostrEvent, + }; + } catch (error) { + console.error('Decryption failed:', error); + return null; + } + } + + /** + * Decrypt an event and extract sender information (unified method) + * Handles both gift wrapped and direct encrypted events + */ + public async decryptEventAndExtractSender( + event: NostrEvent, + recipientPrivateKey: string + ): Promise<{ decryptedEvent: NostrEvent; sender: string } | null> { + const decrypted = await this.decryptMessage(event, recipientPrivateKey); + + if (!decrypted) { + return null; + } + + return { + decryptedEvent: decrypted.event, + sender: decrypted.sender, + }; + } + + /** + * Check if an event is encrypted for a specific recipient + */ + public isEventForRecipient( + event: NostrEvent, + recipientPublicKey: string + ): boolean { + if (event.kind !== GIFT_WRAP_KIND) { + return false; + } + + return event.tags.some( + (tag) => tag[0] === TAG_PUBKEY && tag[1] === recipientPublicKey + ); + } + + /** + * Encrypt a notification event + */ + public async encryptNotification( + senderPrivateKey: string, + recipientPublicKey: string, + notificationContent: string, + tags: string[][] = [] + ): Promise { + const eventTemplate: EventTemplate = { + kind: 21316, // NOTIFICATION_KIND + content: notificationContent, + tags, + created_at: Math.floor(Date.now() / 1000), + }; + + return this.encryptMessage( + senderPrivateKey, + recipientPublicKey, + eventTemplate + ); + } +} diff --git a/packages/dvmcp-commons/src/encryption/index.ts b/packages/dvmcp-commons/src/encryption/index.ts new file mode 100644 index 0000000..bc06d90 --- /dev/null +++ b/packages/dvmcp-commons/src/encryption/index.ts @@ -0,0 +1,2 @@ +export * from './encryption-manager'; +export * from './types'; diff --git a/packages/dvmcp-commons/src/encryption/types.ts b/packages/dvmcp-commons/src/encryption/types.ts new file mode 100644 index 0000000..64addec --- /dev/null +++ b/packages/dvmcp-commons/src/encryption/types.ts @@ -0,0 +1,22 @@ +/** + * Encryption mode enumeration for clear configuration semantics + */ +export enum EncryptionMode { + /** Encryption is disabled - only unencrypted communication */ + DISABLED = 'disabled', + /** Encryption is optional - mirrors the format of received messages (encrypted->encrypted, unencrypted->unencrypted) */ + OPTIONAL = 'optional', + /** Encryption is required - reject unencrypted communication */ + REQUIRED = 'required', +} + +/** + * Encryption configuration interface for NIP-17/NIP-59 support + */ +export interface EncryptionConfig { + /** + * Encryption mode - determines how encryption is handled + * @default EncryptionMode.OPTIONAL + */ + mode?: EncryptionMode; +} diff --git a/packages/dvmcp-commons/src/index.ts b/packages/dvmcp-commons/src/index.ts index e05f13b..2c548c8 100644 --- a/packages/dvmcp-commons/src/index.ts +++ b/packages/dvmcp-commons/src/index.ts @@ -10,3 +10,4 @@ export * as core from './core'; export * as config from './config'; export * as nostr from './nostr'; +export * as encryption from './encryption'; diff --git a/packages/dvmcp-commons/src/nostr/event-publisher.ts b/packages/dvmcp-commons/src/nostr/event-publisher.ts new file mode 100644 index 0000000..cbcc2f4 --- /dev/null +++ b/packages/dvmcp-commons/src/nostr/event-publisher.ts @@ -0,0 +1,108 @@ +import type { EventTemplate, NostrEvent } from 'nostr-tools'; +import type { RelayHandler } from './relay-handler'; +import type { KeyManager } from './key-manager'; +import type { EncryptionManager } from '../encryption/encryption-manager'; + +export interface PublishOptions { + encrypt?: boolean; + recipientPublicKey?: string; +} + +/** + * Centralized event publishing utility with encryption support + */ +export class EventPublisher { + constructor( + private relayHandler: RelayHandler, + private keyManager: KeyManager, + private encryptionManager?: EncryptionManager + ) {} + + /** + * Publish an event with optional encryption + */ + async publishEvent( + event: NostrEvent, + options: PublishOptions = {} + ): Promise { + let eventToPublish = event; + + if ( + options.encrypt && + options.recipientPublicKey && + this.encryptionManager?.isEncryptionEnabled() + ) { + try { + const eventTemplate: EventTemplate = { + kind: event.kind, + content: event.content, + tags: event.tags, + created_at: event.created_at, + }; + + const encryptedEvent = await this.encryptionManager.encryptMessage( + this.keyManager.getPrivateKey(), + options.recipientPublicKey, + eventTemplate + ); + + if (encryptedEvent) { + eventToPublish = encryptedEvent; + } + } catch (error) { + console.warn('Failed to encrypt event, publishing unencrypted:', error); + } + } + + await this.relayHandler.publishEvent(eventToPublish); + } + + /** + * Publish a response event (convenience method) + */ + async publishResponse( + responseEvent: NostrEvent, + recipientPublicKey: string, + shouldEncrypt: boolean = false + ): Promise { + await this.publishEvent(responseEvent, { + encrypt: shouldEncrypt, + recipientPublicKey, + }); + } + + /** + * Publish a notification event with optional encryption + */ + async publishNotification( + content: string, + recipientPublicKey: string, + tags: string[][] = [], + shouldEncrypt: boolean = false + ): Promise { + if (shouldEncrypt && this.encryptionManager?.isEncryptionEnabled()) { + // Use the specialized notification encryption method + const encryptedNotification = + await this.encryptionManager.encryptNotification( + this.keyManager.getPrivateKey(), + recipientPublicKey, + content, + tags + ); + + if (encryptedNotification) { + await this.relayHandler.publishEvent(encryptedNotification); + return; + } + } + + // Publish unencrypted notification + const notificationEvent = this.keyManager.signEvent({ + ...this.keyManager.createEventTemplate(21316), // NOTIFICATION_KIND + content, + tags, + }); + + await this.relayHandler.publishEvent(notificationEvent); + } +} diff --git a/packages/dvmcp-commons/src/nostr/index.ts b/packages/dvmcp-commons/src/nostr/index.ts index 275d0e9..1c0fa00 100644 --- a/packages/dvmcp-commons/src/nostr/index.ts +++ b/packages/dvmcp-commons/src/nostr/index.ts @@ -10,3 +10,6 @@ export * from './key-manager'; // Export relay handler export * from './relay-handler'; + +// Export event publisher +export * from './event-publisher'; diff --git a/packages/dvmcp-commons/src/nostr/key-manager.ts b/packages/dvmcp-commons/src/nostr/key-manager.ts index 525d200..d603f0c 100644 --- a/packages/dvmcp-commons/src/nostr/key-manager.ts +++ b/packages/dvmcp-commons/src/nostr/key-manager.ts @@ -26,6 +26,7 @@ export type KeyManager = { signEvent(event: UnsignedEvent): Event; createEventTemplate(kind: number): UnsignedEvent; getPublicKey(): string; + getPrivateKey(): string; }; /** @@ -57,6 +58,10 @@ export const createKeyManager = (privateKeyHex: string): KeyManager => { getPublicKey(): string { return this.pubkey; } + + getPrivateKey(): string { + return privateKeyHex; + } } return new Manager(); diff --git a/packages/dvmcp-commons/src/nostr/mock-relay.ts b/packages/dvmcp-commons/src/nostr/mock-relay.ts index 815c797..b5c6903 100644 --- a/packages/dvmcp-commons/src/nostr/mock-relay.ts +++ b/packages/dvmcp-commons/src/nostr/mock-relay.ts @@ -24,6 +24,7 @@ import { TAG_CAPABILITY, TAG_UNIQUE_IDENTIFIER, TAG_KIND, + TAG_SERVER_IDENTIFIER, } from '../core/constants'; // Default port for the relay server @@ -81,7 +82,7 @@ const mockToolsList = { created_at: Math.floor(Date.now() / 1000), tags: [ [TAG_UNIQUE_IDENTIFIER, 'tools-list-id'], - ['s', 'test-server-id'], + [TAG_SERVER_IDENTIFIER, 'test-server-id'], [TAG_CAPABILITY, 'test-echo'], ], } as UnsignedEvent; @@ -102,7 +103,7 @@ const mockResourcesList = { created_at: Math.floor(Date.now() / 1000), tags: [ [TAG_UNIQUE_IDENTIFIER, 'resources-list-id'], - ['s', 'test-server-id'], + [TAG_SERVER_IDENTIFIER, 'test-server-id'], [TAG_CAPABILITY, 'test-resource'], ], } as UnsignedEvent; @@ -129,7 +130,7 @@ const mockPromptsList = { created_at: Math.floor(Date.now() / 1000), tags: [ [TAG_UNIQUE_IDENTIFIER, 'prompts-list-id'], - ['s', 'test-server-id'], + [TAG_SERVER_IDENTIFIER, 'test-server-id'], [TAG_CAPABILITY, 'test-prompt'], ], } as UnsignedEvent; diff --git a/packages/dvmcp-discovery/CHANGELOG.md b/packages/dvmcp-discovery/CHANGELOG.md index aa0f6a8..42b320c 100644 --- a/packages/dvmcp-discovery/CHANGELOG.md +++ b/packages/dvmcp-discovery/CHANGELOG.md @@ -1,5 +1,14 @@ # @dvmcp/discovery +## 0.2.4 + +### Patch Changes + +- feat: encryption features +- f79d9a4: implement ping capability +- Updated dependencies + - @dvmcp/commons@0.2.6 + ## 0.2.3 ### Patch Changes diff --git a/packages/dvmcp-discovery/README.md b/packages/dvmcp-discovery/README.md index a737a15..59d0a06 100644 --- a/packages/dvmcp-discovery/README.md +++ b/packages/dvmcp-discovery/README.md @@ -8,6 +8,7 @@ A MCP server implementation that aggregates tools from DVMs across the Nostr net - Provides a unified interface to access tools from multiple DVMs - Tool execution handling and status tracking - Automatic payment for paid tools using Nostr Wallet Connect (NWC) +- Encrypted communication support using NIP-17/NIP-59 - Configurable DVM whitelist - Direct connection to specific providers or servers - Interactive mode with built-in tools @@ -74,25 +75,7 @@ npx dvmcp-discovery --verbose You can also configure the service using environment variables: -```bash -# Set Nostr configuration -export DVMCP_NOSTR_PRIVATE_KEY= -export DVMCP_NOSTR_RELAY_URLS=wss://relay1.com,wss://relay2.com - -# Set MCP service details -export DVMCP_MCP_NAME="My DVMCP Service" -export DVMCP_MCP_VERSION="1.2.0" -export DVMCP_MCP_ABOUT="Custom description" - -# Set NWC configuration -export DVMCP_NWC_CONNECTION_STRING="nostr+walletconnect:..." - -# Limit the number of DVMs to discover -export DVMCP_DISCOVERY_LIMIT=5 - -# Run the service -npx dvmcp-discovery -``` +// TODO: Add environment variables ### NWC Payment Configuration @@ -112,6 +95,16 @@ featureFlags: You can obtain an NWC connection string from compatible wallets like Alby or Coinos. When a tool requires payment, the discovery server will automatically pay the invoice using the configured NWC wallet. +## Encryption Support + +The DVMCP Discovery enables secure communication through a flexible encryption system. It offers three distinct modes: + +- **DISABLED**: No encryption is used for communication. +- **OPTIONAL**: (Default) Encrypted and unencrypted messages are accepted, and responses mirror the format of the incoming message. This provides maximum compatibility. +- **REQUIRED**: Only encrypted communication is accepted and generated, ensuring high security. + +For a comprehensive overview of the available encryption modes and their operational behavior, including configuration examples, please refer to the [DVMCP Encryption Configuration Guide](../dvmcp-commons/src/encryption/README.md). + ## Usage **Prerequisite:** Ensure you have [Bun](https://bun.sh/) installed. diff --git a/packages/dvmcp-discovery/config.example.yml b/packages/dvmcp-discovery/config.example.yml index 392e0d0..1782d19 100644 --- a/packages/dvmcp-discovery/config.example.yml +++ b/packages/dvmcp-discovery/config.example.yml @@ -14,6 +14,14 @@ mcp: # Server description about: "DVMCP Discovery Server for aggregating MCP tools from DVMs" +# [Optional] Encryption Configuration (NIP-17/NIP-59 support) +encryption: + # Encryption mode: 'disabled', 'optional' (default), or 'required' + # - disabled: No encryption support + # - optional: Message format mirroring (encrypted request -> encrypted response) + # - required: Only accepts encrypted communication + mode: "optional" + # NWC (Nostr Wallet Connect) configuration for payments #nwc: # connectionString: "nostr+walletconnect:your_wallet_pubkey_here?relay=wss%3A%2F%2Frelay.example.com&secret=your_secret_here" diff --git a/packages/dvmcp-discovery/package.json b/packages/dvmcp-discovery/package.json index ebc1dd7..725f513 100644 --- a/packages/dvmcp-discovery/package.json +++ b/packages/dvmcp-discovery/package.json @@ -1,6 +1,6 @@ { "name": "@dvmcp/discovery", - "version": "0.2.3", + "version": "0.2.4", "description": "Discovery service for MCP tools in the Nostr DVM ecosystem", "module": "index.ts", "type": "module", @@ -15,11 +15,11 @@ "config.example.yml" ], "scripts": { - "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", + "fmt": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", "dev": "bun --watch index.ts", "start": "bun run cli.ts", "typecheck": "tsc --noEmit", - "lint": "bun run typecheck && bun run format", + "lint": "bun run typecheck && bun run fmt", "test": "bun test", "prepublishOnly": "bun run lint && bun run test" }, @@ -35,7 +35,9 @@ "@modelcontextprotocol/sdk": "^1.11.4", "nostr-tools": "^2.13.0", "yaml": "^2.8.0", - "@dvmcp/commons": "^0.2.5" + "@dvmcp/commons": "^0.2.6", + "yargs": "^17.7.2", + "@types/yargs": "^17.0.33" }, "publishConfig": { "access": "public" diff --git a/packages/dvmcp-discovery/src/base-executor.ts b/packages/dvmcp-discovery/src/base-executor.ts index 6ae6d90..22a9edd 100644 --- a/packages/dvmcp-discovery/src/base-executor.ts +++ b/packages/dvmcp-discovery/src/base-executor.ts @@ -1,21 +1,38 @@ import type { NostrEvent } from 'nostr-tools'; import type { Capability, ExecutionContext } from './base-interfaces'; import { BaseRegistry } from './base-registry'; -import { RESPONSE_KIND, NOTIFICATION_KIND } from '@dvmcp/commons/core'; +import { + RESPONSE_KIND, + NOTIFICATION_KIND, + GIFT_WRAP_KIND, + TAG_PUBKEY, + loggerDiscovery, + TAG_EVENT_ID, +} from '@dvmcp/commons/core'; import type { RelayHandler } from '@dvmcp/commons/nostr'; import type { KeyManager } from '@dvmcp/commons/nostr'; import type { NWCPaymentHandler } from './nwc-payment'; +import { + EncryptionMode, + type EncryptionManager, +} from '@dvmcp/commons/encryption'; +import type { ServerRegistry } from './server-registry'; // Import ServerRegistry type export abstract class BaseExecutor { protected executionSubscriptions: Map void> = new Map(); protected static readonly EXECUTION_TIMEOUT = 30000; protected nwcPaymentHandler: NWCPaymentHandler | null = null; + protected encryptionManager: EncryptionManager | null = null; constructor( protected relayHandler: RelayHandler, protected keyManager: KeyManager, - protected registry: BaseRegistry - ) {} + protected registry: BaseRegistry, + protected serverRegistry: ServerRegistry, // Add ServerRegistry to constructor + encryptionManager?: EncryptionManager | null + ) { + this.encryptionManager = encryptionManager || null; + } public updateRelayHandler(relayHandler: RelayHandler): void { this.relayHandler = relayHandler; @@ -59,7 +76,7 @@ export abstract class BaseExecutor { ): Promise; public async execute(id: string, item: T, params: P): Promise { - return new Promise((resolve, reject) => { + return new Promise(async (resolve, reject) => { try { const request = this.createRequest(id, item, params); const executionId = request.id; @@ -70,19 +87,64 @@ export abstract class BaseExecutor { reject(new Error(`Execution timeout for: ${executionId}`)); }, BaseExecutor.EXECUTION_TIMEOUT); - const subscription = this.relayHandler.subscribeToRequests( - (event: NostrEvent) => { - const isResponseToOurRequest = event.tags.some( - (t: string[]) => t[0] === 'e' && t[1] === executionId - ); + // Helper to check if an event is a response to our request (unencrypted or encrypted) + const isResponseEvent = async ( + event: NostrEvent + ): Promise<{ match: boolean; event: NostrEvent }> => { + // Direct (unencrypted) response + if ( + event.tags.some( + (t: string[]) => t[0] === TAG_EVENT_ID && t[1] === executionId + ) + ) { + return { match: true, event }; + } + // Encrypted response + if (this.encryptionManager && event.kind === GIFT_WRAP_KIND) { + try { + const decryptionResult = + await this.encryptionManager.decryptEventAndExtractSender( + event, + this.keyManager.getPrivateKey() + ); + if (decryptionResult) { + if ( + decryptionResult.decryptedEvent.tags?.some( + (t: string[]) => + t[0] === TAG_EVENT_ID && t[1] === executionId + ) + ) { + // Convert decrypted event to NostrEvent format + const decryptedNostrEvent = { + id: event.id, + pubkey: decryptionResult.sender, + created_at: decryptionResult.decryptedEvent.created_at, + kind: decryptionResult.decryptedEvent.kind, + tags: decryptionResult.decryptedEvent.tags, + content: decryptionResult.decryptedEvent.content, + sig: event.sig, + } as NostrEvent; + return { match: true, event: decryptedNostrEvent }; + } + } + } catch { + // Silently ignore decryption failures + } + } + return { match: false, event }; + }; - if (isResponseToOurRequest) { + const subscription = this.relayHandler.subscribeToRequests( + async (event: NostrEvent) => { + const { match, event: processedEvent } = + await isResponseEvent(event); + if (match) { clearTimeout(timeoutId); - this.handleResponse(event, context, resolve, reject); + this.handleResponse(processedEvent, context, resolve, reject); } }, { - kinds: [RESPONSE_KIND, NOTIFICATION_KIND], + kinds: [RESPONSE_KIND, NOTIFICATION_KIND, GIFT_WRAP_KIND], since: Math.floor(Date.now() / 1000), } ); @@ -92,7 +154,53 @@ export abstract class BaseExecutor { subscription.close(); }); - this.relayHandler.publishEvent(request).catch((err: Error) => { + // Decide if encryption is needed and possible + let eventToPublish = request; + if (this.encryptionManager) { + const recipientPubkey = request.tags.find( + (tag) => tag[0] === TAG_PUBKEY + )?.[1]; + if (recipientPubkey) { + const serverInfo = + this.serverRegistry.getServerByPubkey(recipientPubkey); + const encryptionMode = this.encryptionManager.getEncryptionMode(); + const canEncrypt = + serverInfo?.supportsEncryption && + (encryptionMode === EncryptionMode.REQUIRED || + encryptionMode === EncryptionMode.OPTIONAL); + + if (canEncrypt) { + try { + const encryptedEvent = + await this.encryptionManager.encryptMessage( + this.keyManager.getPrivateKey(), + recipientPubkey, + { + kind: request.kind, + content: request.content, + tags: request.tags, + created_at: request.created_at, + } + ); + if (encryptedEvent) { + eventToPublish = encryptedEvent; + } + } catch (encryptError) { + // If encryption fails, send the original unencrypted request + loggerDiscovery( + 'Failed to encrypt request, sending unencrypted:', + encryptError + ); + } + } else if (encryptionMode === EncryptionMode.REQUIRED) { + throw new Error( + `Recipient server ${recipientPubkey} does not support encryption` + ); + } + } + } + + this.relayHandler.publishEvent(eventToPublish).catch((err: Error) => { this.cleanupExecution(executionId); reject(err); }); diff --git a/packages/dvmcp-discovery/src/base-interfaces.ts b/packages/dvmcp-discovery/src/base-interfaces.ts index 78bb1c2..b1283d7 100644 --- a/packages/dvmcp-discovery/src/base-interfaces.ts +++ b/packages/dvmcp-discovery/src/base-interfaces.ts @@ -6,7 +6,8 @@ export interface Capability { | 'resource' | 'resourceTemplate' | 'server' - | 'completion'; + | 'completion' + | 'ping'; } export interface DVMCPBridgeServer extends Capability { diff --git a/packages/dvmcp-discovery/src/completion-executor.ts b/packages/dvmcp-discovery/src/completion-executor.ts index e1c2066..8667592 100644 --- a/packages/dvmcp-discovery/src/completion-executor.ts +++ b/packages/dvmcp-discovery/src/completion-executor.ts @@ -1,6 +1,7 @@ import { type Event as NostrEvent } from 'nostr-tools'; import { RelayHandler } from '@dvmcp/commons/nostr'; import { createKeyManager } from '@dvmcp/commons/nostr'; +import { EncryptionManager } from '@dvmcp/commons/encryption'; import { BaseExecutor } from './base-executor'; import type { ExecutionContext, Capability } from './base-interfaces'; import { @@ -26,11 +27,18 @@ export class CompletionExecutor extends BaseExecutor< constructor( relayHandler: RelayHandler, keyManager: ReturnType, - private promptRegistry: PromptRegistry, - private resourceRegistry: ResourceRegistry, - private serverRegistry: ServerRegistry + protected promptRegistry: PromptRegistry, + protected resourceRegistry: ResourceRegistry, + protected serverRegistry: ServerRegistry, + encryptionManager?: EncryptionManager ) { - super(relayHandler, keyManager, promptRegistry); + super( + relayHandler, + keyManager, + promptRegistry, + serverRegistry, + encryptionManager + ); } /** diff --git a/packages/dvmcp-discovery/src/config-schema.ts b/packages/dvmcp-discovery/src/config-schema.ts index e3290c8..f53ea4f 100644 --- a/packages/dvmcp-discovery/src/config-schema.ts +++ b/packages/dvmcp-discovery/src/config-schema.ts @@ -5,6 +5,8 @@ */ import type { ConfigSchema } from '@dvmcp/commons/config'; +import type { EncryptionConfig } from '@dvmcp/commons/encryption'; +import { EncryptionMode } from '@dvmcp/commons/encryption'; export const DEFAULT_VALUES = { DEFAULT_RELAY_URL: 'wss://r.dvmcp.fun', @@ -111,6 +113,9 @@ export interface DvmcpDiscoveryConfig { /** Optional feature flags configuration */ featureFlags?: FeatureFlagsConfig; + + /** Optional encryption configuration */ + encryption?: EncryptionConfig; } /** @@ -213,4 +218,17 @@ export const dvmcpDiscoveryConfigSchema: ConfigSchema = { }, }, }, + encryption: { + type: 'object', + required: false, + doc: 'Optional encryption configuration for NIP-17/NIP-59 support', + fields: { + mode: { + type: 'string', + required: false, + default: EncryptionMode.OPTIONAL, + doc: 'Encryption mode: disabled, optional (mirrors incoming format), or required.', + }, + }, + }, }; diff --git a/packages/dvmcp-discovery/src/discovery-server.ts b/packages/dvmcp-discovery/src/discovery-server.ts index bc36480..c2be9d5 100644 --- a/packages/dvmcp-discovery/src/discovery-server.ts +++ b/packages/dvmcp-discovery/src/discovery-server.ts @@ -3,6 +3,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { type Event, type Filter } from 'nostr-tools'; import { RelayHandler } from '@dvmcp/commons/nostr'; import { createKeyManager } from '@dvmcp/commons/nostr'; +import { EncryptionManager } from '@dvmcp/commons/encryption'; import { CompletionExecutor } from './completion-executor'; import { PingExecutor } from './ping-executor'; import type { DvmcpDiscoveryConfig } from './config-schema'; @@ -13,6 +14,7 @@ import { PROMPTS_LIST_KIND, TAG_SERVER_IDENTIFIER, TAG_UNIQUE_IDENTIFIER, + TAG_SUPPORT_ENCRYPTION, } from '@dvmcp/commons/core'; import { type Tool, @@ -42,6 +44,7 @@ export class DiscoveryServer { private mcpServer: McpServer; private relayHandler: RelayHandler; private keyManager: ReturnType; + private encryptionManager: EncryptionManager | null = null; private toolRegistry: ToolRegistry; private toolExecutor: ToolExecutor; @@ -59,6 +62,15 @@ export class DiscoveryServer { this.config = config; this.relayHandler = new RelayHandler(config.nostr.relayUrls); this.keyManager = createKeyManager(config.nostr.privateKey); + + // Initialize encryption manager if encryption is configured + if (config.encryption) { + this.encryptionManager = new EncryptionManager(config.encryption); + loggerDiscovery( + `Encryption manager initialized with mode: ${config.encryption.mode || 'optional'}` + ); + } + this.mcpServer = new McpServer({ name: config.mcp.name, version: config.mcp.version, @@ -71,7 +83,9 @@ export class DiscoveryServer { this.relayHandler, this.keyManager, this.toolRegistry, - this.config + this.serverRegistry, + this.config, + this.encryptionManager || undefined ); this.resourceRegistry = new ResourceRegistry(this.mcpServer); @@ -79,7 +93,9 @@ export class DiscoveryServer { this.relayHandler, this.keyManager, this.resourceRegistry, - this.config + this.serverRegistry, + this.config, + this.encryptionManager || undefined ); this.promptRegistry = new PromptRegistry(this.mcpServer); @@ -87,7 +103,9 @@ export class DiscoveryServer { this.relayHandler, this.keyManager, this.promptRegistry, - this.config + this.serverRegistry, + this.config, + this.encryptionManager || undefined ); this.completionExecutor = new CompletionExecutor( @@ -95,10 +113,16 @@ export class DiscoveryServer { this.keyManager, this.promptRegistry, this.resourceRegistry, - this.serverRegistry + this.serverRegistry, + this.encryptionManager || undefined ); - this.pingExecutor = new PingExecutor(this.relayHandler, this.keyManager); + this.pingExecutor = new PingExecutor( + this.relayHandler, + this.keyManager, + this.serverRegistry, + this.encryptionManager || undefined + ); this.toolRegistry.setExecutionCallback(async (toolId, args) => { return this.toolExecutor.executeTool(toolId, args); @@ -323,8 +347,24 @@ export class DiscoveryServer { loggerDiscovery('Server announcement missing server ID'); return; } - this.serverRegistry.registerServer(serverId, event.pubkey, event.content); - loggerDiscovery(`Registered server: ${serverId} from ${event.pubkey}`); + // Extract support_encryption tag + const supportsEncryptionTag = event.tags.find( + (tag) => tag[0] === TAG_SUPPORT_ENCRYPTION + ); + const supportsEncryption = + supportsEncryptionTag && supportsEncryptionTag[1] === 'true' + ? true + : false; + + this.serverRegistry.registerServer( + serverId, + event.pubkey, + event.content, + supportsEncryption + ); + loggerDiscovery( + `Registered server: ${serverId} from ${event.pubkey}, encryption support: ${supportsEncryption}` + ); } catch (error) { console.error('Error processing server announcement:', error); } @@ -658,7 +698,12 @@ export class DiscoveryServer { capabilities: announcement.capabilities || {}, serverInfo: announcement.serverInfo, instructions: announcement.instructions, - }) + }), + // For direct servers, assuming no explicit 'support_encryption' tag in InitializeResult. + // If the MCP protocol or InitializeResult type is extended to include encryption info, + // this logic would need to be updated to extract it. + // For now, we default to false for direct servers unless explicitly handled. + false // Defaulting to false for direct server encryption support ); loggerDiscovery( `Registered direct server: ${announcement.serverInfo.name || serverId} (${serverId})` diff --git a/packages/dvmcp-discovery/src/nwc-payment.ts b/packages/dvmcp-discovery/src/nwc-payment.ts index 7e1d50d..b91c9cf 100644 --- a/packages/dvmcp-discovery/src/nwc-payment.ts +++ b/packages/dvmcp-discovery/src/nwc-payment.ts @@ -6,7 +6,7 @@ import { import { NWCWalletRequest, NWCWalletResponse } from 'nostr-tools/kinds'; import { hexToBytes } from '@noble/hashes/utils'; import { encrypt, decrypt } from 'nostr-tools/nip04'; -import { loggerDiscovery } from '@dvmcp/commons/core'; +import { loggerDiscovery, TAG_PUBKEY } from '@dvmcp/commons/core'; import type { DvmcpDiscoveryConfig } from './config-schema'; import { RelayHandler } from '@dvmcp/commons/nostr'; @@ -116,7 +116,7 @@ export async function makeNwcRequestEvent( kind: NWCWalletRequest, created_at: Math.round(Date.now() / 1000), content: encryptedContent, - tags: [['p', pubkey]], + tags: [[TAG_PUBKEY, pubkey]], }; return finalizeEvent(eventTemplate, secretKey); diff --git a/packages/dvmcp-discovery/src/ping-executor.ts b/packages/dvmcp-discovery/src/ping-executor.ts index 82817a4..e2c0e63 100644 --- a/packages/dvmcp-discovery/src/ping-executor.ts +++ b/packages/dvmcp-discovery/src/ping-executor.ts @@ -1,243 +1,155 @@ -import { RelayHandler } from '@dvmcp/commons/nostr'; -import type { KeyManager } from '@dvmcp/commons/nostr'; +import type { Event as NostrEvent } from 'nostr-tools'; +import type { PingRequest } from '@modelcontextprotocol/sdk/types.js'; +import { BaseExecutor } from './base-executor'; +import type { ExecutionContext, Capability } from './base-interfaces'; +import type { KeyManager, RelayHandler } from '@dvmcp/commons/nostr'; +import type { EncryptionManager } from '@dvmcp/commons/encryption'; +import type { ServerRegistry } from './server-registry'; import { REQUEST_KIND, RESPONSE_KIND, TAG_METHOD, TAG_PUBKEY, - TAG_EVENT_ID, TAG_SERVER_IDENTIFIER, loggerDiscovery, } from '@dvmcp/commons/core'; -import type { Event, Filter } from 'nostr-tools'; - -export interface PingOptions { - timeout?: number; // milliseconds, default 10000 (10 seconds) -} export interface PingResult { success: boolean; - responseTime?: number; // milliseconds + responseTime?: number; error?: string; - response?: Event; + response?: NostrEvent; } -/** - * Executor for ping functionality - handles sending ping requests to DVMCP servers - * and waiting for responses to verify connection health - */ -export class PingExecutor { - private pendingPings = new Map< - string, - { - resolve: (result: PingResult) => void; - timeout: NodeJS.Timeout; - } - >(); +interface PingCapability extends Capability { + type: 'ping'; + serverPubkey: string; + serverId?: string; +} +export class PingExecutor extends BaseExecutor< + PingCapability, + PingRequest['params'], + PingResult +> { constructor( - private relayHandler: RelayHandler, - private keyManager: KeyManager + relayHandler: RelayHandler, + keyManager: KeyManager, + serverRegistry: ServerRegistry, + encryptionManager?: EncryptionManager ) { - // Subscribe to ping responses - this.setupResponseSubscription(); - } - - /** - * Update the relay handler instance - */ - public updateRelayHandler(relayHandler: RelayHandler): void { - this.relayHandler = relayHandler; - this.setupResponseSubscription(); + super( + relayHandler, + keyManager, + { items: new Map() } as any, + serverRegistry, + encryptionManager + ); } - /** - * Send a ping request to a specific server - * @param serverPubkey - Public key of the server to ping - * @param serverId - Server identifier (optional) - * @param options - Ping options - * @returns Promise that resolves with ping result - */ public async ping( serverPubkey: string, serverId?: string, - options: PingOptions = {} + params?: PingRequest['params'] ): Promise { - const timeout = options.timeout || 10000; // 10 seconds default const startTime = Date.now(); loggerDiscovery( `Sending ping to server ${serverPubkey}${serverId ? ` (${serverId})` : ''}` ); - // Create ping request event - const tags: string[][] = [ - [TAG_METHOD, 'ping'], - [TAG_PUBKEY, serverPubkey], - ]; - - if (serverId) { - tags.push([TAG_SERVER_IDENTIFIER, serverId]); - } - - const pingEvent = this.keyManager.signEvent({ - ...this.keyManager.createEventTemplate(REQUEST_KIND), - content: JSON.stringify({ method: 'ping' }), - tags, - }); - - // Set up promise to wait for response - const resultPromise = new Promise((resolve) => { - const timeoutId = setTimeout(() => { - this.pendingPings.delete(pingEvent.id); - resolve({ - success: false, - error: 'Ping timeout', - responseTime: Date.now() - startTime, - }); - }, timeout); - - this.pendingPings.set(pingEvent.id, { - resolve: (result: PingResult) => { - clearTimeout(timeoutId); - resolve({ - ...result, - responseTime: Date.now() - startTime, - }); - }, - timeout: timeoutId, - }); - }); + const pingCapability: PingCapability = { + id: `ping-${serverPubkey}-${Date.now()}`, + type: 'ping', + serverPubkey, + serverId, + }; try { - // Send ping request - await this.relayHandler.publishEvent(pingEvent); - loggerDiscovery( - `Ping request sent to ${serverPubkey}, waiting for response...` + const result = await this.execute( + pingCapability.id, + pingCapability, + params ); - - // Wait for response - const result = await resultPromise; - - if (result.success) { - loggerDiscovery( - `Ping successful to ${serverPubkey} in ${result.responseTime}ms` - ); - } else { - loggerDiscovery(`Ping failed to ${serverPubkey}: ${result.error}`); - } - return result; } catch (error) { - // Clean up pending ping - const pending = this.pendingPings.get(pingEvent.id); - if (pending) { - clearTimeout(pending.timeout); - this.pendingPings.delete(pingEvent.id); - } + const responseTime = Date.now() - startTime; + + loggerDiscovery(`Ping failed to ${serverPubkey}: ${error}`); return { success: false, error: error instanceof Error ? error.message : 'Unknown error', - responseTime: Date.now() - startTime, + responseTime, }; } } - /** - * Setup subscription to listen for ping responses - */ - private setupResponseSubscription(): void { - const publicKey = this.keyManager.getPublicKey(); + protected createRequest( + id: string, + item: PingCapability, + params: PingRequest['params'] + ): NostrEvent { + const request = this.keyManager.createEventTemplate(REQUEST_KIND); - const filter: Filter = { - kinds: [RESPONSE_KIND], - '#p': [publicKey], - since: Math.floor(Date.now() / 1000), - }; + const tags: string[][] = [ + [TAG_METHOD, 'ping'], + [TAG_PUBKEY, item.serverPubkey], + ]; - this.relayHandler.subscribeToRequests( - (event: Event) => this.handlePingResponse(event), - filter - ); - } + if (item.serverId) { + tags.push([TAG_SERVER_IDENTIFIER, item.serverId]); + } - /** - * Handle incoming ping response events - */ - private handlePingResponse(event: Event): void { - try { - // Find the original request ID from the event tags - const originalEventId = event.tags.find( - (tag) => tag[0] === TAG_EVENT_ID - )?.[1]; - if (!originalEventId) { - return; // Not a response to our ping - } + request.content = JSON.stringify({ method: 'ping', params }); + request.tags = tags; - const pending = this.pendingPings.get(originalEventId); - if (!pending) { - return; // Not a ping we're waiting for - } + return this.keyManager.signEvent(request); + } - // Remove from pending pings - this.pendingPings.delete(originalEventId); + protected async handleResponse( + event: NostrEvent, + context: ExecutionContext, + resolve: (value: PingResult) => void, + reject: (reason: Error) => void + ): Promise { + const responseTime = Date.now() - context.createdAt; - // Parse response - let responseContent: any; + if (event.kind === RESPONSE_KIND) { try { - responseContent = JSON.parse(event.content); - } catch (parseError) { - pending.resolve({ - success: false, - error: 'Invalid response format', + const responseContent = JSON.parse(event.content); + + if (responseContent.error) { + this.cleanupExecution(context.executionId); + resolve({ + success: false, + error: responseContent.error.message || 'Server error', + response: event, + responseTime, + }); + return; + } + + this.cleanupExecution(context.executionId); + resolve({ + success: true, response: event, + responseTime, }); - return; - } - - // Check if it's an error response - if (responseContent.error) { - pending.resolve({ + } catch (error) { + this.cleanupExecution(context.executionId); + resolve({ success: false, - error: responseContent.error.message || 'Server error', + error: 'Invalid response format', response: event, + responseTime, }); - return; } - - // Successful ping response - pending.resolve({ - success: true, - response: event, - }); - } catch (error) { - console.error('Error handling ping response:', error); } } - /** - * Cleanup method to clear pending pings and subscriptions - */ public cleanup(): void { - // Clear all pending timeouts - for (const [eventId, pending] of this.pendingPings) { - clearTimeout(pending.timeout); - pending.resolve({ - success: false, - error: 'Cleanup called', - }); - } - this.pendingPings.clear(); - + super.cleanup(); loggerDiscovery('PingExecutor cleaned up'); } - - /** - * Get the count of pending pings - * @returns Number of pending ping requests - */ - public getPendingPingsCount(): number { - return this.pendingPings.size; - } } diff --git a/packages/dvmcp-discovery/src/prompt-executor.ts b/packages/dvmcp-discovery/src/prompt-executor.ts index e5edc1a..54d9239 100644 --- a/packages/dvmcp-discovery/src/prompt-executor.ts +++ b/packages/dvmcp-discovery/src/prompt-executor.ts @@ -1,8 +1,10 @@ import { type Event as NostrEvent } from 'nostr-tools'; import { RelayHandler } from '@dvmcp/commons/nostr'; import { createKeyManager } from '@dvmcp/commons/nostr'; +import { EncryptionManager } from '@dvmcp/commons/encryption'; import { PromptRegistry, type PromptCapability } from './prompt-registry'; import { BaseExecutor } from './base-executor'; +import type { ServerRegistry } from './server-registry'; // Import ServerRegistry import type { ExecutionContext } from './base-interfaces'; import { REQUEST_KIND, @@ -12,6 +14,7 @@ import { TAG_METHOD, TAG_SERVER_IDENTIFIER, TAG_STATUS, + TAG_INVOICE, } from '@dvmcp/commons/core'; import { loggerDiscovery } from '@dvmcp/commons/core'; import { NWCPaymentHandler } from './nwc-payment'; @@ -32,9 +35,17 @@ export class PromptExecutor extends BaseExecutor< relayHandler: RelayHandler, keyManager: ReturnType, private promptRegistry: PromptRegistry, - private config: DvmcpDiscoveryConfig + protected serverRegistry: ServerRegistry, // Add serverRegistry + private config: DvmcpDiscoveryConfig, + encryptionManager?: EncryptionManager ) { - super(relayHandler, keyManager, promptRegistry); + super( + relayHandler, + keyManager, + promptRegistry, + serverRegistry, + encryptionManager + ); try { if (this.config.nwc?.connectionString) { @@ -135,7 +146,7 @@ export class PromptExecutor extends BaseExecutor< if (status === 'payment-required') { try { - const invoice = event.tags.find((t) => t[0] === 'invoice')?.[1]; + const invoice = event.tags.find((t) => t[0] === TAG_INVOICE)?.[1]; if (!invoice) { throw new Error('No invoice found in payment-required event'); } diff --git a/packages/dvmcp-discovery/src/resource-executor.ts b/packages/dvmcp-discovery/src/resource-executor.ts index 99ad139..49fd4f9 100644 --- a/packages/dvmcp-discovery/src/resource-executor.ts +++ b/packages/dvmcp-discovery/src/resource-executor.ts @@ -1,6 +1,8 @@ import { type Event as NostrEvent } from 'nostr-tools'; import { RelayHandler } from '@dvmcp/commons/nostr'; import { type KeyManager } from '@dvmcp/commons/nostr'; +import { EncryptionManager } from '@dvmcp/commons/encryption'; +import type { ServerRegistry } from './server-registry'; // Import ServerRegistry import { type ReadResourceRequest, type ReadResourceResult, @@ -16,6 +18,7 @@ import { TAG_METHOD, TAG_SERVER_IDENTIFIER, TAG_STATUS, + TAG_INVOICE, } from '@dvmcp/commons/core'; import { loggerDiscovery } from '@dvmcp/commons/core'; import { NWCPaymentHandler } from './nwc-payment'; @@ -32,9 +35,17 @@ export class ResourceExecutor extends BaseExecutor< relayHandler: RelayHandler, keyManager: KeyManager, private resourceRegistry: ResourceRegistry, - private config: DvmcpDiscoveryConfig + protected serverRegistry: ServerRegistry, // Add serverRegistry + private config: DvmcpDiscoveryConfig, + encryptionManager?: EncryptionManager ) { - super(relayHandler, keyManager, resourceRegistry); + super( + relayHandler, + keyManager, + resourceRegistry, + serverRegistry, + encryptionManager + ); try { if (this.config.nwc?.connectionString) { @@ -50,9 +61,8 @@ export class ResourceExecutor extends BaseExecutor< } /** - * Execute a resource with the given ID, and parameters - * @param resourceId - ID of the resource to execute - * @param resource - Resource definition + * Execute a resource with the given ID and parameters + * @param resourceId - ID of the resource to execute (can be resource ID or URI) * @param params - Parameters to pass to the resource * @returns Resource execution result */ @@ -60,9 +70,82 @@ export class ResourceExecutor extends BaseExecutor< resourceId: string, params: ReadResourceRequest['params'] ): Promise { + // First try to find a regular resource by exact ID match const resource = this.resourceRegistry.getResource(resourceId); - if (!resource) throw new Error('Resource not found'); - return this.execute(resourceId, resource as ResourceCapability, params); + if (resource) { + return this.execute(resourceId, resource as ResourceCapability, params); + } + + // If no regular resource found, try to execute as a resource template + return this.executeResourceTemplate(resourceId, params); + } + // TODO: Improve this, we shouldn't need to register the temporary resource template + /** + * Execute a resource template by URI pattern matching + * @param uri - URI to match against resource templates + * @param params - Parameters to pass to the resource + * @returns Resource execution result + */ + private async executeResourceTemplate( + uri: string, + params: ReadResourceRequest['params'] + ): Promise { + const templateMatch = this.resourceRegistry.findResourceTemplateByUri(uri); + if (!templateMatch) { + throw new Error(`Resource not found: ${uri}`); + } + + const { templateId, template } = templateMatch; + const templateInfo = + this.resourceRegistry.getResourceTemplateInfo(templateId); + if (!templateInfo) { + throw new Error(`Template info not found for: ${templateId}`); + } + + // Convert resource template to resource capability for execution + const resourceCapability: ResourceCapability = { + id: uri, + name: template.name, + description: template.description, + uri: uri, + type: 'resource', + pricing: template.pricing, + }; + + // Execute with temporary registration to provide provider info to createRequest + return this.executeWithTemporaryRegistration( + uri, + resourceCapability, + templateInfo.providerPubkey || '', + templateInfo.serverId, + params + ); + } + + /** + * Execute a resource with temporary registration for provider info lookup + */ + private async executeWithTemporaryRegistration( + resourceId: string, + resourceCapability: ResourceCapability, + providerPubkey: string, + serverId: string | undefined, + params: ReadResourceRequest['params'] + ): Promise { + // Temporarily register the resource capability + this.resourceRegistry.registerResource( + resourceId, + resourceCapability, + providerPubkey, + serverId + ); + + try { + return await this.execute(resourceId, resourceCapability, params); + } finally { + // Always clean up the temporary registration + this.resourceRegistry.removeResource(resourceId); + } } public cleanup(): void { @@ -126,7 +209,7 @@ export class ResourceExecutor extends BaseExecutor< if (status === 'payment-required') { try { - const invoice = event.tags.find((t) => t[0] === 'invoice')?.[1]; + const invoice = event.tags.find((t) => t[0] === TAG_INVOICE)?.[1]; if (!invoice) { throw new Error('No invoice found in payment-required event'); } diff --git a/packages/dvmcp-discovery/src/resource-registry.ts b/packages/dvmcp-discovery/src/resource-registry.ts index c611029..c60115b 100644 --- a/packages/dvmcp-discovery/src/resource-registry.ts +++ b/packages/dvmcp-discovery/src/resource-registry.ts @@ -304,6 +304,46 @@ export class ResourceRegistry extends BaseRegistry { }; } + /** + * Find a resource template that matches the given URI + * @param uri - URI to match against resource templates + * @returns Resource template ID and capability if found + */ + public findResourceTemplateByUri( + uri: string + ): { templateId: string; template: ResourceTemplateCapability } | undefined { + for (const [templateId, templateInfo] of this.resourceTemplates.entries()) { + const template = templateInfo.item; + if (this.matchesUriTemplate(uri, template.uriTemplate)) { + return { templateId, template }; + } + } + return undefined; + } + + /** + * Check if a URI matches a URI template pattern + * Uses regex-based matching for URI templates with parameters + * @param uri - The URI to test + * @param template - The URI template pattern (e.g., "proxy://{path}") + * @returns true if the URI matches the template pattern + */ + private matchesUriTemplate(uri: string, template: string): boolean { + // Fast path: exact match + if (uri === template) return true; + + // Fast path: no template variables means no match if not exact + if (!template.includes('{')) return false; + + // Convert URI template to regex pattern + // Escape regex special characters, then replace {param} with capture groups + const pattern = template + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // Escape special chars + .replace(/\\{[^}]+\\}/g, '[^/]*'); // Replace {param} with non-slash matcher + + return new RegExp(`^${pattern}$`).test(uri); + } + /** * Register a resource template with the MCP server * @param templateId - ID of the resource template @@ -320,16 +360,16 @@ export class ResourceRegistry extends BaseRegistry { async (uri: URL | string, params: Record) => { try { const uriString = typeof uri === 'string' ? uri : uri.toString(); - loggerDiscovery( - `Executing resource template ${templateId} with URI: ${uriString}` - ); - const result = await this.executionCallback?.(templateId, { + const result = await this.executionCallback?.(uriString, { uri: uriString, arguments: params, }); - if (!result) + + if (!result) { throw new Error(`No result for resource template ${templateId}`); + } + return result; } catch (error) { loggerDiscovery(`Error executing resource template: ${error}`); @@ -386,7 +426,7 @@ export class ResourceRegistry extends BaseRegistry { } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - console.error( + loggerDiscovery( `Error executing resource ${resourceId}:`, errorMessage ); @@ -408,7 +448,7 @@ export class ResourceRegistry extends BaseRegistry { loggerDiscovery('Resource registered successfully:', resourceId); } catch (error) { - console.error('Error registering resource:', resourceId, error); + loggerDiscovery('Error registering resource:', resourceId, error); } } diff --git a/packages/dvmcp-discovery/src/server-registry.ts b/packages/dvmcp-discovery/src/server-registry.ts index 1df8e99..02fbef0 100644 --- a/packages/dvmcp-discovery/src/server-registry.ts +++ b/packages/dvmcp-discovery/src/server-registry.ts @@ -15,6 +15,7 @@ export interface ServerInfo { pubkey: string; content: string; capabilities?: ServerCapabilities; + supportsEncryption?: boolean; // New field to indicate encryption support } export class ServerRegistry extends BaseRegistry { @@ -29,7 +30,8 @@ export class ServerRegistry extends BaseRegistry { public registerServer( serverId: string, pubkey: string, - content: string + content: string, + supportsEncryption: boolean ): void { let capabilities: ServerCapabilities | undefined; try { @@ -41,7 +43,12 @@ export class ServerRegistry extends BaseRegistry { loggerDiscovery(`Error parsing server announcement: ${error}`); } - const serverInfo: ServerInfo = { pubkey, content, capabilities }; + const serverInfo: ServerInfo = { + pubkey, + content, + capabilities, + supportsEncryption, + }; this.servers.set(serverId, serverInfo); const serverCapability: DVMCPBridgeServer = { @@ -69,6 +76,20 @@ export class ServerRegistry extends BaseRegistry { return this.servers.get(serverId); } + /** + * Get server information by public key + * @param pubkey - Provider's public key + * @returns Server information or undefined if not found + */ + public getServerByPubkey(pubkey: string): ServerInfo | undefined { + for (const serverInfo of this.servers.values()) { + if (serverInfo.pubkey === pubkey) { + return serverInfo; + } + } + return undefined; + } + /** * List all registered servers * @returns Array of server information diff --git a/packages/dvmcp-discovery/src/tool-executor.ts b/packages/dvmcp-discovery/src/tool-executor.ts index 4896e29..6fdd010 100644 --- a/packages/dvmcp-discovery/src/tool-executor.ts +++ b/packages/dvmcp-discovery/src/tool-executor.ts @@ -15,12 +15,15 @@ import { TAG_METHOD, TAG_SERVER_IDENTIFIER, TAG_STATUS, + TAG_INVOICE, } from '@dvmcp/commons/core'; import { loggerDiscovery } from '@dvmcp/commons/core'; import { NWCPaymentHandler } from './nwc-payment'; import type { DvmcpDiscoveryConfig } from './config-schema'; import { BaseExecutor } from './base-executor'; import type { ExecutionContext } from './base-interfaces'; +import type { EncryptionManager } from '@dvmcp/commons/encryption'; +import type { ServerRegistry } from './server-registry'; // Import ServerRegistry export class ToolExecutor extends BaseExecutor< ToolCapability, @@ -33,9 +36,17 @@ export class ToolExecutor extends BaseExecutor< relayHandler: RelayHandler, keyManager: ReturnType, private toolRegistry: ToolRegistry, - private config: DvmcpDiscoveryConfig + protected serverRegistry: ServerRegistry, // Change to protected + private config: DvmcpDiscoveryConfig, + encryptionManager?: EncryptionManager ) { - super(relayHandler, keyManager, toolRegistry); + super( + relayHandler, + keyManager, + toolRegistry, + serverRegistry, + encryptionManager + ); // Initialize NWC payment handler if needed this.initializeNWCPaymentHandler(); @@ -145,7 +156,7 @@ export class ToolExecutor extends BaseExecutor< if (status === 'payment-required') { try { - const invoice = event.tags.find((t) => t[0] === 'invoice')?.[1]; + const invoice = event.tags.find((t) => t[0] === TAG_INVOICE)?.[1]; if (!invoice) { throw new Error('No invoice found in payment-required event'); }