chora-mcp-gateway 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chora_mcp_gateway/__init__.py +3 -0
- chora_mcp_gateway/health_checker.py +230 -0
- chora_mcp_gateway/main.py +148 -0
- chora_mcp_gateway/models.py +32 -0
- chora_mcp_gateway/registry_poller.py +139 -0
- chora_mcp_gateway/router.py +201 -0
- chora_mcp_gateway/server.py +153 -0
- chora_mcp_gateway-0.1.0.dist-info/METADATA +272 -0
- chora_mcp_gateway-0.1.0.dist-info/RECORD +10 -0
- chora_mcp_gateway-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Health checking for backend MCP servers."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Optional, Dict, List
|
|
5
|
+
from pydantic import BaseModel, ConfigDict
|
|
6
|
+
import httpx
|
|
7
|
+
from .router import RoutingTable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BackendHealth(BaseModel):
|
|
11
|
+
"""Health status for a backend server."""
|
|
12
|
+
namespace: str
|
|
13
|
+
status: str # "healthy", "unhealthy", "unreachable", "unknown"
|
|
14
|
+
response_time_ms: Optional[float] = None
|
|
15
|
+
error_message: Optional[str] = None
|
|
16
|
+
|
|
17
|
+
model_config = ConfigDict(extra='allow')
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HealthChecker:
|
|
21
|
+
"""Monitors health of backend MCP servers."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
routing_table: RoutingTable,
|
|
26
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
27
|
+
check_interval: int = 60
|
|
28
|
+
):
|
|
29
|
+
"""
|
|
30
|
+
Initialize health checker.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
routing_table: RoutingTable to monitor and update
|
|
34
|
+
http_client: Optional custom HTTP client
|
|
35
|
+
check_interval: Health check interval in seconds (default: 60)
|
|
36
|
+
"""
|
|
37
|
+
self.routing_table = routing_table
|
|
38
|
+
self.http_client = http_client or httpx.AsyncClient(timeout=5.0)
|
|
39
|
+
self.check_interval = check_interval
|
|
40
|
+
self._is_checking = False
|
|
41
|
+
self._checking_task: Optional[asyncio.Task] = None
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def is_checking(self) -> bool:
|
|
45
|
+
"""Return whether periodic checking is active."""
|
|
46
|
+
return self._is_checking
|
|
47
|
+
|
|
48
|
+
async def check_backend(self, namespace: str) -> BackendHealth:
|
|
49
|
+
"""
|
|
50
|
+
Check health of a specific backend server.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
namespace: Server namespace to check
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
BackendHealth object with status
|
|
57
|
+
"""
|
|
58
|
+
# Find a route for this namespace to get backend URL
|
|
59
|
+
route = None
|
|
60
|
+
for tool_name, r in self.routing_table._routes.items():
|
|
61
|
+
if r.namespace == namespace:
|
|
62
|
+
route = r
|
|
63
|
+
break
|
|
64
|
+
|
|
65
|
+
if route is None:
|
|
66
|
+
return BackendHealth(
|
|
67
|
+
namespace=namespace,
|
|
68
|
+
status="unknown",
|
|
69
|
+
error_message="No route found for namespace"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Construct health endpoint URL
|
|
73
|
+
health_url = f"{route.backend_url}/health"
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
# Make health check request
|
|
77
|
+
response = await self.http_client.get(health_url)
|
|
78
|
+
|
|
79
|
+
# Calculate response time (handle mock responses without elapsed)
|
|
80
|
+
response_time_ms = None
|
|
81
|
+
if hasattr(response, 'elapsed') and response.elapsed:
|
|
82
|
+
response_time_ms = response.elapsed.total_seconds() * 1000
|
|
83
|
+
|
|
84
|
+
# Check status code
|
|
85
|
+
if response.status_code == 200:
|
|
86
|
+
# Backend is healthy
|
|
87
|
+
return BackendHealth(
|
|
88
|
+
namespace=namespace,
|
|
89
|
+
status="healthy",
|
|
90
|
+
response_time_ms=response_time_ms
|
|
91
|
+
)
|
|
92
|
+
elif response.status_code == 503:
|
|
93
|
+
# Backend reports unhealthy
|
|
94
|
+
return BackendHealth(
|
|
95
|
+
namespace=namespace,
|
|
96
|
+
status="unhealthy",
|
|
97
|
+
response_time_ms=response_time_ms
|
|
98
|
+
)
|
|
99
|
+
elif response.status_code == 404:
|
|
100
|
+
# No health endpoint - assume healthy for backwards compatibility
|
|
101
|
+
return BackendHealth(
|
|
102
|
+
namespace=namespace,
|
|
103
|
+
status="healthy",
|
|
104
|
+
response_time_ms=response_time_ms,
|
|
105
|
+
error_message="No health endpoint (backwards compatibility)"
|
|
106
|
+
)
|
|
107
|
+
else:
|
|
108
|
+
# Unexpected status code
|
|
109
|
+
return BackendHealth(
|
|
110
|
+
namespace=namespace,
|
|
111
|
+
status="unhealthy",
|
|
112
|
+
response_time_ms=response_time_ms,
|
|
113
|
+
error_message=f"Unexpected status code: {response.status_code}"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
except httpx.TimeoutException as e:
|
|
117
|
+
# Backend timed out
|
|
118
|
+
return BackendHealth(
|
|
119
|
+
namespace=namespace,
|
|
120
|
+
status="unreachable",
|
|
121
|
+
error_message=f"Timeout: {str(e)}"
|
|
122
|
+
)
|
|
123
|
+
except (httpx.ConnectError, httpx.NetworkError) as e:
|
|
124
|
+
# Backend unreachable
|
|
125
|
+
return BackendHealth(
|
|
126
|
+
namespace=namespace,
|
|
127
|
+
status="unreachable",
|
|
128
|
+
error_message=f"Connection error: {str(e)}"
|
|
129
|
+
)
|
|
130
|
+
except Exception as e:
|
|
131
|
+
# Other error
|
|
132
|
+
return BackendHealth(
|
|
133
|
+
namespace=namespace,
|
|
134
|
+
status="unreachable",
|
|
135
|
+
error_message=f"Error: {str(e)}"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
async def check_all_backends(self) -> List[BackendHealth]:
|
|
139
|
+
"""
|
|
140
|
+
Check health of all backends in routing table.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
List of BackendHealth objects (one per unique namespace)
|
|
144
|
+
"""
|
|
145
|
+
# Get unique namespaces from routing table
|
|
146
|
+
namespaces = set()
|
|
147
|
+
for route in self.routing_table.list_all():
|
|
148
|
+
namespaces.add(route.namespace)
|
|
149
|
+
|
|
150
|
+
# Check health of each namespace
|
|
151
|
+
health_results = []
|
|
152
|
+
for namespace in namespaces:
|
|
153
|
+
health = await self.check_backend(namespace)
|
|
154
|
+
health_results.append(health)
|
|
155
|
+
|
|
156
|
+
# Update routing table with health status
|
|
157
|
+
for tool_name, route in self.routing_table._routes.items():
|
|
158
|
+
if route.namespace == namespace:
|
|
159
|
+
route.health_status = health.status
|
|
160
|
+
|
|
161
|
+
return health_results
|
|
162
|
+
|
|
163
|
+
def get_gateway_health(self) -> Dict[str, any]:
|
|
164
|
+
"""
|
|
165
|
+
Get aggregate gateway health status.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
Dictionary with gateway health metrics
|
|
169
|
+
"""
|
|
170
|
+
# Get unique namespaces and their health status
|
|
171
|
+
namespace_health = {}
|
|
172
|
+
for route in self.routing_table.list_all():
|
|
173
|
+
namespace_health[route.namespace] = route.health_status
|
|
174
|
+
|
|
175
|
+
# Count healthy/unhealthy backends
|
|
176
|
+
total = len(namespace_health)
|
|
177
|
+
healthy = sum(1 for status in namespace_health.values() if status == "healthy")
|
|
178
|
+
unhealthy = total - healthy
|
|
179
|
+
|
|
180
|
+
# Determine overall status
|
|
181
|
+
if total == 0:
|
|
182
|
+
overall_status = "unknown"
|
|
183
|
+
elif healthy == total:
|
|
184
|
+
overall_status = "healthy"
|
|
185
|
+
elif healthy == 0:
|
|
186
|
+
overall_status = "unhealthy"
|
|
187
|
+
else:
|
|
188
|
+
overall_status = "degraded"
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
"status": overall_status,
|
|
192
|
+
"backends_total": total,
|
|
193
|
+
"backends_healthy": healthy,
|
|
194
|
+
"backends_unhealthy": unhealthy,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async def start_periodic_checks(self):
|
|
198
|
+
"""Start periodic health checking in background."""
|
|
199
|
+
if self._is_checking:
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
self._is_checking = True
|
|
203
|
+
self._checking_task = asyncio.create_task(self._check_loop())
|
|
204
|
+
|
|
205
|
+
async def stop_periodic_checks(self):
|
|
206
|
+
"""Stop periodic health checking."""
|
|
207
|
+
if not self._is_checking:
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
self._is_checking = False
|
|
211
|
+
|
|
212
|
+
if self._checking_task:
|
|
213
|
+
self._checking_task.cancel()
|
|
214
|
+
try:
|
|
215
|
+
await self._checking_task
|
|
216
|
+
except asyncio.CancelledError:
|
|
217
|
+
pass
|
|
218
|
+
self._checking_task = None
|
|
219
|
+
|
|
220
|
+
async def _check_loop(self):
|
|
221
|
+
"""Internal health check loop (runs in background)."""
|
|
222
|
+
while self._is_checking:
|
|
223
|
+
try:
|
|
224
|
+
await self.check_all_backends()
|
|
225
|
+
except Exception as e:
|
|
226
|
+
# Log error but don't stop checking
|
|
227
|
+
print(f"Error during health check: {e}")
|
|
228
|
+
|
|
229
|
+
# Wait for next check interval
|
|
230
|
+
await asyncio.sleep(self.check_interval)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main entry point for chora-mcp-gateway server.
|
|
3
|
+
|
|
4
|
+
This module starts the gateway server with auto-discovery, health checking,
|
|
5
|
+
and HTTP/SSE endpoints.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
import uvicorn
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from .router import RoutingTable, Router
|
|
17
|
+
from .health_checker import HealthChecker
|
|
18
|
+
from .registry_poller import RegistryPoller
|
|
19
|
+
from .server import create_app
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def start_gateway(
|
|
23
|
+
registry_path: Path,
|
|
24
|
+
host: str = "0.0.0.0",
|
|
25
|
+
port: int = 8080,
|
|
26
|
+
poll_interval: int = 60,
|
|
27
|
+
health_check_interval: int = 60
|
|
28
|
+
):
|
|
29
|
+
"""
|
|
30
|
+
Start the gateway server with auto-discovery and health checking.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
registry_path: Path to registry.yaml file
|
|
34
|
+
host: Host to bind server to (default: 0.0.0.0)
|
|
35
|
+
port: Port to bind server to (default: 8080)
|
|
36
|
+
poll_interval: Registry polling interval in seconds (default: 60)
|
|
37
|
+
health_check_interval: Health check interval in seconds (default: 60)
|
|
38
|
+
"""
|
|
39
|
+
print(f"[Gateway] Starting chora-mcp-gateway on {host}:{port}")
|
|
40
|
+
print(f"[Gateway] Registry: {registry_path}")
|
|
41
|
+
print(f"[Gateway] Poll interval: {poll_interval}s")
|
|
42
|
+
print(f"[Gateway] Health check interval: {health_check_interval}s")
|
|
43
|
+
|
|
44
|
+
# Create routing table
|
|
45
|
+
routing_table = RoutingTable()
|
|
46
|
+
|
|
47
|
+
# Create router with HTTP client
|
|
48
|
+
http_client = httpx.AsyncClient(timeout=5.0)
|
|
49
|
+
router = Router(routing_table=routing_table, http_client=http_client)
|
|
50
|
+
|
|
51
|
+
# Create health checker
|
|
52
|
+
health_checker = HealthChecker(
|
|
53
|
+
routing_table=routing_table,
|
|
54
|
+
http_client=http_client,
|
|
55
|
+
check_interval=health_check_interval
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Create registry poller with callback to update routing table
|
|
59
|
+
async def on_registry_change(registry):
|
|
60
|
+
"""Callback invoked when registry changes."""
|
|
61
|
+
print(f"[Registry] Detected change - updating routing table")
|
|
62
|
+
await router.update_routing_table(registry)
|
|
63
|
+
print(f"[Registry] Routing table updated: {len(routing_table)} tools")
|
|
64
|
+
|
|
65
|
+
poller = RegistryPoller(
|
|
66
|
+
registry_path=registry_path,
|
|
67
|
+
poll_interval=poll_interval,
|
|
68
|
+
on_change=on_registry_change
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Initial registry load
|
|
72
|
+
try:
|
|
73
|
+
print(f"[Registry] Loading initial registry...")
|
|
74
|
+
await poller.poll_once()
|
|
75
|
+
print(f"[Registry] Initial load complete: {len(routing_table)} tools")
|
|
76
|
+
except Exception as e:
|
|
77
|
+
print(f"[ERROR] Failed to load initial registry: {e}", file=sys.stderr)
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
# Start background tasks
|
|
81
|
+
print(f"[Registry] Starting periodic polling (every {poll_interval}s)")
|
|
82
|
+
await poller.start_polling()
|
|
83
|
+
|
|
84
|
+
print(f"[Health] Starting periodic health checks (every {health_check_interval}s)")
|
|
85
|
+
await health_checker.start_periodic_checks()
|
|
86
|
+
|
|
87
|
+
# Create FastAPI app
|
|
88
|
+
app = create_app(router=router, health_checker=health_checker)
|
|
89
|
+
|
|
90
|
+
# Store background tasks in app state for cleanup
|
|
91
|
+
app.state.poller = poller
|
|
92
|
+
app.state.health_checker = health_checker
|
|
93
|
+
app.state.http_client = http_client
|
|
94
|
+
|
|
95
|
+
# Add shutdown handler
|
|
96
|
+
@app.on_event("shutdown")
|
|
97
|
+
async def shutdown_event():
|
|
98
|
+
"""Cleanup on shutdown."""
|
|
99
|
+
print("[Gateway] Shutting down...")
|
|
100
|
+
await poller.stop_polling()
|
|
101
|
+
await health_checker.stop_periodic_checks()
|
|
102
|
+
await http_client.aclose()
|
|
103
|
+
print("[Gateway] Shutdown complete")
|
|
104
|
+
|
|
105
|
+
# Run server
|
|
106
|
+
config = uvicorn.Config(
|
|
107
|
+
app=app,
|
|
108
|
+
host=host,
|
|
109
|
+
port=port,
|
|
110
|
+
log_level="info"
|
|
111
|
+
)
|
|
112
|
+
server = uvicorn.Server(config)
|
|
113
|
+
await server.serve()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def main():
|
|
117
|
+
"""Main entry point."""
|
|
118
|
+
# Get configuration from environment variables
|
|
119
|
+
registry_path = Path(os.getenv("REGISTRY_PATH", "/app/config/registry.yaml"))
|
|
120
|
+
host = os.getenv("HOST", "0.0.0.0")
|
|
121
|
+
port = int(os.getenv("PORT", "8080"))
|
|
122
|
+
poll_interval = int(os.getenv("POLL_INTERVAL", "60"))
|
|
123
|
+
health_check_interval = int(os.getenv("HEALTH_CHECK_INTERVAL", "60"))
|
|
124
|
+
|
|
125
|
+
# Check if registry file exists
|
|
126
|
+
if not registry_path.exists():
|
|
127
|
+
print(f"[ERROR] Registry file not found: {registry_path}", file=sys.stderr)
|
|
128
|
+
print(f"[ERROR] Please provide a valid registry.yaml file", file=sys.stderr)
|
|
129
|
+
sys.exit(1)
|
|
130
|
+
|
|
131
|
+
# Start gateway
|
|
132
|
+
try:
|
|
133
|
+
asyncio.run(start_gateway(
|
|
134
|
+
registry_path=registry_path,
|
|
135
|
+
host=host,
|
|
136
|
+
port=port,
|
|
137
|
+
poll_interval=poll_interval,
|
|
138
|
+
health_check_interval=health_check_interval
|
|
139
|
+
))
|
|
140
|
+
except KeyboardInterrupt:
|
|
141
|
+
print("\n[Gateway] Interrupted by user")
|
|
142
|
+
except Exception as e:
|
|
143
|
+
print(f"[ERROR] Gateway failed: {e}", file=sys.stderr)
|
|
144
|
+
sys.exit(1)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
if __name__ == "__main__":
|
|
148
|
+
main()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Data models for chora-mcp-gateway."""
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
from pydantic import BaseModel, ConfigDict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ToolDefinition(BaseModel):
|
|
8
|
+
"""Tool definition from registry."""
|
|
9
|
+
name: str
|
|
10
|
+
description: Optional[str] = None
|
|
11
|
+
|
|
12
|
+
model_config = ConfigDict(extra='allow')
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ServerEntry(BaseModel):
|
|
16
|
+
"""Server entry from registry.yaml."""
|
|
17
|
+
namespace: str
|
|
18
|
+
name: str
|
|
19
|
+
docker_image: Optional[str] = None
|
|
20
|
+
port: int
|
|
21
|
+
health_url: Optional[str] = None
|
|
22
|
+
tools: List[ToolDefinition] = []
|
|
23
|
+
|
|
24
|
+
model_config = ConfigDict(extra='allow')
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Registry(BaseModel):
|
|
28
|
+
"""Registry structure from registry.yaml."""
|
|
29
|
+
version: str
|
|
30
|
+
servers: List[ServerEntry] = []
|
|
31
|
+
|
|
32
|
+
model_config = ConfigDict(extra='allow')
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Registry polling for auto-discovery of MCP servers."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import yaml
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional, Callable, Awaitable
|
|
7
|
+
from .models import Registry, ServerEntry, ToolDefinition
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RegistryPoller:
|
|
11
|
+
"""Polls registry.yaml for server definitions and triggers updates."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
registry_path: Path,
|
|
16
|
+
poll_interval: int = 60,
|
|
17
|
+
on_change: Optional[Callable[[Registry], Awaitable[None]]] = None
|
|
18
|
+
):
|
|
19
|
+
"""
|
|
20
|
+
Initialize registry poller.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
registry_path: Path to registry.yaml file
|
|
24
|
+
poll_interval: Polling interval in seconds (default: 60)
|
|
25
|
+
on_change: Optional async callback invoked when registry changes
|
|
26
|
+
"""
|
|
27
|
+
self.registry_path = registry_path
|
|
28
|
+
self.poll_interval = poll_interval
|
|
29
|
+
self.on_change = on_change
|
|
30
|
+
self._is_polling = False
|
|
31
|
+
self._polling_task: Optional[asyncio.Task] = None
|
|
32
|
+
self._current_registry: Optional[Registry] = None
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def is_polling(self) -> bool:
|
|
36
|
+
"""Return whether polling is currently active."""
|
|
37
|
+
return self._is_polling
|
|
38
|
+
|
|
39
|
+
async def load_registry(self) -> Registry:
|
|
40
|
+
"""
|
|
41
|
+
Load registry from YAML file.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Registry object parsed from YAML
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
FileNotFoundError: If registry file doesn't exist
|
|
48
|
+
yaml.YAMLError: If YAML is malformed
|
|
49
|
+
"""
|
|
50
|
+
if not self.registry_path.exists():
|
|
51
|
+
raise FileNotFoundError(f"Registry not found: {self.registry_path}")
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
with open(self.registry_path, 'r', encoding='utf-8') as f:
|
|
55
|
+
data = yaml.safe_load(f)
|
|
56
|
+
except yaml.YAMLError as e:
|
|
57
|
+
raise yaml.YAMLError(f"Failed to parse registry YAML: {e}")
|
|
58
|
+
|
|
59
|
+
# Parse registry data into Pydantic models
|
|
60
|
+
registry = Registry(
|
|
61
|
+
version=data.get('version', '1.0'),
|
|
62
|
+
servers=[]
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
for server_data in data.get('servers', []):
|
|
66
|
+
# Parse tools
|
|
67
|
+
tools = []
|
|
68
|
+
for tool_data in server_data.get('tools', []):
|
|
69
|
+
if isinstance(tool_data, dict):
|
|
70
|
+
tools.append(ToolDefinition(**tool_data))
|
|
71
|
+
|
|
72
|
+
# Create server entry
|
|
73
|
+
server = ServerEntry(
|
|
74
|
+
namespace=server_data['namespace'],
|
|
75
|
+
name=server_data['name'],
|
|
76
|
+
docker_image=server_data.get('docker_image'),
|
|
77
|
+
port=server_data['port'],
|
|
78
|
+
health_url=server_data.get('health_url'),
|
|
79
|
+
tools=tools
|
|
80
|
+
)
|
|
81
|
+
registry.servers.append(server)
|
|
82
|
+
|
|
83
|
+
return registry
|
|
84
|
+
|
|
85
|
+
async def poll_once(self) -> Registry:
|
|
86
|
+
"""
|
|
87
|
+
Poll registry once and return current state.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Registry object
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
FileNotFoundError: If registry file doesn't exist
|
|
94
|
+
yaml.YAMLError: If YAML is malformed
|
|
95
|
+
"""
|
|
96
|
+
registry = await self.load_registry()
|
|
97
|
+
self._current_registry = registry
|
|
98
|
+
|
|
99
|
+
# Invoke callback if provided
|
|
100
|
+
if self.on_change:
|
|
101
|
+
await self.on_change(registry)
|
|
102
|
+
|
|
103
|
+
return registry
|
|
104
|
+
|
|
105
|
+
async def start_polling(self):
|
|
106
|
+
"""Start background polling task."""
|
|
107
|
+
if self._is_polling:
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
self._is_polling = True
|
|
111
|
+
self._polling_task = asyncio.create_task(self._poll_loop())
|
|
112
|
+
|
|
113
|
+
async def stop_polling(self):
|
|
114
|
+
"""Stop background polling task."""
|
|
115
|
+
if not self._is_polling:
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
self._is_polling = False
|
|
119
|
+
|
|
120
|
+
if self._polling_task:
|
|
121
|
+
self._polling_task.cancel()
|
|
122
|
+
try:
|
|
123
|
+
await self._polling_task
|
|
124
|
+
except asyncio.CancelledError:
|
|
125
|
+
pass
|
|
126
|
+
self._polling_task = None
|
|
127
|
+
|
|
128
|
+
async def _poll_loop(self):
|
|
129
|
+
"""Internal polling loop (runs in background task)."""
|
|
130
|
+
while self._is_polling:
|
|
131
|
+
try:
|
|
132
|
+
await self.poll_once()
|
|
133
|
+
except Exception as e:
|
|
134
|
+
# Log error but don't stop polling
|
|
135
|
+
# In production, use proper logging
|
|
136
|
+
print(f"Error during registry poll: {e}")
|
|
137
|
+
|
|
138
|
+
# Wait for next poll interval
|
|
139
|
+
await asyncio.sleep(self.poll_interval)
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Tool routing functionality for chora-mcp-gateway."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Optional, Any, List
|
|
4
|
+
from pydantic import BaseModel, ConfigDict
|
|
5
|
+
import httpx
|
|
6
|
+
from .models import Registry, ServerEntry
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ToolRoute(BaseModel):
|
|
10
|
+
"""Route information for a single tool."""
|
|
11
|
+
namespace: str
|
|
12
|
+
tool_name: str
|
|
13
|
+
backend_url: str
|
|
14
|
+
health_status: str = "unknown"
|
|
15
|
+
|
|
16
|
+
model_config = ConfigDict(extra='allow')
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RoutingTable:
|
|
20
|
+
"""Manages routes from tool names to backend servers."""
|
|
21
|
+
|
|
22
|
+
def __init__(self):
|
|
23
|
+
"""Initialize empty routing table."""
|
|
24
|
+
self._routes: Dict[str, ToolRoute] = {}
|
|
25
|
+
|
|
26
|
+
def add_route(self, full_tool_name: str, route: ToolRoute) -> None:
|
|
27
|
+
"""
|
|
28
|
+
Add a route to the table.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
full_tool_name: Full tool name (namespace.tool)
|
|
32
|
+
route: Route information
|
|
33
|
+
"""
|
|
34
|
+
self._routes[full_tool_name] = route
|
|
35
|
+
|
|
36
|
+
def remove_route(self, full_tool_name: str) -> None:
|
|
37
|
+
"""
|
|
38
|
+
Remove a route from the table.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
full_tool_name: Full tool name (namespace.tool)
|
|
42
|
+
"""
|
|
43
|
+
if full_tool_name in self._routes:
|
|
44
|
+
del self._routes[full_tool_name]
|
|
45
|
+
|
|
46
|
+
def get_route(self, full_tool_name: str) -> Optional[ToolRoute]:
|
|
47
|
+
"""
|
|
48
|
+
Get route for a tool.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
full_tool_name: Full tool name (namespace.tool)
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
ToolRoute if found, None otherwise
|
|
55
|
+
"""
|
|
56
|
+
return self._routes.get(full_tool_name)
|
|
57
|
+
|
|
58
|
+
def list_all(self) -> List[ToolRoute]:
|
|
59
|
+
"""
|
|
60
|
+
List all routes in the table.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
List of all ToolRoute objects
|
|
64
|
+
"""
|
|
65
|
+
return list(self._routes.values())
|
|
66
|
+
|
|
67
|
+
def clear(self) -> None:
|
|
68
|
+
"""Clear all routes from the table."""
|
|
69
|
+
self._routes.clear()
|
|
70
|
+
|
|
71
|
+
def __len__(self) -> int:
|
|
72
|
+
"""Return number of routes in the table."""
|
|
73
|
+
return len(self._routes)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Router:
|
|
77
|
+
"""Routes tool invocations to backend MCP servers."""
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
routing_table: RoutingTable,
|
|
82
|
+
http_client: Optional[httpx.AsyncClient] = None
|
|
83
|
+
):
|
|
84
|
+
"""
|
|
85
|
+
Initialize router.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
routing_table: RoutingTable to use for routing
|
|
89
|
+
http_client: Optional custom HTTP client (creates default if not provided)
|
|
90
|
+
"""
|
|
91
|
+
self.routing_table = routing_table
|
|
92
|
+
self.http_client = http_client or httpx.AsyncClient(timeout=5.0)
|
|
93
|
+
|
|
94
|
+
def parse_tool_name(self, full_tool_name: str) -> tuple[str, str]:
|
|
95
|
+
"""
|
|
96
|
+
Parse tool name into namespace and tool name.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
full_tool_name: Full tool name (namespace.tool)
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Tuple of (namespace, tool_name)
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
ValueError: If tool name format is invalid
|
|
106
|
+
"""
|
|
107
|
+
if '.' not in full_tool_name:
|
|
108
|
+
raise ValueError(
|
|
109
|
+
f"Invalid tool name format. Expected: {{namespace}}.{{tool}}, got: {full_tool_name}"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
parts = full_tool_name.split('.', 1)
|
|
113
|
+
namespace = parts[0]
|
|
114
|
+
tool_name = parts[1]
|
|
115
|
+
|
|
116
|
+
return namespace, tool_name
|
|
117
|
+
|
|
118
|
+
async def invoke_tool(
|
|
119
|
+
self,
|
|
120
|
+
full_tool_name: str,
|
|
121
|
+
params: Dict[str, Any],
|
|
122
|
+
timeout: float = 5.0
|
|
123
|
+
) -> Any:
|
|
124
|
+
"""
|
|
125
|
+
Invoke a tool on the appropriate backend server.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
full_tool_name: Full tool name (namespace.tool)
|
|
129
|
+
params: Parameters to pass to the tool
|
|
130
|
+
timeout: Request timeout in seconds
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Tool execution result
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
ValueError: If tool not found or backend unhealthy
|
|
137
|
+
httpx.TimeoutException: If backend times out
|
|
138
|
+
httpx.HTTPStatusError: If backend returns error
|
|
139
|
+
"""
|
|
140
|
+
# Get route from routing table
|
|
141
|
+
route = self.routing_table.get_route(full_tool_name)
|
|
142
|
+
if route is None:
|
|
143
|
+
raise ValueError(f"Tool not found: {full_tool_name}")
|
|
144
|
+
|
|
145
|
+
# Check backend health
|
|
146
|
+
if route.health_status == "unhealthy":
|
|
147
|
+
raise ValueError(f"Backend server unhealthy: {route.namespace}")
|
|
148
|
+
|
|
149
|
+
# Parse namespace and tool name
|
|
150
|
+
namespace, tool_name = self.parse_tool_name(full_tool_name)
|
|
151
|
+
|
|
152
|
+
# Construct backend URL
|
|
153
|
+
backend_url = f"{route.backend_url}/tools/{tool_name}"
|
|
154
|
+
|
|
155
|
+
# Make HTTP request to backend
|
|
156
|
+
response = await self.http_client.post(
|
|
157
|
+
backend_url,
|
|
158
|
+
json=params,
|
|
159
|
+
timeout=timeout
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Raise for HTTP errors
|
|
163
|
+
response.raise_for_status()
|
|
164
|
+
|
|
165
|
+
# Return JSON response
|
|
166
|
+
return response.json()
|
|
167
|
+
|
|
168
|
+
async def update_routing_table(self, registry: Registry) -> None:
|
|
169
|
+
"""
|
|
170
|
+
Update routing table based on registry.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
registry: Registry with server definitions
|
|
174
|
+
"""
|
|
175
|
+
# Get current routes for comparison
|
|
176
|
+
current_tools = set(route for route in self.routing_table._routes.keys())
|
|
177
|
+
new_tools = set()
|
|
178
|
+
|
|
179
|
+
# Add/update routes for all servers in registry
|
|
180
|
+
for server in registry.servers:
|
|
181
|
+
for tool in server.tools:
|
|
182
|
+
full_tool_name = f"{server.namespace}.{tool.name}"
|
|
183
|
+
new_tools.add(full_tool_name)
|
|
184
|
+
|
|
185
|
+
# Construct backend URL
|
|
186
|
+
backend_url = f"http://{server.name}:{server.port}"
|
|
187
|
+
|
|
188
|
+
# Create route
|
|
189
|
+
route = ToolRoute(
|
|
190
|
+
namespace=server.namespace,
|
|
191
|
+
tool_name=tool.name,
|
|
192
|
+
backend_url=backend_url,
|
|
193
|
+
health_status="healthy" # Default to healthy, will be updated by health checks
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
self.routing_table.add_route(full_tool_name, route)
|
|
197
|
+
|
|
198
|
+
# Remove routes for servers no longer in registry
|
|
199
|
+
removed_tools = current_tools - new_tools
|
|
200
|
+
for tool_name in removed_tools:
|
|
201
|
+
self.routing_table.remove_route(tool_name)
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""FastAPI HTTP/SSE server for MCP Gateway."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Any, List
|
|
4
|
+
from fastapi import FastAPI, HTTPException
|
|
5
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .router import Router, ToolRoute
|
|
10
|
+
from .health_checker import HealthChecker
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ToolInfo(BaseModel):
|
|
14
|
+
"""Tool information for listing."""
|
|
15
|
+
name: str
|
|
16
|
+
namespace: str
|
|
17
|
+
tool_name: str
|
|
18
|
+
backend_url: str
|
|
19
|
+
health_status: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ToolsResponse(BaseModel):
|
|
23
|
+
"""Response for /tools endpoint."""
|
|
24
|
+
tools: List[ToolInfo]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ToolInvocationRequest(BaseModel):
|
|
28
|
+
"""Request body for tool invocation (optional - can be empty dict)."""
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def create_app(router: Router, health_checker: HealthChecker) -> FastAPI:
|
|
33
|
+
"""
|
|
34
|
+
Create FastAPI application for MCP Gateway.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
router: Router instance for tool routing
|
|
38
|
+
health_checker: HealthChecker instance for health monitoring
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
FastAPI application
|
|
42
|
+
"""
|
|
43
|
+
app = FastAPI(
|
|
44
|
+
title="Chora MCP Gateway",
|
|
45
|
+
description="HTTP/SSE gateway for Model Context Protocol servers",
|
|
46
|
+
version="0.1.0"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Add CORS middleware
|
|
50
|
+
app.add_middleware(
|
|
51
|
+
CORSMiddleware,
|
|
52
|
+
allow_origins=["*"], # In production, restrict this
|
|
53
|
+
allow_credentials=True,
|
|
54
|
+
allow_methods=["*"],
|
|
55
|
+
allow_headers=["*"],
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Store router and health_checker in app state
|
|
59
|
+
app.state.router = router
|
|
60
|
+
app.state.health_checker = health_checker
|
|
61
|
+
|
|
62
|
+
@app.get("/health")
|
|
63
|
+
async def get_health() -> Dict[str, Any]:
|
|
64
|
+
"""
|
|
65
|
+
Get gateway health status.
|
|
66
|
+
|
|
67
|
+
Returns aggregate health of all backend servers.
|
|
68
|
+
"""
|
|
69
|
+
health = health_checker.get_gateway_health()
|
|
70
|
+
return health
|
|
71
|
+
|
|
72
|
+
@app.get("/tools", response_model=ToolsResponse)
|
|
73
|
+
async def list_tools() -> ToolsResponse:
|
|
74
|
+
"""
|
|
75
|
+
List all available tools from all backends.
|
|
76
|
+
|
|
77
|
+
Returns list of tools with their routing information.
|
|
78
|
+
"""
|
|
79
|
+
routes = router.routing_table.list_all()
|
|
80
|
+
|
|
81
|
+
tools = []
|
|
82
|
+
for route in routes:
|
|
83
|
+
# Construct full tool name
|
|
84
|
+
full_name = f"{route.namespace}.{route.tool_name}"
|
|
85
|
+
|
|
86
|
+
tool_info = ToolInfo(
|
|
87
|
+
name=full_name,
|
|
88
|
+
namespace=route.namespace,
|
|
89
|
+
tool_name=route.tool_name,
|
|
90
|
+
backend_url=route.backend_url,
|
|
91
|
+
health_status=route.health_status
|
|
92
|
+
)
|
|
93
|
+
tools.append(tool_info)
|
|
94
|
+
|
|
95
|
+
return ToolsResponse(tools=tools)
|
|
96
|
+
|
|
97
|
+
@app.post("/tools/{full_tool_name}")
|
|
98
|
+
async def invoke_tool(full_tool_name: str, params: Dict[str, Any] = None) -> Any:
|
|
99
|
+
"""
|
|
100
|
+
Invoke a tool by routing to backend server.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
full_tool_name: Full tool name (namespace.tool)
|
|
104
|
+
params: Tool parameters (optional)
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Result from backend server
|
|
108
|
+
|
|
109
|
+
Raises:
|
|
110
|
+
HTTPException: 404 if tool not found, 503 if backend unhealthy, 500 on error
|
|
111
|
+
"""
|
|
112
|
+
if params is None:
|
|
113
|
+
params = {}
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
# Invoke tool via router
|
|
117
|
+
result = await router.invoke_tool(full_tool_name, params)
|
|
118
|
+
return result
|
|
119
|
+
|
|
120
|
+
except ValueError as e:
|
|
121
|
+
error_msg = str(e)
|
|
122
|
+
|
|
123
|
+
# Check for specific error types
|
|
124
|
+
if "Tool not found" in error_msg:
|
|
125
|
+
raise HTTPException(status_code=404, detail=error_msg)
|
|
126
|
+
elif "Backend server unhealthy" in error_msg:
|
|
127
|
+
raise HTTPException(status_code=503, detail=error_msg)
|
|
128
|
+
else:
|
|
129
|
+
# Other ValueError
|
|
130
|
+
raise HTTPException(status_code=400, detail=error_msg)
|
|
131
|
+
|
|
132
|
+
except httpx.TimeoutException as e:
|
|
133
|
+
# Backend timeout
|
|
134
|
+
raise HTTPException(
|
|
135
|
+
status_code=504,
|
|
136
|
+
detail=f"Backend timeout: {str(e)}"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
except httpx.HTTPStatusError as e:
|
|
140
|
+
# Backend returned error status
|
|
141
|
+
raise HTTPException(
|
|
142
|
+
status_code=500,
|
|
143
|
+
detail=f"Backend error: {e.response.status_code} {e.response.text}"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
except Exception as e:
|
|
147
|
+
# Unexpected error
|
|
148
|
+
raise HTTPException(
|
|
149
|
+
status_code=500,
|
|
150
|
+
detail=f"Internal server error: {str(e)}"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
return app
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chora-mcp-gateway
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Routing layer with auto-discovery for MCP ecosystem
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: mcp,model-context-protocol,gateway,routing
|
|
7
|
+
Author: Liminal Commons
|
|
8
|
+
Author-email: team@liminalcommons.org
|
|
9
|
+
Requires-Python: >=3.11,<4.0
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Requires-Dist: fastapi (>=0.109.0,<0.110.0)
|
|
17
|
+
Requires-Dist: httpx (>=0.26.0,<0.27.0)
|
|
18
|
+
Requires-Dist: pydantic (>=2.5.0,<3.0.0)
|
|
19
|
+
Requires-Dist: pyyaml (>=6.0.1,<7.0.0)
|
|
20
|
+
Requires-Dist: sse-starlette (>=2.0.0,<3.0.0)
|
|
21
|
+
Requires-Dist: uvicorn (>=0.27.0,<0.28.0)
|
|
22
|
+
Project-URL: Bug Tracker, https://github.com/liminalcommons/chora-mcp-gateway/issues
|
|
23
|
+
Project-URL: Homepage, https://github.com/liminalcommons/chora-mcp-gateway
|
|
24
|
+
Project-URL: Repository, https://github.com/liminalcommons/chora-mcp-gateway
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# chora-mcp-gateway
|
|
28
|
+
|
|
29
|
+
Routing layer with auto-discovery for MCP ecosystem.
|
|
30
|
+
|
|
31
|
+
## Features
|
|
32
|
+
|
|
33
|
+
- **Auto-Discovery**: Polls `registry.yaml` every 60 seconds to discover new MCP servers
|
|
34
|
+
- **Dynamic Routing**: Routes tool invocations to correct backend servers based on namespace
|
|
35
|
+
- **Health-Aware**: Monitors backend health and routes only to healthy servers
|
|
36
|
+
- **HTTP/SSE Server**: FastAPI-based REST API for MCP client communication
|
|
37
|
+
- **CORS Support**: Configurable CORS middleware for web clients
|
|
38
|
+
|
|
39
|
+
## Architecture
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
MCP Clients (Claude Desktop, VSCode, etc.)
|
|
43
|
+
↓ HTTP/REST
|
|
44
|
+
chora-mcp-gateway (port 8080)
|
|
45
|
+
↓ polls registry.yaml every 60s
|
|
46
|
+
↓ health checks backends every 60s
|
|
47
|
+
↓ routes {namespace}.{tool} to backends
|
|
48
|
+
chora-mcp-* servers (Docker containers)
|
|
49
|
+
├─ chora-mcp-manifest (port 8081)
|
|
50
|
+
├─ chora-mcp-n8n (port 8082)
|
|
51
|
+
└─ ... (more MCP servers)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Installation
|
|
55
|
+
|
|
56
|
+
### Local Development
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
# Install dependencies
|
|
60
|
+
poetry install
|
|
61
|
+
|
|
62
|
+
# Run tests
|
|
63
|
+
poetry run pytest -v --cov=src/chora_mcp_gateway
|
|
64
|
+
|
|
65
|
+
# Start gateway (development mode)
|
|
66
|
+
poetry run python -m chora_mcp_gateway.main
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Docker Deployment (Recommended)
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
# Build and start gateway
|
|
73
|
+
docker-compose up --build
|
|
74
|
+
|
|
75
|
+
# View logs
|
|
76
|
+
docker-compose logs -f gateway
|
|
77
|
+
|
|
78
|
+
# Stop gateway
|
|
79
|
+
docker-compose down
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Configuration
|
|
83
|
+
|
|
84
|
+
The gateway is configured via environment variables:
|
|
85
|
+
|
|
86
|
+
| Variable | Default | Description |
|
|
87
|
+
|----------|---------|-------------|
|
|
88
|
+
| `REGISTRY_PATH` | `/app/config/registry.yaml` | Path to registry.yaml file |
|
|
89
|
+
| `HOST` | `0.0.0.0` | Host to bind server to |
|
|
90
|
+
| `PORT` | `8080` | Port to bind server to |
|
|
91
|
+
| `POLL_INTERVAL` | `60` | Registry polling interval (seconds) |
|
|
92
|
+
| `HEALTH_CHECK_INTERVAL` | `60` | Backend health check interval (seconds) |
|
|
93
|
+
|
|
94
|
+
### Registry Format
|
|
95
|
+
|
|
96
|
+
Create a `registry.yaml` file defining your MCP servers:
|
|
97
|
+
|
|
98
|
+
```yaml
|
|
99
|
+
version: "1.0"
|
|
100
|
+
|
|
101
|
+
servers:
|
|
102
|
+
- namespace: manifest
|
|
103
|
+
name: chora-mcp-manifest
|
|
104
|
+
docker_image: chora-mcp-manifest:latest
|
|
105
|
+
port: 8081
|
|
106
|
+
health_url: http://chora-mcp-manifest:8081/health
|
|
107
|
+
tools:
|
|
108
|
+
- name: list_servers
|
|
109
|
+
description: List all registered MCP servers
|
|
110
|
+
- name: get_server
|
|
111
|
+
description: Get details of a specific server
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The gateway will automatically discover tools from this registry.
|
|
115
|
+
|
|
116
|
+
## API Endpoints
|
|
117
|
+
|
|
118
|
+
### GET /health
|
|
119
|
+
|
|
120
|
+
Get aggregate gateway health status.
|
|
121
|
+
|
|
122
|
+
**Response**:
|
|
123
|
+
```json
|
|
124
|
+
{
|
|
125
|
+
"status": "healthy",
|
|
126
|
+
"backends_total": 2,
|
|
127
|
+
"backends_healthy": 2,
|
|
128
|
+
"backends_unhealthy": 0
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
**Status Values**:
|
|
133
|
+
- `healthy`: All backends healthy
|
|
134
|
+
- `degraded`: Some backends unhealthy
|
|
135
|
+
- `unhealthy`: All backends unhealthy
|
|
136
|
+
|
|
137
|
+
### GET /tools
|
|
138
|
+
|
|
139
|
+
List all available tools from all backends.
|
|
140
|
+
|
|
141
|
+
**Response**:
|
|
142
|
+
```json
|
|
143
|
+
{
|
|
144
|
+
"tools": [
|
|
145
|
+
{
|
|
146
|
+
"name": "manifest.list_servers",
|
|
147
|
+
"namespace": "manifest",
|
|
148
|
+
"tool_name": "list_servers",
|
|
149
|
+
"backend_url": "http://chora-mcp-manifest:8081",
|
|
150
|
+
"health_status": "healthy"
|
|
151
|
+
}
|
|
152
|
+
]
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### POST /tools/{tool}
|
|
157
|
+
|
|
158
|
+
Invoke a tool by routing to backend server.
|
|
159
|
+
|
|
160
|
+
**Request**:
|
|
161
|
+
```bash
|
|
162
|
+
POST /tools/manifest.list_servers
|
|
163
|
+
Content-Type: application/json
|
|
164
|
+
|
|
165
|
+
{
|
|
166
|
+
"filter": "namespace:n8n"
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
**Response**:
|
|
171
|
+
```json
|
|
172
|
+
{
|
|
173
|
+
"servers": [
|
|
174
|
+
{"namespace": "n8n", "port": 8082}
|
|
175
|
+
]
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
**Error Responses**:
|
|
180
|
+
- `404`: Tool not found in routing table
|
|
181
|
+
- `503`: Backend server unhealthy
|
|
182
|
+
- `500`: Backend server error
|
|
183
|
+
- `504`: Backend timeout
|
|
184
|
+
|
|
185
|
+
## Development
|
|
186
|
+
|
|
187
|
+
### Running Tests
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
# Run all tests with coverage
|
|
191
|
+
poetry run pytest -v --cov=src/chora_mcp_gateway --cov-report=term-missing
|
|
192
|
+
|
|
193
|
+
# Run specific test file
|
|
194
|
+
poetry run pytest tests/test_server.py -v
|
|
195
|
+
|
|
196
|
+
# Run with BDD feature verification
|
|
197
|
+
poetry run pytest tests/ -v --gherkin-terminal-reporter
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### Test Coverage
|
|
201
|
+
|
|
202
|
+
Current test coverage: **95%** (296 statements, 16 missing)
|
|
203
|
+
|
|
204
|
+
| Module | Coverage |
|
|
205
|
+
|--------|----------|
|
|
206
|
+
| models.py | 100% |
|
|
207
|
+
| router.py | 98% |
|
|
208
|
+
| registry_poller.py | 97% |
|
|
209
|
+
| health_checker.py | 92% |
|
|
210
|
+
| server.py | 91% |
|
|
211
|
+
|
|
212
|
+
### Development Process
|
|
213
|
+
|
|
214
|
+
This project follows strict **DDD → BDD → TDD**:
|
|
215
|
+
|
|
216
|
+
1. **DDD**: Document requirements first
|
|
217
|
+
2. **BDD**: Write Gherkin scenarios before coding
|
|
218
|
+
3. **TDD**: Write failing tests first (RED), implement to pass (GREEN), refactor
|
|
219
|
+
|
|
220
|
+
See `tests/features/*.feature` for BDD scenarios.
|
|
221
|
+
|
|
222
|
+
## Deployment
|
|
223
|
+
|
|
224
|
+
### Docker Compose (Production)
|
|
225
|
+
|
|
226
|
+
```yaml
|
|
227
|
+
version: "3.8"
|
|
228
|
+
|
|
229
|
+
services:
|
|
230
|
+
gateway:
|
|
231
|
+
image: chora-mcp-gateway:latest
|
|
232
|
+
ports:
|
|
233
|
+
- "8080:8080"
|
|
234
|
+
volumes:
|
|
235
|
+
- ./registry.yaml:/app/config/registry.yaml:ro
|
|
236
|
+
environment:
|
|
237
|
+
- REGISTRY_PATH=/app/config/registry.yaml
|
|
238
|
+
- POLL_INTERVAL=60
|
|
239
|
+
- HEALTH_CHECK_INTERVAL=60
|
|
240
|
+
networks:
|
|
241
|
+
- mcp-network
|
|
242
|
+
restart: unless-stopped
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Health Checks
|
|
246
|
+
|
|
247
|
+
The gateway includes Docker health checks:
|
|
248
|
+
|
|
249
|
+
```dockerfile
|
|
250
|
+
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|
251
|
+
CMD python -c "import httpx; httpx.get('http://localhost:8080/health', timeout=2.0)" || exit 1
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
## Development Status
|
|
255
|
+
|
|
256
|
+
**Iteration**: 1 of 4 (80% complete)
|
|
257
|
+
**Status**: Core components complete, integration testing in progress
|
|
258
|
+
**Sprint**: Orchestration Sprint 1 - Gateway Core
|
|
259
|
+
|
|
260
|
+
**Completed**:
|
|
261
|
+
- ✅ Registry polling (97% coverage, 14 tests)
|
|
262
|
+
- ✅ Tool routing (98% coverage, 17 tests)
|
|
263
|
+
- ✅ Health checking (92% coverage, 17 tests)
|
|
264
|
+
- ✅ HTTP/SSE server (91% coverage, 17 tests)
|
|
265
|
+
|
|
266
|
+
**Remaining**:
|
|
267
|
+
- 🔄 Integration testing with real backends
|
|
268
|
+
|
|
269
|
+
## License
|
|
270
|
+
|
|
271
|
+
MIT
|
|
272
|
+
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
chora_mcp_gateway/__init__.py,sha256=-Ph9ihbReSoJCUDYfFd8UV-fmzsc2BqdPpvSvQyFdVc,101
|
|
2
|
+
chora_mcp_gateway/health_checker.py,sha256=xh5--cvf7wBzsvOmtM0ct1979gXO-unRVsIDSmMgc4Y,7703
|
|
3
|
+
chora_mcp_gateway/main.py,sha256=sAjU_dewwzO6kip5BjUB-1MBtlo4u0qvTGbtZhIesgA,4720
|
|
4
|
+
chora_mcp_gateway/models.py,sha256=PIOw80AMAoEY2vLqrHrhBiT7XsCRMK2fDk0leNV-Cnw,751
|
|
5
|
+
chora_mcp_gateway/registry_poller.py,sha256=2mrI2Ya8mqvjTOKDIwRXkiOih7IV7L2_zEmVoxyNtx0,4325
|
|
6
|
+
chora_mcp_gateway/router.py,sha256=C6uJH6gzy6EKxGRPdEXrbbyt0--zCfPmOZE3vYHtWeg,5977
|
|
7
|
+
chora_mcp_gateway/server.py,sha256=iRdjzEq9IOjgn9h2CtX5uOM6JWpHFSOUeoKdOxmmzxI,4410
|
|
8
|
+
chora_mcp_gateway-0.1.0.dist-info/METADATA,sha256=SbpgbK9GBF6MrVfkClHdE5rueZ7WMdf-ydQcltu2HJk,6462
|
|
9
|
+
chora_mcp_gateway-0.1.0.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
10
|
+
chora_mcp_gateway-0.1.0.dist-info/RECORD,,
|