From 8137fd40a3c17ad42da2cba460bf202ee092db28 Mon Sep 17 00:00:00 2001 From: ContextVM Date: Thu, 30 Apr 2026 10:30:38 +0100 Subject: [PATCH 1/2] fix(transport): deduplicate decrypted inner events before processing --- .changeset/brave-vans-roll.md | 5 +++++ .../nostr-server-transport.dedup-response.test.ts | 13 ++++++++++--- src/transport/nostr-server-transport.ts | 10 ++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 .changeset/brave-vans-roll.md diff --git a/.changeset/brave-vans-roll.md b/.changeset/brave-vans-roll.md new file mode 100644 index 0000000..21f0364 --- /dev/null +++ b/.changeset/brave-vans-roll.md @@ -0,0 +1,5 @@ +--- +'@contextvm/sdk': patch +--- + +fix(transport): deduplicate decrypted inner events before processing diff --git a/src/transport/nostr-server-transport.dedup-response.test.ts b/src/transport/nostr-server-transport.dedup-response.test.ts index 3d3d1b2..f8a30fc 100644 --- a/src/transport/nostr-server-transport.dedup-response.test.ts +++ b/src/transport/nostr-server-transport.dedup-response.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'bun:test'; +import { describe, it, expect, mock } from 'bun:test'; import type { RelayHandler } from '../core/interfaces.js'; import type { NostrEvent } from 'nostr-tools'; import type { JSONRPCResponse } from '@modelcontextprotocol/sdk/types.js'; @@ -151,6 +151,8 @@ describe.serial('NostrServerTransport duplicate response prevention', () => { relayHandler: makeCountingRelayHandler(counter), encryptionMode: EncryptionMode.REQUIRED, }); + const onmessage = mock(() => {}); + transport.onmessage = onmessage; // Make decryptMessage deterministically return the same inner event id for both envelopes. const signer = transport['signer']; @@ -204,8 +206,13 @@ describe.serial('NostrServerTransport duplicate response prevention', () => { await transport['processIncomingEvent'](gw2); expect(decryptCalls).toBe(2); - // The transport should only process the inner request once. - // We assert on correlation store size because requests register an event route. + expect(onmessage).toHaveBeenCalledTimes(1); + expect(onmessage).toHaveBeenCalledWith({ + jsonrpc: '2.0', + id: 'inner-request-id', + method: 'tools/list', + params: {}, + }); expect( transport.getInternalStateForTesting().correlationStore.eventRouteCount, ).toBe(1); diff --git a/src/transport/nostr-server-transport.ts b/src/transport/nostr-server-transport.ts index 223f290..c549f86 100644 --- a/src/transport/nostr-server-transport.ts +++ b/src/transport/nostr-server-transport.ts @@ -882,6 +882,16 @@ export class NostrServerTransport ); const currentEvent = JSON.parse(decryptedJson) as NostrEvent; + // Deduplicate decrypted inner events before authorization and dispatch. + if (this.seenEventIds.has(currentEvent.id)) { + this.logger.debug('Skipping duplicate decrypted inner event', { + outerEventId: event.id, + innerEventId: currentEvent.id, + }); + return; + } + this.seenEventIds.set(currentEvent.id, true); + await this.authorizeAndProcessEvent(currentEvent, true, event.kind); } catch (error) { this.logger.error('Failed to handle encrypted Nostr event', { From b1f264c56afdde0e6c06063f1bd47bf350d28166 Mon Sep 17 00:00:00 2001 From: ContextVM Date: Thu, 30 Apr 2026 11:52:17 +0100 Subject: [PATCH 2/2] test(gateway): increase reconnect test delay to prevent event replay Nostr event IDs are deterministic based on the signed event payload, including `created_at` at one-second granularity. The previous 100ms sleep was insufficient, causing the reconnect to replay the exact same initialize event. Waiting 1.1 seconds ensures a fresh `created_at` timestamp and a new event. --- src/gateway/gateway-per-client.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/gateway/gateway-per-client.test.ts b/src/gateway/gateway-per-client.test.ts index 64fec4f..1a3c235 100644 --- a/src/gateway/gateway-per-client.test.ts +++ b/src/gateway/gateway-per-client.test.ts @@ -179,7 +179,11 @@ describe('NostrMCPGateway per-client MCP routing', () => { // Close the connection. await client1.close(); - await sleep(100); + + // Nostr event ids are deterministic over the signed event payload, including + // `created_at` at one-second granularity. Wait long enough so the reconnect + // sends a fresh initialize event instead of replaying the exact same one. + await sleep(1_100); // Reconnect the same client - should trigger a new transport creation // for the initialization request.