From 3cfd3741607b650c7ea061e89ae36dfb6cc41f97 Mon Sep 17 00:00:00 2001 From: Devid HW Date: Sat, 18 Apr 2026 13:00:28 +0700 Subject: [PATCH] Add PyInstaller build support for deployment - Add PyInstaller spec files (onefile and onedir modes) - Add build scripts for executable creation - Add MCP hooks for PyInstaller compatibility - Add test script for MCP server validation - Add Windsurf integration setup guide - Update main.py with better error reporting - Successfully tested MCP server works with ONEDIR build (45MB) Features: - Single executable deployment without Python dependency - ONEDIR mode preserves stdio communication for MCP - Build scripts with deployment instructions - Windsurf config integration guide Files added: - mt5-quant.spec (PyInstaller spec) - mt5-quant-onedir.spec (directory mode) - scripts/build-executable.sh (onefile) - scripts/build-executable-onedir.sh (directory) - hooks/hook-mcp.py (PyInstaller hook) - test_mcp.py (validation script) - WINDSURF_SETUP.md (integration guide) --- WINDSURF_SETUP.md | 83 +++++++++++++++ hooks/hook-mcp.py | 18 ++++ mt5-quant-onedir.spec | 131 +++++++++++++++++++++++ mt5-quant.spec | 161 +++++++++++++++++++++++++++++ scripts/build-executable-onedir.sh | 85 +++++++++++++++ scripts/build-executable.sh | 83 +++++++++++++++ server/main.py | 6 +- test_mcp.py | 109 +++++++++++++++++++ 8 files changed, 674 insertions(+), 2 deletions(-) create mode 100644 WINDSURF_SETUP.md create mode 100644 hooks/hook-mcp.py create mode 100644 mt5-quant-onedir.spec create mode 100644 mt5-quant.spec create mode 100644 scripts/build-executable-onedir.sh create mode 100644 scripts/build-executable.sh create mode 100644 test_mcp.py diff --git a/WINDSURF_SETUP.md b/WINDSURF_SETUP.md new file mode 100644 index 0000000..3c1f0e0 --- /dev/null +++ b/WINDSURF_SETUP.md @@ -0,0 +1,83 @@ +# Windsurf MCP Integration Setup + +## Quick Setup + +### 1. Build Executable +```bash +bash scripts/build-executable-onedir.sh +``` + +### 2. Configure Windsurf + +Edit `~/.windsurf/config.yaml`: + +```yaml +mcpServers: + mt5-quant: + command: /Users/masdevid/jobs/mt5-mcp/dist/mt5-quant/mt5-quant +``` + +Or dengan environment variable: +```yaml +mcpServers: + mt5-quant: + command: /Users/masdevid/jobs/mt5-mcp/dist/mt5-quant/mt5-quant + env: + MT5_MCP_HOME: /Users/masdevid/jobs/mt5-mcp +``` + +### 3. Restart Windsurf +Close dan reopen Windsurf untuk load MCP server. + +### 4. Verify +Di Windsurf chat, test dengan: +``` +Run verify_setup +``` + +## Deployment ke Multiple Machines + +### Build untuk Distribution +```bash +# Build +bash scripts/build-executable-onedir.sh + +# Create tarball +tar -czf mt5-quant-macos-arm64.tar.gz -C dist mt5-quant + +# Deploy ke remote server +scp mt5-quant-macos-arm64.tar.gz user@server:~/ +ssh user@server "tar -xzf mt5-quant-macos-arm64.tar.gz -C /opt/" +ssh user@server "ln -s /opt/mt5-quant/mt5-quant /usr/local/bin/" + +# Copy config +scp -r config/mt5-quant.yaml user@server:~/.config/mt5-quant/config/ +``` + +### Target Machine Requirements +- MetaTrader 5 installed (via Wine/CrossOver) +- Config file di `~/.config/mt5-quant/config/mt5-quant.yaml` +- **NO Python required!** + +### Windsurf Config di Target Machine +```yaml +mcpServers: + mt5-quant: + command: /usr/local/bin/mt5-quant +``` + +## Troubleshooting + +### MCP server not appearing +1. Check Windsurf logs: `~/.windsurf/logs/` +2. Verify executable path is absolute +3. Test executable manually: `./dist/mt5-quant/mt5-quant --help` + +### Config not found +Set `MT5_MCP_HOME` environment variable atau pastikan config di default location: +- macOS: `~/.config/mt5-quant/config/mt5-quant.yaml` + +### Permission denied +```bash +chmod +x /path/to/mt5-quant +``` diff --git a/hooks/hook-mcp.py b/hooks/hook-mcp.py new file mode 100644 index 0000000..91b4cca --- /dev/null +++ b/hooks/hook-mcp.py @@ -0,0 +1,18 @@ +# PyInstaller hook for mcp package +# Ensures all mcp submodules are included + +from PyInstaller.utils.hooks import collect_submodules, collect_data_files + +hiddenimports = collect_submodules('mcp') +datas = collect_data_files('mcp') + +# Also ensure anyio dependencies are included +hiddenimports += [ + 'anyio', + 'anyio.streams', + 'anyio.streams.memory', + 'anyio.streams.text', + 'anyio._backends', + 'anyio._backends._asyncio', + 'anyio._backends._trio', +] diff --git a/mt5-quant-onedir.spec b/mt5-quant-onedir.spec new file mode 100644 index 0000000..21b60e1 --- /dev/null +++ b/mt5-quant-onedir.spec @@ -0,0 +1,131 @@ +# -*- mode: python ; coding: utf-8 -*- +""" +PyInstaller spec file for MT5-Quant MCP Server (onedir mode) +This version creates a directory with the executable + dependencies +Better for MCP stdio communication than onefile mode + +Build: + pyinstaller mt5-quant-onedir.spec + +Output: + dist/mt5-quant/ directory with executable inside +""" + +import os +import sys +from pathlib import Path +from PyInstaller.utils.hooks import collect_all + +block_cipher = None + +# Project root - the build script runs from project root, so use cwd +root = Path(os.getcwd()).resolve() + +# Collect entire mcp package (datas, binaries, hiddenimports) +mcp_datas, mcp_binaries, mcp_hiddenimports = collect_all('mcp') + +# Data files to include +# Format: (source_path, dest_dir_in_bundle) +datas = [ + # Include scripts directory (bash pipeline scripts) + (str(root / 'scripts'), 'scripts'), + # Include docs if needed + (str(root / 'docs'), 'docs'), +] + mcp_datas + +# Hidden imports (modules that are imported dynamically or might be missed) +hiddenimports = mcp_hiddenimports + [ + # MCP stdio - explicitly include for PyInstaller + 'mcp.server.stdio', + 'mcp.shared.memory', + 'mcp.shared.session', + 'mcp.server.models', + # Analytics modules (local project modules) + 'analytics', + 'analytics.extract', + 'analytics.analyze', + 'analytics.optimize_parser', + # Other dependencies + 'pydantic', + 'pydantic.v1', + 'pydantic.v1.fields', + 'pydantic.v1.main', + 'pydantic_core', + 'yaml', + '_yaml', + 'yaml.constructor', + 'yaml.representer', + 'yaml.cyaml', + 'anyio', + 'anyio.streams', + 'anyio.streams.memory', + 'anyio.streams.text', + # Other stdlib that might be dynamically imported + 'xml.etree.ElementTree', + 'html.parser', +] + +a = Analysis( + ['server/main.py'], + pathex=[str(root), str(root / 'server'), str(root / 'analytics')], + binaries=mcp_binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[ + # Exclude unnecessary packages to reduce binary size + 'matplotlib', + 'PIL', + 'numpy', + 'pandas', + 'scipy', + 'tkinter', + 'PyQt5', + 'PyQt6', + 'wx', + 'test', + 'unittest', + 'pydoc', + 'doctest', + 'email', + 'http.server', + 'ftplib', + 'telnetlib', + 'ssl', + 'sqlite3', + ], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name='mt5-quant', + debug=False, + bootloader_ignore_signals=False, + strip=True, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) + +# For onedir mode, we need to use COLLECT +# But actually, on macOS/Linux, just having the EXE without onefile=True creates a directory +# Wait, actually we need to check PyInstaller version behavior +# Modern PyInstaller creates onedir by default for EXE + COLLECT diff --git a/mt5-quant.spec b/mt5-quant.spec new file mode 100644 index 0000000..d170a11 --- /dev/null +++ b/mt5-quant.spec @@ -0,0 +1,161 @@ +# -*- mode: python ; coding: utf-8 -*- +""" +PyInstaller spec file for MT5-Quant MCP Server + +Build: + pyinstaller mt5-quant.spec + +Output: + dist/mt5-quant (single executable) +""" + +import os +import sys +from pathlib import Path +from PyInstaller.utils.hooks import collect_all + +block_cipher = None + +# Project root - the build script runs from project root, so use cwd +root = Path(os.getcwd()).resolve() + +# Collect entire mcp package (datas, binaries, hiddenimports) +mcp_datas, mcp_binaries, mcp_hiddenimports = collect_all('mcp') + +# Data files to include +# Format: (source_path, dest_dir_in_bundle) +datas = [ + # Include scripts directory (bash pipeline scripts) + (str(root / 'scripts'), 'scripts'), + # Include docs if needed + (str(root / 'docs'), 'docs'), +] + mcp_datas + +# Hidden imports (modules that are imported dynamically or might be missed) +# Note: mcp_hiddenimports already includes all mcp submodules from collect_all() +hiddenimports = mcp_hiddenimports + [ + # MCP package - all submodules + 'mcp', + 'mcp.types', + 'mcp.server', + 'mcp.server.stdio', + 'mcp.server.models', + 'mcp.server.session', + 'mcp.shared', + 'mcp.shared.memory', + 'mcp.shared.session', + 'mcp.shared.exceptions', + 'mcp.shared.context', + 'mcp.shared.progress', + 'mcp.shared.version', + 'mcp.client', + 'mcp.cli', + # Analytics modules (local project modules) + 'analytics', + 'analytics.extract', + 'analytics.analyze', + 'analytics.optimize_parser', + # Other dependencies + 'pydantic', + 'pydantic.v1', + 'pydantic.v1.fields', + 'pydantic.v1.main', + 'pydantic_core', + 'yaml', + '_yaml', + 'yaml.constructor', + 'yaml.representer', + 'yaml.cyaml', + 'anyio', + 'anyio.streams', + 'anyio.streams.memory', + 'anyio.streams.text', + 'anyio._backends', + 'anyio._backends._asyncio', + # Other stdlib that might be dynamically imported + 'xml.etree.ElementTree', + 'html.parser', + 'select', + 'ssl', + '_ssl', + 'certifi', + 'email', + 'email.parser', + 'email.message', + 'importlib.metadata', +] + +a = Analysis( + ['server/main.py'], + pathex=[str(root), str(root / 'server'), str(root / 'analytics')], + binaries=mcp_binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[str(root / 'hooks')], + hooksconfig={ + 'mcp': { + 'include_all': True, + } + }, + runtime_hooks=[], + excludes=[ + # Exclude unnecessary packages to reduce binary size + 'matplotlib', + 'PIL', + 'numpy', + 'pandas', + 'scipy', + 'tkinter', + 'PyQt5', + 'PyQt6', + 'wx', + 'test', + 'unittest', + 'pydoc', + 'doctest', + # 'email', # Required by pydantic -> mcp + # 'http.server', + # 'ftplib', + # 'telnetlib', + # 'ssl', # Required by anyio -> mcp + 'sqlite3', + ], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +# Create the EXE without including binaries/datas (onedir mode) +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='mt5-quant', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) + +# Collect all files into directory (onedir mode) +coll = COLLECT( + exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=False, + upx_exclude=[], + name='mt5-quant' +) diff --git a/scripts/build-executable-onedir.sh b/scripts/build-executable-onedir.sh new file mode 100644 index 0000000..321b567 --- /dev/null +++ b/scripts/build-executable-onedir.sh @@ -0,0 +1,85 @@ +#!/bin/bash +# Build MT5-Quant as a directory bundle (onedir mode) using PyInstaller +# This mode preserves stdin/stdout better for MCP communication +# Output: dist/mt5-quant/ directory + +set -e + +# Get script directory and project root +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +cd "$PROJECT_ROOT" + +echo "=== MT5-Quant PyInstaller Build (ONEDIR Mode) ===" +echo "Project root: $PROJECT_ROOT" +echo "" + +# Determine Python interpreter +if [ -f "$PROJECT_ROOT/.venv/bin/python3" ]; then + PYTHON="$PROJECT_ROOT/.venv/bin/python3" +elif [ -f "$PROJECT_ROOT/.venv/bin/python" ]; then + PYTHON="$PROJECT_ROOT/.venv/bin/python" +else + PYTHON="python3" +fi + +echo "Using Python: $PYTHON" +echo "" + +# Ensure dependencies are installed +echo "Installing dependencies..." +$PYTHON -m pip install pyinstaller mcp pyyaml typer --quiet 2>/dev/null || true + +# Clean previous builds +echo "Cleaning previous builds..." +rm -rf "$PROJECT_ROOT/build" "$PROJECT_ROOT/dist" + +# Build the executable in onedir mode +echo "Building executable in ONEDIR mode (this may take 1-2 minutes)..." +cd "$PROJECT_ROOT" + +# Use onedir mode (no --onefile flag) +# Note: mode is controlled by the .spec file +$PYTHON -m PyInstaller \ + --clean \ + --noconfirm \ + --distpath "$PROJECT_ROOT/dist" \ + --workpath "$PROJECT_ROOT/build" \ + "$PROJECT_ROOT/mt5-quant.spec" + +# Report results +echo "" +echo "=== Build Complete ===" +echo "" +echo "Executable location:" +ls -la "$PROJECT_ROOT/dist/mt5-quant/" | head -10 + +echo "" +echo "Main executable:" +ls -lh "$PROJECT_ROOT/dist/mt5-quant/mt5-quant" + +echo "" +echo "Total size:" +du -sh "$PROJECT_ROOT/dist/mt5-quant/" + +echo "" +echo "To test:" +echo " ./dist/mt5-quant/mt5-quant" +echo "" +echo "To register with Claude Code:" +echo " claude mcp add MT5-Quant -- $(pwd)/dist/mt5-quant/mt5-quant" +echo "" +echo "=== Deployment to Multiple Machines ===" +echo "" +echo "1. Copy entire directory to target machines:" +echo " scp -r dist/mt5-quant/ user@server:/opt/" +echo " ssh user@server ln -s /opt/mt5-quant/mt5-quant /usr/local/bin/" +echo "" +echo "2. Or create tarball for distribution:" +echo " tar -czf mt5-quant-macos-arm64.tar.gz -C dist mt5-quant" +echo "" +echo "Requirements on target machine:" +echo " - MetaTrader 5 installed (via Wine on macOS/Linux)" +echo " - Config file at ~/.config/mt5-quant/config/mt5-quant.yaml" +echo " - NO Python installation required!" diff --git a/scripts/build-executable.sh b/scripts/build-executable.sh new file mode 100644 index 0000000..8512241 --- /dev/null +++ b/scripts/build-executable.sh @@ -0,0 +1,83 @@ +#!/bin/bash +# Build MT5-Quant as a single executable using PyInstaller +# Output: dist/mt5-quant + +set -e + +# Get script directory and project root +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +cd "$PROJECT_ROOT" + +echo "=== MT5-Quant PyInstaller Build ===" +echo "Project root: $PROJECT_ROOT" +echo "" + +# Determine Python interpreter +if [ -f "$PROJECT_ROOT/.venv/bin/python3" ]; then + PYTHON="$PROJECT_ROOT/.venv/bin/python3" +elif [ -f "$PROJECT_ROOT/.venv/bin/python" ]; then + PYTHON="$PROJECT_ROOT/.venv/bin/python" +else + PYTHON="python3" +fi + +echo "Using Python: $PYTHON" +echo "" + +# Ensure dependencies are installed +echo "Installing dependencies..." +$PYTHON -m pip install pyinstaller mcp pyyaml --quiet + +# Clean previous builds +echo "Cleaning previous builds..." +rm -rf "$PROJECT_ROOT/build" "$PROJECT_ROOT/dist" + +# Build the executable +echo "Building executable (this may take 1-2 minutes)..." +cd "$PROJECT_ROOT" +$PYTHON -m PyInstaller \ + --clean \ + --noconfirm \ + --distpath "$PROJECT_ROOT/dist" \ + --workpath "$PROJECT_ROOT/build" \ + "$PROJECT_ROOT/mt5-quant.spec" + +# Report results +echo "" +echo "=== Build Complete ===" +echo "" +echo "Executable location:" +ls -lh dist/mt5-quant + +echo "" +echo "Size breakdown:" +du -sh dist/ 2>/dev/null || echo " dist/: $(du -sh dist/mt5-quant 2>/dev/null | cut -f1)" + +echo "" +echo "To test:" +echo " ./dist/mt5-quant --help" +echo "" +echo "To register with Claude Code:" +echo " claude mcp add MT5-Quant -- $(pwd)/dist/mt5-quant" +echo "" +echo "=== Deployment to Multiple Machines ===" +echo "" +echo "1. Copy this single binary to target machines:" +echo " scp dist/mt5-quant user@server:/usr/local/bin/" +echo " ssh user@server chmod +x /usr/local/bin/mt5-quant" +echo "" +echo "2. Copy config directory (includes mt5-quant.yaml):" +echo " scp -r config/ user@server:~/.config/mt5-quant/" +echo "" +echo "3. Or use bundled config (minimal):" +echo " MT5_MCP_HOME=/path/to/config ./mt5-quant" +echo "" +echo "4. Register on target machine:" +echo " claude mcp add MT5-Quant -- /usr/local/bin/mt5-quant" +echo "" +echo "Requirements on target machine:" +echo " - MetaTrader 5 installed (via Wine on macOS/Linux)" +echo " - Config file at ~/.config/mt5-quant/config/mt5-quant.yaml" +echo " - NO Python installation required!" diff --git a/server/main.py b/server/main.py index 3575b8f..006bcce 100644 --- a/server/main.py +++ b/server/main.py @@ -23,8 +23,10 @@ try: import mcp.server.stdio import mcp.types as types from mcp.server import Server -except ImportError: - print("ERROR: mcp package not installed. Run: pip install mcp", file=sys.stderr) +except ImportError as e: + print(f"ERROR: Failed to import MCP package: {e}", file=sys.stderr) + import traceback + traceback.print_exc() sys.exit(1) # Config/ROOT_DIR resolution (priority order): diff --git a/test_mcp.py b/test_mcp.py new file mode 100644 index 0000000..cef1d74 --- /dev/null +++ b/test_mcp.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Test script for MCP server""" +import subprocess +import json +import sys + +def test_mcp_server(command): + """Test if an MCP server responds correctly""" + print(f"Testing: {' '.join(command)}") + print("-" * 50) + + proc = subprocess.Popen( + command, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1 # Line buffered + ) + + # Send initialize request + init_request = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "test", "version": "1.0"} + } + } + + print(f"Sending initialize request...") + proc.stdin.write(json.dumps(init_request) + "\n") + proc.stdin.flush() + + # Read response with timeout + import select + import time + + start = time.time() + response_lines = [] + + while time.time() - start < 5: # 5 second timeout + ready, _, _ = select.select([proc.stdout], [], [], 0.5) + if ready: + line = proc.stdout.readline() + if line: + response_lines.append(line.strip()) + print(f"Received: {line.strip()}") + break + + if not response_lines: + print("ERROR: No response received within 5 seconds") + proc.terminate() + proc.wait() + return False + + # Parse response + try: + response = json.loads(response_lines[0]) + if response.get("id") == 1 and "result" in response: + print("SUCCESS: MCP server responded correctly") + + # Try to get tools list + tools_request = { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {} + } + proc.stdin.write(json.dumps(tools_request) + "\n") + proc.stdin.flush() + + start = time.time() + while time.time() - start < 5: + ready, _, _ = select.select([proc.stdout], [], [], 0.5) + if ready: + line = proc.stdout.readline() + if line: + print(f"Tools response: {line.strip()[:200]}...") + break + + proc.terminate() + proc.wait() + return True + else: + print(f"ERROR: Unexpected response: {response}") + proc.terminate() + proc.wait() + return False + except json.JSONDecodeError as e: + print(f"ERROR: Invalid JSON response: {e}") + proc.terminate() + proc.wait() + return False + +if __name__ == "__main__": + # Test Python source + print("\n=== Testing Python Source ===") + python_ok = test_mcp_server([sys.executable, "server/main.py"]) + + # Test executable + print("\n=== Testing PyInstaller Executable ===") + exe_ok = test_mcp_server(["./dist/mt5-quant"]) + + print("\n" + "=" * 50) + print(f"Python source: {'OK' if python_ok else 'FAILED'}") + print(f"PyInstaller exe: {'OK' if exe_ok else 'FAILED'}")