Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
218fc0b
feat: implement CEP-47 server redirect client/server middleware and r…
abhayguptas Jul 29, 2026
1eb2745
fix: resilient type fallback for applesauce-relay message types
abhayguptas Jul 29, 2026
f16e53a
style: resolve explicit any linting error in applesauce fallback handler
abhayguptas Jul 29, 2026
fc04859
fix: correctly handle array message shape in applesauce-relay-pool fa…
abhayguptas Jul 29, 2026
df056be
fix: resolve CEP-47 redirect race conditions and relay handling
abhayguptas Jul 30, 2026
48796e3
Merge branch 'fix/cep-47-redirect-race-conditions' into feat/cep-47-r…
abhayguptas Jul 30, 2026
18e6490
test: increase timeouts to prevent flakiness in GH Actions
abhayguptas Jul 30, 2026
97f71b9
Revert "test: increase timeouts to prevent flakiness in GH Actions"
abhayguptas Jul 31, 2026
1b3e79b
fix: restore applesauce-relay resubscribe behaviour without deduplica…
abhayguptas Jul 31, 2026
172996f
style: run prettier formatting
abhayguptas Jul 31, 2026
09409da
fix: resolve TS2367 type inference error on EOSE check
abhayguptas Jul 31, 2026
17362fe
fix: resolve applesauce-relay resubscribe race conditions and memory …
abhayguptas Aug 2, 2026
ee3af96
style: fix prettier formatting issues
abhayguptas Aug 3, 2026
eba9d01
fix: resolve TS2349 union overload error in typecheck
abhayguptas Aug 3, 2026
19f4f42
fix: resolve message unwrapping in ApplesauceRelayPool and apply CEP-…
abhayguptas Aug 3, 2026
923b4bb
test: add gateway and proxy redirect composition tests, fix minor issues
abhayguptas Aug 5, 2026
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/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.
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
157 changes: 157 additions & 0 deletions src/gateway/gateway-redirect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import {
afterAll,
afterEach,
beforeAll,
describe,
expect,
test,
} from 'bun:test';
import { sleep } from 'bun';
import { Client } from '@contextvm/mcp-sdk/client';
import { McpServer } from '@contextvm/mcp-sdk/server/mcp';
import { InMemoryTransport } from '@contextvm/mcp-sdk/inMemory';
import { z } from 'zod';
import { bytesToHex } from 'nostr-tools/utils';
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure';
import { ApplesauceRelayPool } from '../relay/applesauce-relay-pool.js';
import { PrivateKeySigner } from '../signer/private-key-signer.js';
import { EncryptionMode } from '../core/interfaces.js';
import { NostrServerTransport } from '../transport/nostr-server-transport.js';
import { NostrClientTransport } from '../transport/nostr-client-transport.js';
import { NostrMCPGateway } from './index.js';
import { withClientRedirect } from '../redirect/index.js';
import { withClientPayments } from '../payments/index.js';
import {
spawnMockRelay,
clearRelayCache,
} from '../__mocks__/test-relay-helpers.js';

/**
* Proves `NostrMCPGateway` correctly wires `redirectConfig` via
* `withServerRedirect` on its internal server transport.
*
* Mirrors `gateway-payments.test.ts` but exercises the redirect path:
* a gateway configured with `redirectConfig` should emit -32044 to
* its Nostr clients, proving the middleware is wired and the high-level
* `redirectConfig` option works end-to-end.
*/
describe.serial('NostrMCPGateway redirect wiring', () => {
let relayUrl: string;
let httpUrl: string;
let stopRelay: (() => void) | undefined;

beforeAll(async () => {
const relay = await spawnMockRelay();
relayUrl = relay.relayUrl;
httpUrl = relay.httpUrl;
stopRelay = relay.stop;
});

afterEach(async () => {
await clearRelayCache(httpUrl);
});

afterAll(async () => {
stopRelay?.();
await sleep(100);
});

test('gateway follows a server redirect configured via redirectConfig', async () => {
// Target Server — a real MCP server reachable over Nostr
const targetSK = generateSecretKey();
const targetServer = new McpServer({
name: 'gateway-target-server',
version: '1.0.0',
});
targetServer.registerTool(
'echo',
{
title: 'Echo',
description: 'Echoes the message',
inputSchema: { message: z.string() },
},
async ({ message }: { message: string }) => ({
content: [{ type: 'text', text: `GW-Redirected: ${message}` }],
}),
);
const targetTransport = new NostrServerTransport({
signer: new PrivateKeySigner(bytesToHex(targetSK)),
relayHandler: new ApplesauceRelayPool([relayUrl]),
encryptionMode: EncryptionMode.DISABLED,
});
await targetServer.connect(targetTransport);
const targetPubkey = getPublicKey(targetSK);

// Gateway — bridges a local MCP server and exposes it over Nostr.
// `redirectConfig` injects the server-side redirect middleware so that
// all inbound requests get redirected to the target server.
const [mcpTransport, gatewayMcpTransport] =
InMemoryTransport.createLinkedPair();
const mcpServer = new McpServer({
name: 'gateway-initial-server',
version: '1.0.0',
});
await mcpServer.connect(mcpTransport);

const gatewaySK = generateSecretKey();
const gateway = new NostrMCPGateway({
mcpClientTransport: gatewayMcpTransport,
nostrTransportOptions: {
signer: new PrivateKeySigner(bytesToHex(gatewaySK)),
relayHandler: new ApplesauceRelayPool([relayUrl]),
encryptionMode: EncryptionMode.DISABLED,
publishRelayList: false,
},
redirectConfig: {
resolveRedirect: async () => ({
target: targetPubkey,
relays: [relayUrl],
}),
},
});
await gateway.start();
const gatewayPubkey = getPublicKey(gatewaySK);

// Client — connects to the gateway over Nostr with redirect support.
// Mirrors the `NostrMCPProxy` wrapping order: payments(base) → redirect(…)
const clientSigner = new PrivateKeySigner(
bytesToHex(generateSecretKey()),
);
const baseClientTransport = withClientPayments(
new NostrClientTransport({
signer: clientSigner,
relayHandler: new ApplesauceRelayPool([relayUrl]),
serverPubkey: gatewayPubkey,
encryptionMode: EncryptionMode.DISABLED,
}),
{},
);
const clientTransport = withClientRedirect(
baseClientTransport,
{
signer: clientSigner,
encryptionMode: EncryptionMode.DISABLED,
wrapTransport: (t) => withClientPayments(t, {}),
},
{ maxRedirects: 2 },
);

const client = new Client({
name: 'gateway-redirect-client',
version: '1.0.0',
});
await client.connect(clientTransport as never);

const res = await client.callTool({
name: 'echo',
arguments: { message: 'hello gateway' },
});
expect((res.content as Array<{ text: string }>)[0].text).toBe(
'GW-Redirected: hello gateway',
);

await client.close();
await gateway.stop();
await targetServer.close();
}, 20000);
});
23 changes: 20 additions & 3 deletions src/gateway/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ 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 +84,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 +140,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';
3 changes: 3 additions & 0 deletions src/payments/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ 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
33 changes: 32 additions & 1 deletion src/proxy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ 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 +38,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 +63,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
Loading
Loading