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/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. 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', {