Skip to content
5 changes: 5 additions & 0 deletions .changeset/cep-47-redirect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@contextvm/sdk": minor
---

Add CEP-47 Server Redirect support. Includes `-32044` error code, `createRedirectMiddleware` for server-side redirects, and `withClientRedirect` for client-side transparent re-issuance. Also fixes a bug in `ApplesauceRelayPool` message handling.
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@
"./payments/*": {
"types": "./dist/esm/payments/*.d.ts",
"default": "./dist/esm/payments/*.js"
},
"./redirect": {
"types": "./dist/esm/redirect/index.d.ts",
"default": "./dist/esm/redirect/index.js"
},
"./redirect/*": {
"types": "./dist/esm/redirect/*.d.ts",
"default": "./dist/esm/redirect/*.js"
}
},
"types": "./dist/esm/index.d.ts",
Expand Down
3 changes: 2 additions & 1 deletion src/__mocks__/mock-relay-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,8 @@ export function startMockRelay(
this.send(['OK', event.id, true, '']);

for (const [uniqueSubId, { instance, filters }] of state.subs.entries()) {
if (matchFilters(filters, event)) {
const isMatch = matchFilters(filters, event);
if (isMatch) {
const originalSubId = uniqueSubId.includes(':')
? uniqueSubId.split(':').slice(1).join(':')
: uniqueSubId;
Expand Down
6 changes: 3 additions & 3 deletions src/gateway/gateway-announcement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ describe('NostrMCPGateway announcement transport in per-client mode', () => {
expect(promptsAnnouncement).toBeDefined();

await gateway.stop();
}, 15000);
}, 30000);

test('should not publish announcement events without announcementMcpTransport in per-client mode', async () => {
// Use a fresh relay hub so events from the previous test don't leak.
Expand Down Expand Up @@ -230,7 +230,7 @@ describe('NostrMCPGateway announcement transport in per-client mode', () => {

await gateway.stop();
freshRelayHub.clear();
}, 15000);
}, 30000);

test('should start and close the dedicated announcement transport during gateway lifecycle', async () => {
const freshRelayHub = new MockRelayHub();
Expand Down Expand Up @@ -283,5 +283,5 @@ describe('NostrMCPGateway announcement transport in per-client mode', () => {
expect(closeCount).toBe(1);

freshRelayHub.clear();
}, 15000);
}, 30000);
});
2 changes: 1 addition & 1 deletion src/gateway/gateway-dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,5 +105,5 @@ describe.serial('NostrMCPGateway single-client dispatch', () => {
await client.close();
await gateway.stop();
await mcpServer.close();
}, 20000);
}, 40000);
});
2 changes: 1 addition & 1 deletion src/gateway/gateway-payments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,5 +125,5 @@ describe.serial('NostrMCPGateway payments wiring', () => {
await client.close();
await gateway.stop();
await mcpServer.close();
}, 20000);
}, 40000);
});
2 changes: 1 addition & 1 deletion src/gateway/gateway-per-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ describe('NostrMCPGateway per-client MCP routing', () => {
stopRelay = () => relayHub.clear();

logger.info('Relay started', { relayUrl: 'memory://relay' });
}, 20000);
}, 40000);

