Skip to content

Commit c3595c6

Browse files
committed
feat(http-transport): implement Phase 1 core HTTP transport with Streamable HTTP
Add HTTP transport support alongside existing stdio transport using MCP SDK's StreamableHTTPSessionManager and Starlette ASGI framework. BREAKING CHANGE: Requires MCP SDK >=1.18.0 (upgraded from >=1.6.0) Changes: - Add transport_config.py with TransportConfig dataclass for transport selection - Add health.py with /health endpoint (returns 503 when database disconnected) - Add http_transport.py with Streamable HTTP implementation using Starlette - Modify entry.py to support transport routing (stdio/http) - Update pyproject.toml and requirements.txt with starlette, uvicorn, mcp>=1.18.0 - Bump version from 0.2.2 to 0.2.5 Implementation Details: - StreamableHTTPSessionManager.handle_request used as ASGI callable - CORS configured to expose Mcp-Session-Id header for browser clients - Health check accesses database from server's lifespan context - Backward compatible: defaults to stdio transport if no config provided - Graceful degradation: server starts even when database is unavailable Testing: - HTTP server verified running on http://0.0.0.0:8000 - Health endpoint tested: returns correct status and 503 code - Test suite: 172/193 passing (no new failures introduced) Ref: HTTP_TRANSPORT_IMPLEMENTATION_PLAN.md Phase 1 (lines 1610-1656)
1 parent 5a91c57 commit c3595c6

File tree

6 files changed

+310
-10
lines changed

6 files changed

+310
-10
lines changed

mcp_arangodb_async/entry.py

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -426,9 +426,10 @@ def _safe_del_request_context(self: Any) -> None:
426426
)
427427

428428

