gnosys-strata 1.1.4__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.
strata/config.py ADDED
@@ -0,0 +1,310 @@
1
+ import json
2
+ import os
3
+ from dataclasses import dataclass, field, asdict
4
+ from pathlib import Path
5
+ from typing import Dict, List, Optional, Callable, Any
6
+
7
+ from platformdirs import user_config_dir
8
+ from watchfiles import awatch
9
+
10
+
11
+ @dataclass
12
+ class MCPServerConfig:
13
+ """Configuration for a single MCP server."""
14
+
15
+ name: str
16
+ type: str = "stdio" # "stdio", "sse", "http"
17
+ command: str = ""
18
+ args: List[str] = field(default_factory=list)
19
+ env: Dict[str, str] = field(default_factory=dict)
20
+ url: Optional[str] = None
21
+ headers: Dict[str, str] = field(default_factory=dict)
22
+ auth: Optional[str] = None # Auth token/key if needed
23
+ enabled: bool = True
24
+
25
+ def to_dict(self) -> Dict[str, Any]:
26
+ """Convert to dictionary for storage."""
27
+ return asdict(self)
28
+
29
+ @classmethod
30
+ def from_dict(cls, data: Dict[str, Any]) -> "MCPServerConfig":
31
+ """Create from dictionary."""
32
+ # Handle legacy format where type might be missing
33
+ type_val = data.get("type", "stdio")
34
+ if "url" in data and not type_val:
35
+ type_val = "sse" # Guess sse if url present
36
+
37
+ return cls(
38
+ name=data["name"],
39
+ type=type_val,
40
+ command=data.get("command", ""),
41
+ args=data.get("args", []),
42
+ env=data.get("env", {}),
43
+ url=data.get("url"),
44
+ headers=data.get("headers", {}),
45
+ auth=data.get("auth"),
46
+ enabled=data.get("enabled", True),
47
+ )
48
+
49
+
50
+ class MCPServerList:
51
+ """Manage a list of MCP server configurations and sets."""
52
+
53
+ def __init__(self, config_path: Optional[Path] = None, use_mcp_format: bool = True):
54
+ """Initialize the MCP server list.
55
+
56
+ Args:
57
+ config_path: Path to the configuration file. If None, uses default.
58
+ use_mcp_format: If True, save in MCP format. If False, use legacy format.
59
+ """
60
+ if config_path is None:
61
+ # Use platformdirs for cross-platform config directory
62
+ config_dir = Path(user_config_dir("strata"))
63
+ config_dir.mkdir(parents=True, exist_ok=True)
64
+ self.config_path = config_dir / "servers.json"
65
+ else:
66
+ self.config_path = Path(config_path)
67
+
68
+ self.servers: Dict[str, MCPServerConfig] = {}
69
+ self.sets: Dict[str, List[str]] = {}
70
+ self.use_mcp_format = use_mcp_format
71
+ self.load()
72
+
73
+ def load(self) -> None:
74
+ """Load server configurations and sets from file."""
75
+ if self.config_path.exists():
76
+ try:
77
+ with open(self.config_path, "r", encoding="utf-8") as f:
78
+ data = json.load(f)
79
+
80
+ # Check if it's MCP format (has "mcp" key with "servers" inside)
81
+ if "mcp" in data and "servers" in data["mcp"]:
82
+ # Parse MCP format
83
+ for name, config in data["mcp"]["servers"].items():
84
+ # MCP format doesn't have "name" field, add it
85
+ config_dict = {
86
+ "name": name,
87
+ "type": config.get("type", "stdio"),
88
+ "env": config.get("env", {}),
89
+ "enabled": config.get("enabled", True),
90
+ }
91
+
92
+ # Add type-specific fields
93
+ if config.get("type") in ["sse", "http"]:
94
+ config_dict["url"] = config.get("url")
95
+ config_dict["headers"] = config.get("headers", {})
96
+ config_dict["auth"] = config.get("auth", "")
97
+ else: # stdio/command
98
+ config_dict["command"] = config.get("command", "")
99
+ config_dict["args"] = config.get("args", [])
100
+
101
+ self.servers[name] = MCPServerConfig.from_dict(config_dict)
102
+
103
+ # Parse sets if available
104
+ self.sets = data["mcp"].get("sets", {})
105
+
106
+ # Otherwise check for legacy format
107
+ elif "servers" in data:
108
+ # Parse legacy format
109
+ for name, config in data["servers"].items():
110
+ self.servers[name] = MCPServerConfig.from_dict(config)
111
+ # Legacy format doesn't support sets usually, or we could add it top-level
112
+ self.sets = data.get("sets", {})
113
+ except Exception as e:
114
+ print(f"Error loading config from {self.config_path}: {e}")
115
+
116
+ def save(self) -> None:
117
+ """Save server configurations and sets to file."""
118
+ if self.use_mcp_format:
119
+ # Save in MCP format
120
+ servers_dict = {}
121
+ for name, server in self.servers.items():
122
+ server_config = {}
123
+
124
+ # Add type field if not stdio (default)
125
+ if server.type and server.type != "stdio":
126
+ server_config["type"] = server.type
127
+
128
+ # Add type-specific fields
129
+ if server.type in ["sse", "http"]:
130
+ server_config["url"] = server.url
131
+ if server.headers:
132
+ server_config["headers"] = server.headers
133
+ if server.auth:
134
+ server_config["auth"] = server.auth
135
+ else: # stdio/command
136
+ server_config["command"] = server.command
137
+ server_config["args"] = server.args
138
+
139
+ if server.env:
140
+ server_config["env"] = server.env
141
+ # Always save enabled field to be explicit
142
+ server_config["enabled"] = server.enabled
143
+ servers_dict[name] = server_config
144
+
145
+ data = {
146
+ "mcp": {
147
+ "servers": servers_dict,
148
+ "sets": self.sets
149
+ }
150
+ }
151
+ else:
152
+ # Save in legacy format
153
+ data = {
154
+ "servers": {
155
+ name: server.to_dict() for name, server in self.servers.items()
156
+ },
157
+ "sets": self.sets
158
+ }
159
+
160
+ self.config_path.parent.mkdir(parents=True, exist_ok=True)
161
+ with open(self.config_path, "w", encoding="utf-8") as f:
162
+ json.dump(data, f, indent=2)
163
+
164
+ def add_server(self, server: MCPServerConfig) -> bool:
165
+ """Add or update a server configuration."""
166
+ if server.name in self.servers and self.servers[server.name] == server:
167
+ return False
168
+ self.servers[server.name] = server
169
+ self.save()
170
+ return True
171
+
172
+ def remove_server(self, name: str) -> bool:
173
+ """Remove a server configuration."""
174
+ if name in self.servers:
175
+ del self.servers[name]
176
+ # Also remove from any sets
177
+ for set_name in list(self.sets.keys()):
178
+ s = self.sets[set_name]
179
+ if isinstance(s, list) and name in s:
180
+ s.remove(name)
181
+ elif isinstance(s, dict) and name in s.get("servers", []):
182
+ s["servers"].remove(name)
183
+ self.save()
184
+ return True
185
+ return False
186
+
187
+ def get_server(self, name: str) -> Optional[MCPServerConfig]:
188
+ """Get a server configuration by name."""
189
+ return self.servers.get(name)
190
+
191
+ def list_servers(self, enabled_only: bool = False) -> List[MCPServerConfig]:
192
+ """List all server configurations."""
193
+ servers = list(self.servers.values())
194
+ if enabled_only:
195
+ servers = [s for s in servers if s.enabled]
196
+ return servers
197
+
198
+ def enable_server(self, name: str) -> bool:
199
+ """Enable a server."""
200
+ if name in self.servers:
201
+ self.servers[name].enabled = True
202
+ self.save()
203
+ return True
204
+ return False
205
+
206
+ def disable_server(self, name: str) -> bool:
207
+ """Disable a server."""
208
+ if name in self.servers:
209
+ self.servers[name].enabled = False
210
+ self.save()
211
+ return True
212
+ return False
213
+
214
+ # --- Sets Management ---
215
+
216
+ def add_set(self, name: str, server_names: List[str], description: str = "", include_sets: Optional[List[str]] = None) -> None:
217
+ """Add or update a set of servers.
218
+
219
+ Args:
220
+ name: Name of the set
221
+ server_names: List of server names in the set
222
+ description: Description of the set's purpose
223
+ include_sets: List of other set names to include (composability)
224
+ """
225
+ self.sets[name] = {
226
+ "description": description,
227
+ "servers": server_names
228
+ }
229
+ if include_sets:
230
+ self.sets[name]["include_sets"] = include_sets
231
+ self.save()
232
+
233
+ def remove_set(self, name: str) -> bool:
234
+ """Remove a set."""
235
+ if name in self.sets:
236
+ del self.sets[name]
237
+ self.save()
238
+ return True
239
+ return False
240
+
241
+ def get_set(self, name: str, _visited: Optional[set] = None) -> Optional[List[str]]:
242
+ """Get servers in a set, resolving included sets recursively."""
243
+ if _visited is None:
244
+ _visited = set()
245
+ if name in _visited:
246
+ return [] # Prevent infinite loops
247
+ _visited.add(name)
248
+
249
+ s = self.sets.get(name)
250
+ if s is None:
251
+ return None
252
+ if isinstance(s, list):
253
+ return s
254
+
255
+ servers = list(s.get("servers", []))
256
+ for included in s.get("include_sets", []):
257
+ included_servers = self.get_set(included, _visited)
258
+ if included_servers:
259
+ for srv in included_servers:
260
+ if srv not in servers:
261
+ servers.append(srv)
262
+ return servers
263
+
264
+ def get_set_details(self, name: str) -> Optional[Dict[str, Any]]:
265
+ """Get full set details including description."""
266
+ details = self.sets.get(name)
267
+ if isinstance(details, list):
268
+ return {"description": "", "servers": details}
269
+ return details
270
+
271
+ def list_sets(self) -> Dict[str, Dict[str, Any]]:
272
+ """List all sets with details.
273
+
274
+ Returns:
275
+ Dictionary mapping set name to set details (description, servers)
276
+ """
277
+ # Normalize output for consistency
278
+ result = {}
279
+ for name, data in self.sets.items():
280
+ if isinstance(data, list):
281
+ result[name] = {"description": "", "servers": data}
282
+ else:
283
+ result[name] = data
284
+ return result
285
+
286
+ # -----------------------
287
+
288
+ async def watch_config(
289
+ self,
290
+ on_changed: Callable[[Dict[str, MCPServerConfig]], None],
291
+ ):
292
+ """Watch configuration file for changes and trigger callback."""
293
+ async for changes in awatch(str(self.config_path.parent)):
294
+ config_changed = False
295
+ for _, path in changes:
296
+ if Path(path) == self.config_path:
297
+ config_changed = True
298
+ break
299
+
300
+ if not config_changed:
301
+ continue
302
+
303
+ self.servers.clear()
304
+ self.sets.clear()
305
+ self.load()
306
+ on_changed(dict(self.servers))
307
+
308
+
309
+ # Global instance for easy access
310
+ mcp_server_list = MCPServerList()
@@ -0,0 +1,109 @@
1
+ """Logging configuration for Strata MCP Router."""
2
+
3
+ import logging
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+
7
+ from platformdirs import user_cache_dir
8
+
9
+
10
+ class BannerFormatter(logging.Formatter):
11
+ """Custom formatter for banner messages that shows only the message."""
12
+
13
+ def format(self, record):
14
+ return record.getMessage()
15
+
16
+
17
+ def log_banner() -> None:
18
+ """Log the Klavis AI colorful banner using a temporary handler."""
19
+ # Create a temporary logger with custom formatter
20
+ banner_logger = logging.getLogger("banner")
21
+ banner_logger.setLevel(logging.INFO)
22
+
23
+ # Create a handler with no formatting for clean output
24
+ console_handler = logging.StreamHandler()
25
+ console_handler.setLevel(logging.INFO)
26
+ console_handler.setFormatter(BannerFormatter())
27
+
28
+ banner_logger.addHandler(console_handler)
29
+ banner_logger.propagate = False # Don't propagate to root logger
30
+
31
+ try:
32
+ banner_text = """
33
+ \033[1;31m██╗ ██╗\033[1;33m██╗ \033[1;32m█████╗ \033[1;36m██╗ ██╗\033[1;34m██╗\033[1;35m███████╗ \033[1;91m█████╗ \033[1;93m██╗\033[0m
34
+ \033[1;31m██║ ██╔╝\033[1;33m██║ \033[1;32m██╔══██╗\033[1;36m██║ ██║\033[1;34m██║\033[1;35m██╔════╝ \033[1;91m██╔══██╗\033[1;93m██║\033[0m
35
+ \033[1;31m█████╔╝ \033[1;33m██║ \033[1;32m███████║\033[1;36m██║ ██║\033[1;34m██║\033[1;35m███████╗ \033[1;91m███████║\033[1;93m██║\033[0m
36
+ \033[1;31m██╔═██╗ \033[1;33m██║ \033[1;32m██╔══██║\033[1;36m╚██╗ ██╔╝\033[1;34m██║\033[1;35m╚════██║ \033[1;91m██╔══██║\033[1;93m██║\033[0m
37
+ \033[1;31m██║ ██╗\033[1;33m███████╗\033[1;32m██║ ██║ \033[1;36m╚████╔╝ \033[1;34m██║\033[1;35m███████║ \033[1;91m██║ ██║\033[1;93m██║\033[0m
38
+ \033[1;31m╚═╝ ╚═╝\033[1;33m╚══════╝\033[1;32m╚═╝ ╚═╝ \033[1;36m╚═══╝ \033[1;34m╚═╝\033[1;35m╚══════╝ \033[1;91m╚═╝ ╚═╝\033[1;93m╚═╝\033[0m
39
+
40
+ \033[1;32mEmpowering AI with Seamless Integration\033[0m
41
+
42
+ \033[1;36m═════════════════════════════════════════════════════════════════════════════\033[0m
43
+ \033[1;32m STRATA MCP \033[0m·\033[1;33m One MCP server that use tools reliably at any scale \033[0m
44
+ \033[1;36m═════════════════════════════════════════════════════════════════════════════\033[0m
45
+ \033[1;33m→ Starting MCP Server...\033[0m
46
+ """
47
+ banner_logger.info(banner_text)
48
+ finally:
49
+ # Clean up the handler
50
+ banner_logger.removeHandler(console_handler)
51
+
52
+
53
+ def setup_logging(log_level: str = "INFO", no_banner: bool = False) -> None:
54
+ """Configure logging to output to both console and file.
55
+
56
+ Args:
57
+ log_level: The logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
58
+ no_banner: Skip displaying the banner on startup
59
+ """
60
+ # Create cache directory for logs
61
+ cache_dir = Path(user_cache_dir("strata"))
62
+ cache_dir.mkdir(parents=True, exist_ok=True)
63
+
64
+ # Generate log file name with timestamp
65
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
66
+ log_file = cache_dir / f"{timestamp}.log"
67
+
68
+ # Configure root logger
69
+ root_logger = logging.getLogger()
70
+ root_logger.setLevel(getattr(logging, log_level.upper()))
71
+
72
+ # Clear any existing handlers
73
+ root_logger.handlers.clear()
74
+
75
+ # Create formatter
76
+ formatter = logging.Formatter(
77
+ "%(asctime)s - %(name)s - %(levelname)s - %(message)s",
78
+ datefmt="%Y-%m-%d %H:%M:%S",
79
+ )
80
+
81
+ # Console handler
82
+ console_handler = logging.StreamHandler()
83
+ console_handler.setLevel(getattr(logging, log_level.upper()))
84
+ console_handler.setFormatter(formatter)
85
+ root_logger.addHandler(console_handler)
86
+
87
+ # Log the setup to console only (before adding file handler)
88
+ logger = logging.getLogger(__name__)
89
+ if not no_banner:
90
+ log_banner()
91
+ # Log initialization message without logging prefix using clean formatter
92
+ logger.info(f"Logging initialized - Console: {log_level}, File: {log_file}")
93
+
94
+ logger.handlers.clear()
95
+
96
+ # File handler (added after the initialization message)
97
+ file_handler = logging.FileHandler(log_file, encoding="utf-8", delay=True)
98
+ file_handler.setLevel(logging.DEBUG) # Always log DEBUG and above to file
99
+ file_handler.setFormatter(formatter)
100
+ root_logger.addHandler(file_handler)
101
+
102
+
103
+ def get_log_dir() -> Path:
104
+ """Get the directory where log files are stored.
105
+
106
+ Returns:
107
+ Path to the log directory
108
+ """
109
+ return Path(user_cache_dir("strata"))
strata/main.py ADDED
@@ -0,0 +1,6 @@
1
+ """Main entry point for Strata MCP Router."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()