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/clever-nights-enjoy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sei-js/mcp-server": patch
---

Block wallet mode on HTTP transports to prevent CORS-based attacks
6 changes: 5 additions & 1 deletion packages/mcp-server/src/server/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ Examples:
Streamable HTTP transport with custom path:
$ SERVER_TRANSPORT=streamable-http SERVER_PORT=8080 SERVER_PATH=/api/mcp npx ${packageInfo.name}

With wallet enabled:
With wallet enabled (STDIO transport only):
$ WALLET_MODE=private-key PRIVATE_KEY=your_private_key_here npx ${packageInfo.name}

Environment Variables:
Expand All @@ -110,6 +110,10 @@ Environment Variables:
MAINNET_RPC_URL Custom RPC URL for Sei mainnet (optional)
TESTNET_RPC_URL Custom RPC URL for Sei testnet (optional)
DEVNET_RPC_URL Custom RPC URL for Sei devnet (optional)

Security Note:
Wallet mode is only supported with stdio transport. HTTP transports block
wallet mode to prevent cross-origin attacks from malicious websites.
`);

program.parse();
Expand Down
6 changes: 2 additions & 4 deletions packages/mcp-server/src/server/transport/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,12 @@ export const createTransport = (config: TransportConfig): McpTransport => {
return new StdioTransport();

case 'streamable-http':
return new StreamableHttpTransport(config.port, config.host, config.path);
return new StreamableHttpTransport(config.port, config.host, config.path, config.walletMode);

case 'http-sse':
return new HttpSseTransport(config.port, config.host, config.path);
return new HttpSseTransport(config.port, config.host, config.path, config.walletMode);

default:
throw new Error(`Unsupported transport mode: ${config.mode}`);
}
};


25 changes: 12 additions & 13 deletions packages/mcp-server/src/server/transport/http-sse.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,35 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import cors from 'cors';
import express, { type Request, type Response } from 'express';
import type { Server } from 'node:http';
import type { McpTransport } from './types.js';
import type { McpTransport, WalletMode } from './types.js';
import { createCorsMiddleware, validateSecurityConfig } from './security.js';

export class HttpSseTransport implements McpTransport {
readonly mode = 'http-sse' as const;
private app: express.Application;
private httpServer: Server | null = null;
private connections = new Map<string, SSEServerTransport>();
private mcpServer: McpServer | null = null;
private walletMode: WalletMode;

constructor(
private port: number,
private host: string,
private path: string
private path: string,
walletMode: WalletMode = 'disabled'
) {
this.walletMode = walletMode;
this.app = express();
this.setupMiddleware();
this.setupRoutes();
}

private setupMiddleware() {
this.app.use(express.json());
this.app.use(
cors({
origin: '*',
methods: ['GET', 'POST', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
exposedHeaders: ['Content-Type', 'Access-Control-Allow-Origin']
})
);
this.app.options('*', cors());

// Secure CORS - no cross-origin allowed by default
this.app.use(createCorsMiddleware());
}

private setupRoutes() {
Expand Down Expand Up @@ -82,6 +78,9 @@ export class HttpSseTransport implements McpTransport {
}

async start(server: McpServer): Promise<void> {
// Block wallet mode on HTTP transports
validateSecurityConfig(this.mode, this.walletMode);

this.mcpServer = server;
return new Promise((resolve, reject) => {
this.httpServer = this.app.listen(this.port, this.host, () => {
Expand Down
45 changes: 45 additions & 0 deletions packages/mcp-server/src/server/transport/security.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { Request, Response, NextFunction, RequestHandler } from 'express';
import type { TransportMode, WalletMode } from './types.js';

/**
* Creates CORS middleware with secure defaults.
* By default, no CORS headers are set (same-origin only).
*/
export function createCorsMiddleware(): RequestHandler {
return (req: Request, res: Response, next: NextFunction) => {
// Handle preflight - reject cross-origin by default
if (req.method === 'OPTIONS') {
return res.sendStatus(204);
}
next();
};
}

/**
* Validates that wallet mode is not used with HTTP transports
* Exits the process if unsafe configuration detected
*/
export function validateSecurityConfig(
transportMode: TransportMode,
walletMode: WalletMode
): void {
const isHttpTransport = transportMode === 'streamable-http' || transportMode === 'http-sse';
const isWalletEnabled = walletMode !== 'disabled';

if (isHttpTransport && isWalletEnabled) {
console.error('');
console.error('╔════════════════════════════════════════════════════════════════╗');
console.error('║ SECURITY ERROR ║');
console.error('╠════════════════════════════════════════════════════════════════╣');
console.error('║ Wallet mode cannot be used with HTTP transports! ║');
console.error('║ ║');
console.error('║ HTTP transports expose the server to cross-origin requests, ║');
console.error('║ allowing malicious websites to steal funds from your wallet. ║');
console.error('║ ║');
console.error('║ Use stdio transport instead (default, works with Claude): ║');
console.error('║ $ WALLET_MODE=private-key PRIVATE_KEY=... npx @sei-js/mcp-server');
console.error('╚════════════════════════════════════════════════════════════════╝');
console.error('');
process.exit(1);
}
}
28 changes: 16 additions & 12 deletions packages/mcp-server/src/server/transport/streamable-http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,38 +2,42 @@ import express, { type Request, type Response } from 'express';
import type { Server } from 'node:http';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { McpTransport, TransportMode } from './types.js';
import {getServer} from '../server.js';
import type { McpTransport, TransportMode, WalletMode } from './types.js';
import { createCorsMiddleware, validateSecurityConfig } from './security.js';
import { getServer } from '../server.js';

export class StreamableHttpTransport implements McpTransport {
public readonly mode: TransportMode = 'streamable-http';
private port: number;
private host: string;
private path: string;
private walletMode: WalletMode;
private app?: express.Express;
private server?: Server;

constructor(port = 8080, host = 'localhost', path = '/mcp') {
constructor(port = 8080, host = 'localhost', path = '/mcp', walletMode: WalletMode = 'disabled') {
this.port = port;
this.host = host;
this.path = path;
this.walletMode = walletMode;
}

// Note: server parameter ignored for now as this is a stateless server
// TODO: allow creating both stateless and stateful remote MCP servers
async start(_server: McpServer): Promise<void> {
// Block wallet mode on HTTP transports
validateSecurityConfig(this.mode, this.walletMode);

this.app = express();
this.app.use(express.json());
this.app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
return res.sendStatus(200);
}
next();
});

// Secure CORS - no cross-origin allowed by default
this.app.use(createCorsMiddleware());

// Health check endpoint
this.app.get('/health', (_req: Request, res: Response) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

this.app.post(this.path, async (req: Request, res: Response) => {
try {
Expand Down
7 changes: 6 additions & 1 deletion packages/mcp-server/src/server/transport/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { WalletMode } from '../../core/config.js';

export type TransportMode = 'stdio' | 'streamable-http' | 'http-sse';

export interface McpTransport {
start(server: McpServer): Promise<void>;
stop(): Promise<void>;
Expand All @@ -9,8 +11,11 @@ export interface McpTransport {

export interface TransportConfig {
mode: TransportMode;
walletMode: 'disabled' | 'private-key';
walletMode: WalletMode;
port: number; // Required for HTTP-based transports
host: string; // Required for HTTP-based transports
path: string; // Required for HTTP-based transports
}

// Re-export WalletMode for convenience
export type { WalletMode };
10 changes: 5 additions & 5 deletions packages/mcp-server/src/tests/server/transport/factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ describe('Transport Factory', () => {

const transport = createTransport(config);

expect(StreamableHttpTransport).toHaveBeenCalledWith(8080, '0.0.0.0', '/api/mcp');
expect(StreamableHttpTransport).toHaveBeenCalledWith(8080, '0.0.0.0', '/api/mcp', 'private-key');
expect(transport).toBe(mockStreamableInstance);
});

Expand All @@ -88,7 +88,7 @@ describe('Transport Factory', () => {

const transport = createTransport(config);

expect(HttpSseTransport).toHaveBeenCalledWith(9000, '127.0.0.1', '/sse');
expect(HttpSseTransport).toHaveBeenCalledWith(9000, '127.0.0.1', '/sse', 'disabled');
expect(transport).toBe(mockSseInstance);
});

Expand Down Expand Up @@ -123,7 +123,7 @@ describe('Transport Factory', () => {

const transport = createTransport(config);

expect(StreamableHttpTransport).toHaveBeenCalledWith(params.port, params.host, params.path);
expect(StreamableHttpTransport).toHaveBeenCalledWith(params.port, params.host, params.path, 'disabled');
expect(transport).toBe(mockInstance);

jest.clearAllMocks();
Expand All @@ -145,7 +145,7 @@ describe('Transport Factory', () => {

const transport1 = createTransport(config1);

expect(HttpSseTransport).toHaveBeenCalledWith(1, '::1', '/');
expect(HttpSseTransport).toHaveBeenCalledWith(1, '::1', '/', 'private-key');
expect(transport1).toBe(mockInstance1);

jest.clearAllMocks();
Expand All @@ -164,7 +164,7 @@ describe('Transport Factory', () => {

const transport2 = createTransport(config2);

expect(StreamableHttpTransport).toHaveBeenCalledWith(65535, '0.0.0.0', '/very/long/path/to/test/edge/cases');
expect(StreamableHttpTransport).toHaveBeenCalledWith(65535, '0.0.0.0', '/very/long/path/to/test/edge/cases', 'disabled');
expect(transport2).toBe(mockInstance2);
});
});
Expand Down
25 changes: 12 additions & 13 deletions packages/mcp-server/src/tests/server/transport/http-sse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ jest.mock('express', () => {
return express;
});

jest.mock('cors', () => jest.fn(() => 'cors-middleware'));
jest.mock('../../../server/transport/security.js', () => ({
createCorsMiddleware: jest.fn(() => 'cors-middleware'),
validateSecurityConfig: jest.fn()
}));

jest.mock('@modelcontextprotocol/sdk/server/sse.js', () => ({
SSEServerTransport: jest.fn()
Expand All @@ -27,7 +30,8 @@ describe('HttpSseTransport', () => {
let mockExpress: jest.MockedFunction<any>;
let mockApp: any;
let mockServer: any;
let mockCors: jest.MockedFunction<any>;
let mockCreateCorsMiddleware: jest.MockedFunction<any>;
let mockValidateSecurityConfig: jest.MockedFunction<any>;
let mockSSEServerTransport: jest.MockedFunction<any>;
let mockTransport: any;
let mockMcpServer: any;
Expand All @@ -38,11 +42,12 @@ describe('HttpSseTransport', () => {

// Import mocked modules
const expressModule = await import('express');
const corsModule = await import('cors');
const securityModule = await import('../../../server/transport/security.js');
const { SSEServerTransport } = await import('@modelcontextprotocol/sdk/server/sse.js');

mockExpress = expressModule.default as jest.MockedFunction<any>;
mockCors = corsModule.default as jest.MockedFunction<any>;
mockCreateCorsMiddleware = securityModule.createCorsMiddleware as jest.MockedFunction<any>;
mockValidateSecurityConfig = securityModule.validateSecurityConfig as jest.MockedFunction<any>;
mockSSEServerTransport = SSEServerTransport as jest.MockedFunction<any>;

// Setup mock objects
Expand Down Expand Up @@ -70,7 +75,7 @@ describe('HttpSseTransport', () => {
// Configure mocks
mockExpress.mockReturnValue(mockApp);
mockExpress.json = jest.fn().mockReturnValue('json-middleware');
mockCors.mockReturnValue('cors-middleware');
mockCreateCorsMiddleware.mockReturnValue('cors-middleware');
mockSSEServerTransport.mockImplementation(() => mockTransport);

// Import the class after mocks are set up
Expand All @@ -96,14 +101,8 @@ describe('HttpSseTransport', () => {

expect(mockExpress).toHaveBeenCalled();
expect(mockApp.use).toHaveBeenCalledWith('json-middleware');
expect(mockCors).toHaveBeenCalledWith({
origin: '*',
methods: ['GET', 'POST', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
exposedHeaders: ['Content-Type', 'Access-Control-Allow-Origin']
});
expect(mockApp.options).toHaveBeenCalledWith('*', 'cors-middleware');
expect(mockCreateCorsMiddleware).toHaveBeenCalled();
expect(mockApp.use).toHaveBeenCalledWith('cors-middleware');
expect(mockApp.get).toHaveBeenCalledWith('/health', expect.any(Function));
expect(mockApp.get).toHaveBeenCalledWith('/sse', expect.any(Function));
expect(mockApp.post).toHaveBeenCalledWith('/sse/message', expect.any(Function));
Expand Down
Loading