Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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();
};
Comment thread
besated marked this conversation as resolved.
}

/**
* 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);
}
}
Comment thread
besated marked this conversation as resolved.
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
4 changes: 3 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';

export type TransportMode = 'stdio' | 'streamable-http' | 'http-sse';
export type WalletMode = 'disabled' | 'private-key';
Comment thread
besated marked this conversation as resolved.
Outdated

export interface McpTransport {
start(server: McpServer): Promise<void>;
stop(): Promise<void>;
Expand All @@ -9,7 +11,7 @@ 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
Expand Down
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
Loading