diff --git a/python/wishilist-faucet-mcp-for-tbnb/README.md b/python/wishilist-faucet-mcp-for-tbnb/README.md new file mode 100644 index 0000000..4619f49 --- /dev/null +++ b/python/wishilist-faucet-mcp-for-tbnb/README.md @@ -0,0 +1,308 @@ +# BNB Testnet Faucet - Server-Side MCP + +A server-side Model Context Protocol (MCP) server for distributing testnet BNB tokens on Binance Smart Chain testnet. This server runs as a public HTTP service that any MCP client can connect to over the internet. + +## Overview + +This is a **server-side MCP implementation** designed to be deployed as a public service. Unlike client-side MCP servers that run locally, this server: + +- Runs as an HTTP service accessible over the internet +- Uses Streamable HTTP transport for MCP communication +- Can be accessed by any MCP client with the server URL +- Designed for SaaS-style deployment where the vendor hosts the service + +## Architecture + +- **Transport**: Streamable HTTP (modern MCP HTTP transport) +- **Deployment**: Public HTTP server +- **Access**: Any MCP client can connect via URL +- **Purpose**: Public TBNB faucet service for BSC testnet + +## Requirements + +- Python 3.10+ +- A wallet with testnet BNB for the faucet +- BSC testnet RPC access +- Server hosting (local or cloud) + +## Installation + +Install dependencies: + +```bash +pip install -r requirements.txt +``` + +## Configuration + +Set environment variables: + +- `FAUCET_WALLET_PRIVATE_KEY`: Private key of the faucet wallet (required) +- `BSC_TESTNET_RPC_URL`: BSC testnet RPC endpoint (optional, has default) +- `SERVER_HOST`: Server bind address (optional, defaults to `0.0.0.0`) +- `SERVER_PORT`: Server port (optional, defaults to `8000`) + +Example: + +```bash +export FAUCET_WALLET_PRIVATE_KEY="0x..." +export BSC_TESTNET_RPC_URL="https://data-seed-prebsc-1-s1.binance.org:8545/" +export SERVER_HOST="0.0.0.0" +export SERVER_PORT="8000" +``` + +## Running the Server + +Start the HTTP server: + +```bash +python tbnb_faucet_server.py +``` + +The server will start on `http://0.0.0.0:8000` (or your configured host/port). + +The MCP endpoint will be available at: `http://localhost:8000/mcp` + +## Available Tools + +### disburse_tbnb + +The primary service - disburses TBNB tokens to any BSC testnet address. + +**Parameters:** +- `recipient_address` (string, required): BSC testnet address to receive tokens +- `amount` (float, optional): Amount of TBNB to send (default: 0.1, max: 1.0) + +**Returns:** +- Transaction hash +- Recipient address +- Amount sent +- Block number +- Transaction status +- Explorer URL + +## Connecting MCP Clients + +### Claude Desktop + +Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS): + +```json +{ + "mcpServers": { + "tbnb-faucet-public": { + "url": "http://your-server-ip:8000/mcp" + } + } +} +``` + +### MCP Inspector + +Test the server: + +```bash +npx -y @modelcontextprotocol/inspector +# Connect to: http://localhost:8000/mcp +``` + +### Python MCP Client + +```python +from mcp import ClientSession +from mcp.client.sse import sse_client + +async with sse_client("http://your-server:8000/mcp") as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + result = await session.call_tool("disburse_tbnb", { + "recipient_address": "0x...", + "amount": 0.1 + }) +``` + +## Deployment + +### Local Development + +```bash +python tbnb_faucet_server.py +``` + +Access at: `http://localhost:8000/mcp` + +### Production Deployment + +For production, use a process manager like systemd, PM2, or Docker: + +**Using systemd:** + +```ini +[Unit] +Description=TBNB Faucet MCP Server +After=network.target + +[Service] +Type=simple +User=your-user +WorkingDirectory=/path/to/example2 +Environment="FAUCET_WALLET_PRIVATE_KEY=0x..." +Environment="BSC_TESTNET_RPC_URL=https://data-seed-prebsc-1-s1.binance.org:8545/" +ExecStart=/usr/bin/python3 tbnb_faucet_server.py +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +**Using Docker:** + +```dockerfile +FROM python:3.11-slim + +WORKDIR /app +COPY requirements.txt . +RUN pip install -r requirements.txt + +COPY tbnb_faucet_server.py . + +ENV SERVER_HOST=0.0.0.0 +ENV SERVER_PORT=8000 + +EXPOSE 8000 + +CMD ["python", "tbnb_faucet_server.py"] +``` + +**Behind a Reverse Proxy (nginx):** + +```nginx +server { + listen 80; + server_name your-domain.com; + + location /mcp { + proxy_pass http://127.0.0.1:8000/mcp; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} +``` + +## Network Details + +- **Network**: Binance Smart Chain Testnet +- **Chain ID**: 97 +- **Default RPC**: https://data-seed-prebsc-1-s1.binance.org:8545/ +- **Block Explorer**: https://testnet.bscscan.com + +## Security Considerations + +⚠️ **Important for Public Servers:** + +1. **Rate Limiting**: Implement rate limiting to prevent abuse + - Consider using middleware like `slowapi` or `flask-limiter` + - Limit requests per IP address + - Limit requests per recipient address + +2. **Authentication**: Consider adding API keys or authentication + - Use environment variables for API keys + - Validate requests before processing + +3. **Monitoring**: Monitor faucet balance and transaction volume + - Set up alerts for low balance + - Log all transactions + - Monitor for suspicious activity + +4. **Private Key Security**: + - Never commit private keys to version control + - Use secure secret management (AWS Secrets Manager, HashiCorp Vault, etc.) + - Restrict file permissions on config files + +5. **Network Security**: + - Use HTTPS in production (via reverse proxy) + - Implement CORS policies if needed + - Firewall rules to restrict access if desired + +## Example Rate Limiting + +Add to your server (using a middleware or decorator): + +```python +from functools import wraps +from collections import defaultdict +from datetime import datetime, timedelta + +# Simple in-memory rate limiter (use Redis for production) +request_counts = defaultdict(list) + +def rate_limit(max_requests=10, window_minutes=60): + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + # Get client IP (would need request context in real implementation) + # For now, using recipient address as identifier + recipient = kwargs.get('recipient_address', 'unknown') + now = datetime.now() + + # Clean old entries + request_counts[recipient] = [ + ts for ts in request_counts[recipient] + if now - ts < timedelta(minutes=window_minutes) + ] + + if len(request_counts[recipient]) >= max_requests: + return { + "success": False, + "error": "Rate limit exceeded. Please try again later." + } + + request_counts[recipient].append(now) + return func(*args, **kwargs) + return wrapper + return decorator +``` + +## Troubleshooting + +**Server won't start:** +- Check if port is already in use +- Verify all environment variables are set +- Check Python version (3.10+ required) + +**Connection refused:** +- Verify server is running +- Check firewall settings +- Ensure SERVER_HOST is set correctly (0.0.0.0 for all interfaces) + +**Transaction failures:** +- Check faucet wallet balance +- Verify RPC endpoint is accessible +- Check network connectivity + +**MCP client can't connect:** +- Verify the URL format: `http://host:port/mcp` +- Check CORS settings if accessing from browser +- Ensure server is accessible from client network + +## Testing + +Run unit tests: + +```bash +pip install -r requirements.txt +pytest test_tbnb_faucet_server.py -v +``` + +Run tests with coverage: + +```bash +pytest test_tbnb_faucet_server.py --cov=tbnb_faucet_server --cov-report=html +``` + +## License + +MIT License - feel free to use and modify as needed. diff --git a/python/wishilist-faucet-mcp-for-tbnb/pytest.ini b/python/wishilist-faucet-mcp-for-tbnb/pytest.ini new file mode 100644 index 0000000..991ba01 --- /dev/null +++ b/python/wishilist-faucet-mcp-for-tbnb/pytest.ini @@ -0,0 +1,13 @@ +[pytest] +testpaths = . +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = + -v + --tb=short + --strict-markers + --disable-warnings +markers = + unit: Unit tests + integration: Integration tests diff --git a/python/wishilist-faucet-mcp-for-tbnb/requirements.txt b/python/wishilist-faucet-mcp-for-tbnb/requirements.txt new file mode 100644 index 0000000..06b0e5f --- /dev/null +++ b/python/wishilist-faucet-mcp-for-tbnb/requirements.txt @@ -0,0 +1,4 @@ +mcp>=1.0.0 +web3>=6.0.0 +pytest>=7.0.0 +pytest-cov>=4.0.0 diff --git a/python/wishilist-faucet-mcp-for-tbnb/tbnb_faucet_server.py b/python/wishilist-faucet-mcp-for-tbnb/tbnb_faucet_server.py new file mode 100644 index 0000000..1552632 --- /dev/null +++ b/python/wishilist-faucet-mcp-for-tbnb/tbnb_faucet_server.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +""" +BNB Testnet Faucet - Server-Side MCP Implementation +HTTP-based MCP server for distributing testnet BNB tokens +Deployed as a public service accessible via HTTP/SSE +""" + +import os +import sys +from typing import Any + +from mcp.server.fastmcp import FastMCP +from web3 import Web3 + +# web3 v7: geth_poa_middleware → ExtraDataToPOAMiddleware +try: + from web3.middleware import ExtraDataToPOAMiddleware as poa_middleware +except ImportError: + from web3.middleware import geth_poa_middleware as poa_middleware + +# Server configuration (must be before FastMCP init; host/port are constructor args) +SERVER_HOST = os.environ.get("SERVER_HOST", "0.0.0.0") +SERVER_PORT = int(os.environ.get("SERVER_PORT", "8000")) + +# Initialize FastMCP server for HTTP transport +mcp = FastMCP( + "TBNB Faucet Server - Public API", + host=SERVER_HOST, + port=SERVER_PORT, + streamable_http_path="/mcp", +) + +# Blockchain configuration +RPC_ENDPOINT = os.environ.get("BSC_TESTNET_RPC_URL", "https://data-seed-prebsc-1-s1.binance.org:8545/") +PRIVATE_KEY = os.environ.get("FAUCET_WALLET_PRIVATE_KEY", "") + +# Initialize Web3 connection +w3 = Web3(Web3.HTTPProvider(RPC_ENDPOINT)) +w3.middleware_onion.inject(poa_middleware, layer=0) + +if not w3.is_connected(): + raise RuntimeError("Failed to connect to BSC Testnet RPC endpoint") + +# Initialize faucet wallet +faucet_address = None +if PRIVATE_KEY: + account = w3.eth.account.from_key(PRIVATE_KEY) + faucet_address = account.address +else: + print("WARNING: FAUCET_WALLET_PRIVATE_KEY not set. Server will not be able to disburse tokens.", file=sys.stderr) + + +def is_valid_address(address: str) -> bool: + """Check if address is a valid BSC address""" + return w3.is_address(address) + + +@mcp.tool() +def disburse_tbnb(recipient_address: str, amount: float = 0.1) -> dict[str, Any]: + """ + Disburse testnet BNB tokens to a recipient address on BSC testnet. + This is the primary service provided by this public MCP server. + + Args: + recipient_address: BSC testnet address to receive TBNB tokens + amount: Amount of TBNB to send (default: 0.1, max: 1.0) + + Returns: + Transaction details including hash, status, and block number + """ + if not PRIVATE_KEY: + return { + "success": False, + "error": "Faucet service is not configured" + } + + if not faucet_address: + return { + "success": False, + "error": "Faucet wallet not initialized" + } + + # Validate recipient address + if not recipient_address or not recipient_address.strip(): + return { + "success": False, + "error": "Recipient address is required" + } + + recipient_address = recipient_address.strip() + + if not is_valid_address(recipient_address): + return { + "success": False, + "error": f"Invalid BSC address format: {recipient_address}" + } + + recipient_address = w3.to_checksum_address(recipient_address) + + # Prevent self-transfer + if recipient_address.lower() == faucet_address.lower(): + return { + "success": False, + "error": "Cannot send tokens to the faucet address itself" + } + + # Validate amount + if amount <= 0: + return { + "success": False, + "error": "Amount must be greater than 0" + } + + if amount > 1.0: + return { + "success": False, + "error": "Maximum amount per request is 1.0 TBNB" + } + + try: + # Get current nonce + nonce = w3.eth.get_transaction_count(faucet_address) + + # Get current gas price + gas_price = w3.eth.gas_price + + # Convert amount to Wei + amount_wei = w3.to_wei(amount, 'ether') + + # Build transaction + transaction = { + 'to': recipient_address, + 'value': amount_wei, + 'gas': 21000, + 'gasPrice': gas_price, + 'nonce': nonce, + 'chainId': 97 # BSC Testnet + } + + # Sign transaction + signed_txn = w3.eth.account.sign_transaction(transaction, PRIVATE_KEY) + + # Send transaction + tx_hash = w3.eth.send_raw_transaction(signed_txn.rawTransaction) + + # Wait for transaction receipt + receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=180) + + return { + "success": True, + "transaction_hash": tx_hash.hex(), + "recipient": recipient_address, + "amount_tbnb": amount, + "block_number": receipt.blockNumber, + "status": "confirmed" if receipt.status == 1 else "failed", + "explorer_url": f"https://testnet.bscscan.com/tx/{tx_hash.hex()}" + } + + except Exception as e: + return { + "success": False, + "error": f"Transaction failed: {str(e)}" + } + + +if __name__ == "__main__": + # Run as HTTP server for public access (host/port set in FastMCP constructor) + mcp.run(transport="streamable-http") diff --git a/python/wishilist-faucet-mcp-for-tbnb/test_tbnb_faucet_server.py b/python/wishilist-faucet-mcp-for-tbnb/test_tbnb_faucet_server.py new file mode 100644 index 0000000..e3c7b8f --- /dev/null +++ b/python/wishilist-faucet-mcp-for-tbnb/test_tbnb_faucet_server.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +""" +Unit tests for BNB Testnet Faucet Server-Side MCP (Example 2) +""" + +import os +import sys +import pytest +from unittest.mock import Mock, patch, MagicMock + +# Set test environment variables before importing server +os.environ["FAUCET_WALLET_PRIVATE_KEY"] = "0x" + "1" * 64 # Dummy private key +os.environ["BSC_TESTNET_RPC_URL"] = "https://test-rpc.example.com" +os.environ["SERVER_HOST"] = "127.0.0.1" +os.environ["SERVER_PORT"] = "8000" + +# Mock Web3 before importing server - connection check runs at import time +mock_w3 = MagicMock() +mock_w3.is_connected.return_value = True + +def _is_address(addr): + """Return False for invalid addresses, True for valid-looking ones.""" + if not addr or not isinstance(addr, str): + return False + addr = addr.strip() + return addr.startswith("0x") and len(addr) == 42 and all(c in "0123456789abcdefABCDEFx" for c in addr[2:]) + +mock_w3.is_address.side_effect = _is_address +mock_w3.to_checksum_address.side_effect = lambda x: x if isinstance(x, str) else str(x) + +# Mock middleware_onion (needed for inject call at import time) +mock_w3.middleware_onion = MagicMock() +mock_w3.middleware_onion.inject = MagicMock() + +mock_account = MagicMock() +# Valid 42-char Ethereum addresses (0x + 40 hex) +VALID_ADDR = "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0" +OTHER_ADDR = "0x1234567890123456789012345678901234567890" # Different from faucet +mock_account.address = VALID_ADDR +mock_w3.eth.account.from_key.return_value = mock_account + +# Web3(...) must return our mock; Web3.HTTPProvider is used as constructor arg +MockWeb3 = MagicMock(return_value=mock_w3) +MockWeb3.HTTPProvider = MagicMock() + +# Patch before import - server connects to BSC at import time +_web3_patcher = patch("web3.Web3", MockWeb3) +_web3_patcher.start() + +# Patch middleware at the server module level (handles both v6 and v7) +_mock_poa = MagicMock() +_middleware_patcher = patch("tbnb_faucet_server.poa_middleware", _mock_poa) +_middleware_patcher.start() + +import tbnb_faucet_server as server # noqa: E402 + + +class TestIsValidAddress: + """Tests for is_valid_address function""" + + @patch('tbnb_faucet_server.w3') + def test_valid_address(self, mock_w3): + """Test with valid address""" + mock_w3.is_address.return_value = True + result = server.is_valid_address(VALID_ADDR) + assert result is True + + @patch('tbnb_faucet_server.w3') + def test_invalid_address(self, mock_w3): + """Test with invalid address""" + mock_w3.is_address.return_value = False + result = server.is_valid_address("invalid") + assert result is False + + +class TestDisburseTbnb: + """Tests for disburse_tbnb tool""" + + def test_no_private_key(self): + """Test when private key is not configured""" + with patch.object(server, "PRIVATE_KEY", ""): + result = server.disburse_tbnb(VALID_ADDR, 0.1) + assert result["success"] is False + assert "not configured" in result["error"].lower() + + def test_no_faucet_address(self): + """Test when faucet address is not initialized""" + with patch.object(server, 'faucet_address', None): + result = server.disburse_tbnb(VALID_ADDR, 0.1) + assert result["success"] is False + assert "not initialized" in result["error"].lower() + + def test_empty_recipient_address(self): + """Test with empty recipient address""" + result = server.disburse_tbnb("", 0.1) + assert result["success"] is False + assert "required" in result["error"].lower() + + def test_invalid_address_format(self): + """Test with invalid address format""" + with patch.object(server, 'is_valid_address', return_value=False): + result = server.disburse_tbnb("invalid_address", 0.1) + assert result["success"] is False + assert "invalid" in result["error"].lower() + + def test_self_transfer_prevention(self): + """Test that sending to faucet address itself is prevented""" + with patch.object(server, 'faucet_address', VALID_ADDR): + with patch.object(server, 'is_valid_address', return_value=True): + with patch('tbnb_faucet_server.w3') as mock_w3: + mock_w3.to_checksum_address.return_value = VALID_ADDR + result = server.disburse_tbnb(VALID_ADDR, 0.1) + assert result["success"] is False + assert "faucet address itself" in result["error"].lower() + + def test_amount_zero(self): + """Test amount validation for zero""" + with patch.object(server, 'faucet_address', VALID_ADDR): + with patch.object(server, 'is_valid_address', return_value=True): + with patch('tbnb_faucet_server.w3') as mock_w3: + mock_w3.to_checksum_address.return_value = OTHER_ADDR + result = server.disburse_tbnb(OTHER_ADDR, 0) + assert result["success"] is False + assert "greater than 0" in result["error"].lower() + + def test_amount_too_large(self): + """Test amount validation for amounts over 1.0""" + with patch.object(server, 'faucet_address', VALID_ADDR): + with patch.object(server, 'is_valid_address', return_value=True): + with patch('tbnb_faucet_server.w3') as mock_w3: + mock_w3.to_checksum_address.return_value = OTHER_ADDR + result = server.disburse_tbnb(OTHER_ADDR, 2.0) + assert result["success"] is False + assert "Maximum amount" in result["error"] or "1.0" in result["error"] + + @patch('tbnb_faucet_server.w3') + def test_successful_disbursement(self, mock_w3): + """Test successful TBNB disbursement""" + with patch.object(server, 'faucet_address', VALID_ADDR): + with patch.object(server, 'is_valid_address', return_value=True): + mock_w3.to_checksum_address.return_value = OTHER_ADDR + mock_w3.eth.get_transaction_count.return_value = 0 + mock_w3.eth.gas_price = 1000000000 + mock_w3.to_wei.return_value = 100000000000000000 # 0.1 ETH in Wei + + # Mock transaction signing + mock_signed = Mock() + mock_signed.rawTransaction = b"raw_tx_data" + mock_w3.eth.account.sign_transaction.return_value = mock_signed + + # Mock transaction hash + mock_tx_hash = Mock() + mock_tx_hash.hex.return_value = "0xabcdef1234567890" + mock_w3.eth.send_raw_transaction.return_value = mock_tx_hash + + # Mock receipt + mock_receipt = Mock() + mock_receipt.blockNumber = 54321 + mock_receipt.status = 1 + mock_w3.eth.wait_for_transaction_receipt.return_value = mock_receipt + + result = server.disburse_tbnb(OTHER_ADDR, 0.1) + + assert result["success"] is True + assert result["transaction_hash"] == "0xabcdef1234567890" + assert result["recipient"] == OTHER_ADDR + assert result["amount_tbnb"] == 0.1 + assert result["block_number"] == 54321 + assert result["status"] == "confirmed" + assert "explorer_url" in result + assert "testnet.bscscan.com" in result["explorer_url"] + + @patch('tbnb_faucet_server.w3') + def test_transaction_failure(self, mock_w3): + """Test handling of transaction failures""" + with patch.object(server, 'faucet_address', VALID_ADDR): + with patch.object(server, 'is_valid_address', return_value=True): + mock_w3.to_checksum_address.return_value = OTHER_ADDR + mock_w3.eth.get_transaction_count.side_effect = Exception("Network error") + + result = server.disburse_tbnb(OTHER_ADDR, 0.1) + + assert result["success"] is False + assert "Transaction failed" in result["error"] + + +class TestServerConfiguration: + """Tests for server configuration""" + + def test_server_host_configuration(self): + """Test SERVER_HOST environment variable""" + assert hasattr(server, 'SERVER_HOST') + assert server.SERVER_HOST is not None + + def test_server_port_configuration(self): + """Test SERVER_PORT environment variable""" + assert hasattr(server, 'SERVER_PORT') + assert isinstance(server.SERVER_PORT, int) + assert server.SERVER_PORT > 0 + + def test_mcp_server_initialized(self): + """Test that MCP server is properly initialized""" + assert server.mcp is not None + assert hasattr(server.mcp, 'run') + + def test_tool_registered(self): + """Test that disburse_tbnb tool is registered""" + assert hasattr(server, 'disburse_tbnb') + + +class TestWeb3Connection: + """Tests for Web3 connection setup""" + + def test_web3_initialized(self): + """Test that Web3 is initialized""" + assert hasattr(server, 'w3') + assert server.w3 is not None + + @patch('tbnb_faucet_server.w3') + def test_connection_check(self, mock_w3): + """Test Web3 connection status""" + mock_w3.is_connected.return_value = True + # The connection check happens at import time + # We can verify the w3 object exists + assert server.w3 is not None + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])