chora-mcp-orchestration 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_orchestration/__init__.py +7 -0
- chora_mcp_orchestration/cli.py +152 -0
- chora_mcp_orchestration/mcp_stdio.py +192 -0
- chora_mcp_orchestration/orchestrator.py +405 -0
- chora_mcp_orchestration/server.py +152 -0
- chora_mcp_orchestration-0.1.0.dist-info/METADATA +111 -0
- chora_mcp_orchestration-0.1.0.dist-info/RECORD +9 -0
- chora_mcp_orchestration-0.1.0.dist-info/WHEEL +4 -0
- chora_mcp_orchestration-0.1.0.dist-info/entry_points.txt +4 -0
|
@@ -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()
|
|
@@ -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,9 @@
|
|
|
1
|
+
chora_mcp_orchestration/__init__.py,sha256=GPTMJU_bt3qcTvBoDVSZZ_0uBr_DrVnOpJVvHs4QtnI,209
|
|
2
|
+
chora_mcp_orchestration/cli.py,sha256=g6CeHGAqZOp63CQhHznpOJzMyMBY4xaHAtjCat8allU,4508
|
|
3
|
+
chora_mcp_orchestration/mcp_stdio.py,sha256=vuwuuvcCowDnPgwx8yGPSE-m7yWzLgk5hJyArZMy65w,6435
|
|
4
|
+
chora_mcp_orchestration/orchestrator.py,sha256=HfWmzZFDoH5l5Goy9ZylYs-XF1Z_6lHXZxo0e8RHCs0,13607
|
|
5
|
+
chora_mcp_orchestration/server.py,sha256=UP4_ZQA_TJxLLaqwFnzORmEepIMdbe0AdIxJh7CL030,4005
|
|
6
|
+
chora_mcp_orchestration-0.1.0.dist-info/entry_points.txt,sha256=a1KdW4XHLYe4ZEsGqcp3VcxUsxFegO1QCMJWhKfRlqE,121
|
|
7
|
+
chora_mcp_orchestration-0.1.0.dist-info/METADATA,sha256=apZ9--I_FiK4bRhmgMncTd7qK9qS4moJO9jD2G4Ng7A,2716
|
|
8
|
+
chora_mcp_orchestration-0.1.0.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
9
|
+
chora_mcp_orchestration-0.1.0.dist-info/RECORD,,
|