This is page 1 of 6. Use http://codebase.md/sammcj/bybit-mcp?page={x} to view the full context. # Directory Structure ``` ├── .env.example ├── .gitignore ├── client │ ├── .env.example │ ├── .gitignore │ ├── package.json │ ├── pnpm-lock.yaml │ ├── README.md │ ├── src │ │ ├── cli.ts │ │ ├── client.ts │ │ ├── config.ts │ │ ├── env.ts │ │ ├── index.ts │ │ └── launch.ts │ └── tsconfig.json ├── DEV_PLAN.md ├── docs │ └── HTTP_SERVER.md ├── eslint.config.js ├── jest.config.js ├── LICENSE ├── package-lock.json ├── package.json ├── pnpm-lock.yaml ├── README.md ├── specs │ ├── bybit │ │ ├── bybit-api-v5-openapi.yaml │ │ └── bybit-api-v5-postman-collection.json │ ├── mcp │ │ ├── mcp-schema.json │ │ └── mcp-schema.ts │ └── README.md ├── src │ ├── __tests__ │ │ ├── GetMLRSI.test.ts │ │ ├── integration.test.ts │ │ ├── test-setup.ts │ │ └── tools.test.ts │ ├── constants.ts │ ├── env.ts │ ├── httpServer.ts │ ├── index.ts │ ├── tools │ │ ├── BaseTool.ts │ │ ├── GetInstrumentInfo.ts │ │ ├── GetKline.ts │ │ ├── GetMarketInfo.ts │ │ ├── GetMarketStructure.ts │ │ ├── GetMLRSI.ts │ │ ├── GetOrderBlocks.ts │ │ ├── GetOrderbook.ts │ │ ├── GetOrderHistory.ts │ │ ├── GetPositions.ts │ │ ├── GetTicker.ts │ │ ├── GetTrades.ts │ │ └── GetWalletBalance.ts │ └── utils │ ├── knnAlgorithm.ts │ ├── mathUtils.ts │ ├── toolLoader.ts │ └── volumeAnalysis.ts ├── tsconfig.json └── webui ├── .dockerignore ├── .env.example ├── build-docker.sh ├── docker-compose.yml ├── docker-entrypoint.sh ├── docker-healthcheck.sh ├── DOCKER.md ├── Dockerfile ├── index.html ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── public │ ├── favicon.svg │ └── inter.woff2 ├── README.md ├── screenshot.png ├── src │ ├── assets │ │ └── fonts │ │ └── fonts.css │ ├── components │ │ ├── AgentDashboard.ts │ │ ├── chat │ │ │ ├── DataCard.ts │ │ │ └── MessageRenderer.ts │ │ ├── ChatApp.ts │ │ ├── DataVerificationPanel.ts │ │ ├── DebugConsole.ts │ │ └── ToolsManager.ts │ ├── main.ts │ ├── services │ │ ├── agentConfig.ts │ │ ├── agentMemory.ts │ │ ├── aiClient.ts │ │ ├── citationProcessor.ts │ │ ├── citationStore.ts │ │ ├── configService.ts │ │ ├── logService.ts │ │ ├── mcpClient.ts │ │ ├── multiStepAgent.ts │ │ ├── performanceOptimiser.ts │ │ └── systemPrompt.ts │ ├── styles │ │ ├── agent-dashboard.css │ │ ├── base.css │ │ ├── citations.css │ │ ├── components.css │ │ ├── data-cards.css │ │ ├── main.css │ │ ├── processing.css │ │ ├── variables.css │ │ └── verification-panel.css │ ├── types │ │ ├── agent.ts │ │ ├── ai.ts │ │ ├── citation.ts │ │ ├── mcp.ts │ │ └── workflow.ts │ └── utils │ ├── dataDetection.ts │ └── formatters.ts ├── tsconfig.json └── vite.config.ts ``` # Files -------------------------------------------------------------------------------- /client/.env.example: -------------------------------------------------------------------------------- ``` # Ollama configuration OLLAMA_HOST=http://localhost:11434 DEFAULT_MODEL=qwen3-30b-a3b-ud-nothink-128k:q4_k_xl # Debug mode DEBUG=false ``` -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- ``` # Ollama configuration OLLAMA_HOST=http://localhost:11434 DEFAULT_MODEL=qwen3-30b-a3b-ud-nothink-128k:q4_k_xl # Bybit API Configuration BYBIT_API_KEY=your_api_key_here BYBIT_API_SECRET=your_api_secret_here BYBIT_USE_TESTNET=false # Debug Mode DEBUG=false ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` # Dependencies node_modules/ .pnpm-store/ # Build output build/ dist/ # Environment variables .env .env.* # IDE files .vscode/ .idea/ *.swp *.swo # Logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* # Operating System .DS_Store Thumbs.db # Test coverage coverage/ # TypeScript *.tsbuildinfo !.env.example # pinescripts # DEV_PLAN.md ``` -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- ``` # Build output build/ dist/ # Dependencies node_modules/ # Logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* # Editor directories and files .idea/ .vscode/ *.swp *.swo *~ # OS generated files .DS_Store .DS_Store? ._* .Spotlight-V100 .Trashes ehthumbs.db Thumbs.db # Environment variables .env !.env.example # Config files .config/ # Cache files .install-check ``` -------------------------------------------------------------------------------- /webui/.env.example: -------------------------------------------------------------------------------- ``` # ============================================================================== # Bybit MCP WebUI - Environment Variables # ============================================================================== # Copy this file to .env and configure for your environment # ============================================================================== # Ollama Configuration # URL of your Ollama instance (supports custom domains) OLLAMA_HOST=https://ollama.my.network # MCP Server Configuration # Leave empty to use current origin (recommended for Docker/Traefik) # Set to specific URL only if MCP server is on different domain MCP_ENDPOINT= # Development overrides (only used in development mode) # MCP_ENDPOINT=http://localhost:8080 # OLLAMA_HOST=http://localhost:11434 ``` -------------------------------------------------------------------------------- /webui/.dockerignore: -------------------------------------------------------------------------------- ``` # ============================================================================== # Docker Ignore File for Bybit MCP WebUI # ============================================================================== # This file excludes unnecessary files from the Docker build context # to reduce build time and final image size. # ============================================================================== # Development files .env .env.local .env.development .env.test .env.production # Node.js node_modules/ npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* # Build outputs (will be copied from builder stage) dist/ build/ .next/ out/ # IDE and editor files .vscode/ .idea/ *.swp *.swo *~ # OS generated files .DS_Store .DS_Store? ._* .Spotlight-V100 .Trashes ehthumbs.db Thumbs.db # Git .git/ .gitignore .gitattributes # Documentation README.md CHANGELOG.md LICENSE *.md # Testing coverage/ .nyc_output/ .jest/ test-results/ playwright-report/ # Logs logs/ *.log # Runtime data pids/ *.pid *.seed *.pid.lock # Coverage directory used by tools like istanbul coverage/ *.lcov # ESLint cache .eslintcache # TypeScript cache *.tsbuildinfo # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Microbundle cache .rpt2_cache/ .rts2_cache_cjs/ .rts2_cache_es/ .rts2_cache_umd/ # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # parcel-bundler cache (https://parceljs.org/) .cache .parcel-cache # Storybook build outputs .out .storybook-out # Temporary folders tmp/ temp/ # Docker files (avoid recursive copying) Dockerfile* .dockerignore docker-compose*.yml # Keep entrypoint scripts (they're copied explicitly) # docker-entrypoint.sh # docker-healthcheck.sh # CI/CD .github/ .gitlab-ci.yml .travis.yml .circleci/ # Package manager lock files (will be copied explicitly) # Commented out as we need these for dependency installation # package-lock.json # yarn.lock # pnpm-lock.yaml # Development tools .prettierrc* .eslintrc* tsconfig.json vite.config.ts jest.config.* vitest.config.* # Backup files *.bak *.backup *.old # Local development .local/ .cache/ # Webpack .webpack/ # Vite .vite/ # Turbo .turbo/ ``` -------------------------------------------------------------------------------- /specs/README.md: -------------------------------------------------------------------------------- ```markdown NOTE: Files in this directory are used for reference only and not by the client or server. ``` -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- ```markdown # Bybit MCP Client A TypeScript client for interacting with Ollama LLMs and the bybit-mcp server. This client provides a command-line interface for easy access to both Ollama's language models and bybit-mcp's trading tools. ## Quick Start 1. Clone the repository and navigate to the client directory: ```bash cd client ``` 2. Copy the example environment file and configure as needed: ```bash cp .env.example .env ``` 3. Start the interactive chat: ```bash pnpm start ``` This will automatically: - Check and install dependencies if needed - Validate the environment configuration - Verify Ollama connection - Start the integrated chat interface ## Environment Configuration The client uses environment variables for configuration. You can set these in your `.env` file: ```bash # Ollama configuration OLLAMA_HOST=http://localhost:11434 DEFAULT_MODEL=qwen3-30b-a3b-ud-nothink-128k:q4_k_xl # Debug mode DEBUG=false ``` For the bybit-mcp server configuration (when using integrated mode), the following environment variables are required: ```bash # Bybit API Configuration (required for integrated mode) BYBIT_API_KEY=your_api_key_here BYBIT_API_SECRET=your_api_secret_here BYBIT_USE_TESTNET=true # optional, defaults to false ``` Environment variables take precedence over stored configuration. ## Installation ### Locally ```bash pnpm i ``` ### NPM **Note: This will only be available if I decide to publish the package to npm.** ```bash pnpm install @bybit-mcp/client ``` ## Usage The client provides several commands for interacting with Ollama models and bybit-mcp tools. It can run in two modes: 1. Integrated mode: Automatically manages the bybit-mcp server 2. Standalone mode: Connects to an externally managed server ### Quick Launch The fastest way to start chatting: ```bash pnpm start # or pnpm chat # or node build/launch.js ``` ### Global Options These options can be used with any command: ```bash # Run in integrated mode (auto-manages server) bybit-mcp-client --integrated [command] # Enable debug logging bybit-mcp-client --debug [command] # Use testnet (for integrated mode) bybit-mcp-client --testnet [command] ``` ### List Available Models View all available Ollama models: ```bash bybit-mcp-client models ``` ### List Available Tools View all available bybit-mcp tools: ```bash # Integrated mode (auto-manages server) bybit-mcp-client --integrated tools # Standalone mode (external server) bybit-mcp-client tools "node /path/to/bybit-mcp/build/index.js" ``` ### Chat with a Model Start an interactive chat session with an Ollama model: ```bash # Use default model bybit-mcp-client chat # Specify a model bybit-mcp-client chat codellama # Add a system message for context bybit-mcp-client chat qwen3-30b-a3b-ud-nothink-128k:q4_k_xl --system "You are a helpful assistant" ``` ### Call a Tool Execute a bybit-mcp tool with arguments: ```bash # Integrated mode bybit-mcp-client --integrated tool get_ticker symbol=BTCUSDT # Standalone mode bybit-mcp-client tool "node /path/to/bybit-mcp/build/index.js" get_ticker symbol=BTCUSDT # With optional category parameter bybit-mcp-client --integrated tool get_ticker symbol=BTCUSDT category=linear ``` ## API Usage You can also use the client programmatically in your TypeScript/JavaScript applications: ```typescript import { BybitMcpClient, Config } from '@bybit-mcp/client'; const config = new Config(); const client = new BybitMcpClient(config); // Using integrated mode (auto-manages server) await client.startIntegratedServer(); // Or connect to external server // await client.connectToServer('node /path/to/bybit-mcp/build/index.js'); // Chat with a model const messages = [ { role: 'user', content: 'Hello, how are you?' } ]; const response = await client.chat('qwen3-30b-a3b-ud-nothink-128k:q4_k_xl', messages); console.log(response); // Call a bybit-mcp tool const result = await client.callTool('get_ticker', { symbol: 'BTCUSDT' }); console.log(result); // Clean up (this will also stop the integrated server if running) await client.close(); ``` ## Features - Quick start script for instant setup - Environment-based configuration - Integrated mode for automatic server management - Interactive chat with Ollama models - Streaming chat responses - Easy access to bybit-mcp trading tools - Configurable settings with persistent storage - Debug logging support - TypeScript support - Command-line interface - Programmatic API ## Requirements - Node.js >= 20 - Ollama running locally or remotely - bybit-mcp server (automatically managed in integrated mode) ## Available Tools For a complete list of available trading tools and their parameters, see the [main README](../README.md#tool-documentation). ``` -------------------------------------------------------------------------------- /webui/README.md: -------------------------------------------------------------------------------- ```markdown # Bybit MCP WebUI A modern, lightweight web interface for the Bybit MCP (Model Context Protocol) server with AI-powered chat capabilities.  ## Features - 🤖 **AI-Powered Chat**: Interactive chat interface with OpenAI-compatible API support (Ollama, etc.) - 📊 **Real-Time Charts**: Interactive candlestick charts with volume using Lightweight Charts - 🔧 **MCP Integration**: Direct access to all 12 Bybit MCP tools including advanced technical analysis - 🧠 **Advanced Analysis**: ML-enhanced RSI, Order Block detection, and Market Structure analysis - 🎨 **Modern UI**: Clean, responsive design with dark/light theme support - ⚡ **Fast & Lightweight**: Built with Vite + Vanilla TypeScript for optimal performance ## Technology Stack - pnpm: Package manager - **Frontend**: Vite + Vanilla TypeScript - **Charts**: Lightweight Charts (TradingView) + Chart.js - **Styling**: CSS3 with CSS Variables - **AI Integration**: OpenAI-compatible API client with streaming support - **MCP Integration**: Direct HTTP client with TypeScript types ## Quick Start ### Prerequisites - Node.js 22+ - Running Bybit MCP server (see main project README) - AI service (Ollama recommended) running locally or remotely ### Installation 1. Install dependencies: ```bash pnpm install ``` 2. Start MCP server (in terminal 1): ```bash cd .. && node build/httpServer.js ``` 3. Start WebUI development server (in terminal 2): ```bash pnpm dev ``` 4. Open your browser to `http://localhost:3000` **Alternative**: Use the experimental concurrent setup: ```bash pnpm dev:full ``` ### Production Build ```bash pnpm build pnpm preview ``` ## Configuration The WebUI can be configured through the settings modal (⚙️ icon) or by modifying the default configuration in the code. ### AI Configuration - **Endpoint**: URL of your AI service (default: `http://localhost:11434`) - **Model**: Model name to use (default: `qwen3-30b-a3b-ud-nothink-128k:q4_k_xl`) - **Temperature**: Response creativity (0.0 - 1.0) - **Max Tokens**: Maximum response length ### MCP Configuration - **Endpoint**: URL of your Bybit MCP server (default: `http://localhost:8080`) - **Timeout**: Request timeout in milliseconds ## Available Views ### 💬 AI Chat - Interactive chat with AI assistant - Streaming responses - Example queries for quick start - Connection status indicators ### 📈 Charts - Real-time price charts - Volume indicators - Multiple timeframes - Symbol selection ### 🔧 MCP Tools - Direct access to all MCP tools - Tool parameter configuration - Response formatting ### 🧠 Analysis - ML-enhanced RSI visualisation - Order block overlays - Market structure analysis - Trading recommendations ## MCP Tools Integration The WebUI provides access to all Bybit MCP server tools: ### Market Data - `get_ticker` - Real-time price data - `get_kline` - Candlestick/OHLCV data - `get_orderbook` - Market depth data - `get_trades` - Recent trades - `get_market_info` - Market information - `get_instrument_info` - Instrument details ### Account Data - `get_wallet_balance` - Wallet balances - `get_positions` - Current positions - `get_order_history` - Order history ### Advanced Analysis - `get_ml_rsi` - ML-enhanced RSI with adaptive thresholds - `get_order_blocks` - Institutional order accumulation zones - `get_market_structure` - Comprehensive market analysis ## Keyboard Shortcuts - `Ctrl/Cmd + K` - Focus chat input - `Enter` - Send message - `Shift + Enter` - New line in chat - `Escape` - Close modals ## Themes The WebUI supports three theme modes: - **Light**: Clean, bright interface - **Dark**: Easy on the eyes for extended use - **Auto**: Follows system preference ## Browser Support - Chrome 90+ - Firefox 88+ - Safari 14+ - Edge 90+ ## Development ### Project Structure ``` src/ ├── components/ # UI components │ ├── ChatApp.ts # Main chat interface │ └── ChartComponent.ts # Chart wrapper ├── services/ # Core services │ ├── aiClient.ts # AI API integration │ ├── mcpClient.ts # MCP server client │ └── configService.ts # Configuration management ├── styles/ # CSS architecture │ ├── variables.css # CSS custom properties │ ├── base.css # Base styles and reset │ ├── components.css # Component styles │ └── main.css # Main stylesheet ├── types/ # TypeScript definitions │ ├── ai.ts # AI service types │ ├── mcp.ts # MCP protocol types │ └── charts.ts # Chart data types ├── utils/ # Utility functions │ └── formatters.ts # Data formatting helpers └── main.ts # Application entry point ``` ### Adding New Features 1. **New MCP Tool**: Add types to `src/types/mcp.ts` and update the client 2. **New Chart Type**: Extend `ChartComponent.ts` with new series types 3. **New AI Feature**: Update `aiClient.ts` and chat interface 4. **New Theme**: Modify CSS variables in `src/styles/variables.css` ### Code Style - Use TypeScript strict mode - Follow functional programming principles where possible - Implement comprehensive error handling - Add JSDoc comments for public APIs - Use consistent naming conventions ## Performance The WebUI is optimised for performance: - **Minimal Bundle**: Vanilla TypeScript with selective imports - **Efficient Charts**: Lightweight Charts for optimal rendering - **Smart Caching**: Configuration and data caching - **Lazy Loading**: Components loaded on demand - **Streaming**: Real-time AI responses without blocking ## Security - **No API Keys in Frontend**: All sensitive data handled by backend services - **CORS Protection**: Proper cross-origin request handling - **Input Validation**: Client-side validation for all user inputs - **Secure Defaults**: Safe configuration defaults ## Troubleshooting ### Common Issues 1. **AI Not Responding**: Check Ollama is running and accessible 2. **MCP Tools Failing**: Verify Bybit MCP server is running 3. **Charts Not Loading**: Check browser console for errors 4. **Theme Not Applying**: Clear browser cache and reload ### Debug Mode Enable debug logging by opening browser console and running: ```javascript localStorage.setItem('debug', 'true'); location.reload(); ``` ## License MIT License - see the main project LICENSE file for details. ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown # Bybit MCP Server A [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) server that provides read-only access to Bybit's cryptocurrency exchange API. **THIS IS ALPHA QUALITY SOFTWARE - USE AT YOUR OWN RISK!** Only ever use a read-only API key with this server. I wouldn't trust my code with your "money" and neither should you! ## Features This MCP server provides the following tools for interacting with Bybit's API: - `get_ticker`: Get real-time ticker information for a trading pair - `get_orderbook`: Get orderbook (market depth) data for a trading pair - `get_kline`: Get kline/candlestick data for a trading pair - `get_market_info`: Get detailed market information for trading pairs - `get_trades`: Get recent trades for a trading pair - `get_instrument_info`: Get detailed instrument information for a specific trading pair - `get_wallet_balance`: Get wallet balance information for the authenticated user - `get_positions`: Get current positions information for the authenticated user - `get_order_history`: Get order history for the authenticated user - `get_ml_rsi`: Get machine learning-based RSI (Relative Strength Index) for a trading pair - `get_market_structure`: Get market structure information for a trading pair - `get_order_blocks`: Detect institutional order accumulation zones - `get_order_history`: Get order history for the authenticated user - `get_orderbook`: Get orderbook (market depth) data for a trading pair - `get_ticker`: Get real-time ticker information for a trading pair There is also a **highly experimental** WebUI, see [WebUI README](webui/README.md) for details.  All code is subject to breaking changes and feature additions / removals as I continue to develop this project. ## Requirements & Installation 1. Node.js (v22+) 2. pnpm (`npm i -g pnpm`) 3. If you want to run the Ollama client as shown in the quick start below, you'll need Ollama installed and running, as well as your model of choice. ```bash pnpm i ``` ## Quick Start To install packages build everything and start the interactive client: ```bash pnpm i ``` Copy the .env.example file to .env and fill in your details. ```bash cp .env.example .env code .env ``` ### MCP-Server (Only) #### Stdio Transport (Default) ```bash pnpm serve ``` #### HTTP/SSE Transport ```bash pnpm start:http ``` The HTTP server runs on port 8080 by default and provides both modern Streamable HTTP and legacy SSE transports, making it compatible with web applications and various MCP clients. See [HTTP Server Documentation](docs/HTTP_SERVER.md) for detailed information. ### MCP-Server and Ollama client Install required client packages: ```bash (cd client && pnpm i) ``` Copy the client .env.example file to .env and fill in your details. ```bash cp client/.env.example client/.env code client/.env ``` Then to start the client and server in one command: ```bash pnpm start ``` ## Configuration ### Environment Variables The server requires Bybit API credentials to be set as environment variables: - `BYBIT_API_KEY`: Your Bybit API key (required) - `BYBIT_API_SECRET`: Your Bybit API secret (required) - **IMPORTANT - Only ever create a read-only API key!** - `BYBIT_USE_TESTNET`: Set to "true" to use testnet instead of mainnet (optional, defaults to false) - `DEBUG`: Set to "true" to enable debug logging (optional, defaults to false) Client environment variables (./client/.env): - `OLLAMA_HOST`: The host of the Ollama server (defaults to http://localhost:11434) - `DEFAULT_MODEL`: The default model to use for chat (defaults to qwen3-30b-a3b-ud-nothink-128k:q4_k_xl) ### MCP Settings Configuration To use this server with MCP clients, you need to add it to your MCP settings configuration file. The file location depends on your client: #### MCP Example - Claude Desktop Location: `~/Library/Application\ Support/Claude/claude_desktop_config.json` ```json { "mcpServers": { "bybit": { "command": "node", "args": ["/path/to/bybit-mcp/build/index.js"], "env": { "BYBIT_API_KEY": "your-api-key", "BYBIT_API_SECRET": "your-api-secret", "BYBIT_USE_TESTNET": "false" } } } } ``` #### MCP Example - [gomcp](https://github.com/sammcj/gomcp) Location: `~/.config/gomcp/config.yaml` ```yaml mcp_servers: - name: "bybit" command: "cd /path/to/bybit-mcp && pnpm run serve" arguments: [] env: BYBIT_API_KEY: "" # Add your Bybit API **READ ONLY** key here BYBIT_API_SECRET: "" # Add your Bybit API **READ ONLY** secret here BYBIT_USE_TESTNET: "true" # Set to false for production DEBUG: "false" # Optional: Set to true for debug logging ``` ## Client Integration This package includes a TypeScript client that provides a command-line interface for interacting with both Ollama LLMs and the bybit-mcp server. The client supports: - Interactive chat with Ollama models - Direct access to all bybit-mcp trading tools - Automatic server management - Environment-based configuration - Debug logging For detailed client documentation, see the [client README](client/README.md). ## Running the Server ### Production 1. Build the server: ```bash pnpm build ``` 2. Run the server: ```bash node build/index.js ``` ### Development For development with automatic TypeScript recompilation: ```bash pnpm watch ``` To inspect the MCP server during development: ```bash pnpm inspector ``` ## Tool Documentation ### Get Ticker Information ```typescript { "name": "get_ticker", "arguments": { "symbol": "BTCUSDT", "category": "spot" // optional, defaults to "spot" } } ``` ### Get Orderbook Data ```typescript { "name": "get_orderbook", "arguments": { "symbol": "BTCUSDT", "category": "spot", // optional, defaults to "spot" "limit": 25 // optional, defaults to 25 (available: 1, 25, 50, 100, 200) } } ``` ### Get Kline/Candlestick Data ```typescript { "name": "get_kline", "arguments": { "symbol": "BTCUSDT", "category": "spot", // optional, defaults to "spot" "interval": "1", // optional, defaults to "1" (available: "1", "3", "5", "15", "30", "60", "120", "240", "360", "720", "D", "M", "W") "limit": 200 // optional, defaults to 200 (max 1000) } } ``` ### Get Market Information ```typescript { "name": "get_market_info", "arguments": { "category": "spot", // optional, defaults to "spot" "symbol": "BTCUSDT", // optional, if not provided returns info for all symbols in the category "limit": 200 // optional, defaults to 200 (max 1000) } } ``` ### Get Recent Trades ```typescript { "name": "get_trades", "arguments": { "symbol": "BTCUSDT", "category": "spot", // optional, defaults to "spot" "limit": 200 // optional, defaults to 200 (max 1000) } } ``` ### Get Instrument Information ```typescript { "name": "get_instrument_info", "arguments": { "symbol": "BTCUSDT", // required "category": "spot" // optional, defaults to "spot" } } ``` Returns detailed information about a trading instrument including: - Base and quote currencies - Trading status - Lot size filters (min/max order quantities) - Price filters (tick size) - Leverage settings (for futures) - Contract details (for futures) ### Get Wallet Balance ```typescript { "name": "get_wallet_balance", "arguments": { "accountType": "UNIFIED", // required (available: "UNIFIED", "CONTRACT", "SPOT") "coin": "BTC" // optional, if not provided returns all coins } } ``` ### Get Positions ```typescript { "name": "get_positions", "arguments": { "category": "linear", // required (available: "linear", "inverse") "symbol": "BTCUSDT", // optional "baseCoin": "BTC", // optional "settleCoin": "USDT", // optional "limit": 200 // optional, defaults to 200 } } ``` ### Get Order History ```typescript { "name": "get_order_history", "arguments": { "category": "spot", // required (available: "spot", "linear", "inverse") "symbol": "BTCUSDT", // optional "baseCoin": "BTC", // optional "orderId": "1234567890", // optional "orderLinkId": "myCustomId", // optional "orderStatus": "Filled", // optional (available: "Created", "New", "Rejected", "PartiallyFilled", "PartiallyFilledCanceled", "Filled", "Cancelled", "Untriggered", "Triggered", "Deactivated") "orderFilter": "Order", // optional (available: "Order", "StopOrder") "limit": 200 // optional, defaults to 200 } } ``` ## Supported Categories - `spot`: Spot trading - `linear`: Linear perpetual contracts - `inverse`: Inverse perpetual contracts ## License MIT ``` -------------------------------------------------------------------------------- /webui/pnpm-workspace.yaml: -------------------------------------------------------------------------------- ```yaml onlyBuiltDependencies: - esbuild ``` -------------------------------------------------------------------------------- /webui/docker-healthcheck.sh: -------------------------------------------------------------------------------- ```bash #!/bin/sh # Health check for both MCP server and WebUI curl -f "http://localhost:${PORT:-8080}/health" || \ curl -f "http://localhost:${PORT:-8080}/" || \ exit 1 ``` -------------------------------------------------------------------------------- /client/src/index.ts: -------------------------------------------------------------------------------- ```typescript // Export main client class and types export { BybitMcpClient } from './client.js' export type { Message } from './client.js' // Export config class export { Config } from './config.js' // Export version export const version = '0.1.0' ``` -------------------------------------------------------------------------------- /webui/src/assets/fonts/fonts.css: -------------------------------------------------------------------------------- ```css /* Local font definitions - uses system fonts for better reliability */ /* * No external font files needed - using system font stack * This provides excellent performance and no external dependencies * while maintaining great visual consistency across platforms */ ``` -------------------------------------------------------------------------------- /webui/docker-entrypoint.sh: -------------------------------------------------------------------------------- ```bash #!/bin/sh set -e echo "🚀 Starting Bybit MCP WebUI..." echo "📊 Environment: ${NODE_ENV:-production}" echo "🌐 Host: ${HOST:-0.0.0.0}" echo "🔌 Port: ${PORT:-8080}" echo "🔧 MCP Port: ${MCP_PORT:-8080}" # Start the MCP HTTP server echo "🔧 Starting MCP Server..." exec node build/httpServer.js ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { "target": "ES2022", "module": "Node16", "moduleResolution": "Node16", "outDir": "./build", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "isolatedModules": true }, "include": ["src/**/*"], "exclude": ["node_modules", "src/__tests__/**/*"] } ``` -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- ```typescript export const CONSTANTS = { PROJECT_NAME: "bybit-mcp", PROJECT_VERSION: "0.1.0", // Testnet URLs for development/testing MAINNET_URL: "https://api.bybit.com", TESTNET_URL: "https://api-testnet.bybit.com", // Default category for API calls DEFAULT_CATEGORY: "spot", // Default interval for kline/candlestick data DEFAULT_INTERVAL: "1", // Maximum number of results to return MAX_RESULTS: 200 } ``` -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- ```javascript import js from "@eslint/js"; import globals from "globals"; import tseslint from "typescript-eslint"; import { defineConfig } from "eslint/config"; export default defineConfig([ { files: ["**/*.{js,mjs,cjs,ts,mts,cts}"], plugins: { js }, extends: ["js/recommended"] }, { files: ["**/*.{js,mjs,cjs,ts,mts,cts}"], languageOptions: { globals: {...globals.browser, ...globals.node} } }, tseslint.configs.recommended, ]); ``` -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "outDir": "./build", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "declaration": true }, "include": [ "src/**/*" ], "exclude": [ "node_modules", "build" ] } ``` -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- ```javascript /** @type {import('ts-jest').JestConfigWithTsJest} */ export default { preset: 'ts-jest', testEnvironment: 'node', extensionsToTreatAsEsm: ['.ts'], moduleNameMapper: { '^(\\.{1,2}/.*)\\.js$': '$1', }, transform: { '^.+\\.tsx?$': [ 'ts-jest', { useESM: true, }, ], }, testTimeout: 30000, maxWorkers: 1, forceExit: true, detectOpenHandles: true, setupFilesAfterEnv: ['<rootDir>/src/__tests__/test-setup.ts'], testPathIgnorePatterns: [ '<rootDir>/build/', '<rootDir>/node_modules/', '<rootDir>/src/__tests__/test-setup.ts', '<rootDir>/src/__tests__/integration.test.ts' ], testMatch: [ '<rootDir>/src/**/*.test.ts' ], collectCoverageFrom: [ 'src/**/*.ts', '!src/**/*.test.ts', '!src/__tests__/**', '!src/index.ts' ] }; ``` -------------------------------------------------------------------------------- /webui/tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, "module": "ESNext", "lib": ["ES2020", "DOM", "DOM.Iterable"], "skipLibCheck": true, /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, /* Linting */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, /* Path mapping */ "baseUrl": ".", "paths": { "@/*": ["src/*"], "@/types/*": ["src/types/*"], "@/components/*": ["src/components/*"], "@/services/*": ["src/services/*"], "@/utils/*": ["src/utils/*"] } }, "include": ["src/**/*", "vite.config.ts"], "exclude": ["node_modules", "dist"] } ``` -------------------------------------------------------------------------------- /webui/src/types/citation.ts: -------------------------------------------------------------------------------- ```typescript /** * Types for the citation and data verification system */ export interface CitationData { referenceId: string; timestamp: string; toolName: string; endpoint?: string; rawData: any; extractedMetrics?: ExtractedMetric[]; } export interface ExtractedMetric { type: 'price' | 'volume' | 'indicator' | 'percentage' | 'other'; label: string; value: string | number; unit?: string; significance: 'high' | 'medium' | 'low'; } export interface CitationReference { referenceId: string; startIndex: number; endIndex: number; text: string; } export interface ProcessedMessage { originalContent: string; processedContent: string; citations: CitationReference[]; } export interface CitationTooltipData { referenceId: string; toolName: string; timestamp: string; endpoint?: string; keyMetrics: ExtractedMetric[]; hasFullData: boolean; } ``` -------------------------------------------------------------------------------- /src/env.ts: -------------------------------------------------------------------------------- ```typescript import { config } from 'dotenv' import { join } from 'path' import { existsSync } from 'fs' // Load environment variables from .env file if it exists const envPath = join(process.cwd(), '.env') if (existsSync(envPath)) { config({ path: envPath }) } export interface EnvConfig { apiKey: string | undefined apiSecret: string | undefined useTestnet: boolean debug: boolean } export function getEnvConfig(): EnvConfig { return { apiKey: process.env.BYBIT_API_KEY, apiSecret: process.env.BYBIT_API_SECRET, useTestnet: process.env.BYBIT_USE_TESTNET === 'true', debug: process.env.DEBUG === 'true', } } // Validate environment variables export function validateEnv(): void { const config = getEnvConfig() // In development mode, API keys are optional if (!config.apiKey || !config.apiSecret) { console.warn('Running in development mode: API keys not provided') } // Additional validations can be added here } ``` -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- ```json { "name": "@bybit-mcp/client", "version": "0.2.0", "description": "TypeScript client for interacting with Ollama LLMs and bybit-mcp server", "type": "module", "bin": { "bybit-mcp-client": "build/cli.js", "bybit-mcp-chat": "build/launch.js" }, "main": "build/index.js", "types": "build/index.d.ts", "files": [ "build", "build/**/*" ], "scripts": { "build": "tsc && chmod +x build/cli.js build/launch.js", "prepare": "npm run build", "watch": "tsc --watch", "start": "node build/launch.js", "chat": "node build/launch.js" }, "keywords": [ "mcp", "ollama", "bybit", "ai", "llm", "client" ], "dependencies": { "@modelcontextprotocol/sdk": "1.12.0", "ollama": "^0.5.15", "commander": "^14.0.0", "chalk": "^5.4.1", "conf": "^13.1.0", "dotenv": "^16.5.0" }, "devDependencies": { "@types/node": "^22.15.21", "typescript": "^5.8.3" }, "engines": { "node": ">=22" }, "exports": { ".": { "types": "./build/index.d.ts", "import": "./build/index.js" } } } ``` -------------------------------------------------------------------------------- /webui/public/favicon.svg: -------------------------------------------------------------------------------- ``` <svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> <!-- Background circle --> <circle cx="16" cy="16" r="16" fill="#2563eb"/> <!-- Chart bars representing trading --> <rect x="6" y="20" width="2" height="6" fill="white" opacity="0.9"/> <rect x="10" y="16" width="2" height="10" fill="white" opacity="0.9"/> <rect x="14" y="12" width="2" height="14" fill="white" opacity="0.9"/> <rect x="18" y="8" width="2" height="18" fill="white" opacity="0.9"/> <rect x="22" y="14" width="2" height="12" fill="white" opacity="0.9"/> <rect x="26" y="18" width="2" height="8" fill="white" opacity="0.9"/> <!-- Trend line --> <path d="M6 22 L10 18 L14 14 L18 10 L22 16 L26 20" stroke="white" stroke-width="1.5" fill="none" opacity="0.8"/> <!-- Small dots at data points --> <circle cx="6" cy="22" r="1" fill="white"/> <circle cx="10" cy="18" r="1" fill="white"/> <circle cx="14" cy="14" r="1" fill="white"/> <circle cx="18" cy="10" r="1" fill="white"/> <circle cx="22" cy="16" r="1" fill="white"/> <circle cx="26" cy="20" r="1" fill="white"/> </svg> ``` -------------------------------------------------------------------------------- /client/src/env.ts: -------------------------------------------------------------------------------- ```typescript import { config } from 'dotenv' import { join } from 'path' import { existsSync } from 'fs' // Load environment variables from .env file if it exists const envPath = join(process.cwd(), '.env') if (existsSync(envPath)) { const result = config({ path: envPath }) if (result.error) { console.error('Error loading .env file:', result.error) } } export interface EnvConfig { ollamaHost: string defaultModel: string debug: boolean } export function getEnvConfig(): EnvConfig { const ollamaHost = process.env.OLLAMA_HOST || process.env.OLLAMA_API_BASE if (!ollamaHost) { throw new Error('OLLAMA_HOST or OLLAMA_API_BASE environment variable must be set') } // Validate the URL format try { new URL(ollamaHost) } catch (error) { throw new Error(`Invalid OLLAMA_HOST URL format: ${ollamaHost}`) } return { ollamaHost, defaultModel: process.env.DEFAULT_MODEL || 'qwen3-30b-a3b-ud-nothink-128k:q4_k_xl', debug: process.env.DEBUG === 'true', } } // Validate required environment variables export function validateEnv(): void { getEnvConfig() // This will throw if validation fails } ``` -------------------------------------------------------------------------------- /webui/package.json: -------------------------------------------------------------------------------- ```json { "name": "bybit-mcp-webui", "version": "1.0.0", "description": "Modern web interface for Bybit MCP server with AI chat capabilities", "type": "module", "scripts": { "dev": "vite", "dev:full": "concurrently \"npm run mcp:start\" \"npm run dev\"", "mcp:start": "cd .. && node build/httpServer.js", "mcp:build": "cd .. && pnpm build", "build": "tsc && vite build", "preview": "vite preview", "type-check": "tsc --noEmit", "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0" }, "dependencies": { "@modelcontextprotocol/sdk": "1.12.0", "cors": "2.8.5", "express": "5.1.0", "marked": "15.0.12" }, "devDependencies": { "@types/node": "^22.15.21", "@typescript-eslint/eslint-plugin": "^8.32.1", "@typescript-eslint/parser": "^8.32.1", "concurrently": "^9.1.2", "eslint": "^9.27.0", "typescript": "^5.8.3", "vite": "^6.3.5" }, "engines": { "node": ">=22.0.0" }, "keywords": [ "bybit", "mcp", "trading", "ai", "chat", "webui", "typescript", "vite" ], "author": "Sam McLeod", "license": "MIT" } ``` -------------------------------------------------------------------------------- /webui/vite.config.ts: -------------------------------------------------------------------------------- ```typescript import { defineConfig } from 'vite' import { resolve } from 'path' export default defineConfig({ define: { // Inject environment variables at build time '__OLLAMA_HOST__': JSON.stringify(process.env.OLLAMA_HOST || 'http://localhost:11434'), '__MCP_ENDPOINT__': JSON.stringify(process.env.MCP_ENDPOINT || ''), // Empty means use current origin }, resolve: { alias: { '@': resolve(__dirname, 'src'), '@/types': resolve(__dirname, 'src/types'), '@/components': resolve(__dirname, 'src/components'), '@/services': resolve(__dirname, 'src/services'), '@/utils': resolve(__dirname, 'src/utils'), }, }, server: { port: 3000, host: true, proxy: { // Proxy MCP server requests '/api/mcp': { target: 'http://localhost:8080', changeOrigin: true, rewrite: (path) => path.replace(/^\/api\/mcp/, ''), configure: (proxy) => { proxy.on('error', (err) => { console.log('Proxy error:', err); }); } }, }, }, build: { outDir: 'dist', sourcemap: true, rollupOptions: { input: { main: resolve(__dirname, 'index.html'), }, }, }, optimizeDeps: { include: ['marked'], }, }) ``` -------------------------------------------------------------------------------- /webui/docker-compose.yml: -------------------------------------------------------------------------------- ```yaml # ============================================================================== # Docker Compose Configuration for Bybit MCP WebUI # ============================================================================== # This file provides easy deployment options for the Bybit MCP WebUI # ============================================================================== services: # ============================================================================== # Bybit MCP WebUI Service # ============================================================================== bybit-mcp-webui: build: context: .. dockerfile: webui/Dockerfile target: production image: bybit-mcp-webui:latest container_name: bybit-mcp-webui restart: unless-stopped stop_grace_period: 2s # Port mapping ports: - "8080:8080" # Environment variables environment: - NODE_ENV=production - PORT=8080 - MCP_PORT=8080 - HOST=0.0.0.0 # Runtime configuration (these override build-time args) - OLLAMA_HOST=${OLLAMA_HOST:-https://ollama.example.com} - MCP_ENDPOINT=${MCP_ENDPOINT:-} # Add your Bybit API credentials here (optional, make sure they're read only!) # - BYBIT_API_KEY=your_api_key_here # - BYBIT_API_SECRET=your_api_secret_here # - BYBIT_TESTNET=true # Security security_opt: - no-new-privileges:true read_only: false user: "1001:1001" labels: - "traefik.enable=true" # Update this to your actual domain - "traefik.http.routers.bybit-mcp.rule=Host(`bybit-mcp.example.com`)" - "traefik.http.services.bybit-mcp.loadbalancer.server.port=8080" - "com.docker.compose.project=bybit-mcp" - "com.docker.compose.service=webui" ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json { "name": "bybit-mcp", "version": "0.2.0", "description": "A MCP server to interact with Bybit's API", "license": "MIT", "type": "module", "bin": { "bybit-mcp": "build/index.js" }, "main": "build/index.js", "files": [ "build", "build/**/*" ], "scripts": { "build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"", "build:all": "pnpm run build && cd client && pnpm run build", "prepare": "npm run build", "watch": "tsc --watch", "inspector": "npx @modelcontextprotocol/inspector build/index.js", "prepack": "npm run build", "serve": "node build/index.js", "serve:http": "node build/httpServer.js", "start": "pnpm run build:all && cd client && pnpm run start", "start:http": "pnpm run build && pnpm run serve:http", "test": "NODE_OPTIONS=--experimental-vm-modules jest", "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch", "test:coverage": "NODE_OPTIONS=--experimental-vm-modules jest --coverage", "test:api": "NODE_OPTIONS=--experimental-vm-modules pnpm test src/__tests__/integration.test.ts" }, "keywords": [ "mcp", "claude", "bybit", "anthropic", "ai", "cryptocurrency", "trading" ], "dependencies": { "@modelcontextprotocol/sdk": "1.12.0", "@types/cors": "2.8.18", "@types/express": "5.0.2", "@types/node": "^22.10.2", "bybit-api": "^4.1.8", "cors": "2.8.5", "dotenv": "16.5.0", "express": "5.1.0", "zod": "^3.25.28" }, "devDependencies": { "@eslint/js": "9.27.0", "@jest/globals": "29.7.0", "@types/jest": "29.5.14", "@types/node": "^22.15.21", "eslint": "9.27.0", "globals": "16.2.0", "jest": "29.7.0", "ts-jest": "29.3.4", "typescript": "^5.8.3", "typescript-eslint": "8.32.1" }, "engines": { "node": ">=22" } } ``` -------------------------------------------------------------------------------- /client/src/config.ts: -------------------------------------------------------------------------------- ```typescript import Conf from 'conf' import { getEnvConfig, type EnvConfig } from './env.js'; interface ConfigSchema extends EnvConfig { } export class Config { private conf: Conf<ConfigSchema> private envConfig: EnvConfig; constructor() { // Get environment configuration this.envConfig = getEnvConfig(); this.conf = new Conf<ConfigSchema>({ projectName: 'bybit-mcp-client', schema: { ollamaHost: { type: 'string', default: this.envConfig.ollamaHost }, defaultModel: { type: 'string', default: this.envConfig.defaultModel }, debug: { type: 'boolean', default: this.envConfig.debug } } }) // Update stored config with any new environment values this.syncWithEnv() } private syncWithEnv(): void { // Environment variables take precedence over stored config if (this.envConfig.ollamaHost) { this.conf.set('ollamaHost', this.envConfig.ollamaHost) } if (this.envConfig.defaultModel) { this.conf.set('defaultModel', this.envConfig.defaultModel) } if (this.envConfig.debug !== undefined) { this.conf.set('debug', this.envConfig.debug) } } get<K extends keyof ConfigSchema>(key: K): ConfigSchema[K] { return this.conf.get(key); } set<K extends keyof ConfigSchema>(key: K, value: ConfigSchema[K]): void { // Only update if not overridden by environment if (!(key in this.envConfig) || this.envConfig[key] === undefined) { this.conf.set(key, value) } } delete(key: keyof ConfigSchema): void { // Only delete if not overridden by environment if (!(key in this.envConfig) || this.envConfig[key] === undefined) { this.conf.delete(key) } } clear(): void { this.conf.clear() // Restore environment values this.syncWithEnv(); } } ``` -------------------------------------------------------------------------------- /src/__tests__/test-setup.ts: -------------------------------------------------------------------------------- ```typescript /** * Jest test setup file * Handles global test configuration and cleanup */ import { jest } from '@jest/globals' // Increase timeout for integration tests jest.setTimeout(30000) // Mock console methods to reduce noise during tests const originalConsoleError = console.error const originalConsoleWarn = console.warn const originalConsoleInfo = console.info beforeAll(() => { // Suppress console output during tests unless explicitly needed console.error = jest.fn() console.warn = jest.fn() console.info = jest.fn() }) afterAll(() => { // Restore original console methods console.error = originalConsoleError console.warn = originalConsoleWarn console.info = originalConsoleInfo }) // Global cleanup after each test afterEach(() => { // Clear all mocks jest.clearAllMocks() // Clear any timers jest.clearAllTimers() // Clear any active timeouts from tools jest.runOnlyPendingTimers() }) // Handle unhandled promise rejections process.on('unhandledRejection', (reason, promise) => { console.error('Unhandled Rejection at:', promise, 'reason:', reason) }) // Handle uncaught exceptions process.on('uncaughtException', (error) => { console.error('Uncaught Exception:', error) }) // Export common test utilities export const createMockResponse = (data: any, success: boolean = true) => { return { retCode: success ? 0 : 1, retMsg: success ? 'OK' : 'Error', result: success ? data : null, retExtInfo: {}, time: Date.now() } } // Mock crypto.randomUUID globally const mockRandomUUID = jest.fn(() => '123e4567-e89b-12d3-a456-426614174000') global.crypto = { ...global.crypto, randomUUID: mockRandomUUID, } as Crypto // Helper to create mock tool call requests export const createMockRequest = (name: string, arguments_: any) => { return { method: "tools/call" as const, params: { name, arguments: arguments_ } } } ``` -------------------------------------------------------------------------------- /webui/src/types/agent.ts: -------------------------------------------------------------------------------- ```typescript /** * Agent-specific types for integration */ // Simplified agent configuration - removed complex options export interface AgentConfig { // Essential settings only maxIterations: number; // default: 5 toolTimeout: number; // default: 30000ms // UI preferences showWorkflowSteps: boolean; // default: false showToolCalls: boolean; // default: false enableDebugMode: boolean; // default: false streamingEnabled: boolean; // default: true } export interface AgentState { isProcessing: boolean; lastQuery?: string; lastResponse?: string; queryCount: number; averageResponseTime: number; successRate: number; } // Workflow event types export interface WorkflowEvent { id: string; type: 'tool_call' | 'agent_thinking' | 'workflow_step' | 'error' | 'complete'; timestamp: number; data: any; agentName?: string; } export interface ToolCallEvent extends WorkflowEvent { type: 'tool_call'; data: { toolName: string; parameters: Record<string, any>; status: 'started' | 'completed' | 'failed'; duration?: number; result?: any; error?: string; }; } export interface AgentThinkingEvent extends WorkflowEvent { type: 'agent_thinking'; data: { reasoning: string; nextAction: string; confidence: number; }; } export interface WorkflowStepEvent extends WorkflowEvent { type: 'workflow_step'; data: { stepName: string; stepDescription: string; progress: number; totalSteps: number; }; } // Removed complex multi-agent and workflow preset configurations // Agent now uses single-agent mode with simplified configuration export const DEFAULT_AGENT_CONFIG: AgentConfig = { // Essential settings with sensible defaults maxIterations: 5, toolTimeout: 30000, // UI preferences - minimal by default showWorkflowSteps: false, showToolCalls: false, enableDebugMode: false, streamingEnabled: true, }; ``` -------------------------------------------------------------------------------- /webui/src/styles/main.css: -------------------------------------------------------------------------------- ```css /* Main CSS file - imports all styles */ /* Local fonts - no external CDN dependencies */ @import '../assets/fonts/fonts.css'; @import './variables.css'; @import './base.css'; @import './components.css'; @import './citations.css'; @import './verification-panel.css'; @import './agent-dashboard.css'; @import './data-cards.css'; @import './processing.css'; /* Responsive design */ @media (max-width: 768px) { .sidebar { width: var(--sidebar-width-collapsed); } .nav-label { display: none; } .header-content { padding: 0 var(--spacing-md); } .logo h1 { font-size: var(--font-size-lg); } .version { display: none; } .chat-input-wrapper { margin: 0 var(--spacing-md); } .chart-grid { grid-template-columns: 1fr; } .chart-controls { flex-wrap: wrap; } } @media (max-width: 480px) { .sidebar { position: fixed; left: -100%; top: var(--header-height); height: calc(100vh - var(--header-height)); z-index: var(--z-fixed); transition: left var(--transition-normal); } .sidebar.open { left: 0; } .content-area { margin-left: 0; } .example-queries { padding: 0 var(--spacing-md); } .modal-content { width: 95%; margin: var(--spacing-md); } } /* Print styles */ @media print { .sidebar, .header-controls, .chat-input-container { display: none; } .main-container { height: auto; } .content-area { margin-left: 0; } .chat-messages { overflow: visible; height: auto; } } /* High contrast mode adjustments */ @media (prefers-contrast: high) { .nav-item:hover, .example-query:hover, .theme-toggle:hover, .settings-btn:hover { outline: 2px solid currentColor; } } /* Reduced motion adjustments */ @media (prefers-reduced-motion: reduce) { .view { transition: none; } .modal, .modal-content { animation: none; } .chat-message { animation: none; } } ``` -------------------------------------------------------------------------------- /src/utils/toolLoader.ts: -------------------------------------------------------------------------------- ```typescript import { BaseToolImplementation } from "../tools/BaseTool.js" import { fileURLToPath } from "url" import { dirname, join } from "path" import { promises as fs } from "fs" async function findToolsPath(): Promise<string> { const currentFilePath = fileURLToPath(import.meta.url) const currentDir = dirname(currentFilePath) return join(currentDir, "..", "tools") } const isToolFile = (file: string): boolean => { return ( file.endsWith(".js") && !file.includes("BaseTool") && !file.includes("index") && !file.endsWith(".test.js") && !file.endsWith(".spec.js") && !file.endsWith(".d.js") ) } export async function loadTools(): Promise<BaseToolImplementation[]> { try { const toolsPath = await findToolsPath() const files = await fs.readdir(toolsPath) const tools: BaseToolImplementation[] = [] for (const file of files) { if (!isToolFile(file)) { continue } try { const modulePath = `file://${join(toolsPath, file)}` const { default: ToolClass } = await import(modulePath) if (!ToolClass || typeof ToolClass !== 'function') { console.warn(JSON.stringify({ type: "warning", message: `Invalid tool class in ${file}` })) continue } const tool = new ToolClass() if ( tool instanceof BaseToolImplementation && tool.name && tool.toolDefinition && typeof tool.toolCall === "function" ) { tools.push(tool) console.info(JSON.stringify({ type: "info", message: `Loaded tool: ${tool.name}` })) } else { console.warn(JSON.stringify({ type: "warning", message: `Invalid tool implementation in ${file}` })) } } catch (error) { console.error(JSON.stringify({ type: "error", message: `Error loading tool from ${file}: ${error instanceof Error ? error.message : String(error)}` })) } } return tools } catch (error) { console.error(JSON.stringify({ type: "error", message: `Failed to load tools: ${error instanceof Error ? error.message : String(error)}` })) return [] } } export function createToolsMap(tools: BaseToolImplementation[]): Map<string, BaseToolImplementation> { return new Map(tools.map((tool) => [tool.name, tool])) } ``` -------------------------------------------------------------------------------- /src/tools/GetTrades.ts: -------------------------------------------------------------------------------- ```typescript import { Tool } from "@modelcontextprotocol/sdk/types.js" import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js" import { z } from "zod" import { BaseToolImplementation } from "./BaseTool.js" import { CONSTANTS } from "../constants.js" import { // CategoryV5, GetPublicTradingHistoryParamsV5, // PublicTradeV5 } from "bybit-api" type SupportedCategory = "spot" | "linear" | "inverse" class GetTrades extends BaseToolImplementation { name = "get_trades"; toolDefinition: Tool = { name: this.name, description: "Get recent trades for a trading pair", inputSchema: { type: "object", properties: { symbol: { type: "string", description: "Trading pair symbol (e.g., 'BTCUSDT')", }, category: { type: "string", description: "Category of the instrument (spot, linear, inverse)", enum: ["spot", "linear", "inverse"], }, limit: { type: "string", description: "Limit for the number of trades (max 1000)", enum: ["1", "10", "50", "100", "200", "500", "1000"], }, }, required: ["symbol"], }, }; async toolCall(request: z.infer<typeof CallToolRequestSchema>) { try { const args = request.params.arguments as unknown if (!args || typeof args !== 'object') { throw new Error("Invalid arguments") } const typedArgs = args as Record<string, unknown> if (!typedArgs.symbol || typeof typedArgs.symbol !== 'string') { throw new Error("Missing or invalid symbol parameter") } const symbol = typedArgs.symbol const category = ( typedArgs.category && typeof typedArgs.category === 'string' && ["spot", "linear", "inverse"].includes(typedArgs.category) ) ? typedArgs.category as SupportedCategory : CONSTANTS.DEFAULT_CATEGORY as SupportedCategory const limit = ( typedArgs.limit && typeof typedArgs.limit === 'string' && ["1", "10", "50", "100", "200", "500", "1000"].includes(typedArgs.limit) ) ? parseInt(typedArgs.limit, 10) : 200 const params: GetPublicTradingHistoryParamsV5 = { category, symbol, limit, } const response = await this.client.getPublicTradingHistory(params) if (response.retCode !== 0) { throw new Error(`Bybit API error: ${response.retMsg}`) } // Transform the trade data into a more readable format and return as array const formattedTrades = response.result.list.map(trade => ({ id: trade.execId, symbol: trade.symbol, price: trade.price, size: trade.size, side: trade.side, time: trade.time, isBlockTrade: trade.isBlockTrade, })) return this.formatResponse(formattedTrades) } catch (error) { return this.handleError(error) } } } export default GetTrades ``` -------------------------------------------------------------------------------- /src/tools/GetMarketInfo.ts: -------------------------------------------------------------------------------- ```typescript import { Tool } from "@modelcontextprotocol/sdk/types.js" import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js" import { z } from "zod" import { BaseToolImplementation } from "./BaseTool.js" import { CONSTANTS } from "../constants.js" import { // CategoryV5, GetInstrumentsInfoParamsV5, } from "bybit-api" type SupportedCategory = "spot" | "linear" | "inverse" class GetMarketInfo extends BaseToolImplementation { name = "get_market_info"; toolDefinition: Tool = { name: this.name, description: "Get detailed market information for trading pairs", inputSchema: { type: "object", properties: { category: { type: "string", description: "Category of the instrument (spot, linear, inverse)", enum: ["spot", "linear", "inverse"], }, symbol: { type: "string", description: "Optional: Trading pair symbol (e.g., 'BTCUSDT'). If not provided, returns info for all symbols in the category", }, limit: { type: "string", description: "Limit for the number of results (max 1000)", enum: ["1", "10", "50", "100", "200", "500", "1000"], }, }, required: [], }, }; async toolCall(request: z.infer<typeof CallToolRequestSchema>) { try { const args = request.params.arguments as unknown if (!args || typeof args !== 'object') { throw new Error("Invalid arguments") } const typedArgs = args as Record<string, unknown> // Validate category explicitly if (typedArgs.category && typeof typedArgs.category === 'string') { if (!["spot", "linear", "inverse"].includes(typedArgs.category)) { throw new Error(`Invalid category: ${typedArgs.category}. Must be one of: spot, linear, inverse`) } } const category = ( typedArgs.category && typeof typedArgs.category === 'string' && ["spot", "linear", "inverse"].includes(typedArgs.category) ) ? typedArgs.category as SupportedCategory : CONSTANTS.DEFAULT_CATEGORY as SupportedCategory const symbol = typedArgs.symbol && typeof typedArgs.symbol === 'string' ? typedArgs.symbol : undefined const limit = ( typedArgs.limit && typeof typedArgs.limit === 'string' && ["1", "10", "50", "100", "200", "500", "1000"].includes(typedArgs.limit) ) ? parseInt(typedArgs.limit, 10) : 200 const params: GetInstrumentsInfoParamsV5 = { category, symbol, limit, } const response = await this.client.getInstrumentsInfo(params) if (response.retCode !== 0) { throw new Error(`Bybit API error: ${response.retMsg}`) } // Return the list array directly return this.formatResponse(response.result.list) } catch (error) { // Ensure error responses are properly formatted const errorMessage = error instanceof Error ? error.message : String(error) return { content: [{ type: "text" as const, text: errorMessage }], isError: true } } } } export default GetMarketInfo ``` -------------------------------------------------------------------------------- /webui/src/styles/processing.css: -------------------------------------------------------------------------------- ```css /* Processing Indicator Styles */ /* Processing message container */ .processing-message { margin-bottom: var(--spacing-lg); animation: fadeIn 0.3s ease-out; } /* Processing indicator container */ .processing-indicator { display: flex; align-items: center; gap: var(--spacing-md); padding: var(--spacing-md); } /* Animated dots */ .processing-dots { display: flex; align-items: center; gap: var(--spacing-xs); } .processing-dots span { width: 8px; height: 8px; border-radius: 50%; background-color: var(--color-primary); animation: processingPulse 1.4s ease-in-out infinite both; } .processing-dots span:nth-child(1) { animation-delay: -0.32s; } .processing-dots span:nth-child(2) { animation-delay: -0.16s; } .processing-dots span:nth-child(3) { animation-delay: 0s; } /* Processing text */ .processing-text { color: var(--text-secondary); font-style: italic; font-size: var(--font-size-sm); } /* Processing content styling */ .message-content.processing { background-color: var(--bg-tertiary); border: 1px dashed var(--border-secondary); opacity: 0.8; } /* Cursor animation for streaming messages */ .cursor { animation: blink 1s infinite; color: var(--color-primary); font-weight: bold; } /* Keyframe animations */ @keyframes processingPulse { 0%, 80%, 100% { transform: scale(0); opacity: 0.5; } 40% { transform: scale(1); opacity: 1; } } @keyframes blink { 0%, 50% { opacity: 1; } 51%, 100% { opacity: 0; } } @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } /* Pulse animation for general loading states */ @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } /* Spin animation for loading spinners */ @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } /* Enhanced processing states */ .processing-message .message-avatar { animation: pulse 2s ease-in-out infinite; } .processing-message .message-time { color: var(--color-primary); font-weight: var(--font-weight-medium); } /* Retry-specific styling */ .processing-text:has-text("Retrying") { color: var(--color-warning); } .processing-message[data-retry="true"] .processing-dots span { background-color: var(--color-warning); } .processing-message[data-retry="true"] .message-avatar { background-color: var(--color-warning); } /* Dark mode adjustments */ @media (prefers-color-scheme: dark) { .processing-dots span { background-color: var(--color-primary-light); } .message-content.processing { background-color: rgba(255, 255, 255, 0.02); border-color: rgba(255, 255, 255, 0.1); } } /* Reduced motion support */ @media (prefers-reduced-motion: reduce) { .processing-dots span { animation: none; opacity: 0.7; } .processing-message .message-avatar { animation: none; } .cursor { animation: none; } .processing-indicator { animation: none; } } /* High contrast mode */ @media (prefers-contrast: high) { .processing-dots span { background-color: currentColor; } .message-content.processing { border-width: 2px; border-style: solid; } } ``` -------------------------------------------------------------------------------- /src/tools/GetWalletBalance.ts: -------------------------------------------------------------------------------- ```typescript import { Tool } from "@modelcontextprotocol/sdk/types.js" import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js" import { z } from "zod" import { BaseToolImplementation } from "./BaseTool.js" import { AccountTypeV5, APIResponseV3WithTime, WalletBalanceV5 } from "bybit-api" // Zod schema for input validation const inputSchema = z.object({ accountType: z.enum(["UNIFIED", "CONTRACT", "SPOT"]), coin: z.string().optional() }) type ToolArguments = z.infer<typeof inputSchema> // Type for the formatted response interface FormattedWalletResponse { accountType: AccountTypeV5 coin?: string data: { list: WalletBalanceV5[] } timestamp: string meta: { requestId: string } } export class GetWalletBalance extends BaseToolImplementation { name = "get_wallet_balance" toolDefinition: Tool = { name: this.name, description: "Get wallet balance information for the authenticated user", inputSchema: { type: "object", properties: { accountType: { type: "string", description: "Account type", enum: ["UNIFIED", "CONTRACT", "SPOT"], }, coin: { type: "string", description: "Cryptocurrency symbol, e.g., BTC, ETH, USDT. If not specified, returns all coins.", }, }, required: ["accountType"], }, } private async getWalletData( accountType: AccountTypeV5, coin?: string ): Promise<APIResponseV3WithTime<{ list: WalletBalanceV5[] }>> { this.logInfo(`Fetching wallet balance for account type: ${accountType}${coin ? `, coin: ${coin}` : ''}`) return await this.client.getWalletBalance({ accountType, coin, }) } async toolCall(request: z.infer<typeof CallToolRequestSchema>) { try { this.logInfo("Starting get_wallet_balance tool call") if (this.isDevMode) { throw new Error("Cannot get wallet balance in development mode - API credentials required") } // Parse and validate input const validationResult = inputSchema.safeParse(request.params.arguments) if (!validationResult.success) { const errorDetails = validationResult.error.errors.map(err => ({ field: err.path.join('.'), message: err.message, code: err.code })) throw new Error(`Invalid input: ${JSON.stringify(errorDetails)}`) } const { accountType, coin } = validationResult.data this.logInfo(`Validated arguments - accountType: ${accountType}${coin ? `, coin: ${coin}` : ''}`) // Execute API request with rate limiting and retry logic const response = await this.executeRequest(async () => { return await this.getWalletData(accountType, coin) }) // Format response const result: FormattedWalletResponse = { accountType, coin, data: { list: response.list }, timestamp: new Date().toISOString(), meta: { requestId: crypto.randomUUID() } } this.logInfo(`Successfully retrieved wallet balance for ${accountType}${coin ? ` (${coin})` : ''}`) return this.formatResponse(result) } catch (error) { this.logInfo(`Error in get_wallet_balance: ${error instanceof Error ? error.message : String(error)}`) return this.handleError(error) } } } export default GetWalletBalance ``` -------------------------------------------------------------------------------- /webui/src/styles/base.css: -------------------------------------------------------------------------------- ```css /* Base styles and reset */ * { box-sizing: border-box; margin: 0; padding: 0; } html { font-size: 16px; scroll-behavior: smooth; } body { font-family: var(--font-family-sans); font-size: var(--font-size-base); font-weight: var(--font-weight-normal); line-height: var(--line-height-normal); color: var(--text-primary); background-color: var(--bg-primary); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; overflow-x: hidden; } /* Focus styles */ *:focus { outline: 2px solid var(--border-focus); outline-offset: 2px; } *:focus:not(:focus-visible) { outline: none; } /* Selection styles */ ::selection { background-color: var(--color-primary); color: var(--text-inverse); } /* Scrollbar styles */ ::-webkit-scrollbar { width: 8px; height: 8px; } ::-webkit-scrollbar-track { background: var(--bg-secondary); } ::-webkit-scrollbar-thumb { background: var(--border-secondary); border-radius: var(--radius-full); } ::-webkit-scrollbar-thumb:hover { background: var(--text-tertiary); } /* Typography */ h1, h2, h3, h4, h5, h6 { font-weight: var(--font-weight-semibold); line-height: var(--line-height-tight); color: var(--text-primary); } h1 { font-size: var(--font-size-3xl); } h2 { font-size: var(--font-size-2xl); } h3 { font-size: var(--font-size-xl); } h4 { font-size: var(--font-size-lg); } h5, h6 { font-size: var(--font-size-base); } p { margin-bottom: var(--spacing-md); } a { color: var(--color-primary); text-decoration: none; transition: color var(--transition-fast); } a:hover { color: var(--color-primary-hover); text-decoration: underline; } /* Form elements */ input, textarea, select, button { font-family: inherit; font-size: inherit; } button { cursor: pointer; border: none; background: none; padding: 0; } button:disabled { cursor: not-allowed; opacity: 0.6; } /* Utility classes */ .hidden { display: none !important; } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } .text-center { text-align: center; } .text-left { text-align: left; } .text-right { text-align: right; } .font-mono { font-family: var(--font-family-mono); } .font-medium { font-weight: var(--font-weight-medium); } .font-semibold { font-weight: var(--font-weight-semibold); } .font-bold { font-weight: var(--font-weight-bold); } .text-xs { font-size: var(--font-size-xs); } .text-sm { font-size: var(--font-size-sm); } .text-lg { font-size: var(--font-size-lg); } .text-xl { font-size: var(--font-size-xl); } .text-primary { color: var(--text-primary); } .text-secondary { color: var(--text-secondary); } .text-tertiary { color: var(--text-tertiary); } .text-success { color: var(--color-success); } .text-warning { color: var(--color-warning); } .text-danger { color: var(--color-danger); } /* Loading animation */ @keyframes spin { to { transform: rotate(360deg); } } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } @keyframes slideInRight { from { opacity: 0; transform: translateX(20px); } to { opacity: 1; transform: translateX(0); } } .animate-spin { animation: spin 1s linear infinite; } .animate-pulse { animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; } .animate-fadeIn { animation: fadeIn 0.3s ease-out; } .animate-slideInRight { animation: slideInRight 0.3s ease-out; } ``` -------------------------------------------------------------------------------- /webui/src/styles/variables.css: -------------------------------------------------------------------------------- ```css /* CSS Custom Properties for theming and consistency */ :root { /* Colors - Light Theme */ --color-primary: #2563eb; --color-primary-hover: #1d4ed8; --color-secondary: #64748b; --color-accent: #10b981; --color-accent-hover: #059669; --color-danger: #ef4444; --color-warning: #f59e0b; --color-success: #10b981; /* Background Colors */ --bg-primary: #ffffff; --bg-secondary: #f8fafc; --bg-tertiary: #f1f5f9; --bg-elevated: #ffffff; --bg-overlay: rgba(0, 0, 0, 0.5); /* Text Colors */ --text-primary: #0f172a; --text-secondary: #475569; --text-tertiary: #94a3b8; --text-inverse: #ffffff; /* Border Colors */ --border-primary: #e2e8f0; --border-secondary: #cbd5e1; --border-focus: #2563eb; /* Shadows */ --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); /* Spacing */ --spacing-xs: 0.25rem; --spacing-sm: 0.5rem; --spacing-md: 1rem; --spacing-lg: 1.5rem; --spacing-xl: 2rem; --spacing-2xl: 3rem; /* Typography - Modern system font stack */ --font-family-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif; --font-family-mono: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; --font-size-xs: 0.75rem; --font-size-sm: 0.875rem; --font-size-base: 1rem; --font-size-lg: 1.125rem; --font-size-xl: 1.25rem; --font-size-2xl: 1.5rem; --font-size-3xl: 1.875rem; --font-weight-normal: 400; --font-weight-medium: 500; --font-weight-semibold: 600; --font-weight-bold: 700; --line-height-tight: 1.25; --line-height-normal: 1.5; --line-height-relaxed: 1.75; /* Border Radius */ --radius-sm: 0.25rem; --radius-md: 0.375rem; --radius-lg: 0.5rem; --radius-xl: 0.75rem; --radius-2xl: 1rem; --radius-full: 9999px; /* Layout */ --header-height: 4rem; --sidebar-width: 16rem; --sidebar-width-collapsed: 4rem; /* Transitions */ --transition-fast: 150ms ease-in-out; --transition-normal: 250ms ease-in-out; --transition-slow: 350ms ease-in-out; /* Z-index */ --z-dropdown: 1000; --z-sticky: 1020; --z-fixed: 1030; --z-modal-backdrop: 1040; --z-modal: 1050; --z-popover: 1060; --z-tooltip: 1070; } /* Dark Theme */ [data-theme="dark"] { /* Background Colors */ --bg-primary: #0f172a; --bg-secondary: #1e293b; --bg-tertiary: #334155; --bg-elevated: #1e293b; /* Text Colors */ --text-primary: #f8fafc; --text-secondary: #cbd5e1; --text-tertiary: #64748b; /* Border Colors */ --border-primary: #334155; --border-secondary: #475569; /* Shadows (adjusted for dark theme) */ --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3); --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2); --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -2px rgba(0, 0, 0, 0.2); --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 10px 10px -5px rgba(0, 0, 0, 0.2); } /* High contrast mode */ @media (prefers-contrast: high) { :root { --border-primary: #000000; --border-secondary: #000000; } [data-theme="dark"] { --border-primary: #ffffff; --border-secondary: #ffffff; } } /* Reduced motion */ @media (prefers-reduced-motion: reduce) { :root { --transition-fast: 0ms; --transition-normal: 0ms; --transition-slow: 0ms; } } /* Chart Colors */ :root { --chart-bullish: #10b981; --chart-bearish: #ef4444; --chart-volume: #6366f1; --chart-grid: #e5e7eb; --chart-text: var(--text-secondary); --chart-background: var(--bg-primary); } [data-theme="dark"] { --chart-grid: #374151; } ``` -------------------------------------------------------------------------------- /src/tools/GetPositions.ts: -------------------------------------------------------------------------------- ```typescript import { Tool } from "@modelcontextprotocol/sdk/types.js" import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js" import { z } from "zod" import { BaseToolImplementation } from "./BaseTool.js" import { // CategoryV5, PositionInfoParamsV5, APIResponseV3WithTime, PositionV5 } from "bybit-api" // Zod schema for input validation const inputSchema = z.object({ category: z.enum(["linear", "inverse"]), symbol: z.string().optional(), baseCoin: z.string().optional(), settleCoin: z.string().optional(), limit: z.enum(["1", "10", "50", "100", "200"]).optional() }) type ToolArguments = z.infer<typeof inputSchema> // Type for the formatted response interface FormattedPositionsResponse { category: "linear" | "inverse" symbol?: string baseCoin?: string settleCoin?: string limit: number data: PositionV5[] timestamp: string meta: { requestId: string } } class GetPositions extends BaseToolImplementation { name = "get_positions" toolDefinition: Tool = { name: this.name, description: "Get positions information for the authenticated user", inputSchema: { type: "object", properties: { category: { type: "string", description: "Product type", enum: ["linear", "inverse"], }, symbol: { type: "string", description: "Trading symbol, e.g., BTCUSDT", }, baseCoin: { type: "string", description: "Base coin. Used to get all symbols with this base coin", }, settleCoin: { type: "string", description: "Settle coin. Used to get all symbols with this settle coin", }, limit: { type: "string", description: "Maximum number of results (default: 200)", enum: ["1", "10", "50", "100", "200"], }, }, required: ["category"], }, } private async getPositionsData( params: PositionInfoParamsV5 ): Promise<APIResponseV3WithTime<{ list: PositionV5[] }>> { this.logInfo(`Fetching positions with params: ${JSON.stringify(params)}`) return await this.client.getPositionInfo(params) } async toolCall(request: z.infer<typeof CallToolRequestSchema>) { try { this.logInfo("Starting get_positions tool call") // Parse and validate input const validationResult = inputSchema.safeParse(request.params.arguments) if (!validationResult.success) { throw new Error(`Invalid input: ${validationResult.error.message}`) } const { category, symbol, baseCoin, settleCoin, limit = "200" } = validationResult.data this.logInfo(`Validated arguments - category: ${category}, symbol: ${symbol}, limit: ${limit}`) // Prepare request parameters const params: PositionInfoParamsV5 = { category, symbol, baseCoin, settleCoin, limit: parseInt(limit, 10) } // Execute API request with rate limiting and retry logic const response = await this.executeRequest(async () => { return await this.getPositionsData(params) }) // Format response const result: FormattedPositionsResponse = { category, symbol, baseCoin, settleCoin, limit: parseInt(limit, 10), data: response.list, timestamp: new Date().toISOString(), meta: { requestId: crypto.randomUUID() } } this.logInfo(`Successfully retrieved positions data${symbol ? ` for ${symbol}` : ''}`) return this.formatResponse(result) } catch (error) { this.logInfo(`Error in get_positions: ${error instanceof Error ? error.message : String(error)}`) return this.handleError(error) } } } export default GetPositions ``` -------------------------------------------------------------------------------- /webui/src/types/ai.ts: -------------------------------------------------------------------------------- ```typescript /** * TypeScript types for AI integration (OpenAI-compatible API) */ // Tool calling types (for function calling) export interface ToolCall { id: string; type: 'function'; function: { name: string; arguments: string; }; } export interface Tool { type: 'function'; function: { name: string; description: string; parameters: any; }; } // Chat message types export interface ChatMessage { role: 'system' | 'user' | 'assistant' | 'tool'; content: string | null; timestamp?: number; id?: string; tool_calls?: ToolCall[]; tool_call_id?: string; name?: string; } export interface ChatCompletionRequest { model: string; messages: ChatMessage[]; temperature?: number; max_tokens?: number; top_p?: number; frequency_penalty?: number; presence_penalty?: number; stream?: boolean; stop?: string | string[]; tools?: Tool[]; tool_choice?: 'auto' | 'none' | { type: 'function'; function: { name: string } }; } export interface ChatCompletionResponse { id: string; object: 'chat.completion'; created: number; model: string; choices: Array<{ index: number; message: ChatMessage; finish_reason: 'stop' | 'length' | 'content_filter' | null; }>; usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number; }; } export interface ChatCompletionStreamResponse { id: string; object: 'chat.completion.chunk'; created: number; model: string; choices: Array<{ index: number; delta: { role?: 'assistant'; content?: string; }; finish_reason: 'stop' | 'length' | 'content_filter' | null; }>; } // AI configuration export interface AIConfig { endpoint: string; model: string; temperature: number; maxTokens: number; systemPrompt: string; } // Chat UI types export interface ChatUIMessage extends ChatMessage { id: string; timestamp: number; isStreaming?: boolean; error?: string; } export interface ChatState { messages: ChatUIMessage[]; isLoading: boolean; isConnected: boolean; currentStreamingId?: string; } // AI service interface export interface AIService { chat(messages: ChatMessage[], options?: Partial<ChatCompletionRequest>): Promise<ChatCompletionResponse>; streamChat( messages: ChatMessage[], onChunk: (chunk: ChatCompletionStreamResponse) => void, options?: Partial<ChatCompletionRequest> ): Promise<void>; isConnected(): Promise<boolean>; } // Error types export interface AIError { code: string; message: string; details?: unknown; } // Model information export interface ModelInfo { id: string; name: string; description?: string; contextLength?: number; capabilities?: string[]; } // Conversation types export interface Conversation { id: string; title: string; messages: ChatUIMessage[]; createdAt: number; updatedAt: number; } export interface ConversationSummary { id: string; title: string; lastMessage?: string; messageCount: number; createdAt: number; updatedAt: number; } // Settings types export interface ChatSettings { ai: AIConfig; mcp: { endpoint: string; timeout: number; }; ui: { theme: 'light' | 'dark' | 'auto'; fontSize: 'small' | 'medium' | 'large'; showTimestamps: boolean; enableSounds: boolean; }; } // Event types for real-time updates export type ChatEvent = | { type: 'message_start'; messageId: string } | { type: 'message_chunk'; messageId: string; content: string } | { type: 'message_complete'; messageId: string } | { type: 'message_error'; messageId: string; error: string } | { type: 'connection_status'; connected: boolean } | { type: 'typing_start' } | { type: 'typing_stop' }; // Utility types export type MessageRole = ChatMessage['role']; export type StreamingState = 'idle' | 'connecting' | 'streaming' | 'complete' | 'error'; ``` -------------------------------------------------------------------------------- /client/src/launch.ts: -------------------------------------------------------------------------------- ```typescript #!/usr/bin/env node import { existsSync } from 'fs' import { join } from 'path' import { execSync, spawn } from 'child_process' import { fileURLToPath } from 'url' import { dirname } from 'path' import { validateEnv, getEnvConfig } from './env.js' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) // Cache file to store last check timestamp const CACHE_FILE = join(__dirname, '..', '.install-check') const CHECK_INTERVAL = 1000 * 60 * 60 // 1 hour function shouldCheckDependencies(): boolean { try { if (existsSync(CACHE_FILE)) { const stat = execSync(`stat -f %m "${CACHE_FILE}"`).toString().trim() const lastCheck = parseInt(stat, 10) * 1000 // Convert to milliseconds return Date.now() - lastCheck > CHECK_INTERVAL } return true } catch { return true } } function updateCheckTimestamp(): void { try { execSync(`touch "${CACHE_FILE}"`) } catch (error) { console.warn('Warning: Could not update dependency check timestamp') } } interface OllamaModel { name: string } interface OllamaListResponse { models: OllamaModel[] } function checkOllama(): boolean { const config = getEnvConfig() try { // First check if we can connect to Ollama const tagsResponse = execSync(`curl -s ${config.ollamaHost}/api/tags`).toString() // Parse the response to check if the required model exists const models = JSON.parse(tagsResponse) as OllamaListResponse const modelExists = models.models.some(model => model.name === config.defaultModel) if (!modelExists) { console.error(`Error: Model "${config.defaultModel}" not found on Ollama server at ${config.ollamaHost}`) console.log('Available models:', models.models.map(m => m.name).join(', ')) console.log(`\nTo pull the required model, run:\ncurl -X POST ${config.ollamaHost}/api/pull -d '{"name": "${config.defaultModel}"}'`) return false } return true } catch (error) { console.error('Error checking Ollama:', error) return false } } function quickDependencyCheck(): boolean { const nodeModulesPath = join(__dirname, '..', 'node_modules') const buildPath = join(__dirname, '..', 'build') if (!existsSync(nodeModulesPath) || !existsSync(buildPath)) { console.log('Installing and building...') try { execSync('pnpm install && pnpm run build', { stdio: 'inherit', cwd: join(__dirname, '..') }) } catch (error) { console.error('Failed to install dependencies and build') return false } } return true } async function main() { try { // Validate environment configuration first validateEnv() const config = getEnvConfig() // Check Ollama connection and model availability if (!checkOllama()) { process.exit(1) } // Only check dependencies if needed if (shouldCheckDependencies()) { if (!quickDependencyCheck()) { process.exit(1) } updateCheckTimestamp() } // Enable debug mode for better error reporting process.env.DEBUG = 'true' // Start the chat interface in integrated mode with debug enabled spawn('node', ['build/cli.js', '--integrated', '--debug', 'chat'], { stdio: 'inherit', cwd: join(__dirname, '..'), env: { ...process.env, OLLAMA_HOST: config.ollamaHost, DEFAULT_MODEL: config.defaultModel } }) } catch (error) { if (error instanceof Error) { console.error('Error:', error.message) if (process.env.DEBUG === 'true') { console.error('Stack trace:', error.stack) } } else { console.error('Unknown error occurred') } process.exit(1) } } main().catch(error => { console.error('Error:', error) if (process.env.DEBUG === 'true') { console.error('Stack trace:', error.stack) } process.exit(1) }) ``` -------------------------------------------------------------------------------- /src/tools/GetOrderbook.ts: -------------------------------------------------------------------------------- ```typescript import { Tool } from "@modelcontextprotocol/sdk/types.js" import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js" import { z } from "zod" import { BaseToolImplementation } from "./BaseTool.js" import { CONSTANTS } from "../constants.js" import { // CategoryV5, GetOrderbookParamsV5, APIResponseV3WithTime } from "bybit-api" // Zod schema for input validation const inputSchema = z.object({ symbol: z.string() .min(1, "Symbol is required") .regex(/^[A-Z0-9]+$/, "Symbol must contain only uppercase letters and numbers"), category: z.enum(["spot", "linear", "inverse"]).optional(), limit: z.union([ z.enum(["1", "25", "50", "100", "200"]), z.number().transform(n => { const validLimits = [1, 25, 50, 100, 200] const closest = validLimits.reduce((prev, curr) => Math.abs(curr - n) < Math.abs(prev - n) ? curr : prev ) return String(closest) }) ]).optional() }) type SupportedCategory = z.infer<typeof inputSchema>["category"] type ToolArguments = z.infer<typeof inputSchema> // Type for Bybit orderbook response interface OrderbookData { s: string // Symbol b: [string, string][] // Bids [price, size] a: [string, string][] // Asks [price, size] ts: number // Timestamp u: number // Update ID } class GetOrderbook extends BaseToolImplementation { name = "get_orderbook" toolDefinition: Tool = { name: this.name, description: "Get orderbook (market depth) data for a trading pair", inputSchema: { type: "object", properties: { symbol: { type: "string", description: "Trading pair symbol (e.g., 'BTCUSDT')", pattern: "^[A-Z0-9]+$" }, category: { type: "string", description: "Category of the instrument (spot, linear, inverse)", enum: ["spot", "linear", "inverse"], }, limit: { type: "string", description: "Limit for the number of bids and asks (1, 25, 50, 100, 200)", enum: ["1", "25", "50", "100", "200"], }, }, required: ["symbol"], }, } private async getOrderbookData( symbol: string, category: "spot" | "linear" | "inverse", limit: string ): Promise<APIResponseV3WithTime<OrderbookData>> { const params: GetOrderbookParamsV5 = { category, symbol, limit: parseInt(limit, 10), } this.logInfo(`Fetching orderbook with params: ${JSON.stringify(params)}`) return await this.client.getOrderbook(params) } async toolCall(request: z.infer<typeof CallToolRequestSchema>) { try { this.logInfo("Starting get_orderbook tool call") // Parse and validate input const validationResult = inputSchema.safeParse(request.params.arguments) if (!validationResult.success) { throw new Error(`Invalid input: ${JSON.stringify(validationResult.error.errors)}`) } const { symbol, category = CONSTANTS.DEFAULT_CATEGORY as "spot" | "linear" | "inverse", limit = "25" } = validationResult.data this.logInfo(`Validated arguments - symbol: ${symbol}, category: ${category}, limit: ${limit}`) // Execute API request with rate limiting and retry logic const response = await this.executeRequest(async () => { const data = await this.getOrderbookData(symbol, category, limit) return data }) // Format response const result = { symbol, category, limit: parseInt(limit, 10), asks: response.a, bids: response.b, timestamp: response.ts, updateId: response.u, meta: { requestId: crypto.randomUUID() } } this.logInfo(`Successfully retrieved orderbook data for ${symbol}`) return this.formatResponse(result) } catch (error) { this.logInfo(`Error in get_orderbook: ${error instanceof Error ? error.message : String(error)}`) return this.handleError(error) } } } export default GetOrderbook ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript #!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js" import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import { CallToolRequestSchema, ListToolsRequestSchema, LoggingLevel, // SetLevelRequest } from "@modelcontextprotocol/sdk/types.js" import { z } from "zod" import { CONSTANTS } from "./constants.js" import { loadTools, createToolsMap } from "./utils/toolLoader.js" import { validateEnv } from "./env.js" // Standard JSON-RPC error codes const PARSE_ERROR = -32700 const INVALID_REQUEST = -32600 const METHOD_NOT_FOUND = -32601 const INVALID_PARAMS = -32602 const INTERNAL_ERROR = -32603 const { PROJECT_NAME, PROJECT_VERSION } = CONSTANTS let toolsMap: Map<string, any> // Create schema for logging request const LoggingRequestSchema = z.object({ method: z.literal("logging/setLevel"), params: z.object({ level: z.enum([ "debug", "info", "notice", "warning", "error", "critical", "alert", "emergency" ] as const) }) }) const server = new Server( { name: PROJECT_NAME, version: PROJECT_VERSION, }, { capabilities: { tools: { listChanged: true }, resources: { subscribe: true, listChanged: true }, prompts: { listChanged: true }, logging: {} }, } ) // Set up logging handler server.setRequestHandler(LoggingRequestSchema, async (request) => { const level = request.params.level // Configure logging level return {} }) server.setRequestHandler(ListToolsRequestSchema, async () => { if (!toolsMap || toolsMap.size === 0) { return { tools: [] } } return { tools: Array.from(toolsMap.values()).map((tool) => tool.toolDefinition), } }) server.setRequestHandler(CallToolRequestSchema, async (request) => { try { if (!toolsMap) { throw new Error("Tools not initialized") } const tool = toolsMap.get(request.params.name) if (!tool) { throw { code: METHOD_NOT_FOUND, message: `Unknown tool: ${request.params.name}. Available tools: ${Array.from( toolsMap.keys() ).join(", ")}` } } if (!request.params.arguments || typeof request.params.arguments !== 'object') { throw { code: INVALID_PARAMS, message: "Invalid or missing arguments" } } return tool.toolCall(request) } catch (error: any) { if (error.code) { throw error } throw { code: INTERNAL_ERROR, message: error instanceof Error ? error.message : String(error) } } }) function formatJsonRpcMessage(level: LoggingLevel, message: string) { return { jsonrpc: "2.0", method: "notifications/message", params: { level, message, logger: "bybit-mcp" }, } } async function main() { try { // Validate environment configuration validateEnv() const tools = await loadTools() toolsMap = createToolsMap(tools) if (tools.length === 0) { console.log(JSON.stringify(formatJsonRpcMessage( "warning", "No tools were loaded. Server will start but may have limited functionality." ))) } else { console.log(JSON.stringify(formatJsonRpcMessage( "info", `Loaded ${tools.length} tools: ${tools.map(t => t.name).join(", ")}` ))) } const transport = new StdioServerTransport() await server.connect(transport) console.log(JSON.stringify(formatJsonRpcMessage( "info", "Server started successfully" ))) } catch (error) { console.error(JSON.stringify(formatJsonRpcMessage( "error", error instanceof Error ? error.message : String(error) ))) process.exit(1) } } process.on("unhandledRejection", (error) => { console.error(JSON.stringify(formatJsonRpcMessage( "error", error instanceof Error ? error.message : String(error) ))) }) main().catch((error) => { console.error(JSON.stringify(formatJsonRpcMessage( "error", error instanceof Error ? error.message : String(error) ))) process.exit(1) }) ``` -------------------------------------------------------------------------------- /src/tools/GetKline.ts: -------------------------------------------------------------------------------- ```typescript import { Tool } from "@modelcontextprotocol/sdk/types.js" import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js" import { z } from "zod" import { BaseToolImplementation } from "./BaseTool.js" import { CONSTANTS } from "../constants.js" import { // CategoryV5, GetKlineParamsV5, } from "bybit-api" type SupportedCategory = "spot" | "linear" | "inverse" type Interval = "1" | "3" | "5" | "15" | "30" | "60" | "120" | "240" | "360" | "720" | "D" | "M" | "W" // Zod schema for input validation const inputSchema = z.object({ symbol: z.string().min(1, "Symbol is required"), category: z.enum(["spot", "linear", "inverse"]).optional(), interval: z.enum(["1", "3", "5", "15", "30", "60", "120", "240", "360", "720", "D", "M", "W"]).optional(), limit: z.number().min(1).max(1000).optional().default(200), includeReferenceId: z.boolean().optional().default(false) }) type ToolArguments = z.infer<typeof inputSchema> class GetKline extends BaseToolImplementation { name = "get_kline"; toolDefinition: Tool = { name: this.name, description: "Get kline/candlestick data for a trading pair. Supports optional reference ID for data verification.", inputSchema: { type: "object", properties: { symbol: { type: "string", description: "Trading pair symbol (e.g., 'BTCUSDT')", }, category: { type: "string", description: "Category of the instrument (spot, linear, inverse)", enum: ["spot", "linear", "inverse"], }, interval: { type: "string", description: "Kline interval", enum: ["1", "3", "5", "15", "30", "60", "120", "240", "360", "720", "D", "M", "W"], }, limit: { type: "number", description: "Limit for the number of candles (max 1000)", minimum: 1, maximum: 1000, }, includeReferenceId: { type: "boolean", description: "Include reference ID and metadata for data verification (default: false)", } }, required: ["symbol"], }, }; async toolCall(request: z.infer<typeof CallToolRequestSchema>) { try { this.logInfo("Starting get_kline tool call") // Parse and validate input const validationResult = inputSchema.safeParse(request.params.arguments) if (!validationResult.success) { const errorDetails = validationResult.error.errors.map(err => ({ field: err.path.join('.'), message: err.message, code: err.code })) throw new Error(`Invalid input: ${JSON.stringify(errorDetails)}`) } const { symbol, category = CONSTANTS.DEFAULT_CATEGORY as SupportedCategory, interval = CONSTANTS.DEFAULT_INTERVAL as Interval, limit, includeReferenceId } = validationResult.data this.logInfo(`Validated arguments - symbol: ${symbol}, category: ${category}, interval: ${interval}, limit: ${limit}, includeReferenceId: ${includeReferenceId}`) const params: GetKlineParamsV5 = { category, symbol, interval, limit, } // Execute API request with rate limiting and retry logic const response = await this.executeRequest(async () => { return await this.client.getKline(params) }) // Transform the kline data into a more readable format const formattedKlines = response.list.map(kline => ({ timestamp: kline[0], open: kline[1], high: kline[2], low: kline[3], close: kline[4], volume: kline[5], turnover: kline[6] })) const result = { symbol, category, interval, limit, data: formattedKlines } // Add reference metadata if requested const resultWithMetadata = this.addReferenceMetadata( result, includeReferenceId, this.name, `/v5/market/kline` ) this.logInfo(`Successfully retrieved kline data for ${symbol}`) return this.formatResponse(resultWithMetadata) } catch (error) { this.logInfo(`Error in get_kline: ${error instanceof Error ? error.message : String(error)}`) return this.handleError(error) } } } export default GetKline ``` -------------------------------------------------------------------------------- /src/tools/GetInstrumentInfo.ts: -------------------------------------------------------------------------------- ```typescript import { Tool } from "@modelcontextprotocol/sdk/types.js" import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js" import { z } from "zod" import { BaseToolImplementation } from "./BaseTool.js" import { CONSTANTS } from "../constants.js" import { // CategoryV5, GetInstrumentsInfoParamsV5, SpotInstrumentInfoV5, LinearInverseInstrumentInfoV5 } from "bybit-api" type SupportedCategory = "spot" | "linear" | "inverse" class GetInstrumentInfo extends BaseToolImplementation { name = "get_instrument_info"; toolDefinition: Tool = { name: this.name, description: "Get detailed instrument information for a specific trading pair", inputSchema: { type: "object", properties: { symbol: { type: "string", description: "Trading pair symbol (e.g., 'BTCUSDT')", }, category: { type: "string", description: "Category of the instrument (spot, linear, inverse)", enum: ["spot", "linear", "inverse"], }, }, required: ["symbol"], }, }; async toolCall(request: z.infer<typeof CallToolRequestSchema>) { try { const args = request.params.arguments as unknown if (!args || typeof args !== 'object') { throw new Error("Invalid arguments") } const typedArgs = args as Record<string, unknown> if (!typedArgs.symbol || typeof typedArgs.symbol !== 'string') { throw new Error("Missing or invalid symbol parameter") } const symbol = typedArgs.symbol const category = ( typedArgs.category && typeof typedArgs.category === 'string' && ["spot", "linear", "inverse"].includes(typedArgs.category) ) ? typedArgs.category as SupportedCategory : CONSTANTS.DEFAULT_CATEGORY as SupportedCategory const params: GetInstrumentsInfoParamsV5 = { category, symbol, } const response = await this.client.getInstrumentsInfo(params) if (response.retCode !== 0) { throw new Error(`Bybit API error: ${response.retMsg}`) } if (!response.result.list || response.result.list.length === 0) { throw new Error(`No instrument info found for symbol: ${symbol}`) } const info = response.result.list[0] let formattedInfo: any if (category === 'spot') { const spotInfo = info as SpotInstrumentInfoV5 formattedInfo = { symbol: spotInfo.symbol, status: spotInfo.status, baseCoin: spotInfo.baseCoin, quoteCoin: spotInfo.quoteCoin, innovation: spotInfo.innovation === "1", marginTrading: spotInfo.marginTrading, lotSizeFilter: { basePrecision: spotInfo.lotSizeFilter.basePrecision, quotePrecision: spotInfo.lotSizeFilter.quotePrecision, minOrderQty: spotInfo.lotSizeFilter.minOrderQty, maxOrderQty: spotInfo.lotSizeFilter.maxOrderQty, minOrderAmt: spotInfo.lotSizeFilter.minOrderAmt, maxOrderAmt: spotInfo.lotSizeFilter.maxOrderAmt, }, priceFilter: { tickSize: spotInfo.priceFilter.tickSize, }, } } else { const futuresInfo = info as LinearInverseInstrumentInfoV5 formattedInfo = { symbol: futuresInfo.symbol, status: futuresInfo.status, baseCoin: futuresInfo.baseCoin, quoteCoin: futuresInfo.quoteCoin, settleCoin: futuresInfo.settleCoin, contractType: futuresInfo.contractType, launchTime: futuresInfo.launchTime, deliveryTime: futuresInfo.deliveryTime, deliveryFeeRate: futuresInfo.deliveryFeeRate, priceFilter: { tickSize: futuresInfo.priceFilter.tickSize, }, lotSizeFilter: { qtyStep: futuresInfo.lotSizeFilter.qtyStep, minOrderQty: futuresInfo.lotSizeFilter.minOrderQty, maxOrderQty: futuresInfo.lotSizeFilter.maxOrderQty, }, leverageFilter: { minLeverage: futuresInfo.leverageFilter.minLeverage, maxLeverage: futuresInfo.leverageFilter.maxLeverage, leverageStep: futuresInfo.leverageFilter.leverageStep, }, fundingInterval: futuresInfo.fundingInterval, } } // Add category and timestamp to the root level formattedInfo.category = category formattedInfo.retrievedAt = new Date().toISOString() return this.formatResponse(formattedInfo) } catch (error) { return this.handleError(error) } } } export default GetInstrumentInfo ``` -------------------------------------------------------------------------------- /webui/Dockerfile: -------------------------------------------------------------------------------- ```dockerfile # ============================================================================== # Bybit MCP WebUI - Two-stage Docker Build # ============================================================================== # This Dockerfile creates an optimized container that includes both: # - The Bybit MCP Server (backend API) # - The WebUI (frontend interface) # # Built with Node.js 22 and following Docker best practices for: # - Two-stage build for smaller final image # - Layer caching optimization # - Security hardening # - Production-ready configuration # ============================================================================== # ============================================================================== # Stage 1: Build Stage # ============================================================================== FROM node:22-alpine AS builder # Install system dependencies RUN apk update && \ apk upgrade && \ apk add --no-cache \ curl \ ca-certificates && \ rm -rf /var/cache/apk/* # Enable pnpm ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable # Set working directory WORKDIR /app # Copy package files for dependency installation COPY package.json pnpm-lock.yaml* ./ COPY webui/package.json webui/pnpm-lock.yaml* ./webui/ # Install all dependencies (including dev dependencies for building) # Skip prepare scripts during dependency installation RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm install --frozen-lockfile --prefer-offline --ignore-scripts # Install WebUI dependencies WORKDIR /app/webui RUN --mount=type=cache,id=pnpm-webui,target=/pnpm/store \ pnpm install --frozen-lockfile --prefer-offline # Copy source code WORKDIR /app COPY . . # Build MCP Server (run tsc manually to avoid prepare script issues) RUN npx tsc && node -e "require('fs').chmodSync('build/index.js', '755')" # Build WebUI with environment variables WORKDIR /app/webui ARG OLLAMA_HOST ARG MCP_ENDPOINT ENV OLLAMA_HOST=${OLLAMA_HOST} ENV MCP_ENDPOINT=${MCP_ENDPOINT} RUN pnpm build # Install only production dependencies for final stage WORKDIR /app RUN --mount=type=cache,id=pnpm-prod,target=/pnpm/store \ pnpm install --frozen-lockfile --prod --prefer-offline --ignore-scripts # ============================================================================== # Stage 2: Production Image # ============================================================================== FROM node:22-alpine AS production # Install system dependencies and security updates RUN apk update && \ apk upgrade && \ apk add --no-cache \ tini \ curl \ ca-certificates && \ rm -rf /var/cache/apk/* # Create non-root user for security RUN addgroup -g 1001 -S nodejs && \ adduser -S bybit -u 1001 -G nodejs # Set production environment ENV NODE_ENV=production ENV PORT=8080 ENV MCP_PORT=8080 ENV MCP_HTTP_PORT=8080 ENV MCP_HTTP_HOST=0.0.0.0 ENV HOST=0.0.0.0 # Enable pnpm ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable # Set working directory WORKDIR /app # Copy production dependencies COPY --from=builder --chown=bybit:nodejs /app/node_modules ./node_modules # Copy built applications COPY --from=builder --chown=bybit:nodejs /app/build ./build COPY --from=builder --chown=bybit:nodejs /app/webui/dist ./webui/dist # Copy necessary configuration files COPY --chown=bybit:nodejs package.json ./ COPY --chown=bybit:nodejs webui/package.json ./webui/ # Copy entrypoint and health check scripts COPY --chown=bybit:nodejs webui/docker-entrypoint.sh ./docker-entrypoint.sh COPY --chown=bybit:nodejs webui/docker-healthcheck.sh ./docker-healthcheck.sh # Make scripts executable RUN chmod +x ./docker-entrypoint.sh ./docker-healthcheck.sh # Switch to non-root user USER bybit:nodejs # Expose port EXPOSE 8080 # Add health check HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD ./docker-healthcheck.sh # Add labels for better container management LABEL maintainer="Sam McLeod" \ description="Bybit MCP WebUI - Trading interface with AI chat capabilities" \ version="1.0.0" \ org.opencontainers.image.title="Bybit MCP WebUI" \ org.opencontainers.image.description="Modern web interface for Bybit MCP server with AI chat capabilities" \ org.opencontainers.image.vendor="Sam McLeod" \ org.opencontainers.image.version="1.0.0" \ org.opencontainers.image.source="https://github.com/sammcj/bybit-mcp" \ org.opencontainers.image.licenses="MIT" # Use tini as init system for proper signal handling ENTRYPOINT ["/sbin/tini", "--"] # Start the application CMD ["./docker-entrypoint.sh"] ``` -------------------------------------------------------------------------------- /src/tools/GetOrderHistory.ts: -------------------------------------------------------------------------------- ```typescript import { Tool } from "@modelcontextprotocol/sdk/types.js" import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js" import { z } from "zod" import { BaseToolImplementation } from "./BaseTool.js" import { // CategoryV5, GetAccountHistoricOrdersParamsV5, } from "bybit-api" type SupportedCategory = "spot" | "linear" | "inverse" type OrderStatus = "Created" | "New" | "Rejected" | "PartiallyFilled" | "PartiallyFilledCanceled" | "Filled" | "Cancelled" | "Untriggered" | "Triggered" | "Deactivated" type OrderFilter = "Order" | "StopOrder" class GetOrderHistory extends BaseToolImplementation { name = "get_order_history"; toolDefinition: Tool = { name: this.name, description: "Get order history for the authenticated user", inputSchema: { type: "object", properties: { category: { type: "string", description: "Product type", enum: ["spot", "linear", "inverse"], default: "spot", }, symbol: { type: "string", description: "Trading symbol, e.g., BTCUSDT", }, baseCoin: { type: "string", description: "Base coin. Used to get all symbols with this base coin", }, orderId: { type: "string", description: "Order ID", }, orderLinkId: { type: "string", description: "User customised order ID", }, orderStatus: { type: "string", description: "Order status", enum: ["Created", "New", "Rejected", "PartiallyFilled", "PartiallyFilledCanceled", "Filled", "Cancelled", "Untriggered", "Triggered", "Deactivated"], }, orderFilter: { type: "string", description: "Order filter", enum: ["Order", "StopOrder"], }, limit: { type: "string", description: "Maximum number of results (default: 200)", enum: ["1", "10", "50", "100", "200"], }, }, required: ["category"], }, }; async toolCall(request: z.infer<typeof CallToolRequestSchema>) { try { const args = request.params.arguments as unknown if (!args || typeof args !== 'object') { throw new Error("Invalid arguments") } const typedArgs = args as Record<string, unknown> if (!typedArgs.category || typeof typedArgs.category !== 'string' || !["spot", "linear", "inverse"].includes(typedArgs.category)) { throw new Error("Missing or invalid category parameter") } const category = typedArgs.category as SupportedCategory const symbol = typedArgs.symbol && typeof typedArgs.symbol === 'string' ? typedArgs.symbol : undefined const baseCoin = typedArgs.baseCoin && typeof typedArgs.baseCoin === 'string' ? typedArgs.baseCoin : undefined const orderId = typedArgs.orderId && typeof typedArgs.orderId === 'string' ? typedArgs.orderId : undefined const orderLinkId = typedArgs.orderLinkId && typeof typedArgs.orderLinkId === 'string' ? typedArgs.orderLinkId : undefined const orderStatus = ( typedArgs.orderStatus && typeof typedArgs.orderStatus === 'string' && ["Created", "New", "Rejected", "PartiallyFilled", "PartiallyFilledCanceled", "Filled", "Cancelled", "Untriggered", "Triggered", "Deactivated"].includes(typedArgs.orderStatus) ) ? typedArgs.orderStatus as OrderStatus : undefined const orderFilter = ( typedArgs.orderFilter && typeof typedArgs.orderFilter === 'string' && ["Order", "StopOrder"].includes(typedArgs.orderFilter) ) ? typedArgs.orderFilter as OrderFilter : undefined const limit = ( typedArgs.limit && typeof typedArgs.limit === 'string' && ["1", "10", "50", "100", "200"].includes(typedArgs.limit) ) ? parseInt(typedArgs.limit, 10) : 200 const params: GetAccountHistoricOrdersParamsV5 = { category, symbol, baseCoin, orderId, orderLinkId, orderStatus, orderFilter, limit, } const response = await this.client.getHistoricOrders(params) if (response.retCode !== 0) { throw new Error(`Bybit API error: ${response.retMsg}`) } return this.formatResponse({ category, symbol, baseCoin, orderId, orderLinkId, orderStatus, orderFilter, limit, data: response.result.list, retrievedAt: new Date().toISOString(), }) } catch (error) { return this.handleError(error) } } } export default GetOrderHistory ``` -------------------------------------------------------------------------------- /src/tools/GetTicker.ts: -------------------------------------------------------------------------------- ```typescript import { Tool, CallToolResult } from "@modelcontextprotocol/sdk/types.js" import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js" import { z } from "zod" import { BaseToolImplementation } from "./BaseTool.js" import { CONSTANTS } from "../constants.js" import { // CategoryV5, GetTickersParamsV5, TickerSpotV5, TickerLinearInverseV5, APIResponseV3WithTime, CategoryListV5 } from "bybit-api" // Zod schema for input validation const inputSchema = z.object({ symbol: z.string() .min(1, "Symbol is required") .regex(/^[A-Z0-9]+$/, "Symbol must contain only uppercase letters and numbers"), category: z.enum(["spot", "linear", "inverse"]).optional(), includeReferenceId: z.boolean().optional().default(false) }) type SupportedCategory = z.infer<typeof inputSchema>["category"] type ToolArguments = z.infer<typeof inputSchema> class GetTicker extends BaseToolImplementation { name = "get_ticker" toolDefinition: Tool = { name: this.name, description: "Get real-time ticker information for a trading pair. Supports optional reference ID for data verification.", inputSchema: { type: "object", properties: { symbol: { type: "string", description: "Trading pair symbol (e.g., 'BTCUSDT')", pattern: "^[A-Z0-9]+$", annotations: { priority: 1 // Required parameter } }, category: { type: "string", description: "Category of the instrument (spot, linear, inverse)", enum: ["spot", "linear", "inverse"], annotations: { priority: 0 // Optional parameter } }, includeReferenceId: { type: "boolean", description: "Include reference ID and metadata for data verification (default: false)", annotations: { priority: 0 // Optional parameter } } }, required: ["symbol"] } } private async getTickerData( symbol: string, category: "spot" | "linear" | "inverse" ): Promise<APIResponseV3WithTime<CategoryListV5<TickerSpotV5[] | TickerLinearInverseV5[], typeof category>>> { if (category === "spot") { const params: GetTickersParamsV5<"spot"> = { category: "spot", symbol } return await this.client.getTickers(params) } else { const params: GetTickersParamsV5<"linear" | "inverse"> = { category: category, symbol } return await this.client.getTickers(params) } } async toolCall(request: z.infer<typeof CallToolRequestSchema>): Promise<CallToolResult> { try { this.logInfo("Starting get_ticker tool call") // Parse and validate input const validationResult = inputSchema.safeParse(request.params.arguments) if (!validationResult.success) { const errorDetails = validationResult.error.errors.map(err => ({ field: err.path.join('.'), message: err.message, code: err.code })) throw new Error(`Invalid input: ${JSON.stringify(errorDetails)}`) } const { symbol, category = CONSTANTS.DEFAULT_CATEGORY as "spot" | "linear" | "inverse", includeReferenceId } = validationResult.data this.logInfo(`Validated arguments - symbol: ${symbol}, category: ${category}, includeReferenceId: ${includeReferenceId}`) // Execute API request with rate limiting and retry logic const response = await this.executeRequest(async () => { return await this.getTickerData(symbol, category) }) // Extract the first ticker from the list const ticker = response.list[0] if (!ticker) { throw new Error(`No ticker data found for ${symbol}`) } // Format response with lastPrice at root level const baseResult = { timestamp: new Date().toISOString(), meta: { requestId: crypto.randomUUID() }, symbol, category, lastPrice: ticker.lastPrice, price24hPcnt: ticker.price24hPcnt, highPrice24h: ticker.highPrice24h, lowPrice24h: ticker.lowPrice24h, prevPrice24h: ticker.prevPrice24h, volume24h: ticker.volume24h, turnover24h: ticker.turnover24h, bid1Price: ticker.bid1Price, bid1Size: ticker.bid1Size, ask1Price: ticker.ask1Price, ask1Size: ticker.ask1Size } // Add reference metadata if requested const resultWithMetadata = this.addReferenceMetadata( baseResult, includeReferenceId, this.name, `/v5/market/tickers` ) this.logInfo(`Successfully retrieved ticker data for ${symbol}`) return this.formatResponse(resultWithMetadata) } catch (error) { this.logInfo(`Error in get_ticker: ${error instanceof Error ? error.message : String(error)}`) return this.handleError(error) } } } export default GetTicker ``` -------------------------------------------------------------------------------- /webui/src/styles/data-cards.css: -------------------------------------------------------------------------------- ```css /* Data Cards - Expandable cards for visualising tool response data */ .data-card { background: var(--bg-elevated); border: 1px solid var(--border-primary); border-radius: var(--radius-lg); margin: var(--spacing-sm) 0; overflow: hidden; transition: all var(--transition-normal); box-shadow: var(--shadow-sm); } .data-card:hover { border-color: var(--border-secondary); box-shadow: var(--shadow-md); } .data-card.expanded { border-color: var(--color-primary); } /* Card Header */ .data-card-header { display: flex; align-items: center; justify-content: space-between; padding: var(--spacing-md); cursor: pointer; user-select: none; transition: background-color var(--transition-fast); } .data-card-header:hover { background: var(--bg-secondary); } .data-card-header:focus { outline: 2px solid var(--color-primary); outline-offset: -2px; } .data-card-title { display: flex; align-items: center; gap: var(--spacing-sm); flex: 1; } .data-card-icon { font-size: 1.2em; opacity: 0.8; } .data-card-title h4 { margin: 0; font-size: var(--font-size-base); font-weight: var(--font-weight-semibold); color: var(--text-primary); } .data-card-controls { display: flex; align-items: center; gap: var(--spacing-md); } .data-card-summary { font-size: var(--font-size-sm); color: var(--text-secondary); font-family: var(--font-family-mono); background: var(--bg-tertiary); padding: var(--spacing-xs) var(--spacing-sm); border-radius: var(--radius-sm); } .expand-toggle { background: none; border: none; cursor: pointer; padding: var(--spacing-xs); border-radius: var(--radius-sm); transition: all var(--transition-fast); display: flex; align-items: center; justify-content: center; min-width: 24px; min-height: 24px; } .expand-toggle:hover { background: var(--bg-tertiary); } .expand-toggle:focus { outline: 2px solid var(--color-primary); outline-offset: 2px; } .expand-icon { font-size: var(--font-size-sm); color: var(--text-secondary); transition: transform var(--transition-fast); } .data-card.expanded .expand-icon { transform: rotate(0deg); } .data-card.collapsed .expand-icon { transform: rotate(-90deg); } /* Card Content */ .data-card-content { border-top: 1px solid var(--border-primary); animation: slideDown var(--transition-normal) ease-out; } .data-card.collapsed .data-card-content { animation: slideUp var(--transition-normal) ease-out; } .data-card-details { padding: var(--spacing-md); } .data-preview { background: var(--bg-secondary); border: 1px solid var(--border-primary); border-radius: var(--radius-sm); padding: var(--spacing-sm); font-family: var(--font-family-mono); font-size: var(--font-size-sm); color: var(--text-secondary); max-height: 200px; overflow-y: auto; white-space: pre-wrap; word-break: break-word; } .data-card-chart { padding: var(--spacing-md); border-top: 1px solid var(--border-primary); background: var(--bg-secondary); } .chart-placeholder { text-align: center; padding: var(--spacing-xl); color: var(--text-tertiary); border: 2px dashed var(--border-secondary); border-radius: var(--radius-md); background: var(--bg-tertiary); } .chart-placeholder p { margin: 0 0 var(--spacing-xs) 0; font-weight: var(--font-weight-medium); } .chart-placeholder small { font-size: var(--font-size-xs); opacity: 0.7; } /* Data Type Specific Styling */ .data-card[data-type="kline"] { border-left: 4px solid #10b981; } .data-card[data-type="rsi"] { border-left: 4px solid #6366f1; } .data-card[data-type="orderBlocks"] { border-left: 4px solid #f59e0b; } .data-card[data-type="price"] { border-left: 4px solid #ef4444; } .data-card[data-type="volume"] { border-left: 4px solid #8b5cf6; } /* Animations */ @keyframes slideDown { from { opacity: 0; max-height: 0; transform: translateY(-10px); } to { opacity: 1; max-height: 500px; transform: translateY(0); } } @keyframes slideUp { from { opacity: 1; max-height: 500px; transform: translateY(0); } to { opacity: 0; max-height: 0; transform: translateY(-10px); } } /* Mobile Responsiveness */ @media (max-width: 768px) { .data-card-header { padding: var(--spacing-sm); } .data-card-controls { gap: var(--spacing-sm); } .data-card-summary { display: none; /* Hide summary on mobile to save space */ } .data-card-details, .data-card-chart { padding: var(--spacing-sm); } .data-preview { font-size: var(--font-size-xs); max-height: 150px; } } /* Accessibility */ @media (prefers-reduced-motion: reduce) { .data-card, .data-card-content, .expand-toggle, .expand-icon { transition: none; } .data-card-content { animation: none; } } /* High contrast mode */ @media (prefers-contrast: high) { .data-card { border-width: 2px; } .data-card-header:focus { outline-width: 3px; } .expand-toggle:focus { outline-width: 3px; } } ``` -------------------------------------------------------------------------------- /DEV_PLAN.md: -------------------------------------------------------------------------------- ```markdown # Bybit MCP WebUI Development Plan ## 📋 Project Overview A modern web interface for the Bybit MCP (Model Context Protocol) server that provides real-time cryptocurrency market data and advanced technical analysis tools through AI-powered chat interactions. ### 🎯 Core Concept - **AI Chat Interface**: Users ask questions about crypto markets in natural language - **MCP Integration**: AI automatically calls MCP tools to fetch real-time data from Bybit - **Data Visualization**: Charts and analysis are displayed alongside chat responses - **Technical Analysis**: ML-enhanced RSI, Order Blocks, Market Structure analysis ## 🚧 What's Remaining ### 4. **Enhanced Chat Features** **Tasks**: - [ ] **Chat history**: Add persisted chat history, we want to keep this very lightweight, simple and fast / low latency, we also don't want it getting too big, so we will limit it to the last 20 messages for up to 20 chats, it shouldn't rely on any external services. - [ ] Add in-memory caching of tool responses with a default TTL of 3 minutes (configurable) - [ ] **Chart Integration**: Auto-generate charts when AI mentions price data - [ ] **Analysis Widgets**: Embed analysis results directly in chat - [ ] **Message Actions**: Copy, share, regenerate responses - [ ] **Chat History**: Persistent conversation storage - [ ] **Quick Actions**: Predefined queries for common tasks ### 5. **Data Visualisation Improvements** **Tasks**: - [ ] **Real-time Updates**: WebSocket or polling for live data - [ ] **Multiple Symbols**: Support for comparing different cryptocurrencies - [ ] **Portfolio View**: Track multiple positions and P&L - [ ] **Alert System**: Price and indicator-based notifications ## 🔧 Technical Architecture ### **Frontend Stack** - **Framework**: Vanilla TypeScript with Vite - **Styling**: CSS with CSS variables for theming - **MCP Integration**: Official `@modelcontextprotocol/sdk` with HTTP fallback - **AI Integration**: OpenAI-compatible API (Ollama) ### **Backend Integration** - **MCP Server**: Node.js with Express HTTP server - **API Endpoints**: - `GET /tools` - List available tools - `POST /call-tool` - Execute tools - `GET /health` - Health check - **Data Source**: Bybit REST API ### **Development Setup** ```bash # Terminal 1: Start MCP Server pnpm run serve:http # Terminal 2: Start WebUI cd webui && pnpm dev ``` ## 🎯 Priority Tasks (Next Sprint) ### **High Priority** ✅ **COMPLETED** 1. **Charts Implementation** - Core value proposition ✅ 2. **MCP Tools Tab** - Essential for debugging and exploration ✅ 3. **Analysis Tab** - Showcase advanced features ✅ ### **Medium Priority** 1. **Chat Enhancements** - Improve user experience 2. **Real-time Updates** - Add live data streaming ### **Low Priority** 1. **Portfolio Features** - Advanced functionality 2. **Alert System** - Nice-to-have features ## 📚 Key Learnings ### **MCP Integration Challenges** - **Complex Protocol**: Official MCP SDK requires proper session management - **Solution**: Custom HTTP endpoints provide simpler integration path - **Hybrid Approach**: Use HTTP for tools, keep MCP protocol for future features ### **AI Tool Calling** - **Model Compatibility**: Not all models support function calling properly - **Fallback Strategy**: Text parsing works when native tool calls fail - **Format Issues**: Ollama expects `arguments` as string, not object ### **Development Workflow** - **Debug Console**: Essential for troubleshooting complex integrations - **Real-time Logging**: Dramatically improves development speed - **Incremental Testing**: Build and test each component separately ### **CORS and Proxying** - **Development**: Vite proxy handles CORS issues elegantly - **Production**: Direct API calls work with proper CORS headers ## 🚀 Success Metrics ### **Functional Goals** - [x] AI can fetch real-time crypto prices - [x] Tool calling works reliably - [x] Debug information is accessible - [ ] Charts display live market data - [ ] Analysis tools provide actionable insights ### **User Experience Goals** - [x] Intuitive chat interface - [x] Responsive design - [x] Error handling and recovery - [ ] Fast chart rendering - [ ] Seamless data updates ## 📝 Notes for Next Developer ### **Immediate Focus** Start with the **Charts Tab** as it provides the most user value. The `get_kline` tool is working and returns OHLCV data ready for charting. ### **Code Structure** - **Services**: Well-organized in `src/services/` - **Components**: Modular design in `src/components/` - **Types**: Comprehensive TypeScript definitions - **Debugging**: Use the debug console (`Ctrl+``) extensively ### **Testing Strategy** - Use debug console to verify tool calls - Test with different AI models (current: qwen3-30b) - Verify MCP server endpoints manually if needed ### **Known Working Examples** - Ask: "What's the current BTC price?" → Gets real data - Tool: `get_ticker(symbol="BTCUSDT", category="spot")` → Returns price data - All 12 MCP tools are loaded and accessible The foundation is solid - now it's time to build the visualization layer! 🎨 ``` -------------------------------------------------------------------------------- /webui/src/services/logService.ts: -------------------------------------------------------------------------------- ```typescript /** * Log Service - Captures and streams application logs for debugging */ export interface LogEntry { id: string; timestamp: number; level: 'log' | 'info' | 'warn' | 'error' | 'debug'; message: string; data?: any; source?: string; } export class LogService { private logs: LogEntry[] = []; private listeners: Set<(logs: LogEntry[]) => void> = new Set(); private maxLogs = 1000; // Keep last 1000 logs private originalConsole: { log: typeof console.log; info: typeof console.info; warn: typeof console.warn; error: typeof console.error; debug: typeof console.debug; }; constructor() { // Store original console methods this.originalConsole = { log: console.log.bind(console), info: console.info.bind(console), warn: console.warn.bind(console), error: console.error.bind(console), debug: console.debug.bind(console), }; // Intercept console methods this.interceptConsole(); } private interceptConsole(): void { const self = this; console.log = function(...args: any[]) { self.addLog('log', self.formatMessage(args), args.length > 1 ? args.slice(1) : undefined); self.originalConsole.log(...args); }; console.info = function(...args: any[]) { self.addLog('info', self.formatMessage(args), args.length > 1 ? args.slice(1) : undefined); self.originalConsole.info(...args); }; console.warn = function(...args: any[]) { self.addLog('warn', self.formatMessage(args), args.length > 1 ? args.slice(1) : undefined); self.originalConsole.warn(...args); }; console.error = function(...args: any[]) { self.addLog('error', self.formatMessage(args), args.length > 1 ? args.slice(1) : undefined); self.originalConsole.error(...args); }; console.debug = function(...args: any[]) { self.addLog('debug', self.formatMessage(args), args.length > 1 ? args.slice(1) : undefined); self.originalConsole.debug(...args); }; } private formatMessage(args: any[]): string { return args.map(arg => { if (typeof arg === 'string') { return arg; } else if (typeof arg === 'object') { try { return JSON.stringify(arg, null, 2); } catch { return String(arg); } } else { return String(arg); } }).join(' '); } private addLog(level: LogEntry['level'], message: string, data?: any): void { const entry: LogEntry = { id: `log_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, timestamp: Date.now(), level, message, data, source: this.getSource() }; this.logs.push(entry); // Keep only the last maxLogs entries if (this.logs.length > this.maxLogs) { this.logs = this.logs.slice(-this.maxLogs); } // Notify listeners this.notifyListeners(); } private getSource(): string { try { const stack = new Error().stack; if (stack) { const lines = stack.split('\n'); // Find the first line that's not from this service for (let i = 3; i < lines.length; i++) { const line = lines[i]; if (line && !line.includes('logService.ts') && !line.includes('console.')) { const match = line.match(/at\s+(.+?)\s+\((.+?):(\d+):(\d+)\)/); if (match) { const [, , file, lineNum] = match; const fileName = file.split('/').pop() || file; return `${fileName}:${lineNum}`; } } } } } catch { // Ignore errors in source detection } return 'unknown'; } /** * Get all logs */ getLogs(): LogEntry[] { return [...this.logs]; } /** * Get logs filtered by level */ getLogsByLevel(levels: LogEntry['level'][]): LogEntry[] { return this.logs.filter(log => levels.includes(log.level)); } /** * Clear all logs */ clearLogs(): void { this.logs = []; this.notifyListeners(); } /** * Subscribe to log updates */ subscribe(listener: (logs: LogEntry[]) => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); } /** * Export logs as text */ exportLogs(): string { return this.logs.map(log => { const time = new Date(log.timestamp).toISOString(); const level = log.level.toUpperCase().padEnd(5); const source = log.source ? ` [${log.source}]` : ''; return `${time} ${level}${source} ${log.message}`; }).join('\n'); } /** * Add a custom log entry */ addCustomLog(level: LogEntry['level'], message: string, data?: any): void { this.addLog(level, message, data); } private notifyListeners(): void { this.listeners.forEach(listener => { try { listener([...this.logs]); } catch (error) { this.originalConsole.error('Error in log listener:', error); } }); } /** * Restore original console methods */ restore(): void { console.log = this.originalConsole.log; console.info = this.originalConsole.info; console.warn = this.originalConsole.warn; console.error = this.originalConsole.error; console.debug = this.originalConsole.debug; } } // Create singleton instance export const logService = new LogService(); // Cleanup on page unload if (typeof window !== 'undefined') { window.addEventListener('beforeunload', () => { logService.restore(); }); } ``` -------------------------------------------------------------------------------- /webui/src/types/mcp.ts: -------------------------------------------------------------------------------- ```typescript /** * TypeScript types for MCP (Model Context Protocol) server integration */ // Base MCP types export interface MCPRequest { jsonrpc: '2.0'; id: string | number; method: string; params?: Record<string, unknown>; } export interface MCPResponse<T = unknown> { jsonrpc: '2.0'; id: string | number; result?: T; error?: MCPError; } export interface MCPError { code: number; message: string; data?: unknown; } // Tool definitions export interface MCPTool { name: string; description: string; inputSchema: { type: 'object'; properties?: Record<string, unknown>; required?: string[]; }; } export interface MCPToolCall { name: string; arguments: Record<string, unknown>; } export interface MCPToolResult { content: Array<{ type: 'text'; text: string; }>; isError?: boolean; } // Bybit-specific types export interface BybitCategory { spot: 'spot'; linear: 'linear'; inverse: 'inverse'; option: 'option'; } export interface TickerData { symbol: string; category: string; lastPrice: string; price24hPcnt: string; highPrice24h: string; lowPrice24h: string; prevPrice24h: string; volume24h: string; turnover24h: string; bid1Price: string; bid1Size: string; ask1Price: string; ask1Size: string; usdIndexPrice?: string; timestamp: string; } export interface KlineData { symbol: string; category: string; interval: string; data: Array<{ startTime: number; openPrice: string; highPrice: string; lowPrice: string; closePrice: string; volume: string; turnover: string; }>; } export interface OrderbookData { symbol: string; category: string; bids: Array<[string, string]>; // [price, size] asks: Array<[string, string]>; // [price, size] timestamp: string; } // Advanced analysis types export interface MLRSIData { symbol: string; interval: string; data: Array<{ timestamp: number; standardRsi: number; mlRsi: number; adaptiveOverbought: number; adaptiveOversold: number; knnDivergence: number; effectiveNeighbors: number; trend: 'bullish' | 'bearish' | 'neutral'; }>; metadata: { mlEnabled: boolean; featuresUsed: string[]; smoothingApplied: string; calculationTime: number; }; } export interface OrderBlock { id: string; timestamp: number; top: number; bottom: number; average: number; volume: number; mitigated: boolean; mitigationTime?: number; } export interface OrderBlocksData { symbol: string; interval: string; bullishBlocks: OrderBlock[]; bearishBlocks: OrderBlock[]; currentSupport: number[]; currentResistance: number[]; metadata: { volumePivotLength: number; mitigationMethod: string; blocksDetected: number; activeBullishBlocks: number; activeBearishBlocks: number; }; } export interface MarketStructureData { symbol: string; interval: string; marketRegime: 'trending_up' | 'trending_down' | 'ranging' | 'volatile'; trendStrength: number; volatilityLevel: 'low' | 'medium' | 'high'; keyLevels: { support: number[]; resistance: number[]; liquidityZones: Array<{ price: number; strength: number; type: 'support' | 'resistance'; }>; }; orderBlocks?: OrderBlocksData; mlRsi?: MLRSIData; recommendations: string[]; metadata: { analysisDepth: number; calculationTime: number; confidence: number; }; } // Tool parameter types export interface GetTickerParams { symbol: string; category?: keyof BybitCategory; } export interface GetKlineParams { symbol: string; category?: keyof BybitCategory; interval?: string; limit?: number; } export interface GetOrderbookParams { symbol: string; category?: keyof BybitCategory; limit?: number; } export interface GetMLRSIParams { symbol: string; category: keyof BybitCategory; interval: string; rsiLength?: number; knnNeighbors?: number; knnLookback?: number; mlWeight?: number; featureCount?: number; smoothingMethod?: string; limit?: number; } export interface GetOrderBlocksParams { symbol: string; category: keyof BybitCategory; interval: string; volumePivotLength?: number; bullishBlocks?: number; bearishBlocks?: number; mitigationMethod?: string; limit?: number; } export interface GetMarketStructureParams { symbol: string; category: keyof BybitCategory; interval: string; analysisDepth?: number; includeOrderBlocks?: boolean; includeMLRSI?: boolean; includeLiquidityZones?: boolean; } // Available MCP tools export type MCPToolName = | 'get_ticker' | 'get_orderbook' | 'get_kline' | 'get_market_info' | 'get_trades' | 'get_instrument_info' | 'get_wallet_balance' | 'get_positions' | 'get_order_history' | 'get_ml_rsi' | 'get_order_blocks' | 'get_market_structure'; export type MCPToolParams<T extends MCPToolName> = T extends 'get_ticker' ? GetTickerParams : T extends 'get_kline' ? GetKlineParams : T extends 'get_orderbook' ? GetOrderbookParams : T extends 'get_ml_rsi' ? GetMLRSIParams : T extends 'get_order_blocks' ? GetOrderBlocksParams : T extends 'get_market_structure' ? GetMarketStructureParams : Record<string, unknown>; export type MCPToolResponse<T extends MCPToolName> = T extends 'get_ticker' ? TickerData : T extends 'get_kline' ? KlineData : T extends 'get_orderbook' ? OrderbookData : T extends 'get_ml_rsi' ? MLRSIData : T extends 'get_order_blocks' ? OrderBlocksData : T extends 'get_market_structure' ? MarketStructureData : unknown; ``` -------------------------------------------------------------------------------- /docs/HTTP_SERVER.md: -------------------------------------------------------------------------------- ```markdown # Bybit MCP HTTP Server The Bybit MCP server now supports HTTP/SSE transport in addition to the standard stdio transport. This enables web applications and other HTTP clients to interact with the MCP server. ## Features - **Modern Streamable HTTP Transport**: Latest MCP protocol support with session management - **Legacy SSE Transport**: Backwards compatibility with older MCP clients - **Health Monitoring**: Built-in health check endpoint - **CORS Support**: Configurable cross-origin resource sharing - **Session Management**: Automatic session lifecycle management - **Graceful Shutdown**: Proper cleanup on server termination ## Configuration ### Environment Variables - `MCP_HTTP_PORT`: Server port (default: 8080) - `MCP_HTTP_HOST`: Server host (default: localhost) - `CORS_ORIGIN`: CORS origin policy (default: *) ### Example Configuration ```bash export MCP_HTTP_PORT=8080 export MCP_HTTP_HOST=0.0.0.0 export CORS_ORIGIN="https://myapp.com" ``` ## Endpoints ### Health Check - **URL**: `GET /health` - **Description**: Server health and status information - **Response**: JSON with server status, version, and active transport counts ```json { "status": "healthy", "name": "bybit-mcp", "version": "0.2.0", "timestamp": "2025-05-24T04:19:35.168Z", "transports": { "streamable": 0, "sse": 0 } } ``` ### Modern Streamable HTTP Transport - **URL**: `POST|GET|DELETE /mcp` - **Description**: Modern MCP protocol endpoint with session management - **Headers**: - `Content-Type: application/json` - `mcp-session-id: <session-id>` (for existing sessions) ### Legacy SSE Transport - **URL**: `GET /sse` - **Description**: Server-Sent Events endpoint for legacy clients - **Response**: SSE stream with session ID - **URL**: `POST /messages?sessionId=<session-id>` - **Description**: Message endpoint for SSE clients - **Headers**: `Content-Type: application/json` ## Usage ### Starting the HTTP Server ```bash # Build the project pnpm build # Start HTTP server pnpm start:http # Or run directly node build/httpServer.js ``` ### Client Connection Examples #### Modern HTTP Client (Recommended) ```typescript import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const client = new Client({ name: 'my-client', version: '1.0.0' }); const transport = new StreamableHTTPClientTransport( new URL('http://localhost:8080/mcp') ); await client.connect(transport); ``` #### Legacy SSE Client ```typescript import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; const client = new Client({ name: 'legacy-client', version: '1.0.0' }); const transport = new SSEClientTransport( new URL('http://localhost:8080/sse') ); await client.connect(transport); ``` #### Backwards Compatible Client ```typescript import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; const baseUrl = new URL('http://localhost:8080'); let client: Client; try { // Try modern transport first client = new Client({ name: 'client', version: '1.0.0' }); const transport = new StreamableHTTPClientTransport(new URL('/mcp', baseUrl)); await client.connect(transport); console.log("Connected using Streamable HTTP transport"); } catch (error) { // Fall back to SSE transport console.log("Falling back to SSE transport"); client = new Client({ name: 'client', version: '1.0.0' }); const sseTransport = new SSEClientTransport(new URL('/sse', baseUrl)); await client.connect(sseTransport); console.log("Connected using SSE transport"); } ``` ### Web Application Integration The HTTP server is designed to work seamlessly with web applications. The included WebUI demonstrates how to integrate with the MCP server over HTTP. #### Proxy Configuration (Vite) ```typescript // vite.config.ts export default defineConfig({ server: { proxy: { '/api/mcp': { target: 'http://localhost:8080', changeOrigin: true, rewrite: (path) => path.replace(/^\/api\/mcp/, ''), }, }, }, }); ``` ## Available Tools The HTTP server exposes all the same tools as the stdio version: - `get_instrument_info` - Get trading instrument information - `get_kline` - Get candlestick/kline data - `get_ml_rsi` - Get ML-enhanced RSI indicator - `get_market_info` - Get market information - `get_market_structure` - Get market structure analysis - `get_order_blocks` - Get order block detection - `get_order_history` - Get order history - `get_orderbook` - Get order book data - `get_positions` - Get current positions - `get_ticker` - Get ticker information - `get_trades` - Get recent trades - `get_wallet_balance` - Get wallet balance ## Security Considerations - Configure CORS appropriately for production use - Use HTTPS in production environments - Consider rate limiting for public deployments - Validate all input parameters - Monitor session counts and cleanup ## Troubleshooting ### Common Issues 1. **Port already in use**: Change the port using `MCP_HTTP_PORT` environment variable 2. **CORS errors**: Configure `CORS_ORIGIN` environment variable 3. **Connection refused**: Ensure the server is running and accessible 4. **Session errors**: Check that session IDs are properly managed ### Debugging Enable debug logging by setting the log level: ```bash export LOG_LEVEL=debug node build/httpServer.js ``` ### Health Check Always verify the server is healthy: ```bash curl http://localhost:8080/health ``` ## Performance - Session management is memory-based (consider Redis for production) - Automatic cleanup of closed sessions - Configurable timeouts and limits - Graceful shutdown handling ## Development For development, you can run both the HTTP server and WebUI simultaneously: ```bash # Terminal 1: Start MCP HTTP server pnpm start:http # Terminal 2: Start WebUI development server cd webui && pnpm dev ``` The WebUI will proxy MCP requests to the HTTP server automatically. ``` -------------------------------------------------------------------------------- /webui/src/services/citationProcessor.ts: -------------------------------------------------------------------------------- ```typescript /** * Citation processor for parsing AI responses and creating interactive citations */ import type { CitationReference, ProcessedMessage, CitationTooltipData } from '@/types/citation'; import { citationStore } from './citationStore'; export class CitationProcessor { // Regex pattern to match citation references like [REF001], [REF123], etc. private static readonly CITATION_PATTERN = /\[REF(\d{3})\]/g; /** * Process AI response content to extract and convert citations */ processMessage(content: string): ProcessedMessage { const citations: CitationReference[] = []; let processedContent = content; let match; // Reset regex lastIndex to ensure we find all matches CitationProcessor.CITATION_PATTERN.lastIndex = 0; // Find all citation patterns in the content while ((match = CitationProcessor.CITATION_PATTERN.exec(content)) !== null) { const fullMatch = match[0]; // e.g., "[REF001]" const referenceId = fullMatch; // Keep the full format for consistency citations.push({ referenceId, startIndex: match.index, endIndex: match.index + fullMatch.length, text: fullMatch }); } // Convert citation patterns to interactive elements if (citations.length > 0) { processedContent = this.convertCitationsToInteractive(content, citations); } return { originalContent: content, processedContent, citations }; } /** * Convert citation patterns to interactive HTML elements */ private convertCitationsToInteractive(content: string, citations: CitationReference[]): string { let processedContent = content; // Process citations in reverse order to maintain correct indices const sortedCitations = [...citations].sort((a, b) => b.startIndex - a.startIndex); for (const citation of sortedCitations) { const citationData = citationStore.getCitation(citation.referenceId); let hasData = citationData !== undefined; // For testing: create mock data if no real data exists if (!hasData) { console.log(`🧪 Creating mock citation data for ${citation.referenceId}`); const mockData = { referenceId: citation.referenceId, timestamp: new Date().toISOString(), toolName: 'get_ticker', endpoint: '/v5/market/tickers', rawData: { symbol: 'BTCUSDT', lastPrice: '$103,411.53', price24hPcnt: '-0.73%', volume24h: '19.73 BTC' }, extractedMetrics: [ { type: 'price' as const, label: 'Last Price', value: '$103,411.53', unit: 'USD', significance: 'high' as const }, { type: 'percentage' as const, label: '24h Change', value: '-0.73%', unit: '%', significance: 'high' as const } ] }; citationStore.storeCitation(mockData); hasData = true; } // Create a more compact, single-line span element const interactiveElement = `<span class="citation-ref ${hasData ? 'has-data' : 'no-data'}" data-reference-id="${citation.referenceId}" data-has-data="${hasData}" title="${hasData ? 'Click to view data details' : 'Citation data not available'}" role="button" tabindex="0">${citation.text}</span>`; processedContent = processedContent.slice(0, citation.startIndex) + interactiveElement + processedContent.slice(citation.endIndex); } return processedContent; } /** * Get tooltip data for a citation reference */ getCitationTooltipData(referenceId: string): CitationTooltipData | null { const citationData = citationStore.getCitation(referenceId); if (!citationData) { return null; } return { referenceId: citationData.referenceId, toolName: citationData.toolName, timestamp: citationData.timestamp, endpoint: citationData.endpoint, keyMetrics: citationData.extractedMetrics || [], hasFullData: true }; } /** * Format timestamp for display */ formatTimestamp(timestamp: string): string { try { const date = new Date(timestamp); return date.toLocaleString(); } catch (error) { return timestamp; } } /** * Create tooltip HTML content */ createTooltipContent(tooltipData: CitationTooltipData): string { const formattedTime = this.formatTimestamp(tooltipData.timestamp); let metricsHtml = ''; if (tooltipData.keyMetrics.length > 0) { metricsHtml = ` <div class="citation-metrics"> <h4>Key Data Points:</h4> <ul> ${tooltipData.keyMetrics.map(metric => ` <li class="metric-${metric.significance}"> <span class="metric-label">${metric.label}:</span> <span class="metric-value">${metric.value}${metric.unit ? ' ' + metric.unit : ''}</span> </li> `).join('')} </ul> </div> `; } return ` <div class="citation-tooltip"> <div class="citation-header"> <span class="citation-id">${tooltipData.referenceId}</span> <span class="citation-tool">${tooltipData.toolName}</span> </div> <div class="citation-time">${formattedTime}</div> ${tooltipData.endpoint ? `<div class="citation-endpoint">${tooltipData.endpoint}</div>` : ''} ${metricsHtml} <div class="citation-actions"> <button class="btn-view-full" data-reference-id="${tooltipData.referenceId}"> View Full Data </button> </div> </div> `; } /** * Extract all citation references from content */ extractCitationReferences(content: string): string[] { const references: string[] = []; let match; CitationProcessor.CITATION_PATTERN.lastIndex = 0; while ((match = CitationProcessor.CITATION_PATTERN.exec(content)) !== null) { references.push(match[0]); } return references; } /** * Validate citation reference format */ isValidCitationReference(reference: string): boolean { return CitationProcessor.CITATION_PATTERN.test(reference); } } // Singleton instance export const citationProcessor = new CitationProcessor(); ``` -------------------------------------------------------------------------------- /webui/DOCKER.md: -------------------------------------------------------------------------------- ```markdown # 🐳 Docker Deployment Guide This guide covers how to build and deploy the Bybit MCP WebUI using Docker. ## 📋 Prerequisites - Docker Engine 20.10+ - Docker Compose 2.0+ - 2GB+ available RAM - 1GB+ available disk space ## 🚀 Quick Start ### Option 1: Using Docker Compose (Recommended) ```bash # Clone the repository git clone https://github.com/sammcj/bybit-mcp.git cd bybit-mcp/webui # Start the application docker-compose up -d # View logs docker-compose logs -f # Stop the application docker-compose down ``` ### Option 2: Using Docker Build ```bash # Build the image docker build -t bybit-mcp-webui -f webui/Dockerfile . # Run the container docker run -d \ --name bybit-mcp-webui \ -p 8080:8080 \ --restart unless-stopped \ bybit-mcp-webui ``` ## 🌐 Access the Application Once running, access the WebUI at: - **Local**: http://localhost:8080 - **Network**: http://YOUR_SERVER_IP:8080 ## ⚙️ Configuration ### Environment Variables | Variable | Default | Description | |----------|---------|-------------| | `NODE_ENV` | `production` | Application environment | | `PORT` | `8080` | HTTP server port | | `MCP_PORT` | `8080` | MCP server port | | `HOST` | `0.0.0.0` | Bind address | | `BYBIT_API_KEY` | - | Bybit API key (optional) | | `BYBIT_API_SECRET` | - | Bybit API secret (optional) | | `BYBIT_TESTNET` | `true` | Use Bybit testnet | ### Custom Configuration Create a `.env` file in the webui directory: ```bash # .env BYBIT_API_KEY=your_api_key_here BYBIT_API_SECRET=your_api_secret_here BYBIT_TESTNET=false PORT=3000 ``` Then update docker-compose.yml: ```yaml services: bybit-mcp-webui: env_file: - .env ``` ## 🔧 Advanced Usage ### Development Mode For development with hot reload: ```bash # Build development image docker build -t bybit-mcp-webui:dev --target deps -f webui/Dockerfile . # Run with volume mounts docker run -d \ --name bybit-mcp-dev \ -p 8080:8080 \ -v $(pwd):/app \ -v /app/node_modules \ -v /app/webui/node_modules \ bybit-mcp-webui:dev \ sh -c "cd /app && pnpm dev:full" ``` ### Production Deployment For production with reverse proxy: ```yaml # docker-compose.prod.yml services: bybit-mcp-webui: image: bybit-mcp-webui:latest environment: - NODE_ENV=production - BYBIT_TESTNET=false deploy: replicas: 2 resources: limits: memory: 1G cpus: '1.0' networks: - traefik labels: - "traefik.enable=true" - "traefik.http.routers.bybit.rule=Host(`your-domain.com`)" - "traefik.http.routers.bybit.tls.certresolver=letsencrypt" networks: traefik: external: true ``` ## 🔍 Monitoring & Debugging ### Health Checks The container includes built-in health checks: ```bash # Check container health docker ps docker inspect bybit-mcp-webui | grep -A 10 Health # Manual health check docker exec bybit-mcp-webui /app/healthcheck.sh ``` ### Logs ```bash # View application logs docker logs bybit-mcp-webui # Follow logs in real-time docker logs -f bybit-mcp-webui # View last 100 lines docker logs --tail 100 bybit-mcp-webui ``` ### Container Shell Access ```bash # Access container shell docker exec -it bybit-mcp-webui sh # Check running processes docker exec bybit-mcp-webui ps aux # Check disk usage docker exec bybit-mcp-webui df -h ``` ## 🛠️ Troubleshooting ### Common Issues **Port Already in Use** ```bash # Find process using port 8080 lsof -i :8080 # or netstat -tulpn | grep 8080 # Kill the process or use different port docker run -p 3000:8080 bybit-mcp-webui ``` **Permission Denied** ```bash # Check if Docker daemon is running sudo systemctl status docker # Add user to docker group sudo usermod -aG docker $USER newgrp docker ``` **Build Failures** ```bash # Clear Docker cache docker system prune -a # Rebuild without cache docker build --no-cache -t bybit-mcp-webui -f webui/Dockerfile . ``` **Memory Issues** ```bash # Increase Docker memory limit # Docker Desktop: Settings > Resources > Memory # Check container memory usage docker stats bybit-mcp-webui ``` ### Performance Optimization **Two-stage Build Benefits:** - ✅ Smaller final image (~200MB vs ~1GB) - ✅ Faster deployments - ✅ Better security (no dev dependencies) - ✅ Optimized layer caching - ✅ Cleaner, more maintainable Dockerfile **Resource Limits:** ```yaml deploy: resources: limits: memory: 512M # Adjust based on usage cpus: '0.5' # Adjust based on load ``` ## 🔒 Security ### Best Practices 1. **Non-root User**: Container runs as user `bybit` (UID 1001) 2. **Read-only Filesystem**: Where possible 3. **No New Privileges**: Security option enabled 4. **Minimal Base Image**: Alpine Linux 5. **Health Checks**: Built-in monitoring 6. **Resource Limits**: Prevent resource exhaustion ### API Key Security **Never commit API keys to version control!** ```bash # Use environment variables export BYBIT_API_KEY="your_key" export BYBIT_API_SECRET="your_secret" # Or use Docker secrets echo "your_key" | docker secret create bybit_api_key - echo "your_secret" | docker secret create bybit_api_secret - ``` ## 📊 Monitoring ### Prometheus Metrics Add monitoring with Prometheus: ```yaml # docker-compose.monitoring.yml services: prometheus: image: prom/prometheus:latest ports: - "9090:9090" volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml grafana: image: grafana/grafana:latest ports: - "3000:3000" environment: - GF_SECURITY_ADMIN_PASSWORD=admin ``` ## 🚀 Deployment Platforms ### Docker Swarm ```bash docker stack deploy -c docker-compose.yml bybit-mcp ``` ### Kubernetes ```bash # Generate Kubernetes manifests kompose convert -f docker-compose.yml kubectl apply -f . ``` ### Cloud Platforms - **AWS ECS**: Use the provided Dockerfile - **Google Cloud Run**: Compatible with minimal changes - **Azure Container Instances**: Direct deployment support - **DigitalOcean App Platform**: Git-based deployment ## 📚 Additional Resources - [Docker Best Practices](https://docs.docker.com/develop/dev-best-practices/) - [Multi-stage Builds](https://docs.docker.com/develop/dev-best-practices/dockerfile_best-practices/#use-multi-stage-builds) - [Docker Compose Reference](https://docs.docker.com/compose/compose-file/) - [Container Security](https://docs.docker.com/engine/security/) --- **Need help?** Open an issue on [GitHub](https://github.com/sammcj/bybit-mcp/issues) 🚀 ``` -------------------------------------------------------------------------------- /webui/src/utils/formatters.ts: -------------------------------------------------------------------------------- ```typescript /** * Utility functions for formatting data */ /** * Format a number as currency */ export function formatCurrency(value: number, currency: string = 'USD', decimals?: number): string { const options: Intl.NumberFormatOptions = { style: 'currency', currency, }; if (decimals !== undefined) { options.minimumFractionDigits = decimals; options.maximumFractionDigits = decimals; } return new Intl.NumberFormat('en-US', options).format(value); } /** * Format a number with appropriate decimal places */ export function formatNumber(value: number, decimals: number = 2): string { return new Intl.NumberFormat('en-US', { minimumFractionDigits: decimals, maximumFractionDigits: decimals, }).format(value); } /** * Format a large number with K, M, B suffixes */ export function formatLargeNumber(value: number): string { const absValue = Math.abs(value); if (absValue >= 1e9) { return formatNumber(value / 1e9, 2) + 'B'; } else if (absValue >= 1e6) { return formatNumber(value / 1e6, 2) + 'M'; } else if (absValue >= 1e3) { return formatNumber(value / 1e3, 2) + 'K'; } return formatNumber(value, 2); } /** * Format a percentage */ export function formatPercentage(value: number, decimals: number = 2): string { return new Intl.NumberFormat('en-US', { style: 'percent', minimumFractionDigits: decimals, maximumFractionDigits: decimals, }).format(value / 100); } /** * Format a timestamp */ export function formatTimestamp(timestamp: number, options?: Intl.DateTimeFormatOptions): string { const defaultOptions: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', }; return new Intl.DateTimeFormat('en-US', { ...defaultOptions, ...options }).format(new Date(timestamp)); } /** * Format a relative time (e.g., "2 minutes ago") */ export function formatRelativeTime(timestamp: number): string { const now = Date.now(); const diff = now - timestamp; const seconds = Math.floor(diff / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); if (days > 0) { return `${days} day${days > 1 ? 's' : ''} ago`; } else if (hours > 0) { return `${hours} hour${hours > 1 ? 's' : ''} ago`; } else if (minutes > 0) { return `${minutes} minute${minutes > 1 ? 's' : ''} ago`; } else { return 'Just now'; } } /** * Format a trading symbol for display */ export function formatSymbol(symbol: string): string { // Convert BTCUSDT to BTC/USDT const commonQuotes = ['USDT', 'USDC', 'BTC', 'ETH', 'BNB']; for (const quote of commonQuotes) { if (symbol.endsWith(quote)) { const base = symbol.slice(0, -quote.length); return `${base}/${quote}`; } } return symbol; } /** * Format price change with color indication */ export function formatPriceChange(change: number, isPercentage: boolean = false): { formatted: string; className: string; icon: string; } { const isPositive = change > 0; const isNegative = change < 0; let formatted: string; if (isPercentage) { formatted = formatPercentage(Math.abs(change)); } else { formatted = formatNumber(Math.abs(change)); } const className = isPositive ? 'text-success' : isNegative ? 'text-danger' : 'text-secondary'; const icon = isPositive ? '↗' : isNegative ? '↘' : '→'; return { formatted: `${isPositive ? '+' : isNegative ? '-' : ''}${formatted}`, className, icon, }; } /** * Format volume with appropriate units */ export function formatVolume(volume: number): string { return formatLargeNumber(volume); } /** * Format market cap */ export function formatMarketCap(marketCap: number): string { return formatLargeNumber(marketCap); } /** * Format order book price levels */ export function formatOrderBookLevel(price: string, size: string): { price: string; size: string; total: string; } { const priceNum = parseFloat(price); const sizeNum = parseFloat(size); const total = priceNum * sizeNum; return { price: formatNumber(priceNum, 4), size: formatNumber(sizeNum, 6), total: formatNumber(total, 2), }; } /** * Format RSI value with overbought/oversold indication */ export function formatRSI(rsi: number): { formatted: string; className: string; status: 'overbought' | 'oversold' | 'neutral'; } { const formatted = formatNumber(rsi, 2); let className: string; let status: 'overbought' | 'oversold' | 'neutral'; if (rsi >= 70) { className = 'text-danger'; status = 'overbought'; } else if (rsi <= 30) { className = 'text-success'; status = 'oversold'; } else { className = 'text-secondary'; status = 'neutral'; } return { formatted, className, status }; } /** * Format file size */ export function formatFileSize(bytes: number): string { const units = ['B', 'KB', 'MB', 'GB', 'TB']; let size = bytes; let unitIndex = 0; while (size >= 1024 && unitIndex < units.length - 1) { size /= 1024; unitIndex++; } return `${formatNumber(size, unitIndex === 0 ? 0 : 2)} ${units[unitIndex]}`; } /** * Truncate text with ellipsis */ export function truncateText(text: string, maxLength: number): string { if (text.length <= maxLength) { return text; } return text.slice(0, maxLength - 3) + '...'; } /** * Format duration in milliseconds to human readable */ export function formatDuration(ms: number): string { if (ms < 1000) { return `${ms}ms`; } const seconds = ms / 1000; if (seconds < 60) { return `${formatNumber(seconds, 1)}s`; } const minutes = seconds / 60; if (minutes < 60) { return `${formatNumber(minutes, 1)}m`; } const hours = minutes / 60; return `${formatNumber(hours, 1)}h`; } /** * Format confidence score */ export function formatConfidence(confidence: number): { formatted: string; className: string; level: 'high' | 'medium' | 'low'; } { const formatted = formatPercentage(confidence); let className: string; let level: 'high' | 'medium' | 'low'; if (confidence >= 80) { className = 'text-success'; level = 'high'; } else if (confidence >= 60) { className = 'text-warning'; level = 'medium'; } else { className = 'text-danger'; level = 'low'; } return { formatted, className, level }; } ``` -------------------------------------------------------------------------------- /webui/src/services/configService.ts: -------------------------------------------------------------------------------- ```typescript /** * Configuration service for managing application settings */ import type { ChatSettings, AIConfig } from '@/types/ai'; import { systemPromptService } from './systemPrompt'; const STORAGE_KEY = 'bybit-mcp-webui-settings'; // Get environment-based defaults function getDefaultSettings(): ChatSettings { // Check for environment variables (available in build-time or runtime) const ollamaHost = (typeof window !== 'undefined' && (window as any).__OLLAMA_HOST__) || (typeof process !== 'undefined' && process.env?.OLLAMA_HOST) || 'http://localhost:11434'; const mcpEndpoint = (typeof window !== 'undefined' && (window as any).__MCP_ENDPOINT__) || (typeof process !== 'undefined' && process.env?.MCP_ENDPOINT) || ''; // Empty means use current origin in production return { ai: { endpoint: ollamaHost, model: 'qwen3-30b-a3b-ud-nothink-128k:q4_k_xl', temperature: 0.7, maxTokens: 2048, systemPrompt: systemPromptService.generateLegacySystemPrompt(), }, mcp: { endpoint: mcpEndpoint, timeout: 30000, }, ui: { theme: 'auto', fontSize: 'medium', showTimestamps: true, enableSounds: false, }, }; } const DEFAULT_SETTINGS: ChatSettings = getDefaultSettings(); export class ConfigService { private settings: ChatSettings; private listeners: Set<(settings: ChatSettings) => void> = new Set(); constructor() { this.settings = this.loadSettings(); this.applyTheme(); } /** * Get current settings */ getSettings(): ChatSettings { return { ...this.settings }; } /** * Update settings */ updateSettings(updates: Partial<ChatSettings>): void { this.settings = this.mergeSettings(this.settings, updates); this.saveSettings(); this.applyTheme(); this.notifyListeners(); } /** * Reset settings to defaults */ resetSettings(): void { this.settings = { ...DEFAULT_SETTINGS }; this.saveSettings(); this.applyTheme(); this.notifyListeners(); } /** * Get AI configuration */ getAIConfig(): AIConfig { return { ...this.settings.ai }; } /** * Update AI configuration */ updateAIConfig(config: Partial<AIConfig>): void { this.updateSettings({ ai: { ...this.settings.ai, ...config }, }); } /** * Get MCP configuration */ getMCPConfig(): { endpoint: string; timeout: number } { return { ...this.settings.mcp }; } /** * Update MCP configuration */ updateMCPConfig(config: Partial<{ endpoint: string; timeout: number }>): void { this.updateSettings({ mcp: { ...this.settings.mcp, ...config }, }); } /** * Subscribe to settings changes */ subscribe(listener: (settings: ChatSettings) => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); } /** * Apply theme to document */ private applyTheme(): void { const { theme } = this.settings.ui; const root = document.documentElement; if (theme === 'auto') { // Use system preference const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; root.setAttribute('data-theme', prefersDark ? 'dark' : 'light'); } else { root.setAttribute('data-theme', theme); } // Apply font size const { fontSize } = this.settings.ui; root.setAttribute('data-font-size', fontSize); } /** * Load settings from localStorage */ private loadSettings(): ChatSettings { try { const stored = localStorage.getItem(STORAGE_KEY); if (stored) { const parsed = JSON.parse(stored); const merged = this.mergeSettings(DEFAULT_SETTINGS, parsed); // Fix legacy localhost URLs when running in production if (typeof window !== 'undefined' && window.location.hostname !== 'localhost') { if (merged.mcp.endpoint === 'http://localhost:8080' || merged.mcp.endpoint.includes('localhost')) { console.log('🔧 Detected legacy localhost MCP endpoint, resetting to auto-detect'); merged.mcp.endpoint = ''; // Reset to auto-detect current origin } } return merged; } } catch (error) { console.warn('Failed to load settings from localStorage:', error); } return { ...DEFAULT_SETTINGS }; } /** * Save settings to localStorage */ private saveSettings(): void { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(this.settings)); } catch (error) { console.warn('Failed to save settings to localStorage:', error); } } /** * Deep merge settings objects */ private mergeSettings(base: ChatSettings, updates: Partial<ChatSettings>): ChatSettings { return { ai: { ...base.ai, ...updates.ai }, mcp: { ...base.mcp, ...updates.mcp }, ui: { ...base.ui, ...updates.ui }, }; } /** * Notify all listeners of settings changes */ private notifyListeners(): void { this.listeners.forEach(listener => { try { listener(this.settings); } catch (error) { console.error('Error in settings listener:', error); } }); } } // Singleton instance export const configService = new ConfigService(); // Listen for system theme changes if (typeof window !== 'undefined') { window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { const settings = configService.getSettings(); if (settings.ui.theme === 'auto') { configService.updateSettings({}); // Trigger theme reapplication } }); } // Export convenience functions export function getAIConfig(): AIConfig { return configService.getAIConfig(); } export function getMCPConfig(): { endpoint: string; timeout: number } { return configService.getMCPConfig(); } export function updateAIConfig(config: Partial<AIConfig>): void { configService.updateAIConfig(config); } export function updateMCPConfig(config: Partial<{ endpoint: string; timeout: number }>): void { configService.updateMCPConfig(config); } export function toggleTheme(): void { const settings = configService.getSettings(); const currentTheme = settings.ui.theme; let newTheme: 'light' | 'dark' | 'auto'; if (currentTheme === 'light') { newTheme = 'dark'; } else if (currentTheme === 'dark') { newTheme = 'auto'; } else { newTheme = 'light'; } configService.updateSettings({ ui: { ...settings.ui, theme: newTheme }, }); } ``` -------------------------------------------------------------------------------- /webui/src/components/DebugConsole.ts: -------------------------------------------------------------------------------- ```typescript /** * Debug Console Component - Real-time streaming log viewer */ import { logService, type LogEntry } from '../services/logService'; export class DebugConsole { private container: HTMLElement; private isVisible: boolean = false; private autoScroll: boolean = true; private filterLevels: Set<LogEntry['level']> = new Set(['log', 'info', 'warn', 'error']); private unsubscribe?: () => void; constructor(container: HTMLElement) { this.container = container; this.render(); this.setupEventListeners(); // Subscribe to log updates this.unsubscribe = logService.subscribe((logs) => { this.updateLogs(logs); }); } private render(): void { this.container.innerHTML = ` <div class="debug-console ${this.isVisible ? 'visible' : 'hidden'}"> <div class="debug-header"> <div class="debug-title"> <span class="debug-icon">🔍</span> <span>Debug Console</span> <span class="debug-count">(${logService.getLogs().length})</span> </div> <div class="debug-controls"> <div class="debug-filters"> <label><input type="checkbox" data-level="log" ${this.filterLevels.has('log') ? 'checked' : ''}> Log</label> <label><input type="checkbox" data-level="info" ${this.filterLevels.has('info') ? 'checked' : ''}> Info</label> <label><input type="checkbox" data-level="warn" ${this.filterLevels.has('warn') ? 'checked' : ''}> Warn</label> <label><input type="checkbox" data-level="error" ${this.filterLevels.has('error') ? 'checked' : ''}> Error</label> </div> <button class="debug-btn" data-action="clear">Clear</button> <button class="debug-btn" data-action="export">Export</button> <button class="debug-btn" data-action="scroll-toggle"> ${this.autoScroll ? '📌' : '📌'} </button> <button class="debug-btn debug-toggle" data-action="toggle"> ${this.isVisible ? '▼' : '▲'} </button> </div> </div> <div class="debug-content"> <div class="debug-logs" id="debug-logs"></div> </div> </div> `; this.updateLogs(logService.getLogs()); } private setupEventListeners(): void { this.container.addEventListener('click', (e) => { const target = e.target as HTMLElement; const action = target.getAttribute('data-action'); switch (action) { case 'toggle': this.toggle(); break; case 'clear': logService.clearLogs(); break; case 'export': this.exportLogs(); break; case 'scroll-toggle': this.autoScroll = !this.autoScroll; target.textContent = this.autoScroll ? '📌' : '📌'; target.title = this.autoScroll ? 'Auto-scroll enabled' : 'Auto-scroll disabled'; break; } }); this.container.addEventListener('change', (e) => { const target = e.target as HTMLInputElement; const level = target.getAttribute('data-level') as LogEntry['level']; if (level) { if (target.checked) { this.filterLevels.add(level); } else { this.filterLevels.delete(level); } this.updateLogs(logService.getLogs()); } }); } private updateLogs(logs: LogEntry[]): void { const logsContainer = this.container.querySelector('#debug-logs') as HTMLElement; if (!logsContainer) return; // Filter logs by selected levels const filteredLogs = logs.filter(log => this.filterLevels.has(log.level)); // Update count const countElement = this.container.querySelector('.debug-count') as HTMLElement; if (countElement) { countElement.textContent = `(${filteredLogs.length}/${logs.length})`; } // Render logs logsContainer.innerHTML = filteredLogs.map(log => this.renderLogEntry(log)).join(''); // Auto-scroll to bottom if (this.autoScroll && this.isVisible) { logsContainer.scrollTop = logsContainer.scrollHeight; } } private renderLogEntry(log: LogEntry): string { const time = new Date(log.timestamp).toLocaleTimeString(); const levelClass = `debug-log-${log.level}`; const source = log.source ? ` <span class="debug-source">[${log.source}]</span>` : ''; let dataHtml = ''; if (log.data) { const dataStr = typeof log.data === 'object' ? JSON.stringify(log.data, null, 2) : String(log.data); dataHtml = `<div class="debug-data">${this.escapeHtml(dataStr)}</div>`; } return ` <div class="debug-log-entry ${levelClass}"> <div class="debug-log-header"> <span class="debug-time">${time}</span> <span class="debug-level">${log.level.toUpperCase()}</span> ${source} </div> <div class="debug-message">${this.escapeHtml(log.message)}</div> ${dataHtml} </div> `; } private escapeHtml(text: string): string { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } private exportLogs(): void { const logs = logService.exportLogs(); const blob = new Blob([logs], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `debug-logs-${new Date().toISOString().slice(0, 19)}.txt`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } public toggle(): void { this.isVisible = !this.isVisible; const debugConsole = this.container.querySelector('.debug-console') as HTMLElement; const toggleBtn = this.container.querySelector('.debug-toggle') as HTMLElement; if (debugConsole) { debugConsole.className = `debug-console ${this.isVisible ? 'visible' : 'hidden'}`; } if (toggleBtn) { toggleBtn.textContent = this.isVisible ? '▼' : '▲'; } // Auto-scroll when opening if (this.isVisible && this.autoScroll) { setTimeout(() => { const logsContainer = this.container.querySelector('#debug-logs') as HTMLElement; if (logsContainer) { logsContainer.scrollTop = logsContainer.scrollHeight; } }, 100); } } public show(): void { if (!this.isVisible) { this.toggle(); } } public hide(): void { if (this.isVisible) { this.toggle(); } } public destroy(): void { if (this.unsubscribe) { this.unsubscribe(); } } } ``` -------------------------------------------------------------------------------- /webui/src/services/agentConfig.ts: -------------------------------------------------------------------------------- ```typescript /** * Agent configuration service for managing agent settings */ import type { AgentConfig, AgentState } from '@/types/agent'; import { DEFAULT_AGENT_CONFIG } from '@/types/agent'; export class AgentConfigService { private static readonly STORAGE_KEY = 'bybit-mcp-agent-config'; private static readonly STATE_KEY = 'bybit-mcp-agent-state'; private config: AgentConfig; private state: AgentState; private listeners: Set<(config: AgentConfig) => void> = new Set(); constructor() { this.config = this.loadConfig(); this.state = this.loadState(); } /** * Get current agent configuration */ getConfig(): AgentConfig { return { ...this.config }; } /** * Update agent configuration */ updateConfig(updates: Partial<AgentConfig>): void { this.config = { ...this.config, ...updates }; this.saveConfig(); this.notifyListeners(); } /** * Reset configuration to defaults */ resetConfig(): void { this.config = { ...DEFAULT_AGENT_CONFIG }; this.saveConfig(); this.notifyListeners(); } /** * Apply a simple preset configuration */ applyPreset(presetName: 'quick' | 'standard' | 'comprehensive'): void { const updates: Partial<AgentConfig> = {}; switch (presetName) { case 'quick': updates.maxIterations = 2; updates.showWorkflowSteps = false; updates.showToolCalls = false; break; case 'standard': updates.maxIterations = 5; updates.showWorkflowSteps = false; updates.showToolCalls = false; break; case 'comprehensive': updates.maxIterations = 8; updates.showWorkflowSteps = true; updates.showToolCalls = true; break; } this.updateConfig(updates); } /** * Get current agent state */ getState(): AgentState { return { ...this.state }; } /** * Update agent state */ updateState(updates: Partial<AgentState>): void { this.state = { ...this.state, ...updates }; this.saveState(); } /** * Record a successful query */ recordQuery(responseTime: number, _toolCallsCount: number): void { const currentState = this.getState(); const queryCount = currentState.queryCount + 1; const averageResponseTime = (currentState.averageResponseTime * currentState.queryCount + responseTime) / queryCount; this.updateState({ queryCount, averageResponseTime, successRate: (currentState.successRate * currentState.queryCount + 1) / queryCount, lastQuery: undefined, lastResponse: undefined }); } /** * Record a failed query */ recordFailure(): void { const currentState = this.getState(); const queryCount = currentState.queryCount + 1; this.updateState({ queryCount, successRate: (currentState.successRate * currentState.queryCount) / queryCount }); } /** * Subscribe to configuration changes */ subscribe(listener: (config: AgentConfig) => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); } /** * Get configuration for specific analysis type */ getConfigForAnalysis(analysisType: 'quick' | 'standard' | 'comprehensive'): AgentConfig { const baseConfig = this.getConfig(); switch (analysisType) { case 'quick': return { ...baseConfig, maxIterations: 2, showWorkflowSteps: false, showToolCalls: false }; case 'standard': return { ...baseConfig, maxIterations: 5, showWorkflowSteps: false, showToolCalls: false }; case 'comprehensive': return { ...baseConfig, maxIterations: 8, showWorkflowSteps: true, showToolCalls: true }; default: return baseConfig; } } /** * Validate configuration */ validateConfig(config: Partial<AgentConfig>): string[] { const errors: string[] = []; if (config.maxIterations !== undefined) { if (config.maxIterations < 1 || config.maxIterations > 20) { errors.push('Max iterations must be between 1 and 20'); } } if (config.toolTimeout !== undefined) { if (config.toolTimeout < 5000 || config.toolTimeout > 120000) { errors.push('Tool timeout must be between 5 and 120 seconds'); } } return errors; } /** * Export configuration */ exportConfig(): string { return JSON.stringify({ config: this.config, state: this.state, exportedAt: new Date().toISOString() }, null, 2); } /** * Import configuration */ importConfig(configJson: string): void { try { const imported = JSON.parse(configJson); if (imported.config) { const errors = this.validateConfig(imported.config); if (errors.length > 0) { throw new Error(`Invalid configuration: ${errors.join(', ')}`); } this.config = { ...DEFAULT_AGENT_CONFIG, ...imported.config }; this.saveConfig(); this.notifyListeners(); } } catch (error) { throw new Error(`Failed to import configuration: ${error instanceof Error ? error.message : 'Unknown error'}`); } } // Private methods private loadConfig(): AgentConfig { try { const stored = localStorage.getItem(AgentConfigService.STORAGE_KEY); if (stored) { const parsed = JSON.parse(stored); return { ...DEFAULT_AGENT_CONFIG, ...parsed }; } } catch (error) { console.warn('Failed to load agent config from localStorage:', error); } return { ...DEFAULT_AGENT_CONFIG }; } private saveConfig(): void { try { localStorage.setItem(AgentConfigService.STORAGE_KEY, JSON.stringify(this.config)); } catch (error) { console.warn('Failed to save agent config to localStorage:', error); } } private loadState(): AgentState { try { const stored = localStorage.getItem(AgentConfigService.STATE_KEY); if (stored) { return JSON.parse(stored); } } catch (error) { console.warn('Failed to load agent state from localStorage:', error); } return { isProcessing: false, queryCount: 0, averageResponseTime: 0, successRate: 0 }; } private saveState(): void { try { localStorage.setItem(AgentConfigService.STATE_KEY, JSON.stringify(this.state)); } catch (error) { console.warn('Failed to save agent state to localStorage:', error); } } // Removed metrics-related methods - now using simplified state tracking private notifyListeners(): void { this.listeners.forEach(listener => listener(this.config)); } } // Singleton instance export const agentConfigService = new AgentConfigService(); ``` -------------------------------------------------------------------------------- /webui/build-docker.sh: -------------------------------------------------------------------------------- ```bash #!/bin/bash # ============================================================================== # Bybit MCP WebUI - Docker Build Script # ============================================================================== # This script provides convenient commands for building and managing # the Docker container for the Bybit MCP WebUI. # ============================================================================== set -e # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color # Configuration IMAGE_NAME="bybit-mcp-webui" CONTAINER_NAME="bybit-mcp-webui" PORT="8080" # Functions print_header() { echo -e "${BLUE}==============================================================================${NC}" echo -e "${BLUE} $1${NC}" echo -e "${BLUE}==============================================================================${NC}" } print_success() { echo -e "${GREEN}✅ $1${NC}" } print_warning() { echo -e "${YELLOW}⚠️ $1${NC}" } print_error() { echo -e "${RED}❌ $1${NC}" } print_info() { echo -e "${BLUE}ℹ️ $1${NC}" } # Check if Docker is running check_docker() { if ! docker info > /dev/null 2>&1; then print_error "Docker is not running. Please start Docker and try again." exit 1 fi } # Build the Docker image build_image() { print_header "Building Docker Image" print_info "Building $IMAGE_NAME with Node.js 22..." docker build -t "$IMAGE_NAME:latest" -f Dockerfile .. print_success "Docker image built successfully!" docker images | grep "$IMAGE_NAME" # Show image size print_info "Image size:" docker images "$IMAGE_NAME:latest" --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}" } # Run the container run_container() { print_header "Running Container" # Stop existing container if running if docker ps -q -f name="$CONTAINER_NAME" | grep -q .; then print_warning "Stopping existing container..." docker stop "$CONTAINER_NAME" docker rm "$CONTAINER_NAME" fi print_info "Starting new container..." docker run -d \ --name "$CONTAINER_NAME" \ -p "$PORT:8080" \ --restart unless-stopped \ "$IMAGE_NAME:latest" print_success "Container started successfully!" print_info "Access the WebUI at: http://localhost:$PORT" } # Stop the container stop_container() { print_header "Stopping Container" if docker ps -q -f name="$CONTAINER_NAME" | grep -q .; then docker stop "$CONTAINER_NAME" docker rm "$CONTAINER_NAME" print_success "Container stopped and removed." else print_warning "No running container found." fi } # Show container logs show_logs() { print_header "Container Logs" if docker ps -q -f name="$CONTAINER_NAME" | grep -q .; then docker logs -f "$CONTAINER_NAME" else print_error "Container is not running." exit 1 fi } # Show container status show_status() { print_header "Container Status" echo "Docker Images:" docker images | grep "$IMAGE_NAME" || echo "No images found." echo "" echo "Running Containers:" docker ps | grep "$CONTAINER_NAME" || echo "No running containers found." echo "" echo "All Containers:" docker ps -a | grep "$CONTAINER_NAME" || echo "No containers found." } # Clean up Docker resources cleanup() { print_header "Cleaning Up" print_info "Stopping and removing containers..." docker ps -a -q -f name="$CONTAINER_NAME" | xargs -r docker rm -f print_info "Removing images..." docker images -q "$IMAGE_NAME" | xargs -r docker rmi -f print_info "Cleaning up unused Docker resources..." docker system prune -f print_success "Cleanup completed!" } # Development mode with volume mounts dev_mode() { print_header "Development Mode" # Stop existing container docker ps -q -f name="$CONTAINER_NAME-dev" | xargs -r docker rm -f print_info "Starting development container with volume mounts..." docker run -d \ --name "$CONTAINER_NAME-dev" \ -p "$PORT:8080" \ -v "$(pwd)/..:/app" \ -v "/app/node_modules" \ -v "/app/webui/node_modules" \ --restart unless-stopped \ "$IMAGE_NAME:latest" \ sh -c "cd /app && pnpm dev:full" print_success "Development container started!" print_info "Access the WebUI at: http://localhost:$PORT" print_warning "Note: Changes to source files will trigger rebuilds." } # Show help show_help() { echo "Bybit MCP WebUI - Docker Build Script" echo "" echo "Usage: $0 [COMMAND]" echo "" echo "Commands:" echo " build Build the Docker image" echo " run Run the container" echo " stop Stop and remove the container" echo " restart Stop and start the container" echo " logs Show container logs" echo " status Show container and image status" echo " cleanup Remove all containers and images" echo " dev Run in development mode with volume mounts" echo " compose Use Docker Compose (up/down/logs)" echo " help Show this help message" echo "" echo "Examples:" echo " $0 build && $0 run # Build and run" echo " $0 restart # Restart container" echo " $0 logs # Follow logs" echo " $0 compose up # Use Docker Compose" } # Docker Compose commands compose_command() { case "$1" in up) print_header "Starting with Docker Compose" docker-compose up -d print_success "Services started with Docker Compose!" ;; down) print_header "Stopping Docker Compose Services" docker-compose down print_success "Services stopped!" ;; logs) print_header "Docker Compose Logs" docker-compose logs -f ;; *) print_error "Unknown compose command: $1" echo "Available: up, down, logs" exit 1 ;; esac } # Main script logic main() { check_docker case "$1" in build) build_image ;; run) run_container ;; stop) stop_container ;; restart) stop_container sleep 2 run_container ;; logs) show_logs ;; status) show_status ;; cleanup) cleanup ;; dev) dev_mode ;; compose) compose_command "$2" ;; help|--help|-h) show_help ;; "") print_warning "No command specified." show_help ;; *) print_error "Unknown command: $1" show_help exit 1 ;; esac } # Run the script main "$@" ```