chora-mcp-orchestration 0.1.0__tar.gz

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,111 @@
1
+ Metadata-Version: 2.4
2
+ Name: chora-mcp-orchestration
3
+ Version: 0.1.0
4
+ Summary: MCP server orchestration CLI and HTTP server for managing Docker-based MCP services
5
+ License: MIT
6
+ Keywords: mcp,orchestration,docker,deployment
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: click (>=8.1.0,<9.0.0)
17
+ Requires-Dist: docker (>=7.0.0,<8.0.0)
18
+ Requires-Dist: fastapi (>=0.109.0,<0.110.0)
19
+ Requires-Dist: httpx (>=0.26.0,<0.27.0)
20
+ Requires-Dist: pydantic (>=2.5.0,<3.0.0)
21
+ Requires-Dist: pyyaml (>=6.0,<7.0)
22
+ Requires-Dist: uvicorn (>=0.27.0,<0.28.0)
23
+ Project-URL: Bug Tracker, https://github.com/liminalcommons/chora-mcp-orchestration/issues
24
+ Project-URL: Homepage, https://github.com/liminalcommons/chora-mcp-orchestration
25
+ Project-URL: Repository, https://github.com/liminalcommons/chora-mcp-orchestration
26
+ Description-Content-Type: text/markdown
27
+
28
+ # chora-mcp-orchestration
29
+
30
+ MCP server orchestration CLI and HTTP server for managing Docker-based MCP services.
31
+
32
+ ## Features
33
+
34
+ - **Dual Mode Operation**: CLI commands + MCP HTTP server
35
+ - **Docker Integration**: Manages containers via Docker SDK
36
+ - **Auto-Discovery**: Reads registry.yaml for server definitions
37
+ - **Health Monitoring**: Tracks container and endpoint health
38
+ - **Log Access**: View container logs for debugging
39
+
40
+ ## Installation
41
+
42
+ ```bash
43
+ poetry install
44
+ ```
45
+
46
+ ## Usage
47
+
48
+ ### CLI Mode
49
+
50
+ ```bash
51
+ # Initialize ecosystem
52
+ chora-orch init
53
+
54
+ # Deploy server
55
+ chora-orch deploy n8n
56
+
57
+ # List servers
58
+ chora-orch list
59
+
60
+ # Check health
61
+ chora-orch health manifest
62
+
63
+ # View logs
64
+ chora-orch logs n8n --tail 50
65
+
66
+ # Stop server
67
+ chora-orch stop n8n
68
+
69
+ # Get status
70
+ chora-orch status
71
+ ```
72
+
73
+ ### MCP Server Mode
74
+
75
+ ```bash
76
+ # Start MCP HTTP server
77
+ chora-orch-serve --port 8090
78
+ ```
79
+
80
+ Then access tools via HTTP:
81
+ - `POST /tools/init`
82
+ - `POST /tools/deploy`
83
+ - `POST /tools/list`
84
+ - `POST /tools/health`
85
+ - `POST /tools/logs`
86
+ - `POST /tools/stop`
87
+ - `POST /tools/status`
88
+
89
+ ## Development
90
+
91
+ ```bash
92
+ # Run tests
93
+ poetry run pytest -v
94
+
95
+ # Run with coverage
96
+ poetry run pytest --cov=chora_mcp_orchestration --cov-report=term-missing
97
+
98
+ # Type check (if mypy added)
99
+ poetry run mypy src/
100
+ ```
101
+
102
+ ## Documentation
103
+
104
+ - [Requirements](docs/ORCHESTRATION-REQUIREMENTS.md) - Full specification
105
+ - [CLI Scenarios](tests/features/orchestration_cli.feature) - BDD scenarios
106
+ - [MCP Server Scenarios](tests/features/orchestration_mcp_server.feature) - HTTP tool scenarios
107
+
108
+ ## License
109
+
110
+ MIT
111
+
@@ -0,0 +1,83 @@
1
+ # chora-mcp-orchestration
2
+
3
+ MCP server orchestration CLI and HTTP server for managing Docker-based MCP services.
4
+
5
+ ## Features
6
+
7
+ - **Dual Mode Operation**: CLI commands + MCP HTTP server
8
+ - **Docker Integration**: Manages containers via Docker SDK
9
+ - **Auto-Discovery**: Reads registry.yaml for server definitions
10
+ - **Health Monitoring**: Tracks container and endpoint health
11
+ - **Log Access**: View container logs for debugging
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ poetry install
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ### CLI Mode
22
+
23
+ ```bash
24
+ # Initialize ecosystem
25
+ chora-orch init
26
+
27
+ # Deploy server
28
+ chora-orch deploy n8n
29
+
30
+ # List servers
31
+ chora-orch list
32
+
33
+ # Check health
34
+ chora-orch health manifest
35
+
36
+ # View logs
37
+ chora-orch logs n8n --tail 50
38
+
39
+ # Stop server
40
+ chora-orch stop n8n
41
+
42
+ # Get status
43
+ chora-orch status
44
+ ```
45
+
46
+ ### MCP Server Mode
47
+
48
+ ```bash
49
+ # Start MCP HTTP server
50
+ chora-orch-serve --port 8090
51
+ ```
52
+
53
+ Then access tools via HTTP:
54
+ - `POST /tools/init`
55
+ - `POST /tools/deploy`
56
+ - `POST /tools/list`
57
+ - `POST /tools/health`
58
+ - `POST /tools/logs`
59
+ - `POST /tools/stop`
60
+ - `POST /tools/status`
61
+
62
+ ## Development
63
+
64
+ ```bash
65
+ # Run tests
66
+ poetry run pytest -v
67
+
68
+ # Run with coverage
69
+ poetry run pytest --cov=chora_mcp_orchestration --cov-report=term-missing
70
+
71
+ # Type check (if mypy added)
72
+ poetry run mypy src/
73
+ ```
74
+
75
+ ## Documentation
76
+
77
+ - [Requirements](docs/ORCHESTRATION-REQUIREMENTS.md) - Full specification
78
+ - [CLI Scenarios](tests/features/orchestration_cli.feature) - BDD scenarios
79
+ - [MCP Server Scenarios](tests/features/orchestration_mcp_server.feature) - HTTP tool scenarios
80
+
81
+ ## License
82
+
83
+ MIT
@@ -0,0 +1,60 @@
1
+ [tool.poetry]
2
+ name = "chora-mcp-orchestration"
3
+ version = "0.1.0"
4
+ description = "MCP server orchestration CLI and HTTP server for managing Docker-based MCP services"
5
+ authors = ["Liminal Commons <team@liminalcommons.org>"]
6
+ license = "MIT"
7
+ readme = "README.md"
8
+ homepage = "https://github.com/liminalcommons/chora-mcp-orchestration"
9
+ repository = "https://github.com/liminalcommons/chora-mcp-orchestration"
10
+ keywords = ["mcp", "orchestration", "docker", "deployment"]
11
+ packages = [{include = "chora_mcp_orchestration", from = "src"}]
12
+
13
+ [tool.poetry.urls]
14
+ "Bug Tracker" = "https://github.com/liminalcommons/chora-mcp-orchestration/issues"
15
+
16
+ [tool.poetry.dependencies]
17
+ python = "^3.11"
18
+ docker = "^7.0.0"
19
+ pyyaml = "^6.0"
20
+ httpx = "^0.26.0"
21
+ fastapi = "^0.109.0"
22
+ uvicorn = "^0.27.0"
23
+ pydantic = "^2.5.0"
24
+ click = "^8.1.0"
25
+
26
+ [tool.poetry.group.dev.dependencies]
27
+ pytest = "^7.4.0"
28
+ pytest-asyncio = "^0.21.0"
29
+ pytest-cov = "^4.1.0"
30
+ pytest-mock = "^3.12.0"
31
+
32
+ [tool.poetry.scripts]
33
+ chora-orch = "chora_mcp_orchestration.cli:main"
34
+ chora-orch-serve = "chora_mcp_orchestration.server_main:main"
35
+
36
+ [build-system]
37
+ requires = ["poetry-core"]
38
+ build-backend = "poetry.core.masonry.api"
39
+
40
+ [tool.pytest.ini_options]
41
+ testpaths = ["tests"]
42
+ python_files = ["test_*.py"]
43
+ python_classes = ["Test*"]
44
+ python_functions = ["test_*"]
45
+ asyncio_mode = "auto"
46
+ addopts = "-v --strict-markers"
47
+
48
+ [tool.coverage.run]
49
+ source = ["src"]
50
+ omit = ["tests/*"]
51
+
52
+ [tool.coverage.report]
53
+ exclude_lines = [
54
+ "pragma: no cover",
55
+ "def __repr__",
56
+ "raise AssertionError",
57
+ "raise NotImplementedError",
58
+ "if __name__ == .__main__.:",
59
+ "if TYPE_CHECKING:",
60
+ ]
@@ -0,0 +1,7 @@
1
+ """Chora MCP Orchestration - Docker-based MCP server management."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .orchestrator import DockerOrchestrator, ServerDefinition
6
+
7
+ __all__ = ["DockerOrchestrator", "ServerDefinition"]
@@ -0,0 +1,152 @@
1
+ """CLI commands for chora-mcp-orchestration."""
2
+
3
+ import click
4
+ import json
5
+ from .orchestrator import DockerOrchestrator
6
+
7
+
8
+ @click.group()
9
+ @click.option('--registry', default=None, help='Path to registry.yaml')
10
+ @click.pass_context
11
+ def cli(ctx, registry):
12
+ """Chora MCP Orchestration CLI."""
13
+ ctx.ensure_object(dict)
14
+ ctx.obj['registry'] = registry
15
+
16
+
17
+ @cli.command()
18
+ @click.pass_context
19
+ def init(ctx):
20
+ """Initialize MCP ecosystem with gateway and manifest."""
21
+ try:
22
+ orch = DockerOrchestrator(registry_path=ctx.obj.get('registry'))
23
+ result = orch.init()
24
+
25
+ click.echo(f"Status: {result['status']}")
26
+ click.echo(f"Network: {result['network']}")
27
+ click.echo("Services:")
28
+ for svc in result['services']:
29
+ click.echo(f" {svc['name']}: {svc['status']}")
30
+ except Exception as e:
31
+ click.echo(f"Error: {e}", err=True)
32
+ raise click.Abort()
33
+
34
+
35
+ @cli.command()
36
+ @click.argument('namespace')
37
+ @click.pass_context
38
+ def deploy(ctx, namespace):
39
+ """Deploy MCP server by namespace."""
40
+ try:
41
+ orch = DockerOrchestrator(registry_path=ctx.obj.get('registry'))
42
+ result = orch.deploy(namespace)
43
+
44
+ click.echo(f"Status: {result['status']}")
45
+ click.echo(f"Namespace: {result['namespace']}")
46
+ except ValueError as e:
47
+ click.echo(f"Error: {e}", err=True)
48
+ raise click.Abort()
49
+
50
+
51
+ @cli.command(name='list')
52
+ @click.option('--format', type=click.Choice(['table', 'json']), default='table')
53
+ @click.pass_context
54
+ def list_servers(ctx, format):
55
+ """List all running MCP servers."""
56
+ try:
57
+ orch = DockerOrchestrator(registry_path=ctx.obj.get('registry'))
58
+ result = orch.list()
59
+
60
+ if format == 'json':
61
+ click.echo(json.dumps(result, indent=2))
62
+ else:
63
+ for server in result['servers']:
64
+ click.echo(f"{server['namespace']} - {server.get('status', 'unknown')}")
65
+ except Exception as e:
66
+ click.echo(f"Error: {e}", err=True)
67
+ raise click.Abort()
68
+
69
+
70
+ @cli.command()
71
+ @click.argument('namespace')
72
+ @click.pass_context
73
+ def health(ctx, namespace):
74
+ """Get health status for a server."""
75
+ try:
76
+ orch = DockerOrchestrator(registry_path=ctx.obj.get('registry'))
77
+ result = orch.health(namespace)
78
+
79
+ click.echo(f"Namespace: {result['namespace']}")
80
+ click.echo(f"Docker Status: {result['docker_status']}")
81
+ except ValueError as e:
82
+ click.echo(f"Error: {e}", err=True)
83
+ raise click.Abort()
84
+
85
+
86
+ @cli.command()
87
+ @click.argument('namespace')
88
+ @click.option('--tail', default=100, help='Number of lines to show')
89
+ @click.pass_context
90
+ def logs(ctx, namespace, tail):
91
+ """Get logs from a server container."""
92
+ try:
93
+ orch = DockerOrchestrator(registry_path=ctx.obj.get('registry'))
94
+ result = orch.logs(namespace, tail=tail)
95
+
96
+ for line in result['logs']:
97
+ click.echo(line)
98
+ except ValueError as e:
99
+ click.echo(f"Error: {e}", err=True)
100
+ raise click.Abort()
101
+
102
+
103
+ @cli.command()
104
+ @click.argument('namespace')
105
+ @click.option('--force', is_flag=True, help='Force kill immediately')
106
+ @click.pass_context
107
+ def stop(ctx, namespace, force):
108
+ """Stop a running server."""
109
+ try:
110
+ orch = DockerOrchestrator(registry_path=ctx.obj.get('registry'))
111
+ result = orch.stop(namespace, force=force)
112
+
113
+ click.echo(f"Status: {result['status']}")
114
+ click.echo(f"Namespace: {result['namespace']}")
115
+ except ValueError as e:
116
+ click.echo(f"Error: {e}", err=True)
117
+ raise click.Abort()
118
+
119
+
120
+ @cli.command()
121
+ @click.option('--format', type=click.Choice(['table', 'json']), default='table')
122
+ @click.pass_context
123
+ def status(ctx, format):
124
+ """Get comprehensive orchestration status."""
125
+ try:
126
+ orch = DockerOrchestrator(registry_path=ctx.obj.get('registry'))
127
+ result = orch.status()
128
+
129
+ if format == 'json':
130
+ click.echo(json.dumps(result, indent=2))
131
+ else:
132
+ click.echo(f"Docker: {result['docker_status']}")
133
+ click.echo(f"Servers running: {result['servers_running']}")
134
+ except Exception as e:
135
+ click.echo(f"Error: {e}", err=True)
136
+ raise click.Abort()
137
+
138
+
139
+ @cli.command()
140
+ def mcp():
141
+ """Start MCP server in stdio mode (for AI clients like Cline, Claude Desktop)."""
142
+ from .mcp_stdio import main as stdio_main
143
+ stdio_main()
144
+
145
+
146
+ def main():
147
+ """Entry point for CLI."""
148
+ cli(obj={})
149
+
150
+
151
+ if __name__ == '__main__':
152
+ main()
@@ -0,0 +1,192 @@
1
+ """MCP stdio server for orchestration tools."""
2
+
3
+ import sys
4
+ import json
5
+ import logging
6
+ from typing import Dict, Any
7
+ from .orchestrator import DockerOrchestrator
8
+
9
+ # Configure logging to stderr (stdout reserved for JSON-RPC)
10
+ logging.basicConfig(
11
+ level=logging.INFO,
12
+ format='%(asctime)s - %(levelname)s - %(message)s',
13
+ stream=sys.stderr
14
+ )
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def get_tool_definitions() -> list[Dict[str, Any]]:
19
+ """Return MCP tool definitions for orchestration."""
20
+ return [
21
+ {
22
+ "name": "init",
23
+ "description": "Initialize MCP ecosystem with gateway and manifest",
24
+ "inputSchema": {
25
+ "type": "object",
26
+ "properties": {
27
+ "registry_path": {"type": "string", "description": "Path to registry.yaml (optional)"}
28
+ }
29
+ }
30
+ },
31
+ {
32
+ "name": "deploy",
33
+ "description": "Deploy MCP server by namespace",
34
+ "inputSchema": {
35
+ "type": "object",
36
+ "properties": {
37
+ "namespace": {"type": "string", "description": "Server namespace from registry"}
38
+ },
39
+ "required": ["namespace"]
40
+ }
41
+ },
42
+ {
43
+ "name": "list",
44
+ "description": "List all running MCP servers",
45
+ "inputSchema": {
46
+ "type": "object",
47
+ "properties": {}
48
+ }
49
+ },
50
+ {
51
+ "name": "health",
52
+ "description": "Get health status for a server",
53
+ "inputSchema": {
54
+ "type": "object",
55
+ "properties": {
56
+ "namespace": {"type": "string", "description": "Server namespace"}
57
+ },
58
+ "required": ["namespace"]
59
+ }
60
+ },
61
+ {
62
+ "name": "logs",
63
+ "description": "Get logs from a server container",
64
+ "inputSchema": {
65
+ "type": "object",
66
+ "properties": {
67
+ "namespace": {"type": "string", "description": "Server namespace"},
68
+ "tail": {"type": "number", "description": "Number of lines to show", "default": 100}
69
+ },
70
+ "required": ["namespace"]
71
+ }
72
+ },
73
+ {
74
+ "name": "stop",
75
+ "description": "Stop a running server",
76
+ "inputSchema": {
77
+ "type": "object",
78
+ "properties": {
79
+ "namespace": {"type": "string", "description": "Server namespace"},
80
+ "force": {"type": "boolean", "description": "Force kill immediately", "default": False}
81
+ },
82
+ "required": ["namespace"]
83
+ }
84
+ },
85
+ {
86
+ "name": "status",
87
+ "description": "Get comprehensive orchestration status",
88
+ "inputSchema": {
89
+ "type": "object",
90
+ "properties": {}
91
+ }
92
+ }
93
+ ]
94
+
95
+
96
+ def handle_tool_call(name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
97
+ """Execute orchestration tool and return result."""
98
+ try:
99
+ registry_path = arguments.get("registry_path") if name == "init" else None
100
+ orch = DockerOrchestrator(registry_path=registry_path)
101
+
102
+ if name == "init":
103
+ return orch.init()
104
+ elif name == "deploy":
105
+ return orch.deploy(arguments["namespace"])
106
+ elif name == "list":
107
+ return orch.list()
108
+ elif name == "health":
109
+ return orch.health(arguments["namespace"])
110
+ elif name == "logs":
111
+ tail = arguments.get("tail", 100)
112
+ return orch.logs(arguments["namespace"], tail=tail)
113
+ elif name == "stop":
114
+ force = arguments.get("force", False)
115
+ return orch.stop(arguments["namespace"], force=force)
116
+ elif name == "status":
117
+ return orch.status()
118
+ else:
119
+ raise ValueError(f"Unknown tool: {name}")
120
+ except Exception as e:
121
+ logger.error(f"Tool execution error: {e}", exc_info=True)
122
+ raise
123
+
124
+
125
+ def send_response(request_id: Any, result: Any = None, error: Any = None):
126
+ """Send JSON-RPC response to stdout."""
127
+ response = {
128
+ "jsonrpc": "2.0",
129
+ "id": request_id
130
+ }
131
+
132
+ if error:
133
+ response["error"] = {
134
+ "code": -32603,
135
+ "message": str(error)
136
+ }
137
+ else:
138
+ response["result"] = result
139
+
140
+ print(json.dumps(response), flush=True)
141
+
142
+
143
+ def main():
144
+ """stdio MCP server main loop."""
145
+ logger.info("Starting orchestration MCP server (stdio mode)")
146
+
147
+ try:
148
+ for line in sys.stdin:
149
+ line = line.strip()
150
+ if not line:
151
+ continue
152
+
153
+ try:
154
+ request = json.loads(line)
155
+ request_id = request.get("id")
156
+ method = request.get("method")
157
+ params = request.get("params", {})
158
+
159
+ logger.info(f"Received request: method={method}, id={request_id}")
160
+
161
+ if method == "tools/list":
162
+ tools = get_tool_definitions()
163
+ send_response(request_id, {"tools": tools})
164
+
165
+ elif method == "tools/call":
166
+ tool_name = params.get("name")
167
+ arguments = params.get("arguments", {})
168
+
169
+ logger.info(f"Executing tool: {tool_name} with args: {arguments}")
170
+ result = handle_tool_call(tool_name, arguments)
171
+ send_response(request_id, result)
172
+
173
+ else:
174
+ send_response(request_id, error=f"Unknown method: {method}")
175
+
176
+ except json.JSONDecodeError as e:
177
+ logger.error(f"JSON decode error: {e}")
178
+ send_response(None, error=f"Invalid JSON: {e}")
179
+
180
+ except Exception as e:
181
+ logger.error(f"Request handling error: {e}", exc_info=True)
182
+ send_response(request_id if 'request_id' in locals() else None, error=str(e))
183
+
184
+ except KeyboardInterrupt:
185
+ logger.info("Shutting down stdio MCP server")
186
+ except Exception as e:
187
+ logger.error(f"Fatal error: {e}", exc_info=True)
188
+ sys.exit(1)
189
+
190
+
191
+ if __name__ == "__main__":
192
+ main()
@@ -0,0 +1,405 @@
1
+ """Docker orchestrator for managing MCP server containers."""
2
+
3
+ import time
4
+ from typing import Dict, Any, List, Optional
5
+ from dataclasses import dataclass
6
+ import yaml
7
+ import docker
8
+ import httpx
9
+
10
+
11
+ @dataclass
12
+ class ServerDefinition:
13
+ """Definition of an MCP server from registry."""
14
+ namespace: str
15
+ name: str
16
+ docker_image: str
17
+ port: int
18
+ health_url: str
19
+ tools: List[Dict[str, str]] = None
20
+ volumes: Dict[str, Dict[str, str]] = None # {host_path: {'bind': container_path, 'mode': 'rw'}}
21
+
22
+
23
+ class DockerOrchestrator:
24
+ """Manages MCP server containers via Docker SDK."""
25
+
26
+ def __init__(self, registry_path: Optional[str] = None):
27
+ """
28
+ Initialize orchestrator.
29
+
30
+ Args:
31
+ registry_path: Path to registry.yaml file (absolute or relative to cwd)
32
+ """
33
+ import os
34
+ self.client = docker.from_env()
35
+
36
+ # Resolve registry path
37
+ if registry_path:
38
+ # Use provided path (make absolute if relative)
39
+ self.registry_path = os.path.abspath(registry_path) if not os.path.isabs(registry_path) else registry_path
40
+ else:
41
+ # Try to find default registry in workspace
42
+ cwd = os.getcwd()
43
+ default_path = os.path.join(cwd, "chora-mcp-gateway", "config", "registry.yaml")
44
+ self.registry_path = default_path
45
+
46
+ self.registry = self._load_registry()
47
+
48
+ def _load_registry(self) -> Dict[str, Any]:
49
+ """Load registry from YAML file."""
50
+ import os
51
+ try:
52
+ if not os.path.exists(self.registry_path):
53
+ # Return empty registry if file doesn't exist
54
+ return {"version": "1.0", "servers": []}
55
+
56
+ with open(self.registry_path, 'r') as f:
57
+ registry = yaml.safe_load(f)
58
+
59
+ # Validate basic registry structure
60
+ if not isinstance(registry, dict):
61
+ raise ValueError(f"Invalid registry format in {self.registry_path}: expected dict, got {type(registry)}")
62
+
63
+ if "servers" not in registry:
64
+ registry["servers"] = []
65
+
66
+ return registry
67
+
68
+ except FileNotFoundError:
69
+ return {"version": "1.0", "servers": []}
70
+ except yaml.YAMLError as e:
71
+ raise ValueError(f"Failed to parse registry YAML at {self.registry_path}: {e}")
72
+
73
+ def _get_server_def(self, namespace: str) -> ServerDefinition:
74
+ """Get server definition by namespace."""
75
+ for server in self.registry.get("servers", []):
76
+ if server["namespace"] == namespace:
77
+ return ServerDefinition(
78
+ namespace=server["namespace"],
79
+ name=server["name"],
80
+ docker_image=server["docker_image"],
81
+ port=server["port"],
82
+ health_url=server.get("health_url", ""),
83
+ tools=server.get("tools", []),
84
+ volumes=server.get("volumes")
85
+ )
86
+ raise ValueError(f"Namespace '{namespace}' not found in registry")
87
+
88
+ def _ensure_network(self) -> None:
89
+ """Ensure mcp-network exists."""
90
+ try:
91
+ self.client.networks.get("mcp-network")
92
+ except docker.errors.NotFound:
93
+ self.client.networks.create("mcp-network", driver="bridge")
94
+
95
+ def _wait_for_health(self, container_name: str, health_url: str, timeout: int = 30) -> bool:
96
+ """Wait for container to become healthy."""
97
+ start_time = time.time()
98
+ while time.time() - start_time < timeout:
99
+ try:
100
+ # Check Docker health status
101
+ container = self.client.containers.get(container_name)
102
+ health_status = container.attrs.get("State", {}).get("Health", {}).get("Status")
103
+
104
+ if health_status == "healthy":
105
+ return True
106
+
107
+ # Also try HTTP health check
108
+ if health_url:
109
+ try:
110
+ response = httpx.get(health_url.replace(container_name, "localhost"), timeout=2.0)
111
+ if response.status_code == 200:
112
+ return True
113
+ except:
114
+ pass
115
+
116
+ except Exception:
117
+ pass
118
+
119
+ time.sleep(2)
120
+
121
+ return False
122
+
123
+ def _get_container_by_namespace(self, namespace: str) -> Any:
124
+ """Find container by namespace."""
125
+ container_name = f"chora-mcp-{namespace}"
126
+ try:
127
+ return self.client.containers.get(container_name)
128
+ except docker.errors.NotFound:
129
+ raise ValueError(f"Server not found: {namespace}")
130
+
131
+ def init(self) -> Dict[str, Any]:
132
+ """
133
+ Initialize MCP ecosystem with gateway and manifest.
134
+
135
+ Returns:
136
+ Status dictionary with initialized services
137
+ """
138
+ self._ensure_network()
139
+
140
+ services = []
141
+
142
+ # Deploy gateway and manifest
143
+ for namespace in ["gateway", "manifest"]:
144
+ try:
145
+ result = self.deploy(namespace)
146
+ services.append({
147
+ "name": namespace,
148
+ "status": "healthy" if result.get("health_status") == "healthy" else "unhealthy",
149
+ "port": result.get("port")
150
+ })
151
+ except Exception as e:
152
+ services.append({
153
+ "name": namespace,
154
+ "status": "failed",
155
+ "error": str(e)
156
+ })
157
+
158
+ return {
159
+ "status": "initialized",
160
+ "services": services,
161
+ "network": "mcp-network"
162
+ }
163
+
164
+ def deploy(self, namespace: str) -> Dict[str, Any]:
165
+ """
166
+ Deploy an MCP server container.
167
+
168
+ Args:
169
+ namespace: Server namespace from registry
170
+
171
+ Returns:
172
+ Deployment status dictionary
173
+ """
174
+ server_def = self._get_server_def(namespace)
175
+ self._ensure_network()
176
+
177
+ # Check if container already exists
178
+ container_name = f"chora-mcp-{namespace}"
179
+ try:
180
+ existing = self.client.containers.get(container_name)
181
+ existing.stop()
182
+ existing.remove()
183
+ except docker.errors.NotFound:
184
+ pass
185
+
186
+ # Prepare volumes
187
+ volumes = server_def.volumes or {}
188
+
189
+ # Special handling: auto-mount config for gateway if not specified
190
+ if namespace == "gateway" and not any("/app/config" in str(v) for v in volumes.values()):
191
+ # Try to find config directory in workspace
192
+ import os
193
+ workspace_root = os.getcwd()
194
+ config_path = os.path.join(workspace_root, "chora-mcp-gateway", "config")
195
+ if os.path.exists(config_path):
196
+ volumes[config_path] = {'bind': '/app/config', 'mode': 'rw'}
197
+
198
+ # Run container
199
+ container = self.client.containers.run(
200
+ server_def.docker_image,
201
+ name=container_name,
202
+ ports={f"{server_def.port}/tcp": server_def.port},
203
+ network="mcp-network",
204
+ volumes=volumes if volumes else None,
205
+ detach=True,
206
+ remove=False
207
+ )
208
+
209
+ # Wait for health check
210
+ health_status = "healthy" if self._wait_for_health(container_name, server_def.health_url) else "unhealthy"
211
+
212
+ return {
213
+ "status": "deployed",
214
+ "namespace": namespace,
215
+ "container_id": container.id,
216
+ "port": server_def.port,
217
+ "health_status": health_status
218
+ }
219
+
220
+ def list(self) -> Dict[str, Any]:
221
+ """
222
+ List all running MCP servers.
223
+
224
+ Returns:
225
+ Dictionary with list of servers
226
+ """
227
+ containers = self.client.containers.list(filters={"network": "mcp-network"})
228
+
229
+ servers = []
230
+ for container in containers:
231
+ # Extract namespace from container name
232
+ name = container.name
233
+ namespace = name.replace("chora-mcp-", "") if name.startswith("chora-mcp-") else name
234
+
235
+ # Get port mapping
236
+ ports = container.attrs.get("NetworkSettings", {}).get("Ports", {})
237
+ port = None
238
+ for port_spec, bindings in ports.items():
239
+ if bindings:
240
+ port = int(bindings[0]["HostPort"])
241
+ break
242
+
243
+ # Get health status
244
+ health = container.attrs.get("State", {}).get("Health", {}).get("Status", "unknown")
245
+
246
+ # Calculate uptime
247
+ started_at = container.attrs.get("State", {}).get("StartedAt", "")
248
+ uptime = "unknown" # Simplified for now
249
+
250
+ servers.append({
251
+ "namespace": namespace,
252
+ "container_id": container.id[:12],
253
+ "status": container.status,
254
+ "health": health,
255
+ "port": port,
256
+ "uptime": uptime
257
+ })
258
+
259
+ return {"servers": servers}
260
+
261
+ def health(self, namespace: str) -> Dict[str, Any]:
262
+ """
263
+ Get health status for a server.
264
+
265
+ Args:
266
+ namespace: Server namespace
267
+
268
+ Returns:
269
+ Health status dictionary
270
+ """
271
+ container = self._get_container_by_namespace(namespace)
272
+ server_def = self._get_server_def(namespace)
273
+
274
+ # Get Docker status
275
+ docker_status = container.status
276
+
277
+ # Query health endpoint
278
+ health_response = None
279
+ tools_count = 0
280
+
281
+ try:
282
+ health_url = server_def.health_url.replace(f"chora-mcp-{namespace}", "localhost")
283
+ response = httpx.get(health_url, timeout=5.0)
284
+ if response.status_code == 200:
285
+ health_response = response.json()
286
+
287
+ # Also get tools count
288
+ tools_url = health_url.replace("/health", "/tools")
289
+ tools_response = httpx.get(tools_url, timeout=5.0)
290
+ if tools_response.status_code == 200:
291
+ tools_count = len(tools_response.json().get("tools", []))
292
+ except Exception:
293
+ health_response = {"error": "Health check failed"}
294
+
295
+ return {
296
+ "namespace": namespace,
297
+ "container_id": container.id[:12],
298
+ "docker_status": docker_status,
299
+ "health_endpoint": server_def.health_url.replace(f"chora-mcp-{namespace}", "localhost"),
300
+ "health_response": health_response,
301
+ "tools_count": tools_count,
302
+ "uptime": "unknown" # Simplified
303
+ }
304
+
305
+ def logs(self, namespace: str, tail: int = 100) -> Dict[str, Any]:
306
+ """
307
+ Get logs from a server container.
308
+
309
+ Args:
310
+ namespace: Server namespace
311
+ tail: Number of lines to return
312
+
313
+ Returns:
314
+ Dictionary with logs
315
+ """
316
+ container = self._get_container_by_namespace(namespace)
317
+
318
+ logs_bytes = container.logs(tail=tail)
319
+ logs_str = logs_bytes.decode('utf-8')
320
+ log_lines = [line for line in logs_str.split('\n') if line.strip()]
321
+
322
+ return {
323
+ "namespace": namespace,
324
+ "logs": log_lines,
325
+ "lines": len(log_lines)
326
+ }
327
+
328
+ def stop(self, namespace: str, force: bool = False) -> Dict[str, Any]:
329
+ """
330
+ Stop a server container.
331
+
332
+ Args:
333
+ namespace: Server namespace
334
+ force: If True, kill immediately; otherwise graceful shutdown
335
+
336
+ Returns:
337
+ Stop status dictionary
338
+ """
339
+ container = self._get_container_by_namespace(namespace)
340
+
341
+ if force:
342
+ container.kill()
343
+ graceful = False
344
+ else:
345
+ container.stop(timeout=10)
346
+ graceful = True
347
+
348
+ return {
349
+ "status": "stopped",
350
+ "namespace": namespace,
351
+ "graceful": graceful
352
+ }
353
+
354
+ def status(self) -> Dict[str, Any]:
355
+ """
356
+ Get comprehensive orchestration status.
357
+
358
+ Returns:
359
+ Status dictionary with all information
360
+ """
361
+ # Check Docker daemon
362
+ try:
363
+ self.client.ping()
364
+ docker_status = "connected"
365
+ except Exception:
366
+ docker_status = "disconnected"
367
+ return {
368
+ "docker_status": docker_status,
369
+ "error": "Docker daemon not accessible"
370
+ }
371
+
372
+ # Get running servers
373
+ list_result = self.list()
374
+ servers = list_result["servers"]
375
+ servers_running = len(servers)
376
+ servers_healthy = sum(1 for s in servers if s["health"] == "healthy")
377
+
378
+ # Check gateway
379
+ gateway_status = {"status": "unknown"}
380
+ gateway_tools_count = 0
381
+
382
+ try:
383
+ response = httpx.get("http://localhost:8080/health", timeout=5.0)
384
+ if response.status_code == 200:
385
+ gateway_status = response.json()
386
+
387
+ # Get tools count
388
+ tools_response = httpx.get("http://localhost:8080/tools", timeout=5.0)
389
+ if tools_response.status_code == 200:
390
+ gateway_tools_count = len(tools_response.json().get("tools", []))
391
+ except Exception:
392
+ gateway_status = {"status": "unreachable"}
393
+
394
+ return {
395
+ "docker_status": docker_status,
396
+ "network": "mcp-network",
397
+ "gateway": {
398
+ "status": gateway_status.get("status"),
399
+ "url": "http://localhost:8080",
400
+ "tools_count": gateway_tools_count
401
+ },
402
+ "servers_running": servers_running,
403
+ "servers_healthy": servers_healthy,
404
+ "servers": servers
405
+ }
@@ -0,0 +1,152 @@
1
+ """MCP HTTP server for orchestration tools."""
2
+
3
+ from fastapi import FastAPI, HTTPException
4
+ from pydantic import BaseModel
5
+ from typing import Optional
6
+ from .orchestrator import DockerOrchestrator
7
+
8
+ app = FastAPI(title="Chora MCP Orchestration", version="0.1.0")
9
+
10
+
11
+ # Pydantic models for request/response
12
+ class InitRequest(BaseModel):
13
+ registry_path: Optional[str] = None
14
+
15
+
16
+ class DeployRequest(BaseModel):
17
+ namespace: str
18
+
19
+
20
+ class HealthRequest(BaseModel):
21
+ namespace: str
22
+
23
+
24
+ class LogsRequest(BaseModel):
25
+ namespace: str
26
+ tail: Optional[int] = 100
27
+
28
+
29
+ class StopRequest(BaseModel):
30
+ namespace: str
31
+ force: Optional[bool] = False
32
+
33
+
34
+ class StatusRequest(BaseModel):
35
+ pass
36
+
37
+
38
+ @app.get("/health")
39
+ def health_check():
40
+ """Health check endpoint."""
41
+ return {"status": "healthy"}
42
+
43
+
44
+ @app.get("/tools")
45
+ def list_tools():
46
+ """List available MCP tools."""
47
+ return {
48
+ "tools": [
49
+ {"name": "init", "description": "Initialize MCP ecosystem"},
50
+ {"name": "deploy", "description": "Deploy MCP server"},
51
+ {"name": "list", "description": "List running servers"},
52
+ {"name": "health", "description": "Get server health"},
53
+ {"name": "logs", "description": "Get server logs"},
54
+ {"name": "stop", "description": "Stop a server"},
55
+ {"name": "status", "description": "Get orchestration status"}
56
+ ]
57
+ }
58
+
59
+
60
+ @app.post("/tools/init")
61
+ def init_tool(request: InitRequest):
62
+ """Initialize MCP ecosystem."""
63
+ try:
64
+ orch = DockerOrchestrator(registry_path=request.registry_path)
65
+ result = orch.init()
66
+ return result
67
+ except Exception as e:
68
+ raise HTTPException(status_code=500, detail=str(e))
69
+
70
+
71
+ @app.post("/tools/deploy")
72
+ def deploy_tool(request: DeployRequest):
73
+ """Deploy MCP server."""
74
+ try:
75
+ orch = DockerOrchestrator()
76
+ result = orch.deploy(request.namespace)
77
+ return result
78
+ except ValueError as e:
79
+ raise HTTPException(status_code=404, detail=str(e))
80
+ except Exception as e:
81
+ raise HTTPException(status_code=500, detail=str(e))
82
+
83
+
84
+ @app.post("/tools/list")
85
+ def list_tool(request: dict):
86
+ """List running servers."""
87
+ try:
88
+ orch = DockerOrchestrator()
89
+ result = orch.list()
90
+ return result
91
+ except Exception as e:
92
+ raise HTTPException(status_code=500, detail=str(e))
93
+
94
+
95
+ @app.post("/tools/health")
96
+ def health_tool(request: HealthRequest):
97
+ """Get server health."""
98
+ try:
99
+ orch = DockerOrchestrator()
100
+ result = orch.health(request.namespace)
101
+ return result
102
+ except ValueError as e:
103
+ raise HTTPException(status_code=404, detail=str(e))
104
+ except Exception as e:
105
+ raise HTTPException(status_code=500, detail=str(e))
106
+
107
+
108
+ @app.post("/tools/logs")
109
+ def logs_tool(request: LogsRequest):
110
+ """Get server logs."""
111
+ try:
112
+ orch = DockerOrchestrator()
113
+ result = orch.logs(request.namespace, tail=request.tail)
114
+ return result
115
+ except ValueError as e:
116
+ raise HTTPException(status_code=404, detail=str(e))
117
+ except Exception as e:
118
+ raise HTTPException(status_code=500, detail=str(e))
119
+
120
+
121
+ @app.post("/tools/stop")
122
+ def stop_tool(request: StopRequest):
123
+ """Stop a server."""
124
+ try:
125
+ orch = DockerOrchestrator()
126
+ result = orch.stop(request.namespace, force=request.force)
127
+ return result
128
+ except ValueError as e:
129
+ raise HTTPException(status_code=404, detail=str(e))
130
+ except Exception as e:
131
+ raise HTTPException(status_code=500, detail=str(e))
132
+
133
+
134
+ @app.post("/tools/status")
135
+ def status_tool(request: dict):
136
+ """Get orchestration status."""
137
+ try:
138
+ orch = DockerOrchestrator()
139
+ result = orch.status()
140
+ return result
141
+ except Exception as e:
142
+ raise HTTPException(status_code=500, detail=str(e))
143
+
144
+
145
+ def main():
146
+ """Entry point for MCP HTTP server."""
147
+ import uvicorn
148
+ uvicorn.run(app, host="0.0.0.0", port=8090)
149
+
150
+
151
+ if __name__ == "__main__":
152
+ main()