From 7dccd90980d9e4e5cbdc01539e96677770912cd6 Mon Sep 17 00:00:00 2001 From: Max Matveev Date: Tue, 13 Jan 2026 19:05:27 +0100 Subject: [PATCH] Add read-only mode (& fix SSE server mode). --- README.md | 3 +- package-lock.json | 2 +- package.json | 6 +-- smithery.yaml | 8 ++- src/config.ts | 9 ++-- src/index.ts | 75 ++++++++++++++++----------- src/server.ts | 9 ++-- src/tools.ts | 100 ++++++++++++++++++++---------------- test/read-only-mode.test.ts | 76 +++++++++++++++++++++++++++ 9 files changed, 202 insertions(+), 86 deletions(-) create mode 100644 test/read-only-mode.test.ts diff --git a/README.md b/README.md index 02331cb..0fad4be 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,7 @@ For a complete guide on creating and customizing Flex Queries, see the [IB Flex | Paper Trading | `IB_PAPER_TRADING` | `--ib-paper-trading` | | Auth Timeout | `IB_AUTH_TIMEOUT` | `--ib-auth-timeout` | | Flex Token | `IB_FLEX_TOKEN` | N/A | +| Read-only mode | `IB_READ_ONLY_MODE` | `--ib-read-only-mode` | ## Available MCP Tools @@ -195,7 +196,7 @@ For a complete guide on creating and customizing Flex Queries, see the [IB Flex | `get_account_info` | Retrieve account information and balances | | `get_positions` | Get current positions and P&L | | `get_market_data` | Real-time market data for symbols | -| `place_order` | Place market, limit, or stop orders | +| `place_order` | Place market, limit, or stop orders (only if read-only mode is disabled) | | `get_order_status` | Check order execution status | | `get_live_orders` | Get all live/open orders for monitoring | diff --git a/package-lock.json b/package-lock.json index 6fe0fef..ba5e8ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10186,4 +10186,4 @@ } } } -} +} \ No newline at end of file diff --git a/package.json b/package.json index 9b315b1..1c1800d 100644 --- a/package.json +++ b/package.json @@ -20,9 +20,9 @@ "build": "tsc && echo '#!/usr/bin/env node' | cat - dist/index.js > temp && mv temp dist/index.js && chmod +x dist/index.js", "prepublishOnly": "npm run build", "start": "node dist/index.js", - "start:http": "MCP_HTTP_SERVER=true node dist/index.js", + "start:http": "MCP_HTTP_SERVER=true node dist/index-http.js", "dev": "tsx src/index.ts", - "dev:http": "MCP_HTTP_SERVER=true tsx src/index.ts", + "dev:http": "MCP_HTTP_SERVER=true tsx src/index-http.ts", "watch": "tsc --watch", "clean": "rm -rf dist", "test": "vitest run", @@ -67,4 +67,4 @@ "typescript": "^5.3.0", "vitest": "^3.2.4" } -} +} \ No newline at end of file diff --git a/smithery.yaml b/smithery.yaml index 35d1728..be48310 100644 --- a/smithery.yaml +++ b/smithery.yaml @@ -10,6 +10,7 @@ startCommand: IB_USERNAME: config.IB_USERNAME, IB_PASSWORD_AUTH: config.IB_PASSWORD_AUTH, IB_AUTH_TIMEOUT: config.IB_AUTH_TIMEOUT, + IB_READ_ONLY_MODE: config.IB_READ_ONLY_MODE } }) configSchema: # JSON Schema defining the configuration options for the MCP. @@ -30,8 +31,13 @@ startCommand: type: number description: "Authentication timeout in milliseconds (default: 60000)" default: 60000 + IB_READ_ONLY_MODE: + type: boolean + description: "Enable read-only mode (default: false)" + default: false exampleConfig: IB_HEADLESS_MODE: true IB_USERNAME: "your_ib_username" IB_PASSWORD_AUTH: "your_ib_password" - IB_AUTH_TIMEOUT: 60000 \ No newline at end of file + IB_AUTH_TIMEOUT: 60000 + IB_READ_ONLY_MODE: false \ No newline at end of file diff --git a/src/config.ts b/src/config.ts index 988407c..12a77c2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -8,16 +8,19 @@ export const config = { IB_GATEWAY_PORT: parseInt(process.env.IB_GATEWAY_PORT || "5000"), IB_ACCOUNT: process.env.IB_ACCOUNT || "", IB_PASSWORD: process.env.IB_PASSWORD || "", - + // Headless authentication configuration IB_USERNAME: process.env.IB_USERNAME || "", IB_PASSWORD_AUTH: process.env.IB_PASSWORD_AUTH || process.env.IB_PASSWORD || "", IB_AUTH_TIMEOUT: parseInt(process.env.IB_AUTH_TIMEOUT || "60000"), IB_HEADLESS_MODE: process.env.IB_HEADLESS_MODE === "true", - + // Paper trading configuration IB_PAPER_TRADING: process.env.IB_PAPER_TRADING === "true", - + + // Read-only mode configuration + IB_READ_ONLY_MODE: process.env.IB_READ_ONLY_MODE === "true", + // Flex Query configuration IB_FLEX_TOKEN: process.env.IB_FLEX_TOKEN || "", diff --git a/src/index.ts b/src/index.ts index 63d6f88..9f82fe3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,19 +12,19 @@ import { Logger } from "./logger.js"; function parseArgs(): z.infer { const args: any = {}; const argv = process.argv.slice(2); - + // Log raw arguments for debugging Logger.info(`🔍 Raw command line arguments: ${JSON.stringify(argv)}`); - + for (let i = 0; i < argv.length; i++) { const arg = argv[i]; - + if (arg.startsWith('--')) { const key = arg.slice(2); const nextArg = argv[i + 1]; - + Logger.debug(`🔍 Processing flag: ${key}, nextArg: ${nextArg}`); - + switch (key) { case 'ib-username': args.IB_USERNAME = nextArg; @@ -63,15 +63,25 @@ function parseArgs(): z.infer { args.IB_PAPER_TRADING = true; Logger.debug(`🔍 Set IB_PAPER_TRADING to: true (flag only)`); } + case 'ib-read-only-mode': + // Support both --ib-read-only-mode (boolean flag) and --ib-read-only-mode=true/false + if (nextArg && !nextArg.startsWith('--')) { + args.IB_READ_ONLY_MODE = nextArg.toLowerCase() === 'true'; + Logger.debug(`🔍 Set IB_READ_ONLY_MODE to: ${nextArg.toLowerCase() === 'true'} (from arg: ${nextArg})`); + i++; + } else { + args.IB_READ_ONLY_MODE = true; + Logger.debug(`🔍 Set IB_READ_ONLY_MODE to: true (flag only)`); + } break; } } else if (arg.includes('=')) { const [key, value] = arg.split('=', 2); const cleanKey = key.startsWith('--') ? key.slice(2) : key; - + Logger.debug(`🔍 Processing key=value: ${cleanKey}=${value}`); - + switch (cleanKey) { case 'ib-username': args.IB_USERNAME = value; @@ -94,11 +104,15 @@ function parseArgs(): z.infer { args.IB_PAPER_TRADING = value.toLowerCase() === 'true'; Logger.debug(`🔍 Set IB_PAPER_TRADING to: ${value.toLowerCase() === 'true'} (from value: ${value})`); break; + case 'ib-read-only-mode': + args.IB_READ_ONLY_MODE = value.toLowerCase() === 'true'; + Logger.debug(`🔍 Set IB_READ_ONLY_MODE to: ${value.toLowerCase() === 'true'} (from value: ${value})`); + break; } } } - + Logger.info(`🔍 Parsed args: ${JSON.stringify(args, null, 2)}`); return args; } @@ -110,10 +124,12 @@ export const configSchema = z.object({ IB_PASSWORD_AUTH: z.string().optional(), IB_AUTH_TIMEOUT: z.number().optional(), IB_HEADLESS_MODE: z.boolean().optional(), - + // Paper trading configuration IB_PAPER_TRADING: z.boolean().optional(), + // Read-only mode configuration + IB_READ_ONLY_MODE: z.boolean().optional(), }); // Global gateway manager instance @@ -123,12 +139,12 @@ let gatewayManager: IBGatewayManager | null = null; async function initializeGateway(ibClient?: IBClient) { if (!gatewayManager) { gatewayManager = new IBGatewayManager(); - + try { Logger.info('⚡ Quick Gateway initialization for MCP plugin...'); await gatewayManager.quickStartGateway(); Logger.info('✅ Gateway initialization completed (background startup if needed)'); - + // Update client port if provided if (ibClient) { ibClient.updatePort(gatewayManager.getCurrentPort()); @@ -147,7 +163,7 @@ async function cleanupAll(signal?: string) { if (signal) { Logger.info(`🛑 Received ${signal}, cleaning up temp files only...`); } - + // Only cleanup temp files - don't shutdown gateway (leave it running for next npx process) if (gatewayManager) { try { @@ -169,7 +185,7 @@ const gracefulShutdown = (signal: string) => { return; // Silent return to avoid log spam } isShuttingDown = true; - + // Don't use async/await here to avoid potential hanging cleanupAll(signal).finally(() => { process.exit(0); @@ -199,11 +215,11 @@ process.on('unhandledRejection', (reason, promise) => { // Check if this module is being run directly (for stdio compatibility) // This handles direct execution, npx, and bin script execution -const isMainModule = import.meta.url === `file://${process.argv[1]}` || - process.argv[1]?.endsWith('index.js') || - process.argv[1]?.endsWith('dist/index.js') || - process.argv[1]?.endsWith('ib-mcp') || - process.argv[1]?.includes('/.bin/ib-mcp'); +const isMainModule = import.meta.url === `file://${process.argv[1]}` || + process.argv[1]?.endsWith('index.js') || + process.argv[1]?.endsWith('dist/index.js') || + process.argv[1]?.endsWith('ib-mcp') || + process.argv[1]?.includes('/.bin/ib-mcp'); function IBMCP({ config: userConfig }: { config: z.infer }) { // Merge user config with environment config @@ -250,20 +266,20 @@ if (isMainModule) { // Suppress known problematic outputs that might interfere with JSON-RPC process.env.SUPPRESS_LOAD_MESSAGE = '1'; process.env.NO_UPDATE_NOTIFIER = '1'; - + // Log environment info for debugging MCP plugin issues Logger.info(`🔍 Environment: PWD=${process.cwd()}, NODE_ENV=${process.env.NODE_ENV || 'undefined'}`); Logger.info(`🔍 Process: npm_execpath=${process.env.npm_execpath || 'undefined'}, npm_command=${process.env.npm_command || 'undefined'}`); - + // Check if we're running in npx/MCP plugin context const isNpx = process.env.npm_execpath?.includes('npx') || process.cwd().includes('.npm'); if (isNpx) { Logger.info('📦 Detected npx execution - likely running via MCP community plugin'); } - + // Log startup information Logger.logStartup(); - + // Parse command line arguments and merge with environment variables // Priority: args > env > defaults const argsConfig = parseArgs(); @@ -272,39 +288,40 @@ if (isMainModule) { IB_PASSWORD_AUTH: process.env.IB_PASSWORD_AUTH || process.env.IB_PASSWORD, IB_AUTH_TIMEOUT: process.env.IB_AUTH_TIMEOUT ? parseInt(process.env.IB_AUTH_TIMEOUT) : undefined, IB_HEADLESS_MODE: process.env.IB_HEADLESS_MODE === 'true', + IB_READ_ONLY_MODE: process.env.IB_READ_ONLY_MODE === 'true', }; - + // Log environment config for debugging const logEnvConfig = { ...envConfig }; if (logEnvConfig.IB_PASSWORD_AUTH) logEnvConfig.IB_PASSWORD_AUTH = '[REDACTED]'; Logger.info(`🔍 Environment config: ${JSON.stringify(logEnvConfig, null, 2)}`); - + // Merge configs with priority: args > env > defaults const finalConfig = { ...envConfig, ...argsConfig, }; - + // Log final config before cleanup const logFinalConfig = { ...finalConfig }; if (logFinalConfig.IB_PASSWORD_AUTH) logFinalConfig.IB_PASSWORD_AUTH = '[REDACTED]'; Logger.info(`🔍 Final config before cleanup: ${JSON.stringify(logFinalConfig, null, 2)}`); - + // Remove undefined values Object.keys(finalConfig).forEach(key => { if (finalConfig[key as keyof typeof finalConfig] === undefined) { delete finalConfig[key as keyof typeof finalConfig]; } }); - + // Log final config after cleanup const logFinalConfigAfter = { ...finalConfig }; if (logFinalConfigAfter.IB_PASSWORD_AUTH) logFinalConfigAfter.IB_PASSWORD_AUTH = '[REDACTED]'; Logger.info(`🔍 Final config after cleanup: ${JSON.stringify(logFinalConfigAfter, null, 2)}`); - + const stdioTransport = new StdioServerTransport(); - const server = IBMCP({config: finalConfig}) + const server = IBMCP({ config: finalConfig }) server.connect(stdioTransport); } diff --git a/src/server.ts b/src/server.ts index 6fd17ad..9ae5278 100644 --- a/src/server.ts +++ b/src/server.ts @@ -12,9 +12,12 @@ export const configSchema = z.object({ IB_PASSWORD_AUTH: z.string().optional(), IB_AUTH_TIMEOUT: z.number().optional(), IB_HEADLESS_MODE: z.boolean().optional(), - + // Paper trading configuration IB_PAPER_TRADING: z.boolean().optional(), + + // Read-only mode configuration + IB_READ_ONLY_MODE: z.boolean().optional(), }); // Global gateway manager instance @@ -24,12 +27,12 @@ let gatewayManager: IBGatewayManager | null = null; async function initializeGateway(ibClient?: IBClient) { if (!gatewayManager) { gatewayManager = new IBGatewayManager(); - + try { Logger.info('⚡ Quick Gateway initialization for MCP plugin...'); await gatewayManager.quickStartGateway(); Logger.info('✅ Gateway initialization completed (background startup if needed)'); - + // Update client port if provided if (ibClient) { ibClient.updatePort(gatewayManager.getCurrentPort()); diff --git a/src/tools.ts b/src/tools.ts index 56d890f..8e9aab3 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -2,9 +2,9 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { IBClient } from "./ib-client.js"; import { IBGatewayManager } from "./gateway-manager.js"; import { ToolHandlers, ToolHandlerContext } from "./tool-handlers.js"; -import { +import { AuthenticateZodShape, - GetAccountInfoZodShape, + GetAccountInfoZodShape, GetPositionsZodShape, GetMarketDataZodShape, PlaceOrderZodShape, @@ -21,9 +21,9 @@ import { } from "./tool-definitions.js"; export function registerTools( - server: McpServer, - ibClient: IBClient, - gatewayManager?: IBGatewayManager, + server: McpServer, + ibClient: IBClient, + gatewayManager?: IBGatewayManager, userConfig?: any ) { // Create handler context @@ -56,7 +56,7 @@ export function registerTools( // Register get_positions tool server.tool( - "get_positions", + "get_positions", "Get current positions. Usage: `{}` or `{ \"accountId\": \"\" }`.", GetPositionsZodShape, async (args) => await handlers.getPositions(args) @@ -70,17 +70,19 @@ export function registerTools( async (args) => await handlers.getMarketData(args) ); - // Register place_order tool - server.tool( - "place_order", - "Place a trading order. Examples:\n" + - "- Market buy: `{ \"accountId\":\"abc\",\"symbol\":\"AAPL\",\"action\":\"BUY\",\"orderType\":\"MKT\",\"quantity\":1 }`\n" + - "- Limit sell: `{ \"accountId\":\"abc\",\"symbol\":\"AAPL\",\"action\":\"SELL\",\"orderType\":\"LMT\",\"quantity\":1,\"price\":185.5 }`\n" + - "- Stop sell: `{ \"accountId\":\"abc\",\"symbol\":\"AAPL\",\"action\":\"SELL\",\"orderType\":\"STP\",\"quantity\":1,\"stopPrice\":180 }`\n" + - "- Suppress confirmations: `{ \"accountId\":\"abc\",\"symbol\":\"AAPL\",\"action\":\"BUY\",\"orderType\":\"MKT\",\"quantity\":1,\"suppressConfirmations\":true }`", - PlaceOrderZodShape, - async (args) => await handlers.placeOrder(args) - ); + // Register place_order tool (skip if in read-only mode) + if (!userConfig?.IB_READ_ONLY_MODE) { + server.tool( + "place_order", + "Place a trading order. Examples:\n" + + "- Market buy: `{ \"accountId\":\"abc\",\"symbol\":\"AAPL\",\"action\":\"BUY\",\"orderType\":\"MKT\",\"quantity\":1 }`\n" + + "- Limit sell: `{ \"accountId\":\"abc\",\"symbol\":\"AAPL\",\"action\":\"SELL\",\"orderType\":\"LMT\",\"quantity\":1,\"price\":185.5 }`\n" + + "- Stop sell: `{ \"accountId\":\"abc\",\"symbol\":\"AAPL\",\"action\":\"SELL\",\"orderType\":\"STP\",\"quantity\":1,\"stopPrice\":180 }`\n" + + "- Suppress confirmations: `{ \"accountId\":\"abc\",\"symbol\":\"AAPL\",\"action\":\"BUY\",\"orderType\":\"MKT\",\"quantity\":1,\"suppressConfirmations\":true }`", + PlaceOrderZodShape, + async (args) => await handlers.placeOrder(args) + ); + } // Register get_order_status tool server.tool( @@ -99,13 +101,15 @@ export function registerTools( async (args) => await handlers.getLiveOrders(args) ); - // Register confirm_order tool - server.tool( - "confirm_order", - "Manually confirm an order that requires confirmation. Usage: `{ \"replyId\": \"742a95a7-55f6-4d67-861b-2fd3e2b61e3c\", \"messageIds\": [\"o10151\", \"o10153\"] }`.", - ConfirmOrderZodShape, - async (args) => await handlers.confirmOrder(args) - ); + // Register confirm_order tool (skip if in read-only mode) + if (!userConfig?.IB_READ_ONLY_MODE) { + server.tool( + "confirm_order", + "Manually confirm an order that requires confirmation. Usage: `{ \"replyId\": \"742a95a7-55f6-4d67-861b-2fd3e2b61e3c\", \"messageIds\": [\"o10151\", \"o10153\"] }`.", + ConfirmOrderZodShape, + async (args) => await handlers.confirmOrder(args) + ); + } // Register get_alerts tool server.tool( @@ -115,29 +119,35 @@ export function registerTools( async (args) => await handlers.getAlerts(args) ); - // Register create_alert tool - server.tool( - "create_alert", - "Create a new trading alert. Usage: `{ \"accountId\": \"\", \"alertRequest\": { \"alertName\": \"Price Alert\", \"conditions\": [{ \"conidex\": \"265598\", \"type\": \"price\", \"operator\": \">\", \"triggerMethod\": \"last\", \"value\": \"150\" }] } }`.", - CreateAlertZodShape, - async (args) => await handlers.createAlert(args) - ); + // Register create_alert tool (skip if in read-only mode) + if (!userConfig?.IB_READ_ONLY_MODE) { + server.tool( + "create_alert", + "Create a new trading alert. Usage: `{ \"accountId\": \"\", \"alertRequest\": { \"alertName\": \"Price Alert\", \"conditions\": [{ \"conidex\": \"265598\", \"type\": \"price\", \"operator\": \">\", \"triggerMethod\": \"last\", \"value\": \"150\" }] } }`.", + CreateAlertZodShape, + async (args) => await handlers.createAlert(args) + ); + } - // Register activate_alert tool - server.tool( - "activate_alert", - "Activate a previously created alert. Usage: `{ \"accountId\": \"\", \"alertId\": \"\" }`.", - ActivateAlertZodShape, - async (args) => await handlers.activateAlert(args) - ); + // Register activate_alert tool (skip if in read-only mode) + if (!userConfig?.IB_READ_ONLY_MODE) { + server.tool( + "activate_alert", + "Activate a previously created alert. Usage: `{ \"accountId\": \"\", \"alertId\": \"\" }`.", + ActivateAlertZodShape, + async (args) => await handlers.activateAlert(args) + ); + } - // Register delete_alert tool - server.tool( - "delete_alert", - "Delete an alert. Usage: `{ \"accountId\": \"\", \"alertId\": \"\" }`.", - DeleteAlertZodShape, - async (args) => await handlers.deleteAlert(args) - ); + // Register delete_alert tool (skip if in read-only mode) + if (!userConfig?.IB_READ_ONLY_MODE) { + server.tool( + "delete_alert", + "Delete an alert. Usage: `{ \"accountId\": \"\", \"alertId\": \"\" }`.", + DeleteAlertZodShape, + async (args) => await handlers.deleteAlert(args) + ); + } // Register Flex Query tools (only if token is configured) if (userConfig?.IB_FLEX_TOKEN) { diff --git a/test/read-only-mode.test.ts b/test/read-only-mode.test.ts new file mode 100644 index 0000000..934fb2e --- /dev/null +++ b/test/read-only-mode.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { registerTools } from '../src/tools.js'; +import { IBClient } from '../src/ib-client.js'; +import { IBGatewayManager } from '../src/gateway-manager.js'; + +// Mock dependencies +vi.mock('../src/ib-client.js'); +vi.mock('../src/gateway-manager.js'); + +describe('Read-Only Mode Tool Registration', () => { + let mockMcpServer: McpServer; + let mockIBClient: IBClient; + let mockGatewayManager: IBGatewayManager; + let registeredTools: string[] = []; + + beforeEach(() => { + registeredTools = []; + mockMcpServer = { + tool: vi.fn().mockImplementation((name, ...args) => { + registeredTools.push(name); + return mockMcpServer; + }), + } as unknown as McpServer; + + mockIBClient = {} as IBClient; + mockGatewayManager = {} as IBGatewayManager; + }); + + it('should register ALL tools when read-only mode is DISABLED (default)', () => { + const config = {}; // No IB_READ_ONLY_MODE + registerTools(mockMcpServer, mockIBClient, mockGatewayManager, config); + + // Verify write tools are registered + expect(registeredTools).toContain('place_order'); + expect(registeredTools).toContain('confirm_order'); + expect(registeredTools).toContain('create_alert'); + expect(registeredTools).toContain('activate_alert'); + expect(registeredTools).toContain('delete_alert'); + + // Verify read tools are also registered + expect(registeredTools).toContain('get_positions'); + expect(registeredTools).toContain('get_market_data'); + }); + + it('should register ALL tools when read-only mode is EXPLICITLY FALSE', () => { + const config = { IB_READ_ONLY_MODE: false }; + registerTools(mockMcpServer, mockIBClient, mockGatewayManager, config); + + expect(registeredTools).toContain('place_order'); + expect(registeredTools).toContain('confirm_order'); + expect(registeredTools).toContain('create_alert'); + expect(registeredTools).toContain('activate_alert'); + expect(registeredTools).toContain('delete_alert'); + }); + + it('should NOT register modification tools when read-only mode is ENABLED', () => { + const config = { IB_READ_ONLY_MODE: true }; + registerTools(mockMcpServer, mockIBClient, mockGatewayManager, config); + + // Verify write tools are NOT registered + expect(registeredTools).not.toContain('place_order'); + expect(registeredTools).not.toContain('confirm_order'); + expect(registeredTools).not.toContain('create_alert'); + expect(registeredTools).not.toContain('activate_alert'); + expect(registeredTools).not.toContain('delete_alert'); + + // Verify read tools ARE registered + expect(registeredTools).toContain('get_positions'); + expect(registeredTools).toContain('get_market_data'); + expect(registeredTools).toContain('get_account_info'); + expect(registeredTools).toContain('get_live_orders'); + expect(registeredTools).toContain('get_order_status'); + expect(registeredTools).toContain('get_alerts'); + }); +});