afterAll(async () => {
stopRelay?.();
Expand Down
2 changes: 1 addition & 1 deletion src/gateway/gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,5 +222,5 @@ describe('NostrMCPGateway End-to-End Test', () => {
// if (testGateway.isActive()) {
// await testGateway.stop();
// }
// }, 15000);
// }, 30000);
});
20 changes: 17 additions & 3 deletions src/gateway/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from '../transport/nostr-server-transport.js';
import { withServerPayments } from '../payments/index.js';
import type { ServerPaymentsOptions } from '../payments/server-payments.js';
import { withServerRedirect, type ServerRedirectConfig } from '../redirect/index.js';
import { NOTIFICATIONS_INITIALIZED_METHOD } from '../core/index.js';
import { createLogger } from '../core/utils/logger.js';
import { LruCache } from '../core/utils/lru-cache.js';
Expand Down Expand Up @@ -80,6 +81,12 @@ export interface NostrMCPGatewayOptions {
* receive an invoice notification.
*/
paymentOptions?: ServerPaymentsOptions;

/**
* CEP-47 server redirect configuration.
* When provided, evaluates inbound requests and redirects clients before payment gating.
*/
redirectConfig?: ServerRedirectConfig;
}

/**
Expand Down Expand Up @@ -130,13 +137,20 @@ export class NostrMCPGateway {
this.closeClientTransport(clientPubkey),
});

// Wrap with `withServerRedirect` first so redirected requests halt before payment gating.
let transport = nostrServerTransport;
if (options.redirectConfig) {
transport = withServerRedirect(transport, options.redirectConfig);
}

// Wrap with `withServerPayments` so CEP-8 gating, PMI/cap advertisement and
// payment_interaction negotiation are attached when a processor + priced
// capabilities are provided. Mirrors the client-side `withClientPayments`
// wiring in NostrMCPProxy. No-op when paymentOptions is omitted.
this.nostrServerTransport = options.paymentOptions
? withServerPayments(nostrServerTransport, options.paymentOptions)
: nostrServerTransport;
if (options.paymentOptions) {
transport = withServerPayments(transport, options.paymentOptions);
}
this.nostrServerTransport = transport;

if (this.createMcpClientTransport) {
this.clientTransportPromises = new Map();
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export * from './gateway/index.js';
export * from './proxy/index.js';
export * from './transport/index.js';
export * from './payments/index.js';
export * from './redirect/index.js';
4 changes: 4 additions & 0 deletions src/payments/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ export const PAYMENT_REQUIRED_ERROR_CODE = -32042;
/** CEP-8 explicit-gating JSON-RPC error: payment pending. */
export const PAYMENT_PENDING_ERROR_CODE = -32043;

/** CEP-47 JSON-RPC error: server redirect. */
export const REDIRECT_ERROR_CODE = -32044;


/**
* CEP-8 unsupported payment_interaction negotiation error.
*
Expand Down
30 changes: 29 additions & 1 deletion src/proxy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from '../transport/nostr-client-transport.js';
import { withClientPayments } from '../payments/client-payments.js';
import type { ClientPaymentsOptions } from '../payments/client-payments.js';
import { withClientRedirect, type ClientRedirectOptions } from '../redirect/index.js';
import { createLogger } from '../core/utils/logger.js';

const logger = createLogger('proxy');
Expand Down Expand Up @@ -34,6 +35,11 @@ export interface NostrMCPProxyOptions {
* (programmatic) payment so the proxy can settle invoices itself.
*/
paymentOptions?: ClientPaymentsOptions;
/**
* CEP-47 client redirect configuration and hooks.
* When provided, transparently follows server redirections before payment gating.
*/
redirectOptions?: ClientRedirectOptions;
}

/**
Expand All @@ -54,10 +60,32 @@ export class NostrMCPProxy {
// No handlers ⇒ PMI-agnostic: explicit_gating surfaces `-32042` as an error;
// transparent forwards `payment_required` and keeps the request alive with
// synthetic progress.
this.nostrTransport = withClientPayments(
const initialTransport = withClientPayments(
new NostrClientTransport(options.nostrTransportOptions),
options.paymentOptions ?? {},
);

if (options.redirectOptions) {
const {
serverPubkey: _serverPubkey,
relayHandler: _relayHandler,
discoveryRelayUrls: _discoveryRelayUrls,
fallbackOperationalRelayUrls: _fallbackOperationalRelayUrls,
...baseOpts
} = options.nostrTransportOptions;

this.nostrTransport = withClientRedirect(
initialTransport,
{
...baseOpts,
wrapTransport: (t) =>
withClientPayments(t, options.paymentOptions ?? {}),
},
options.redirectOptions,
);
} else {
this.nostrTransport = initialTransport;
}
}

/**
Expand Down
12 changes: 6 additions & 6 deletions src/proxy/proxy-gateway-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ describe('Proxy-Gateway E2E Test (Without Mock Responses)', () => {
expect(gateway.isActive()).toBe(true);

await client.close();
}, 15000);
}, 30000);

test.serial(
'should list tools through proxy-gateway chain',
Expand Down Expand Up @@ -173,7 +173,7 @@ describe('Proxy-Gateway E2E Test (Without Mock Responses)', () => {
});

await client.close();
}, 15000);
}, 30000);

test('should list resources through proxy-gateway chain', async () => {
const transport = getProxyTransport();
Expand All @@ -193,7 +193,7 @@ describe('Proxy-Gateway E2E Test (Without Mock Responses)', () => {
expect(resources.resourceTemplates.length).toBe(1);

await client.close();
}, 15000);
}, 30000);

test('should read resource through proxy-gateway chain', async () => {
const transport = getProxyTransport();
Expand All @@ -219,7 +219,7 @@ describe('Proxy-Gateway E2E Test (Without Mock Responses)', () => {
});

await client.close();
}, 15000);
}, 30000);

test('should handle multiple concurrent requests through proxy-gateway chain', async () => {
const transport = getProxyTransport();
Expand Down Expand Up @@ -258,7 +258,7 @@ describe('Proxy-Gateway E2E Test (Without Mock Responses)', () => {
});

await client.close();
}, 15000);
}, 30000);

test('should handle error cases through proxy-gateway chain', async () => {
const transport = getProxyTransport();
Expand All @@ -284,5 +284,5 @@ describe('Proxy-Gateway E2E Test (Without Mock Responses)', () => {
}

await client.close();
}, 15000);
}, 30000);
});
2 changes: 1 addition & 1 deletion src/proxy/proxy-payments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,5 +123,5 @@ describe.serial('NostrMCPProxy payments wiring', () => {
await client.close();
await proxy.stop();
await mcpServer.close();
}, 20000);
}, 40000);
});
Loading
Loading