diff --git a/README.md b/README.md index ee1ee5d..434fac1 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,9 @@ The server reads configuration from the environment or a `.env` file. Both | `DOLIBARR_URL` / `DOLIBARR_SHOP_URL` | Base Dolibarr API endpoint, e.g. `https://example.com/api/index.php`. Trailing slashes are handled automatically. | | `DOLIBARR_API_KEY` | Personal Dolibarr API token. | | `LOG_LEVEL` | Optional logging verbosity (`INFO`, `DEBUG`, `WARNING`, …). | +| `MCP_TRANSPORT` | Transport to use: `stdio` (default) or `http` for streamable HTTP. | +| `MCP_HTTP_HOST` | Host/interface to bind when using HTTP transport (default `0.0.0.0`). | +| `MCP_HTTP_PORT` | Port to bind when using HTTP transport (default `8080`). | Example `.env`: @@ -127,8 +130,8 @@ same environment variables when launched from Linux or macOS hosts. ### Start the MCP server -The server communicates over STDIO, so run it in the foreground from the virtual -environment: +The server communicates over STDIO by default, so run it in the foreground from +the virtual environment: ```bash python -m dolibarr_mcp.dolibarr_mcp_server @@ -137,6 +140,20 @@ python -m dolibarr_mcp.dolibarr_mcp_server Logs are written to stderr to avoid interfering with the MCP protocol. Keep the process running while Claude Desktop is active. +### HTTP streaming mode (for Open WebUI or remote MCP clients) + +Enable the HTTP transport by setting `MCP_TRANSPORT=http` (and optionally +`MCP_HTTP_HOST` / `MCP_HTTP_PORT`). This keeps the server running without STDIO +and exposes the Streamable HTTP transport compatible with Open WebUI: + +```bash +MCP_TRANSPORT=http MCP_HTTP_PORT=8080 python -m dolibarr_mcp.dolibarr_mcp_server +``` + +Then point Open WebUI’s MCP configuration at `http://:8080/`. The MCP +protocol headers (including `mcp-protocol-version`) are handled automatically by +Open WebUI’s MCP client. + ### Test the Dolibarr credentials Use the standalone connectivity check before wiring the server into an MCP host: @@ -161,4 +178,3 @@ When the environment variables are already set, omit the overrides and run ## 📄 License Released under the [MIT License](LICENSE). - diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 4f2278b..fcc24f1 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -16,6 +16,9 @@ services: - PYTHONUNBUFFERED=1 # MCP Server Configuration + - MCP_TRANSPORT=${MCP_TRANSPORT:-http} + - MCP_HTTP_PORT=${MCP_HTTP_PORT:-8080} + - MCP_HTTP_HOST=${MCP_HTTP_HOST:-0.0.0.0} - MCP_SERVER_NAME=dolibarr-mcp - MCP_SERVER_VERSION=1.0.0 @@ -29,7 +32,7 @@ services: # Expose port for future HTTP interface ports: - - "8080:8080" + - "18004:8080" # Health check healthcheck: diff --git a/pyproject.toml b/pyproject.toml index 491738b..88b6a00 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ dependencies = [ "pydantic-settings>=2.0.0", "click>=8.1.0", "python-dotenv>=1.0.0", + "uvicorn>=0.25.0", ] [project.optional-dependencies] diff --git a/requirements.txt b/requirements.txt index d60f979..cb0b52c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,6 +12,9 @@ python-dotenv>=1.0.0 # CLI support click>=8.1.0 +# HTTP server runtime for Streamable HTTP transport +uvicorn>=0.25.0 + # Development and testing pytest>=7.4.0 pytest-asyncio>=0.21.0 diff --git a/src/dolibarr_mcp/config.py b/src/dolibarr_mcp/config.py index a2fc62d..b715ae6 100644 --- a/src/dolibarr_mcp/config.py +++ b/src/dolibarr_mcp/config.py @@ -38,6 +38,21 @@ class Config(BaseSettings): default="INFO", ) + mcp_transport: str = Field( + description="Transport for MCP server (stdio or http)", + default="stdio", + ) + + mcp_http_host: str = Field( + description="HTTP host/interface for MCP server", + default="0.0.0.0", + ) + + mcp_http_port: int = Field( + description="HTTP port for MCP server", + default=8080, + ) + @field_validator("dolibarr_url") @classmethod def validate_dolibarr_url(cls, v: str) -> str: @@ -114,6 +129,36 @@ class Config(BaseSettings): return "INFO" return v.upper() + @field_validator("mcp_transport") + @classmethod + def validate_transport(cls, v: str) -> str: + """Validate MCP transport selection.""" + if not v: + v = os.getenv("MCP_TRANSPORT", "stdio") + normalized = v.lower() + if normalized not in {"stdio", "http"}: + print(f"⚠️ Invalid MCP_TRANSPORT '{v}', defaulting to stdio", file=sys.stderr) + return "stdio" + return normalized + + @field_validator("mcp_http_host") + @classmethod + def validate_http_host(cls, v: str) -> str: + """Validate HTTP host.""" + return v or os.getenv("MCP_HTTP_HOST", "0.0.0.0") + + @field_validator("mcp_http_port") + @classmethod + def validate_http_port(cls, v: int) -> int: + """Validate HTTP port.""" + try: + port = int(v) + except Exception as exc: + raise ValueError(f"Invalid MCP_HTTP_PORT '{v}': {exc}") from exc + if not 1 <= port <= 65535: + raise ValueError("MCP_HTTP_PORT must be between 1 and 65535") + return port + @classmethod def from_env(cls) -> "Config": """Create configuration from environment variables with validation.""" diff --git a/src/dolibarr_mcp/dolibarr_mcp_server.py b/src/dolibarr_mcp/dolibarr_mcp_server.py index a7b90fe..6b9d4e4 100644 --- a/src/dolibarr_mcp/dolibarr_mcp_server.py +++ b/src/dolibarr_mcp/dolibarr_mcp_server.py @@ -3,7 +3,6 @@ import asyncio import json import sys -import os import logging from contextlib import asynccontextmanager @@ -11,12 +10,19 @@ from contextlib import asynccontextmanager from mcp.server.models import InitializationOptions from mcp.server import NotificationOptions, Server from mcp.server.stdio import stdio_server +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager from mcp.types import Tool, TextContent # Import our Dolibarr components from .config import Config from .dolibarr_client import DolibarrClient, DolibarrAPIError +# HTTP transport imports +from starlette.applications import Starlette +from starlette.responses import Response +from starlette.routing import Route +import uvicorn + # Configure logging to stderr so it doesn't interfere with MCP protocol logging.basicConfig( @@ -1379,11 +1385,13 @@ async def handle_call_tool(name: str, arguments: dict): @asynccontextmanager -async def test_api_connection(): +async def test_api_connection(config: Config | None = None): """Test API connection and yield client if successful.""" - config = None + created_config = False try: - config = Config() + if config is None: + config = Config() + created_config = True # Check if environment variables are set if not config.dolibarr_url or config.dolibarr_url == "https://your-dolibarr-instance.com/api/index.php": @@ -1413,17 +1421,77 @@ async def test_api_connection(): yield True # Allow server to start anyway except Exception as e: print(f"⚠️ API test error: {e}", file=sys.stderr) - if config is None: + if config is None or created_config: print("💡 Check your .env file configuration", file=sys.stderr) print("⚠️ Server will start but API calls may fail", file=sys.stderr) yield True # Allow server to start anyway +async def _run_stdio_server(_config: Config) -> None: + """Run the MCP server over STDIO (default).""" + async with stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + InitializationOptions( + server_name="dolibarr-mcp", + server_version="1.0.1", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) + + +def _build_http_app(session_manager: StreamableHTTPSessionManager) -> Starlette: + """Create Starlette app that forwards to the StreamableHTTP session manager.""" + + async def options_handler(request): + """Lightweight CORS-friendly response for preflight requests.""" + return Response(status_code=204) + + async def lifespan(app): + async with session_manager.run(): + yield + + return Starlette( + routes=[ + Route("/", session_manager.handle_request, methods=["GET", "POST", "DELETE"]), + Route("/{path:path}", session_manager.handle_request, methods=["GET", "POST", "DELETE"]), + Route("/", options_handler, methods=["OPTIONS"]), + Route("/{path:path}", options_handler, methods=["OPTIONS"]), + ], + lifespan=lifespan, + ) + + +async def _run_http_server(config: Config) -> None: + """Run the MCP server over HTTP (StreamableHTTP).""" + session_manager = StreamableHTTPSessionManager(server, json_response=False, stateless=False) + app = _build_http_app(session_manager) + print( + f"🌐 Starting MCP HTTP server on {config.mcp_http_host}:{config.mcp_http_port}", + file=sys.stderr, + ) + uvicorn_config = uvicorn.Config( + app, + host=config.mcp_http_host, + port=config.mcp_http_port, + log_level=config.log_level.lower(), + loop="asyncio", + access_log=False, + ) + uvicorn_server = uvicorn.Server(uvicorn_config) + await uvicorn_server.serve() + + async def main(): """Run the Dolibarr MCP server.""" - + config = Config() + # Test API connection but don't fail if it's not working - async with test_api_connection() as api_ok: + async with test_api_connection(config) as api_ok: if not api_ok: print("⚠️ Starting server without valid API connection", file=sys.stderr) print("📝 Configure your .env file to enable API functionality", file=sys.stderr) @@ -1434,21 +1502,12 @@ async def main(): print("🚀 Starting Professional Dolibarr MCP server...", file=sys.stderr) print("✅ Server ready with comprehensive ERP management capabilities", file=sys.stderr) print("📝 Tools will attempt to connect when called", file=sys.stderr) - + try: - async with stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - InitializationOptions( - server_name="dolibarr-mcp", - server_version="1.0.1", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), - ) + if config.mcp_transport == "http": + await _run_http_server(config) + else: + await _run_stdio_server(config) except Exception as e: print(f"💥 Server error: {e}", file=sys.stderr) raise