Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/brave-vans-roll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@contextvm/sdk': patch
---

fix(transport): deduplicate decrypted inner events before processing
6 changes: 5 additions & 1 deletion src/gateway/gateway-per-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 10 additions & 3 deletions src/transport/nostr-server-transport.dedup-response.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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'];
Expand Down Expand Up @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions src/transport/nostr-server-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down
Loading