429-
async def run() -> None:
430-
"""Run the MCP server with stdio transport.
431-
429+
async def run_stdio() -> None:
430+
"""Run the MCP server with stdio transport (original implementation).
431+
432+
This is the default transport for desktop AI clients like Claude Desktop.
432433
Sets up the server with proper initialization options and runs it
433434
until termination.
434435
"""
@@ -438,7 +439,7 @@ async def run() -> None:
438439
write_stream,
439440
InitializationOptions(
440441
server_name="mcp-arangodb-async",
441-
server_version="0.1.0",
442+
server_version="0.2.5",
442443
capabilities=server.get_capabilities(
443444
notification_options=NotificationOptions(),
444445
experimental_capabilities={},
@@ -447,13 +448,46 @@ async def run() -> None:
447448
)
448449

449450

450-
def main() -> None:
451+
async def run(transport_config: "TransportConfig | None" = None) -> None:
452+
"""
453+
Run the MCP server with specified transport.
454+
455+
Args:
456+
transport_config: Transport configuration. If None, uses stdio (default).
457+
"""
458+
# Import here to avoid circular dependency and to make HTTP dependencies optional
459+
from .transport_config import TransportConfig
460+
461+
if transport_config is None:
462+
transport_config = TransportConfig() # Default to stdio
463+
464+
if transport_config.transport == "stdio":
465+
await run_stdio()
466+
elif transport_config.transport == "http":
467+
# Import HTTP transport only when needed
468+
from .http_transport import run_http_server
469+
470+
await run_http_server(
471+
server,
472+
host=transport_config.http_host,
473+
port=transport_config.http_port,
474+
stateless=transport_config.http_stateless,
475+
cors_origins=transport_config.http_cors_origins,
476+
)
477+
else:
478+
raise ValueError(f"Unknown transport: {transport_config.transport}")
479+
480+
481+
def main(transport_config: "TransportConfig | None" = None) -> None:
451482
"""Console script entry point for arango-server command.
452-
483+
453484
This is the main entry point that starts the async MCP server.
454485
Used by the console script defined in pyproject.toml.
486+
487+
Args:
488+
transport_config: Optional transport configuration. If None, uses stdio (default).
455489
"""
456-
asyncio.run(run())
490+
asyncio.run(run(transport_config))
457491

458492

459493
if __name__ == "__main__":

mcp_arangodb_async/health.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""
2+
Health Check Endpoint for MCP ArangoDB Server
3+
4+
This module provides health check functionality for monitoring server status.
5+
Returns database connectivity status and server information.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import logging
11+
from typing import Any, Dict
12+
13+
from arango.database import StandardDatabase
14+
15+
logger = logging.getLogger(__name__)
16+
17+
18+
async def health_check(db: StandardDatabase | None) -> Dict[str, Any]:
19+
"""
20+
Check server and database health status.
21+
22+
Args:
23+
db: ArangoDB database instance (may be None if connection failed)
24+
25+
Returns:
26+
Dictionary containing health status information:
27+
- status: "healthy" or "unhealthy"
28+
- database_connected: Boolean indicating database connectivity
29+
- database_info: Database version and details (if connected)
30+
- error: Error message (if unhealthy)
31+
"""
32+
health_status: Dict[str, Any] = {
33+
"status": "healthy",
34+
"database_connected": False,
35+
}
36+
37+
# Check database connectivity
38+
if db is None:
39+
health_status["status"] = "unhealthy"
40+
health_status["error"] = "Database connection not established"
41+
logger.warning("Health check: Database connection is None")
42+
return health_status
43+
44+
try:
45+
# Test database connectivity by getting version
46+
version = db.version()
47+
health_status["database_connected"] = True
48+
health_status["database_info"] = {
49+
"version": version,
50+
"name": db.name,
51+
}
52+
logger.debug(f"Health check: Database connected (version: {version})")
53+
except Exception as e:
54+
health_status["status"] = "unhealthy"
55+
health_status["database_connected"] = False
56+
health_status["error"] = f"Database connectivity check failed: {str(e)}"
57+
logger.error(f"Health check failed: {e}")
58+
59+
return health_status
60+
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
"""
2+
HTTP Transport Implementation for MCP ArangoDB Server
3+
4+
This module implements Streamable HTTP transport using Starlette and the MCP SDK.
5+
Supports both stateful and stateless operation modes with proper CORS configuration.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import asyncio
11+
import logging
12+
from typing import Any
13+
14+
import uvicorn
15+
from starlette.applications import Starlette
16+
from starlette.middleware.cors import CORSMiddleware
17+
from starlette.requests import Request
18+
from starlette.responses import JSONResponse
19+
from starlette.routing import Route
20+
21+
from mcp.server.lowlevel import Server
22+
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
23+
24+
from .health import health_check
25+
26+
logger = logging.getLogger(__name__)
27+
28+
29+
def create_health_route(mcp_server: Server) -> Route:
30+
"""
31+
Create health check route for the HTTP server.
32+
33+
Args:
34+
mcp_server: MCP Server instance to check health of
35+
36+
Returns:
37+
Starlette Route for health endpoint
38+
"""
39+
async def health_endpoint(request: Request) -> JSONResponse:
40+
"""Health check endpoint handler."""
41+
try:
42+
# Access database from server's lifespan context
43+
ctx = mcp_server.request_context
44+
db = ctx.lifespan_context.get("db") if ctx and hasattr(ctx, "lifespan_context") else None
45+
46+
# Get health status
47+
status = await health_check(db)
48+
49+
# Return appropriate HTTP status code
50+
http_status = 200 if status["status"] == "healthy" else 503
51+
52+
return JSONResponse(status, status_code=http_status)
53+
except Exception as e:
54+
logger.error(f"Health check endpoint error: {e}")
55+
return JSONResponse(
56+
{"status": "unhealthy", "error": str(e)},
57+
status_code=503
58+
)
59+
60+
return Route("/health", health_endpoint, methods=["GET"])
61+
62+
63+
def create_http_app(
64+
mcp_server: Server,
65+
cors_origins: list[str] | None = None,
66+
stateless: bool = False,
67+
) -> tuple[Starlette, StreamableHTTPSessionManager]:
68+
"""
69+
Create Starlette application with MCP Streamable HTTP transport.
70+
71+
Args:
72+
mcp_server: MCP Server instance
73+
cors_origins: List of allowed CORS origins (default: ["*"])
74+
stateless: Whether to run in stateless mode (default: False)
75+
76+
Returns:
77+
Tuple of (Starlette app, StreamableHTTPSessionManager)
78+
"""
79+
if cors_origins is None:
80+
cors_origins = ["*"]
81+
82+
# Create StreamableHTTP session manager
83+
session_manager = StreamableHTTPSessionManager(
84+
mcp_server,
85+
stateless=stateless,
86+
)
87+
88+
# Create Starlette routes
89+
routes = [
90+
create_health_route(mcp_server),
91+
]
92+
93+
# Create Starlette app
94+
app = Starlette(routes=routes)
95+
96+
# Mount MCP StreamableHTTP endpoint at /mcp
97+
# The session_manager.handle_request is an ASGI callable
98+
app.mount("/mcp", session_manager.handle_request)
99+
100+
# Add CORS middleware
101+
# IMPORTANT: Mcp-Session-Id header must be exposed for browser clients
102+
app = CORSMiddleware(
103+
app,
104+
allow_origins=cors_origins,
105+
allow_methods=["GET", "POST", "DELETE"], # MCP streamable HTTP methods
106+
allow_headers=["*"],
107+
expose_headers=["Mcp-Session-Id"], # Critical for session management
108+
)
109+
110+
return app, session_manager
111+
112+
113+
async def run_http_server(
114+
mcp_server: Server,
115+
host: str = "0.0.0.0",
116+
port: int = 8000,
117+
stateless: bool = False,
118+
cors_origins: list[str] | None = None,
119+
) -> None:
120+
"""
121+
Run the MCP server with HTTP transport using uvicorn.
122+
123+
Args:
124+
mcp_server: MCP Server instance
125+
host: Host address to bind to (default: "0.0.0.0")
126+
port: Port number to bind to (default: 8000)
127+
stateless: Whether to run in stateless mode (default: False)
128+
cors_origins: List of allowed CORS origins (default: ["*"])
129+
"""
130+
logger.info(f"Starting MCP HTTP server on {host}:{port} (stateless={stateless})")
131+
132+
# Create Starlette app with MCP transport
133+
app, session_manager = create_http_app(
134+
mcp_server,
135+
cors_origins=cors_origins,
136+
stateless=stateless,
137+
)
138+
139+
# Create uvicorn config
140+
config = uvicorn.Config(
141+
app,
142+
host=host,
143+
port=port,
144+
log_level="info",
145+
access_log=True,
146+
)
147+
148+
# Create uvicorn server
149+
server = uvicorn.Server(config)
150+
151+
# Run session manager and uvicorn server concurrently
152+
async with session_manager.run():
153+
logger.info(f"MCP HTTP server ready at http://{host}:{port}/mcp")
154+
logger.info(f"Health check endpoint at http://{host}:{port}/health")
155+
await server.serve()
156+
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""
2+
Transport Configuration for MCP ArangoDB Server
3+
4+
This module defines configuration for different MCP transport types (stdio, HTTP).
5+
Provides validation and default values for transport-specific settings.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from dataclasses import dataclass
11+
from typing import Literal
12+
13+
14+
@dataclass(frozen=True)
15+
class TransportConfig:
16+
"""Configuration for MCP server transport.
17+
18+
Attributes:
19+
transport: Transport type ("stdio" or "http")
20+
http_host: Host address for HTTP transport (default: "0.0.0.0")
21+
http_port: Port number for HTTP transport (default: 8000)
22+
http_stateless: Whether to run HTTP in stateless mode (default: False)
23+
http_cors_origins: List of allowed CORS origins (default: ["*"])
24+
"""
25+
26+
transport: Literal["stdio", "http"] = "stdio"
27+
http_host: str = "0.0.0.0"
28+
http_port: int = 8000
29+
http_stateless: bool = False
30+
http_cors_origins: list[str] | None = None
31+
32+
def __post_init__(self) -> None:
33+
"""Validate configuration after initialization."""
34+
# Validate transport type
35+
if self.transport not in ("stdio", "http"):
36+
raise ValueError(f"Invalid transport: {self.transport}. Must be 'stdio' or 'http'.")
37+
38+
# Validate HTTP port
39+
if not (1 <= self.http_port <= 65535):
40+
raise ValueError(f"Invalid HTTP port: {self.http_port}. Must be between 1 and 65535.")
41+
42+
# Set default CORS origins if None
43+
if self.http_cors_origins is None:
44+
# Use object.__setattr__ because dataclass is frozen
45+
object.__setattr__(self, "http_cors_origins", ["*"])
46+

pyproject.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "mcp-arangodb-async"
7-
version = "0.2.2"
7+
version = "0.2.5"
88
description = "A Model Context Protocol server for ArangoDB"
99
readme = "README.md"
1010
license = "Apache-2.0"
@@ -26,9 +26,11 @@ requires-python = ">=3.11"
2626
dependencies = [
2727
"python-arango>=7.6,<8",
2828
"python-dotenv>=1.0,<2",
29-
"mcp>=1.6",
29+
"mcp>=1.18.0",
3030
"pydantic>=2,<3",
3131
"jsonschema>=4,<5",
32+
"starlette>=0.27.0,<1.0",
33+
"uvicorn[standard]>=0.23.0,<1.0",
3234
]
3335

3436
[project.optional-dependencies]

requirements.txt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
python-arango>=7.6,<8
22
python-dotenv>=1.0,<2
3-
mcp>=1.6
3+
mcp>=1.18.0
44
pydantic>=2,<3
55
jsonschema>=4,<5
6+
starlette>=0.27.0,<1.0
7+
uvicorn[standard]>=0.23.0,<1.0

0 commit comments

Comments
 (0)