Add HTTP transport option for MCP server

This commit is contained in:
latinogino
2025-12-28 13:37:24 +01:00
parent 58882ff8c0
commit 373f4063b2
6 changed files with 152 additions and 25 deletions

View File

@@ -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 WebUIs MCP configuration at `http://<host>:8080/`. The MCP
protocol headers (including `mcp-protocol-version`) are handled automatically by
Open WebUIs 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).

View File

@@ -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:

View File

@@ -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]

View File

@@ -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

View File

@@ -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."""

View File

@@ -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