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.
@@ -0,0 +1,3 @@
1
+ """chora-mcp-gateway: Routing layer with auto-discovery for MCP ecosystem."""
2
+
3
+ __version__ = "0.1.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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.2.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any